diff --git a/src/nsls2api/cli/settings.py b/src/nsls2api/cli/settings.py index f0c2d7aa..8469de85 100644 --- a/src/nsls2api/cli/settings.py +++ b/src/nsls2api/cli/settings.py @@ -1,9 +1,12 @@ import configparser import os +import sys from enum import Enum from pathlib import Path from typing import Any +from nsls2api.cli import settings_migration + class ApiEnvironment(str, Enum): PRODUCTION = "https://api.nsls2.bnl.gov" @@ -23,16 +26,34 @@ class Config: @staticmethod def get_filepath() -> Path: - """Get the configuration file path""" - config_user_home = os.path.expanduser("~") - return Path(config_user_home) / ".config" / "nsls2" + """Get the configuration file path ($XDG_CONFIG_HOME/nsls2/api/cli.ini). + + Respects XDG_CONFIG_HOME if set; falls back to ~/.config on POSIX and + %APPDATA% on Windows. + """ + if sys.platform == "win32": + appdata = os.environ.get("APPDATA", "").strip() + base = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming" + else: + xdg = os.environ.get("XDG_CONFIG_HOME", "").strip() + base = Path(xdg) if xdg else Path.home() / ".config" + return base / "nsls2" / "api" / "cli.ini" @classmethod def read(cls) -> configparser.ConfigParser: - """Read the configuration file""" - config = configparser.ConfigParser() + """Read the configuration file, migrating legacy config if present. + + If migration was skipped or failed and the new config file does not yet + exist, fall back to reading the legacy bare file so existing settings + (base_url, token) are not silently dropped. + """ config_filepath = cls.get_filepath() - config.read(config_filepath) + settings_migration.migrate_legacy_config(config_filepath) + config = configparser.ConfigParser() + if config_filepath.is_file(): + config.read(config_filepath) + else: + settings_migration.read_legacy_fallback(config, config_filepath) return config @classmethod diff --git a/src/nsls2api/cli/settings_migration.py b/src/nsls2api/cli/settings_migration.py new file mode 100644 index 00000000..f7da974b --- /dev/null +++ b/src/nsls2api/cli/settings_migration.py @@ -0,0 +1,220 @@ +"""Legacy config migration for nsls2api CLI. + +This module handles the one-time migration of the old bare-file config +(``~/.config/nsls2``) to the current XDG-compliant location. It is +intentionally isolated so that it can be removed entirely — along with +``test_settings_migration.py`` — once legacy configs are no longer in +the wild. + +Callers (``settings.Config.read``) interact only via the three public +functions: + +- :func:`legacy_filepath` — canonical legacy path. +- :func:`migrate_legacy_config` — perform the one-time migration. +- :func:`read_legacy_fallback` — back-compat read when migration is not + possible. +""" + +import configparser +import os +import shutil +import sys +import tempfile +from pathlib import Path + + +def legacy_filepath() -> Path: + """Return the old (pre-migration) config path: ``~/.config/nsls2``. + + The legacy config was written as a **bare file** (not a directory), + which prevents other nsls2 tools from using ``~/.config/nsls2/`` as a + directory. This path is always ``~/.config/nsls2`` regardless of + ``XDG_CONFIG_HOME``, because the legacy writer hard-coded it. + """ + return Path.home() / ".config" / "nsls2" + + +def migrate_legacy_config(config_filepath: Path) -> Path | None: + """Migrate the legacy bare-file config to *config_filepath*, if needed. + + If ``~/.config/nsls2`` exists as a **regular file** (the old format) + and *config_filepath* does not yet exist, copies the content to the + new location and removes the legacy file only after the new file is + confirmed written. + + In the default (no-XDG) case the legacy file occupies the path + component ``nsls2`` that must become a *directory* for the new path. + The legacy file is removed just before ``mkdir`` so the name is free; + content is already preserved in a temporary copy made at the start of + migration. + + Returns the new path if migration occurred, ``None`` otherwise. + + Emits a warning to stderr and returns ``None`` (without raising) if + migration fails for any reason, so a failure never breaks a normal CLI + command. + """ + legacy = legacy_filepath() + new = config_filepath + + # Already migrated (or user already has a new-style config) — nothing to do. + if new.exists(): + return None + + # Only migrate a plain FILE. If legacy is a directory, skip silently. + if not legacy.is_file(): + return None + + # Rename (the staging step) requires write+execute on the containing + # directory, not on the file itself. + if not os.access(legacy.parent, os.W_OK | os.X_OK): + print( + f"Warning: nsls2api config migration skipped — " + f"'{legacy.parent}' is not writable. " + f"To migrate manually:\n" + f" 1. Move '{legacy}' aside (e.g. rename it to '{legacy}.bak')\n" + f" 2. Create the directory '{new.parent}'\n" + f" 3. Move the saved file into place as '{new}'", + file=sys.stderr, + ) + return None + + # Step 1 — copy legacy content to a temp sibling. Legacy stays in place + # until migration succeeds; a failure here leaves everything unchanged. + fd, tmp_name = tempfile.mkstemp(dir=legacy.parent, prefix=".nsls2-migrate-") + os.close(fd) + tmp = Path(tmp_name) + try: + shutil.copy2(legacy, tmp) + except OSError as exc: + tmp.unlink(missing_ok=True) + print( + f"Warning: nsls2api config migration failed ({exc}). " + f"Your settings remain at '{legacy}'. " + f"To migrate manually:\n" + f" 1. Move '{legacy}' aside (e.g. rename it to '{legacy}.bak')\n" + f" 2. Create the directory '{new.parent}'\n" + f" 3. Move the saved file into place as '{new}'", + file=sys.stderr, + ) + return None + + # Step 2 — move tmp to the new location, handling the name-collision case. + # + # When new is located under the legacy path (e.g. XDG_CONFIG_HOME unset, or + # set to ~/.config), legacy (~/.config/nsls2, a file) sits exactly where the + # 'nsls2' directory component of new.parent must be created. We must remove + # the legacy file before mkdir can succeed; since tmp already holds a full + # copy, this is safe. + # + # When XDG_CONFIG_HOME points to a genuinely different directory, new lives + # in a separate tree and there is no collision; legacy is left untouched + # until after the new file is successfully written. + # + # Paths are compared after resolution so that XDG_CONFIG_HOME values that + # refer to ~/.config via an alternative spelling (trailing slash, '..' segment, + # or a symlink) are still recognised as the collision case. + legacy_resolved = legacy.resolve() + new_under_legacy = any( + legacy_resolved == parent.resolve() for parent in new.parents + ) + legacy_removed = False + # Record which ancestor directories of new.parent don't yet exist so that, + # on failure, we can undo exactly what mkdir created — and no more. This + # list is computed after any legacy.unlink() so the freed 'nsls2' name is + # included when it is part of the path that must be created. + created_dirs: list[Path] = [] + try: + if new_under_legacy: + legacy.unlink() # free the 'nsls2' name; tmp holds the copy + legacy_removed = True + # Collect innermost-first ancestors that mkdir will create. + p = new.parent + while not p.exists(): + created_dirs.append(p) + p = p.parent + new.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(tmp), str(new)) # cross-device safe + except OSError as exc: + # Recovery: restore legacy from tmp when possible. + if legacy_removed: + # The legacy name was freed; mkdir may have created directory + # entries in its place. Remove only the empty directories we + # created (innermost-first, rmdir only — never recursive) so + # that the legacy name is available again for restore. + for d in created_dirs: + try: + if d.is_dir(): + d.rmdir() + except OSError: + break + if not legacy.exists(): + # The legacy name is free — move tmp straight back. + try: + shutil.move(str(tmp), str(legacy)) + print( + f"Warning: nsls2api config migration failed ({exc}). " + f"Your settings remain at '{legacy}'. " + f"To migrate manually:\n" + f" 1. Move '{legacy}' aside (e.g. rename it to '{legacy}.bak')\n" + f" 2. Create the directory '{new.parent}'\n" + f" 3. Move the saved file into place as '{new}'", + file=sys.stderr, + ) + except OSError: + print( + f"Warning: nsls2api config migration failed ({exc}) and " + f"could not be recovered. " + f"Your settings are at '{tmp}'. " + f"To recover:\n" + f" 1. Create the directory '{new.parent}'\n" + f" 2. Move '{tmp}' into place as '{new}'", + file=sys.stderr, + ) + else: + # Created dirs couldn't all be removed; legacy name still + # occupied. Settings safe in tmp. + print( + f"Warning: nsls2api config migration failed ({exc}) and " + f"could not be recovered. " + f"Your settings are at '{tmp}'. " + f"To recover:\n" + f" 1. Create the directory '{new.parent}'\n" + f" 2. Move '{tmp}' into place as '{new}'", + file=sys.stderr, + ) + else: + # Legacy was not removed (XDG case, or copy step) — settings intact. + tmp.unlink(missing_ok=True) + print( + f"Warning: nsls2api config migration failed ({exc}). " + f"Your settings remain at '{legacy}'. " + f"To migrate manually:\n" + f" 1. Move '{legacy}' aside (e.g. rename it to '{legacy}.bak')\n" + f" 2. Create the directory '{new.parent}'\n" + f" 3. Move the saved file into place as '{new}'", + file=sys.stderr, + ) + return None + + # Step 3 — migration succeeded. + # XDG case: legacy was not removed in step 2; delete it now. + if not new_under_legacy: + legacy.unlink(missing_ok=True) + return new + + +def read_legacy_fallback( + config: configparser.ConfigParser, config_filepath: Path +) -> None: + """Populate *config* from the legacy file when *config_filepath* is absent. + + Called by ``Config.read()`` when migration was skipped or failed and + the new config file still does not exist. This preserves back-compat: + existing settings (base_url, token) are not silently dropped. + + Modifies *config* in-place; returns ``None``. + """ + legacy = legacy_filepath() + if legacy.is_file(): + config.read(legacy) diff --git a/src/nsls2api/tests/cli/__init__.py b/src/nsls2api/tests/cli/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/nsls2api/tests/cli/test_settings.py b/src/nsls2api/tests/cli/test_settings.py new file mode 100644 index 00000000..974b8c17 --- /dev/null +++ b/src/nsls2api/tests/cli/test_settings.py @@ -0,0 +1,138 @@ +"""Tests for nsls2api.cli.settings — config path and read/write. + +Migration and legacy fallback tests live in test_settings_migration.py. +""" + +from __future__ import annotations + +import configparser +import os +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +from nsls2api.cli.settings import Config, ConfigKey + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _patch_home(tmp_path: Path): + """Redirect Path.home() to tmp_path. Env vars are controlled per-test.""" + return patch.object(Path, "home", return_value=tmp_path) + + +# --------------------------------------------------------------------------- +# get_filepath — path resolution +# --------------------------------------------------------------------------- + +class TestGetFilepath: + def test_default_posix_path(self, tmp_path: Path): + """Without XDG_CONFIG_HOME, resolves to ~/.config/nsls2/api/cli.ini.""" + with _patch_home(tmp_path), patch.dict(os.environ, {}, clear=False): + os.environ.pop("XDG_CONFIG_HOME", None) + result = Config.get_filepath() + assert result == tmp_path / ".config" / "nsls2" / "api" / "cli.ini" + + def test_respects_xdg_config_home(self, tmp_path: Path): + """XDG_CONFIG_HOME is honoured when set.""" + xdg = str(tmp_path / "xdg") + with _patch_home(tmp_path), patch.dict(os.environ, {"XDG_CONFIG_HOME": xdg}): + result = Config.get_filepath() + assert result == tmp_path / "xdg" / "nsls2" / "api" / "cli.ini" + + def test_blank_xdg_config_home_falls_back(self, tmp_path: Path): + """A blank XDG_CONFIG_HOME is treated as unset.""" + with _patch_home(tmp_path), patch.dict(os.environ, {"XDG_CONFIG_HOME": " "}): + result = Config.get_filepath() + assert result == tmp_path / ".config" / "nsls2" / "api" / "cli.ini" + + +# --------------------------------------------------------------------------- +# set_value / read — basic round-trip +# --------------------------------------------------------------------------- + +class TestSetValueRead: + def test_set_creates_dirs_and_file(self, tmp_path: Path): + """set_value creates the nsls2/api/ directory and writes cli.ini.""" + with _patch_home(tmp_path), patch.dict(os.environ, {}, clear=False): + os.environ.pop("XDG_CONFIG_HOME", None) + Config.set_value("api", ConfigKey.BASE_URL, "https://example.com") + filepath = Config.get_filepath() + + assert filepath.exists() + assert filepath.parent.is_dir() + cfg = configparser.ConfigParser() + cfg.read(filepath) + assert cfg.get("api", "base_url") == "https://example.com" + + def test_get_value_round_trip(self, tmp_path: Path): + with _patch_home(tmp_path), patch.dict(os.environ, {}, clear=False): + os.environ.pop("XDG_CONFIG_HOME", None) + Config.set_value("api", ConfigKey.TOKEN, "tok123") + result = Config.get_value("api", ConfigKey.TOKEN) + assert result == "tok123" + + def test_get_value_missing_returns_none(self, tmp_path: Path): + with _patch_home(tmp_path), patch.dict(os.environ, {}, clear=False): + os.environ.pop("XDG_CONFIG_HOME", None) + result = Config.get_value("api", ConfigKey.BASE_URL) + assert result is None + + +# --------------------------------------------------------------------------- +# Windows branch of get_filepath() +# --------------------------------------------------------------------------- + +@pytest.mark.skipif(sys.platform != "win32", reason="Only run on Windows") +class TestGetFilepathWindows: + """Run the Windows branch of get_filepath() on real Windows.""" + + def test_windows_path_with_appdata(self, tmp_path: Path, monkeypatch): + """With APPDATA set, uses %APPDATA%/nsls2/api/cli.ini.""" + appdata = str(tmp_path / "AppData" / "Roaming") + monkeypatch.setenv("APPDATA", appdata) + result = Config.get_filepath() + assert result == tmp_path / "AppData" / "Roaming" / "nsls2" / "api" / "cli.ini" + + def test_windows_path_without_appdata(self, tmp_path: Path, monkeypatch): + """Without APPDATA, falls back to Path.home()/AppData/Roaming/...""" + monkeypatch.delenv("APPDATA", raising=False) + with _patch_home(tmp_path): + result = Config.get_filepath() + assert result == tmp_path / "AppData" / "Roaming" / "nsls2" / "api" / "cli.ini" + + +class TestGetFilepathWindowsSimulated: + """Simulate the Windows branch on all platforms via sys.platform patching.""" + + def test_windows_path_with_appdata_simulated(self, tmp_path: Path, monkeypatch): + """Patch sys.platform to win32; APPDATA set → uses APPDATA path.""" + appdata = str(tmp_path / "AppData" / "Roaming") + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setenv("APPDATA", appdata) + result = Config.get_filepath() + assert result == tmp_path / "AppData" / "Roaming" / "nsls2" / "api" / "cli.ini" + + def test_windows_path_without_appdata_simulated(self, tmp_path: Path, monkeypatch): + """Patch sys.platform to win32; no APPDATA → falls back to home.""" + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.delenv("APPDATA", raising=False) + with _patch_home(tmp_path): + result = Config.get_filepath() + assert result == tmp_path / "AppData" / "Roaming" / "nsls2" / "api" / "cli.ini" + + def test_windows_blank_appdata_falls_back(self, tmp_path: Path, monkeypatch): + """Patch sys.platform to win32; whitespace-only APPDATA → falls back to home. + + Mirrors test_blank_xdg_config_home_falls_back: a blank/whitespace-only + env var should be treated as unset on both platforms. + """ + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setenv("APPDATA", " ") + with _patch_home(tmp_path): + result = Config.get_filepath() + assert result == tmp_path / "AppData" / "Roaming" / "nsls2" / "api" / "cli.ini" diff --git a/src/nsls2api/tests/cli/test_settings_migration.py b/src/nsls2api/tests/cli/test_settings_migration.py new file mode 100644 index 00000000..7acb0e9d --- /dev/null +++ b/src/nsls2api/tests/cli/test_settings_migration.py @@ -0,0 +1,404 @@ +"""Tests for nsls2api.cli.settings_migration — legacy config migration and fallback.""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +from nsls2api.cli import settings_migration +from nsls2api.cli.settings import Config + + +# --------------------------------------------------------------------------- +# Helpers (duplicated from test_settings.py — both are intentionally +# self-contained so this module can be deleted independently when legacy +# support is dropped) +# --------------------------------------------------------------------------- + +def _make_legacy(home: Path, content: str = "[api]\nbase_url = http://127.0.0.1:8080\n") -> Path: + """Create the legacy bare-file config at /.config/nsls2.""" + legacy = home / ".config" / "nsls2" + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.write_text(content) + return legacy + + +def _patch_home(tmp_path: Path): + """Redirect Path.home() to tmp_path. Env vars are controlled per-test.""" + return patch.object(Path, "home", return_value=tmp_path) + + +# --------------------------------------------------------------------------- +# migrate_legacy_config +# --------------------------------------------------------------------------- + +class TestMigrateLegacyConfig: + def test_migrates_legacy_file(self, tmp_path: Path): + """Legacy bare file is migrated to the new location; content is preserved. + + In the default (no-XDG) case the legacy file occupies the path component + that must become a directory. Migration copies the content to a temp file + first, removes the legacy file to free the name, then moves the temp file + to the new path. After migration: + - ~/.config/nsls2 must be a DIRECTORY (not a file) + - ~/.config/nsls2/api/cli.ini must exist with original content + - no temp files remain in ~/.config/ + """ + legacy = _make_legacy(tmp_path) + dot_config = tmp_path / ".config" + with _patch_home(tmp_path), patch.dict(os.environ, {}, clear=False): + os.environ.pop("XDG_CONFIG_HOME", None) + config_filepath = Config.get_filepath() + new_path = settings_migration.migrate_legacy_config(config_filepath) + + assert new_path is not None + assert new_path == tmp_path / ".config" / "nsls2" / "api" / "cli.ini" + assert new_path.exists() + assert legacy.is_dir() # legacy path is now a directory + content = new_path.read_text() + assert "base_url" in content + assert "127.0.0.1:8080" in content + # No temp files left behind. + leftover = list(dot_config.glob(".nsls2-migrate-*")) + assert leftover == [], f"Unexpected temp files left over: {leftover}" + + def test_no_op_when_new_already_exists(self, tmp_path: Path): + """Migration is skipped when the new config file is already present. + + The new.exists() short-circuit fires before any legacy handling, so + this test only needs the new file to be in place — no legacy required. + """ + new = tmp_path / ".config" / "nsls2" / "api" / "cli.ini" + new.parent.mkdir(parents=True, exist_ok=True) + new.write_text("[api]\nbase_url = https://already.here\n") + + with _patch_home(tmp_path), patch.dict(os.environ, {}, clear=False): + os.environ.pop("XDG_CONFIG_HOME", None) + config_filepath = Config.get_filepath() + result = settings_migration.migrate_legacy_config(config_filepath) + + assert result is None + assert new.read_text().startswith("[api]\nbase_url = https://already.here") + + def test_no_op_when_legacy_absent(self, tmp_path: Path): + """Migration is a no-op when the legacy file doesn't exist.""" + with _patch_home(tmp_path), patch.dict(os.environ, {}, clear=False): + os.environ.pop("XDG_CONFIG_HOME", None) + config_filepath = Config.get_filepath() + result = settings_migration.migrate_legacy_config(config_filepath) + assert result is None + + def test_no_op_when_legacy_is_directory(self, tmp_path: Path): + """Migration skips silently if ~/.config/nsls2 is already a directory.""" + legacy_dir = tmp_path / ".config" / "nsls2" + legacy_dir.mkdir(parents=True, exist_ok=True) + (legacy_dir / "cli").mkdir() + + with _patch_home(tmp_path), patch.dict(os.environ, {}, clear=False): + os.environ.pop("XDG_CONFIG_HOME", None) + config_filepath = Config.get_filepath() + result = settings_migration.migrate_legacy_config(config_filepath) + assert result is None + + @pytest.mark.skipif(sys.platform == "win32", reason="chmod not meaningful on Windows") + def test_warns_and_skips_when_parent_dir_not_writable(self, tmp_path: Path, capsys): + """Non-writable parent directory: warning is printed with platform-neutral + hint; no exception raised; returns None; legacy file untouched.""" + legacy = _make_legacy(tmp_path) + dot_config = tmp_path / ".config" + dot_config.chmod(0o555) # remove write permission (execute retained) from the containing dir + + try: + with _patch_home(tmp_path), patch.dict(os.environ, {}, clear=False): + os.environ.pop("XDG_CONFIG_HOME", None) + new_path = Config.get_filepath() + result = settings_migration.migrate_legacy_config(new_path) + finally: + dot_config.chmod(0o755) # restore so tmp_path cleanup works + + assert result is None + captured = capsys.readouterr() + assert "migration skipped" in captured.err + # Warning identifies the non-writable directory, not the file. + assert str(dot_config) in captured.err + # Hint must use platform-neutral numbered steps, not POSIX shell commands. + assert "Create the directory" in captured.err + assert str(new_path.parent) in captured.err + + @pytest.mark.skipif(sys.platform == "win32", reason="chmod not meaningful on Windows") + def test_readonly_file_in_writable_dir_migrates(self, tmp_path: Path): + """A read-only legacy file in a writable parent directory migrates + successfully. The permission check gates on the parent dir (needed for + rename/unlink), not on the file itself.""" + legacy = _make_legacy(tmp_path) + legacy.chmod(0o444) # file read-only, but parent dir is writable + + try: + with _patch_home(tmp_path), patch.dict(os.environ, {}, clear=False): + os.environ.pop("XDG_CONFIG_HOME", None) + config_filepath = Config.get_filepath() + new_path = settings_migration.migrate_legacy_config(config_filepath) + finally: + # If migration succeeded the file is gone; restore only if still there. + if legacy.is_file(): + legacy.chmod(0o644) + + assert new_path is not None + assert new_path.exists() + assert "base_url" in new_path.read_text() + + def test_migration_triggered_by_read(self, tmp_path: Path): + """Calling read() auto-migrates the legacy file and returns its contents.""" + _make_legacy(tmp_path, "[api]\nbase_url = http://127.0.0.1:8080\ntoken = abc\n") + legacy = tmp_path / ".config" / "nsls2" + + with _patch_home(tmp_path), patch.dict(os.environ, {}, clear=False): + os.environ.pop("XDG_CONFIG_HOME", None) + cfg = Config.read() + + assert cfg.get("api", "base_url") == "http://127.0.0.1:8080" + assert cfg.get("api", "token") == "abc" + assert legacy.is_dir() # legacy path is now a directory + new = tmp_path / ".config" / "nsls2" / "api" / "cli.ini" + assert new.exists() + + def test_copy_failure_cleans_up_temp_and_reports_legacy( + self, tmp_path: Path, capsys + ): + """If the initial copy step raises, migrate_legacy_config(): + - returns None + - legacy file is still intact + - no .nsls2-migrate-* temp files are left behind + - warning says settings remain at legacy + """ + import nsls2api.cli.settings_migration as migration_mod + + legacy = _make_legacy(tmp_path) + dot_config = tmp_path / ".config" + + with ( + _patch_home(tmp_path), + patch.dict(os.environ, {}, clear=False), + patch.object(migration_mod.shutil, "copy2", side_effect=OSError("disk full")), + ): + os.environ.pop("XDG_CONFIG_HOME", None) + config_filepath = Config.get_filepath() + result = settings_migration.migrate_legacy_config(config_filepath) + + assert result is None + assert legacy.is_file() # legacy untouched + assert legacy.read_text().startswith("[api]") + leftover = list(dot_config.glob(".nsls2-migrate-*")) + assert leftover == [], f"Temp files leaked: {leftover}" + captured = capsys.readouterr() + assert "remain at" in captured.err + assert "settings are at" not in captured.err + + def test_shutil_move_failure_restores_legacy(self, tmp_path: Path, capsys): + """If shutil.move raises after legacy has been unlinked (no-XDG collision + case), the legacy file is restored from the temp copy and the warning says + settings remain at legacy — no data loss.""" + import nsls2api.cli.settings_migration as migration_mod + + content = "[api]\nbase_url = http://127.0.0.1:8080\ntoken = secret\n" + legacy = _make_legacy(tmp_path, content) + + # Simulate a cross-device rename failure: the migration move raises, but + # the restore move (second call) must succeed so recovery can complete. + _real_move = migration_mod.shutil.move + _calls = [0] + + def _move_fails_once(src, dst, *args, **kwargs): + _calls[0] += 1 + if _calls[0] == 1: + raise OSError("EXDEV") + return _real_move(src, dst, *args, **kwargs) + + with ( + _patch_home(tmp_path), + patch.dict(os.environ, {}, clear=False), + patch.object(migration_mod.shutil, "move", side_effect=_move_fails_once), + ): + os.environ.pop("XDG_CONFIG_HOME", None) + config_filepath = Config.get_filepath() + result = settings_migration.migrate_legacy_config(config_filepath) + + assert result is None + assert legacy.is_file(), "Legacy config must be restored as a file" + assert "base_url" in legacy.read_text(), "Legacy config content was lost!" + assert "secret" in legacy.read_text(), "Token was lost!" + captured = capsys.readouterr() + assert "remain at" in captured.err + assert "settings are at" not in captured.err + + def test_xdg_destination_tree_not_deleted_on_move_failure( + self, tmp_path: Path, capsys + ): + """Regression: when XDG_CONFIG_HOME is set, new.parent may be a + pre-existing directory unrelated to the legacy path. A shutil.move + failure must NOT delete that directory. No rmtree of the destination + tree is performed.""" + import nsls2api.cli.settings_migration as migration_mod + + xdg = tmp_path / "xdg" + xdg.mkdir() + # Pre-create destination tree with a sentinel file. + existing_api_dir = xdg / "nsls2" / "api" + existing_api_dir.mkdir(parents=True) + sentinel = existing_api_dir / "sentinel.txt" + sentinel.write_text("do not delete me") + + legacy = _make_legacy(tmp_path) + + with ( + _patch_home(tmp_path), + patch.dict(os.environ, {"XDG_CONFIG_HOME": str(xdg)}, clear=False), + patch.object(migration_mod.shutil, "move", side_effect=OSError("EXDEV")), + ): + config_filepath = Config.get_filepath() + result = settings_migration.migrate_legacy_config(config_filepath) + + assert result is None + assert sentinel.exists(), "Destination tree was deleted on move failure!" + assert sentinel.read_text() == "do not delete me" + # Legacy file must still be present (XDG case: legacy not unlinked before move). + assert legacy.is_file() + captured = capsys.readouterr() + assert "remain at" in captured.err + + @pytest.mark.parametrize("xdg_suffix", [ + "", # exact: XDG_CONFIG_HOME=/tmp/.../home/.config + "/", # trailing slash + "/../.config" # unnormalized '..' segment resolving to the same dir + ]) + def test_migrates_when_xdg_equals_config_dir( + self, tmp_path: Path, xdg_suffix: str + ): + """When XDG_CONFIG_HOME resolves to ~/.config (via exact match, trailing + slash, or '..' segment), migration takes the collision branch and produces + the same result as when XDG_CONFIG_HOME is unset: + - new config written to ~/.config/nsls2/api/cli.ini + - legacy path becomes a directory + - no temp files left behind + """ + dot_config = tmp_path / ".config" + dot_config.mkdir(parents=True, exist_ok=True) + legacy = _make_legacy(tmp_path) + xdg_value = str(dot_config) + xdg_suffix + + with ( + _patch_home(tmp_path), + patch.dict(os.environ, {"XDG_CONFIG_HOME": xdg_value}, clear=False), + ): + config_filepath = Config.get_filepath() + new_path = settings_migration.migrate_legacy_config(config_filepath) + + assert new_path is not None + # Compare resolved paths: XDG values with trailing slash or '..' segments + # produce an unresolved new_path; normalise both sides before comparing. + expected = (tmp_path / ".config" / "nsls2" / "api" / "cli.ini").resolve() + assert new_path.resolve() == expected + assert new_path.exists() + assert "base_url" in new_path.read_text() + assert legacy.is_dir(), "Legacy path must become a directory after migration" + leftover = list(dot_config.glob(".nsls2-migrate-*")) + assert leftover == [], f"Unexpected temp files left over: {leftover}" + + def test_xdg_equals_config_dir_move_failure_restores_legacy( + self, tmp_path: Path, capsys + ): + """When XDG_CONFIG_HOME=~/.config and shutil.move fails, the legacy file + is restored (collision branch recovery), not silently discarded.""" + import nsls2api.cli.settings_migration as migration_mod + + dot_config = tmp_path / ".config" + dot_config.mkdir(parents=True, exist_ok=True) + content = "[api]\nbase_url = http://127.0.0.1:8080\ntoken = secret\n" + legacy = _make_legacy(tmp_path, content) + + # Simulate a cross-device rename failure: the migration move raises, but + # the restore move (second call) must succeed so recovery can complete. + _real_move = migration_mod.shutil.move + _calls = [0] + + def _move_fails_once(src, dst, *args, **kwargs): + _calls[0] += 1 + if _calls[0] == 1: + raise OSError("EXDEV") + return _real_move(src, dst, *args, **kwargs) + + with ( + _patch_home(tmp_path), + patch.dict( + os.environ, + {"XDG_CONFIG_HOME": str(dot_config)}, + clear=False, + ), + patch.object(migration_mod.shutil, "move", side_effect=_move_fails_once), + ): + config_filepath = Config.get_filepath() + result = settings_migration.migrate_legacy_config(config_filepath) + + assert result is None + assert legacy.is_file(), "Legacy config must be restored as a file" + assert "base_url" in legacy.read_text() + assert "secret" in legacy.read_text() + captured = capsys.readouterr() + assert "remain at" in captured.err + assert "settings are at" not in captured.err + + +# --------------------------------------------------------------------------- +# read() legacy fallback +# --------------------------------------------------------------------------- + +class TestReadLegacyFallback: + """When migration is skipped/failed, read() must still return legacy values.""" + + def test_read_falls_back_to_legacy_when_migration_skipped( + self, tmp_path: Path + ): + """When migration is skipped (returns None), read() should still return + the legacy base_url rather than an empty config.""" + _make_legacy( + tmp_path, "[api]\nbase_url = http://127.0.0.1:8080\ntoken = mytoken\n" + ) + with ( + _patch_home(tmp_path), + patch.dict(os.environ, {}, clear=False), + patch("nsls2api.cli.settings_migration.migrate_legacy_config", return_value=None), + ): + os.environ.pop("XDG_CONFIG_HOME", None) + cfg = Config.read() + + assert cfg.get("api", "base_url") == "http://127.0.0.1:8080" + assert cfg.get("api", "token") == "mytoken" + + def test_read_falls_back_to_legacy_when_new_absent(self, tmp_path: Path): + """When migration is suppressed and no new config exists, read() falls back + to the legacy file (fallback path in read()).""" + _make_legacy( + tmp_path, "[api]\nbase_url = https://api.example.com\n" + ) + # Patch migrate_legacy_config to a no-op so migration never runs and the + # new config file is never created — this isolates the read() fallback. + with ( + _patch_home(tmp_path), + patch.dict(os.environ, {}, clear=False), + patch("nsls2api.cli.settings_migration.migrate_legacy_config", return_value=None), + ): + os.environ.pop("XDG_CONFIG_HOME", None) + cfg = Config.read() + + assert cfg.get("api", "base_url") == "https://api.example.com" + + def test_read_returns_empty_config_when_both_absent(self, tmp_path: Path): + """When neither new nor legacy config exists, read() returns an empty config.""" + with _patch_home(tmp_path), patch.dict(os.environ, {}, clear=False): + os.environ.pop("XDG_CONFIG_HOME", None) + cfg = Config.read() + assert not cfg.sections()