diff --git a/tests/test_cli/test_commands.py b/tests/test_cli/test_commands.py index 02e3fd5db5..bfe9bdbb83 100644 --- a/tests/test_cli/test_commands.py +++ b/tests/test_cli/test_commands.py @@ -13,7 +13,8 @@ def test_tidy3d_root_command_names_are_unique(): command_names = list(tidy3d_cli.commands.keys()) assert len(command_names) == len(set(command_names)) assert "config" in tidy3d_cli.commands - assert "migrate" in tidy3d_cli.commands + assert {"configure", "convert", "develop"}.issubset(set(command_names)) + assert "migrate" not in tidy3d_cli.commands def test_config_group_commands_are_namespaced(): diff --git a/tests/test_cli/test_migrate.py b/tests/test_cli/test_migrate.py deleted file mode 100644 index 6d8ed03e9c..0000000000 --- a/tests/test_cli/test_migrate.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import annotations - -import json -import os -from pathlib import Path - -import pytest -from pydantic import SecretStr - -from tidy3d.config import config, reload_config -from tidy3d.web.cli import migrate as migrate_module - - -@pytest.fixture -def temp_config_dir(monkeypatch, tmp_path) -> Path: - """Provide an isolated configuration directory for migration tests.""" - - original_base = os.environ.get("TIDY3D_BASE_DIR") - monkeypatch.setenv("TIDY3D_BASE_DIR", str(tmp_path)) - reload_config(profile="default") - config_dir = Path(tmp_path) / "config" - config_dir.mkdir(parents=True, exist_ok=True) - yield config_dir - if original_base is None: - monkeypatch.delenv("TIDY3D_BASE_DIR", raising=False) - else: - monkeypatch.setenv("TIDY3D_BASE_DIR", original_base) - reload_config(profile="default") - - -def _normalize_secret(value): - if isinstance(value, SecretStr): - return value.get_secret_value() - return value - - -def _write_auth_file(config_dir: Path) -> Path: - credential_path = config_dir / "auth.json" - credential_path.write_text( - json.dumps({"email": "user@example.com", "password": "hunter2"}), - encoding="utf-8", - ) - return credential_path - - -def test_persist_api_key_creates_backup(temp_config_dir): - credential_path = _write_auth_file(temp_config_dir) - result = migrate_module._persist_api_key("new-api-key", credential_path) - - assert result is True - assert not credential_path.exists() - assert (temp_config_dir / "auth.json.bak").is_file() - assert _normalize_secret(config.web.apikey) == "new-api-key" - - -def test_persist_api_key_rolls_back_when_save_fails(monkeypatch, temp_config_dir): - credential_path = _write_auth_file(temp_config_dir) - original_apikey = config.web.apikey - - def failing_save(*args, **kwargs): - raise OSError("disk full") - - monkeypatch.setattr(migrate_module.config, "save", failing_save) - - result = migrate_module._persist_api_key("new-api-key", credential_path) - - assert result is False - assert credential_path.is_file() - assert not (temp_config_dir / "auth.json.bak").exists() - assert _normalize_secret(config.web.apikey) == _normalize_secret(original_apikey) - - -def test_persist_api_key_rolls_back_when_backup_fails(monkeypatch, temp_config_dir): - credential_path = _write_auth_file(temp_config_dir) - original_apikey = config.web.apikey - - def failing_replace(*args, **kwargs): - raise PermissionError("read-only filesystem") - - monkeypatch.setattr(migrate_module.os, "replace", failing_replace) - - result = migrate_module._persist_api_key("new-api-key", credential_path) - - assert result is False - assert credential_path.is_file() - assert not (temp_config_dir / "auth.json.bak").exists() - assert _normalize_secret(config.web.apikey) == _normalize_secret(original_apikey) diff --git a/tidy3d/config/loader.py b/tidy3d/config/loader.py index 53046cb5ef..0e71210d30 100644 --- a/tidy3d/config/loader.py +++ b/tidy3d/config/loader.py @@ -256,6 +256,18 @@ def resolve_config_directory() -> Path: ) return _temporary_config_dir() + canonical_dir = canonical_config_directory() + if _is_writable(canonical_dir.parent): + legacy_dir = legacy_config_directory() + if legacy_dir.exists(): + log.warning( + f"Using canonical configuration directory at '{canonical_dir}'. " + "Found legacy directory at '~/.tidy3d', which will be ignored. " + "Remove it manually or run 'tidy3d config migrate --delete-legacy' to clean up.", + log_once=True, + ) + return canonical_dir + legacy_dir = legacy_config_directory() if legacy_dir.exists(): log.warning( @@ -264,10 +276,6 @@ def resolve_config_directory() -> Path: ) return legacy_dir - canonical_dir = canonical_config_directory() - if _is_writable(canonical_dir.parent): - return canonical_dir - log.warning(f"Unable to write to '{canonical_dir}'; falling back to temporary directory.") return _temporary_config_dir() diff --git a/tidy3d/web/__init__.py b/tidy3d/web/__init__.py index e69a00f78f..0cdc8942e5 100644 --- a/tidy3d/web/__init__.py +++ b/tidy3d/web/__init__.py @@ -39,9 +39,6 @@ ) from .cli import tidy3d_cli from .cli.app import configure_fn as configure -from .cli.migrate import migrate - -migrate() __all__ = [ "Batch", diff --git a/tidy3d/web/cli/app.py b/tidy3d/web/cli/app.py index 8393b2ec02..00911c1f1b 100644 --- a/tidy3d/web/cli/app.py +++ b/tidy3d/web/cli/app.py @@ -4,8 +4,8 @@ from __future__ import annotations -import json -import os.path +import os +import shutil import ssl import click @@ -17,8 +17,7 @@ legacy_config_directory, migrate_legacy_config, ) -from tidy3d.web.cli.constants import CREDENTIAL_FILE, TIDY3D_DIR -from tidy3d.web.cli.migrate import migrate as migrate_authentication +from tidy3d.web.cli.constants import TIDY3D_DIR from tidy3d.web.core.constants import HEADER_APIKEY from tidy3d.web.core.environment import Env @@ -90,15 +89,6 @@ def auth(req): req.headers[HEADER_APIKEY] = apikey return req - if os.path.exists(CREDENTIAL_FILE): - with open(CREDENTIAL_FILE, encoding="utf-8") as fp: - auth_json = json.load(fp) - email = auth_json["email"] - password = auth_json["password"] - if email and password: - if migrate_authentication(): - return - if not apikey: current_apikey = get_description() message = f"Current API key: [{current_apikey}]\n" if current_apikey else "" @@ -119,12 +109,6 @@ def auth(req): click.echo("API key is invalid.") -@click.command(name="auth-migrate") -def migrate_command(): - """Click command to migrate the credential to api key.""" - migrate_authentication() - - @click.command() @click.argument("lsf_file") @click.argument("new_file") @@ -158,20 +142,7 @@ def config_reset(yes: bool, preserve_profiles: bool) -> None: click.echo("Configuration reset to defaults.") -@click.command(name="config-migrate") -@click.option( - "--overwrite", - is_flag=True, - help="Replace existing files in the destination configuration directory if they already exist.", -) -@click.option( - "--delete-legacy", - is_flag=True, - help="Remove the legacy '~/.tidy3d' directory after a successful migration.", -) -def config_migrate(overwrite: bool, delete_legacy: bool) -> None: - """Copy configuration files from '~/.tidy3d' to the canonical location.""" - +def _run_config_migration(overwrite: bool, delete_legacy: bool) -> None: legacy_dir = legacy_config_directory() if not legacy_dir.exists(): click.echo("No legacy configuration directory found at '~/.tidy3d'; nothing to migrate.") @@ -181,6 +152,20 @@ def config_migrate(overwrite: bool, delete_legacy: bool) -> None: try: destination = migrate_legacy_config(overwrite=overwrite, remove_legacy=delete_legacy) except FileExistsError: + if delete_legacy: + try: + shutil.rmtree(legacy_dir) + except OSError as exc: + click.echo( + f"Destination '{canonical_dir}' already exists and the legacy directory " + f"could not be removed. Error: {exc}" + ) + return + click.echo( + f"Destination '{canonical_dir}' already exists. " + "Skipped copying legacy files and removed the legacy '~/.tidy3d' directory." + ) + return click.echo( f"Destination '{canonical_dir}' already exists. " "Use '--overwrite' to replace the existing files." @@ -203,6 +188,23 @@ def config_migrate(overwrite: bool, delete_legacy: bool) -> None: ) +@click.command(name="config-migrate") +@click.option( + "--overwrite", + is_flag=True, + help="Replace existing files in the destination configuration directory if they already exist.", +) +@click.option( + "--delete-legacy", + is_flag=True, + help="Remove the legacy '~/.tidy3d' directory after a successful migration.", +) +def config_migrate(overwrite: bool, delete_legacy: bool) -> None: + """Copy configuration files from '~/.tidy3d' to the canonical location.""" + + _run_config_migration(overwrite, delete_legacy) + + @click.group() def config_group(): """Configuration utilities.""" @@ -212,7 +214,6 @@ def config_group(): config_group.add_command(config_reset, name="reset") tidy3d_cli.add_command(configure) -tidy3d_cli.add_command(migrate_command, name="migrate") tidy3d_cli.add_command(convert) tidy3d_cli.add_command(develop) tidy3d_cli.add_command(config_group, name="config") diff --git a/tidy3d/web/cli/constants.py b/tidy3d/web/cli/constants.py index 78c9e9ac19..4f467df7a2 100644 --- a/tidy3d/web/cli/constants.py +++ b/tidy3d/web/cli/constants.py @@ -8,4 +8,3 @@ TIDY3D_DIR = str(_CONFIG_ROOT) CONFIG_FILE = str(_CONFIG_ROOT / "config.toml") -CREDENTIAL_FILE = str(_CONFIG_ROOT / "auth.json") diff --git a/tidy3d/web/cli/migrate.py b/tidy3d/web/cli/migrate.py deleted file mode 100644 index ae5b540354..0000000000 --- a/tidy3d/web/cli/migrate.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Migrate authentication to API key.""" - -from __future__ import annotations - -import json -import os -from pathlib import Path - -import click -import requests - -from tidy3d.config import config -from tidy3d.web.core.constants import HEADER_APPLICATION, HEADER_APPLICATION_VALUE -from tidy3d.web.core.environment import Env - -from .constants import CREDENTIAL_FILE, TIDY3D_DIR - - -def _persist_api_key(apikey: str, credential_path: Path) -> bool: - """Persist the API key and back up the legacy auth file.""" - - previous_apikey = getattr(getattr(config, "web", None), "apikey", None) - - try: - config.update_section("web", apikey=apikey) - config.save() - except Exception as exc: - config.update_section("web", apikey=previous_apikey) - click.echo(f"Failed to store API key in configuration; migration aborted. Error: {exc}") - return False - - backup_path = credential_path.with_name(f"{credential_path.name}.bak") - try: - os.replace(credential_path, backup_path) - except OSError as exc: - config.update_section("web", apikey=previous_apikey) - try: - config.save() - except Exception: - pass - click.echo( - "Stored API key but failed to back up legacy 'auth.json'; " - "restored previous configuration. " - f"Error: {exc}" - ) - return False - - click.echo("Migrate successfully. auth.json is renamed to auth.json.bak.") - return True - - -def migrate() -> bool: - """Click command to migrate the credential to api key.""" - if os.path.exists(CREDENTIAL_FILE): - with open(CREDENTIAL_FILE, encoding="utf-8") as fp: - auth_json = json.load(fp) - email = auth_json["email"] - password = auth_json["password"] - if email and password: - is_migrate = click.prompt( - "This system was found to use the old authentication protocol based on auth.json, " - "which will not be supported in the upcoming 2.0 release. We strongly recommend " - "migrating to the API key authentication before the release. Would you like to " - "migrate to the API key authentication now? " - "This will create a '~/.tidy3d/config' file on your machine " - "to store the API key from your online account but all other " - "workings of Tidy3D will remain the same.", - type=bool, - default=True, - ) - if is_migrate: - headers = {HEADER_APPLICATION: HEADER_APPLICATION_VALUE} - resp = requests.get( - f"{Env.current.web_api_endpoint}/auth", - headers=headers, - auth=(email, password), - ) - if resp.status_code != 200: - click.echo(f"Migrate to api key failed: {resp.text}") - return False - # click.echo(json.dumps(resp.json(), indent=4)) - access_token = resp.json()["data"]["auth"]["accessToken"] - headers["Authorization"] = f"Bearer {access_token}" - resp = requests.get(f"{Env.current.web_api_endpoint}/apikey", headers=headers) - if resp.status_code != 200: - click.echo(f"Migrate to api key failed: {resp.text}") - return False - click.echo(json.dumps(resp.json(), indent=4)) - apikey = resp.json()["data"] - if not apikey: - resp = requests.post(f"{Env.current.web_api_endpoint}/apikey", headers=headers) - if resp.status_code != 200: - click.echo(f"Migrate to api key failed: {resp.text}") - return False - apikey = resp.json()["data"] - base_dir = Path(TIDY3D_DIR) - base_dir.mkdir(parents=True, exist_ok=True) - credential_path = Path(CREDENTIAL_FILE) - return _persist_api_key(apikey, credential_path) - click.echo("You can migrate to api key by running 'tidy3d migrate' command.") - click.echo("Could not find a valid auth.json file, skipping migration.") - return False