From 793a810cd20267f3cdc830554cb760f655582aa4 Mon Sep 17 00:00:00 2001 From: Wasim Sandhu Date: Tue, 7 Jul 2026 14:25:36 -0700 Subject: [PATCH] Make watcher API base URL configurable per environment Replace the hardcoded API_URLS map with per-environment URLs stored in config (`api_base_urls`), so self-hosted deployments point each of staging/production/preview at their own Data Hub without editing source. - WatcherConfig: `api_base_urls` dict + `api_base_url` property, with transparent migration of legacy scalar `api_base_url`/`watcher_id`. - CLI: prompt for the URL on `init` for every environment; switching resolves flag > stored > error and preserves other envs' URLs. - Service/client: require a URL for the active environment and fail loudly when none is configured. - Update docs and tests; bump watcher to 0.5.0. Co-authored-by: Cursor --- developer-docs/watcher.md | 21 +++-- uv.lock | 2 +- watcher/pyproject.toml | 2 +- watcher/src/data_hub_watcher/cli.py | 101 ++++++++++++--------- watcher/src/data_hub_watcher/constants.py | 5 -- watcher/src/data_hub_watcher/models.py | 43 ++++++--- watcher/src/data_hub_watcher/service.py | 22 +++-- watcher/tests/test_cli_self_update.py | 1 + watcher/tests/test_cli_set_environment.py | 16 +++- watcher/tests/test_preview_environment.py | 103 +++++++++++----------- watcher/tests/test_runtime.py | 4 +- watcher/tests/test_service.py | 1 + watcher/tests/test_updater.py | 4 +- 13 files changed, 192 insertions(+), 133 deletions(-) diff --git a/developer-docs/watcher.md b/developer-docs/watcher.md index 71477be9..10e11cf3 100644 --- a/developer-docs/watcher.md +++ b/developer-docs/watcher.md @@ -43,7 +43,7 @@ All commands accept two global flags: Interactive setup wizard that: -1. Prompts for the environment (`staging`, `production`, or `preview`). Choosing `preview` also prompts for a custom API base URL. +1. Prompts for the environment (`staging`, `production`, or `preview`) and the Data Hub API base URL for it. There is no built-in default URL — each environment points at whatever deployment you host — and the URL is saved per environment so switching back later reuses it without re-prompting. 2. Prompts for an API key (or reads `DATA_HUB_API_KEY` from the environment). The key is saved to a per-environment file at `~/.data-hub/.env.` (e.g. `.env.staging`), so switching between environments later doesn't require re-entering it. 3. Fetches existing instruments from the API or registers a new one. 4. Prompts for the watch directory, file patterns, run detection pattern, stability period, and upload mode. @@ -137,7 +137,8 @@ The config file lives at `~/.data-hub/config.yaml` by default. Override with `-- ```yaml version: 1 environment: production # "staging", "production", or "preview" -api_base_url: null # required when environment is "preview" +api_base_urls: # one API base URL per environment (no default) + production: https://datahub.example.com/api/v1 watcher_ids: # one registration id per environment production: initial_scan: null # null (default), "full", or "new-only" @@ -177,17 +178,21 @@ A config written by an older watcher used a single top-level `watcher_id`. It is ### Switching environments -A single PC can move between `staging`, `production`, and `preview` and back. Because staging and production are separate Data Hub deployments with separate databases, each holds its own watcher registration — `watcher_ids` stores one id per environment, and the credentials live in per-environment `~/.data-hub/.env.` files. +A single PC can move between `staging`, `production`, and `preview` and back. Because these are separate Data Hub deployments with separate databases, each holds its own watcher registration and its own API base URL — `watcher_ids` stores one id per environment, `api_base_urls` stores one URL per environment, and the credentials live in per-environment `~/.data-hub/.env.` files. + +The first switch to an environment needs its URL via `--api-base-url`; later switches reuse the stored value, so you never re-enter it. Switch with: ```sh -# Switch to staging (reuses a stored registration, or registers one). -uv run data-hub-watcher config set-environment staging +# First switch to an environment: provide its base URL (stored for next time). +uv run data-hub-watcher config set-environment staging \ + --api-base-url https://datahub-staging.example.com/api/v1 -# Point at a preview deployment (the base URL is required). -uv run data-hub-watcher config set-environment preview \ - --api-base-url https://data-hub-git-my-branch.vercel.app/api/v1 +# Subsequent switches reuse the stored URL — no flag needed. +uv run data-hub-watcher config set-environment production \ + --api-base-url https://datahub.example.com/api/v1 +uv run data-hub-watcher config set-environment staging ``` `config edit` also re-prompts for the environment and runs the same switch flow when it changes. Useful flags: `--api-key` (otherwise the key is read from the env file or prompted), `--show-key`, and `--no-register` (fail instead of registering a new watcher if none is stored for the target). diff --git a/uv.lock b/uv.lock index b0e94c01..35231ff8 100644 --- a/uv.lock +++ b/uv.lock @@ -416,7 +416,7 @@ requires-dist = [ [[package]] name = "data-hub-watcher" -version = "0.4.0" +version = "0.5.0" source = { editable = "watcher" } dependencies = [ { name = "click" }, diff --git a/watcher/pyproject.toml b/watcher/pyproject.toml index a93e3750..b52a7bf9 100644 --- a/watcher/pyproject.toml +++ b/watcher/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "data-hub-watcher" -version = "0.4.0" +version = "0.5.0" description = "File-watcher agent for lab instrument PCs that ingests data into Data Hub." readme = "README.md" requires-python = ">=3.12" diff --git a/watcher/src/data_hub_watcher/cli.py b/watcher/src/data_hub_watcher/cli.py index 921fb97e..70e6ee3b 100644 --- a/watcher/src/data_hub_watcher/cli.py +++ b/watcher/src/data_hub_watcher/cli.py @@ -15,7 +15,6 @@ from data_hub_watcher.api_client import ApiError, DataHubClient from data_hub_watcher.config_io import config_checksum, load_config, save_config from data_hub_watcher.constants import ( - API_URLS, DEFAULT_CONFIG_DIR, DEFAULT_STABILITY_PERIOD_SECONDS, RUN_DETECTION_PRESETS, @@ -145,13 +144,16 @@ def _clean_api_key(value: str) -> str: def _make_client( environment: str, api_key: str | None = None, api_base_url: str | None = None ) -> DataHubClient: - if environment == "preview": - if not api_base_url: - raise click.ClickException("api_base_url is required for the 'preview' environment.") - base_url = api_base_url - else: - base_url = API_URLS[environment] - return DataHubClient(base_url, api_key=api_key) + # Every environment gets its URL from config now — there is no baked-in + # default — so a missing URL is a hard error rather than a silent fall + # through to someone else's server. + if not api_base_url: + raise click.ClickException( + f"No API base URL is configured for the '{environment}' environment. " + "Set one with `data-hub-watcher init` or " + f"`data-hub-watcher config set-environment {environment} --api-base-url `." + ) + return DataHubClient(api_base_url, api_key=api_key) def _load_and_client(ctx: click.Context) -> tuple[WatcherConfig, DataHubClient, Path]: @@ -205,12 +207,15 @@ def init(ctx: click.Context, show_key: bool) -> None: type=click.Choice(list(SUPPORTED_ENVIRONMENTS), case_sensitive=False), ) - api_base_url: str | None = None - if environment == "preview": - raw_url: str = click.prompt( - "Preview deployment base URL (e.g. https://data-hub-git-my-branch.vercel.app/api/v1)" - ) - api_base_url = raw_url.rstrip("/") + # Every environment needs its own Data Hub API base URL; there is no + # baked-in default. Preview points at an ephemeral branch deploy, the + # others at the operator's own staging/production host. + url_example = ( + "https://data-hub-git-my-branch.vercel.app/api/v1" + if environment == "preview" + else "https://datahub.example.com/api/v1" + ) + api_base_url: str = click.prompt(f"Data Hub API base URL (e.g. {url_example})").rstrip("/") # 2. API key — overlay any existing per-environment env file so the user # doesn't have to re-enter a key they've already saved for this target. @@ -332,7 +337,7 @@ def init(ctx: click.Context, show_key: bool) -> None: config = WatcherConfig( version=1, environment=environment, - api_base_url=api_base_url, + api_base_urls={environment: api_base_url}, watcher_ids={environment: watcher_id}, instrument=InstrumentConfig( id=selected.id, @@ -576,20 +581,30 @@ def _switch_environment( inst = cfg.instrument config_dir = path.parent - if target == "preview": - if not api_base_url: - raise click.ClickException("--api-base-url is required when switching to 'preview'.") - api_base_url = api_base_url.rstrip("/") + # Resolve the target environment's API base URL: an explicit --api-base-url + # wins, otherwise reuse the URL already stored for that environment so + # switching back never re-prompts. With neither available we can't build a + # client, so fail before any network or credential work. + stored_url = cfg.api_base_urls.get(target) + if api_base_url: + target_url = api_base_url.rstrip("/") + elif stored_url: + target_url = stored_url + else: + raise click.ClickException( + f"--api-base-url is required to switch to '{target}': " + "no API base URL is stored for it yet." + ) # A preview redeploy points at a new database server-side, so the stored # registration won't exist and local state must be reset. Compare against # the URL the DB was seeded against (`meta`), falling back to the config. preview_redeploy = False - if target == "preview" and cfg.environment == "preview" and api_base_url is not None: - seeded_url = _preview_seed_url(config_dir) or cfg.api_base_url - preview_redeploy = seeded_url is not None and api_base_url != seeded_url + if target == "preview" and cfg.environment == "preview": + seeded_url = _preview_seed_url(config_dir) or stored_url + preview_redeploy = seeded_url is not None and target_url != seeded_url - if cfg.environment == target and not preview_redeploy: + if cfg.environment == target and target_url == stored_url and not preview_redeploy: click.echo(f"Already on environment '{target}'.") return cfg @@ -605,7 +620,7 @@ def _switch_environment( api_key = _clean_api_key(click.prompt("DATA_HUB_API_KEY", hide_input=not show_key)) save_api_key(api_key, target) - client = _make_client(target, api_key=api_key, api_base_url=api_base_url) + client = _make_client(target, api_key=api_key, api_base_url=target_url) try: client.list_instruments() except ApiError as exc: @@ -640,10 +655,13 @@ def _switch_environment( watcher_ids[target] = watcher_id click.echo(f"Registered watcher for {target}: {watcher_id}") + # Preserve every other environment's stored URL; only (re)set the target's. + api_base_urls = {**cfg.api_base_urls, target: target_url} + new_cfg = WatcherConfig( version=cfg.version, environment=target, # type: ignore[arg-type] # validated by click.Choice - api_base_url=api_base_url if target == "preview" else None, + api_base_urls=api_base_urls, watcher_ids=watcher_ids, initial_scan=cfg.initial_scan, instrument=inst, @@ -654,10 +672,10 @@ def _switch_environment( # Record which preview deployment this state DB was seeded against; the # next switch reads it back via `_preview_seed_url` to decide whether the # URL changed and the DB must be reset (see `preview_redeploy`). - if target == "preview" and api_base_url is not None: + if target == "preview": try: db = StateDB(state_db_path(config_dir, target)) - db.set_meta(PREVIEW_SEED_URL_META_KEY, api_base_url) + db.set_meta(PREVIEW_SEED_URL_META_KEY, target_url) db.close() except Exception: logger.debug("Could not record preview deployment URL", exc_info=True) @@ -801,7 +819,12 @@ def config_validate(ctx: click.Context) -> None: "environment", type=click.Choice(list(SUPPORTED_ENVIRONMENTS), case_sensitive=False), ) -@click.option("--api-base-url", default=None, help="Required when environment is 'preview'.") +@click.option( + "--api-base-url", + default=None, + help="API base URL for the target environment. Required the first time you " + "switch to an environment; reused from config on later switches.", +) @click.option("--api-key", default=None, help="API key for the target environment.") @click.option( "--show-key", @@ -845,22 +868,20 @@ def config_edit(ctx: click.Context) -> None: # Re-prompt for environment first; a change delegates to the full switch # flow (re-registration, key resolution, state reset) before the rest of - # the edit runs against the freshly-saved config. + # the edit runs against the freshly-saved config. The switch reuses the + # target env's stored URL, prompting via --api-base-url only when none is + # saved yet, so no URL prompt is needed here. target_env = click.prompt( "Environment", type=click.Choice(list(SUPPORTED_ENVIRONMENTS), case_sensitive=False), default=cfg.environment, ) - preview_url: str | None = None - if target_env == "preview": - preview_url = click.prompt( - "Preview deployment base URL", - default=cfg.api_base_url, - ) - if target_env != cfg.environment or ( - target_env == "preview" and preview_url is not None and preview_url != cfg.api_base_url - ): - cfg = _switch_environment(path, cfg, target_env, api_base_url=preview_url) + if target_env != cfg.environment: + stored_url = cfg.api_base_urls.get(target_env) + api_base_url = None + if not stored_url: + api_base_url = click.prompt(f"Data Hub API base URL for {target_env}").rstrip("/") + cfg = _switch_environment(path, cfg, target_env, api_base_url=api_base_url) inst = cfg.instrument @@ -898,7 +919,7 @@ def config_edit(ctx: click.Context) -> None: new_config = WatcherConfig( version=cfg.version, environment=cfg.environment, - api_base_url=cfg.api_base_url, + api_base_urls=cfg.api_base_urls, watcher_ids=cfg.watcher_ids, initial_scan=cfg.initial_scan, instrument=InstrumentConfig( diff --git a/watcher/src/data_hub_watcher/constants.py b/watcher/src/data_hub_watcher/constants.py index 5e5224b7..d504c442 100644 --- a/watcher/src/data_hub_watcher/constants.py +++ b/watcher/src/data_hub_watcher/constants.py @@ -7,11 +7,6 @@ from dotenv import load_dotenv -API_URLS: dict[str, str] = { - "staging": "https://data-hub-env-staging-arcadia-science.vercel.app/api/v1", - "production": "https://data-hub.arcadiascience.com/api/v1", -} - def _read_watcher_version() -> str: # Resolve the installed distribution version once at import time so the diff --git a/watcher/src/data_hub_watcher/models.py b/watcher/src/data_hub_watcher/models.py index e6ed10c0..ec8fb56c 100644 --- a/watcher/src/data_hub_watcher/models.py +++ b/watcher/src/data_hub_watcher/models.py @@ -72,7 +72,13 @@ def _validate_directory(cls, v: Path) -> Path: class WatcherConfig(BaseModel): version: Literal[1] environment: Literal["staging", "production", "preview"] - api_base_url: str | None = None + # One API base URL per environment, keyed by the `environment` literal, so + # a single PC can switch between staging/production/preview without + # re-entering the URL each time. There is no baked-in default: every + # environment a host actually uses must have its URL set (prompted at + # `init`, reused on switch). The active environment's URL is exposed via + # the `api_base_url` property below. + api_base_urls: dict[str, str] = Field(default_factory=dict) # One watcher_id per environment so a single PC can switch between # staging/production/preview without losing the registration it already # holds in each. Keyed by the `environment` literal. @@ -85,15 +91,27 @@ class WatcherConfig(BaseModel): @model_validator(mode="before") @classmethod - def _migrate_legacy_watcher_id(cls, data: Any) -> Any: - # Pre-multi-env configs (and pre-multi-env constructor calls) carry a - # single top-level `watcher_id`. Lift it into `watcher_ids` under the - # active environment so on-disk configs migrate transparently on load. - if isinstance(data, dict) and "watcher_id" in data and "watcher_ids" not in data: + def _migrate_legacy_fields(cls, data: Any) -> Any: + # Pre-multi-env configs (and pre-multi-env constructor calls) carry + # single top-level `watcher_id` / `api_base_url` scalars. Lift them + # into their per-environment maps under the active environment so + # on-disk configs (and legacy `api_base_url=` kwargs) migrate + # transparently on load. + if not isinstance(data, dict): + return data + env = data.get("environment") + if "watcher_id" in data and "watcher_ids" not in data: legacy = data.pop("watcher_id") - env = data.get("environment") if legacy and env: data["watcher_ids"] = {env: legacy} + if "api_base_url" in data and "api_base_urls" not in data: + legacy_url = data.pop("api_base_url") + if legacy_url and env: + data["api_base_urls"] = {env: legacy_url} + elif "api_base_url" in data: + # Both the scalar and the map were supplied; drop the scalar so it + # isn't flagged as an unexpected field. + data.pop("api_base_url") return data @property @@ -101,6 +119,11 @@ def watcher_id(self) -> str | None: """The watcher_id registered for the active environment, if any.""" return self.watcher_ids.get(self.environment) + @property + def api_base_url(self) -> str | None: + """The API base URL configured for the active environment, if any.""" + return self.api_base_urls.get(self.environment) + def resolve_initial_scan(self) -> Literal["full", "new-only"]: """Effective initial-scan mode, defaulting by environment when unset. @@ -112,12 +135,6 @@ def resolve_initial_scan(self) -> Literal["full", "new-only"]: return self.initial_scan return "full" if self.environment == "production" else "new-only" - @model_validator(mode="after") - def _validate_preview_url(self) -> WatcherConfig: - if self.environment == "preview" and not self.api_base_url: - raise ValueError("api_base_url is required when environment is 'preview'") - return self - @model_validator(mode="after") def _emit_warnings(self) -> WatcherConfig: # Surface common misconfigurations as warnings at load time rather diff --git a/watcher/src/data_hub_watcher/service.py b/watcher/src/data_hub_watcher/service.py index def694c9..860eefe2 100644 --- a/watcher/src/data_hub_watcher/service.py +++ b/watcher/src/data_hub_watcher/service.py @@ -594,7 +594,6 @@ def _run_service_loop(stop_event: threading.Event, sm: Any) -> None: from data_hub_watcher.api_client import ApiError, DataHubClient from data_hub_watcher.config_io import load_config from data_hub_watcher.constants import ( - API_URLS, env_file_path, resolve_state_db_path, ) @@ -655,14 +654,19 @@ def _run_service_loop(stop_event: threading.Event, sm: Any) -> None: cfg = load_config(path) inst = cfg.instrument - if cfg.environment == "preview": - # WatcherConfig's model validator guarantees api_base_url is - # set whenever environment is "preview"; the assertion is here - # to make that invariant visible to pyright. - assert cfg.api_base_url is not None - base_url = cfg.api_base_url - else: - base_url = API_URLS[cfg.environment] + base_url = cfg.api_base_url + if not base_url: + # No baked-in default exists anymore; a config without a URL for the + # active environment can't build a client. Fail loudly so the operator + # re-runs `config set-environment` rather than the service silently + # looping on connection errors. + logger.error( + "No API base URL configured for environment %r. " + "Re-run 'data-hub-watcher config set-environment %s --api-base-url '.", + cfg.environment, + cfg.environment, + ) + raise SystemExit(1) client = DataHubClient(base_url) # Step 1: Check instrument status (mirrors CLI watch startup) diff --git a/watcher/tests/test_cli_self_update.py b/watcher/tests/test_cli_self_update.py index cbe1fc22..c50689a4 100644 --- a/watcher/tests/test_cli_self_update.py +++ b/watcher/tests/test_cli_self_update.py @@ -43,6 +43,7 @@ def _make_config(tmp_path: Path) -> WatcherConfig: return WatcherConfig( version=1, environment="staging", + api_base_urls={"staging": "https://staging.example.test/api/v1"}, watcher_ids={"staging": "w-test"}, instrument=InstrumentConfig( id="test-instrument", diff --git a/watcher/tests/test_cli_set_environment.py b/watcher/tests/test_cli_set_environment.py index 3498c912..d1567275 100644 --- a/watcher/tests/test_cli_set_environment.py +++ b/watcher/tests/test_cli_set_environment.py @@ -31,15 +31,23 @@ def _write_config( *, environment: str, watcher_ids: dict[str, str], - api_base_url: str | None = None, + api_base_urls: dict[str, str] | None = None, ) -> Path: watch_dir = tmp_path / "data" watch_dir.mkdir(exist_ok=True) (watch_dir / "RUN001_sample.csv").write_text("a,b\n1,2\n") + # Default: give the stable staging/production environments a stored URL so + # switching between them reuses it without needing --api-base-url. Preview + # is intentionally left out so the "no stored URL" path stays testable. + if api_base_urls is None: + api_base_urls = { + "staging": "https://staging.example.test/api/v1", + "production": "https://production.example.test/api/v1", + } cfg = WatcherConfig( version=1, environment=environment, # type: ignore[arg-type] - api_base_url=api_base_url, + api_base_urls=api_base_urls, watcher_ids=watcher_ids, instrument=InstrumentConfig( id="test-instrument", @@ -183,7 +191,7 @@ def test_changed_url_resets_db_and_reregisters( tmp_path, environment="preview", watcher_ids={"preview": "w-prev-a"}, - api_base_url=PREVIEW_A, + api_base_urls={"preview": PREVIEW_A}, ) result = _invoke( @@ -211,7 +219,7 @@ def test_unchanged_url_preserves_db( tmp_path, environment="preview", watcher_ids={"preview": "w-prev-a"}, - api_base_url=PREVIEW_A, + api_base_urls={"preview": PREVIEW_A}, ) result = _invoke( diff --git a/watcher/tests/test_preview_environment.py b/watcher/tests/test_preview_environment.py index 8d0fd4b2..a87ca6fa 100644 --- a/watcher/tests/test_preview_environment.py +++ b/watcher/tests/test_preview_environment.py @@ -1,6 +1,8 @@ -"""Unit tests for the 'preview' environment option. +"""Unit tests for per-environment API base URLs. -Covers model validation, YAML config round-trips, and client construction. +Covers model validation (now lenient — a config may lack a URL), YAML config +round-trips, legacy `api_base_url` migration, and client construction (which +requires a URL for every environment). """ from __future__ import annotations @@ -9,11 +11,9 @@ import click import pytest import yaml -from pydantic import ValidationError from data_hub_watcher.cli import _make_client from data_hub_watcher.config_io import load_config, save_config -from data_hub_watcher.constants import API_URLS from data_hub_watcher.models import InstrumentConfig, RunDetectionConfig, WatcherConfig PREVIEW_URL = "https://data-hub-git-my-branch.vercel.app/api/v1" @@ -33,44 +33,47 @@ def _make_instrument(tmp_path: Path) -> InstrumentConfig: # ------------------------------------------------------------------ -# Group 1: WatcherConfig model validation +# Group 1: WatcherConfig model validation (lenient — URL optional) # ------------------------------------------------------------------ class TestWatcherConfigValidation: - def test_preview_requires_api_base_url(self, tmp_path: Path) -> None: - with pytest.raises(ValidationError, match="api_base_url is required"): - WatcherConfig( - version=1, - environment="preview", - instrument=_make_instrument(tmp_path), - ) - - def test_preview_with_api_base_url_succeeds(self, tmp_path: Path) -> None: + def test_config_without_url_is_valid(self, tmp_path: Path) -> None: + # No environment requires a URL at the model layer anymore; the URL is + # enforced when a client is built (see TestMakeClient). cfg = WatcherConfig( version=1, environment="preview", - api_base_url=PREVIEW_URL, instrument=_make_instrument(tmp_path), ) - assert cfg.environment == "preview" + assert cfg.api_base_url is None + assert cfg.api_base_urls == {} + + def test_legacy_api_base_url_migrates_to_map(self, tmp_path: Path) -> None: + # `api_base_url` is no longer a field, so exercise the legacy scalar + # via `model_validate` (what `load_config` uses) rather than a kwarg. + cfg = WatcherConfig.model_validate( + { + "version": 1, + "environment": "preview", + "api_base_url": PREVIEW_URL, + "instrument": _make_instrument(tmp_path).model_dump(mode="json"), + } + ) + assert cfg.api_base_urls == {"preview": PREVIEW_URL} assert cfg.api_base_url == PREVIEW_URL - def test_staging_does_not_require_api_base_url(self, tmp_path: Path) -> None: + def test_api_base_url_reflects_active_environment(self, tmp_path: Path) -> None: cfg = WatcherConfig( version=1, environment="staging", + api_base_urls={ + "staging": "https://staging.example.test/api/v1", + "production": "https://prod.example.test/api/v1", + }, instrument=_make_instrument(tmp_path), ) - assert cfg.api_base_url is None - - def test_production_does_not_require_api_base_url(self, tmp_path: Path) -> None: - cfg = WatcherConfig( - version=1, - environment="production", - instrument=_make_instrument(tmp_path), - ) - assert cfg.api_base_url is None + assert cfg.api_base_url == "https://staging.example.test/api/v1" # ------------------------------------------------------------------ @@ -79,12 +82,12 @@ def test_production_does_not_require_api_base_url(self, tmp_path: Path) -> None: class TestConfigRoundTrip: - def test_preview_config_round_trip(self, tmp_path: Path) -> None: + def test_round_trip_preserves_all_urls(self, tmp_path: Path) -> None: path = tmp_path / "config.yaml" original = WatcherConfig( version=1, environment="preview", - api_base_url=PREVIEW_URL, + api_base_urls={"preview": PREVIEW_URL}, watcher_ids={"preview": "w-123"}, instrument=_make_instrument(tmp_path), ) @@ -93,10 +96,11 @@ def test_preview_config_round_trip(self, tmp_path: Path) -> None: loaded = load_config(path) assert loaded.environment == "preview" + assert loaded.api_base_urls == {"preview": PREVIEW_URL} assert loaded.api_base_url == PREVIEW_URL assert loaded.watcher_id == "w-123" - def test_preview_yaml_without_url_fails_to_load(self, tmp_path: Path) -> None: + def test_legacy_scalar_url_in_yaml_migrates_on_load(self, tmp_path: Path) -> None: path = tmp_path / "config.yaml" watch_dir = tmp_path / "data" watch_dir.mkdir(exist_ok=True) @@ -105,6 +109,7 @@ def test_preview_yaml_without_url_fails_to_load(self, tmp_path: Path) -> None: raw = { "version": 1, "environment": "preview", + "api_base_url": PREVIEW_URL, "instrument": { "id": "test-instrument", "watch_directory": str(watch_dir), @@ -114,10 +119,11 @@ def test_preview_yaml_without_url_fails_to_load(self, tmp_path: Path) -> None: } path.write_text(yaml.dump(raw), encoding="utf-8") - with pytest.raises(click.ClickException, match="api_base_url is required"): - load_config(path) + loaded = load_config(path) + assert loaded.api_base_urls == {"preview": PREVIEW_URL} + assert loaded.api_base_url == PREVIEW_URL - def test_staging_config_omits_api_base_url_in_yaml(self, tmp_path: Path) -> None: + def test_config_without_url_omits_scalar_in_yaml(self, tmp_path: Path) -> None: path = tmp_path / "config.yaml" cfg = WatcherConfig( version=1, @@ -126,34 +132,31 @@ def test_staging_config_omits_api_base_url_in_yaml(self, tmp_path: Path) -> None ) save_config(cfg, path) - raw_yaml = path.read_text(encoding="utf-8") - data = yaml.safe_load(raw_yaml) + data = yaml.safe_load(path.read_text(encoding="utf-8")) - assert data.get("api_base_url") is None + assert "api_base_url" not in data + assert data.get("api_base_urls") == {} # ------------------------------------------------------------------ -# Group 3: _make_client helper +# Group 3: _make_client helper (URL required for every environment) # ------------------------------------------------------------------ class TestMakeClient: - def test_preview_uses_custom_url(self) -> None: + def test_uses_provided_url(self) -> None: client = _make_client("preview", api_base_url=PREVIEW_URL) assert client.base_url == PREVIEW_URL - def test_preview_without_url_raises(self) -> None: - with pytest.raises(click.ClickException, match="api_base_url is required"): - _make_client("preview") - - def test_staging_uses_hardcoded_url(self) -> None: - client = _make_client("staging") - assert client.base_url == API_URLS["staging"] + def test_staging_uses_provided_url(self) -> None: + url = "https://staging.example.test/api/v1" + client = _make_client("staging", api_base_url=url) + assert client.base_url == url - def test_production_uses_hardcoded_url(self) -> None: - client = _make_client("production") - assert client.base_url == API_URLS["production"] + def test_missing_url_raises(self) -> None: + with pytest.raises(click.ClickException, match="No API base URL is configured"): + _make_client("preview") - def test_staging_ignores_api_base_url(self) -> None: - client = _make_client("staging", api_base_url="https://should-be-ignored.example.com") - assert client.base_url == API_URLS["staging"] + def test_missing_url_raises_for_staging(self) -> None: + with pytest.raises(click.ClickException, match="No API base URL is configured"): + _make_client("staging") diff --git a/watcher/tests/test_runtime.py b/watcher/tests/test_runtime.py index fb3380a9..dc9ef3e7 100644 --- a/watcher/tests/test_runtime.py +++ b/watcher/tests/test_runtime.py @@ -329,7 +329,9 @@ def _cfg( return WatcherConfig( version=1, environment=environment, # type: ignore[arg-type] - api_base_url="https://x.example/api/v1" if environment == "preview" else None, + api_base_urls={environment: "https://x.example/api/v1"} + if environment == "preview" + else {}, watcher_ids={environment: "w-test"}, initial_scan=initial_scan, instrument=InstrumentConfig( diff --git a/watcher/tests/test_service.py b/watcher/tests/test_service.py index e2d8d87a..9f5321e0 100644 --- a/watcher/tests/test_service.py +++ b/watcher/tests/test_service.py @@ -1006,6 +1006,7 @@ def _make_config( return WatcherConfig( version=1, environment=environment, # type: ignore[arg-type] + api_base_urls={environment: f"https://{environment}.example.test/api/v1"}, watcher_ids={environment: watcher_id} if watcher_id else {}, instrument=instrument, ) diff --git a/watcher/tests/test_updater.py b/watcher/tests/test_updater.py index 78452935..c11a587b 100644 --- a/watcher/tests/test_updater.py +++ b/watcher/tests/test_updater.py @@ -79,7 +79,9 @@ def _make_config( return WatcherConfig( version=1, environment=environment, # type: ignore[arg-type] - api_base_url="https://example.test/api/v1" if environment == "preview" else None, + api_base_urls={environment: "https://example.test/api/v1"} + if environment == "preview" + else {}, watcher_ids={environment: "w-test"}, instrument=instrument, )