Skip to content

Commit 088b8f4

Browse files
feat(profile): add --profile flag and port/settings isolation (#167)
* 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. * chore: lock aw-core 0.5.17 (latest PyPI) 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.
1 parent a693cac commit 088b8f4

12 files changed

Lines changed: 435 additions & 17 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ test:
2424
@# Note that extensive integration tests are also run in the bundle repo,
2525
@# for both aw-server and aw-server-rust, but without code coverage.
2626
python -c 'import aw_server'
27-
python -m pytest tests/test_server.py
27+
python -m pytest tests/test_server.py tests/test_profile.py tests/test_profile_config.py
2828

2929
typecheck:
3030
python -m mypy aw_server tests --ignore-missing-imports

aw_server/api.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
from .__about__ import __version__
2424
from .exceptions import NotFound
25+
from .profile import profile_from_env
2526
from .settings import Settings
2627

2728
logger = logging.getLogger(__name__)
@@ -54,6 +55,7 @@ def __init__(self, db, testing) -> None:
5455
self.db = db
5556
self.settings = Settings(testing)
5657
self.testing = testing
58+
self.profile = profile_from_env(testing=testing)
5759
self.last_event = {} # type: dict
5860

5961
def get_info(self) -> Dict[str, Any]:
@@ -63,6 +65,7 @@ def get_info(self) -> Dict[str, Any]:
6365
"version": __version__,
6466
"testing": self.testing,
6567
"device_id": get_device_id(),
68+
"profile": self.profile,
6669
}
6770
return payload
6871

aw_server/config.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from aw_core.config import load_config_toml
22

3+
from .profile import DEFAULT_PROFILE, is_testing
4+
35
default_config = """
46
[server]
57
host = "localhost"
@@ -18,4 +20,26 @@
1820
[server-testing.custom_static]
1921
""".strip()
2022

21-
config = load_config_toml("aw-server", default_config)
23+
24+
def load_config():
25+
"""Load aw-server.toml from the current profile's config dir.
26+
27+
Must be called *after* ``export_profile`` so aw-core dirs see
28+
``AW_PROFILE`` and isolate the file from other instances.
29+
"""
30+
return load_config_toml("aw-server", default_config)
31+
32+
33+
def config_section(profile: str) -> str:
34+
"""TOML section for this profile: ``server`` or ``server-<profile>``."""
35+
return "server" if profile == DEFAULT_PROFILE else f"server-{profile}"
36+
37+
38+
def default_port(profile: str) -> int:
39+
"""Built-in port: 5666 for testing, 5600 otherwise.
40+
41+
Named profiles take ``port`` from their own isolated config (the
42+
research build bakes 5667 into that file). There is no hash-to-port
43+
table — a custom profile without a port set collides with default.
44+
"""
45+
return 5666 if is_testing(profile) else 5600

aw_server/main.py

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@
55
from aw_datastore import get_storage_methods
66

77
from . import __version__
8-
from .config import config
8+
from .config import config_section, default_port, load_config
9+
from .profile import (
10+
DEFAULT_PROFILE,
11+
export_profile,
12+
is_testing,
13+
resolve_profile,
14+
)
915
from .server import _start
1016

1117
logger = logging.getLogger(__name__)
@@ -35,6 +41,9 @@ def main():
3541
if settings.testing:
3642
logger.info("Will run in testing mode")
3743

44+
if settings.profile != DEFAULT_PROFILE:
45+
logger.info(f"Running with profile: {settings.profile}")
46+
3847
if settings.custom_static:
3948
logger.info(f"Using custom_static: {settings.custom_static}")
4049

