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

23
aw_core/__about__.py Normal file
View File

@@ -0,0 +1,23 @@
# Inspired by:
# https://github.com/pypa/pipfile/blob/master/pipfile/__about__.py
__all__ = [
"__title__",
"__summary__",
"__uri__",
"__author__",
"__email__",
"__license__",
"__copyright__",
]
__title__ = "aw-core"
__summary__ = "Core library for ActivityWatch"
__uri__ = "https://github.com/ActivityWatch/aw-core"
__author__ = "Erik Bjäreholt, Johan Bjäreholt"
__email__ = "erik@bjareho.lt, johan@bjareho.lt"
__license__ = "MPL2"
__copyright__ = f"Copyright 2017 {__author__}"

29
aw_core/__init__.py Normal file
View File

@@ -0,0 +1,29 @@
# ignore: F401
from . import __about__
from . import decorators
from . import util
from . import dirs
from . import config
from . import log
from . import models
from .models import Event
from . import schema
__all__ = [
"__about__",
# Classes
"Event",
# Modules
"decorators",
"util",
"dirs",
"config",
"log",
"models",
"schema",
]

116
aw_core/config.py Normal file
View File

@@ -0,0 +1,116 @@
import logging
import os
from typing import Union
import tomlkit
from deprecation import deprecated
from aw_core import dirs
logger = logging.getLogger(__name__)
def _merge(a: dict, b: dict, path=None):
"""
Recursively merges b into a, with b taking precedence.
From: https://stackoverflow.com/a/7205107/965332
"""
if path is None:
path = []
for key in b:
if key in a:
if isinstance(a[key], dict) and isinstance(b[key], dict):
_merge(a[key], b[key], path + [str(key)])
elif a[key] == b[key]:
pass # same leaf value
else:
a[key] = b[key]
else:
a[key] = b[key]
return a
def _comment_out_toml(s: str):
# Only comment out keys, not headers or empty lines
return "\n".join(
[
"#" + line if line.strip() and not line.strip().startswith("[") else line
for line in s.split("\n")
]
)
def load_config_toml(
appname: str, default_config: str
) -> Union[dict, tomlkit.container.Container]:
config_dir = dirs.get_config_dir(appname)
config_file_path = os.path.join(config_dir, f"{appname}.toml")
# Run early to ensure input is valid toml before writing
default_config_toml = tomlkit.parse(default_config)
# Override defaults from existing config file
if os.path.isfile(config_file_path):
with open(config_file_path) as f:
config = f.read()
config_toml = tomlkit.parse(config)
else:
# If file doesn't exist, write with commented-out default config
with open(config_file_path, "w") as f:
f.write(_comment_out_toml(default_config))
config_toml = dict()
config = _merge(default_config_toml, config_toml)
return config
def save_config_toml(appname: str, config: str) -> None:
# Check that passed config string is valid toml
assert tomlkit.parse(config)
config_dir = dirs.get_config_dir(appname)
config_file_path = os.path.join(config_dir, f"{appname}.toml")
with open(config_file_path, "w") as f:
f.write(config)
@deprecated(
details="Use the load_config_toml function instead",
deprecated_in="0.5.3",
)
def load_config(appname, default_config):
"""
Take the defaults, and if a config file exists, use the settings specified
there as overrides for their respective defaults.
"""
config = default_config
config_dir = dirs.get_config_dir(appname)
config_file_path = os.path.join(config_dir, f"{appname}.toml")
# Override defaults from existing config file
if os.path.isfile(config_file_path):
with open(config_file_path) as f:
config.read_file(f)
# Overwrite current config file (necessary in case new default would be added)
save_config(appname, config)
return config
@deprecated(
details="Use the save_config_toml function instead",
deprecated_in="0.5.3",
)
def save_config(appname, config):
config_dir = dirs.get_config_dir(appname)
config_file_path = os.path.join(config_dir, f"{appname}.ini")
with open(config_file_path, "w") as f:
config.write(f)
# Flush and fsync to lower risk of corrupted files
f.flush()
os.fsync(f.fileno())

51
aw_core/decorators.py Normal file
View File

