77from pathlib import Path
88
99import typer
10+ from packaging .utils import canonicalize_name
1011from pydantic import ValidationError
1112from rich .style import Style
1213from rich .table import Table
1314from rich .text import Text
1415
1516from data_designer .cli .plugin_catalog import (
17+ DATA_DESIGNER_PLUGIN_PACKAGE_PREFIX ,
1618 DEFAULT_PLUGIN_CATALOG_ALIAS ,
1719 PLUGIN_CATALOG_ALIAS_PATTERN ,
1820 CompatibilityResult ,
3638)
3739from data_designer .config .utils .constants import NordColor
3840
41+ NARROW_CATALOG_LAYOUT_WIDTH = 100
42+
3943
4044class PluginCatalogController :
4145 """Controller for plugin catalog browsing, alias management, and package workflows.
@@ -117,6 +121,7 @@ def run_info(
117121 catalog .alias ,
118122 refresh = refresh ,
119123 include_incompatible = True ,
124+ command_name = "info" ,
120125 )
121126 entry = package_entries [0 ]
122127 compatibility = self .catalog_service .evaluate_compatibility (entry )
@@ -176,6 +181,7 @@ def run_install(
176181 catalog .alias ,
177182 refresh = refresh ,
178183 include_incompatible = True ,
184+ command_name = "install" ,
179185 )
180186 entry = package_entries [0 ]
181187 compatibility = self .catalog_service .evaluate_compatibility (entry )
@@ -210,9 +216,9 @@ def run_install(
210216 if dry_run :
211217 if not compatibility .is_compatible :
212218 print_warning (
213- "Dry run complete; no changes made. A real install would be blocked because compatibility "
214- "checks failed."
219+ "Dry run complete; no changes made. Install would be blocked because compatibility checks failed."
215220 )
221+ raise typer .Exit (code = 1 )
216222 else :
217223 print_info ("Dry run complete; no changes made" )
218224 return
@@ -255,6 +261,7 @@ def run_uninstall(
255261 catalog .alias ,
256262 refresh = refresh ,
257263 include_incompatible = True ,
264+ command_name = "uninstall" ,
258265 )
259266 entry = package_entries [0 ]
260267
@@ -296,7 +303,7 @@ def run_uninstall(
296303 )
297304
298305 def run_installed (self ) -> None :
299- """List installed runtime plugin entry points without importing plugin modules."""
306+ """List installed runtime plugins without importing plugin modules."""
300307 print_header ("Installed Data Designer Runtime Plugins" )
301308 installed_plugins = self .catalog_service .list_installed_plugins ()
302309 if not installed_plugins :
@@ -408,6 +415,7 @@ def _get_package_entries_or_exit(
408415 * ,
409416 refresh : bool ,
410417 include_incompatible : bool ,
418+ command_name : str ,
411419 ) -> list [PluginCatalogEntry ]:
412420 try :
413421 package_entries = self .catalog_service .get_package_entries (
@@ -421,9 +429,44 @@ def _get_package_entries_or_exit(
421429 raise typer .Exit (code = 1 )
422430 if not package_entries :
423431 print_error (f"Plugin package or alias { package_name !r} was not found in catalog { catalog_alias !r} " )
432+ self ._display_runtime_plugin_recovery_hint (
433+ package_name ,
434+ catalog_alias ,
435+ refresh = refresh ,
436+ include_incompatible = include_incompatible ,
437+ command_name = command_name ,
438+ )
424439 raise typer .Exit (code = 1 )
425440 return package_entries
426441
442+ def _display_runtime_plugin_recovery_hint (
443+ self ,
444+ package_name : str ,
445+ catalog_alias : str ,
446+ * ,
447+ refresh : bool ,
448+ include_incompatible : bool ,
449+ command_name : str ,
450+ ) -> None :
451+ try :
452+ runtime_entries = self .catalog_service .get_runtime_plugin_entries (
453+ package_name ,
454+ catalog_alias ,
455+ refresh = refresh ,
456+ include_incompatible = include_incompatible ,
457+ )
458+ except (PluginCatalogError , OSError , ValueError ):
459+ return
460+
461+ if not runtime_entries :
462+ return
463+
464+ entry = runtime_entries [0 ]
465+ package_alias = _package_alias (entry .package .name ) or entry .package .name
466+ print_info (f"{ package_name !r} is a runtime plugin exposed by plugin package { entry .package .name !r} ." )
467+ command = _plugin_package_command (command_name , package_alias , catalog_alias )
468+ print_info (f"Use the package instead: { shlex .join (command )} " )
469+
427470 def _display_empty_list_state (self , catalog_alias : str , * , include_incompatible : bool ) -> None :
428471 if include_incompatible :
429472 print_warning ("No plugin packages found" )
@@ -460,8 +503,37 @@ def _display_empty_search_state(
460503 return
461504
462505 print_warning ("No matching plugin packages found" )
506+ suggestions = self ._suggest_entries (query , catalog_alias , include_incompatible = include_incompatible )
507+ if suggestions :
508+ package_names = [
509+ package_entries [0 ].package .name
510+ for package_entries in self .catalog_service .group_entries_by_package (suggestions ).values ()
511+ ]
512+ print_info (f"Closest package matches: { ', ' .join (package_names )} " )
513+ print_info ("Try fewer terms, a package alias, or a runtime plugin name." )
514+
515+ def _suggest_entries (
516+ self ,
517+ query : str ,
518+ catalog_alias : str ,
519+ * ,
520+ include_incompatible : bool ,
521+ ) -> list [PluginCatalogEntry ]:
522+ try :
523+ return self .catalog_service .suggest_entries (
524+ query ,
525+ catalog_alias ,
526+ refresh = False ,
527+ include_incompatible = include_incompatible ,
528+ )
529+ except (PluginCatalogError , OSError , ValueError ):
530+ return []
463531
464532 def _display_catalog_entries (self , entries : list [PluginCatalogEntry ]) -> None :
533+ if _console_width () < NARROW_CATALOG_LAYOUT_WIDTH :
534+ self ._display_catalog_entries_vertical (entries )
535+ return
536+
465537 table = Table (title = "Catalog Plugin Packages" , border_style = NordColor .NORD8 .value )
466538 table .add_column ("Package" , style = NordColor .NORD14 .value , no_wrap = True )
467539 table .add_column ("Description" , style = NordColor .NORD4 .value )
@@ -482,15 +554,33 @@ def _display_catalog_entries(self, entries: list[PluginCatalogEntry]) -> None:
482554 )
483555 console .print (table )
484556
557+ def _display_catalog_entries_vertical (self , entries : list [PluginCatalogEntry ]) -> None :
558+ for index , package_entries in enumerate (self .catalog_service .group_entries_by_package (entries ).values ()):
559+ entry = package_entries [0 ]
560+ compatibility = self .catalog_service .evaluate_compatibility (entry )
561+ docs_url = entry .docs .url if entry .docs is not None and entry .docs .url is not None else ""
562+ if index :
563+ console .print ()
564+ console .print (Text (entry .package .name , style = f"bold { NordColor .NORD14 .value } " ))
565+ console .print (f" Description: { entry .description } " )
566+ console .print (f" Runtime plugins: { _format_runtime_plugins (package_entries )} " )
567+ console .print (f" Compatible: { 'yes' if compatibility .is_compatible else 'no' } " )
568+ if docs_url :
569+ console .print (f" Docs: { docs_url } " )
570+
485571 @staticmethod
486572 def _display_installed_plugins (installed_plugins : list [InstalledPluginInfo ]) -> None :
487573 table = Table (title = "Installed Runtime Plugins" , border_style = NordColor .NORD8 .value )
488574 table .add_column ("Runtime Plugin" , style = NordColor .NORD14 .value , no_wrap = True )
575+ table .add_column ("Package" , style = NordColor .NORD9 .value , no_wrap = True )
576+ table .add_column ("Version" , style = NordColor .NORD13 .value , no_wrap = True )
489577 table .add_column ("Entry Point" , style = NordColor .NORD4 .value )
490578
491579 for plugin in installed_plugins :
492580 table .add_row (
493581 plugin .name ,
582+ plugin .package_name or "" ,
583+ plugin .package_version or "" ,
494584 plugin .entry_point_value ,
495585 )
496586 console .print (table )
@@ -526,6 +616,26 @@ def _format_runtime_plugins(entries: list[PluginCatalogEntry]) -> str:
526616 return ", " .join (f"{ entry .name } ({ entry .plugin_type .value } )" for entry in entries )
527617
528618
619+ def _package_alias (package_name : str ) -> str | None :
620+ canonical_package_name = canonicalize_name (package_name )
621+ if not canonical_package_name .startswith (DATA_DESIGNER_PLUGIN_PACKAGE_PREFIX ):
622+ return None
623+ return canonical_package_name .removeprefix (DATA_DESIGNER_PLUGIN_PACKAGE_PREFIX )
624+
625+
626+ def _plugin_package_command (command_name : str , package_alias : str , catalog_alias : str ) -> list [str ]:
627+ command = ["data-designer" , "plugin" ]
628+ if catalog_alias != DEFAULT_PLUGIN_CATALOG_ALIAS :
629+ command .extend (["--catalog" , catalog_alias ])
630+ command .extend ([command_name , package_alias ])
631+ return command
632+
633+
634+ def _console_width () -> int :
635+ width = getattr (console , "width" , None )
636+ return width if isinstance (width , int ) else 120
637+
638+
529639def _format_docs_link (docs_url : str | None ) -> Text :
530640 if not docs_url :
531641 return Text ("" )
0 commit comments