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
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:
23
aw_core/__about__.py
Normal file
23
aw_core/__about__.py
Normal 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
29
aw_core/__init__.py
Normal 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
116
aw_core/config.py
Normal 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
51
aw_core/decorators.py
Normal 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
179
aw_core/dirs.py
Normal 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
129
aw_core/log.py
Normal 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
142
aw_core/models.py
Normal 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
0
aw_core/py.typed
Normal file
20
aw_core/schema.py
Normal file
20
aw_core/schema.py
Normal 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"))
|
||||
45
aw_core/schemas/bucket.json
Normal file
45
aw_core/schemas/bucket.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
aw_core/schemas/event.json
Normal file
19
aw_core/schemas/event.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
aw_core/schemas/export.json
Normal file
16
aw_core/schemas/export.json
Normal 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
24
aw_core/util.py
Normal 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()}")
|
||||
Reference in New Issue
Block a user