@@ -0,0 +1,51 @@
import functools
import logging
import time
import warnings
def deprecated(f): # pragma: no cover
"""
This is a decorator which can be used to mark functions
as deprecated. It will result in a warning being emitted
when the function is used.
Taken from: http://stackoverflow.com/a/30253848/965332
"""
# Warn only once per deprecated function
warned_for = False
@functools.wraps(f)
def g(*args, **kwargs):
# TODO: Use logging module instead?
nonlocal warned_for
if not warned_for:
warnings.simplefilter("always", DeprecationWarning) # turn off filter
warnings.warn(
f"Call to deprecated function {f.__name__}, "
"this warning will only show once per function.",
category=DeprecationWarning,
stacklevel=2,
)
warnings.simplefilter("default", DeprecationWarning) # reset filter
warned_for = True
return f(*args, **kwargs)
return g
def restart_on_exception(f, delay=1, exception=Exception): # pragma: no cover
@functools.wraps(f)
def g(*args, **kwargs):
while True:
try:
f(*args, **kwargs)
except exception as e:
# TODO: Use warnings module instead?
logging.error(f"{f.__name__} crashed due to exception, restarting.")
logging.error(e)
time.sleep(
delay
) # To prevent extremely fast restarts in case of bad state.
return g

179
aw_core/dirs.py Normal file
View File

