diff --git a/Makefile b/Makefile index c4d38ce..b59e751 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/aw_client/cli.py b/aw_client/cli.py index 724bbc7..62e4089 100755 --- a/aw_client/cli.py +++ b/aw_client/cli.py @@ -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", @@ -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) diff --git a/aw_client/client.py b/aw_client/client.py index f176982..8cd7750 100644 --- a/aw_client/client.py +++ b/aw_client/client.py @@ -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 @@ -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. @@ -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( @@ -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 @@ -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 @@ -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 diff --git a/aw_client/config.py b/aw_client/config.py index 8096d09..b87851f 100644 --- a/aw_client/config.py +++ b/aw_client/config.py @@ -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" @@ -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 @@ -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 diff --git a/aw_client/profile.py b/aw_client/profile.py new file mode 100644 index 0000000..e0efad7 --- /dev/null +++ b/aw_client/profile.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..98a8abc --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +import os + +import pytest + + +@pytest.fixture(autouse=True) +def _clear_aw_profile_after_test(): + """export_profile() writes os.environ directly; monkeypatch.delenv does + not record an undo when the var was already unset, so later tests would + inherit a leftover profile.""" + yield + os.environ.pop("AW_PROFILE", None) diff --git a/tests/test_auth.py b/tests/test_auth.py index 1be3816..c71b3ea 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -23,7 +23,18 @@ def write_server_config(tmp_path, filename: str, content: str) -> None: def test_load_local_server_api_key_matches_port(tmp_path, monkeypatch): - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + monkeypatch.setattr( + "aw_client.config._user_config_dir", + lambda appname: str(tmp_path / appname), + ) + monkeypatch.setattr( + "aw_client.config._user_data_dir", + lambda appname: str(tmp_path / "data" / appname), + ) + monkeypatch.setattr( + "aw_client.config._user_cache_dir", + lambda appname: str(tmp_path / "cache" / appname), + ) write_server_config( tmp_path, "config.toml", @@ -36,10 +47,15 @@ def test_load_local_server_api_key_matches_port(tmp_path, monkeypatch): ) assert load_local_server_api_key("127.0.0.1", 5601) == "secret123" - assert load_local_server_api_key("localhost", "5666") == "testing-secret" + assert ( + load_local_server_api_key("localhost", "5666", profile="testing") + == "testing-secret" + ) assert load_local_server_api_key("::1", 5601) == "secret123" assert load_local_server_api_key("127.0.0.1", 5600) is None assert load_local_server_api_key("example.com", 5601) is None + # Default profile must not pick up the testing server's key. + assert load_local_server_api_key("localhost", "5666") is None def test_client_sends_authorization_header_for_local_server(tmp_path, monkeypatch): diff --git a/tests/test_profile.py b/tests/test_profile.py new file mode 100644 index 0000000..a52ebc8 --- /dev/null +++ b/tests/test_profile.py @@ -0,0 +1,94 @@ +"""Unit tests for profile resolution.""" + +import os + +import pytest + +from aw_client.profile import ( + DEFAULT_PROFILE, + TESTING_PROFILE, + export_profile, + is_testing, + profile_from_env, + profile_suffix, + resolve_profile, + validate_profile, +) + + +class TestResolveProfile: + def test_no_flags_gives_default(self): + assert resolve_profile(None, False) == DEFAULT_PROFILE + + def test_testing_flag_is_an_alias(self): + assert resolve_profile(None, True) == TESTING_PROFILE + + def test_explicit_profile_wins(self): + assert resolve_profile("research", False) == "research" + + def test_testing_with_matching_profile_is_allowed(self): + assert resolve_profile("testing", True) == TESTING_PROFILE + + def test_testing_with_conflicting_profile_raises(self): + with pytest.raises(ValueError, match="conflicts"): + resolve_profile("research", True) + + @pytest.mark.parametrize( + "name", ["Research", "with space", "a" * 33, "", "../escape", "-leading"] + ) + def test_invalid_names_rejected(self, name): + with pytest.raises(ValueError): + validate_profile(name) + + @pytest.mark.parametrize("name", ["research", "aw2", "my_profile", "my-profile"]) + def test_valid_names_accepted(self, name): + assert validate_profile(name) == name + + +class TestProfileSuffix: + def test_default_has_no_suffix(self): + assert profile_suffix(DEFAULT_PROFILE) == "" + + def test_testing_keeps_legacy_suffix(self): + assert profile_suffix(TESTING_PROFILE) == "-testing" + + def test_custom_profile_suffix(self): + assert profile_suffix("research") == "-research" + + def test_suffixes_are_disjoint(self): + suffixes = {profile_suffix(p) for p in ("default", "testing", "research")} + assert len(suffixes) == 3 + + +class TestIsTesting: + def test_only_testing_profile_is_testing(self): + assert is_testing(TESTING_PROFILE) + assert not is_testing(DEFAULT_PROFILE) + assert not is_testing("research") + + +class TestExportProfile: + def test_exported_for_child_processes(self, monkeypatch): + monkeypatch.delenv("AW_PROFILE", raising=False) + export_profile("research") + assert os.environ["AW_PROFILE"] == "research" + + def test_default_unsets_env_so_aw_core_keeps_bare_root(self, monkeypatch): + monkeypatch.setenv("AW_PROFILE", "research") + export_profile(DEFAULT_PROFILE) + assert "AW_PROFILE" not in os.environ + + +class TestProfileFromEnv: + def test_defaults_when_unset(self, monkeypatch): + monkeypatch.delenv("AW_PROFILE", raising=False) + assert profile_from_env(False) == DEFAULT_PROFILE + assert profile_from_env(True) == TESTING_PROFILE + + def test_reads_exported_profile(self, monkeypatch): + monkeypatch.setenv("AW_PROFILE", "research") + assert profile_from_env(False) == "research" + + def test_ignores_invalid_env_value(self, monkeypatch): + monkeypatch.setenv("AW_PROFILE", "Not A Profile") + assert profile_from_env(False) == DEFAULT_PROFILE diff --git a/tests/test_profile_config.py b/tests/test_profile_config.py new file mode 100644 index 0000000..045368b --- /dev/null +++ b/tests/test_profile_config.py @@ -0,0 +1,294 @@ +"""Client constructor, config sections, persistqueue, and rust api-key lookup.""" + +import logging +import os +from pathlib import Path + +import pytest + +from aw_client import ActivityWatchClient +from aw_client import client as client_module +from aw_client.config import load_local_server_api_key, rust_server_config_candidates +from aw_client.profile import DEFAULT_PROFILE, TESTING_PROFILE + + +@pytest.fixture +def isolated_dirs(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache")) + monkeypatch.delenv("AW_PROFILE", raising=False) + monkeypatch.setattr(client_module, "SingleInstance", lambda name: object()) + return tmp_path + + +class TestClientConstructor: + def test_testing_alias_uses_testing_port_and_exports_env(self, isolated_dirs): + client = ActivityWatchClient("t", testing=True) + assert client.profile == TESTING_PROFILE + assert client.testing is True + assert client.server_address.endswith(":5666") + assert os.environ["AW_PROFILE"] == "testing" + + def test_explicit_profile_testing_is_the_same(self, isolated_dirs): + client = ActivityWatchClient("t", profile="testing") + assert client.profile == TESTING_PROFILE + assert client.testing is True + assert client.server_address.endswith(":5666") + + def test_default_unsets_env_and_uses_5600(self, isolated_dirs, monkeypatch): + monkeypatch.setenv("AW_PROFILE", "research") + client = ActivityWatchClient("t", profile="default") + assert client.profile == DEFAULT_PROFILE + assert client.testing is False + assert client.server_address.endswith(":5600") + assert "AW_PROFILE" not in os.environ + + def test_named_profile_falls_back_to_server_section(self, isolated_dirs, caplog): + with caplog.at_level(logging.WARNING, logger="aw_client.client"): + client = ActivityWatchClient("t", profile="research") + assert client.profile == "research" + assert client.testing is False + assert client.server_address.endswith(":5600") + assert os.environ["AW_PROFILE"] == "research" + assert any( + "falling back to [server]" in rec.message + and "may collide with the default instance" in rec.message + for rec in caplog.records + ) + + def test_env_from_launcher_is_used_when_no_flags(self, isolated_dirs, monkeypatch): + monkeypatch.setenv("AW_PROFILE", "research") + client = ActivityWatchClient("t") + assert client.profile == "research" + assert os.environ["AW_PROFILE"] == "research" + + def test_explicit_testing_overrides_stale_env(self, isolated_dirs, monkeypatch): + monkeypatch.setenv("AW_PROFILE", "research") + client = ActivityWatchClient("t", testing=True) + assert client.profile == TESTING_PROFILE + assert os.environ["AW_PROFILE"] == "testing" + + def test_host_and_port_kwargs_still_win(self, isolated_dirs): + client = ActivityWatchClient( + "t", profile="research", host="127.0.0.1", port=5667 + ) + assert client.server_address == "http://127.0.0.1:5667" + + def test_conflicting_flags_raise(self, isolated_dirs): + with pytest.raises(ValueError, match="conflicts"): + ActivityWatchClient("t", testing=True, profile="research") + + +class TestPersistqueueSuffix: + def test_testing_keeps_legacy_suffix(self, isolated_dirs): + client = ActivityWatchClient("aw-test-client", testing=True) + assert "aw-test-client-testing." in client.request_queue.persistqueue_path + + def test_named_profile_suffix_is_disjoint(self, isolated_dirs): + testing = ActivityWatchClient("aw-test-client", testing=True) + research = ActivityWatchClient("aw-test-client", profile="research") + assert ( + testing.request_queue.persistqueue_path + != research.request_queue.persistqueue_path + ) + assert "aw-test-client-research." in research.request_queue.persistqueue_path + + def test_reconnect_preserves_named_profile_queue_path( + self, isolated_dirs, monkeypatch + ): + def profile_data_dir(module): + profile = os.environ.get("AW_PROFILE") + root = "activitywatch" if profile is None else f"activitywatch-{profile}" + return str(isolated_dirs / root / module) + + monkeypatch.setattr(client_module, "get_data_dir", profile_data_dir) + research = ActivityWatchClient("aw-test-client", profile="research") + original_path = research.request_queue.persistqueue_path + monkeypatch.setattr(research.request_queue, "_try_connect", lambda: True) + research.request_queue.connected = True + research.connect() + + default = ActivityWatchClient("other-client", profile="default") + assert default.request_queue.persistqueue_path != original_path + research.disconnect() + + assert research.request_queue.persistqueue_path == original_path + + +@pytest.fixture +def fake_platform_dirs(tmp_path, monkeypatch): + """Point rust-config lookup at a tmp tree. platformdirs on macOS/Windows + ignores XDG_* even when set, so patch the wrappers rather than env vars. + """ + data = tmp_path / "data" + config = tmp_path / "config" + cache = tmp_path / "cache" + + def _join(root: Path, appname: str) -> str: + return str(root / appname) + + monkeypatch.setattr( + "aw_client.config._user_data_dir", lambda appname: _join(data, appname) + ) + monkeypatch.setattr( + "aw_client.config._user_config_dir", + lambda appname: _join(config, appname), + ) + monkeypatch.setattr( + "aw_client.config._user_cache_dir", + lambda appname: _join(cache, appname), + ) + monkeypatch.delenv("AW_PROFILE", raising=False) + return {"data": data, "config": config, "cache": cache, "root": tmp_path} + + +def _write_rust_config( + config_root: Path, appname: str, filename: str, content: str +) -> Path: + rust_dir = config_root / appname / "aw-server-rust" + rust_dir.mkdir(parents=True, exist_ok=True) + path = rust_dir / filename + path.write_text(content) + return path + + +def test_load_local_server_api_key_named_profile(fake_platform_dirs): + config = fake_platform_dirs["config"] + _write_rust_config( + config, + "activitywatch-research", + "config.toml", + 'port = 5667\n\n[auth]\napi_key = "research-secret"\n', + ) + _write_rust_config( + config, + "activitywatch", + "config.toml", + 'port = 5600\n\n[auth]\napi_key = "default-secret"\n', + ) + # Suffixed names in the isolated root must not be read — dir isolation + # is the point (ActivityWatch/activitywatch#1399). + _write_rust_config( + config, + "activitywatch-research", + "config-research.toml", + 'port = 5667\n\n[auth]\napi_key = "suffixed-must-be-ignored"\n', + ) + assert ( + load_local_server_api_key("127.0.0.1", 5667, profile="research") + == "research-secret" + ) + assert load_local_server_api_key("127.0.0.1", 5600, profile="default") == ( + "default-secret" + ) + assert load_local_server_api_key("127.0.0.1", 5600, profile="research") is None + + +class TestTestingRootApiKeyLookup: + """Rust API-key lookup follows the #1399 testing-root rule.""" + + def test_fresh_setup_reads_bare_config_in_new_root(self, fake_platform_dirs): + _write_rust_config( + fake_platform_dirs["config"], + "activitywatch-testing", + "config.toml", + 'port = 5666\n\n[auth]\napi_key = "new-root-secret"\n', + ) + assert ( + load_local_server_api_key("127.0.0.1", 5666, profile="testing") + == "new-root-secret" + ) + paths = [p for p, _ in rust_server_config_candidates("testing")] + assert paths[0].endswith( + os.path.join("activitywatch-testing", "aw-server-rust", "config.toml") + ) + + def test_legacy_artifacts_keep_suffixed_shared_root(self, fake_platform_dirs): + _write_rust_config( + fake_platform_dirs["config"], + "activitywatch", + "config-testing.toml", + 'port = 5666\n\n[auth]\napi_key = "legacy-secret"\n', + ) + assert ( + load_local_server_api_key("127.0.0.1", 5666, profile="testing") + == "legacy-secret" + ) + paths = [p for p, _ in rust_server_config_candidates("testing")] + assert paths[0].endswith( + os.path.join("activitywatch", "aw-server-rust", "config-testing.toml") + ) + + def test_new_root_wins_over_legacy_artifacts(self, fake_platform_dirs): + _write_rust_config( + fake_platform_dirs["config"], + "activitywatch", + "config-testing.toml", + 'port = 5666\n\n[auth]\napi_key = "legacy-secret"\n', + ) + _write_rust_config( + fake_platform_dirs["config"], + "activitywatch-testing", + "config.toml", + 'port = 5666\n\n[auth]\napi_key = "new-root-secret"\n', + ) + assert ( + load_local_server_api_key("127.0.0.1", 5666, profile="testing") + == "new-root-secret" + ) + + def test_empty_new_root_still_finds_legacy_key(self, fake_platform_dirs): + """Python creating activitywatch-testing/ must not hide rust's legacy key.""" + (fake_platform_dirs["config"] / "activitywatch-testing").mkdir(parents=True) + _write_rust_config( + fake_platform_dirs["config"], + "activitywatch", + "config-testing.toml", + 'port = 5666\n\n[auth]\napi_key = "legacy-secret"\n', + ) + assert ( + load_local_server_api_key("127.0.0.1", 5666, profile="testing") + == "legacy-secret" + ) + + +class TestCliPortOverride: + def test_explicit_port_5600_is_not_discarded(self, monkeypatch): + captured = {} + + class FakeClient: + def __init__(self, **kwargs): + captured.update(kwargs) + + def get_buckets(self): + return {} + + monkeypatch.setattr("aw_client.cli.aw_client.ActivityWatchClient", FakeClient) + from click.testing import CliRunner + + from aw_client.cli import main + + result = CliRunner().invoke(main, ["--port", "5600", "buckets"]) + assert result.exit_code == 0, result.output + assert captured["port"] == 5600 + + def test_omitted_port_lets_profile_config_win(self, monkeypatch): + captured = {} + + class FakeClient: + def __init__(self, **kwargs): + captured.update(kwargs) + + def get_buckets(self): + return {} + + monkeypatch.setattr("aw_client.cli.aw_client.ActivityWatchClient", FakeClient) + from click.testing import CliRunner + + from aw_client.cli import main + + result = CliRunner().invoke(main, ["--testing", "buckets"]) + assert result.exit_code == 0, result.output + assert captured["port"] is None + assert captured["testing"] is True