diff --git a/CLI-COMMANDS.md b/CLI-COMMANDS.md index 4593b802..f5f60f92 100644 --- a/CLI-COMMANDS.md +++ b/CLI-COMMANDS.md @@ -12,6 +12,46 @@ export ROBOFLOW_API_KEY=rf_xxxxx # recommended for scripts and agents roboflow auth login # or interactive login ``` +### Select a Roboflow region + +Roboflow uses the US platform by default. To authenticate with the EU +data-residency platform, select the region during login: + +```bash +roboflow auth login --region eu +# The backwards-compatible alias accepts the same option: +roboflow login --region eu +``` + +The selection is saved in the Roboflow config file. You can change it later +or inspect the effective endpoints with: + +```bash +roboflow auth set-region eu +roboflow auth status +``` + +For CI and other non-interactive environments, set `ROBOFLOW_REGION=eu`. +`ROBOFLOW_REGION` accepts `us` or `eu` (case-insensitive); an environment +value takes precedence over the saved region. Explicit per-URL environment or +config values such as `API_URL` continue to take precedence over the region. + +| Endpoint | `us` (default) | `eu` | +|----------|----------------|------| +| API | `https://api.roboflow.com` | `https://api.roboflow.eu` | +| App / CLI authentication | `https://app.roboflow.com` | `https://app.roboflow.eu` | +| Object detection | `https://serverless.roboflow.com` | `https://serverless.roboflow.eu` | +| Instance segmentation | `https://serverless.roboflow.com` | `https://serverless.roboflow.eu` | +| Dedicated deployment | `https://roboflow.cloud` | `https://eu.roboflow.cloud` | +| Universe | `https://universe.roboflow.com` | `https://universe.roboflow.com` | +| Semantic segmentation | `https://segment.roboflow.com` | `https://segment.roboflow.com` | + +Roboflow Universe remains a single global product, so its URL stays on +`.com` in the EU region. Semantic segmentation also remains on its current +`.com` endpoint. EU and US use separate authentication backends; obtain EU +API keys from `https://app.roboflow.eu` and log in again after switching if +your existing credentials were issued by the other region. + ## Global flags | Flag | Short | Description | @@ -377,7 +417,7 @@ Version numbers are always numeric — that's how `x/y` is disambiguated between | Command | Description | |---------|-------------| -| `auth` | Login, logout, status, set default workspace | +| `auth` | Login, logout, status, set region or default workspace | | `api-key` | List, create, update, protect, disable, revoke workspace API keys | | `workspace` | List and inspect workspaces | | `project` | List, get, create projects | diff --git a/README.md b/README.md index 63b78ead..98dde3c5 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,20 @@ import roboflow roboflow.login() ``` +### Using Roboflow EU + +The same package supports Roboflow's EU data-residency platform. Select it +when logging in with the CLI: + +```bash +roboflow auth login --region eu +``` + +For environment-based configuration and CI, set `ROBOFLOW_REGION=eu` before +running Python or CLI commands. EU and US use separate authentication +backends, so use an EU API key obtained from +[`app.roboflow.eu`](https://app.roboflow.eu). +
Authenticate with an API key diff --git a/roboflow/__init__.py b/roboflow/__init__.py index c248fa03..b7d01c96 100644 --- a/roboflow/__init__.py +++ b/roboflow/__init__.py @@ -66,7 +66,19 @@ def check_key(api_key, model, notebook, num_retries=0): return "onboarding" -def login(workspace=None, force=False): +def login(workspace=None, force=False, region=None): + normalized_region = None + if region is not None: + if not isinstance(region, str) or region.lower() not in {"us", "eu"}: + raise ValueError(f"Invalid region '{region}'. Expected one of: us, eu.") + normalized_region = region.lower() + + # Resolve at call time so a region passed by the CLI is honored even though + # the module-level URL constants were resolved when roboflow was imported. + from roboflow.config import resolve_url + + app_url = resolve_url("APP_URL", region=normalized_region) + os_name = os.name if os_name == "nt": @@ -76,22 +88,31 @@ def login(workspace=None, force=False): # default configuration location conf_location = os.getenv("ROBOFLOW_CONFIG_DIR", default=default_path) + existing_config = {} if os.path.isfile(conf_location) and not force: write_line("You are already logged into Roboflow. To make a different login,run roboflow.login(force=True).") return None # we could eventually return the workspace object here # return Roboflow().workspace() elif os.path.isfile(conf_location) and force: + try: + with open(conf_location) as f: + existing_config = json.load(f) + except json.JSONDecodeError: + # A forced login has historically replaced an unreadable config. + existing_config = {} + if not isinstance(existing_config, dict): + existing_config = {} os.remove(conf_location) if workspace is None: - write_line("visit " + APP_URL + "/auth-cli to get your authentication token.") + write_line("visit " + app_url + "/auth-cli to get your authentication token.") else: - write_line("visit " + APP_URL + "/auth-cli/?workspace=" + workspace + " to get your authentication token.") + write_line("visit " + app_url + "/auth-cli/?workspace=" + workspace + " to get your authentication token.") token = getpass("Paste the authentication token here: ") - r_login = requests.get(APP_URL + "/query/cliAuthToken/" + token) + r_login = requests.get(app_url + "/query/cliAuthToken/" + token) if r_login.status_code == 200: r_login = r_login.json() @@ -102,16 +123,18 @@ def login(workspace=None, force=False): if not os.path.exists(os.path.dirname(conf_location)): os.makedirs(os.path.dirname(conf_location)) - r_login = {"workspaces": r_login} + existing_config["workspaces"] = r_login # set first workspace as default workspace - default_workspace_id = list(r_login["workspaces"].keys())[0] - workspace = r_login["workspaces"][default_workspace_id] - r_login["RF_WORKSPACE"] = workspace["url"] + default_workspace_id = list(existing_config["workspaces"].keys())[0] + workspace = existing_config["workspaces"][default_workspace_id] + existing_config["RF_WORKSPACE"] = workspace["url"] + if normalized_region is not None: + existing_config["ROBOFLOW_REGION"] = normalized_region # write config file with open(conf_location, "w") as f: - json.dump(r_login, f, indent=2) + json.dump(existing_config, f, indent=2) else: r_login.raise_for_status() diff --git a/roboflow/cli/handlers/_aliases.py b/roboflow/cli/handlers/_aliases.py index 301c2dab..5e7ceb0a 100644 --- a/roboflow/cli/handlers/_aliases.py +++ b/roboflow/cli/handlers/_aliases.py @@ -40,12 +40,15 @@ def login_alias( login_api_key: Annotated[ Optional[str], typer.Option("--api-key", help="API key (skip interactive login)") ] = None, + region: Annotated[ + Optional[str], typer.Option("--region", metavar="{us,eu}", help="Roboflow platform region") + ] = None, force: Annotated[bool, typer.Option("--force", "-f", help="Force re-login")] = False, ) -> None: """Log in to Roboflow (alias for 'auth login').""" from roboflow.cli.handlers.auth import _login - args = ctx_to_args(ctx, login_api_key=login_api_key, force=force) + args = ctx_to_args(ctx, login_api_key=login_api_key, region=region, force=force) _login(args) @app.command("whoami", hidden=True) diff --git a/roboflow/cli/handlers/auth.py b/roboflow/cli/handlers/auth.py index 374b7fc2..22fcab50 100644 --- a/roboflow/cli/handlers/auth.py +++ b/roboflow/cli/handlers/auth.py @@ -1,4 +1,4 @@ -"""Auth commands: login, logout, status, set-workspace.""" +"""Auth commands: login, logout, status, set-region, set-workspace.""" from __future__ import annotations @@ -18,10 +18,19 @@ def login( login_workspace: Annotated[ Optional[str], typer.Option("--workspace", help="Set default workspace during login") ] = None, + region: Annotated[ + Optional[str], typer.Option("--region", metavar="{us,eu}", help="Roboflow platform region") + ] = None, force: Annotated[bool, typer.Option("--force", "-f", help="Force re-login even if already logged in")] = False, ) -> None: """Log in to Roboflow.""" - args = ctx_to_args(ctx, login_api_key=login_api_key, login_workspace=login_workspace, force=force) + args = ctx_to_args( + ctx, + login_api_key=login_api_key, + login_workspace=login_workspace, + region=region, + force=force, + ) _login(args) @@ -42,6 +51,16 @@ def set_workspace( _set_workspace(args) +@auth_app.command("set-region") +def set_region( + ctx: typer.Context, + region: Annotated[str, typer.Argument(metavar="{us,eu}", help="Roboflow platform region")], +) -> None: + """Set the Roboflow platform region.""" + args = ctx_to_args(ctx, region=region) + _set_region(args) + + @auth_app.command("logout") def logout(ctx: typer.Context) -> None: """Remove stored credentials.""" @@ -95,6 +114,37 @@ def _mask_key(key: str) -> str: return key[:2] + "*" * (len(key) - 4) + key[-2:] +def _validate_region(args, region: Optional[str]) -> Optional[str]: # noqa: ANN001 + """Normalize and validate a region supplied explicitly on the CLI.""" + if region is None: + return None + + normalized = region.lower() + if normalized not in {"us", "eu"}: + from roboflow.cli._output import output_error + + output_error( + args, + f"Invalid region '{region}'.", + hint="Region must be 'us' or 'eu'.", + exit_code=2, + ) + return normalized + + +def _region_status() -> tuple[dict[str, str], list[str]]: + """Return the effective region metadata in structured and text forms.""" + from roboflow.config import get_effective_region, resolve_url + + region = get_effective_region() + api_url = resolve_url("API_URL", region=region) + app_url = resolve_url("APP_URL", region=region) + return ( + {"region": region, "api_url": api_url, "app_url": app_url}, + [f"Region: {region}", f"API URL: {api_url}", f"App URL: {app_url}"], + ) + + def _print_completion_tip(args) -> None: # noqa: ANN001 """Nudge users towards shell completion after a successful login. @@ -111,17 +161,20 @@ def _login(args): # noqa: ANN001 api_key = getattr(args, "login_api_key", None) or getattr(args, "api_key", None) workspace_id = getattr(args, "login_workspace", None) or getattr(args, "workspace", None) + region = _validate_region(args, getattr(args, "region", None)) force = getattr(args, "force", False) if api_key: # Non-interactive: validate key and fetch workspace info import requests - from roboflow.config import API_URL + from roboflow.config import resolve_url - resp = requests.post(API_URL + "/?api_key=" + api_key) + api_url = resolve_url("API_URL", region=region) + app_url = resolve_url("APP_URL", region=region) + resp = requests.post(api_url + "/?api_key=" + api_key) if resp.status_code == 401: - output_error(args, "Invalid API key.", hint="Check your key at app.roboflow.com/settings", exit_code=2) + output_error(args, "Invalid API key.", hint=f"Check your key at {app_url}/settings", exit_code=2) return if resp.status_code != 200: output_error(args, f"API error ({resp.status_code}).", exit_code=1) @@ -141,9 +194,9 @@ def _login(args): # noqa: ANN001 # Fetch workspace name from the API ws_name = ws_url try: - from roboflow.adapters import rfapi - - ws_json = rfapi.get_workspace(api_key, ws_url) + ws_resp = requests.get(f"{api_url}/{ws_url}?api_key={api_key}") + ws_resp.raise_for_status() + ws_json = ws_resp.json() ws_detail = ws_json.get("workspace", ws_json) ws_name = ws_detail.get("name", ws_url) except Exception: # noqa: BLE001 @@ -155,6 +208,8 @@ def _login(args): # noqa: ANN001 workspaces[ws_url] = {"url": ws_url, "name": ws_name, "apiKey": api_key} config["workspaces"] = workspaces config["RF_WORKSPACE"] = ws_url + if region is not None: + config["ROBOFLOW_REGION"] = region _save_config(config) note = "" @@ -184,7 +239,7 @@ def _login(args): # noqa: ANN001 ) return - roboflow.login(workspace=workspace_id, force=force) + roboflow.login(workspace=workspace_id, force=force, region=region) # Re-read config after interactive login config = _load_config() ws = config.get("RF_WORKSPACE", "unknown") @@ -202,6 +257,7 @@ def _status(args): # noqa: ANN001 from roboflow.cli._output import output, output_error + region_data, region_lines = _region_status() config = _load_config() workspaces = config.get("workspaces", {}) default_ws_url = config.get("RF_WORKSPACE") @@ -215,14 +271,19 @@ def _status(args): # noqa: ANN001 if explicit_api_key or (api_key and not default_ws_url): import requests - from roboflow.config import API_URL + from roboflow.config import resolve_url assert api_key is not None # guaranteed by the condition above - resp = requests.post(API_URL + "/?api_key=" + api_key) + resp = requests.post(resolve_url("API_URL", region=region_data["region"]) + "/?api_key=" + api_key) if resp.status_code == 200: ws_url = resp.json().get("workspace", "unknown") - data = {"url": ws_url, "name": ws_url, "apiKey": _mask_key(api_key)} - lines = [ + data = { + "url": ws_url, + "name": ws_url, + "apiKey": _mask_key(api_key), + **region_data, + } + lines = region_lines + [ f"Workspace: {ws_url}", f" URL: {ws_url}", f" API Key: {_mask_key(api_key)}", @@ -234,6 +295,21 @@ def _status(args): # noqa: ANN001 return if not workspaces and not default_ws_url and not api_key: + if getattr(args, "json", False): + import json + import sys + + payload = { + "error": { + "message": "Not logged in.", + "hint": "Run 'roboflow auth login' to authenticate.", + }, + **region_data, + } + print(json.dumps(payload), file=sys.stderr) + raise SystemExit(2) + + output(args, region_data, text="\n".join(region_lines)) output_error(args, "Not logged in.", hint="Run 'roboflow auth login' to authenticate.", exit_code=2) return # unreachable, but helps mypy @@ -249,7 +325,8 @@ def _status(args): # noqa: ANN001 display_key = api_key or default_ws.get("apiKey", "") masked = dict(default_ws) masked["apiKey"] = _mask_key(display_key) - lines = [ + masked.update(region_data) + lines = region_lines + [ f"Workspace: {masked.get('name', 'unknown')}", f" URL: {masked.get('url', 'unknown')}", f" API Key: {masked['apiKey']}", @@ -257,14 +334,52 @@ def _status(args): # noqa: ANN001 output(args, masked, text="\n".join(lines)) else: # RF_WORKSPACE is set but no matching workspace details - data = {"url": default_ws_url, "name": default_ws_url} + data = {"url": default_ws_url, "name": default_ws_url, **region_data} output( args, data, - text=f"Workspace: {default_ws_url}\n (no detailed info available)", + text="\n".join(region_lines + [f"Workspace: {default_ws_url}", " (no detailed info available)"]), ) +def _set_region(args): # noqa: ANN001 + from roboflow.cli._output import output + from roboflow.config import get_effective_region, resolve_url + + region = _validate_region(args, args.region) + assert region is not None + previous_region = get_effective_region() + + config = _load_config() + config["ROBOFLOW_REGION"] = region + _save_config(config) + + effective_region = get_effective_region() + api_url = resolve_url("API_URL") + app_url = resolve_url("APP_URL") + warning = ( + f"Stored credentials were issued by the previously configured {previous_region.upper()} platform. " + "EU and US use separate authentication backends and API keys, so " + f"'roboflow auth login --force --region {region}' may be needed." + ) + environment_note = "" + if effective_region != region: + environment_note = ( + f"\nNote: ROBOFLOW_REGION overrides the saved choice; the effective region remains {effective_region}." + ) + output( + args, + { + "region": effective_region, + "configured_region": region, + "api_url": api_url, + "app_url": app_url, + "warning": warning, + }, + text=(f"Region set to: {region}{environment_note}\nAPI URL: {api_url}\nApp URL: {app_url}\nWarning: {warning}"), + ) + + def _set_workspace(args): # noqa: ANN001 from roboflow.cli._output import output diff --git a/roboflow/config.py b/roboflow/config.py index 800ef6ac..13fd70f6 100644 --- a/roboflow/config.py +++ b/roboflow/config.py @@ -1,5 +1,32 @@ import json import os +import sys + +REGION_URL_DEFAULTS = { + "us": {}, + "eu": { + "API_URL": "https://api.roboflow.eu", + "APP_URL": "https://app.roboflow.eu", + "OBJECT_DETECTION_URL": "https://serverless.roboflow.eu", + "INSTANCE_SEGMENTATION_URL": "https://serverless.roboflow.eu", + "DEDICATED_DEPLOYMENT_URL": "https://eu.roboflow.cloud", + }, +} + +URL_DEFAULTS = { + "API_URL": "https://api.roboflow.com", + "APP_URL": "https://app.roboflow.com", + "UNIVERSE_URL": "https://universe.roboflow.com", + "INSTANCE_SEGMENTATION_URL": "https://serverless.roboflow.com", + "SEMANTIC_SEGMENTATION_URL": "https://segment.roboflow.com", + "OBJECT_DETECTION_URL": "https://serverless.roboflow.com", + "CLIP_FEATURIZE_URL": "CLIP FEATURIZE URL NOT IN ENV", + "OCR_URL": "OCR URL NOT IN ENV", + "DEDICATED_DEPLOYMENT_URL": "https://roboflow.cloud", +} + +_UNSET = object() +_WARNED_UNKNOWN_REGIONS: set[str] = set() def get_conditional_configuration_variable(key, default): @@ -42,6 +69,40 @@ def get_conditional_configuration_variable(key, default): return default +def _normalize_region(region) -> str: + normalized_region = region.strip().lower() if isinstance(region, str) else "" + if normalized_region in REGION_URL_DEFAULTS: + return normalized_region + + warning_key = repr(region) + if warning_key not in _WARNED_UNKNOWN_REGIONS: + print( + f"Warning: unknown Roboflow region {region!r}; falling back to 'us'.", + file=sys.stderr, + ) + _WARNED_UNKNOWN_REGIONS.add(warning_key) + return "us" + + +def get_effective_region() -> str: + """Return the configured Roboflow region, defaulting safely to US.""" + region = get_conditional_configuration_variable("ROBOFLOW_REGION", default="us") + return _normalize_region(region) + + +def resolve_url(key: str, region: str | None = None) -> str: + """Resolve a Roboflow URL using explicit overrides before region defaults.""" + if key not in URL_DEFAULTS: + raise KeyError(f"Unknown Roboflow URL configuration key: {key}") + + explicit_url = get_conditional_configuration_variable(key, default=_UNSET) + if explicit_url is not _UNSET: + return explicit_url + + effective_region = get_effective_region() if region is None else _normalize_region(region) + return REGION_URL_DEFAULTS[effective_region].get(key, URL_DEFAULTS[key]) + + CLASSIFICATION_MODEL = os.getenv("CLASSIFICATION_MODEL", "ClassificationModel") INSTANCE_SEGMENTATION_MODEL = "InstanceSegmentationModel" KEYPOINT_DETECTION_MODEL = "KeypointDetectionModel" @@ -49,22 +110,18 @@ def get_conditional_configuration_variable(key, default): SEMANTIC_SEGMENTATION_MODEL = "SemanticSegmentationModel" PREDICTION_OBJECT = os.getenv("PREDICTION_OBJECT", "Prediction") -API_URL = get_conditional_configuration_variable("API_URL", "https://api.roboflow.com") -APP_URL = get_conditional_configuration_variable("APP_URL", "https://app.roboflow.com") -UNIVERSE_URL = get_conditional_configuration_variable("UNIVERSE_URL", "https://universe.roboflow.com") +API_URL = resolve_url("API_URL") +APP_URL = resolve_url("APP_URL") +UNIVERSE_URL = resolve_url("UNIVERSE_URL") -INSTANCE_SEGMENTATION_URL = get_conditional_configuration_variable( - "INSTANCE_SEGMENTATION_URL", "https://serverless.roboflow.com" -) -SEMANTIC_SEGMENTATION_URL = get_conditional_configuration_variable( - "SEMANTIC_SEGMENTATION_URL", "https://segment.roboflow.com" -) -OBJECT_DETECTION_URL = get_conditional_configuration_variable("OBJECT_DETECTION_URL", "https://serverless.roboflow.com") +INSTANCE_SEGMENTATION_URL = resolve_url("INSTANCE_SEGMENTATION_URL") +SEMANTIC_SEGMENTATION_URL = resolve_url("SEMANTIC_SEGMENTATION_URL") +OBJECT_DETECTION_URL = resolve_url("OBJECT_DETECTION_URL") -CLIP_FEATURIZE_URL = get_conditional_configuration_variable("CLIP_FEATURIZE_URL", "CLIP FEATURIZE URL NOT IN ENV") -OCR_URL = get_conditional_configuration_variable("OCR_URL", "OCR URL NOT IN ENV") +CLIP_FEATURIZE_URL = resolve_url("CLIP_FEATURIZE_URL") +OCR_URL = resolve_url("OCR_URL") -DEDICATED_DEPLOYMENT_URL = get_conditional_configuration_variable("DEDICATED_DEPLOYMENT_URL", "https://roboflow.cloud") +DEDICATED_DEPLOYMENT_URL = resolve_url("DEDICATED_DEPLOYMENT_URL") DEMO_KEYS = ["coco-128-sample", "chess-sample-only-api-key"] diff --git a/tests/cli/test_auth_region.py b/tests/cli/test_auth_region.py new file mode 100644 index 00000000..9f75defb --- /dev/null +++ b/tests/cli/test_auth_region.py @@ -0,0 +1,180 @@ +"""Region-specific tests for the auth CLI handler.""" + +import json +import os +import tempfile +import unittest +from unittest import mock + +import responses +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + + +class TestAuthRegion(unittest.TestCase): + def setUp(self) -> None: + self.tempdir = tempfile.TemporaryDirectory() + self.config_path = os.path.join(self.tempdir.name, "config.json") + self.env_patch = mock.patch.dict( + os.environ, + {"ROBOFLOW_CONFIG_DIR": self.config_path}, + clear=False, + ) + self.env_patch.start() + for key in ("ROBOFLOW_REGION", "API_URL", "APP_URL", "ROBOFLOW_API_KEY"): + os.environ.pop(key, None) + + def tearDown(self) -> None: + self.env_patch.stop() + self.tempdir.cleanup() + + def _write_config(self, config: dict) -> None: + with open(self.config_path, "w") as config_file: + json.dump(config, config_file) + + def _read_config(self) -> dict: + with open(self.config_path) as config_file: + return json.load(config_file) + + def _write_logged_in_config(self) -> None: + self._write_config( + { + "workspaces": { + "eu-workspace": { + "url": "eu-workspace", + "name": "EU Workspace", + "apiKey": "eu-secret-key", + } + }, + "RF_WORKSPACE": "eu-workspace", + } + ) + + def test_login_and_alias_help_include_region(self) -> None: + auth_result = runner.invoke(app, ["auth", "login", "--help"]) + alias_result = runner.invoke(app, ["login", "--help"]) + + self.assertEqual(auth_result.exit_code, 0) + self.assertEqual(alias_result.exit_code, 0) + self.assertIn("--region", auth_result.output) + self.assertIn("--region", alias_result.output) + + def test_interactive_login_passes_normalized_region(self) -> None: + with mock.patch("roboflow.login") as login: + result = runner.invoke(app, ["auth", "login", "--region", "EU"]) + + self.assertEqual(result.exit_code, 0, result.output) + login.assert_called_once_with(workspace=None, force=False, region="eu") + + def test_login_alias_passes_normalized_region(self) -> None: + with mock.patch("roboflow.login") as login: + result = runner.invoke(app, ["login", "--region", "EU"]) + + self.assertEqual(result.exit_code, 0, result.output) + login.assert_called_once_with(workspace=None, force=False, region="eu") + + @responses.activate + def test_api_key_login_uses_eu_api_and_persists_region(self) -> None: + responses.add( + responses.POST, + "https://api.roboflow.eu/?api_key=eu-key", + json={"workspace": "eu-workspace"}, + status=200, + ) + responses.add( + responses.GET, + "https://api.roboflow.eu/eu-workspace?api_key=eu-key", + json={"workspace": {"name": "EU Workspace"}}, + status=200, + ) + + result = runner.invoke( + app, + ["auth", "login", "--api-key", "eu-key", "--region", "eu"], + ) + + self.assertEqual(result.exit_code, 0, result.output) + self.assertEqual( + [call.request.url for call in responses.calls], + [ + "https://api.roboflow.eu/?api_key=eu-key", + "https://api.roboflow.eu/eu-workspace?api_key=eu-key", + ], + ) + config = self._read_config() + self.assertEqual(config["ROBOFLOW_REGION"], "eu") + self.assertEqual(config["RF_WORKSPACE"], "eu-workspace") + self.assertEqual(config["workspaces"]["eu-workspace"]["apiKey"], "eu-key") + + def test_set_region_then_status_shows_eu_endpoints(self) -> None: + self._write_logged_in_config() + + set_result = runner.invoke(app, ["auth", "set-region", "eu"]) + status_result = runner.invoke(app, ["auth", "status"]) + + self.assertEqual(set_result.exit_code, 0, set_result.output) + self.assertIn("Region set to: eu", set_result.output) + self.assertIn("API URL: https://api.roboflow.eu", set_result.output) + self.assertIn("App URL: https://app.roboflow.eu", set_result.output) + self.assertIn("separate authentication backends and API keys", set_result.output) + self.assertIn("roboflow auth login --force", set_result.output) + self.assertEqual(status_result.exit_code, 0, status_result.output) + self.assertIn("Region: eu", status_result.output) + self.assertIn("API URL: https://api.roboflow.eu", status_result.output) + self.assertIn("App URL: https://app.roboflow.eu", status_result.output) + self.assertEqual(self._read_config()["ROBOFLOW_REGION"], "eu") + + def test_status_json_includes_region_and_urls(self) -> None: + self._write_logged_in_config() + runner.invoke(app, ["auth", "set-region", "eu"]) + + result = runner.invoke(app, ["--json", "auth", "status"]) + + self.assertEqual(result.exit_code, 0, result.output) + payload = json.loads(result.stdout) + self.assertEqual(payload["region"], "eu") + self.assertEqual(payload["api_url"], "https://api.roboflow.eu") + self.assertEqual(payload["app_url"], "https://app.roboflow.eu") + + def test_set_region_reports_environment_override_as_effective(self) -> None: + os.environ["ROBOFLOW_REGION"] = "us" + + result = runner.invoke(app, ["--json", "auth", "set-region", "eu"]) + + self.assertEqual(result.exit_code, 0, result.output) + payload = json.loads(result.stdout) + self.assertEqual(payload["configured_region"], "eu") + self.assertEqual(payload["region"], "us") + self.assertEqual(payload["api_url"], "https://api.roboflow.com") + self.assertEqual(payload["app_url"], "https://app.roboflow.com") + self.assertEqual(self._read_config()["ROBOFLOW_REGION"], "eu") + + def test_region_only_status_shows_endpoints_and_remains_not_logged_in(self) -> None: + set_result = runner.invoke(app, ["auth", "set-region", "eu"]) + status_result = runner.invoke(app, ["--json", "auth", "status"]) + + self.assertEqual(set_result.exit_code, 0, set_result.output) + self.assertEqual(status_result.exit_code, 2, status_result.output) + payload = json.loads(status_result.stderr) + self.assertEqual(payload["error"]["message"], "Not logged in.") + self.assertEqual(payload["region"], "eu") + self.assertEqual(payload["api_url"], "https://api.roboflow.eu") + self.assertEqual(payload["app_url"], "https://app.roboflow.eu") + + def test_set_region_rejects_invalid_value_without_mutating_config(self) -> None: + original = {"ROBOFLOW_REGION": "us", "preserved": True} + self._write_config(original) + + result = runner.invoke(app, ["auth", "set-region", "bogus"]) + + self.assertNotEqual(result.exit_code, 0) + self.assertIn("Invalid region 'bogus'", result.output) + self.assertIn("must be 'us' or 'eu'", result.output) + self.assertEqual(self._read_config(), original) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_login_region.py b/tests/test_login_region.py new file mode 100644 index 00000000..40bdb62d --- /dev/null +++ b/tests/test_login_region.py @@ -0,0 +1,108 @@ +"""Tests for region-aware interactive login.""" + +import io +import json +import os +import tempfile +import unittest +from contextlib import redirect_stdout +from unittest import mock + +import responses + +import roboflow + + +class TestLoginRegion(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory() + self.config_path = os.path.join(self.temporary_directory.name, "config.json") + self.environment = mock.patch.dict( + os.environ, + { + "HOME": self.temporary_directory.name, + "ROBOFLOW_CONFIG_DIR": self.config_path, + }, + clear=True, + ) + self.environment.start() + + def tearDown(self) -> None: + self.environment.stop() + self.temporary_directory.cleanup() + + @responses.activate + def test_eu_login_uses_eu_app_and_persists_region(self) -> None: + token = "auth-token" + workspaces = { + "workspace-id": { + "url": "example-workspace", + "apiKey": "example-api-key", + } + } + responses.get( + f"https://app.roboflow.eu/query/cliAuthToken/{token}", + json=workspaces, + status=200, + ) + + output = io.StringIO() + with mock.patch.object(roboflow, "getpass", return_value=token), redirect_stdout(output): + roboflow.login(region="EU") + + self.assertIn("https://app.roboflow.eu/auth-cli", output.getvalue()) + self.assertEqual(responses.calls[0].request.url, f"https://app.roboflow.eu/query/cliAuthToken/{token}") + with open(self.config_path) as config_file: + config = json.load(config_file) + self.assertEqual(config["ROBOFLOW_REGION"], "eu") + self.assertEqual(config["workspaces"], workspaces) + self.assertEqual(config["RF_WORKSPACE"], "example-workspace") + + @responses.activate + def test_forced_login_preserves_existing_region_and_other_config(self) -> None: + existing_config = { + "ROBOFLOW_REGION": "eu", + "API_URL": "https://custom-api.example.com", + "workspaces": {"old": {"url": "old-workspace", "apiKey": "old-key"}}, + "RF_WORKSPACE": "old-workspace", + } + with open(self.config_path, "w") as config_file: + json.dump(existing_config, config_file) + + token = "replacement-token" + workspaces = { + "new": { + "url": "new-workspace", + "apiKey": "new-key", + } + } + responses.get( + f"https://app.roboflow.eu/query/cliAuthToken/{token}", + json=workspaces, + status=200, + ) + + with mock.patch.object(roboflow, "getpass", return_value=token), redirect_stdout(io.StringIO()): + roboflow.login(force=True) + + with open(self.config_path) as config_file: + config = json.load(config_file) + self.assertEqual(config["ROBOFLOW_REGION"], "eu") + self.assertEqual(config["API_URL"], "https://custom-api.example.com") + self.assertEqual(config["workspaces"], workspaces) + self.assertEqual(config["RF_WORKSPACE"], "new-workspace") + + def test_invalid_region_does_not_mutate_config(self) -> None: + original_config = {"ROBOFLOW_REGION": "eu", "marker": "unchanged"} + with open(self.config_path, "w") as config_file: + json.dump(original_config, config_file) + + with self.assertRaisesRegex(ValueError, "Invalid region 'bogus'.*us, eu"): + roboflow.login(force=True, region="bogus") + + with open(self.config_path) as config_file: + self.assertEqual(json.load(config_file), original_config) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_region_config.py b/tests/test_region_config.py new file mode 100644 index 00000000..c2ce5754 --- /dev/null +++ b/tests/test_region_config.py @@ -0,0 +1,127 @@ +"""Tests for region-aware Roboflow URL configuration.""" + +import contextlib +import importlib +import io +import json +import os +import tempfile +import unittest +from pathlib import Path + +import roboflow.config as config_module + +URL_DEFAULTS = { + "API_URL": "https://api.roboflow.com", + "APP_URL": "https://app.roboflow.com", + "UNIVERSE_URL": "https://universe.roboflow.com", + "INSTANCE_SEGMENTATION_URL": "https://serverless.roboflow.com", + "SEMANTIC_SEGMENTATION_URL": "https://segment.roboflow.com", + "OBJECT_DETECTION_URL": "https://serverless.roboflow.com", + "CLIP_FEATURIZE_URL": "CLIP FEATURIZE URL NOT IN ENV", + "OCR_URL": "OCR URL NOT IN ENV", + "DEDICATED_DEPLOYMENT_URL": "https://roboflow.cloud", +} + +REGION_ENVIRONMENT_KEYS = ("ROBOFLOW_CONFIG_DIR", "ROBOFLOW_REGION", *URL_DEFAULTS) + + +class TestRegionConfiguration(unittest.TestCase): + def setUp(self) -> None: + self.temp_directory = tempfile.TemporaryDirectory() + self.config_path = Path(self.temp_directory.name) / "config.json" + self.saved_environment = {key: os.environ[key] for key in REGION_ENVIRONMENT_KEYS if key in os.environ} + for key in REGION_ENVIRONMENT_KEYS: + os.environ.pop(key, None) + os.environ["ROBOFLOW_CONFIG_DIR"] = str(self.config_path) + self.config = importlib.reload(config_module) + + def tearDown(self) -> None: + for key in REGION_ENVIRONMENT_KEYS: + os.environ.pop(key, None) + os.environ.update(self.saved_environment) + importlib.reload(config_module) + self.temp_directory.cleanup() + + def _write_config(self, config: dict) -> None: + self.config_path.write_text(json.dumps(config)) + + def _reload_config(self): + self.config = importlib.reload(config_module) + return self.config + + def test_existing_us_url_defaults_are_unchanged(self) -> None: + self.assertEqual(self.config.get_effective_region(), "us") + for key, expected_url in URL_DEFAULTS.items(): + with self.subTest(key=key): + self.assertEqual(getattr(self.config, key), expected_url) + self.assertEqual(self.config.resolve_url(key), expected_url) + + def test_region_and_explicit_url_precedence(self) -> None: + self._write_config({"ROBOFLOW_REGION": "us"}) + os.environ["ROBOFLOW_REGION"] = "EU" + config = self._reload_config() + self.assertEqual(config.get_effective_region(), "eu") + self.assertEqual(config.API_URL, "https://api.roboflow.eu") + + os.environ.pop("ROBOFLOW_REGION") + self._write_config({"ROBOFLOW_REGION": "eU"}) + config = self._reload_config() + self.assertEqual(config.get_effective_region(), "eu") + self.assertEqual(config.API_URL, "https://api.roboflow.eu") + + os.environ["API_URL"] = "https://api.env.example" + config = self._reload_config() + self.assertEqual(config.API_URL, "https://api.env.example") + self.assertEqual(config.resolve_url("API_URL"), "https://api.env.example") + + os.environ.pop("API_URL") + self._write_config( + { + "ROBOFLOW_REGION": "eu", + "API_URL": "https://api.config.example", + } + ) + config = self._reload_config() + self.assertEqual(config.API_URL, "https://api.config.example") + self.assertEqual(config.resolve_url("API_URL"), "https://api.config.example") + + def test_eu_region_url_map(self) -> None: + self._write_config({"ROBOFLOW_REGION": "eu"}) + config = self._reload_config() + expected_urls = { + "API_URL": "https://api.roboflow.eu", + "APP_URL": "https://app.roboflow.eu", + "OBJECT_DETECTION_URL": "https://serverless.roboflow.eu", + "INSTANCE_SEGMENTATION_URL": "https://serverless.roboflow.eu", + "DEDICATED_DEPLOYMENT_URL": "https://eu.roboflow.cloud", + "UNIVERSE_URL": "https://universe.roboflow.com", + "SEMANTIC_SEGMENTATION_URL": "https://segment.roboflow.com", + } + for key, expected_url in expected_urls.items(): + with self.subTest(key=key): + self.assertEqual(getattr(config, key), expected_url) + self.assertEqual(config.resolve_url(key), expected_url) + + os.environ["ROBOFLOW_REGION"] = "us" + self.assertEqual( + config.resolve_url("API_URL", region="EU"), + "https://api.roboflow.eu", + ) + + def test_unknown_region_warns_once_and_falls_back_to_us(self) -> None: + os.environ["ROBOFLOW_REGION"] = "bogus" + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + config = self._reload_config() + self.assertEqual(config.get_effective_region(), "us") + self.assertEqual(config.resolve_url("API_URL"), URL_DEFAULTS["API_URL"]) + + warning_lines = stderr.getvalue().splitlines() + self.assertEqual(len(warning_lines), 1) + self.assertIn("unknown Roboflow region 'bogus'", warning_lines[0]) + self.assertIn("falling back to 'us'", warning_lines[0]) + + +if __name__ == "__main__": + unittest.main()