Skip to content

Commit 6d86483

Browse files
committed
fix: align catalog remove with displayed order
1 parent e8a8afc commit 6d86483

4 files changed

Lines changed: 172 additions & 9 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2744,15 +2744,19 @@ def integration_catalog_add(
27442744
project_root = _require_specify_project()
27452745
catalog = IntegrationCatalog(project_root)
27462746

2747+
# Normalize once here so the success message reflects what was actually
2748+
# stored. ``IntegrationCatalog.add_catalog`` strips again defensively.
2749+
normalized_url = url.strip()
2750+
27472751
try:
2748-
catalog.add_catalog(url, name)
2752+
catalog.add_catalog(normalized_url, name)
27492753
except IntegrationCatalogError as exc:
27502754
# Covers both URL validation (base class) and config-file validation
27512755
# (IntegrationValidationError subclass).
27522756
console.print(f"[red]Error:[/red] {exc}")
27532757
raise typer.Exit(1)
27542758

2755-
console.print(f"[green]✓[/green] Catalog source added: {url}")
2759+
console.print(f"[green]✓[/green] Catalog source added: {normalized_url}")
27562760

27572761

27582762
@integration_catalog_app.command("remove")

src/specify_cli/integrations/catalog.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from dataclasses import dataclass
1717
from datetime import datetime, timezone
1818
from pathlib import Path
19-
from typing import Any, Dict, List, Optional
19+
from typing import Any, Dict, List, Optional, Tuple
2020

2121
import yaml
2222
from packaging import version as pkg_version
@@ -527,7 +527,18 @@ def add_catalog(self, url: str, name: Optional[str] = None) -> None:
527527
)
528528

529529
def remove_catalog(self, index: int) -> str:
530-
"""Remove a catalog source by 0-based index. Returns the removed name."""
530+
"""Remove a catalog source by 0-based index.
531+
532+
``index`` is interpreted in the same display order shown by
533+
``integration catalog list`` (i.e. sorted ascending by priority,
534+
with missing priority defaulting to ``yaml_index + 1``, matching
535+
``_load_catalog_config()``). This way, the index a user sees in
536+
``catalog list`` is the index they pass to ``catalog remove``,
537+
even if the underlying YAML lists entries in a different order
538+
from how they sort by priority.
539+
540+
Returns the removed catalog's name.
541+
"""
531542
config_path = self.project_root / ".specify" / self.CONFIG_FILENAME
532543
if not config_path.exists():
533544
raise IntegrationValidationError("No catalog config file found.")
@@ -558,12 +569,33 @@ def remove_catalog(self, index: int) -> str:
558569
"Catalog config contains no catalog entries."
559570
)
560571

561-
if index < 0 or index >= len(catalogs):
572+
# Map displayed index -> raw YAML index using the same priority
573+
# defaulting as ``_load_catalog_config``. We deliberately stay
574+
# tolerant here (no new validation errors) because the goal is
575+
# only to mirror the order shown by ``catalog list``; entries
576+
# that ``_load_catalog_config`` would have rejected outright
577+
# would have failed ``catalog list`` already.
578+
priority_pairs: List[Tuple[int, int]] = []
579+
for yaml_idx, item in enumerate(catalogs):
580+
if isinstance(item, dict):
581+
try:
582+
priority = int(item.get("priority", yaml_idx + 1))
583+
except (TypeError, ValueError):
584+
priority = yaml_idx + 1
585+
else:
586+
priority = yaml_idx + 1
587+
priority_pairs.append((priority, yaml_idx))
588+
# Stable sort: ties keep their YAML order, matching list-view ordering.
589+
priority_pairs.sort(key=lambda p: p[0])
590+
display_order: List[int] = [yaml_idx for _, yaml_idx in priority_pairs]
591+
592+
if index < 0 or index >= len(display_order):
562593
raise IntegrationValidationError(
563-
f"Catalog index {index} out of range (0-{len(catalogs) - 1})."
594+
f"Catalog index {index} out of range (0-{len(display_order) - 1})."
564595
)
565596

566-
removed = catalogs.pop(index)
597+
target_yaml_idx = display_order[index]
598+
removed = catalogs.pop(target_yaml_idx)
567599

568600
if catalogs:
569601
data["catalogs"] = catalogs
@@ -592,8 +624,8 @@ def remove_catalog(self, index: int) -> str:
592624
) from exc
593625

594626
if isinstance(removed, dict):
595-
return removed.get("name", f"catalog-{index + 1}")
596-
return f"catalog-{index + 1}"
627+
return removed.get("name", f"catalog-{target_yaml_idx + 1}")
628+
return f"catalog-{target_yaml_idx + 1}"
597629

598630

599631
# ---------------------------------------------------------------------------

tests/integrations/test_cli.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -832,6 +832,41 @@ def test_catalog_add_then_remove_roundtrip(self, tmp_path, monkeypatch):
832832
assert remove_result.exit_code == 0, remove_result.output
833833
assert "'mine' removed" in remove_result.output
834834

