Skip to content

Commit 11f17e7

Browse files
feat(dirs): profile isolation via AW_PROFILE appname suffix (#149)
When AW_PROFILE is set, all platform directories (data, config, cache, log) are rooted under activitywatch-<profile> instead of activitywatch. This gives every profile a completely separate on-disk tree — identical to the Chrome/Firefox model suggested in ActivityWatch/activitywatch#1399. Mechanism: _get_appname() reads AW_PROFILE at call time and passes it to every platformdirs call. No profile parameter needs to be threaded through the datastore or log layers because those already use get_data_dir() and get_log_dir() internally. Backwards compatibility: - Unset or empty AW_PROFILE → bare 'activitywatch' appname, identical to today. - The existing -testing/-research file suffixes in the datastore layer are redundant under full directory isolation but harmless to leave in place. Part of ActivityWatch/activitywatch#1399 (aw-core layer).
1 parent 34198c6 commit 11f17e7

2 files changed

Lines changed: 117 additions & 5 deletions

File tree

aw_core/dirs.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,25 @@
88
GetDirFunc = Callable[[Optional[str]], str]
99

1010

11+
def _get_appname() -> str:
12+
"""Return the platformdirs appname, optionally suffixed by the active profile.
13+
14+
If the ``AW_PROFILE`` environment variable is set to a non-empty string the
15+
appname becomes ``activitywatch-<profile>`` so that *all* platform
16+
directories (data, config, cache, log) are completely separate from the
17+
default profile. An unset or empty ``AW_PROFILE`` returns the bare
18+
``"activitywatch"`` name, which is identical to the pre-profile behaviour.
19+
20+
This is the single authoritative place where profile isolation is applied.
21+
Every module that uses :func:`get_data_dir`, :func:`get_config_dir`,
22+
:func:`get_cache_dir` or :func:`get_log_dir` automatically inherits the
23+
correct root for the running profile; no ``profile=`` parameter needs to be
24+
threaded through the call chain.
25+
"""
26+
profile = os.environ.get("AW_PROFILE", "")
27+
return f"activitywatch-{profile}" if profile else "activitywatch"
28+
29+
1130
def ensure_path_exists(path: str) -> None:
1231
if not os.path.exists(path):
1332
os.makedirs(path)
@@ -25,19 +44,19 @@ def wrapper(subpath: Optional[str] = None) -> str:
2544

2645
@_ensure_returned_path_exists
2746
def get_data_dir(module_name: Optional[str] = None) -> str:
28-
data_dir = platformdirs.user_data_dir("activitywatch")
47+
data_dir = platformdirs.user_data_dir(_get_appname())
2948
return os.path.join(data_dir, module_name) if module_name else data_dir
3049

3150

3251
@_ensure_returned_path_exists
3352
def get_cache_dir(module_name: Optional[str] = None) -> str:
34-
cache_dir = platformdirs.user_cache_dir("activitywatch")
53+
cache_dir = platformdirs.user_cache_dir(_get_appname())
3554
return os.path.join(cache_dir, module_name) if module_name else cache_dir
3655

3756

3857
@_ensure_returned_path_exists
3958
def get_config_dir(module_name: Optional[str] = None) -> str:
40-
config_dir = platformdirs.user_config_dir("activitywatch")
59+
config_dir = platformdirs.user_config_dir(_get_appname())
4160
return os.path.join(config_dir, module_name) if module_name else config_dir
4261

4362

@@ -47,7 +66,7 @@ def get_log_dir(module_name: Optional[str] = None) -> str: # pragma: no cover
4766
# we want to keep using XDG_DATA_HOME for backwards compatibility
4867
# https://github.com/ActivityWatch/aw-core/pull/122#issuecomment-1768020335
4968
if sys.platform.startswith("linux"):
50-
log_dir = platformdirs.user_cache_path("activitywatch") / "log"
69+
log_dir = platformdirs.user_cache_path(_get_appname()) / "log"
5170
else:
52-
log_dir = platformdirs.user_log_dir("activitywatch")
71+
log_dir = platformdirs.user_log_dir(_get_appname())
5372
return os.path.join(log_dir, module_name) if module_name else log_dir

tests/test_dirs.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Tests for profile isolation via AW_PROFILE in aw_core.dirs.
2+
3+
Three profiles (default / testing / research) must yield completely disjoint
4+
directory roots so that an ActivityWatch research build cannot read from or
5+
write to a participant's personal datastore.
6+
"""
7+
8+
import os
9+
from unittest.mock import patch
10+
11+
from aw_core.dirs import _get_appname, get_cache_dir, get_config_dir, get_data_dir
12+
13+
from . import context # noqa: F401
14+
15+
16+
# ---------------------------------------------------------------------------
17+
# _get_appname
18+
# ---------------------------------------------------------------------------
19+
20+
21+
class TestGetAppname:
22+
def test_unset_returns_bare_name(self):
23+
"""AW_PROFILE absent → bare 'activitywatch', identical to legacy."""
24+
with patch.dict(os.environ, {"AW_PROFILE": ""}):
25+
assert _get_appname() == "activitywatch"
26+
27+
def test_empty_string_returns_bare_name(self):
28+
"""AW_PROFILE='' is treated the same as unset."""
29+
with patch.dict(os.environ, {"AW_PROFILE": ""}):
30+
assert _get_appname() == "activitywatch"
31+
32+
def test_testing_profile(self):
33+
with patch.dict(os.environ, {"AW_PROFILE": "testing"}):
34+
assert _get_appname() == "activitywatch-testing"
35+
36+
def test_research_profile(self):
37+
with patch.dict(os.environ, {"AW_PROFILE": "research"}):
38+
assert _get_appname() == "activitywatch-research"
39+
40+
def test_arbitrary_profile(self):
41+
with patch.dict(os.environ, {"AW_PROFILE": "myproject"}):
42+
assert _get_appname() == "activitywatch-myproject"
43+
44+
45+
# ---------------------------------------------------------------------------
46+
# Directory isolation
47+
# ---------------------------------------------------------------------------
48+
49+
50+
def _data_dir(profile: str) -> str:
51+
"""Return get_data_dir() under the given profile (empty string = default)."""
52+
with patch.dict(os.environ, {"AW_PROFILE": profile}):
53+
return get_data_dir()
54+
55+
56+
class TestDirsIsolation:
57+
"""Three profiles must yield fully disjoint directory roots."""
58+
59+
def test_default_has_no_profile_suffix(self):
60+
d = _data_dir("")
61+
assert "activitywatch" in d
62+
# The bare appname must not contain a dash after "activitywatch"
63+
assert "activitywatch-" not in d
64+
65+
def test_testing_suffix_present(self):
66+
assert "activitywatch-testing" in _data_dir("testing")
67+
68+
def test_research_suffix_present(self):
69+
assert "activitywatch-research" in _data_dir("research")
70+
71+
def test_three_profiles_are_disjoint(self):
72+
"""default, testing, research all produce distinct, non-nested paths."""
73+
default = _data_dir("")
74+
testing = _data_dir("testing")
75+
research = _data_dir("research")
76+
77+
dirs = {default, testing, research}
78+
assert len(dirs) == 3, f"Profiles are not disjoint: {dirs}"
79+
80+
for a in dirs:
81+
for b in dirs:
82+
if a != b:
83+
assert not a.startswith(b + os.sep), f"{a!r} is a subpath of {b!r}"
84+
85+
def test_config_dir_isolated(self):
86+
with patch.dict(os.environ, {"AW_PROFILE": "research"}):
87+
cfg = get_config_dir()
88+
assert "activitywatch-research" in cfg
89+
90+
def test_cache_dir_isolated(self):
91+
with patch.dict(os.environ, {"AW_PROFILE": "testing"}):
92+
cache = get_cache_dir()
93+
assert "activitywatch-testing" in cache

0 commit comments

Comments
 (0)