Skip to content

Commit f80900e

Browse files
feat(profile): add profile= to ActivityWatchClient and --profile CLI (#118)
* feat(profile): add profile= to ActivityWatchClient and --profile CLI --testing stays as an alias for --profile testing. The client exports AW_PROFILE before loading config so aw-core dirs isolate the queue file; persistqueue keeps the -testing suffix and named profiles get the same shape. Local rust API-key lookup reads config-<profile>.toml for named profiles so a research client does not inherit the default instance's key. The default profile unsets AW_PROFILE rather than setting it to "default", because aw-core suffixes any non-empty value. Part of ActivityWatch/activitywatch#1399. * fix(profile): make API-key lookup tests OS-independent Do not pull tests/test_auth.py into make test — those tests assume XDG_CONFIG_HOME, which platformdirs ignores on macOS/Windows. Patch get_config_dir in the named-profile test instead. * fix(cli): treat --port 5600 as a real override Click defaulted --port to 5600, so an explicit --port 5600 was indistinguishable from "unset" and got discarded. Default is now None so profile config wins only when the flag is omitted. * fix(profile): preserve queue path across reconnects * fix(profile): apply #1399 testing-root rule to rust API-key lookup Isolated profile roots use bare config.toml. Legacy testing keeps config-testing.toml on the shared root. Named profiles no longer read config-<profile>.toml. Warn when an unprovisioned named profile falls back to [server]/5600, matching aw-server#167. Part of ActivityWatch/activitywatch#1399.
1 parent 101b803 commit f80900e

9 files changed

Lines changed: 758 additions & 37 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ build:
55

66
test:
77
python -c "import aw_client"
8-
pytest -s -vv tests/test_requestqueue.py
8+
pytest -s -vv tests/test_requestqueue.py tests/test_profile.py tests/test_profile_config.py
99

1010
test-integration:
1111
pytest -v tests/test_client.py

aw_client/cli.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,9 @@ class _Context:
3838
)
3939
@click.option(
4040
"--port",
41-
default=5600,
42-
help="Port to use",
41+
default=None,
42+
type=int,
43+
help="Port to use (default: profile config, 5600 / 5666)",
4344
)
4445
@click.option(
4546
"-v",
@@ -48,13 +49,24 @@ class _Context:
4849
help="Verbosity",
4950
)
5051
@click.option("--testing", is_flag=True, help="Set to use testing ports by default")
52+
@click.option(
53+
"--profile",
54+
default=None,
55+
help="Named instance profile. --testing is an alias for --profile testing.",
56+
)
5157
@click.pass_context
52-
def main(ctx, testing: bool, verbose: bool, host: str, port: int):
58+
def main(
59+
ctx, testing: bool, verbose: bool, host: str, port: int, profile: Optional[str]
60+
):
5361
ctx.obj = _Context()
62+
# default=None so `--port 5600` is a real override, not discarded as
63+
# "the Click default". None lets ActivityWatchClient read the profile
64+
# config (5600 / 5666 / baked research port).
5465
ctx.obj.client = aw_client.ActivityWatchClient(
5566
host=host,
56-
port=port if port != 5600 else (5666 if testing else 5600),
67+
port=port,
5768
testing=testing,
69+
profile=profile,
5870
)
5971
logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO)
6072

aw_client/client.py

Lines changed: 64 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@
2525
from aw_transform.heartbeats import heartbeat_merge
2626

2727
from .config import load_config, load_local_server_api_key
28+
from .profile import (
29+
DEFAULT_PROFILE,
30+
export_profile,
31+
is_testing,
32+
profile_from_env,
33+
profile_suffix,
34+
resolve_profile,
35+
)
2836
from .singleinstance import SingleInstance
2937

3038
# FIXME: This line is probably badly placed
@@ -67,6 +75,7 @@ def __init__(
6775
host=None,
6876
port=None,
6977
protocol="http",
78+
profile: Optional[str] = None,
7079
) -> None:
7180
"""
7281
A handy wrapper around the aw-server REST API. The recommended way of interacting with the server.
@@ -77,19 +86,45 @@ def __init__(
7786
7887
.. literalinclude:: examples/client.py
7988
:lines: 7-
89+
90+
``profile`` selects an isolated instance (config, port, queue file).
91+
``testing=True`` is the compat shim for ``profile="testing"``. If
92+
neither is set, ``AW_PROFILE`` from the launcher is used.
8093
"""
81-
self.testing = testing
94+
if profile is not None or testing:
95+
resolved = resolve_profile(profile, testing)
96+
else:
97+
resolved = profile_from_env(False)
98+
export_profile(resolved)
99+
self.profile = resolved
100+
self.testing = is_testing(resolved)
82101

83102
self.client_name = client_name
84103
self.client_hostname = socket.gethostname()
85104

