From 07951ff207a5fd33521c5cf88cf93235bcb37f24 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:49:14 -0500 Subject: [PATCH] fix: make catalog additions idempotent Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../bundles/catalog/command_add.py | 17 +++- src/specify_cli/bundles/catalog_config.py | 36 +++++-- .../extensions/catalog/command_add.py | 18 ++-- src/specify_cli/integrations/__init__.py | 19 ++-- .../integrations/catalog/command_add.py | 11 ++- .../presets/catalog/command_add.py | 20 ++-- src/specify_cli/workflows/catalog/_domain.py | 19 +++- .../workflows/catalog/command_add.py | 10 +- .../workflows/step/catalog/_domain.py | 19 +++- .../workflows/step/catalog/command_add.py | 12 ++- .../bundles/catalog/test_command_add.py | 7 ++ .../bundles/test_catalog_config.py | 99 ++++++++++++++++++- .../extensions/catalog/test_command_add.py | 22 +++++ .../integrations/catalog/test_command_add.py | 13 ++- .../specify_cli/integrations/test_catalog.py | 32 ++++-- .../presets/catalog/test_command_add.py | 24 +++++ .../workflows/catalog/test_command_add.py | 19 ++++ .../step/catalog/test_command_add.py | 20 ++++ tests/test_workflows.py | 40 ++++++-- 19 files changed, 388 insertions(+), 69 deletions(-) diff --git a/src/specify_cli/bundles/catalog/command_add.py b/src/specify_cli/bundles/catalog/command_add.py index b734878f1c..b3f57eb999 100644 --- a/src/specify_cli/bundles/catalog/command_add.py +++ b/src/specify_cli/bundles/catalog/command_add.py @@ -28,14 +28,21 @@ def catalog_add( project_root = require_project_root() from ..catalog_config import add_source - source = add_source( + source, status = add_source( project_root, url, policy=policy, priority=priority, source_id=source_id ) except BundlerError as exc: _fail(str(exc)) return - console.print( - f"[green]✓[/green] Added catalog '{_escape_markup(str(source.id))}' " - f"(priority {source.priority}, {source.install_policy.value})." - ) + safe_id = _escape_markup(str(source.id)) + if status == "unchanged": + console.print( + f"[green]✓[/green] Catalog '{safe_id}' already configured " + f"(priority {source.priority}, {source.install_policy.value})." + ) + else: + console.print( + f"[green]✓[/green] Added catalog '{safe_id}' " + f"(priority {source.priority}, {source.install_policy.value})." + ) diff --git a/src/specify_cli/bundles/catalog_config.py b/src/specify_cli/bundles/catalog_config.py index abd9156b90..2fcb3972f6 100644 --- a/src/specify_cli/bundles/catalog_config.py +++ b/src/specify_cli/bundles/catalog_config.py @@ -139,7 +139,7 @@ def add_source( policy: str, priority: int, source_id: str | None = None, -) -> CatalogSource: +) -> tuple[CatalogSource, str]: url = url.strip() if not url: raise BundlerError("A catalog url is required.") @@ -183,24 +183,40 @@ def add_source( url = _canonicalize_url(url) install_policy = InstallPolicy.parse(policy) - resolved_id = (source_id or _derive_id(url)).strip() + requested_id = source_id.strip() if source_id is not None else "" + resolved_id = requested_id or _derive_id(url) catalogs = _read(project_root) + requested_source = CatalogSource.from_dict( + { + "id": resolved_id, + "url": url, + "priority": priority, + "install_policy": install_policy.value, + }, + Scope.PROJECT, + ) for existing in catalogs: - if existing.get("id") == resolved_id or existing.get("url") == url: + existing_source = CatalogSource.from_dict(existing, Scope.PROJECT) + if ( + existing_source.id == requested_source.id + or existing_source.url == requested_source.url + ): + if ( + existing_source.url == requested_source.url + and (not requested_id or existing_source.id == requested_source.id) + and existing_source.priority == requested_source.priority + and existing_source.install_policy is requested_source.install_policy + ): + return existing_source, "unchanged" raise BundlerError( f"Catalog source '{resolved_id}' (or url) already exists in this project." ) - entry = { - "id": resolved_id, - "url": url, - "priority": int(priority), - "install_policy": install_policy.value, - } + entry = requested_source.to_dict() catalogs.append(entry) _write(project_root, catalogs) - return CatalogSource.from_dict(entry, Scope.PROJECT) + return requested_source, "added" def remove_source(project_root: Path, id_or_url: str) -> str: diff --git a/src/specify_cli/extensions/catalog/command_add.py b/src/specify_cli/extensions/catalog/command_add.py index 0844b9d479..c9dd70ccdb 100644 --- a/src/specify_cli/extensions/catalog/command_add.py +++ b/src/specify_cli/extensions/catalog/command_add.py @@ -60,9 +60,19 @@ def catalog_add( safe_name = _escape_markup(name) safe_url = _escape_markup(url) + entry = { + "name": name, + "url": url, + "priority": priority, + "install_allowed": install_allowed, + "description": description, + } + # Check for duplicate name for existing in catalogs: if isinstance(existing, dict) and existing.get("name") == name: + if existing == entry: + return _commands.console.print( f"[yellow]Warning:[/yellow] A catalog named '{safe_name}' already exists." ) @@ -71,13 +81,7 @@ def catalog_add( ) raise typer.Exit(1) - catalogs.append({ - "name": name, - "url": url, - "priority": priority, - "install_allowed": install_allowed, - "description": description, - }) + catalogs.append(entry) config["catalogs"] = catalogs config_path.write_text( diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index 9f8dbd66fd..83e30eca8c 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -515,14 +515,14 @@ def get_project_catalog_configs(self) -> Optional[List[Dict[str, Any]]]: for e in entries ] - def add_catalog(self, url: str, name: Optional[str] = None) -> None: + def add_catalog(self, url: str, name: Optional[str] = None) -> str: """Add a catalog source to the project-level config file. The URL is normalized (whitespace stripped) and validated before being - written. Duplicate URLs are rejected, including near-duplicates that - differ only by surrounding whitespace. Priority is derived as - ``max(existing) + 1`` so the new entry sorts last in the resolution - order unless the user edits the file manually. + written. An existing URL is unchanged when no name is supplied or the + supplied name matches; a different explicit name is rejected. Priority + is derived as ``max(existing) + 1`` so the new entry sorts last in the + resolution order unless the user edits the file manually. """ url = url.strip() if not url: @@ -557,6 +557,7 @@ def add_catalog(self, url: str, name: Optional[str] = None) -> None: # Validate each existing entry before mutating anything. Fail fast so # we don't silently preserve a corrupt sibling entry or derive a new # priority from a bogus value. + normalized_name = str(name).strip() if name is not None else "" existing_priorities: List[int] = [] valid_catalog_count = 0 for idx, cat in enumerate(catalogs): @@ -577,6 +578,11 @@ def add_catalog(self, url: str, name: Optional[str] = None) -> None: f"Invalid catalog entry at index {idx} in {config_path}: {exc}" ) from exc if existing_url == url: + generated_name = f"catalog-{valid_catalog_count + 1}" + existing_name = str(cat.get("name") or generated_name).strip() + if not normalized_name or existing_name == normalized_name: + self._load_catalog_config(config_path) + return "unchanged" raise IntegrationValidationError( f"Catalog URL already configured: {url}" ) @@ -603,9 +609,7 @@ def add_catalog(self, url: str, name: Optional[str] = None) -> None: # Match `_load_catalog_config()`'s defaulting rule so the new # entry still sorts after implicit-priority siblings. existing_priorities.append(idx + 1) - max_priority = max(existing_priorities, default=0) - normalized_name = str(name).strip() if name is not None else "" generated_name = f"catalog-{valid_catalog_count + 1}" catalogs.append( { @@ -627,6 +631,7 @@ def add_catalog(self, url: str, name: Optional[str] = None) -> None: sort_keys=False, allow_unicode=True, ) + return "added" def remove_catalog(self, index: int) -> str: """Remove a catalog source by 0-based index. diff --git a/src/specify_cli/integrations/catalog/command_add.py b/src/specify_cli/integrations/catalog/command_add.py index bde5690065..0087b06034 100644 --- a/src/specify_cli/integrations/catalog/command_add.py +++ b/src/specify_cli/integrations/catalog/command_add.py @@ -4,6 +4,7 @@ from typing import Optional import typer +from rich.markup import escape as _rich_escape from ..._console import console from . import catalog_app @@ -32,11 +33,17 @@ def integration_catalog_add( normalized_url = url.strip() try: - catalog.add_catalog(normalized_url, name) + status = catalog.add_catalog(normalized_url, name) except IntegrationCatalogError as exc: # Covers both URL validation (base class) and config-file validation # (IntegrationValidationError subclass). console.print(f"[red]Error:[/red] {exc}") raise typer.Exit(1) - console.print(f"[green]✓[/green] Catalog source added: {normalized_url}") + safe_url = _rich_escape(normalized_url) + if status == "unchanged": + console.print( + f"[green]✓[/green] Catalog source already configured: {safe_url}" + ) + else: + console.print(f"[green]✓[/green] Catalog source added: {safe_url}") diff --git a/src/specify_cli/presets/catalog/command_add.py b/src/specify_cli/presets/catalog/command_add.py index eedf01275b..35d7dec52e 100644 --- a/src/specify_cli/presets/catalog/command_add.py +++ b/src/specify_cli/presets/catalog/command_add.py @@ -75,9 +75,19 @@ def preset_catalog_add( safe_name = _escape_markup(str(name)) safe_url = _escape_markup(str(url)) + entry = { + "name": name, + "url": url, + "priority": priority, + "install_allowed": install_allowed, + "description": description, + } + # Check for duplicate name for existing in catalogs: if isinstance(existing, dict) and existing.get("name") == name: + if existing == entry: + return console.print( f"[yellow]Warning:[/yellow] A catalog named '{safe_name}' already exists." ) @@ -86,15 +96,7 @@ def preset_catalog_add( ) raise typer.Exit(1) - catalogs.append( - { - "name": name, - "url": url, - "priority": priority, - "install_allowed": install_allowed, - "description": description, - } - ) + catalogs.append(entry) config["catalogs"] = catalogs config_path.write_text( diff --git a/src/specify_cli/workflows/catalog/_domain.py b/src/specify_cli/workflows/catalog/_domain.py index 089b46f2a3..93bc496f12 100644 --- a/src/specify_cli/workflows/catalog/_domain.py +++ b/src/specify_cli/workflows/catalog/_domain.py @@ -714,10 +714,12 @@ def get_catalog_configs(self) -> list[dict[str, Any]]: for e in entries ] - def add_catalog(self, url: str, name: str | None = None) -> None: + def add_catalog(self, url: str, name: str | None = None) -> str: """Add a catalog source to the project-level config.""" + url = url.strip() self._validate_catalog_url(url) config_path = self.project_root / ".specify" / "workflow-catalogs.yml" + normalized_name = str(name).strip() if name is not None else "" data: dict[str, Any] = {"catalogs": []} if config_path.exists(): @@ -741,8 +743,16 @@ def add_catalog(self, url: str, name: str | None = None) -> None: "Catalog config 'catalogs' must be a list." ) # Check for duplicate URL (guard against non-dict entries) - for cat in catalogs: - if isinstance(cat, dict) and cat.get("url") == url: + for idx, cat in enumerate(catalogs): + if ( + isinstance(cat, dict) + and str(cat.get("url", "")).strip() == url + ): + generated_name = f"catalog-{idx + 1}" + existing_name = str(cat.get("name") or generated_name).strip() + if not normalized_name or existing_name == normalized_name: + self._load_catalog_config(config_path) + return "unchanged" raise WorkflowValidationError( f"Catalog URL already configured: {url}" ) @@ -768,7 +778,7 @@ def _coerce_priority(value: Any) -> int: ) catalogs.append( { - "name": name or f"catalog-{len(catalogs) + 1}", + "name": normalized_name or f"catalog-{len(catalogs) + 1}", "url": url, "priority": max_priority + 1, "install_allowed": True, @@ -785,6 +795,7 @@ def _coerce_priority(value: Any) -> int: raise WorkflowValidationError( f"Failed to write catalog config {config_path}: {exc}" ) from exc + return "added" def remove_catalog(self, index: int) -> str: """Remove a catalog source by index (0-based). Returns the removed name.""" diff --git a/src/specify_cli/workflows/catalog/command_add.py b/src/specify_cli/workflows/catalog/command_add.py index 6363c07c20..d333dad936 100644 --- a/src/specify_cli/workflows/catalog/command_add.py +++ b/src/specify_cli/workflows/catalog/command_add.py @@ -17,9 +17,15 @@ def workflow_catalog_add( project_root = cli._require_specify_project() catalog = WorkflowCatalog(project_root) try: - catalog.add_catalog(url, name) + status = catalog.add_catalog(url, name) except WorkflowValidationError as exc: cli.console.print(f"[red]Error:[/red] {exc}") raise cli.typer.Exit(1) - cli.console.print(f"[green]✓[/green] Catalog source added: {url}") + safe_url = cli._escape_markup(url.strip()) + if status == "unchanged": + cli.console.print( + f"[green]✓[/green] Catalog source already configured: {safe_url}" + ) + else: + cli.console.print(f"[green]✓[/green] Catalog source added: {safe_url}") diff --git a/src/specify_cli/workflows/step/catalog/_domain.py b/src/specify_cli/workflows/step/catalog/_domain.py index cebebb3e2f..08a1b22f57 100644 --- a/src/specify_cli/workflows/step/catalog/_domain.py +++ b/src/specify_cli/workflows/step/catalog/_domain.py @@ -591,10 +591,12 @@ def get_catalog_configs(self) -> list[dict[str, Any]]: for e in entries ] - def add_catalog(self, url: str, name: str | None = None) -> None: + def add_catalog(self, url: str, name: str | None = None) -> str: """Add a catalog source to the project-level config.""" + url = url.strip() self._validate_catalog_url(url) config_path = self.project_root / ".specify" / "step-catalogs.yml" + normalized_name = str(name).strip() if name is not None else "" data: dict[str, Any] = {"catalogs": []} if config_path.exists(): @@ -617,8 +619,16 @@ def add_catalog(self, url: str, name: str | None = None) -> None: raise StepValidationError( "Catalog config 'catalogs' must be a list." ) - for cat in catalogs: - if isinstance(cat, dict) and cat.get("url") == url: + for idx, cat in enumerate(catalogs): + if ( + isinstance(cat, dict) + and str(cat.get("url", "")).strip() == url + ): + generated_name = f"catalog-{idx + 1}" + existing_name = str(cat.get("name") or generated_name).strip() + if not normalized_name or existing_name == normalized_name: + self._load_catalog_config(config_path) + return "unchanged" raise StepValidationError( f"Catalog URL already configured: {url}" ) @@ -643,7 +653,7 @@ def _coerce_priority(value: Any) -> int: ) catalogs.append( { - "name": name or f"catalog-{len(catalogs) + 1}", + "name": normalized_name or f"catalog-{len(catalogs) + 1}", "url": url, "priority": max_priority + 1, "install_allowed": True, @@ -662,6 +672,7 @@ def _coerce_priority(value: Any) -> int: raise StepValidationError( f"Failed to write catalog config {config_path}: {exc}" ) from exc + return "added" def remove_catalog(self, index: int) -> str: """Remove a catalog source by index (0-based). Returns the removed name.""" diff --git a/src/specify_cli/workflows/step/catalog/command_add.py b/src/specify_cli/workflows/step/catalog/command_add.py index 845473c7a6..32f0c791e7 100644 --- a/src/specify_cli/workflows/step/catalog/command_add.py +++ b/src/specify_cli/workflows/step/catalog/command_add.py @@ -18,9 +18,17 @@ def workflow_step_catalog_add( catalog = StepCatalog(project_root) try: - catalog.add_catalog(url, name) + status = catalog.add_catalog(url, name) except StepValidationError as exc: cli.console.print(f"[red]Error:[/red] {exc}") raise cli.typer.Exit(1) - cli.console.print(f"[green]✓[/green] Step catalog source added: {url}") + safe_url = cli._escape_markup(url.strip()) + if status == "unchanged": + cli.console.print( + f"[green]✓[/green] Step catalog source already configured: {safe_url}" + ) + else: + cli.console.print( + f"[green]✓[/green] Step catalog source added: {safe_url}" + ) diff --git a/tests/specify_cli/bundles/catalog/test_command_add.py b/tests/specify_cli/bundles/catalog/test_command_add.py index 5a6cc9c0e4..89ddea0744 100644 --- a/tests/specify_cli/bundles/catalog/test_command_add.py +++ b/tests/specify_cli/bundles/catalog/test_command_add.py @@ -27,6 +27,13 @@ def test_catalog_add_and_remove(project: Path): app, ["bundle", "catalog", "add", str(catalog), "--id", "local"] ) assert added.exit_code == 0, added.output + assert "Added catalog" in added.output + + unchanged = runner.invoke( + app, ["bundle", "catalog", "add", str(catalog), "--id", "local"] + ) + assert unchanged.exit_code == 0, unchanged.output + assert "already configured" in unchanged.output listed = runner.invoke(app, ["bundle", "catalog", "list"]) assert "local" in listed.output diff --git a/tests/specify_cli/bundles/test_catalog_config.py b/tests/specify_cli/bundles/test_catalog_config.py index 5a8f62a2e5..1fbbc575e3 100644 --- a/tests/specify_cli/bundles/test_catalog_config.py +++ b/tests/specify_cli/bundles/test_catalog_config.py @@ -63,12 +63,96 @@ def test_add_source_persists_absolute_local_path(tmp_path: Path, monkeypatch): catalog.write_text("{}", encoding="utf-8") monkeypatch.chdir(project) - source = cc.add_source(project, "sub/cat.json", policy="install-allowed", priority=50) + source, status = cc.add_source( + project, "sub/cat.json", policy="install-allowed", priority=50 + ) + assert status == "added" assert Path(source.url).is_absolute() assert Path(source.url) == catalog.resolve() +def test_add_source_normalizes_existing_entry_for_idempotency(tmp_path: Path): + project = tmp_path / "proj" + (project / ".specify").mkdir(parents=True) + cc._write( + project, + [ + { + "id": " example ", + "url": " https://example.com/catalog.json ", + "priority": "50", + "install_policy": "install-allowed", + "metadata": "preserved", + } + ], + ) + original = cc._config_path(project).read_bytes() + + source, status = cc.add_source( + project, + "https://example.com/catalog.json", + policy="install-allowed", + priority=50, + source_id="example", + ) + + assert status == "unchanged" + assert source.id == "example" + assert cc._config_path(project).read_bytes() == original + + +def test_add_source_rejects_partial_identity_matches(tmp_path: Path): + project = tmp_path / "proj" + (project / ".specify").mkdir(parents=True) + cc.add_source( + project, + "https://example.com/catalog.json", + policy="install-allowed", + priority=50, + source_id="example", + ) + + with pytest.raises(BundlerError, match="already exists"): + cc.add_source( + project, + "https://example.com/other.json", + policy="install-allowed", + priority=50, + source_id="example", + ) + with pytest.raises(BundlerError, match="already exists"): + cc.add_source( + project, + "https://example.com/catalog.json", + policy="install-allowed", + priority=50, + source_id="different", + ) + + +def test_add_source_uses_existing_id_when_id_is_omitted(tmp_path: Path): + project = tmp_path / "proj" + (project / ".specify").mkdir(parents=True) + first, _ = cc.add_source( + project, + "https://example.com/catalog.json", + policy="install-allowed", + priority=50, + source_id="custom", + ) + + second, status = cc.add_source( + project, + "https://example.com/catalog.json", + policy="install-allowed", + priority=50, + ) + + assert status == "unchanged" + assert second == first + + def test_remove_source_accepts_relative_local_path(tmp_path: Path, monkeypatch): """add_source stores a local path as an absolute url, so remove_source must accept the same relative path the caller added; otherwise `remove ./cat.json` @@ -234,7 +318,10 @@ def test_add_source_allows_local_path_with_colon(tmp_path: Path, monkeypatch): (project / ".specify").mkdir(parents=True) monkeypatch.chdir(project) # A relative path containing ':' but no '://' is still a local path. - source = cc.add_source(project, "weird:name.json", policy="install-allowed", priority=50) + source, status = cc.add_source( + project, "weird:name.json", policy="install-allowed", priority=50 + ) + assert status == "added" assert source.url.endswith("weird:name.json") or "weird" in source.url @@ -248,7 +335,13 @@ def test_add_source_rejects_plain_http_for_non_localhost(tmp_path: Path): def test_add_source_allows_http_for_localhost(tmp_path: Path): project = tmp_path / "proj" (project / ".specify").mkdir(parents=True) - source = cc.add_source(project, "http://localhost:8080/c.json", policy="install-allowed", priority=50) + source, status = cc.add_source( + project, + "http://localhost:8080/c.json", + policy="install-allowed", + priority=50, + ) + assert status == "added" assert source.url == "http://localhost:8080/c.json" diff --git a/tests/specify_cli/extensions/catalog/test_command_add.py b/tests/specify_cli/extensions/catalog/test_command_add.py index 3ae60be90e..2c8c4b6578 100644 --- a/tests/specify_cli/extensions/catalog/test_command_add.py +++ b/tests/specify_cli/extensions/catalog/test_command_add.py @@ -23,6 +23,28 @@ class TestExtensionCatalogAddCLI: """CLI tests for ``specify extension catalog add``.""" + def test_catalog_add_is_idempotent_for_identical_entry(self, tmp_path): + project_dir = tmp_path / "test-project" + project_dir.mkdir() + (project_dir / ".specify").mkdir() + args = [ + "extension", + "catalog", + "add", + "https://example.com/catalog.json", + "--name", + "community", + ] + + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + assert runner.invoke(app, args).exit_code == 0 + config_path = project_dir / ".specify" / "extension-catalogs.yml" + original = config_path.read_bytes() + assert runner.invoke(app, args).exit_code == 0 + assert config_path.read_bytes() == original + assert runner.invoke(app, [*args, "--priority", "11"]).exit_code == 1 + def test_catalog_add_escapes_url_markup(self, tmp_path): """Catalog add should render user-supplied URLs literally.""" from specify_cli import app diff --git a/tests/specify_cli/integrations/catalog/test_command_add.py b/tests/specify_cli/integrations/catalog/test_command_add.py index 0686b1bad1..d740c572ce 100644 --- a/tests/specify_cli/integrations/catalog/test_command_add.py +++ b/tests/specify_cli/integrations/catalog/test_command_add.py @@ -96,7 +96,9 @@ def test_catalog_add_rejects_invalid_url(self, tmp_path, monkeypatch): assert result.exit_code == 1 assert "HTTPS" in result.output - def test_catalog_add_rejects_duplicate(self, tmp_path, monkeypatch): + def test_catalog_add_reports_unchanged_and_conflicting_duplicate( + self, tmp_path, monkeypatch + ): project = self._make_project(tmp_path) url = "https://dup.example.com/catalog.json" first = self._invoke( @@ -106,5 +108,12 @@ def test_catalog_add_rejects_duplicate(self, tmp_path, monkeypatch): second = self._invoke( ["integration", "catalog", "add", url], project ) - assert second.exit_code == 1 + assert second.exit_code == 0, second.output assert "already configured" in second.output + + conflict = self._invoke( + ["integration", "catalog", "add", url, "--name", "different"], + project, + ) + assert conflict.exit_code == 1 + assert "already configured" in conflict.output diff --git a/tests/specify_cli/integrations/test_catalog.py b/tests/specify_cli/integrations/test_catalog.py index 3d0c4a5e23..b496a56b6d 100644 --- a/tests/specify_cli/integrations/test_catalog.py +++ b/tests/specify_cli/integrations/test_catalog.py @@ -878,12 +878,28 @@ def test_add_catalog_normalizes_name(self, tmp_path, monkeypatch): entries = data["catalogs"] assert [e["name"] for e in entries] == ["mine", "catalog-2"] - def test_add_catalog_rejects_duplicate_url(self, tmp_path, monkeypatch): + def test_add_catalog_is_idempotent_for_matching_url_and_name( + self, tmp_path, monkeypatch + ): self._isolate(tmp_path, monkeypatch) cat = IntegrationCatalog(tmp_path) - cat.add_catalog("https://dup.example.com/catalog.json") - with pytest.raises(IntegrationValidationError, match="already configured"): + assert ( + cat.add_catalog("https://dup.example.com/catalog.json") + == "added" + ) + cfg_path = tmp_path / ".specify" / "integration-catalogs.yml" + original = cfg_path.read_bytes() + + assert ( cat.add_catalog("https://dup.example.com/catalog.json") + == "unchanged" + ) + assert cfg_path.read_bytes() == original + + with pytest.raises(IntegrationValidationError, match="already configured"): + cat.add_catalog( + "https://dup.example.com/catalog.json", name="different" + ) def test_add_catalog_rejects_invalid_url(self, tmp_path, monkeypatch): self._isolate(tmp_path, monkeypatch) @@ -1212,13 +1228,17 @@ def test_add_catalog_strips_whitespace_in_url(self, tmp_path, monkeypatch): data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) assert data["catalogs"][0]["url"] == "https://a.example.com/catalog.json" - def test_add_catalog_rejects_whitespace_only_duplicate(self, tmp_path, monkeypatch): - """A second add with only whitespace differences must be rejected as a duplicate.""" + def test_add_catalog_accepts_whitespace_only_duplicate( + self, tmp_path, monkeypatch + ): + """A second add with only whitespace differences is unchanged.""" self._isolate(tmp_path, monkeypatch) cat = IntegrationCatalog(tmp_path) cat.add_catalog("https://a.example.com/catalog.json", name="a") - with pytest.raises(IntegrationValidationError, match="already configured"): + assert ( cat.add_catalog(" https://a.example.com/catalog.json ") + == "unchanged" + ) def test_remove_catalog_wraps_unlink_oserror(self, tmp_path, monkeypatch): """An OSError from `Path.unlink` surfaces as IntegrationValidationError.""" diff --git a/tests/specify_cli/presets/catalog/test_command_add.py b/tests/specify_cli/presets/catalog/test_command_add.py index 1b6fae541a..a1c6de9587 100644 --- a/tests/specify_cli/presets/catalog/test_command_add.py +++ b/tests/specify_cli/presets/catalog/test_command_add.py @@ -9,6 +9,30 @@ class TestPresetCatalogAdd: """Test multi-catalog support in PresetCatalog.""" + def test_catalog_add_is_idempotent_for_identical_entry(self, project_dir): + from unittest.mock import patch + + from typer.testing import CliRunner + + from specify_cli import app + + args = [ + "preset", + "catalog", + "add", + "https://example.com/catalog.json", + "--name", + "community", + ] + runner = CliRunner() + with patch.object(Path, "cwd", return_value=project_dir): + assert runner.invoke(app, args).exit_code == 0 + config_path = project_dir / ".specify" / "preset-catalogs.yml" + original = config_path.read_bytes() + assert runner.invoke(app, args).exit_code == 0 + assert config_path.read_bytes() == original + assert runner.invoke(app, [*args, "--priority", "11"]).exit_code == 1 + def test_catalog_add_escapes_rich_markup(self, project_dir): """`preset catalog add` must not parse the name/url as Rich markup. diff --git a/tests/specify_cli/workflows/catalog/test_command_add.py b/tests/specify_cli/workflows/catalog/test_command_add.py index 8885d62ecc..a81634a5ca 100644 --- a/tests/specify_cli/workflows/catalog/test_command_add.py +++ b/tests/specify_cli/workflows/catalog/test_command_add.py @@ -28,3 +28,22 @@ def test_workflow_catalog_add_persists_named_source(project_dir, monkeypatch): and config["url"] == "https://example.com/workflows.json" for config in configs ) + + +def test_workflow_catalog_add_reports_unchanged(project_dir, monkeypatch): + monkeypatch.chdir(project_dir) + args = [ + "workflow", + "catalog", + "add", + "https://example.com/workflows.json", + "--name", + "local", + ] + + runner = CliRunner() + assert runner.invoke(app, args).exit_code == 0 + unchanged = runner.invoke(app, args) + + assert unchanged.exit_code == 0, unchanged.output + assert "already configured" in unchanged.output diff --git a/tests/specify_cli/workflows/step/catalog/test_command_add.py b/tests/specify_cli/workflows/step/catalog/test_command_add.py index 168dde0ab7..4e39f84834 100644 --- a/tests/specify_cli/workflows/step/catalog/test_command_add.py +++ b/tests/specify_cli/workflows/step/catalog/test_command_add.py @@ -29,3 +29,23 @@ def test_workflow_step_catalog_add_persists_named_source(project_dir, monkeypatc and config["url"] == "https://example.com/steps.json" for config in configs ) + + +def test_workflow_step_catalog_add_reports_unchanged(project_dir, monkeypatch): + monkeypatch.chdir(project_dir) + args = [ + "workflow", + "step", + "catalog", + "add", + "https://example.com/steps.json", + "--name", + "local", + ] + + runner = CliRunner() + assert runner.invoke(app, args).exit_code == 0 + unchanged = runner.invoke(app, args) + + assert unchanged.exit_code == 0, unchanged.output + assert "already configured" in unchanged.output diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 6c7545f211..9b42b5f02c 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -8815,14 +8815,28 @@ def test_add_catalog_with_existing_inf_priority(self, project_dir): new = next(c for c in data["catalogs"] if c["url"] == "https://b.example.com/c.json") assert new["priority"] == 1 # max(inf coerced to 0) + 1 - def test_add_catalog_duplicate_rejected(self, project_dir): + def test_add_catalog_normalizes_stored_url_for_idempotency(self, project_dir): from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError catalog = WorkflowCatalog(project_dir) - catalog.add_catalog("https://example.com/catalog.json") + assert ( + catalog.add_catalog("https://example.com/catalog.json", "mine") + == "added" + ) + config_path = project_dir / ".specify" / "workflow-catalogs.yml" + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) + data["catalogs"][0]["url"] = " https://example.com/catalog.json " + config_path.write_text(yaml.safe_dump(data), encoding="utf-8") + original = config_path.read_bytes() + + assert ( + catalog.add_catalog("https://example.com/catalog.json", "mine") + == "unchanged" + ) + assert config_path.read_bytes() == original with pytest.raises(WorkflowValidationError, match="already configured"): - catalog.add_catalog("https://example.com/catalog.json") + catalog.add_catalog("https://example.com/catalog.json", "different") def test_remove_catalog(self, project_dir): from specify_cli.workflows.catalog import WorkflowCatalog @@ -9557,14 +9571,28 @@ def test_add_catalog_rejects_falsy_non_mapping_config( assert config_path.read_text(encoding="utf-8") == original - def test_add_catalog_duplicate_rejected(self, project_dir): + def test_add_catalog_normalizes_stored_url_for_idempotency(self, project_dir): from specify_cli.workflows.step.catalog import StepCatalog, StepValidationError catalog = StepCatalog(project_dir) - catalog.add_catalog("https://example.com/steps.json") + assert ( + catalog.add_catalog("https://example.com/steps.json", "mine") + == "added" + ) + config_path = project_dir / ".specify" / "step-catalogs.yml" + data = yaml.safe_load(config_path.read_text(encoding="utf-8")) + data["catalogs"][0]["url"] = " https://example.com/steps.json " + config_path.write_text(yaml.safe_dump(data), encoding="utf-8") + original = config_path.read_bytes() + + assert ( + catalog.add_catalog("https://example.com/steps.json", "mine") + == "unchanged" + ) + assert config_path.read_bytes() == original with pytest.raises(StepValidationError, match="already configured"): - catalog.add_catalog("https://example.com/steps.json") + catalog.add_catalog("https://example.com/steps.json", "different") def test_remove_catalog(self, project_dir): from specify_cli.workflows.step.catalog import StepCatalog