@@ -0,0 +1,179 @@
import os
import sys
from functools import wraps
from typing import Callable, Optional
import platformdirs
GetDirFunc = Callable[[Optional[str]], str]
_DEFAULT_APPNAME = "activitywatch"
_TESTING_PROFILE = "testing"
_TESTING_APPNAME = f"{_DEFAULT_APPNAME}-{_TESTING_PROFILE}"
# Filenames that mark a machine as still using the pre-profile shared-root
# testing layout (ActivityWatch/activitywatch#1399). Keep this list specific:
# a false positive would pin a fresh install to the legacy layout forever.
_LEGACY_TESTING_FILENAME_MARKERS = (
"peewee-sqlite-testing",
"sqlite-testing",
"settings-testing",
"config-testing",
"-testing.db",
"-testing.toml",
"-testing.json",
"_testing_",
)
def get_profile() -> str:
"""Return the active ``AW_PROFILE`` value, or ``""`` for the default profile."""
return os.environ.get("AW_PROFILE", "") or ""
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 (
platformdirs.user_data_dir,
platformdirs.user_config_dir,
platformdirs.user_cache_dir,
):
if os.path.isdir(getter(_TESTING_APPNAME)):
return True
return False
def _is_legacy_testing_filename(name: str) -> bool:
lower = name.lower()
return any(marker in lower for marker in _LEGACY_TESTING_FILENAME_MARKERS)
def _legacy_testing_artifacts_exist() -> bool:
"""True if testing data still lives under the shared ``activitywatch`` root."""
roots = (
platformdirs.user_data_dir(_DEFAULT_APPNAME),
platformdirs.user_config_dir(_DEFAULT_APPNAME),
platformdirs.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/...) not further.
if os.path.relpath(dirpath, root) != ".":
dirnames.clear()
return False
def using_legacy_testing_root() -> bool:
"""Whether ``AW_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 get_profile() != _TESTING_PROFILE:
return False
if _new_testing_root_exists():
return False
return _legacy_testing_artifacts_exist()
def legacy_testing_suffix(testing: bool) -> str:
"""Return ``"-testing"`` only when testing data shares the default root.
Isolated profile roots (including new-style ``activitywatch-testing/``) use
bare filenames: the directory already isolates. Suffixed names remain only
in legacy mode so existing ``peewee-sqlite-testing.v2.db`` files keep working.
"""
if not testing:
return ""
profile = get_profile()
if profile == _TESTING_PROFILE and not using_legacy_testing_root():
return ""
if profile and profile != _TESTING_PROFILE:
# Named isolated profile — directory already isolates.
return ""
return "-testing"
def _get_appname() -> str:
"""Return the platformdirs appname, optionally suffixed by the active profile.
If the ``AW_PROFILE`` environment variable is set to a non-empty string the
appname becomes ``activitywatch-<profile>`` so that *all* platform
directories (data, config, cache, log) are completely separate from the
default profile. An unset or empty ``AW_PROFILE`` returns the bare
``"activitywatch"`` name, which is identical to the pre-profile behaviour.
The ``testing`` profile is special: see :func:`using_legacy_testing_root`.
Existing testing data in the shared root is not orphaned; fresh setups and
machines that already have ``activitywatch-testing/`` use the isolated root.
This is the single authoritative place where profile isolation is applied.
Every module that uses :func:`get_data_dir`, :func:`get_config_dir`,
:func:`get_cache_dir` or :func:`get_log_dir` automatically inherits the
correct root for the running profile; no ``profile=`` parameter needs to be
threaded through the call chain.
"""
profile = get_profile()
if not profile:
return _DEFAULT_APPNAME
if profile == _TESTING_PROFILE and using_legacy_testing_root():
return _DEFAULT_APPNAME
return f"{_DEFAULT_APPNAME}-{profile}"
def ensure_path_exists(path: str) -> None:
if not os.path.exists(path):
os.makedirs(path)
def _ensure_returned_path_exists(f: GetDirFunc) -> GetDirFunc:
@wraps(f)
def wrapper(subpath: Optional[str] = None) -> str:
path = f(subpath)
ensure_path_exists(path)
return path
return wrapper
@_ensure_returned_path_exists
def get_data_dir(module_name: Optional[str] = None) -> str:
data_dir = platformdirs.user_data_dir(_get_appname())
return os.path.join(data_dir, module_name) if module_name else data_dir
@_ensure_returned_path_exists
def get_cache_dir(module_name: Optional[str] = None) -> str:
cache_dir = platformdirs.user_cache_dir(_get_appname())
return os.path.join(cache_dir, module_name) if module_name else cache_dir
@_ensure_returned_path_exists
def get_config_dir(module_name: Optional[str] = None) -> str:
config_dir = platformdirs.user_config_dir(_get_appname())
return os.path.join(config_dir, module_name) if module_name else config_dir
@_ensure_returned_path_exists
def get_log_dir(module_name: Optional[str] = None) -> str: # pragma: no cover
# on Linux/Unix, platformdirs changed to using XDG_STATE_HOME instead of XDG_DATA_HOME for log_dir in v2.6
# we want to keep using XDG_DATA_HOME for backwards compatibility
# https://github.com/ActivityWatch/aw-core/pull/122#issuecomment-1768020335
if sys.platform.startswith("linux"):
log_dir = platformdirs.user_cache_path(_get_appname()) / "log"
else:
log_dir = platformdirs.user_log_dir(_get_appname())
return os.path.join(log_dir, module_name) if module_name else log_dir

129
aw_core/log.py Normal file
View File

@@ -0,0 +1,129 @@
import logging
import os
import sys
from datetime import datetime
from logging.handlers import RotatingFileHandler
from typing import List, Optional
from . import dirs
from .decorators import deprecated
# NOTE: Will be removed in a future version since it's not compatible
# with running a multi-service process.
# TODO: prefix with `_`
log_file_path = None
@deprecated
def get_log_file_path() -> Optional[str]: # pragma: no cover
"""DEPRECATED: Use get_latest_log_file instead."""
return log_file_path
def setup_logging(
name: str,
testing=False,
verbose=False,
log_stderr=True,
log_file=False,
): # pragma: no cover
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG if verbose else logging.INFO)
root_logger.handlers = []
# run with LOG_LEVEL=DEBUG to customize log level across all AW components
log_level = os.environ.get("LOG_LEVEL")
if log_level:
if hasattr(logging, log_level.upper()):
root_logger.setLevel(getattr(logging, log_level.upper()))
else:
root_logger.warning(
f"No logging level called {log_level} (as specified in env var)"
)
if log_stderr:
root_logger.addHandler(_create_stderr_handler())
if log_file:
root_logger.addHandler(_create_file_handler(name, testing=testing))
def excepthook(type_, value, traceback):
root_logger.exception("Unhandled exception", exc_info=(type_, value, traceback))
# call the default excepthook if log_stderr isn't true
# (otherwise it'll just get duplicated)
if not log_stderr:
sys.__excepthook__(type_, value, traceback)
sys.excepthook = excepthook
def _get_latest_log_files(name, testing=False) -> List[str]: # pragma: no cover
"""
Returns a list with the paths of all available logfiles for `name`,
sorted by latest first.
"""
log_dir = dirs.get_log_dir(name)
files = filter(lambda filename: name in filename, os.listdir(log_dir))
# Isolated testing roots already separate logs by directory; the
# filename filter is only needed in the shared-root legacy layout.
isolated_testing = testing and not dirs.legacy_testing_suffix(testing)
if not isolated_testing:
files = filter(
lambda filename: (
"testing" in filename if testing else "testing" not in filename
),
files,
)
return [os.path.join(log_dir, filename) for filename in sorted(files, reverse=True)]
def get_latest_log_file(name, testing=False) -> Optional[str]: # pragma: no cover
"""
Returns the filename of the last logfile with ``name``.
Useful when you want to read the logfile of another ActivityWatch service.
"""
last_logs = _get_latest_log_files(name, testing=testing)
return last_logs[0] if last_logs else None
def _create_stderr_handler() -> logging.Handler: # pragma: no cover
stderr_handler = logging.StreamHandler(stream=sys.stderr)
stderr_handler.setFormatter(_create_human_formatter())
return stderr_handler
def _create_file_handler(
name, testing=False, log_json=False
) -> logging.Handler: # pragma: no cover
log_dir = dirs.get_log_dir(name)
# Set logfile path and name
global log_file_path
# Should result in something like:
# $LOG_DIR/aw-server_testing_2017-01-05T00:21:39.log
file_ext = ".log.json" if log_json else ".log"
now_str = str(datetime.now().replace(microsecond=0).isoformat()).replace(":", "-")
testing_prefix = (
"testing_" if testing and dirs.legacy_testing_suffix(testing) else ""
)
log_name = name + "_" + testing_prefix + now_str + file_ext
log_file_path = os.path.join(log_dir, log_name)
# Create rotating logfile handler, max 10MB per file, 3 files max
# Prevents logfile from growing too large, like in:
# - https://github.com/ActivityWatch/activitywatch/issues/815#issue-1423555466
# - https://github.com/ActivityWatch/activitywatch/issues/756#issuecomment-1266662861
fh = RotatingFileHandler(
log_file_path, mode="a", maxBytes=10 * 1024 * 1024, backupCount=3
)
fh.setFormatter(_create_human_formatter())
return fh
def _create_human_formatter() -> logging.Formatter: # pragma: no cover
return logging.Formatter(
"%(asctime)s [%(levelname)-5s]: %(message)s (%(name)s:%(lineno)s)",
"%Y-%m-%d %H:%M:%S",
)

142
aw_core/models.py Normal file
View File

@@ -0,0 +1,142 @@
import json
import logging
import numbers
import typing
from datetime import datetime, timedelta, timezone
from typing import (
Any,
Dict,
Optional,
Union,
)
import iso8601
logger = logging.getLogger(__name__)
Number = Union[int, float]
Id = Optional[Union[int, str]]
ConvertibleTimestamp = Union[datetime, str]
Duration = Union[timedelta, Number]
Data = Dict[str, Any]
def _timestamp_parse(ts_in: ConvertibleTimestamp) -> datetime:
"""
Takes something representing a timestamp and
returns a timestamp in the representation we want.
"""
ts = iso8601.parse_date(ts_in) if isinstance(ts_in, str) else ts_in
# Set resolution to milliseconds instead of microseconds
# (Fixes incompability with software based on unix time, for example mongodb)
ts = ts.replace(microsecond=int(ts.microsecond / 1000) * 1000)
# Add timezone if not set
if not ts.tzinfo:
# Needed? All timestamps should be iso8601 so ought to always contain timezone.
# Yes, because it is optional in iso8601
logger.warning(f"timestamp without timezone found, using UTC: {ts}")
ts = ts.replace(tzinfo=timezone.utc)
return ts
class Event(dict):
"""
Used to represents an event.
"""
def __init__(
self,
id: Optional[Id] = None,
timestamp: Optional[ConvertibleTimestamp] = None,
duration: Duration = 0,
data: Optional[Data] = None,
) -> None:
self.id = id
if timestamp is None:
logger.warning(
"Event initializer did not receive a timestamp argument, "
"using now as timestamp"
)
# FIXME: The typing.cast here was required for mypy to shut up, weird...
self.timestamp = datetime.now(typing.cast(timezone, timezone.utc))
else:
# The conversion needs to be explicit here for mypy to pick it up
# (lacks support for properties)
self.timestamp = _timestamp_parse(timestamp)
self.duration = duration # type: ignore
self.data = data or {}
def __eq__(self, other: object) -> bool:
if isinstance(other, Event):
return (
self.timestamp == other.timestamp
and self.duration == other.duration
and self.data == other.data
)
else:
raise TypeError(
f"operator not supported between instances of '{type(self)}' and '{type(other)}'"
)
def __lt__(self, other: object) -> bool:
if isinstance(other, Event):
return self.timestamp < other.timestamp
else:
raise TypeError(
f"operator not supported between instances of '{type(self)}' and '{type(other)}'"
)
def to_json_dict(self) -> dict:
"""Useful when sending data over the wire.
Any mongodb interop should not use do this as it accepts datetimes."""
json_data = self.copy()
json_data["timestamp"] = self.timestamp.astimezone(timezone.utc).isoformat()
json_data["duration"] = self.duration.total_seconds()
return json_data
def to_json_str(self) -> str:
data = self.to_json_dict()
return json.dumps(data)
def _hasprop(self, propname: str) -> bool:
"""Badly named, but basically checks if the underlying
dict has a prop, and if it is a non-empty list"""
return propname in self and self[propname] is not None
@property
def id(self) -> Id:
return self["id"] if self._hasprop("id") else None
@id.setter
def id(self, id: Id) -> None:
self["id"] = id
@property
def data(self) -> dict:
return self["data"] if self._hasprop("data") else {}
@data.setter
def data(self, data: dict) -> None:
self["data"] = data
@property
def timestamp(self) -> datetime:
return self["timestamp"]
@timestamp.setter
def timestamp(self, timestamp: ConvertibleTimestamp) -> None:
self["timestamp"] = _timestamp_parse(timestamp).astimezone(timezone.utc)
@property
def duration(self) -> timedelta:
return self["duration"] if self._hasprop("duration") else timedelta(0)
@duration.setter
def duration(self, duration: Duration) -> None:
if isinstance(duration, timedelta):
self["duration"] = duration
elif isinstance(duration, numbers.Real):
self["duration"] = timedelta(seconds=duration) # type: ignore
else:
raise TypeError(f"Couldn't parse duration of invalid type {type(duration)}")

0
aw_core/py.typed Normal file
View File

20
aw_core/schema.py Normal file
View File

@@ -0,0 +1,20 @@
import os
import json
def _this_dir() -> str:
return os.path.dirname(os.path.abspath(__file__))
def _schema_dir() -> str:
return os.path.join(os.path.dirname(_this_dir()), "aw_core", "schemas")
def get_json_schema(name: str) -> dict:
with open(os.path.join(_schema_dir(), name + ".json")) as f:
data = json.load(f)
return data
if __name__ == "__main__":
print(get_json_schema("event"))

View File

@@ -0,0 +1,45 @@
{
"$id": "https://activitywatch.net/schemas/bucket.v0.json",
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Bucket",
"description": "The Bucket model that is used in ActivityWatch",
"type": "object",
"required": ["id", "type", "client", "hostname"],
"properties": {
"id": {
"description": "The unique id for the bucket",
"type": "string"
},
"name": {
"description": "The readable and renameable name for the bucket",
"type": "string"
},
"type": {
"description": "The event type",
"type": "string"
},
"client": {
"description": "The name of the client that is reporting to the bucket",
"type": "string"
},
"hostname": {
"description": "The hostname of the machine on which the client is running",
"type": "string"
},
"created": {
"description": "The creation datetime of the bucket",
"type": "string",
"format": "date-time"
},
"data": {
"description": "",
"type": "object"
},
"events": {
"type": "array",
"items": {
"$ref": "#/definitions/Event"
}
}
}
}

View File

@@ -0,0 +1,19 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Event",
"description": "The Event model that is used in ActivityWatch",
"type": "object",
"required": ["timestamp"],
"properties": {
"timestamp": {
"type": "string",
"format": "date-time"
},
"duration": {
"type": "number"
},
"data": {
"type": "object"
}
}
}

View File

@@ -0,0 +1,16 @@
{
"$id": "https://activitywatch.net/schemas/export.v0.json",
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Export",
"description": "The Export model that is used by ActivityWatch",
"type": "object",
"required": [],
"properties": {
"buckets": {
"type": "array",
"items": {
"$ref": "#/definitions/Bucket"
}
}
}
}

24
aw_core/util.py Normal file
View File

@@ -0,0 +1,24 @@
import sys
from typing import Tuple
import logging
logger = logging.getLogger(__name__)
class VersionException(Exception): ...
def _version_info_tuple() -> Tuple[int, int, int]: # pragma: no cover
return (sys.version_info.major, sys.version_info.minor, sys.version_info.micro)
def assert_version(required_version: Tuple[int, ...] = (3, 5)): # pragma: no cover
actual_version = _version_info_tuple()
if actual_version <= required_version:
raise VersionException(
(
"Python version {} not supported, you need to upgrade your Python"
+ " version to at least {}."
).format(required_version)
)
logger.debug(f"Python version: {_version_info_tuple()}")