|
| 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 |
0 commit comments