DataDesigner/packages/data-designer/tests/cli/test_main.py
Johnny Greco d14c9b3ccc
feat(cli): add plugin catalog core (#618)
* feat(cli): add plugin catalog services

Add typed catalog and tap models, persistent tap storage, cached
catalog loading, compatibility evaluation, install plan generation,
and runtime plugin discovery helpers.

Refs #617

* feat(cli): add plugins command group

Wire list, search, info, install, installed, and tap management
commands through the existing command-controller CLI pattern.

Refs #617

* test(cli): cover plugin catalog workflows

Add regression coverage for tap caching, catalog compatibility,
installer command generation, local path resolution, and Typer command
delegation.

Refs #617

* fix(cli): align plugin taps with schema v2

Validate tap catalogs against the schema v2 contract used by
NVIDIA-NeMo/DataDesignerPlugins#36, including source union fields,
docs URLs, package paths, compatibility metadata, and unique runtime
plugin names.

Derive Git install targets as package-qualified PEP 508 direct
references so git tap entries install the package described by the
catalog source metadata.

Refs #617

* fix(cli): address plugin review feedback

- Invalidate import caches before post-install entry point verification
- Make tap aliases case-insensitive and cache catalogs by alias plus URL
- Prefer compatible catalog entries before falling back to forced installs
- Clarify unused --tap behavior and list installed entry points without imports
- Add direct controller coverage and update CLI plugin documentation

Refs #617

* fix(cli): gate incompatible plugin installs

Fetch install targets before compatibility filtering so the controller
owns the final --force decision and the incompatible install guard stays
reachable.

Refs #617

* style(cli): format plugin catalog files

Apply ruff formatting to the plugin command and tap repository tests so
CI format checks pass on the PR merge commit.

Refs #617

* fix(cli): reject duplicate plugin entry names

Key catalog duplicate detection by entry_point.name so distinct catalog
entries cannot register the same runtime plugin name.

Refs #617

* fix(cli): preserve GitHub tree tap paths

* fix(cli): verify plugin entry point names

* align plugin CLI with catalog schema

- adopt catalog terminology for plugin source aliases
- parse package-first plugin catalog metadata from the plugin repo
- install package requirements with optional catalog indexes

* tidy plugin catalog workflow docs

* align plugin catalog CLI with package contract

* add plugin package uninstall workflow

* test plugin package command targets

* document plugin package aliases

* address plugin catalog review feedback

* prefer runtime plugin lookup matches

* rename plugins command to plugin

* show plugin package descriptions

* rename plugin catalogs command

* add protected plugin package installs

* document plugin package install modes

* avoid building project during plugin installs

* harden plugin package installs

* tighten plugin catalog contracts

* fix no-args help exit code

* make plugin docs links robust

* document plugin CLI catalog workflows

* clarify plugin entry point verification

* simplify plugin CLI docs

* narrow plugin search fields

* hide plugin catalog cache ttl

* remove plugin catalog trust flag

* improve plugin CLI recovery UX

* polish plugin catalog table display

* stabilize plugin catalog table test

* tighten plugin catalog edge cases

* harden plugin catalog verification

- Escape catalog-provided Rich markup before rendering CLI output
- Reject runtime plugin names that collide after enum-key normalization
- Load installed runtime entry points in a subprocess before reporting success

* simplify plugin entry point verification

Load matching entry points directly after install instead of spawning a
separate Python process. This keeps the check package-scoped while still
catching broken entry-point targets and non-Plugin objects.

* require newer uv for plugin plans

Use uv >= 0.10.0 as the single supported uv requirement for
plugin package commands. Auto mode now falls back to a pip plan with
an upgrade warning when uv is unavailable or too old, while explicit
uv selection remains strict.

* verify pip fallback availability

* polish plugin CLI status markers

* clarify plugin compatibility labels

* simplify plugin info install details

* address plugin CLI review nits

* support versioned plugin package installs

* share plugin install metadata rendering

* show installed plugin packages

* harden versioned plugin installs

- Preserve catalog requirement constraints for versioned installs
- Remove stale install-plan metadata fields
- Expand parser, uv, controller, and local-catalog dry-run coverage

* harden plugin help tests

* show plugin package versions

Add package version metadata support for plugin catalogs and resolve current versions from exact requirements or simple indexes when catalog entries omit them.

Update plugin list/info/install metadata to show the plugin package version and Data Designer compatibility requirement while removing the separate Data Designer version line.

* format plugin catalog tests

* harden plugin package metadata checks

* harden plugin CLI test coverage

* add plugin discovery docs (#642)

Signed-off-by: Johnny Greco <jogreco@nvidia.com>

---------

Signed-off-by: Johnny Greco <jogreco@nvidia.com>
2026-05-13 12:26:58 -04:00

258 lines
10 KiB
Python

# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import importlib.metadata
from unittest.mock import Mock, call, patch
from typer.testing import CliRunner
from data_designer.cli.main import app, main
from data_designer.cli.version_notice import UpdateNotice
from data_designer.config.utils.constants import DEFAULT_NUM_RECORDS
from data_designer.engine.storage.artifact_storage import ResumeMode
runner = CliRunner()
@patch("data_designer.cli.main.app")
@patch("data_designer.cli.main.ensure_cli_default_model_settings")
def test_main_bootstraps_before_running_app(mock_bootstrap: Mock, mock_app: Mock) -> None:
"""The CLI entrypoint bootstraps defaults before invoking Typer."""
call_order = Mock()
call_order.attach_mock(mock_bootstrap, "bootstrap")
call_order.attach_mock(mock_app, "app")
with patch("sys.argv", ["data-designer"]):
main()
assert call_order.mock_calls == [call.bootstrap(), call.app()]
@patch("data_designer.cli.main.app")
@patch("data_designer.cli.main.ensure_cli_default_model_settings")
def test_main_bootstraps_for_plugin_commands(mock_bootstrap: Mock, mock_app: Mock) -> None:
"""The plugin command still runs through CLI default setup before Typer dispatch."""
with patch("sys.argv", ["data-designer", "plugin", "list"]):
main()
mock_bootstrap.assert_called_once_with()
mock_app.assert_called_once_with()
@patch("data_designer.cli.main.app")
@patch("data_designer.cli.main.ensure_cli_default_model_settings")
def test_main_skips_bootstrap_for_version(mock_bootstrap: Mock, mock_app: Mock) -> None:
"""The CLI entrypoint avoids default setup for the fast version path."""
with patch("sys.argv", ["data-designer", "--version"]):
main()
mock_bootstrap.assert_not_called()
mock_app.assert_called_once_with()
@patch("data_designer.cli.main.app")
@patch("data_designer.cli.main.ensure_cli_default_model_settings")
def test_main_skips_bootstrap_when_version_follows_another_flag(mock_bootstrap: Mock, mock_app: Mock) -> None:
"""The CLI entrypoint detects eager version requests even after another root flag."""
with patch("sys.argv", ["data-designer", "--help", "--version"]):
main()
mock_bootstrap.assert_not_called()
mock_app.assert_called_once_with()
def test_app_version_prints_installed_data_designer_version() -> None:
with (
patch("data_designer.cli.main.importlib.metadata.version", return_value="0.6.0") as mock_version,
patch("data_designer.cli.main.should_show_update_notice", return_value=True),
patch("data_designer.cli.version_notice.get_update_notice", return_value=None) as mock_notice,
):
result = runner.invoke(app, ["--version"])
assert result.exit_code == 0
assert result.output == "0.6.0\n"
mock_version.assert_called_once_with("data-designer")
mock_notice.assert_called_once_with("0.6.0")
def test_app_version_prints_update_notice_after_installed_version() -> None:
notice = UpdateNotice(latest_version="0.6.1", upgrade_command="uv tool upgrade data-designer")
with (
patch("data_designer.cli.main.importlib.metadata.version", return_value="0.6.0"),
patch("data_designer.cli.main.should_show_update_notice", return_value=True),
patch("data_designer.cli.version_notice.get_update_notice", return_value=notice),
):
result = runner.invoke(app, ["--version"])
assert result.exit_code == 0
assert result.output.splitlines()[0] == "0.6.0"
assert "New Data Designer version available: 0.6.1" in result.output
assert "Upgrade with: uv tool upgrade data-designer" in result.output
def test_app_version_skips_update_notice_when_stdout_is_not_tty() -> None:
with (
patch("data_designer.cli.main.importlib.metadata.version", return_value="0.6.0"),
patch("data_designer.cli.main.should_show_update_notice", return_value=False),
patch("data_designer.cli.version_notice.get_update_notice") as mock_notice,
):
result = runner.invoke(app, ["--version"])
assert result.exit_code == 0
assert result.output == "0.6.0\n"
mock_notice.assert_not_called()
def test_app_version_skips_update_notice_when_lookup_fails() -> None:
with (
patch("data_designer.cli.main.importlib.metadata.version", return_value="0.6.0"),
patch("data_designer.cli.main.should_show_update_notice", return_value=True),
patch("data_designer.cli.version_notice.get_update_notice", side_effect=RuntimeError("network failure")),
):
result = runner.invoke(app, ["--version"])
assert result.exit_code == 0
assert result.output == "0.6.0\n"
def test_app_version_skips_update_notice_when_lazy_import_fails() -> None:
real_import = __import__
def fail_ui_import(name: str, *args: object, **kwargs: object) -> object:
if name == "data_designer.cli.ui":
raise ImportError("ui unavailable")
return real_import(name, *args, **kwargs)
with (
patch("data_designer.cli.main.importlib.metadata.version", return_value="0.6.0"),
patch("data_designer.cli.main.should_show_update_notice", return_value=True),
patch("builtins.__import__", side_effect=fail_ui_import),
):
result = runner.invoke(app, ["--version"])
assert result.exit_code == 0
assert result.output == "0.6.0\n"
def test_app_version_skips_update_notice_when_render_fails() -> None:
notice = UpdateNotice(latest_version="0.6.1", upgrade_command="uv tool upgrade data-designer")
with (
patch("data_designer.cli.main.importlib.metadata.version", return_value="0.6.0"),
patch("data_designer.cli.main.should_show_update_notice", return_value=True),
patch("data_designer.cli.version_notice.get_update_notice", return_value=notice),
patch("data_designer.cli.ui.print_update_notice", side_effect=RuntimeError("render failure")),
):
result = runner.invoke(app, ["--version"])
assert result.exit_code == 0
assert result.output == "0.6.0\n"
def test_app_version_errors_when_package_version_is_missing() -> None:
with patch(
"data_designer.cli.main.importlib.metadata.version",
side_effect=importlib.metadata.PackageNotFoundError("data-designer"),
):
result = runner.invoke(app, ["--version"])
assert result.exit_code == 1
assert "Unable to resolve installed data-designer package version." in result.output
@patch("data_designer.cli.commands.create.GenerationController")
def test_app_dispatches_lazy_create_command(mock_controller_cls: Mock) -> None:
"""The Typer app dispatches lazy-loaded commands through the resolved callback."""
mock_controller = Mock()
mock_controller_cls.return_value = mock_controller
result = runner.invoke(app, ["create", "config.yaml"])
assert result.exit_code == 0
mock_controller.run_create.assert_called_once_with(
config_source="config.yaml",
num_records=DEFAULT_NUM_RECORDS,
dataset_name="dataset",
artifact_path=None,
resume=ResumeMode.NEVER,
output_format=None,
)
@patch("data_designer.cli.commands.plugin.PluginCatalogController")
def test_app_dispatches_lazy_plugin_list_command(mock_controller_cls: Mock) -> None:
"""The plugin group lazily resolves command callbacks without loading a catalog."""
mock_controller = Mock()
mock_controller_cls.return_value = mock_controller
result = runner.invoke(
app,
["plugin", "--catalog", "local", "list", "--refresh", "--include-incompatible"],
)
assert result.exit_code == 0
mock_controller.run_list.assert_called_once_with(
catalog_alias="local",
refresh=True,
include_incompatible=True,
)
@patch("data_designer.cli.commands.plugin.PluginCatalogController")
def test_app_dispatches_lazy_plugin_catalog_list_command(mock_controller_cls: Mock) -> None:
"""Nested plugin catalog commands resolve through the lazy command group."""
mock_controller = Mock()
mock_controller_cls.return_value = mock_controller
result = runner.invoke(app, ["plugin", "catalog", "list"])
assert result.exit_code == 0
mock_controller.run_catalog_list.assert_called_once_with()
def test_app_help_keeps_config_and_plugin_commands_reachable() -> None:
config_result = runner.invoke(app, ["config", "--help"])
plugin_result = runner.invoke(app, ["plugin", "--help"])
assert config_result.exit_code == 0
assert "providers" in config_result.output
assert "models" in config_result.output
assert plugin_result.exit_code == 0
assert "list" in plugin_result.output
assert "install" in plugin_result.output
assert "uninstall" in plugin_result.output
assert "catalog" in plugin_result.output
assert "install strategy" in plugin_result.output
assert "installed plugin packages" in plugin_result.output
assert "runtime plugins and their packages" not in plugin_result.output
def test_no_args_help_exits_successfully_for_lazy_groups() -> None:
root_result = runner.invoke(app, [])
plugin_result = runner.invoke(app, ["plugin"])
plugin_catalog_result = runner.invoke(app, ["plugin", "catalog"])
assert root_result.exit_code == 0
assert "Data Designer CLI" in root_result.output
assert "plugin" in root_result.output
assert plugin_result.exit_code == 0
assert "Discover, install, and uninstall" in plugin_result.output
assert "catalog" in plugin_result.output
assert plugin_catalog_result.exit_code == 0
assert "Manage plugin catalog aliases" in plugin_catalog_result.output
assert "add" in plugin_catalog_result.output
def test_app_does_not_expose_legacy_plugins_command() -> None:
result = runner.invoke(app, ["plugins", "--help"])
assert result.exit_code != 0
assert "No such command" in result.output
def test_plugin_does_not_expose_legacy_catalogs_command() -> None:
result = runner.invoke(app, ["plugin", "catalogs", "--help"])
assert result.exit_code != 0
assert "No such command" in result.output