Skip to content
Merged
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
29 changes: 24 additions & 5 deletions aw_core/dirs.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,25 @@
GetDirFunc = Callable[[Optional[str]], str]


def _get_appname() -> str:
"""Return the platformdirs appname, optionally suffixed by the active profile.

If the ``AW_PROFILE`` environment variable is set to a non-empty string the
appname becomes ``activitywatch-<profile>`` so that *all* platform
directories (data, config, cache, log) are completely separate from the
default profile. An unset or empty ``AW_PROFILE`` returns the bare
``"activitywatch"`` name, which is identical to the pre-profile behaviour.

This is the single authoritative place where profile isolation is applied.
Every module that uses :func:`get_data_dir`, :func:`get_config_dir`,
:func:`get_cache_dir` or :func:`get_log_dir` automatically inherits the
correct root for the running profile; no ``profile=`` parameter needs to be
threaded through the call chain.
"""
profile = os.environ.get("AW_PROFILE", "")
return f"activitywatch-{profile}" if profile else "activitywatch"


def ensure_path_exists(path: str) -> None:
if not os.path.exists(path):
os.makedirs(path)
Expand All @@ -25,19 +44,19 @@ def wrapper(subpath: Optional[str] = None) -> str:

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


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


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


Expand All @@ -47,7 +66,7 @@ def get_log_dir(module_name: Optional[str] = None) -> str: # pragma: no cover
# we want to keep using XDG_DATA_HOME for backwards compatibility
# https://github.com/ActivityWatch/aw-core/pull/122#issuecomment-1768020335
if sys.platform.startswith("linux"):
log_dir = platformdirs.user_cache_path("activitywatch") / "log"
log_dir = platformdirs.user_cache_path(_get_appname()) / "log"
else:
log_dir = platformdirs.user_log_dir("activitywatch")
log_dir = platformdirs.user_log_dir(_get_appname())
return os.path.join(log_dir, module_name) if module_name else log_dir
93 changes: 93 additions & 0 deletions tests/test_dirs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Tests for profile isolation via AW_PROFILE in aw_core.dirs.

Three profiles (default / testing / research) must yield completely disjoint
directory roots so that an ActivityWatch research build cannot read from or
write to a participant's personal datastore.
"""

import os
from unittest.mock import patch

from aw_core.dirs import _get_appname, get_cache_dir, get_config_dir, get_data_dir

from . import context # noqa: F401


# ---------------------------------------------------------------------------
# _get_appname
# ---------------------------------------------------------------------------


class TestGetAppname:
def test_unset_returns_bare_name(self):
"""AW_PROFILE absent → bare 'activitywatch', identical to legacy."""
with patch.dict(os.environ, {"AW_PROFILE": ""}):
assert _get_appname() == "activitywatch"

def test_empty_string_returns_bare_name(self):
"""AW_PROFILE='' is treated the same as unset."""
with patch.dict(os.environ, {"AW_PROFILE": ""}):
assert _get_appname() == "activitywatch"

def test_testing_profile(self):
with patch.dict(os.environ, {"AW_PROFILE": "testing"}):
assert _get_appname() == "activitywatch-testing"

def test_research_profile(self):
with patch.dict(os.environ, {"AW_PROFILE": "research"}):
assert _get_appname() == "activitywatch-research"

def test_arbitrary_profile(self):
with patch.dict(os.environ, {"AW_PROFILE": "myproject"}):
assert _get_appname() == "activitywatch-myproject"


# ---------------------------------------------------------------------------
# Directory isolation
# ---------------------------------------------------------------------------


def _data_dir(profile: str) -> str:
"""Return get_data_dir() under the given profile (empty string = default)."""
with patch.dict(os.environ, {"AW_PROFILE": profile}):
return get_data_dir()


class TestDirsIsolation:
"""Three profiles must yield fully disjoint directory roots."""

def test_default_has_no_profile_suffix(self):
d = _data_dir("")
assert "activitywatch" in d
# The bare appname must not contain a dash after "activitywatch"
assert "activitywatch-" not in d

def test_testing_suffix_present(self):
assert "activitywatch-testing" in _data_dir("testing")

def test_research_suffix_present(self):
assert "activitywatch-research" in _data_dir("research")

def test_three_profiles_are_disjoint(self):
"""default, testing, research all produce distinct, non-nested paths."""
default = _data_dir("")
testing = _data_dir("testing")
research = _data_dir("research")

dirs = {default, testing, research}
assert len(dirs) == 3, f"Profiles are not disjoint: {dirs}"

for a in dirs:
for b in dirs:
if a != b:
assert not a.startswith(b + os.sep), f"{a!r} is a subpath of {b!r}"

def test_config_dir_isolated(self):
with patch.dict(os.environ, {"AW_PROFILE": "research"}):
cfg = get_config_dir()
assert "activitywatch-research" in cfg

def test_cache_dir_isolated(self):
with patch.dict(os.environ, {"AW_PROFILE": "testing"}):
cache = get_cache_dir()
assert "activitywatch-testing" in cache
Loading