Skip to content

Commit 893c70a

Browse files
committed
harden plugin CLI test coverage
1 parent 8a0f55b commit 893c70a

3 files changed

Lines changed: 251 additions & 38 deletions

File tree

packages/data-designer/tests/cli/controllers/test_plugin_catalog_controller.py

Lines changed: 92 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,38 @@ def test_run_info_renders_package_metadata_with_nested_runtime_plugins(
416416
assert mock_console.print.call_count >= 1
417417

418418

419+
@patch("data_designer.cli.controllers.plugin_catalog_controller.print_warning")
420+
@patch("data_designer.cli.controllers.plugin_catalog_controller.console")
421+
@patch("data_designer.cli.controllers.plugin_catalog_controller.display_config_preview")
422+
def test_run_info_still_renders_package_metadata_when_install_plan_cannot_be_built(
423+
mock_display_config_preview: MagicMock,
424+
mock_console: MagicMock,
425+
mock_print_warning: MagicMock,
426+
controller: PluginCatalogController,
427+
) -> None:
428+
entry = _entry()
429+
catalog = _catalog()
430+
controller.catalog_service.get_catalog.return_value = catalog
431+
controller.catalog_service.get_package_entries.return_value = [entry]
432+
controller.catalog_service.evaluate_compatibility.return_value = CompatibilityResult(True, [])
433+
controller.catalog_service.get_package_current_version.return_value = "0.2.0"
434+
controller.install_service.build_install_plan.side_effect = ValueError("pip is unavailable")
435+
436+
controller.run_info("text-transform", catalog_alias="local")
437+
438+
mock_console.print.assert_any_call(" Version: [bold]0.2.0[/bold]")
439+
mock_console.print.assert_any_call(" Runtime plugins: [bold]text-transform (processor)[/bold]")
440+
mock_console.print.assert_any_call(" Compatibility: [bold green]data-designer>=0.5.7 ✓[/bold green]")
441+
mock_print_warning.assert_called_once_with("pip is unavailable")
442+
metadata = mock_display_config_preview.call_args.args[0]
443+
assert metadata["package"] == {
444+
"name": "data-designer-text-transform",
445+
"description": "Transform text records",
446+
"version": "0.2.0",
447+
}
448+
mock_display_config_preview.assert_called_once()
449+
450+
419451
@pytest.mark.parametrize(
420452
("install_mode", "expected_strategy"),
421453
[
@@ -874,9 +906,11 @@ def test_run_install_preserves_version_in_runtime_plugin_recovery_hint(
874906

875907

876908
@patch("data_designer.cli.controllers.plugin_catalog_controller.console")
909+
@patch("data_designer.cli.controllers.plugin_catalog_controller.print_error")
877910
@patch("data_designer.cli.controllers.plugin_catalog_controller.print_warning")
878911
def test_run_install_dry_run_renders_incompatible_plan_and_block_message(
879912
mock_print_warning: MagicMock,
913+
mock_print_error: MagicMock,
880914
mock_console: MagicMock,
881915
controller: PluginCatalogController,
882916
) -> None:
@@ -894,9 +928,16 @@ def test_run_install_dry_run_renders_incompatible_plan_and_block_message(
894928
controller.run_install("data-designer-text-transform", catalog_alias="local", dry_run=True)
895929

896930
assert exc_info.value.exit_code == 1
931+
controller.catalog_service.get_package_entries.assert_called_once_with(
932+
"data-designer-text-transform",
933+
"local",
934+
refresh=False,
935+
include_incompatible=True,
936+
)
897937
controller.install_service.build_install_plan.assert_called_once_with(entry, catalog, manager="auto")
898938
controller.install_service.install.assert_not_called()
899939
controller.install_service.verify_entry_points.assert_not_called()
940+
mock_print_error.assert_not_called()
900941
mock_console.print.assert_any_call(" Install strategy: [bold]pip install[/bold]")
901942
assert all(
902943
"Command:" not in str(call_args.args[0]) for call_args in mock_console.print.call_args_list if call_args.args
@@ -911,44 +952,6 @@ def test_run_install_dry_run_renders_incompatible_plan_and_block_message(
911952
)
912953

913954

914-
@patch("data_designer.cli.controllers.plugin_catalog_controller.console")
915-
@patch("data_designer.cli.controllers.plugin_catalog_controller.print_error")
916-
@patch("data_designer.cli.controllers.plugin_catalog_controller.print_warning")
917-
def test_run_install_dry_run_renders_incompatible_entry_for_inspection(
918-
mock_print_warning: MagicMock,
919-
mock_print_error: MagicMock,
920-
mock_console: MagicMock,
921-
controller: PluginCatalogController,
922-
) -> None:
923-
entry = _entry(data_designer_requirement="data-designer>=99.0", data_designer_specifier=">=99.0")
924-
catalog = _catalog()
925-
controller.catalog_service.get_catalog.return_value = catalog
926-
controller.catalog_service.get_package_entries.return_value = [entry]
927-
controller.catalog_service.evaluate_compatibility.return_value = CompatibilityResult(
928-
False,
929-
["Data Designer 0.5.7 does not satisfy >=99.0"],
930-
)
931-
controller.install_service.build_install_plan.return_value = _plan(catalog)
932-
933-
with pytest.raises(typer.Exit) as exc_info:
934-
controller.run_install("data-designer-text-transform", catalog_alias="local", dry_run=True)
935-
936-
assert exc_info.value.exit_code == 1
937-
controller.catalog_service.get_package_entries.assert_called_once_with(
938-
"data-designer-text-transform",
939-
"local",
940-
refresh=False,
941-
include_incompatible=True,
942-
)
943-
controller.install_service.build_install_plan.assert_called_once_with(entry, catalog, manager="auto")
944-
controller.install_service.install.assert_not_called()
945-
mock_print_error.assert_not_called()
946-
mock_print_warning.assert_called_once_with(
947-
"Dry run complete; no changes made. Install would be blocked because compatibility checks failed."
948-
)
949-
assert mock_console.print.call_count >= 1
950-
951-
952955
@patch("data_designer.cli.controllers.plugin_catalog_controller.console")
953956
@patch("data_designer.cli.controllers.plugin_catalog_controller.print_warning")
954957
def test_run_install_warns_when_install_plan_has_source_warning(
@@ -1025,6 +1028,32 @@ def test_run_install_warns_when_verification_misses_entry_point(
10251028
assert mock_console.print.call_count >= 1
10261029

10271030

1031+
@patch("data_designer.cli.controllers.plugin_catalog_controller.console")
1032+
@patch("data_designer.cli.controllers.plugin_catalog_controller.print_error")
1033+
def test_run_install_wraps_package_manager_failure(
1034+
mock_print_error: MagicMock,
1035+
mock_console: MagicMock,
1036+
controller: PluginCatalogController,
1037+
) -> None:
1038+
entry = _entry()
1039+
catalog = _catalog()
1040+
plan = _plan(catalog)
1041+
controller.catalog_service.get_catalog.return_value = catalog
1042+
controller.catalog_service.get_package_entries.return_value = [entry]
1043+
controller.catalog_service.evaluate_compatibility.return_value = CompatibilityResult(True, [])
1044+
controller.install_service.build_install_plan.return_value = plan
1045+
controller.install_service.install.side_effect = RuntimeError("installer exited with status 2")
1046+
1047+
with pytest.raises(typer.Exit) as exc_info:
1048+
controller.run_install("data-designer-text-transform", catalog_alias="local", yes=True)
1049+
1050+
assert exc_info.value.exit_code == 1
1051+
controller.install_service.install.assert_called_once_with(plan)
1052+
controller.install_service.verify_entry_points.assert_not_called()
1053+
mock_print_error.assert_called_once_with("installer exited with status 2")
1054+
assert mock_console.print.call_count >= 1
1055+
1056+
10281057
@patch("data_designer.cli.controllers.plugin_catalog_controller.console")
10291058
@patch("data_designer.cli.controllers.plugin_catalog_controller.print_success")
10301059
def test_run_uninstall_dry_run_renders_plan_without_uninstalling(
@@ -1104,6 +1133,31 @@ def test_run_uninstall_wraps_plan_error(
11041133
mock_print_error.assert_called_once_with("Failed to build plugin uninstall plan: uv was requested")
11051134

11061135

1136+
@patch("data_designer.cli.controllers.plugin_catalog_controller.console")
1137+
@patch("data_designer.cli.controllers.plugin_catalog_controller.print_error")
1138+
def test_run_uninstall_wraps_package_manager_failure(
1139+
mock_print_error: MagicMock,
1140+
mock_console: MagicMock,
1141+
controller: PluginCatalogController,
1142+
) -> None:
1143+
entry = _entry()
1144+
catalog = _catalog()
1145+
plan = _uninstall_plan(catalog)
1146+
controller.catalog_service.get_catalog.return_value = catalog
1147+
controller.catalog_service.get_package_entries.return_value = [entry]
1148+
controller.install_service.build_uninstall_plan.return_value = plan
1149+
controller.install_service.uninstall.side_effect = RuntimeError("uninstaller exited with status 2")
1150+
1151+
with pytest.raises(typer.Exit) as exc_info:
1152+
controller.run_uninstall("data-designer-text-transform", catalog_alias="local", yes=True)
1153+
1154+
assert exc_info.value.exit_code == 1
1155+
controller.install_service.uninstall.assert_called_once_with(plan)
1156+
controller.install_service.verify_entry_points_removed.assert_not_called()
1157+
mock_print_error.assert_called_once_with("uninstaller exited with status 2")
1158+
assert mock_console.print.call_count >= 1
1159+
1160+
11071161
@patch("data_designer.cli.controllers.plugin_catalog_controller.console")
11081162
@patch("data_designer.cli.controllers.plugin_catalog_controller.print_success")
11091163
def test_run_uninstall_reports_success_when_entry_points_are_removed(

packages/data-designer/tests/cli/repositories/test_plugin_catalog_repository.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,80 @@ def test_load_catalog_accepts_equivalent_data_designer_marker_quoting(tmp_path:
406406
assert catalog.plugins[0].compatibility.data_designer.marker == "python_version < '3.12'"
407407

408408

409+
def test_load_catalog_rejects_non_list_packages(tmp_path: Path) -> None:
410+
catalog_dir = tmp_path / "catalog"
411+
catalog_dir.mkdir()
412+
catalog_path = catalog_dir / "plugins.json"
413+
catalog_path.write_text(json.dumps({"schema_version": 2, "packages": {"name": "data-designer-bad"}}))
414+
repository = PluginCatalogRepository(tmp_path)
415+
repository.add_catalog("local", str(catalog_path))
416+
417+
with pytest.raises(PluginCatalogError, match="catalog document has invalid packages; expected a list"):
418+
repository.load_catalog("local", refresh=True)
419+
420+
421+
def test_load_catalog_rejects_invalid_runtime_plugin_type(tmp_path: Path) -> None:
422+
catalog_path = _write_catalog(
423+
tmp_path,
424+
packages=[
425+
_package_entry(
426+
package_name="data-designer-invalid-plugin-type",
427+
plugins=[_runtime_plugin("invalid-plugin-type", plugin_type="unknown-type")],
428+
)
429+
],
430+
)
431+
repository = PluginCatalogRepository(tmp_path)
432+
repository.add_catalog("local", str(catalog_path))
433+
434+
with pytest.raises(PluginCatalogError, match="plugin_type 'unknown-type' is invalid"):
435+
repository.load_catalog("local", refresh=True)
436+
437+
438+
def test_load_catalog_rejects_invalid_entry_point_group(tmp_path: Path) -> None:
439+
runtime_plugin = _runtime_plugin("invalid-entry-point-group")
440+
runtime_plugin["entry_point"]["group"] = "other.plugins"
441+
catalog_path = _write_catalog(
442+
tmp_path,
443+
packages=[
444+
_package_entry(
445+
package_name="data-designer-invalid-entry-point-group",
446+
plugins=[runtime_plugin],
447+
)
448+
],
449+
)
450+
repository = PluginCatalogRepository(tmp_path)
451+
repository.add_catalog("local", str(catalog_path))
452+
453+
with pytest.raises(PluginCatalogError, match="entry_point.group 'other.plugins' is invalid"):
454+
repository.load_catalog("local", refresh=True)
455+
456+
457+
def test_load_catalog_rejects_mismatched_data_designer_requirement_specifier(tmp_path: Path) -> None:
458+
package = _package_entry()
459+
package["compatibility"]["data_designer"] = {
460+
"requirement": "data-designer>=0.5.7",
461+
"specifier": ">=0.6.0",
462+
"marker": None,
463+
}
464+
catalog_path = _write_catalog(tmp_path, packages=[package])
465+
repository = PluginCatalogRepository(tmp_path)
466+
repository.add_catalog("local", str(catalog_path))
467+
468+
with pytest.raises(PluginCatalogError, match="expected '>=0.5.7' from requirement"):
469+
repository.load_catalog("local", refresh=True)
470+
471+
472+
def test_load_catalog_rejects_non_http_docs_url(tmp_path: Path) -> None:
473+
package = _package_entry()
474+
package["docs"]["url"] = "ftp://docs.example.test/plugins/data-designer-text-transform/"
475+
catalog_path = _write_catalog(tmp_path, packages=[package])
476+
repository = PluginCatalogRepository(tmp_path)
477+
repository.add_catalog("local", str(catalog_path))
478+
479+
with pytest.raises(PluginCatalogError, match="expected an absolute HTTP\\(S\\) URL"):
480+
repository.load_catalog("local", refresh=True)
481+
482+
409483
def test_load_catalog_rejects_invalid_schema_v2_install_metadata(tmp_path: Path) -> None:
410484
catalog_path = _write_catalog(
411485
tmp_path,

packages/data-designer/tests/cli/services/test_plugin_install_service.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,24 @@ def test_build_auto_install_plan_uses_uv_add_for_non_package_user_project(
332332
mock_which.assert_called_once_with("uv")
333333

334334

335+
@patch("data_designer.cli.services.plugin_install_service.shutil.which", return_value="/usr/bin/uv")
336+
def test_build_auto_install_plan_uses_uv_add_with_python310_pyproject_fallback(
337+
mock_which: Mock,
338+
tmp_path: Path,
339+
) -> None:
340+
_write_project(tmp_path)
341+
entry = _entry(package_name="data-designer-template", install={"requirement": "data-designer-template"})
342+
catalog = PluginCatalogConfig(alias="local", url="/catalog/plugins.json")
343+
service = PluginInstallService(working_dir=tmp_path, active_virtualenv=True)
344+
345+
with patch("data_designer.cli.services.plugin_install_service.tomllib", None):
346+
plan = service.build_install_plan(entry, catalog, manager="auto")
347+
348+
assert plan.install_mode == "uv-project"
349+
assert plan.project_root == str(tmp_path)
350+
mock_which.assert_called_once_with("uv")
351+
352+
335353
@patch("data_designer.cli.services.plugin_install_service.shutil.which", return_value=None)
336354
def test_build_auto_install_plan_chooses_pip_when_uv_is_unavailable(mock_which: Mock) -> None:
337355
entry = _entry(package_name="data-designer-template", install={"requirement": "data-designer-template"})
@@ -593,6 +611,73 @@ def test_build_auto_uninstall_plan_skips_uv_remove_when_package_is_not_a_project
593611
mock_which.assert_called_once_with("uv")
594612

595613

614+
@patch("data_designer.cli.services.plugin_install_service.shutil.which", return_value="/usr/bin/uv")
615+
def test_build_auto_uninstall_plan_detects_project_dependency_with_python310_pyproject_fallback(
616+
mock_which: Mock,
617+
tmp_path: Path,
618+
) -> None:
619+
_write_project(tmp_path, dependencies=["data-designer-template>=0.1"])
620+
entry = _entry(package_name="data-designer-template", install={"requirement": "data-designer-template"})
621+
catalog = PluginCatalogConfig(alias="local", url="/catalog/plugins.json")
622+
service = PluginInstallService(working_dir=tmp_path, active_virtualenv=True)
623+
624+
with patch("data_designer.cli.services.plugin_install_service.tomllib", None):
625+
plan = service.build_uninstall_plan(entry, catalog, manager="auto")
626+
627+
assert plan.commands == [
628+
[
629+
"uv",
630+
"remove",
631+
"--project",
632+
str(tmp_path),
633+
"--no-sync",
634+
"data-designer-template",
635+
],
636+
[
637+
"uv",
638+
"pip",
639+
"uninstall",
640+
"--python",
641+
sys.executable,
642+
"data-designer-template",
643+
],
644+
]
645+
assert plan.uninstall_mode == "uv-project"
646+
assert plan.project_root == str(tmp_path)
647+
mock_which.assert_called_once_with("uv")
648+
649+
650+
@patch("data_designer.cli.services.plugin_install_service.shutil.which", return_value="/usr/bin/uv")
651+
def test_build_auto_uninstall_plan_handles_malformed_python310_pyproject_fallback(
652+
mock_which: Mock,
653+
tmp_path: Path,
654+
) -> None:
655+
tmp_path.mkdir(exist_ok=True)
656+
(tmp_path / "pyproject.toml").write_text(
657+
'[project]\nname = "synthetic-data-project"\ndependencies = [not valid\n',
658+
encoding="utf-8",
659+
)
660+
entry = _entry(package_name="data-designer-template", install={"requirement": "data-designer-template"})
661+
catalog = PluginCatalogConfig(alias="local", url="/catalog/plugins.json")
662+
service = PluginInstallService(working_dir=tmp_path, active_virtualenv=True)
663+
664+
with patch("data_designer.cli.services.plugin_install_service.tomllib", None):
665+
plan = service.build_uninstall_plan(entry, catalog, manager="auto")
666+
667+
assert plan.command == [
668+
"uv",
669+
"pip",
670+
"uninstall",
671+
"--python",
672+
sys.executable,
673+
"data-designer-template",
674+
]
675+
assert plan.commands == [plan.command]
676+
assert plan.uninstall_mode == "uv-environment"
677+
assert plan.project_root is None
678+
mock_which.assert_called_once_with("uv")
679+
680+
596681
@patch("data_designer.cli.services.plugin_install_service.shutil.which", return_value="/usr/bin/uv")
597682
def test_build_uv_install_plan_targets_current_python_and_adds_catalog_index(mock_which: Mock) -> None:
598683
entry = _entry(

0 commit comments

Comments
 (0)