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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions aw_server/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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]:
Expand All @@ -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

Expand Down
26 changes: 25 additions & 1 deletion aw_server/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from aw_core.config import load_config_toml

from .profile import DEFAULT_PROFILE, is_testing

default_config = """
[server]
host = "localhost"
Expand All @@ -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-<profile>``."""
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
56 changes: 48 additions & 8 deletions aw_server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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}")

Expand All @@ -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(
Expand Down Expand Up @@ -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()
Comment thread
TimeToBuildBob marked this conversation as resolved.
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)
Expand Down
104 changes: 104 additions & 0 deletions aw_server/profile.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions aw_server/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ def decorator(*args, **kwargs):
"version": fields.String(),
"testing": fields.Boolean(),
"device_id": fields.String(),
"profile": fields.String(),
},
)

Expand Down
7 changes: 6 additions & 1 deletion aw_server/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
10 changes: 5 additions & 5 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 17 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import os

import pytest
from aw_client import ActivityWatchClient
Expand All @@ -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")
Expand Down
Loading
Loading