Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ build:

test:
python -c "import aw_client"
pytest -s -vv tests/test_requestqueue.py
pytest -s -vv tests/test_requestqueue.py tests/test_profile.py tests/test_profile_config.py

test-integration:
pytest -v tests/test_client.py
Expand Down
20 changes: 16 additions & 4 deletions aw_client/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ class _Context:
)
@click.option(
"--port",
default=5600,
help="Port to use",
default=None,
type=int,
help="Port to use (default: profile config, 5600 / 5666)",
)
@click.option(
"-v",
Expand All @@ -48,13 +49,24 @@ class _Context:
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):
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 if port != 5600 else (5666 if testing else 5600),
port=port,
testing=testing,
profile=profile,
)
logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO)

Expand Down
84 changes: 64 additions & 20 deletions aw_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
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
Expand Down Expand Up @@ -67,6 +75,7 @@ def __init__(
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.
Expand All @@ -77,19 +86,45 @@ def __init__(

.. 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.
"""
self.testing = testing
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_config = _config["server" if not testing else "server-testing"]
client_config = _config["client" if not testing else "client-testing"]
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)
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(
Expand Down Expand Up @@ -391,7 +426,9 @@ def disconnect(self):
self.request_queue.join()

# Throw away old thread object, create new one since same thread cannot be started twice
self.request_queue = RequestQueue(self)
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

Expand Down Expand Up @@ -436,7 +473,11 @@ class RequestQueue(threading.Thread):

VERSION = 1 # update this whenever the queue-file format changes

def __init__(self, client: ActivityWatchClient) -> None:
def __init__(
self,
client: ActivityWatchClient,
persistqueue_path: Optional[str] = None,
) -> None:
threading.Thread.__init__(self, daemon=True)

self.client = client
Expand All @@ -449,22 +490,25 @@ def __init__(self, client: ActivityWatchClient) -> None:

self._attempt_reconnect_interval = 10

# Setup failed queues file
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)

persistqueue_path = os.path.join(
queued_dir,
"{}{}.v{}.persistqueue".format(
self.client.client_name,
"-testing" if client.testing else "",
self.VERSION,
),
)
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
Expand Down
158 changes: 148 additions & 10 deletions aw_client/config.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,32 @@
import logging
import os
from typing import Optional, Union
from typing import List, Optional, Tuple, Union

import platformdirs
import tomlkit
from aw_core import dirs
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"
Expand All @@ -29,7 +48,130 @@ def load_config():
return load_config_toml("aw-client", default_config)


def load_local_server_api_key(host: str, port: Union[int, str]) -> Optional[str]:
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

Expand All @@ -38,14 +180,10 @@ def load_local_server_api_key(host: str, port: Union[int, str]) -> Optional[str]
except (TypeError, ValueError):
return None

config_dir = dirs.get_config_dir("aw-server-rust")
candidates = (
("config.toml", 5600),
("config-testing.toml", 5666),
)
if profile is None:
profile = profile_from_env(False)

for filename, default_port in candidates:
config_path = os.path.join(config_dir, filename)
for config_path, default_port in rust_server_config_candidates(profile):
if not os.path.isfile(config_path):
continue

Expand Down
Loading
Loading