86105
_config = load_config()
87-
server_config = _config["server" if not testing else "server-testing"]
88-
client_config = _config["client" if not testing else "client-testing"]
106+
server_key = "server" if resolved == DEFAULT_PROFILE else f"server-{resolved}"
107+
client_key = "client" if resolved == DEFAULT_PROFILE else f"client-{resolved}"
108+
if server_key not in _config:
109+
if resolved != DEFAULT_PROFILE:
110+
logger.warning(
111+
"Profile %s has no [%s] section, falling back to [server] "
112+
"(port %s may collide with the default instance)",
113+
resolved,
114+
server_key,
115+
5666 if is_testing(resolved) else 5600,
116+
)
117+
server_key = "server"
118+
if client_key not in _config:
119+
client_key = "client"
120+
server_config = _config[server_key]
121+
client_config = _config[client_key]
89122

90123
server_host = host or server_config["hostname"]
91124
server_port = port or server_config["port"]
92-
self.server_api_key = load_local_server_api_key(str(server_host), server_port)
125+
self.server_api_key = load_local_server_api_key(
126+
str(server_host), server_port, profile=resolved
127+
)
93128
self.server_address = f"{protocol}://{server_host}:{server_port}"
94129

95130
self.instance = SingleInstance(
@@ -391,7 +426,9 @@ def disconnect(self):
391426
self.request_queue.join()
392427

393428
# Throw away old thread object, create new one since same thread cannot be started twice
394-
self.request_queue = RequestQueue(self)
429+
self.request_queue = RequestQueue(
430+
self, persistqueue_path=self.request_queue.persistqueue_path
431+
)
395432
# Reset so warn-before-connect fires again if user calls queued ops before reconnecting
396433
self._warned_queue_before_connect = False
397434

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

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

439-
def __init__(self, client: ActivityWatchClient) -> None:
476+
def __init__(
477+
self,
478+
client: ActivityWatchClient,
479+
persistqueue_path: Optional[str] = None,
480+
) -> None:
440481
threading.Thread.__init__(self, daemon=True)
441482

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

450491
self._attempt_reconnect_interval = 10
451492

452-
# Setup failed queues file
453-
data_dir = get_data_dir("aw-client")
454-
queued_dir = os.path.join(data_dir, "queued")
455-
if not os.path.exists(queued_dir):
456-
os.makedirs(queued_dir)
457-
458-
persistqueue_path = os.path.join(
459-
queued_dir,
460-
"{}{}.v{}.persistqueue".format(
461-
self.client.client_name,
462-
"-testing" if client.testing else "",
463-
self.VERSION,
464-
),
465-
)
493+
if persistqueue_path is None:
494+
data_dir = get_data_dir("aw-client")
495+
queued_dir = os.path.join(data_dir, "queued")
496+
if not os.path.exists(queued_dir):
497+
os.makedirs(queued_dir)
498+
499+
profile = getattr(client, "profile", None)
500+
suffix = (
501+
profile_suffix(profile)
502+
if profile is not None
503+
else ("-testing" if client.testing else "")
504+
)
505+
persistqueue_path = os.path.join(
506+
queued_dir,
507+
f"{self.client.client_name}{suffix}.v{self.VERSION}.persistqueue",
508+
)
466509

467510
logger.debug(f"queue path '{persistqueue_path}'")
511+
self.persistqueue_path = persistqueue_path
468512

469513
self._persistqueue = persistqueue.FIFOSQLiteQueue(
470514
persistqueue_path, multithreading=True, auto_commit=False

aw_client/config.py

Lines changed: 148 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,32 @@
11
import logging
22
import os
3-
from typing import Optional, Union
3+
from typing import List, Optional, Tuple, Union
44

5+
import platformdirs
56
import tomlkit
6-
from aw_core import dirs
77
from aw_core.config import load_config_toml
88

9+
from .profile import DEFAULT_PROFILE, TESTING_PROFILE, profile_from_env
10+
911
logger = logging.getLogger(__name__)
1012

13+
_DEFAULT_APPNAME = "activitywatch"
14+
_TESTING_APPNAME = "activitywatch-testing"
15+
16+
# Identical to aw-core#152 / aw-server-rust#652 so python and rust agree on
17+
# the same on-disk state (ActivityWatch/activitywatch#1399). Keep this list
18+
# specific: a false positive would pin a fresh install to legacy forever.
19+
_LEGACY_TESTING_FILENAME_MARKERS = (
20+
"peewee-sqlite-testing",
21+
"sqlite-testing",
22+
"settings-testing",
23+
"config-testing",
24+
"-testing.db",
25+
"-testing.toml",
26+
"-testing.json",
27+
"_testing_",
28+
)
29+
1130
default_config = """
1231
[server]
1332
hostname = "127.0.0.1"
@@ -29,7 +48,130 @@ def load_config():
2948
return load_config_toml("aw-client", default_config)
3049

3150

32-
def load_local_server_api_key(host: str, port: Union[int, str]) -> Optional[str]:
51+
def _user_data_dir(appname: str) -> str:
52+
return platformdirs.user_data_dir(appname)
53+
54+
55+
def _user_config_dir(appname: str) -> str:
56+
return platformdirs.user_config_dir(appname)
57+
58+
59+
def _user_cache_dir(appname: str) -> str:
60+
return platformdirs.user_cache_dir(appname)
61+
62+
63+
def _is_legacy_testing_filename(name: str) -> bool:
64+
lower = name.lower()
65+
return any(marker in lower for marker in _LEGACY_TESTING_FILENAME_MARKERS)
66+
67+
68+
def _new_testing_root_exists() -> bool:
69+
"""True if any platform dir for ``activitywatch-testing`` already exists.
70+
71+
Must not create directories: existence is the signal that a previous run
72+
already adopted the isolated testing root.
73+
"""
74+
for getter in (_user_data_dir, _user_config_dir, _user_cache_dir):
75+
if os.path.isdir(getter(_TESTING_APPNAME)):
76+
return True
77+
return False
78+
79+
80+
def _legacy_testing_artifacts_exist() -> bool:
81+
"""True if testing data still lives under the shared ``activitywatch`` root."""
82+
roots = (
83+
_user_data_dir(_DEFAULT_APPNAME),
84+
_user_config_dir(_DEFAULT_APPNAME),
85+
_user_cache_dir(_DEFAULT_APPNAME),
86+
)
87+
for root in roots:
88+
if not os.path.isdir(root):
89+
continue
90+
for dirpath, dirnames, filenames in os.walk(root):
91+
for filename in filenames:
92+
if _is_legacy_testing_filename(filename):
93+
return True
94+
# Descend one level (activitywatch/aw-server-rust/...) not further.
95+
if os.path.relpath(dirpath, root) != ".":
96+
dirnames.clear()
97+
return False
98+
99+
100+
def using_legacy_testing_root() -> bool:
101+
"""Whether ``profile=testing`` should stay on the shared ``activitywatch`` root.
102+
103+
Resolution rule (ActivityWatch/activitywatch#1399), identical on python
104+
and rust:
105+
106+
1. If ``activitywatch-testing/`` already exists: use it (new layout).
107+
2. Else if legacy testing artifacts exist in the bare ``activitywatch/``
108+
root: stay in legacy mode (old paths, old filenames).
109+
3. Else (fresh setup): create and use ``activitywatch-testing/``.
110+
"""
111+
if _new_testing_root_exists():
112+
return False
113+
return _legacy_testing_artifacts_exist()
114+
115+
116+
def rust_server_config_candidates(profile: str) -> List[Tuple[str, int]]:
117+
"""Ordered rust config files to read for this profile.
118+
119+
Isolated profile roots (including new-style ``activitywatch-testing/``)
120+
use bare ``config.toml`` — the directory already isolates. Suffixed
121+
``config-testing.toml`` remains only in the legacy shared-root layout.
122+
123+
Lookup does **not** create directories (``get_config_dir`` would, and
124+
creating ``activitywatch-testing/`` would flip the fallback). Testing
125+
tries the other layout second so a python client that already created
126+
the new root still finds a rust server that wrote the key on the
127+
shared root — the regression Erik named on #1399.
128+
"""
129+
testing_new = (
130+
os.path.join(
131+
_user_config_dir(_TESTING_APPNAME), "aw-server-rust", "config.toml"
132+
),
133+
5666,
134+
)
135+
testing_legacy = (
136+
os.path.join(
137+
_user_config_dir(_DEFAULT_APPNAME),
138+
"aw-server-rust",
139+
"config-testing.toml",
140+
),
141+
5666,
142+
)
143+
if profile == TESTING_PROFILE:
144+
if using_legacy_testing_root():
145+
return [testing_legacy, testing_new]
146+
return [testing_new, testing_legacy]
147+
if profile == DEFAULT_PROFILE or not profile:
148+
return [
149+
(
150+
os.path.join(
151+
_user_config_dir(_DEFAULT_APPNAME),
152+
"aw-server-rust",
153+
"config.toml",
154+
),
155+
5600,
156+
)
157+
]
158+
return [
159+
(
160+
os.path.join(
161+
_user_config_dir(f"{_DEFAULT_APPNAME}-{profile}"),
162+
"aw-server-rust",
163+
"config.toml",
164+
),
165+
5600,
166+
)
167+
]
168+
169+
170+
def load_local_server_api_key(
171+
host: str,
172+
port: Union[int, str],
173+
profile: Optional[str] = None,
174+
) -> Optional[str]:
33175
if host not in {"127.0.0.1", "localhost", "::1"}:
34176
return None
35177

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

41-
config_dir = dirs.get_config_dir("aw-server-rust")
42-
candidates = (
43-
("config.toml", 5600),
44-
("config-testing.toml", 5666),
45-
)
183+
if profile is None:
184+
profile = profile_from_env(False)
46185

47-
for filename, default_port in candidates:
48-
config_path = os.path.join(config_dir, filename)
186+
for config_path, default_port in rust_server_config_candidates(profile):
49187
if not os.path.isfile(config_path):
50188
continue
51189

0 commit comments

Comments
 (0)