@@ -488,6 +488,176 @@ def _get_marketplace():
488488 return _marketplace
489489
490490
491+ def handle_install_recipes (params : dict ) -> dict :
492+ """Handle an InstallRecipes RPC request.
493+
494+ Activates a recipe package in the marketplace. The package should already be
495+ installed by the caller (e.g., via pip install --target). This handler discovers
496+ and activates the package's recipes.
497+
498+ Args:
499+ params: Dict containing either:
500+ - 'recipes': str - A local file path (package already installed to target)
501+ - 'recipes': {'packageName': str, 'version': str|None} - A package spec
502+
503+ Returns:
504+ Dict with 'recipesInstalled' count and 'version' (if resolved)
505+ """
506+ import importlib
507+ import importlib .util
508+
509+ marketplace = _get_marketplace ()
510+ before_count = len (list (marketplace .all_recipes ()))
511+
512+ recipes = params .get ('recipes' )
513+ installed_version = None
514+
515+ if isinstance (recipes , str ):
516+ # Local file path - package should already be installed by caller
517+ local_path = Path (recipes )
518+ logger .info (f"Activating recipes from local path: { recipes } " )
519+
520+ # Find and import the package
521+ # For local paths, we look for the package name from setup.py/pyproject.toml
522+ package_name = _find_package_name (local_path )
523+ if package_name :
524+ _import_and_activate_package (package_name , marketplace )
525+
526+ elif isinstance (recipes , dict ):
527+ # Package spec with name and optional version - package should already be installed
528+ package_name = recipes .get ('packageName' )
529+ version = recipes .get ('version' )
530+
531+ if not package_name :
532+ raise ValueError ("Package name is required" )
533+
534+ logger .info (f"Activating recipes package: { package_name } " )
535+
536+ # Get the installed version
537+ try :
538+ import importlib .metadata
539+ installed_version = importlib .metadata .version (package_name )
540+ except Exception :
541+ pass
542+
543+ _import_and_activate_package (package_name , marketplace )
544+ else :
545+ raise ValueError (f"Invalid recipes parameter: { recipes } " )
546+
547+ after_count = len (list (marketplace .all_recipes ()))
548+ recipes_installed = after_count - before_count
549+
550+ logger .info (f"InstallRecipes: installed { recipes_installed } recipes" )
551+ return {
552+ 'recipesInstalled' : recipes_installed ,
553+ 'version' : installed_version
554+ }
555+
556+
557+ def _find_package_name (local_path : Path ) -> Optional [str ]:
558+ """Find the package name from a local path."""
559+ import tomllib
560+
561+ # Try pyproject.toml first
562+ pyproject_path = local_path / 'pyproject.toml'
563+ if pyproject_path .exists ():
564+ try :
565+ with open (pyproject_path , 'rb' ) as f :
566+ data = tomllib .load (f )
567+ # Try [project] section first (PEP 621)
568+ if 'project' in data and 'name' in data ['project' ]:
569+ return data ['project' ]['name' ]
570+ # Try [tool.poetry] section
571+ if 'tool' in data and 'poetry' in data ['tool' ] and 'name' in data ['tool' ]['poetry' ]:
572+ return data ['tool' ]['poetry' ]['name' ]
573+ except Exception as e :
574+ logger .warning (f"Failed to parse pyproject.toml: { e } " )
575+
576+ # Try setup.py
577+ setup_py = local_path / 'setup.py'
578+ if setup_py .exists ():
579+ # Simple heuristic: look for name= in setup.py
580+ try :
581+ content = setup_py .read_text ()
582+ import re
583+ match = re .search (r'name\s*=\s*["\']([^"\']+)["\']' , content )
584+ if match :
585+ return match .group (1 )
586+ except Exception as e :
587+ logger .warning (f"Failed to parse setup.py: { e } " )
588+
589+ return None
590+
591+
592+ def _import_and_activate_package (package_name : str , marketplace ):
593+ """Import a package and call its activate function using entry points.
594+
595+ Uses importlib.metadata to discover entry points registered under
596+ the 'openrewrite.recipes' group and calls their activate functions.
597+ Since matching package names to entry points is unreliable (hyphens vs
598+ underscores, different naming conventions), we activate ALL entry points
599+ but the marketplace handles deduplication.
600+ """
601+ from importlib .metadata import entry_points
602+
603+ # Normalize package name for comparison
604+ def normalize (name : str ) -> str :
605+ return name .replace ('-' , '_' ).replace ('.' , '_' ).lower ()
606+
607+ normalized_name = normalize (package_name )
608+
609+ # Find all entry points in the openrewrite.recipes group
610+ eps = entry_points (group = 'openrewrite.recipes' )
611+ activated = False
612+
613+ for ep in eps :
614+ try :
615+ # Try to match this entry point to our package using the dist attribute
616+ dist_name = None
617+ if hasattr (ep , 'dist' ) and ep .dist is not None :
618+ dist_name = ep .dist .name if hasattr (ep .dist , 'name' ) else str (ep .dist )
619+
620+ # Check if this entry point belongs to our package
621+ matches_package = False
622+ if dist_name and normalize (dist_name ) == normalized_name :
623+ matches_package = True
624+ elif ep .name and normalize (ep .name ) == normalized_name :
625+ matches_package = True
626+ elif ':' in ep .value :
627+ # Check module name in value (e.g., "sample_recipe:activate")
628+ module_name = ep .value .split (':' )[0 ]
629+ if normalize (module_name ) == normalized_name :
630+ matches_package = True
631+
632+ if matches_package :
633+ logger .info (f"Loading entry point: { ep .name } -> { ep .value } (dist: { dist_name } )" )
634+ activate_fn = ep .load ()
635+ activate_fn (marketplace )
636+ activated = True
637+ logger .info (f"Successfully activated { ep .name } " )
638+ except Exception as e :
639+ logger .warning (f"Failed to process entry point { ep .name } : { e } " )
640+
641+ if not activated :
642+ # Fallback: try direct module import (for packages without entry points)
643+ import importlib
644+ module_name = package_name .replace ('-' , '_' )
645+ try :
646+ if module_name in sys .modules :
647+ importlib .reload (sys .modules [module_name ])
648+ else :
649+ importlib .import_module (module_name )
650+
651+ module = sys .modules .get (module_name )
652+ if module and hasattr (module , 'activate' ):
653+ logger .info (f"Calling activate() on { module_name } " )
654+ module .activate (marketplace )
655+ else :
656+ logger .warning (f"Package { package_name } does not have an activate() function" )
657+ except ImportError as e :
658+ logger .warning (f"Could not import { module_name } : { e } " )
659+
660+
491661def handle_get_marketplace (params : dict ) -> List [dict ]:
492662 """Handle a GetMarketplace RPC request.
493663
@@ -560,6 +730,7 @@ def _recipe_descriptor_to_dict(descriptor) -> dict:
560730 }
561731 for name , value , opt in descriptor .options
562732 ],
733+ 'dataTables' : [], # Python recipes don't have data tables yet
563734 'recipeList' : [_recipe_descriptor_to_dict (r ) for r in descriptor .recipe_list ],
564735 }
565736
@@ -859,6 +1030,7 @@ def handle_request(method: str, params: dict) -> Any:
8591030 'GetLanguages' : handle_get_languages ,
8601031 'Print' : handle_print ,
8611032 'Reset' : handle_reset ,
1033+ 'InstallRecipes' : handle_install_recipes ,
8621034 'GetMarketplace' : handle_get_marketplace ,
8631035 'PrepareRecipe' : handle_prepare_recipe ,
8641036 'Visit' : handle_visit ,
0 commit comments