835+
def test_catalog_add_strips_whitespace_in_success_output_and_storage(
836+
self, tmp_path, monkeypatch
837+
):
838+
"""Surrounding whitespace in the URL must not appear in the success
839+
message or be persisted to the YAML config."""
840+
project = self._make_project(tmp_path)
841+
monkeypatch.setenv("HOME", str(tmp_path))
842+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
843+
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
844+
845+
padded_url = " https://padded.example.com/catalog.json "
846+
clean_url = "https://padded.example.com/catalog.json"
847+
848+
add_result = self._invoke(
849+
[
850+
"integration",
851+
"catalog",
852+
"add",
853+
padded_url,
854+
"--name",
855+
"padded",
856+
],
857+
project,
858+
)
859+
assert add_result.exit_code == 0, add_result.output
860+
assert clean_url in add_result.output
861+
assert padded_url not in add_result.output
862+
863+
cfg_path = project / ".specify" / "integration-catalogs.yml"
864+
import yaml as _yaml
865+
data = _yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
866+
urls = [c["url"] for c in data["catalogs"]]
867+
assert clean_url in urls
868+
assert padded_url not in urls
869+
835870
def test_catalog_add_rejects_invalid_url(self, tmp_path, monkeypatch):
836871
project = self._make_project(tmp_path)
837872
result = self._invoke(

tests/integrations/test_integration_catalog.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -955,3 +955,95 @@ def test_remove_last_catalog_deletes_file_and_restores_defaults(
955955
# Follow-up loads fall back to built-in defaults, not an error.
956956
active = cat.get_active_catalogs()
957957
assert [e.name for e in active] == ["default", "community"]
958+
959+
def test_remove_catalog_uses_display_order_with_explicit_priorities(
960+
self, tmp_path, monkeypatch
961+
):
962+
"""`remove_catalog(index)` must remove the entry shown at that index by
963+
`catalog list`, not the entry at that raw YAML position."""
964+
self._isolate(tmp_path, monkeypatch)
965+
# YAML order: alpha (priority=20), beta (priority=10), gamma (priority=15).
966+
# Display (sorted by priority asc): beta (10), gamma (15), alpha (20).
967+
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
968+
cfg_path.parent.mkdir(parents=True, exist_ok=True)
969+
cfg_path.write_text(
970+
yaml.dump(
971+
{
972+
"catalogs": [
973+
{"url": "https://alpha.example.com/c.json", "name": "alpha", "priority": 20},
974+
{"url": "https://beta.example.com/c.json", "name": "beta", "priority": 10},
975+
{"url": "https://gamma.example.com/c.json", "name": "gamma", "priority": 15},
976+
]
977+
}
978+
),
979+
encoding="utf-8",
980+
)
981+
cat = IntegrationCatalog(tmp_path)
982+
983+
# Display index 0 = beta (lowest priority), not alpha (raw YAML idx 0).
984+
removed = cat.remove_catalog(0)
985+
assert removed == "beta"
986+
987+
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
988+
remaining_names = [c["name"] for c in data["catalogs"]]
989+
# YAML order is preserved for the survivors; only beta is gone.
990+
assert remaining_names == ["alpha", "gamma"]
991+
992+
def test_remove_catalog_display_order_with_missing_priorities(
993+
self, tmp_path, monkeypatch
994+
):
995+
"""Entries without `priority` default to `idx + 1` (matching
996+
`_load_catalog_config`), so display order tracks YAML order and the
997+
first display entry is the first YAML entry."""
998+
self._isolate(tmp_path, monkeypatch)
999+
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
1000+
cfg_path.parent.mkdir(parents=True, exist_ok=True)
1001+
cfg_path.write_text(
1002+
yaml.dump(
1003+
{
1004+
"catalogs": [
1005+
{"url": "https://one.example.com/c.json", "name": "one"},
1006+
{"url": "https://two.example.com/c.json", "name": "two"},
1007+
{"url": "https://three.example.com/c.json", "name": "three"},
1008+
]
1009+
}
1010+
),
1011+
encoding="utf-8",
1012+
)
1013+
cat = IntegrationCatalog(tmp_path)
1014+
1015+
# Implicit priorities: one=1, two=2, three=3 → display order matches YAML.
1016+
removed = cat.remove_catalog(0)
1017+
assert removed == "one"
1018+
1019+
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
1020+
assert [c["name"] for c in data["catalogs"]] == ["two", "three"]
1021+
1022+
def test_remove_catalog_display_order_mixes_explicit_and_default(
1023+
self, tmp_path, monkeypatch
1024+
):
1025+
"""An explicit low priority should sort ahead of default-priority
1026+
siblings, even if it appears later in the YAML."""
1027+
self._isolate(tmp_path, monkeypatch)
1028+
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
1029+
cfg_path.parent.mkdir(parents=True, exist_ok=True)
1030+
# Defaults: a=1, b=2 (implicit). Explicit c=0 → display: c, a, b.
1031+
cfg_path.write_text(
1032+
yaml.dump(
1033+
{
1034+
"catalogs": [
1035+
{"url": "https://a.example.com/c.json", "name": "a"},
1036+
{"url": "https://b.example.com/c.json", "name": "b"},
1037+
{"url": "https://c.example.com/c.json", "name": "c", "priority": 0},
1038+
]
1039+
}
1040+
),
1041+
encoding="utf-8",
1042+
)
1043+
cat = IntegrationCatalog(tmp_path)
1044+
1045+
removed = cat.remove_catalog(0)
1046+
assert removed == "c"
1047+
1048+
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
1049+
assert [c["name"] for c in data["catalogs"]] == ["a", "b"]

0 commit comments

Comments
 (0)