From 319c09c9ae438b8aaedb687fb3f776194bbc1e27 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 7 Jul 2026 12:32:13 +0800 Subject: [PATCH 1/3] Expose workflow catalog add metadata options --- src/specify_cli/workflows/_commands.py | 18 +++- src/specify_cli/workflows/catalog.py | 30 +++++-- tests/test_workflows.py | 109 +++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 10 deletions(-) diff --git a/src/specify_cli/workflows/_commands.py b/src/specify_cli/workflows/_commands.py index 095e3e415e..5069dd5989 100644 --- a/src/specify_cli/workflows/_commands.py +++ b/src/specify_cli/workflows/_commands.py @@ -1009,6 +1009,13 @@ def workflow_catalog_list(): def workflow_catalog_add( url: str = typer.Argument(..., help="Catalog URL to add"), name: str | None = typer.Option(None, "--name", help="Catalog name"), + priority: int | None = typer.Option(None, "--priority", help="Priority (lower = higher priority)"), + install_allowed: bool = typer.Option( + True, + "--install-allowed/--no-install-allowed", + help="Allow workflows from this catalog to be installed", + ), + description: str = typer.Option("", "--description", help="Description of the catalog"), ): """Add a workflow catalog source.""" from .catalog import WorkflowCatalog, WorkflowValidationError @@ -1016,7 +1023,7 @@ def workflow_catalog_add( project_root = _require_specify_project() catalog = WorkflowCatalog(project_root) try: - catalog.add_catalog(url, name) + catalog.add_catalog(url, name, priority, install_allowed, description) except WorkflowValidationError as exc: console.print(f"[red]Error:[/red] {exc}") raise typer.Exit(1) @@ -1661,6 +1668,13 @@ def workflow_step_catalog_list(): def workflow_step_catalog_add( url: str = typer.Argument(..., help="Catalog URL to add"), name: str | None = typer.Option(None, "--name", help="Catalog name"), + priority: int | None = typer.Option(None, "--priority", help="Priority (lower = higher priority)"), + install_allowed: bool = typer.Option( + True, + "--install-allowed/--no-install-allowed", + help="Allow steps from this catalog to be installed", + ), + description: str = typer.Option("", "--description", help="Description of the catalog"), ): """Add a step catalog source.""" from .catalog import StepCatalog, StepValidationError @@ -1669,7 +1683,7 @@ def workflow_step_catalog_add( catalog = StepCatalog(project_root) try: - catalog.add_catalog(url, name) + catalog.add_catalog(url, name, priority, install_allowed, description) except StepValidationError as exc: console.print(f"[red]Error:[/red] {exc}") raise typer.Exit(1) diff --git a/src/specify_cli/workflows/catalog.py b/src/specify_cli/workflows/catalog.py index 97bf58a04e..67f06f9dae 100644 --- a/src/specify_cli/workflows/catalog.py +++ b/src/specify_cli/workflows/catalog.py @@ -476,7 +476,14 @@ 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, + priority: int | None = None, + install_allowed: bool = True, + description: str = "", + ) -> None: """Add a catalog source to the project-level config.""" self._validate_catalog_url(url) config_path = self.project_root / ".specify" / "workflow-catalogs.yml" @@ -530,9 +537,9 @@ def _coerce_priority(value: Any) -> int: { "name": name or f"catalog-{len(catalogs) + 1}", "url": url, - "priority": max_priority + 1, - "install_allowed": True, - "description": "", + "priority": max_priority + 1 if priority is None else priority, + "install_allowed": install_allowed, + "description": description, } ) data["catalogs"] = catalogs @@ -1086,7 +1093,14 @@ 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, + priority: int | None = None, + install_allowed: bool = True, + description: str = "", + ) -> None: """Add a catalog source to the project-level config.""" self._validate_catalog_url(url) config_path = self.project_root / ".specify" / "step-catalogs.yml" @@ -1136,9 +1150,9 @@ def _coerce_priority(value: Any) -> int: { "name": name or f"catalog-{len(catalogs) + 1}", "url": url, - "priority": max_priority + 1, - "install_allowed": True, - "description": "", + "priority": max_priority + 1 if priority is None else priority, + "install_allowed": install_allowed, + "description": description, } ) data["catalogs"] = catalogs diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 11338b3009..c8aa98b0c4 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -4513,6 +4513,60 @@ def test_add_catalog(self, project_dir): data = yaml.safe_load(config_path.read_text()) assert len(data["catalogs"]) == 1 assert data["catalogs"][0]["url"] == "https://example.com/new-catalog.json" + assert data["catalogs"][0]["priority"] == 1 + assert data["catalogs"][0]["install_allowed"] is True + assert data["catalogs"][0]["description"] == "" + + def test_add_catalog_accepts_metadata_overrides(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog + + catalog = WorkflowCatalog(project_dir) + catalog.add_catalog( + "https://example.com/new-catalog.json", + "my-catalog", + priority=7, + install_allowed=False, + description="Workflow source", + ) + + config_path = project_dir / ".specify" / "workflow-catalogs.yml" + data = yaml.safe_load(config_path.read_text()) + assert data["catalogs"][0] == { + "name": "my-catalog", + "url": "https://example.com/new-catalog.json", + "priority": 7, + "install_allowed": False, + "description": "Workflow source", + } + + def test_catalog_add_cli_accepts_metadata_options(self, project_dir, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + result = CliRunner().invoke( + app, + [ + "workflow", + "catalog", + "add", + "https://example.com/new-catalog.json", + "--name", + "my-catalog", + "--priority", + "7", + "--no-install-allowed", + "--description", + "Workflow source", + ], + ) + + assert result.exit_code == 0, result.output + config_path = project_dir / ".specify" / "workflow-catalogs.yml" + data = yaml.safe_load(config_path.read_text()) + assert data["catalogs"][0]["priority"] == 7 + assert data["catalogs"][0]["install_allowed"] is False + assert data["catalogs"][0]["description"] == "Workflow source" def test_add_catalog_duplicate_rejected(self, project_dir): from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError @@ -4959,6 +5013,61 @@ def test_add_catalog(self, project_dir): data = yaml.safe_load(config_path.read_text()) assert len(data["catalogs"]) == 1 assert data["catalogs"][0]["url"] == "https://example.com/new-steps.json" + assert data["catalogs"][0]["priority"] == 1 + assert data["catalogs"][0]["install_allowed"] is True + assert data["catalogs"][0]["description"] == "" + + def test_add_catalog_accepts_metadata_overrides(self, project_dir): + from specify_cli.workflows.catalog import StepCatalog + + catalog = StepCatalog(project_dir) + catalog.add_catalog( + "https://example.com/new-steps.json", + "my-steps", + priority=7, + install_allowed=False, + description="Step source", + ) + + config_path = project_dir / ".specify" / "step-catalogs.yml" + data = yaml.safe_load(config_path.read_text()) + assert data["catalogs"][0] == { + "name": "my-steps", + "url": "https://example.com/new-steps.json", + "priority": 7, + "install_allowed": False, + "description": "Step source", + } + + def test_catalog_add_cli_accepts_metadata_options(self, project_dir, monkeypatch): + from typer.testing import CliRunner + from specify_cli import app + + monkeypatch.chdir(project_dir) + result = CliRunner().invoke( + app, + [ + "workflow", + "step", + "catalog", + "add", + "https://example.com/new-steps.json", + "--name", + "my-steps", + "--priority", + "7", + "--no-install-allowed", + "--description", + "Step source", + ], + ) + + assert result.exit_code == 0, result.output + config_path = project_dir / ".specify" / "step-catalogs.yml" + data = yaml.safe_load(config_path.read_text()) + assert data["catalogs"][0]["priority"] == 7 + assert data["catalogs"][0]["install_allowed"] is False + assert data["catalogs"][0]["description"] == "Step source" def test_add_catalog_empty_yaml_file(self, project_dir): """An empty YAML config file should be treated as empty, not corrupted.""" From b141978005590dfa83588cb66bc7f2a38f072727 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 8 Jul 2026 17:42:22 +0800 Subject: [PATCH 2/3] fix: pass catalog metadata by keyword in workflow add commands --- src/specify_cli/workflows/_commands.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/workflows/_commands.py b/src/specify_cli/workflows/_commands.py index 5069dd5989..375cc937a9 100644 --- a/src/specify_cli/workflows/_commands.py +++ b/src/specify_cli/workflows/_commands.py @@ -1023,7 +1023,13 @@ def workflow_catalog_add( project_root = _require_specify_project() catalog = WorkflowCatalog(project_root) try: - catalog.add_catalog(url, name, priority, install_allowed, description) + catalog.add_catalog( + url, + name, + priority=priority, + install_allowed=install_allowed, + description=description, + ) except WorkflowValidationError as exc: console.print(f"[red]Error:[/red] {exc}") raise typer.Exit(1) @@ -1683,7 +1689,13 @@ def workflow_step_catalog_add( catalog = StepCatalog(project_root) try: - catalog.add_catalog(url, name, priority, install_allowed, description) + catalog.add_catalog( + url, + name, + priority=priority, + install_allowed=install_allowed, + description=description, + ) except StepValidationError as exc: console.print(f"[red]Error:[/red] {exc}") raise typer.Exit(1) From 95d4c0894e0e88dee9152ffbb2a7938ae0c4fdb6 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 10 Jul 2026 10:37:10 +0800 Subject: [PATCH 3/3] fix(workflows): normalize catalog priority overrides --- src/specify_cli/workflows/catalog.py | 108 +++++++++++++++++---------- tests/test_workflows.py | 102 +++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 41 deletions(-) diff --git a/src/specify_cli/workflows/catalog.py b/src/specify_cli/workflows/catalog.py index 67f06f9dae..4ffcaac181 100644 --- a/src/specify_cli/workflows/catalog.py +++ b/src/specify_cli/workflows/catalog.py @@ -50,6 +50,27 @@ class WorkflowCatalogEntry: description: str = "" +def _normalize_catalog_priority( + value: Any, + *, + catalog_name: str | int, + error_cls: type[Exception], +) -> int: + """Normalize a catalog priority to int and reject bool explicitly.""" + if isinstance(value, bool): + raise error_cls( + f"Invalid priority for catalog '{catalog_name}': " + f"expected integer, got {value!r}" + ) + try: + return int(value) + except (TypeError, ValueError) as exc: + raise error_cls( + f"Invalid priority for catalog '{catalog_name}': " + f"expected integer, got {value!r}" + ) from exc + + # --------------------------------------------------------------------------- # WorkflowRegistry # --------------------------------------------------------------------------- @@ -210,14 +231,11 @@ def _load_catalog_config( if not url: continue self._validate_catalog_url(url) - try: - priority = int(item.get("priority", idx + 1)) - except (TypeError, ValueError): - raise WorkflowValidationError( - f"Invalid priority for catalog " - f"'{item.get('name', idx + 1)}': " - f"expected integer, got {item.get('priority')!r}" - ) + priority = _normalize_catalog_priority( + item.get("priority", idx + 1), + catalog_name=item.get("name", idx + 1), + error_cls=WorkflowValidationError, + ) raw_install = item.get("install_allowed", False) if isinstance(raw_install, str): install_allowed = raw_install.strip().lower() in ( @@ -516,28 +534,33 @@ def add_catalog( f"Catalog URL already configured: {url}" ) - # Derive priority from the highest existing priority + 1. - # Coerce existing priorities to int with a safe fallback so a user-edited - # workflow-catalogs.yml with a non-integer priority (e.g. "1") doesn't blow up. - def _coerce_priority(value: Any) -> int: - try: - return int(value) - except (TypeError, ValueError): - return 0 - max_priority = max( ( - _coerce_priority(cat.get("priority", 0)) - for cat in catalogs + _normalize_catalog_priority( + cat["priority"], + catalog_name=cat.get("name", idx + 1), + error_cls=WorkflowValidationError, + ) + for idx, cat in enumerate(catalogs) if isinstance(cat, dict) + if "priority" in cat ), default=0, ) + catalog_name = name or f"catalog-{len(catalogs) + 1}" catalogs.append( { - "name": name or f"catalog-{len(catalogs) + 1}", + "name": catalog_name, "url": url, - "priority": max_priority + 1 if priority is None else priority, + "priority": ( + max_priority + 1 + if priority is None + else _normalize_catalog_priority( + priority, + catalog_name=catalog_name, + error_cls=WorkflowValidationError, + ) + ), "install_allowed": install_allowed, "description": description, } @@ -832,14 +855,11 @@ def _load_catalog_config( if not url: continue self._validate_catalog_url(url) - try: - priority = int(item.get("priority", idx + 1)) - except (TypeError, ValueError): - raise StepValidationError( - f"Invalid priority for catalog " - f"'{item.get('name', idx + 1)}': " - f"expected integer, got {item.get('priority')!r}" - ) + priority = _normalize_catalog_priority( + item.get("priority", idx + 1), + catalog_name=item.get("name", idx + 1), + error_cls=StepValidationError, + ) raw_install = item.get("install_allowed", False) if isinstance(raw_install, str): install_allowed = raw_install.strip().lower() in ( @@ -1130,27 +1150,33 @@ def add_catalog( f"Catalog URL already configured: {url}" ) - # Coerce existing priorities to int with a safe fallback so a user-edited - # step-catalogs.yml with a non-integer priority (e.g. "1") doesn't blow up. - def _coerce_priority(value: Any) -> int: - try: - return int(value) - except (TypeError, ValueError): - return 0 - max_priority = max( ( - _coerce_priority(cat.get("priority", 0)) - for cat in catalogs + _normalize_catalog_priority( + cat["priority"], + catalog_name=cat.get("name", idx + 1), + error_cls=StepValidationError, + ) + for idx, cat in enumerate(catalogs) if isinstance(cat, dict) + if "priority" in cat ), default=0, ) + catalog_name = name or f"catalog-{len(catalogs) + 1}" catalogs.append( { - "name": name or f"catalog-{len(catalogs) + 1}", + "name": catalog_name, "url": url, - "priority": max_priority + 1 if priority is None else priority, + "priority": ( + max_priority + 1 + if priority is None + else _normalize_catalog_priority( + priority, + catalog_name=catalog_name, + error_cls=StepValidationError, + ) + ), "install_allowed": install_allowed, "description": description, } diff --git a/tests/test_workflows.py b/tests/test_workflows.py index c8aa98b0c4..c948b268f6 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -4539,6 +4539,33 @@ def test_add_catalog_accepts_metadata_overrides(self, project_dir): "description": "Workflow source", } + def test_add_catalog_coerces_string_priority_override(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog + + catalog = WorkflowCatalog(project_dir) + catalog.add_catalog( + "https://example.com/new-catalog.json", + "my-catalog", + priority="7", + ) + + data = yaml.safe_load( + (project_dir / ".specify" / "workflow-catalogs.yml").read_text() + ) + assert data["catalogs"][0]["priority"] == 7 + + def test_add_catalog_rejects_bool_priority_override(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError + + catalog = WorkflowCatalog(project_dir) + + with pytest.raises(WorkflowValidationError, match=r"expected integer, got True"): + catalog.add_catalog( + "https://example.com/new-catalog.json", + "my-catalog", + priority=True, + ) + def test_catalog_add_cli_accepts_metadata_options(self, project_dir, monkeypatch): from typer.testing import CliRunner from specify_cli import app @@ -4620,6 +4647,30 @@ def test_load_catalog_config_non_dict_yaml_raises(self, project_dir): with pytest.raises(WorkflowValidationError, match="expected a mapping"): catalog.get_active_catalogs() + def test_load_catalog_config_rejects_bool_priority(self, project_dir): + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError + + config_path = project_dir / ".specify" / "workflow-catalogs.yml" + config_path.write_text( + yaml.dump( + { + "catalogs": [ + { + "name": "custom", + "url": "https://example.com/wf-catalog.json", + "priority": True, + "install_allowed": True, + } + ] + } + ), + encoding="utf-8", + ) + + catalog = WorkflowCatalog(project_dir) + with pytest.raises(WorkflowValidationError, match=r"expected integer, got True"): + catalog.get_active_catalogs() + def test_add_catalog_malformed_yaml_raises(self, project_dir): """A malformed YAML config file must raise WorkflowValidationError when adding a catalog.""" from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError @@ -5039,6 +5090,33 @@ def test_add_catalog_accepts_metadata_overrides(self, project_dir): "description": "Step source", } + def test_add_catalog_coerces_string_priority_override(self, project_dir): + from specify_cli.workflows.catalog import StepCatalog + + catalog = StepCatalog(project_dir) + catalog.add_catalog( + "https://example.com/new-steps.json", + "my-steps", + priority="7", + ) + + data = yaml.safe_load( + (project_dir / ".specify" / "step-catalogs.yml").read_text() + ) + assert data["catalogs"][0]["priority"] == 7 + + def test_add_catalog_rejects_bool_priority_override(self, project_dir): + from specify_cli.workflows.catalog import StepCatalog, StepValidationError + + catalog = StepCatalog(project_dir) + + with pytest.raises(StepValidationError, match=r"expected integer, got True"): + catalog.add_catalog( + "https://example.com/new-steps.json", + "my-steps", + priority=True, + ) + def test_catalog_add_cli_accepts_metadata_options(self, project_dir, monkeypatch): from typer.testing import CliRunner from specify_cli import app @@ -5167,6 +5245,30 @@ def test_get_catalog_configs(self, project_dir): assert configs[0]["name"] == "default" assert isinstance(configs[0]["install_allowed"], bool) + def test_load_catalog_config_rejects_bool_priority(self, project_dir): + from specify_cli.workflows.catalog import StepCatalog, StepValidationError + + config_path = project_dir / ".specify" / "step-catalogs.yml" + config_path.write_text( + yaml.dump( + { + "catalogs": [ + { + "name": "custom", + "url": "https://example.com/step-catalog.json", + "priority": True, + "install_allowed": True, + } + ] + } + ), + encoding="utf-8", + ) + + catalog = StepCatalog(project_dir) + with pytest.raises(StepValidationError, match=r"expected integer, got True"): + catalog.get_active_catalogs() + def test_search_with_mock_catalog(self, project_dir, monkeypatch): from specify_cli.workflows.catalog import StepCatalog