@@ -57,7 +66,13 @@ def parse_settings():
5766
parser.add_argument(
5867
"--testing",
5968
action="store_true",
60-
help="Run aw-server in testing mode using different ports and database",
69+
help="Run aw-server in testing mode using different ports and database (alias for --profile testing)",
70+
)
71+
parser.add_argument(
72+
"--profile",
73+
dest="profile",
74+
default=None,
75+
help="Named instance profile (data, config, port and settings are isolated). --testing is an alias for --profile testing.",
6176
)
6277
parser.add_argument("--verbose", action="store_true", help="Be chatty.")
6378
parser.add_argument(
@@ -94,17 +109,42 @@ def parse_settings():
94109
print(__version__)
95110
sys.exit(0)
96111

112+
try:
113+
profile = resolve_profile(args.profile, args.testing)
114+
except ValueError as e:
115+
parser.error(str(e))
116+
# Export before loading config so aw-core dirs isolate this profile.
117+
export_profile(profile)
118+
testing = is_testing(profile)
119+
97120
""" Parse config file """
98-
configsection = "server" if not args.testing else "server-testing"
121+
config = load_config()
122+
section = config_section(profile)
123+
if section not in config:
124+
if profile not in (DEFAULT_PROFILE,):
125+
logger.warning(
126+
"Profile %s has no [%s] section, falling back to [server] "
127+
"(port %s may collide with the default instance)",
128+
profile,
129+
section,
130+
default_port(profile),
131+
)
132+
section = "server"
99133
settings = argparse.Namespace()
100-
settings.host = config[configsection]["host"]
101-
settings.port = int(config[configsection]["port"])
102-
settings.storage = config[configsection]["storage"]
103-
settings.cors_origins = config[configsection]["cors_origins"]
104-
settings.custom_static = dict(config[configsection]["custom_static"])
134+
settings.host = config[section]["host"]
135+
settings.port = int(config[section]["port"])
136+
settings.storage = config[section]["storage"]
137+
settings.cors_origins = config[section]["cors_origins"]
138+
settings.custom_static = dict(config[section]["custom_static"])
139+
settings.profile = profile
140+
settings.testing = testing
105141

106142
""" If a argument is not none, override the config value """
107143
for key, value in vars(args).items():
144+
if key in ("testing", "profile"):
145+
# Resolved above; --profile testing must keep testing=True
146+
# even when the raw --testing flag was absent.
147+
continue
108148
if value is not None:
109149
if key == "custom_static":
110150
settings.custom_static = parse_str_to_dict(value)

aw_server/profile.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Profile resolution for aw-server.
2+
3+
A *profile* names an isolated ActivityWatch instance (data, config, port,
4+
settings). `default` is the ordinary install, `testing` is what `--testing`
5+
has always meant, and any other name (for example `research`) is a sibling
6+
instance that can run at the same time as the others.
7+
8+
The carrier is the ``AW_PROFILE`` environment variable. aw-core's
9+
``_get_appname()`` suffixes the platformdirs root when it is set to a
10+
non-empty value, so exporting the profile here isolates dirs for this
11+
process and anything it spawns — without threading a flag through the
12+
datastore, settings, or Flask stack.
13+
14+
Kept in sync with aw-qt's ``aw_qt/profile.py`` (same validation rule as
15+
aw-server-rust). One intentional difference: the default profile *unsets*
16+
``AW_PROFILE`` instead of setting it to ``"default"``. aw-core treats any
17+
non-empty value as a suffix, so ``AW_PROFILE=default`` would resolve to
18+
``activitywatch-default`` and orphan an existing install.
19+
20+
Testing-root note (ActivityWatch/activitywatch#1399): python aw-core#149
21+
maps ``AW_PROFILE=testing`` to ``activitywatch-testing``. The rust
22+
isolation branch keeps testing on the bare ``activitywatch`` root so
23+
existing ``sqlite-testing.db`` files are not orphaned. This module follows
24+
the already-merged python dirs contract; unifying the rust testing root
25+
is a follow-up on that isolation PR, not something to special-case here.
26+
"""
27+
28+
import os
29+
import re
30+
from typing import Optional
31+
32+
DEFAULT_PROFILE = "default"
33+
TESTING_PROFILE = "testing"
34+
35+
#: Same rule as aw-server-rust's `validate_profile`: lowercase alphanumeric
36+
#: plus `-`/`_`, at most 32 chars, so a profile is always a safe path segment.
37+
PROFILE_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$")
38+
39+
ENV_VAR = "AW_PROFILE"
40+
41+
42+
def validate_profile(profile: str) -> str:
43+
"""Return the profile unchanged, or raise ValueError if it is not usable."""
44+
if not PROFILE_RE.match(profile):
45+
raise ValueError(
46+
f"Invalid profile name {profile!r}: expected lowercase alphanumeric "
47+
"with '-' or '_', at most 32 characters"
48+
)
49+
return profile
50+
51+
52+
def resolve_profile(profile: Optional[str], testing: bool) -> str:
53+
"""Resolve the effective profile from the CLI flags.
54+
55+
``--testing`` is an alias for ``--profile testing``; passing both is only
56+
an error if they disagree.
57+
"""
58+
if profile is None:
59+
return TESTING_PROFILE if testing else DEFAULT_PROFILE
60+
61+
profile = validate_profile(profile)
62+
if testing and profile != TESTING_PROFILE:
63+
raise ValueError(
64+
f"--testing conflicts with --profile {profile}: --testing is an "
65+
f"alias for --profile {TESTING_PROFILE}"
66+
)
67+
return profile
68+
69+
70+
def is_testing(profile: str) -> bool:
71+
return profile == TESTING_PROFILE
72+
73+
74+
def profile_suffix(profile: str) -> str:
75+
"""Filename suffix for a profile (``""``, ``"-testing"``, ``"-research"``)."""
76+
return "" if profile == DEFAULT_PROFILE else f"-{profile}"
77+
78+
79+
def profile_from_env(testing: bool = False) -> str:
80+
"""Read the profile the process was started with.
81+
82+
Falls back to the `--testing` bool for callers that only track that, so
83+
behaviour is unchanged when no profile was set.
84+
"""
85+
profile = os.environ.get(ENV_VAR)
86+
if not profile:
87+
return TESTING_PROFILE if testing else DEFAULT_PROFILE
88+
try:
89+
return validate_profile(profile)
90+
except ValueError:
91+
return TESTING_PROFILE if testing else DEFAULT_PROFILE
92+
93+
94+
def export_profile(profile: str) -> None:
95+
"""Publish the profile to this process and its children.
96+
97+
The default profile leaves ``AW_PROFILE`` unset so aw-core keeps the
98+
bare ``activitywatch`` root. Named profiles (including ``testing``)
99+
set the env var; children inherit it without a CLI flag.
100+
"""
101+
if profile == DEFAULT_PROFILE:
102+
os.environ.pop(ENV_VAR, None)
103+
else:
104+
os.environ[ENV_VAR] = profile

aw_server/rest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def decorator(*args, **kwargs):
6666
"version": fields.String(),
6767
"testing": fields.Boolean(),
6868
"device_id": fields.String(),
69+
"profile": fields.String(),
6970
},
7071
)
7172

aw_server/settings.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,15 @@
33

44
from aw_core.dirs import get_config_dir
55

6+
from .profile import profile_from_env, profile_suffix
7+
68

79
class Settings:
810
def __init__(self, testing: bool):
9-
filename = "settings.json" if not testing else "settings-testing.json"
11+
# Dir isolation (AW_PROFILE) already separates profiles; the filename
12+
# suffix is the pre-profile workaround and stays so --testing still
13+
# finds settings-testing.json. Named profiles get the same shape.
14+
filename = f"settings{profile_suffix(profile_from_env(testing=testing))}.json"
1015
self.config_file = Path(get_config_dir("aw-server")) / filename
1116
self.load()
1217

poetry.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/conftest.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import os
23

34
import pytest
45
from aw_client import ActivityWatchClient
@@ -7,9 +8,24 @@
78
logging.basicConfig(level=logging.WARN)
89

910

11+
@pytest.fixture(autouse=True)
12+
def _clear_aw_profile_after_test():
13+
"""export_profile() writes os.environ directly; monkeypatch.delenv does
14+
not record an undo when the var was already unset, so later tests would
15+
inherit a leftover profile."""
16+
yield
17+
os.environ.pop("AW_PROFILE", None)
18+
19+
1020
@pytest.fixture(scope="session")
1121
def app():
12-
return AWFlask("127.0.0.1", testing=True)
22+
# AWFlask does not go through parse_settings(), so a leftover AW_PROFILE
23+
# from the environment (or a prior test) would disagree with testing=True.
24+
old = os.environ.pop("AW_PROFILE", None)
25+
application = AWFlask("127.0.0.1", testing=True)
26+
if old is not None:
27+
os.environ["AW_PROFILE"] = old
28+
return application
1329

1430

1531
@pytest.fixture(scope="session")

0 commit comments

Comments
 (0)