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
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:
3
aw_client/__init__.py
Normal file
3
aw_client/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .client import ActivityWatchClient
|
||||
|
||||
__all__ = ["ActivityWatchClient"]
|
||||
3
aw_client/__main__.py
Normal file
3
aw_client/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from aw_client import cli
|
||||
|
||||
cli.main()
|
||||
89
aw_client/classes.py
Normal file
89
aw_client/classes.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Default classes
|
||||
|
||||
Taken from default classes in aw-webui
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
from typing import (
|
||||
Any,
|
||||
Dict,
|
||||
List,
|
||||
Tuple,
|
||||
)
|
||||
|
||||
import aw_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CategoryId = List[str]
|
||||
CategorySpec = Dict[str, Any]
|
||||
|
||||
default_classes: List[Tuple[CategoryId, CategorySpec]] = [
|
||||
(["Work"], {"type": "regex", "regex": "Google Docs|libreoffice|ReText"}),
|
||||
(
|
||||
["Work", "Programming"],
|
||||
{
|
||||
"type": "regex",
|
||||
"regex": "GitHub|Stack Overflow|BitBucket|Gitlab|vim|Spyder|kate|Ghidra|Scite",
|
||||
},
|
||||
),
|
||||
(
|
||||
["Work", "Programming", "ActivityWatch"],
|
||||
{"type": "regex", "regex": "ActivityWatch|aw-", "ignore_case": True},
|
||||
),
|
||||
(["Work", "Image"], {"type": "regex", "regex": "Gimp|Inkscape"}),
|
||||
(["Work", "Video"], {"type": "regex", "regex": "Kdenlive"}),
|
||||
(["Work", "Audio"], {"type": "regex", "regex": "Audacity"}),
|
||||
(["Work", "3D"], {"type": "regex", "regex": "Blender"}),
|
||||
(["Media", "Games"], {"type": "regex", "regex": "Minecraft|RimWorld"}),
|
||||
(["Media", "Video"], {"type": "regex", "regex": "YouTube|Plex|VLC"}),
|
||||
(
|
||||
["Media", "Social Media"],
|
||||
{
|
||||
"type": "regex",
|
||||
"regex": "reddit|Facebook|Twitter|Instagram|devRant",
|
||||
"ignore_case": True,
|
||||
},
|
||||
),
|
||||
(
|
||||
["Media", "Music"],
|
||||
{"type": "regex", "regex": "Spotify|Deezer", "ignore_case": True},
|
||||
),
|
||||
(
|
||||
["Comms", "IM"],
|
||||
{
|
||||
"type": "regex",
|
||||
"regex": "Messenger|Telegram|Signal|WhatsApp|Rambox|Slack|Riot|Discord|Nheko",
|
||||
},
|
||||
),
|
||||
(
|
||||
["Comms", "Email"],
|
||||
{"type": "regex", "regex": "Gmail|Thunderbird|mutt|alpine"},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def get_classes() -> List[Tuple[List[str], dict]]:
|
||||
"""
|
||||
Get classes from server-side settings.
|
||||
Might throw a 404 if not set yet, in which case we use the default classes as a fallback.
|
||||
"""
|
||||
# NOTE: Always tries to fetch from prod server,
|
||||
# which is potentially wrong if testing server is being used.
|
||||
awc = aw_client.ActivityWatchClient(f"get-setting-{random.randint(0, 10000)}")
|
||||
try:
|
||||
classes = awc.get_setting("classes")
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to get classes from server, using default classes as fallback"
|
||||
)
|
||||
return default_classes
|
||||
if not classes:
|
||||
logger.warning(
|
||||
"Classes setting is empty/unset, using default classes as fallback"
|
||||
)
|
||||
return default_classes
|
||||
# map into list of tuples
|
||||
return [(v["name"], v["rule"]) for v in classes]
|
||||
332
aw_client/cli.py
Executable file
332
aw_client/cli.py
Executable file
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import logging
|
||||
import textwrap
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import click
|
||||
from aw_core import Event
|
||||
from tabulate import tabulate
|
||||
|
||||
import aw_client
|
||||
|
||||
from . import queries
|
||||
from .classes import default_classes, get_classes
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
td1day = timedelta(days=1)
|
||||
td1yr = timedelta(days=365)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _Context:
|
||||
client: aw_client.ActivityWatchClient
|
||||
|
||||
|
||||
@click.group(
|
||||
help="CLI utility for aw-client to aid in interacting with the ActivityWatch server"
|
||||
)
|
||||
@click.option(
|
||||
"--host",
|
||||
default="127.0.0.1",
|
||||
help="Address of host",
|
||||
)
|
||||
@click.option(
|
||||
"--port",
|
||||
default=None,
|
||||
type=int,
|
||||
help="Port to use (default: profile config, 5600 / 5666)",
|
||||
)
|
||||
@click.option(
|
||||
"-v",
|
||||
"--verbose",
|
||||
is_flag=True,
|
||||
help="Verbosity",
|
||||
)
|
||||
@click.option("--testing", is_flag=True, help="Set to use testing ports by default")
|
||||
@click.option(
|
||||
"--profile",
|
||||
default=None,
|
||||
help="Named instance profile. --testing is an alias for --profile testing.",
|
||||
)
|
||||
@click.pass_context
|
||||
def main(
|
||||
ctx, testing: bool, verbose: bool, host: str, port: int, profile: Optional[str]
|
||||
):
|
||||
ctx.obj = _Context()
|
||||
# default=None so `--port 5600` is a real override, not discarded as
|
||||
# "the Click default". None lets ActivityWatchClient read the profile
|
||||
# config (5600 / 5666 / baked research port).
|
||||
ctx.obj.client = aw_client.ActivityWatchClient(
|
||||
host=host,
|
||||
port=port,
|
||||
testing=testing,
|
||||
profile=profile,
|
||||
)
|
||||
logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO)
|
||||
|
||||
|
||||
@main.command(help="Send a heartbeat to bucket with ID `bucket_id` with JSON `data`")
|
||||
@click.argument("bucket_id")
|
||||
@click.argument("data")
|
||||
@click.option("--pulsetime", default=60, help="pulsetime to use for merging heartbeats")
|
||||
@click.pass_obj
|
||||
def heartbeat(obj: _Context, bucket_id: str, data: str, pulsetime: int):
|
||||
now = datetime.now(timezone.utc)
|
||||
e = Event(duration=0, data=json.loads(data), timestamp=now)
|
||||
print(e)
|
||||
obj.client.heartbeat(bucket_id, e, pulsetime)
|
||||
|
||||
|
||||
@main.command(help="List all buckets")
|
||||
@click.pass_obj
|
||||
def buckets(obj: _Context):
|
||||
buckets = obj.client.get_buckets()
|
||||
print("Buckets:")
|
||||
for bucket in buckets:
|
||||
print(f" - {bucket}")
|
||||
|
||||
|
||||
@main.command(help="Query events from bucket with ID `bucket_id`")
|
||||
@click.argument("bucket_id")
|
||||
@click.pass_obj
|
||||
def events(obj: _Context, bucket_id: str):
|
||||
events = obj.client.get_events(bucket_id)
|
||||
print("events:")
|
||||
for e in events:
|
||||
print(
|
||||
" - {} ({}) {}".format(
|
||||
e.timestamp.replace(tzinfo=None, microsecond=0),
|
||||
str(e.duration).split(".")[0],
|
||||
e.data,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@main.command(help="Run a query in file at `path` on the server")
|
||||
@click.argument("path")
|
||||
@click.option("--name")
|
||||
@click.option("--cache", is_flag=True)
|
||||
@click.option("--json", "_json", is_flag=True)
|
||||
@click.option("--start", default=now - td1day, type=click.DateTime())
|
||||
@click.option("--stop", default=now + td1yr, type=click.DateTime())
|
||||
@click.option(
|
||||
"--timezone",
|
||||
help="Time zone for start and stop options."
|
||||
" Must be a valid IANA identifier like e.g. 'Europe/Warsaw'.",
|
||||
)
|
||||
@click.pass_obj
|
||||
def query(
|
||||
obj: _Context,
|
||||
path: str,
|
||||
cache: bool,
|
||||
_json: bool,
|
||||
start: datetime,
|
||||
stop: datetime,
|
||||
timezone: str,
|
||||
name: Optional[str] = None,
|
||||
):
|
||||
with open(path) as f:
|
||||
query = f.read()
|
||||
|
||||
if timezone:
|
||||
zone_info = ZoneInfo(timezone)
|
||||
start = start.replace(tzinfo=zone_info)
|
||||
stop = stop.replace(tzinfo=zone_info)
|
||||
|
||||
result = obj.client.query(query, [(start, stop)], cache=cache, name=name)
|
||||
if _json:
|
||||
print(json.dumps(result))
|
||||
else:
|
||||
for period in result:
|
||||
print(f"Showing 10 out of {len(period)} events:")
|
||||
for event in period[:10]:
|
||||
event.pop("id")
|
||||
event.pop("timestamp")
|
||||
print(
|
||||
" - Duration: {} \tData: {}".format(
|
||||
str(timedelta(seconds=event["duration"])).split(".")[0],
|
||||
event["data"],
|
||||
)
|
||||
)
|
||||
print(
|
||||
"Total duration:\t",
|
||||
timedelta(seconds=sum(e["duration"] for e in period)),
|
||||
)
|
||||
|
||||
|
||||
@main.command(help="Generate an activity report")
|
||||
@click.argument("hostname")
|
||||
@click.option("--cache", is_flag=True)
|
||||
@click.option("--start", default=now - td1day, type=click.DateTime())
|
||||
@click.option("--stop", default=now + td1yr, type=click.DateTime())
|
||||
@click.option("--limit", default=10)
|
||||
@click.pass_obj
|
||||
def report(
|
||||
obj: _Context,
|
||||
hostname: str,
|
||||
cache: bool,
|
||||
start: datetime,
|
||||
stop: datetime,
|
||||
name: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
):
|
||||
logger.info(f"Querying between {start} and {stop}")
|
||||
bid_window = f"aw-watcher-window_{hostname}"
|
||||
bid_afk = f"aw-watcher-afk_{hostname}"
|
||||
|
||||
if not start.tzinfo:
|
||||
start = start.astimezone()
|
||||
if not stop.tzinfo:
|
||||
stop = stop.astimezone()
|
||||
|
||||
bid_browsers: List[str] = []
|
||||
|
||||
classes = get_classes()
|
||||
params = queries.DesktopQueryParams(
|
||||
bid_browsers=bid_browsers,
|
||||
classes=classes,
|
||||
filter_classes=[],
|
||||
filter_afk=True,
|
||||
include_audible=True,
|
||||
bid_window=bid_window,
|
||||
bid_afk=bid_afk,
|
||||
)
|
||||
query = queries.fullDesktopQuery(params)
|
||||
logger.debug("Query: \n" + queries.pretty_query(query))
|
||||
|
||||
result = obj.client.query(query, [(start, stop)], cache=cache, name=name)
|
||||
|
||||
# TODO: Print titles, apps, categories, with most time
|
||||
for period in result:
|
||||
print()
|
||||
# print(period["window"]["cat_events"])
|
||||
|
||||
cat_events = _parse_events(period["window"]["cat_events"])
|
||||
print_top(
|
||||
cat_events,
|
||||
lambda e: " > ".join(e.data["$category"]),
|
||||
title="Categories",
|
||||
n=limit,
|
||||
)
|
||||
|
||||
title_events = _parse_events(period["window"]["title_events"])
|
||||
print_top(title_events, lambda e: e.data["title"], title="Titles", n=limit)
|
||||
|
||||
active_events = _parse_events(period["window"]["title_events"])
|
||||
print(
|
||||
"Total duration:\t",
|
||||
sum((e.duration for e in active_events), timedelta()),
|
||||
)
|
||||
|
||||
|
||||
def _parse_events(events: List[dict]) -> List[Event]:
|
||||
return [Event(**event) for event in events]
|
||||
|
||||
|
||||
def print_top(events: List[Event], key=lambda e: e.data, title="Events", n=10):
|
||||
print(f"Top {n} {title}" + (f" (out of {len(events)})" if len(events) > 10 else ""))
|
||||
print(
|
||||
tabulate(
|
||||
[
|
||||
(event.duration, key(event))
|
||||
for event in sorted(events, key=lambda e: e.duration, reverse=True)[:10]
|
||||
],
|
||||
headers=["Duration", "Key"],
|
||||
)
|
||||
)
|
||||
print()
|
||||
|
||||
|
||||
@main.command(help="Query 'canonical events' for a single host (filtered, classified)")
|
||||
@click.argument("hostname")
|
||||
@click.option("--cache", is_flag=True)
|
||||
@click.option("--start", default=now - td1day, type=click.DateTime())
|
||||
@click.option("--stop", default=now + td1yr, type=click.DateTime())
|
||||
@click.option(
|
||||
"--format",
|
||||
type=click.Choice(["table", "json"]),
|
||||
default="table",
|
||||
help="Output format (table or json)",
|
||||
)
|
||||
@click.pass_obj
|
||||
def canonical(
|
||||
obj: _Context,
|
||||
hostname: str,
|
||||
cache: bool,
|
||||
start: datetime,
|
||||
stop: datetime,
|
||||
format: str,
|
||||
name: Optional[str] = None,
|
||||
):
|
||||
logger.info(f"Querying between {start} and {stop}")
|
||||
bid_window = f"aw-watcher-window_{hostname}"
|
||||
bid_afk = f"aw-watcher-afk_{hostname}"
|
||||
|
||||
if not start.tzinfo:
|
||||
start = start.astimezone()
|
||||
if not stop.tzinfo:
|
||||
stop = stop.astimezone()
|
||||
|
||||
classes = default_classes
|
||||
|
||||
query = queries.canonicalEvents(
|
||||
queries.DesktopQueryParams(
|
||||
bid_window=bid_window,
|
||||
bid_afk=bid_afk,
|
||||
classes=classes,
|
||||
)
|
||||
)
|
||||
query = f"""{query}\n RETURN = events;"""
|
||||
logger.debug("Query: \n" + queries.pretty_query(query))
|
||||
|
||||
result = obj.client.query(query, [(start, stop)], cache=cache, name=name)
|
||||
|
||||
# TODO: Print titles, apps, categories, with most time
|
||||
for period in result:
|
||||
events = _parse_events(period)
|
||||
|
||||
if format == "json":
|
||||
# Output as JSON array with ISO-8601 timestamps
|
||||
json_events = [
|
||||
{
|
||||
"timestamp": e.timestamp.isoformat(),
|
||||
"duration": e.duration.total_seconds(),
|
||||
"data": e.data,
|
||||
}
|
||||
for e in events
|
||||
]
|
||||
print(json.dumps(json_events))
|
||||
else:
|
||||
# Table format (original behavior)
|
||||
print()
|
||||
print(f"Showing last 10 out of {len(events)} events:")
|
||||
|
||||
print(
|
||||
tabulate(
|
||||
[
|
||||
(
|
||||
str(e.timestamp).split(".")[0],
|
||||
str(e.duration).split(".")[0],
|
||||
f'[{e.data["app"]}] {textwrap.shorten(e.data["title"], 60, placeholder="...")}',
|
||||
)
|
||||
for e in events[-10:]
|
||||
],
|
||||
headers=["Timestamp", "Duration", "Data"],
|
||||
)
|
||||
)
|
||||
|
||||
print()
|
||||
print(
|
||||
"Total duration:\t",
|
||||
timedelta(seconds=sum(e["duration"] for e in period)),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
636
aw_client/client.py
Normal file
636
aw_client/client.py
Normal file
@@ -0,0 +1,636 @@
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import warnings
|
||||
from collections import namedtuple
|
||||
from datetime import datetime
|
||||
from time import sleep
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
|
||||
import persistqueue
|
||||
import requests as req
|
||||
from aw_core.dirs import get_data_dir
|
||||
from aw_core.models import Event
|
||||
from aw_transform.heartbeats import heartbeat_merge
|
||||
|
||||
from .config import load_config, load_local_server_api_key
|
||||
from .profile import (
|
||||
DEFAULT_PROFILE,
|
||||
export_profile,
|
||||
is_testing,
|
||||
profile_from_env,
|
||||
profile_suffix,
|
||||
resolve_profile,
|
||||
)
|
||||
from .singleinstance import SingleInstance
|
||||
|
||||
# FIXME: This line is probably badly placed
|
||||
logging.getLogger("requests").setLevel(logging.WARNING)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _log_request_exception(e: req.RequestException):
|
||||
logger.warning(str(e))
|
||||
try:
|
||||
d = e.response.json() if e.response else None
|
||||
logger.warning(f"Error message received: {d}")
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
|
||||
def _dt_is_tzaware(dt: datetime) -> bool:
|
||||
return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
|
||||
|
||||
|
||||
def always_raise_for_request_errors(f: Callable[..., req.Response]):
|
||||
@functools.wraps(f)
|
||||
def g(*args, **kwargs):
|
||||
r = f(*args, **kwargs)
|
||||
try:
|
||||
r.raise_for_status()
|
||||
except req.RequestException as e:
|
||||
_log_request_exception(e)
|
||||
raise e
|
||||
return r
|
||||
|
||||
return g
|
||||
|
||||
|
||||
class ActivityWatchClient:
|
||||
def __init__(
|
||||
self,
|
||||
client_name: str = "unknown",
|
||||
testing=False,
|
||||
host=None,
|
||||
port=None,
|
||||
protocol="http",
|
||||
profile: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
A handy wrapper around the aw-server REST API. The recommended way of interacting with the server.
|
||||
|
||||
Can be used with a `with`-statement as an alternative to manually calling connect and disconnect in a try-finally clause.
|
||||
|
||||
:Example:
|
||||
|
||||
.. literalinclude:: examples/client.py
|
||||
:lines: 7-
|
||||
|
||||
``profile`` selects an isolated instance (config, port, queue file).
|
||||
``testing=True`` is the compat shim for ``profile="testing"``. If
|
||||
neither is set, ``AW_PROFILE`` from the launcher is used.
|
||||
"""
|
||||
if profile is not None or testing:
|
||||
resolved = resolve_profile(profile, testing)
|
||||
else:
|
||||
resolved = profile_from_env(False)
|
||||
export_profile(resolved)
|
||||
self.profile = resolved
|
||||
self.testing = is_testing(resolved)
|
||||
|
||||
self.client_name = client_name
|
||||
self.client_hostname = socket.gethostname()
|
||||
|
||||
_config = load_config()
|
||||
server_key = "server" if resolved == DEFAULT_PROFILE else f"server-{resolved}"
|
||||
client_key = "client" if resolved == DEFAULT_PROFILE else f"client-{resolved}"
|
||||
if server_key not in _config:
|
||||
if resolved != DEFAULT_PROFILE:
|
||||
logger.warning(
|
||||
"Profile %s has no [%s] section, falling back to [server] "
|
||||
"(port %s may collide with the default instance)",
|
||||
resolved,
|
||||
server_key,
|
||||
5666 if is_testing(resolved) else 5600,
|
||||
)
|
||||
server_key = "server"
|
||||
if client_key not in _config:
|
||||
client_key = "client"
|
||||
server_config = _config[server_key]
|
||||
client_config = _config[client_key]
|
||||
|
||||
server_host = host or server_config["hostname"]
|
||||
server_port = port or server_config["port"]
|
||||
self.server_api_key = load_local_server_api_key(
|
||||
str(server_host), server_port, profile=resolved
|
||||
)
|
||||
self.server_address = f"{protocol}://{server_host}:{server_port}"
|
||||
|
||||
self.instance = SingleInstance(
|
||||
f"{self.client_name}-at-{server_host}-on-{server_port}"
|
||||
)
|
||||
|
||||
self.commit_interval = client_config["commit_interval"]
|
||||
|
||||
self.request_queue = RequestQueue(self)
|
||||
# Dict of each last heartbeat in each bucket
|
||||
self.last_heartbeat = {} # type: Dict[str, Event]
|
||||
self._warned_queue_before_connect = False
|
||||
|
||||
#
|
||||
# Get/Post base requests
|
||||
#
|
||||
|
||||
def _url(self, endpoint: str):
|
||||
return f"{self.server_address}/api/0/{endpoint}"
|
||||
|
||||
def _headers(self, headers: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
||||
request_headers = dict(headers or {})
|
||||
if self.server_api_key:
|
||||
request_headers.setdefault("Authorization", f"Bearer {self.server_api_key}")
|
||||
return request_headers
|
||||
|
||||
@always_raise_for_request_errors
|
||||
def _get(self, endpoint: str, params: Optional[dict] = None) -> req.Response:
|
||||
return req.get(self._url(endpoint), params=params, headers=self._headers())
|
||||
|
||||
@always_raise_for_request_errors
|
||||
def _post(
|
||||
self,
|
||||
endpoint: str,
|
||||
data: Union[List[Any], Dict[str, Any]],
|
||||
params: Optional[dict] = None,
|
||||
) -> req.Response:
|
||||
headers = self._headers(
|
||||
{"Content-type": "application/json", "charset": "utf-8"}
|
||||
)
|
||||
return req.post(
|
||||
self._url(endpoint),
|
||||
data=bytes(json.dumps(data), "utf8"),
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
@always_raise_for_request_errors
|
||||
def _delete(self, endpoint: str, data: Any = None) -> req.Response:
|
||||
if data is None:
|
||||
data = {}
|
||||
headers = self._headers({"Content-type": "application/json"})
|
||||
return req.delete(self._url(endpoint), data=json.dumps(data), headers=headers)
|
||||
|
||||
def get_info(self):
|
||||
"""Returns a dict currently containing the keys 'hostname' and 'testing'."""
|
||||
endpoint = "info"
|
||||
return self._get(endpoint).json()
|
||||
|
||||
#
|
||||
# Event get/post requests
|
||||
#
|
||||
|
||||
def get_event(
|
||||
self,
|
||||
bucket_id: str,
|
||||
event_id: int,
|
||||
) -> Optional[Event]:
|
||||
endpoint = f"buckets/{bucket_id}/events/{event_id}"
|
||||
try:
|
||||
event = self._get(endpoint).json()
|
||||
return Event(**event)
|
||||
except req.exceptions.HTTPError as e:
|
||||
if e.response and e.response.status_code == 404:
|
||||
return None
|
||||
else:
|
||||
raise
|
||||
|
||||
def get_events(
|
||||
self,
|
||||
bucket_id: str,
|
||||
limit: int = -1,
|
||||
start: Optional[datetime] = None,
|
||||
end: Optional[datetime] = None,
|
||||
) -> List[Event]:
|
||||
endpoint = f"buckets/{bucket_id}/events"
|
||||
|
||||
params = dict() # type: Dict[str, str]
|
||||
if limit is not None:
|
||||
params["limit"] = str(limit)
|
||||
if start is not None:
|
||||
params["start"] = start.isoformat()
|
||||
if end is not None:
|
||||
params["end"] = end.isoformat()
|
||||
|
||||
events = self._get(endpoint, params=params).json()
|
||||
return [Event(**event) for event in events]
|
||||
|
||||
def insert_event(self, bucket_id: str, event: Event) -> Optional[Event]:
|
||||
endpoint = f"buckets/{bucket_id}/events"
|
||||
data = [event.to_json_dict()]
|
||||
response = self._post(endpoint, data)
|
||||
if response.json():
|
||||
return Event(**response.json()[0])
|
||||
return None
|
||||
|
||||
def insert_events(
|
||||
self, bucket_id: str, events: List[Event]
|
||||
) -> Optional[List[Event]]:
|
||||
endpoint = f"buckets/{bucket_id}/events"
|
||||
data = [event.to_json_dict() for event in events]
|
||||
response = self._post(endpoint, data)
|
||||
if response.json():
|
||||
return [Event(**e) for e in response.json()]
|
||||
return None
|
||||
|
||||
def delete_event(self, bucket_id: str, event_id: int) -> None:
|
||||
endpoint = f"buckets/{bucket_id}/events/{event_id}"
|
||||
self._delete(endpoint)
|
||||
|
||||
def get_eventcount(
|
||||
self,
|
||||
bucket_id: str,
|
||||
limit: int = -1,
|
||||
start: Optional[datetime] = None,
|
||||
end: Optional[datetime] = None,
|
||||
) -> int:
|
||||
endpoint = f"buckets/{bucket_id}/events/count"
|
||||
|
||||
params = dict() # type: Dict[str, str]
|
||||
if start is not None:
|
||||
params["start"] = start.isoformat()
|
||||
if end is not None:
|
||||
params["end"] = end.isoformat()
|
||||
|
||||
response = self._get(endpoint, params=params)
|
||||
return int(response.text)
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
bucket_id: str,
|
||||
event: Event,
|
||||
pulsetime: float,
|
||||
queued: bool = False,
|
||||
commit_interval: Optional[float] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
bucket_id: The bucket_id of the bucket to send the heartbeat to
|
||||
event: The actual heartbeat event
|
||||
pulsetime: The maximum amount of time in seconds since the last heartbeat to be merged with the previous heartbeat in aw-server
|
||||
queued: Use the aw-client queue feature to queue events if client loses connection with the server
|
||||
commit_interval: Override default pre-merge commit interval
|
||||
|
||||
NOTE: This endpoint can use the failed requests retry queue.
|
||||
This makes the request itself non-blocking and therefore
|
||||
the function will in that case always returns None.
|
||||
"""
|
||||
|
||||
endpoint = f"buckets/{bucket_id}/heartbeat?pulsetime={pulsetime}"
|
||||
_commit_interval = commit_interval or self.commit_interval
|
||||
|
||||
if queued:
|
||||
self._warn_queue_before_connect()
|
||||
# Pre-merge heartbeats
|
||||
if bucket_id not in self.last_heartbeat:
|
||||
self.last_heartbeat[bucket_id] = event
|
||||
return None
|
||||
|
||||
last_heartbeat = self.last_heartbeat[bucket_id]
|
||||
|
||||
merge = heartbeat_merge(last_heartbeat, event, pulsetime)
|
||||
|
||||
if merge:
|
||||
# If last_heartbeat becomes longer than commit_interval
|
||||
# then commit, else cache merged.
|
||||
diff = (last_heartbeat.duration).total_seconds()
|
||||
if diff >= _commit_interval:
|
||||
data = merge.to_json_dict()
|
||||
self.request_queue.add_request(endpoint, data)
|
||||
self.last_heartbeat[bucket_id] = event
|
||||
else:
|
||||
self.last_heartbeat[bucket_id] = merge
|
||||
else:
|
||||
data = last_heartbeat.to_json_dict()
|
||||
self.request_queue.add_request(endpoint, data)
|
||||
self.last_heartbeat[bucket_id] = event
|
||||
else:
|
||||
self._post(endpoint, event.to_json_dict())
|
||||
|
||||
#
|
||||
# Bucket get/post requests
|
||||
#
|
||||
|
||||
def get_buckets(self) -> dict:
|
||||
return self._get("buckets/").json()
|
||||
|
||||
def create_bucket(self, bucket_id: str, event_type: str, queued=False):
|
||||
if queued:
|
||||
self._warn_queue_before_connect()
|
||||
self.request_queue.register_bucket(bucket_id, event_type)
|
||||
else:
|
||||
endpoint = f"buckets/{bucket_id}"
|
||||
data = {
|
||||
"client": self.client_name,
|
||||
"hostname": self.client_hostname,
|
||||
"type": event_type,
|
||||
}
|
||||
self._post(endpoint, data)
|
||||
|
||||
def delete_bucket(self, bucket_id: str, force: bool = False):
|
||||
self._delete(f"buckets/{bucket_id}" + ("?force=1" if force else ""))
|
||||
|
||||
# @deprecated
|
||||
def setup_bucket(self, bucket_id: str, event_type: str):
|
||||
self.create_bucket(bucket_id, event_type, queued=True)
|
||||
|
||||
# Import & export
|
||||
|
||||
def export_all(self) -> dict:
|
||||
return self._get("export").json()
|
||||
|
||||
def export_bucket(self, bucket_id) -> dict:
|
||||
return self._get(f"buckets/{bucket_id}/export").json()
|
||||
|
||||
def import_bucket(self, bucket: dict) -> None:
|
||||
endpoint = "import"
|
||||
self._post(endpoint, {"buckets": {bucket["id"]: bucket}})
|
||||
|
||||
#
|
||||
# Query (server-side transformation)
|
||||
#
|
||||
|
||||
def query(
|
||||
self,
|
||||
query: str,
|
||||
timeperiods: List[Tuple[datetime, datetime]],
|
||||
name: Optional[str] = None,
|
||||
cache: bool = False,
|
||||
) -> List[Any]:
|
||||
endpoint = "query/"
|
||||
params = {} # type: Dict[str, Any]
|
||||
if cache:
|
||||
if not name:
|
||||
raise Exception(
|
||||
"You are not allowed to do caching without a query name"
|
||||
)
|
||||
params["name"] = name
|
||||
params["cache"] = int(cache)
|
||||
|
||||
# Check that datetimes have timezone information
|
||||
for start, stop in timeperiods:
|
||||
try:
|
||||
assert _dt_is_tzaware(start)
|
||||
assert _dt_is_tzaware(stop)
|
||||
except AssertionError:
|
||||
raise ValueError("start/stop needs to have a timezone set") from None
|
||||
|
||||
data = {
|
||||
"timeperiods": [
|
||||
"/".join([start.isoformat(), end.isoformat()])
|
||||
for start, end in timeperiods
|
||||
],
|
||||
"query": query.split("\n"),
|
||||
}
|
||||
response = self._post(endpoint, data, params=params)
|
||||
return response.json()
|
||||
|
||||
#
|
||||
# Settings
|
||||
#
|
||||
|
||||
def get_setting(self, key: Optional[str] = None) -> dict:
|
||||
if key:
|
||||
return self._get(f"settings/{key}").json()
|
||||
else:
|
||||
return self._get("settings").json()
|
||||
|
||||
def set_setting(self, key: str, value: str) -> None:
|
||||
self._post(f"settings/{key}", value)
|
||||
|
||||
#
|
||||
# Connect and disconnect
|
||||
#
|
||||
|
||||
def __enter__(self):
|
||||
self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.disconnect()
|
||||
|
||||
def connect(self):
|
||||
if not self.request_queue.is_alive():
|
||||
self.request_queue.start()
|
||||
|
||||
def disconnect(self):
|
||||
self.request_queue.stop()
|
||||
self.request_queue.join()
|
||||
|
||||
# Throw away old thread object, create new one since same thread cannot be started twice
|
||||
self.request_queue = RequestQueue(
|
||||
self, persistqueue_path=self.request_queue.persistqueue_path
|
||||
)
|
||||
# Reset so warn-before-connect fires again if user calls queued ops before reconnecting
|
||||
self._warned_queue_before_connect = False
|
||||
|
||||
def wait_for_start(self, timeout: int = 10) -> None:
|
||||
"""Wait for the server to start by trying to get the server info."""
|
||||
start_time = datetime.now()
|
||||
sleep_time = 0.1
|
||||
while (datetime.now() - start_time).seconds < timeout:
|
||||
try:
|
||||
self.get_info()
|
||||
break
|
||||
except req.exceptions.ConnectionError:
|
||||
sleep(sleep_time)
|
||||
sleep_time *= 2
|
||||
else:
|
||||
raise Exception(f"Server at {self.server_address} did not start in time")
|
||||
|
||||
def _warn_queue_before_connect(self) -> None:
|
||||
if self._warned_queue_before_connect or self.request_queue.is_alive():
|
||||
return
|
||||
|
||||
warnings.warn(
|
||||
"Queued requests require calling connect() or using `with client:` "
|
||||
"before buckets can be created and queued events can flush.",
|
||||
UserWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
self._warned_queue_before_connect = True
|
||||
|
||||
|
||||
QueuedRequest = namedtuple("QueuedRequest", ["endpoint", "data"])
|
||||
Bucket = namedtuple("Bucket", ["id", "type"])
|
||||
|
||||
|
||||
class RequestQueue(threading.Thread):
|
||||
"""Used to asynchronously send heartbeats.
|
||||
|
||||
Handles:
|
||||
- Cases where the server is temporarily unavailable
|
||||
- Saves all queued requests to file in case of a server crash
|
||||
"""
|
||||
|
||||
VERSION = 1 # update this whenever the queue-file format changes
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: ActivityWatchClient,
|
||||
persistqueue_path: Optional[str] = None,
|
||||
) -> None:
|
||||
threading.Thread.__init__(self, daemon=True)
|
||||
|
||||
self.client = client
|
||||
|
||||
self.connected = False
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
# Buckets that will have events queued to them, will be created if they don't exist
|
||||
self._registered_buckets = [] # type: List[Bucket]
|
||||
|
||||
self._attempt_reconnect_interval = 10
|
||||
|
||||
if persistqueue_path is None:
|
||||
data_dir = get_data_dir("aw-client")
|
||||
queued_dir = os.path.join(data_dir, "queued")
|
||||
if not os.path.exists(queued_dir):
|
||||
os.makedirs(queued_dir)
|
||||
|
||||
profile = getattr(client, "profile", None)
|
||||
suffix = (
|
||||
profile_suffix(profile)
|
||||
if profile is not None
|
||||
else ("-testing" if client.testing else "")
|
||||
)
|
||||
persistqueue_path = os.path.join(
|
||||
queued_dir,
|
||||
f"{self.client.client_name}{suffix}.v{self.VERSION}.persistqueue",
|
||||
)
|
||||
|
||||
logger.debug(f"queue path '{persistqueue_path}'")
|
||||
self.persistqueue_path = persistqueue_path
|
||||
|
||||
self._persistqueue = persistqueue.FIFOSQLiteQueue(
|
||||
persistqueue_path, multithreading=True, auto_commit=False
|
||||
)
|
||||
self._current = None # type: Optional[QueuedRequest]
|
||||
|
||||
def _get_next(self) -> Optional[QueuedRequest]:
|
||||
# self._current will always hold the next not-yet-sent event,
|
||||
# until self._task_done() is called.
|
||||
if not self._current:
|
||||
try:
|
||||
self._current = self._persistqueue.get(block=False)
|
||||
except persistqueue.exceptions.Empty:
|
||||
return None
|
||||
return self._current
|
||||
|
||||
def _task_done(self) -> None:
|
||||
self._current = None
|
||||
self._persistqueue.task_done()
|
||||
|
||||
def _create_buckets(self) -> None:
|
||||
for bucket in self._registered_buckets:
|
||||
self.client.create_bucket(bucket.id, bucket.type)
|
||||
|
||||
def _try_connect(self) -> bool:
|
||||
try: # Try to connect
|
||||
self._create_buckets()
|
||||
self.connected = True
|
||||
logger.info(
|
||||
f"Connection to aw-server established by {self.client.client_name}"
|
||||
)
|
||||
except req.RequestException:
|
||||
self.connected = False
|
||||
|
||||
return self.connected
|
||||
|
||||
def wait(self, seconds) -> bool:
|
||||
return self._stop_event.wait(seconds)
|
||||
|
||||
def should_stop(self) -> bool:
|
||||
return self._stop_event.is_set()
|
||||
|
||||
def _dispatch_request(self) -> None:
|
||||
request = self._get_next()
|
||||
if not request:
|
||||
self.wait(0.2) # seconds to wait before re-polling the empty queue
|
||||
return
|
||||
|
||||
try:
|
||||
self.client._post(request.endpoint, request.data)
|
||||
except req.exceptions.ConnectTimeout:
|
||||
# Triggered by:
|
||||
# - server not running (connection refused)
|
||||
# - server not responding (timeout)
|
||||
# Safe to retry according to requests docs:
|
||||
# https://requests.readthedocs.io/en/latest/api/#requests.ConnectTimeout
|
||||
|
||||
self.connected = False
|
||||
logger.warning(
|
||||
"Connection refused or timeout, will queue requests until connection is available."
|
||||
)
|
||||
# wait a bit before retrying, so we don't spam the server (or logs), see:
|
||||
# - https://github.com/ActivityWatch/activitywatch/issues/815
|
||||
# - https://github.com/ActivityWatch/activitywatch/issues/756#issuecomment-1266662861
|
||||
sleep(0.5)
|
||||
return
|
||||
except req.RequestException as e:
|
||||
if e.response and e.response.status_code == 400:
|
||||
# HTTP 400 - Bad request
|
||||
# Example case: https://github.com/ActivityWatch/activitywatch/issues/815
|
||||
# We don't want to retry, because a bad payload is likely to fail forever.
|
||||
logger.error(f"Bad request, not retrying: {request.data}")
|
||||
elif e.response and e.response.status_code == 500:
|
||||
# HTTP 500 - Internal server error
|
||||
# It is possible that the server is in a bad state (and will recover on restart),
|
||||
# in which case we want to retry. I hope this can never caused by a bad payload.
|
||||
logger.error(f"Internal server error, retrying: {request.data}")
|
||||
sleep(0.5)
|
||||
return
|
||||
else:
|
||||
logger.exception(f"Unknown error, not retrying: {request.data}")
|
||||
except Exception:
|
||||
logger.exception(f"Unknown error, not retrying: {request.data}")
|
||||
|
||||
# Mark the request as done
|
||||
self._task_done()
|
||||
|
||||
def run(self) -> None:
|
||||
self._stop_event.clear()
|
||||
while not self.should_stop():
|
||||
# Connect
|
||||
while not self._try_connect():
|
||||
logger.warning(
|
||||
f"Not connected to server, {self._persistqueue.qsize()} requests in queue"
|
||||
)
|
||||
if self.wait(self._attempt_reconnect_interval):
|
||||
break
|
||||
|
||||
# Dispatch requests until connection is lost or thread should stop
|
||||
while self.connected and not self.should_stop():
|
||||
self._dispatch_request()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
|
||||
def add_request(self, endpoint: str, data: dict) -> None:
|
||||
"""
|
||||
Add a request to the queue.
|
||||
NOTE: Only supports heartbeats
|
||||
"""
|
||||
assert "/heartbeat" in endpoint
|
||||
assert isinstance(data, dict)
|
||||
self._persistqueue.put(QueuedRequest(endpoint, data))
|
||||
|
||||
def register_bucket(self, bucket_id: str, event_type: str) -> None:
|
||||
bucket = Bucket(bucket_id, event_type)
|
||||
self._registered_buckets.append(bucket)
|
||||
|
||||
if not self.connected:
|
||||
return
|
||||
|
||||
try:
|
||||
self.client.create_bucket(bucket_id, event_type)
|
||||
except req.RequestException:
|
||||
self.connected = False
|
||||
206
aw_client/config.py
Normal file
206
aw_client/config.py
Normal file
@@ -0,0 +1,206 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
import platformdirs
|
||||
import tomlkit
|
||||
from aw_core.config import load_config_toml
|
||||
|
||||
from .profile import DEFAULT_PROFILE, TESTING_PROFILE, profile_from_env
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_APPNAME = "activitywatch"
|
||||
_TESTING_APPNAME = "activitywatch-testing"
|
||||
|
||||
# Identical to aw-core#152 / aw-server-rust#652 so python and rust agree on
|
||||
# the same on-disk state (ActivityWatch/activitywatch#1399). Keep this list
|
||||
# specific: a false positive would pin a fresh install to legacy forever.
|
||||
_LEGACY_TESTING_FILENAME_MARKERS = (
|
||||
"peewee-sqlite-testing",
|
||||
"sqlite-testing",
|
||||
"settings-testing",
|
||||
"config-testing",
|
||||
"-testing.db",
|
||||
"-testing.toml",
|
||||
"-testing.json",
|
||||
"_testing_",
|
||||
)
|
||||
|
||||
default_config = """
|
||||
[server]
|
||||
hostname = "127.0.0.1"
|
||||
port = "5600"
|
||||
|
||||
[client]
|
||||
commit_interval = 10
|
||||
|
||||
[server-testing]
|
||||
hostname = "127.0.0.1"
|
||||
port = "5666"
|
||||
|
||||
[client-testing]
|
||||
commit_interval = 5
|
||||
""".strip()
|
||||
|
||||
|
||||
def load_config():
|
||||
return load_config_toml("aw-client", default_config)
|
||||
|
||||
|
||||
def _user_data_dir(appname: str) -> str:
|
||||
return platformdirs.user_data_dir(appname)
|
||||
|
||||
|
||||
def _user_config_dir(appname: str) -> str:
|
||||
return platformdirs.user_config_dir(appname)
|
||||
|
||||
|
||||
def _user_cache_dir(appname: str) -> str:
|
||||
return platformdirs.user_cache_dir(appname)
|
||||
|
||||
|
||||
def _is_legacy_testing_filename(name: str) -> bool:
|
||||
lower = name.lower()
|
||||
return any(marker in lower for marker in _LEGACY_TESTING_FILENAME_MARKERS)
|
||||
|
||||
|
||||
def _new_testing_root_exists() -> bool:
|
||||
"""True if any platform dir for ``activitywatch-testing`` already exists.
|
||||
|
||||
Must not create directories: existence is the signal that a previous run
|
||||
already adopted the isolated testing root.
|
||||
"""
|
||||
for getter in (_user_data_dir, _user_config_dir, _user_cache_dir):
|
||||
if os.path.isdir(getter(_TESTING_APPNAME)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _legacy_testing_artifacts_exist() -> bool:
|
||||
"""True if testing data still lives under the shared ``activitywatch`` root."""
|
||||
roots = (
|
||||
_user_data_dir(_DEFAULT_APPNAME),
|
||||
_user_config_dir(_DEFAULT_APPNAME),
|
||||
_user_cache_dir(_DEFAULT_APPNAME),
|
||||
)
|
||||
for root in roots:
|
||||
if not os.path.isdir(root):
|
||||
continue
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
for filename in filenames:
|
||||
if _is_legacy_testing_filename(filename):
|
||||
return True
|
||||
# Descend one level (activitywatch/aw-server-rust/...) not further.
|
||||
if os.path.relpath(dirpath, root) != ".":
|
||||
dirnames.clear()
|
||||
return False
|
||||
|
||||
|
||||
def using_legacy_testing_root() -> bool:
|
||||
"""Whether ``profile=testing`` should stay on the shared ``activitywatch`` root.
|
||||
|
||||
Resolution rule (ActivityWatch/activitywatch#1399), identical on python
|
||||
and rust:
|
||||
|
||||
1. If ``activitywatch-testing/`` already exists: use it (new layout).
|
||||
2. Else if legacy testing artifacts exist in the bare ``activitywatch/``
|
||||
root: stay in legacy mode (old paths, old filenames).
|
||||
3. Else (fresh setup): create and use ``activitywatch-testing/``.
|
||||
"""
|
||||
if _new_testing_root_exists():
|
||||
return False
|
||||
return _legacy_testing_artifacts_exist()
|
||||
|
||||
|
||||
def rust_server_config_candidates(profile: str) -> List[Tuple[str, int]]:
|
||||
"""Ordered rust config files to read for this profile.
|
||||
|
||||
Isolated profile roots (including new-style ``activitywatch-testing/``)
|
||||
use bare ``config.toml`` — the directory already isolates. Suffixed
|
||||
``config-testing.toml`` remains only in the legacy shared-root layout.
|
||||
|
||||
Lookup does **not** create directories (``get_config_dir`` would, and
|
||||
creating ``activitywatch-testing/`` would flip the fallback). Testing
|
||||
tries the other layout second so a python client that already created
|
||||
the new root still finds a rust server that wrote the key on the
|
||||
shared root — the regression Erik named on #1399.
|
||||
"""
|
||||
testing_new = (
|
||||
os.path.join(
|
||||
_user_config_dir(_TESTING_APPNAME), "aw-server-rust", "config.toml"
|
||||
),
|
||||
5666,
|
||||
)
|
||||
testing_legacy = (
|
||||
os.path.join(
|
||||
_user_config_dir(_DEFAULT_APPNAME),
|
||||
"aw-server-rust",
|
||||
"config-testing.toml",
|
||||
),
|
||||
5666,
|
||||
)
|
||||
if profile == TESTING_PROFILE:
|
||||
if using_legacy_testing_root():
|
||||
return [testing_legacy, testing_new]
|
||||
return [testing_new, testing_legacy]
|
||||
if profile == DEFAULT_PROFILE or not profile:
|
||||
return [
|
||||
(
|
||||
os.path.join(
|
||||
_user_config_dir(_DEFAULT_APPNAME),
|
||||
"aw-server-rust",
|
||||
"config.toml",
|
||||
),
|
||||
5600,
|
||||
)
|
||||
]
|
||||
return [
|
||||
(
|
||||
os.path.join(
|
||||
_user_config_dir(f"{_DEFAULT_APPNAME}-{profile}"),
|
||||
"aw-server-rust",
|
||||
"config.toml",
|
||||
),
|
||||
5600,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def load_local_server_api_key(
|
||||
host: str,
|
||||
port: Union[int, str],
|
||||
profile: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
if host not in {"127.0.0.1", "localhost", "::1"}:
|
||||
return None
|
||||
|
||||
try:
|
||||
requested_port = int(str(port))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if profile is None:
|
||||
profile = profile_from_env(False)
|
||||
|
||||
for config_path, default_port in rust_server_config_candidates(profile):
|
||||
if not os.path.isfile(config_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
config = tomlkit.parse(f.read())
|
||||
configured_port = int(str(config.get("port", default_port)))
|
||||
if configured_port != requested_port:
|
||||
continue
|
||||
|
||||
auth_config = config.get("auth", {})
|
||||
api_key = auth_config.get("api_key")
|
||||
if api_key:
|
||||
return str(api_key)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to read aw-server-rust config %s: %s", config_path, e
|
||||
)
|
||||
|
||||
return None
|
||||
111
aw_client/profile.py
Normal file
111
aw_client/profile.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Profile resolution for aw-client.
|
||||
|
||||
A *profile* names an isolated ActivityWatch instance (data, config, port,
|
||||
queue file). `default` is the ordinary install, `testing` is what
|
||||
`--testing` has always meant, and any other name (for example `research`)
|
||||
is a sibling instance that can run at the same time as the others.
|
||||
|
||||
The carrier is the ``AW_PROFILE`` environment variable. aw-core's
|
||||
``_get_appname()`` suffixes the platformdirs root when it is set to a
|
||||
non-empty value, so exporting the profile here isolates dirs for this
|
||||
process — without threading a flag through the request queue or REST
|
||||
client internals.
|
||||
|
||||
Kept in sync with aw-qt's ``aw_qt/profile.py`` and aw-server's
|
||||
``aw_server/profile.py`` (same validation rule as aw-server-rust). One
|
||||
intentional difference: the default profile *unsets* ``AW_PROFILE``
|
||||
instead of setting it to ``"default"``. aw-core treats any non-empty
|
||||
value as a suffix, so ``AW_PROFILE=default`` would resolve to
|
||||
``activitywatch-default`` and orphan an existing install.
|
||||
|
||||
Testing-root note (ActivityWatch/activitywatch#1399): python and rust
|
||||
resolve ``testing`` identically — new-root-plus-legacy-fallback:
|
||||
|
||||
1. If ``activitywatch-testing/`` exists: use it.
|
||||
2. Else if legacy testing artifacts exist in the bare ``activitywatch/``
|
||||
root: stay in legacy mode.
|
||||
3. Else (fresh setup): create and use ``activitywatch-testing/``.
|
||||
|
||||
Isolated roots use bare filenames (``config.toml``). Suffixed names
|
||||
(``config-testing.toml``) stay legacy-only. The rust API-key lookup in
|
||||
``config.py`` follows this rule so a ``--testing`` client finds the key
|
||||
where aw-server-rust actually wrote it.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
DEFAULT_PROFILE = "default"
|
||||
TESTING_PROFILE = "testing"
|
||||
|
||||
#: Same rule as aw-server-rust's `validate_profile`: lowercase alphanumeric
|
||||
#: plus `-`/`_`, at most 32 chars, so a profile is always a safe path segment.
|
||||
PROFILE_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$")
|
||||
|
||||
ENV_VAR = "AW_PROFILE"
|
||||
|
||||
|
||||
def validate_profile(profile: str) -> str:
|
||||
"""Return the profile unchanged, or raise ValueError if it is not usable."""
|
||||
if not PROFILE_RE.match(profile):
|
||||
raise ValueError(
|
||||
f"Invalid profile name {profile!r}: expected lowercase alphanumeric "
|
||||
"with '-' or '_', at most 32 characters"
|
||||
)
|
||||
return profile
|
||||
|
||||
|
||||
def resolve_profile(profile: Optional[str], testing: bool) -> str:
|
||||
"""Resolve the effective profile from the CLI flags.
|
||||
|
||||
``--testing`` is an alias for ``--profile testing``; passing both is only
|
||||
an error if they disagree.
|
||||
"""
|
||||
if profile is None:
|
||||
return TESTING_PROFILE if testing else DEFAULT_PROFILE
|
||||
|
||||
profile = validate_profile(profile)
|
||||
if testing and profile != TESTING_PROFILE:
|
||||
raise ValueError(
|
||||
f"--testing conflicts with --profile {profile}: --testing is an "
|
||||
f"alias for --profile {TESTING_PROFILE}"
|
||||
)
|
||||
return profile
|
||||
|
||||
|
||||
def is_testing(profile: str) -> bool:
|
||||
return profile == TESTING_PROFILE
|
||||
|
||||
|
||||
def profile_suffix(profile: str) -> str:
|
||||
"""Filename suffix for a profile (``""``, ``"-testing"``, ``"-research"``)."""
|
||||
return "" if profile == DEFAULT_PROFILE else f"-{profile}"
|
||||
|
||||
|
||||
def profile_from_env(testing: bool = False) -> str:
|
||||
"""Read the profile the process was started with.
|
||||
|
||||
Falls back to the `--testing` bool for callers that only track that, so
|
||||
behaviour is unchanged when no profile was set.
|
||||
"""
|
||||
profile = os.environ.get(ENV_VAR)
|
||||
if not profile:
|
||||
return TESTING_PROFILE if testing else DEFAULT_PROFILE
|
||||
try:
|
||||
return validate_profile(profile)
|
||||
except ValueError:
|
||||
return TESTING_PROFILE if testing else DEFAULT_PROFILE
|
||||
|
||||
|
||||
def export_profile(profile: str) -> None:
|
||||
"""Publish the profile to this process and its children.
|
||||
|
||||
The default profile leaves ``AW_PROFILE`` unset so aw-core keeps the
|
||||
bare ``activitywatch`` root. Named profiles (including ``testing``)
|
||||
set the env var; children inherit it without a CLI flag.
|
||||
"""
|
||||
if profile == DEFAULT_PROFILE:
|
||||
os.environ.pop(ENV_VAR, None)
|
||||
else:
|
||||
os.environ[ENV_VAR] = profile
|
||||
1
aw_client/py.typed
Normal file
1
aw_client/py.typed
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
334
aw_client/queries.py
Normal file
334
aw_client/queries.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""
|
||||
Common queries.
|
||||
|
||||
Most of these are from: https://github.com/ActivityWatch/aw-webui/blob/master/src/queries.ts
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import (
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
|
||||
from typing_extensions import TypeGuard
|
||||
|
||||
import aw_client
|
||||
|
||||
from .classes import get_classes
|
||||
|
||||
|
||||
class EnhancedJSONEncoder(json.JSONEncoder):
|
||||
"""For encoding dataclasses into JSON"""
|
||||
|
||||
def default(self, o):
|
||||
if dataclasses.is_dataclass(o):
|
||||
return dataclasses.asdict(o) # type: ignore
|
||||
return super().default(o)
|
||||
|
||||
|
||||
"""
|
||||
Do these dataclasses look confusing?
|
||||
Read up on dataclass inheritance: https://stackoverflow.com/a/53085935/965332
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _QueryParamsDefaultsBase:
|
||||
bid_browsers: List[str] = field(default_factory=list)
|
||||
classes: List[Tuple[List[str], dict]] = field(default_factory=list)
|
||||
filter_classes: List[List[str]] = field(default_factory=list)
|
||||
filter_afk: bool = True
|
||||
include_audible: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueryParams(_QueryParamsDefaultsBase):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DesktopQueryParamsBase:
|
||||
bid_window: str
|
||||
bid_afk: str
|
||||
always_active_pattern: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DesktopQueryParams(QueryParams, _DesktopQueryParamsBase):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AndroidQueryParamsBase:
|
||||
bid_android: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class AndroidQueryParams(QueryParams, _AndroidQueryParamsBase):
|
||||
pass
|
||||
|
||||
|
||||
def isDesktopParams(params: QueryParams) -> TypeGuard[DesktopQueryParams]:
|
||||
return isinstance(params, DesktopQueryParams)
|
||||
|
||||
|
||||
def isAndroidParams(params: QueryParams) -> TypeGuard[AndroidQueryParams]:
|
||||
return isinstance(params, AndroidQueryParams)
|
||||
|
||||
|
||||
def canonicalEvents(params: Union[DesktopQueryParams, AndroidQueryParams]) -> str:
|
||||
if not params.classes:
|
||||
# if categories not explicitly set,
|
||||
# get categories from server settings
|
||||
params.classes = get_classes()
|
||||
|
||||
# Needs escaping for regex patterns like '\w' to work (JSON.stringify adds extra unnecessary escaping)
|
||||
classes_str = json.dumps(params.classes, cls=EnhancedJSONEncoder)
|
||||
classes_str = re.sub(r"\\\\", r"\\", classes_str)
|
||||
|
||||
cat_filter_str = json.dumps(params.filter_classes)
|
||||
|
||||
# For simplicity, we assume that bid_window and bid_android are exchangeable (note however it needs special treatment)
|
||||
bid_window = (
|
||||
params.bid_window
|
||||
if isinstance(params, DesktopQueryParams)
|
||||
else params.bid_android
|
||||
)
|
||||
|
||||
return "\n".join(
|
||||
[
|
||||
# Fetch window/app events
|
||||
f'events = flood(query_bucket(find_bucket("{bid_window}")));',
|
||||
# On Android, merge events to avoid overload of events
|
||||
(
|
||||
'events = merge_events_by_keys(events, ["app"]);'
|
||||
if isAndroidParams(params)
|
||||
else ""
|
||||
),
|
||||
# Fetch not-afk events
|
||||
(
|
||||
f"""
|
||||
not_afk = flood(query_bucket(find_bucket("{params.bid_afk}")));
|
||||
not_afk = filter_keyvals(not_afk, "status", ["not-afk"]);"""
|
||||
+ (
|
||||
"""
|
||||
not_treat_as_afk = filter_keyvals_regex(events, "app", "%s");
|
||||
not_afk = period_union(not_afk, not_treat_as_afk);
|
||||
not_treat_as_afk = filter_keyvals_regex(events, "title", "%s");
|
||||
not_afk = period_union(not_afk, not_treat_as_afk);"""
|
||||
% (
|
||||
params.always_active_pattern.replace('"', '\\"'),
|
||||
params.always_active_pattern.replace('"', '\\"'),
|
||||
)
|
||||
if params.always_active_pattern
|
||||
else ""
|
||||
)
|
||||
if isDesktopParams(params)
|
||||
else ""
|
||||
),
|
||||
# Fetch browser events
|
||||
(
|
||||
(browserEvents(params) if isDesktopParams(params) else "")
|
||||
+ ( # Include focused and audible browser events as indications of not-afk
|
||||
"""
|
||||
audible_events = filter_keyvals(browser_events, "audible", [true]);
|
||||
not_afk = period_union(not_afk, audible_events);
|
||||
"""
|
||||
if params.include_audible
|
||||
else ""
|
||||
)
|
||||
if params.bid_browsers
|
||||
else ""
|
||||
),
|
||||
# Filter out window events when the user was afk
|
||||
(
|
||||
"events = filter_period_intersect(events, not_afk);"
|
||||
if isDesktopParams(params) and params.filter_afk
|
||||
else ""
|
||||
),
|
||||
# Categorize
|
||||
f"events = categorize(events, {classes_str});" if params.classes else "",
|
||||
# Filter out selected categories
|
||||
(
|
||||
f'events = filter_keyvals(events, "$category", {cat_filter_str});'
|
||||
if params.filter_classes
|
||||
else ""
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def pretty_query(query: str) -> str:
|
||||
return "\n".join([line.strip() for line in query.split("\n") if line.strip()])
|
||||
|
||||
|
||||
def _browser_in_buckets(browser: str, browserbuckets: List[str]) -> Optional[str]:
|
||||
for bucket in browserbuckets:
|
||||
if browser in bucket:
|
||||
return bucket
|
||||
return None
|
||||
|
||||
|
||||
def browsersWithBuckets(browserbuckets: List[str]) -> List[Tuple[str, str]]:
|
||||
"""Returns a list of (browserName, bucketId) pairs for found browser buckets"""
|
||||
browsername_to_bucketid: List[Tuple[str, Optional[str]]] = [
|
||||
(browserName, _browser_in_buckets(browserName, browserbuckets))
|
||||
for browserName in browser_appnames
|
||||
]
|
||||
|
||||
# Only return browsers for which a bucket could be found
|
||||
return [t for t in browsername_to_bucketid if t[1]] # type: ignore
|
||||
|
||||
|
||||
def browserEvents(params: DesktopQueryParams) -> str:
|
||||
"""Returns a list of active browser events (where the browser was the active window) from all browser buckets"""
|
||||
code = "browser_events = [];"
|
||||
|
||||
for browserName, bucketId in browsersWithBuckets(params.bid_browsers):
|
||||
browser_appnames_str = json.dumps(browser_appnames[browserName])
|
||||
code += f"""
|
||||
events_{browserName} = flood(query_bucket("{bucketId}"));
|
||||
window_{browserName} = filter_keyvals(events, "app", {browser_appnames_str});
|
||||
events_{browserName} = filter_period_intersect(events_{browserName}, window_{browserName});
|
||||
events_{browserName} = split_url_events(events_{browserName});
|
||||
browser_events = concat(browser_events, events_{browserName});
|
||||
browser_events = sort_by_timestamp(browser_events);
|
||||
"""
|
||||
return code
|
||||
|
||||
|
||||
browser_appnames = {
|
||||
"chrome": [
|
||||
# Chrome
|
||||
"Google Chrome",
|
||||
"Google-chrome",
|
||||
"chrome.exe",
|
||||
"google-chrome-stable",
|
||||
# Chromium
|
||||
"Chromium",
|
||||
"Chromium-browser",
|
||||
"Chromium-browser-chromium",
|
||||
"chromium.exe",
|
||||
# Pre-releases
|
||||
"Google-chrome-beta",
|
||||
"Google-chrome-unstable",
|
||||
# Brave (should this be merged with the brave entry?)
|
||||
"Brave-browser",
|
||||
],
|
||||
"firefox": [
|
||||
"Firefox",
|
||||
"Firefox.exe",
|
||||
"firefox",
|
||||
"firefox.exe",
|
||||
"Firefox Developer Edition",
|
||||
"firefoxdeveloperedition",
|
||||
"Firefox-esr",
|
||||
"Firefox Beta",
|
||||
"Nightly",
|
||||
"org.mozilla.firefox",
|
||||
],
|
||||
"opera": ["opera.exe", "Opera"],
|
||||
"brave": ["brave.exe"],
|
||||
"edge": [
|
||||
"msedge.exe", # Windows
|
||||
"Microsoft Edge", # macOS
|
||||
],
|
||||
"vivaldi": ["Vivaldi-stable", "Vivaldi-snapshot", "vivaldi.exe"],
|
||||
}
|
||||
|
||||
default_limit = 100
|
||||
|
||||
|
||||
def querystr_to_array(querystr: str) -> List[str]:
|
||||
return [line + ";" for line in querystr.split(";") if line]
|
||||
|
||||
|
||||
def escape_doublequote(s: str) -> str:
|
||||
return s.replace('"', '\\"')
|
||||
|
||||
|
||||
def fullDesktopQuery(
|
||||
params: DesktopQueryParams,
|
||||
) -> str:
|
||||
# Escape `"`
|
||||
params.bid_window = escape_doublequote(params.bid_window)
|
||||
params.bid_afk = escape_doublequote(params.bid_afk)
|
||||
params.bid_browsers = [escape_doublequote(bucket) for bucket in params.bid_browsers]
|
||||
|
||||
# Build the base query
|
||||
query = f"""
|
||||
{canonicalEvents(params)}
|
||||
title_events = sort_by_duration(merge_events_by_keys(events, ["app", "title"]));
|
||||
app_events = sort_by_duration(merge_events_by_keys(title_events, ["app"]));
|
||||
cat_events = sort_by_duration(merge_events_by_keys(events, ["$category"]));
|
||||
app_events = limit_events(app_events, {default_limit});
|
||||
title_events = limit_events(title_events, {default_limit});
|
||||
duration = sum_durations(events);
|
||||
"""
|
||||
|
||||
# Add browser-related query parts if browser buckets exist
|
||||
if params.bid_browsers:
|
||||
query += f"""
|
||||
browser_events = split_url_events(browser_events);
|
||||
browser_urls = merge_events_by_keys(browser_events, ["url"]);
|
||||
browser_urls = sort_by_duration(browser_urls);
|
||||
browser_urls = limit_events(browser_urls, {default_limit});
|
||||
browser_domains = merge_events_by_keys(browser_events, ["$domain"]);
|
||||
browser_domains = sort_by_duration(browser_domains);
|
||||
browser_domains = limit_events(browser_domains, {default_limit});
|
||||
browser_duration = sum_durations(browser_events);
|
||||
"""
|
||||
else:
|
||||
query += """
|
||||
browser_events = [];
|
||||
browser_urls = [];
|
||||
browser_domains = [];
|
||||
browser_duration = 0;
|
||||
"""
|
||||
|
||||
# Add the return statement
|
||||
query += """
|
||||
RETURN = {
|
||||
"events": events,
|
||||
"window": {
|
||||
"app_events": app_events,
|
||||
"title_events": title_events,
|
||||
"cat_events": cat_events,
|
||||
"active_events": not_afk,
|
||||
"duration": duration
|
||||
},
|
||||
"browser": {
|
||||
"domains": browser_domains,
|
||||
"urls": browser_urls,
|
||||
"duration": browser_duration
|
||||
}
|
||||
};
|
||||
"""
|
||||
return query
|
||||
|
||||
|
||||
def test_fullDesktopQuery():
|
||||
params = DesktopQueryParams(
|
||||
bid_window="aw-watcher-window_",
|
||||
bid_afk="aw-watcher-afk_",
|
||||
)
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
start = now - timedelta(days=7)
|
||||
end = now
|
||||
timeperiods = [(start, end)]
|
||||
query = fullDesktopQuery(params)
|
||||
|
||||
awc = aw_client.ActivityWatchClient("test")
|
||||
res = awc.query(query, timeperiods)[0]
|
||||
events = res["events"]
|
||||
print(len(events))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_fullDesktopQuery()
|
||||
47
aw_client/singleinstance.py
Normal file
47
aw_client/singleinstance.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
|
||||
if sys.platform != "win32":
|
||||
import fcntl
|
||||
|
||||
from aw_core.dirs import get_cache_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SingleInstance:
|
||||
"""
|
||||
This code is taken and modified form the tendo python package
|
||||
(Python Software Foundation License Version 2)
|
||||
http://pythonhosted.org/tendo/_modules/tendo/singleton.html
|
||||
"""
|
||||
|
||||
def __init__(self, client_name):
|
||||
self.lockfile = os.path.join(get_cache_dir("client_locks"), client_name)
|
||||
logger.debug("SingleInstance lockfile: " + self.lockfile)
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
# file already exists, we try to remove (in case previous execution was interrupted)
|
||||
if os.path.exists(self.lockfile):
|
||||
os.unlink(self.lockfile)
|
||||
self.fd = os.open(self.lockfile, os.O_CREAT | os.O_EXCL | os.O_RDWR)
|
||||
except OSError as e:
|
||||
if e.errno == 13:
|
||||
logger.error("Another instance is already running, quitting.")
|
||||
sys.exit(-1)
|
||||
else:
|
||||
raise e
|
||||
else: # non Windows
|
||||
self.fp = open(self.lockfile, "w")
|
||||
try:
|
||||
fcntl.lockf(self.fp, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
logger.error("Another instance is already running, quitting.")
|
||||
sys.exit(-1)
|
||||
|
||||
def __del__(self):
|
||||
if sys.platform == "win32":
|
||||
if hasattr(self, "fd"):
|
||||
os.close(self.fd)
|
||||
os.unlink(self.lockfile)
|
||||
Reference in New Issue
Block a user