From 3b1d42457a770ad638ca916f759802c7f7820f5b Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 25 Aug 2026 04:26:24 +0000 Subject: [PATCH 1/2] feat(profile): add --profile flag and port/settings isolation --testing stays as an alias for --profile testing. The profile is exported as AW_PROFILE before config load so aw-core dirs isolate data/config; settings keep the existing -testing filename suffix and named profiles get the same shape. /api/0/info reports profile. The default profile unsets AW_PROFILE rather than setting it to "default", because aw-core suffixes any non-empty value (so AW_PROFILE=default would become activitywatch-default). Part of ActivityWatch/activitywatch#1399. --- Makefile | 2 +- aw_server/api.py | 3 + aw_server/config.py | 26 ++++++- aw_server/main.py | 56 ++++++++++++--- aw_server/profile.py | 104 ++++++++++++++++++++++++++++ aw_server/rest.py | 1 + aw_server/settings.py | 7 +- tests/conftest.py | 18 ++++- tests/test_profile.py | 94 +++++++++++++++++++++++++ tests/test_profile_config.py | 130 +++++++++++++++++++++++++++++++++++ tests/test_server.py | 1 + 11 files changed, 430 insertions(+), 12 deletions(-) create mode 100644 aw_server/profile.py create mode 100644 tests/test_profile.py create mode 100644 tests/test_profile_config.py diff --git a/Makefile b/Makefile index dd7b70f9..2a58b51a 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ test: @# Note that extensive integration tests are also run in the bundle repo, @# for both aw-server and aw-server-rust, but without code coverage. python -c 'import aw_server' - python -m pytest tests/test_server.py + python -m pytest tests/test_server.py tests/test_profile.py tests/test_profile_config.py typecheck: python -m mypy aw_server tests --ignore-missing-imports diff --git a/aw_server/api.py b/aw_server/api.py index 08ebca3b..20319ea0 100644 --- a/aw_server/api.py +++ b/aw_server/api.py @@ -22,6 +22,7 @@ from .__about__ import __version__ from .exceptions import NotFound +from .profile import profile_from_env from .settings import Settings logger = logging.getLogger(__name__) @@ -54,6 +55,7 @@ def __init__(self, db, testing) -> None: self.db = db self.settings = Settings(testing) self.testing = testing + self.profile = profile_from_env(testing=testing) self.last_event = {} # type: dict def get_info(self) -> Dict[str, Any]: @@ -63,6 +65,7 @@ def get_info(self) -> Dict[str, Any]: "version": __version__, "testing": self.testing, "device_id": get_device_id(), + "profile": self.profile, } return payload diff --git a/aw_server/config.py b/aw_server/config.py index aa904317..16fb9426 100644 --- a/aw_server/config.py +++ b/aw_server/config.py @@ -1,5 +1,7 @@ from aw_core.config import load_config_toml +from .profile import DEFAULT_PROFILE, is_testing + default_config = """ [server] host = "localhost" @@ -18,4 +20,26 @@ [server-testing.custom_static] """.strip() -config = load_config_toml("aw-server", default_config) + +def load_config(): + """Load aw-server.toml from the current profile's config dir. + + Must be called *after* ``export_profile`` so aw-core dirs see + ``AW_PROFILE`` and isolate the file from other instances. + """ + return load_config_toml("aw-server", default_config) + + +def config_section(profile: str) -> str: + """TOML section for this profile: ``server`` or ``server-``.""" + return "server" if profile == DEFAULT_PROFILE else f"server-{profile}" + + +def default_port(profile: str) -> int: + """Built-in port: 5666 for testing, 5600 otherwise. + + Named profiles take ``port`` from their own isolated config (the + research build bakes 5667 into that file). There is no hash-to-port + table — a custom profile without a port set collides with default. + """ + return 5666 if is_testing(profile) else 5600 diff --git a/aw_server/main.py b/aw_server/main.py index 9df6a9ea..c4dd5189 100644 --- a/aw_server/main.py +++ b/aw_server/main.py @@ -5,7 +5,13 @@ from aw_datastore import get_storage_methods from . import __version__ -from .config import config +from .config import config_section, default_port, load_config +from .profile import ( + DEFAULT_PROFILE, + export_profile, + is_testing, + resolve_profile, +) from .server import _start logger = logging.getLogger(__name__) @@ -35,6 +41,9 @@ def main(): if settings.testing: logger.info("Will run in testing mode") + if settings.profile != DEFAULT_PROFILE: + logger.info(f"Running with profile: {settings.profile}") + if settings.custom_static: logger.info(f"Using custom_static: {settings.custom_static}") @@ -57,7 +66,13 @@ def parse_settings(): parser.add_argument( "--testing", action="store_true", - help="Run aw-server in testing mode using different ports and database", + help="Run aw-server in testing mode using different ports and database (alias for --profile testing)", + ) + parser.add_argument( + "--profile", + dest="profile", + default=None, + help="Named instance profile (data, config, port and settings are isolated). --testing is an alias for --profile testing.", ) parser.add_argument("--verbose", action="store_true", help="Be chatty.") parser.add_argument( @@ -94,17 +109,42 @@ def parse_settings(): print(__version__) sys.exit(0) + try: + profile = resolve_profile(args.profile, args.testing) + except ValueError as e: + parser.error(str(e)) + # Export before loading config so aw-core dirs isolate this profile. + export_profile(profile) + testing = is_testing(profile) + """ Parse config file """ - configsection = "server" if not args.testing else "server-testing" + config = load_config() + section = config_section(profile) + if section not in config: + if profile not in (DEFAULT_PROFILE,): + logger.warning( + "Profile %s has no [%s] section, falling back to [server] " + "(port %s may collide with the default instance)", + profile, + section, + default_port(profile), + ) + section = "server" settings = argparse.Namespace() - settings.host = config[configsection]["host"] - settings.port = int(config[configsection]["port"]) - settings.storage = config[configsection]["storage"] - settings.cors_origins = config[configsection]["cors_origins"] - settings.custom_static = dict(config[configsection]["custom_static"]) + settings.host = config[section]["host"] + settings.port = int(config[section]["port"]) + settings.storage = config[section]["storage"] + settings.cors_origins = config[section]["cors_origins"] + settings.custom_static = dict(config[section]["custom_static"]) + settings.profile = profile + settings.testing = testing """ If a argument is not none, override the config value """ for key, value in vars(args).items(): + if key in ("testing", "profile"): + # Resolved above; --profile testing must keep testing=True + # even when the raw --testing flag was absent. + continue if value is not None: if key == "custom_static": settings.custom_static = parse_str_to_dict(value) diff --git a/aw_server/profile.py b/aw_server/profile.py new file mode 100644 index 00000000..787512f1 --- /dev/null +++ b/aw_server/profile.py @@ -0,0 +1,104 @@ +"""Profile resolution for aw-server. + +A *profile* names an isolated ActivityWatch instance (data, config, port, +settings). `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 and anything it spawns — without threading a flag through the +datastore, settings, or Flask stack. + +Kept in sync with aw-qt's ``aw_qt/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 aw-core#149 +maps ``AW_PROFILE=testing`` to ``activitywatch-testing``. The rust +isolation branch keeps testing on the bare ``activitywatch`` root so +existing ``sqlite-testing.db`` files are not orphaned. This module follows +the already-merged python dirs contract; unifying the rust testing root +is a follow-up on that isolation PR, not something to special-case here. +""" + +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/aw_server/rest.py b/aw_server/rest.py index 73c473d9..0b2c320f 100644 --- a/aw_server/rest.py +++ b/aw_server/rest.py @@ -66,6 +66,7 @@ def decorator(*args, **kwargs): "version": fields.String(), "testing": fields.Boolean(), "device_id": fields.String(), + "profile": fields.String(), }, ) diff --git a/aw_server/settings.py b/aw_server/settings.py index 3e07b569..65c2971a 100644 --- a/aw_server/settings.py +++ b/aw_server/settings.py @@ -3,10 +3,15 @@ from aw_core.dirs import get_config_dir +from .profile import profile_from_env, profile_suffix + class Settings: def __init__(self, testing: bool): - filename = "settings.json" if not testing else "settings-testing.json" + # Dir isolation (AW_PROFILE) already separates profiles; the filename + # suffix is the pre-profile workaround and stays so --testing still + # finds settings-testing.json. Named profiles get the same shape. + filename = f"settings{profile_suffix(profile_from_env(testing=testing))}.json" self.config_file = Path(get_config_dir("aw-server")) / filename self.load() diff --git a/tests/conftest.py b/tests/conftest.py index ff106870..7e324047 100755 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ import logging +import os import pytest from aw_client import ActivityWatchClient @@ -7,9 +8,24 @@ logging.basicConfig(level=logging.WARN) +@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) + + @pytest.fixture(scope="session") def app(): - return AWFlask("127.0.0.1", testing=True) + # AWFlask does not go through parse_settings(), so a leftover AW_PROFILE + # from the environment (or a prior test) would disagree with testing=True. + old = os.environ.pop("AW_PROFILE", None) + application = AWFlask("127.0.0.1", testing=True) + if old is not None: + os.environ["AW_PROFILE"] = old + return application @pytest.fixture(scope="session") diff --git a/tests/test_profile.py b/tests/test_profile.py new file mode 100644 index 00000000..2ef418de --- /dev/null +++ b/tests/test_profile.py @@ -0,0 +1,94 @@ +"""Unit tests for profile resolution.""" + +import os + +import pytest + +from aw_server.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 00000000..718ca683 --- /dev/null +++ b/tests/test_profile_config.py @@ -0,0 +1,130 @@ +"""Port, settings, and CLI wiring for named profiles.""" + +import os +import sys + +import pytest + +from aw_server.config import config_section, default_port +from aw_server.main import parse_settings +from aw_server.profile import DEFAULT_PROFILE, TESTING_PROFILE, export_profile +from aw_server.settings import Settings + + +@pytest.fixture +def xdg_tmp(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) + return tmp_path + + +class TestConfigHelpers: + def test_sections_are_disjoint(self): + sections = {config_section(p) for p in ("default", "testing", "research")} + assert sections == {"server", "server-testing", "server-research"} + + def test_default_ports(self): + assert default_port(DEFAULT_PROFILE) == 5600 + assert default_port(TESTING_PROFILE) == 5666 + assert default_port("research") == 5600 + + +class TestSettingsFilename: + def test_testing_keeps_legacy_name(self, tmp_path, monkeypatch): + monkeypatch.setattr( + "aw_server.settings.get_config_dir", lambda module: str(tmp_path) + ) + monkeypatch.delenv("AW_PROFILE", raising=False) + settings = Settings(True) + assert settings.config_file.name == "settings-testing.json" + + def test_default_unsuffixed(self, tmp_path, monkeypatch): + monkeypatch.setattr( + "aw_server.settings.get_config_dir", lambda module: str(tmp_path) + ) + monkeypatch.delenv("AW_PROFILE", raising=False) + settings = Settings(False) + assert settings.config_file.name == "settings.json" + + def test_named_profile_suffix(self, tmp_path, monkeypatch): + monkeypatch.setattr( + "aw_server.settings.get_config_dir", lambda module: str(tmp_path) + ) + monkeypatch.setenv("AW_PROFILE", "research") + settings = Settings(False) + assert settings.config_file.name == "settings-research.json" + + +class TestParseSettings: + def test_testing_flag_selects_testing_port(self, xdg_tmp, monkeypatch): + monkeypatch.setattr(sys, "argv", ["aw-server", "--testing"]) + settings, _storage = parse_settings() + assert settings.testing is True + assert settings.profile == TESTING_PROFILE + assert settings.port == 5666 + assert os.environ["AW_PROFILE"] == "testing" + + def test_profile_testing_is_alias_for_testing_flag(self, xdg_tmp, monkeypatch): + monkeypatch.setattr(sys, "argv", ["aw-server", "--profile", "testing"]) + settings, _storage = parse_settings() + assert settings.testing is True + assert settings.profile == TESTING_PROFILE + assert settings.port == 5666 + + def test_default_keeps_port_5600_and_unsets_env(self, xdg_tmp, monkeypatch): + monkeypatch.setenv("AW_PROFILE", "research") + monkeypatch.setattr(sys, "argv", ["aw-server"]) + settings, _storage = parse_settings() + assert settings.testing is False + assert settings.profile == DEFAULT_PROFILE + assert settings.port == 5600 + assert "AW_PROFILE" not in os.environ + + def test_named_profile_exports_env_and_falls_back_to_server_section( + self, xdg_tmp, monkeypatch + ): + monkeypatch.setattr(sys, "argv", ["aw-server", "--profile", "research"]) + settings, _storage = parse_settings() + assert settings.testing is False + assert settings.profile == "research" + assert settings.port == 5600 + assert os.environ["AW_PROFILE"] == "research" + + def test_cli_port_override(self, xdg_tmp, monkeypatch): + monkeypatch.setattr( + sys, "argv", ["aw-server", "--profile", "research", "--port", "5667"] + ) + settings, _storage = parse_settings() + assert settings.port == 5667 + assert settings.profile == "research" + + def test_conflicting_flags_are_a_usage_error(self, xdg_tmp, monkeypatch): + monkeypatch.setattr( + sys, "argv", ["aw-server", "--testing", "--profile", "research"] + ) + with pytest.raises(SystemExit): + parse_settings() + + def test_invalid_profile_is_a_usage_error(self, xdg_tmp, monkeypatch): + monkeypatch.setattr(sys, "argv", ["aw-server", "--profile", "Research"]) + with pytest.raises(SystemExit): + parse_settings() + + +def test_named_profile_config_is_isolated_from_default(xdg_tmp, monkeypatch): + """AW_PROFILE must be exported before load_config, else both profiles + share ~/.config/activitywatch/aw-server.""" + import aw_core.dirs as dirs + + if not hasattr(dirs, "_get_appname"): + pytest.skip("aw-core < 0.5.17 does not suffix dirs by AW_PROFILE") + + export_profile("research") + research_dir = dirs.get_config_dir("aw-server") + export_profile(DEFAULT_PROFILE) + default_dir = dirs.get_config_dir("aw-server") + assert research_dir != default_dir + assert "activitywatch-research" in research_dir + assert "activitywatch-research" not in default_dir diff --git a/tests/test_server.py b/tests/test_server.py index f35f9c89..3549f7ea 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -24,6 +24,7 @@ def test_info(flask_client): r = flask_client.get("/api/0/info") assert r.status_code == 200 assert r.json["testing"] + assert r.json["profile"] == "testing" def test_buckets(flask_client, bucket, benchmark): From b9f6a30200b9fb338527fc8bba1bdaaa3213575e Mon Sep 17 00:00:00 2001 From: Bob Date: Tue, 25 Aug 2026 04:39:11 +0000 Subject: [PATCH 2/2] chore: lock aw-core 0.5.17 (latest PyPI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profile-aware dirs from ActivityWatch/aw-core#149 are on git master but not on PyPI — 0.5.17 was cut in 2024. AW_PROFILE export is a no-op until the next aw-core release; this lock bump is just current PyPI. --- poetry.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index be877b73..3ab617e1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -56,19 +56,19 @@ typing-extensions = "*" [[package]] name = "aw-core" -version = "0.5.16" +version = "0.5.17" description = "Core library for ActivityWatch" optional = false -python-versions = ">=3.8,<4.0" +python-versions = "<4.0,>=3.8" groups = ["main"] files = [ - {file = "aw_core-0.5.16-py3-none-any.whl", hash = "sha256:3b8186081792ebc0a7efe5793e2951a220c71b261e1fbb289753cd2eebe70cb4"}, - {file = "aw_core-0.5.16.tar.gz", hash = "sha256:b1089f6d976ad96648d496b50885b91c85166cd054c14bafc7ed01c967ea465f"}, + {file = "aw_core-0.5.17-py3-none-any.whl", hash = "sha256:8c3dae7fddd23984f7711e4ff7933ba45ce25e5e416fdde4a9f1677e95c64feb"}, + {file = "aw_core-0.5.17.tar.gz", hash = "sha256:f8ac5418d6a1de2b868bc75447d4d495aebc577a77eea56ba11dc2bde3771e27"}, ] [package.dependencies] deprecation = "*" -iso8601 = ">=1.0.2,<2.0.0" +iso8601 = "*" jsonschema = ">=4.3,<5.0" peewee = "==3.*" platformdirs = "3.10"