Skip to content

Commit 7b6d68b

Browse files
authored
Python recipe marketplace installer (#6667)
Add RPC-based recipe installation support for Python packages, allowing recipes to be installed from local file paths or package specifications. The installer uses entry points to discover and activate recipe packages.
1 parent d3dee72 commit 7b6d68b

7 files changed

Lines changed: 397 additions & 0 deletions

File tree

rewrite-python/rewrite/src/rewrite/rpc/server.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
491661
def 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,
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Copyright 2025 the original author or authors.
3+
* <p>
4+
* Licensed under the Moderne Source Available License (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* <p>
8+
* https://docs.moderne.io/licensing/moderne-source-available-license
9+
* <p>
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.openrewrite.python.marketplace;
17+
18+
import lombok.Getter;
19+
import lombok.RequiredArgsConstructor;
20+
import org.openrewrite.Recipe;
21+
import org.openrewrite.config.RecipeDescriptor;
22+
import org.openrewrite.python.rpc.PythonRewriteRpc;
23+
import org.openrewrite.marketplace.RecipeBundle;
24+
import org.openrewrite.marketplace.RecipeBundleReader;
25+
import org.openrewrite.marketplace.RecipeListing;
26+
import org.openrewrite.marketplace.RecipeMarketplace;
27+
28+
import java.util.Map;
29+
30+
@RequiredArgsConstructor
31+
public class PipRecipeBundleReader implements RecipeBundleReader {
32+
private final @Getter RecipeBundle bundle;
33+
private final PythonRewriteRpc rpc;
34+
35+
@Override
36+
public RecipeMarketplace read() {
37+
return rpc.getMarketplace(bundle);
38+
}
39+
40+
@Override
41+
public RecipeDescriptor describe(RecipeListing listing) {
42+
return rpc.prepareRecipe(listing.getName()).getDescriptor();
43+
}
44+
45+
@Override
46+
public Recipe prepare(RecipeListing listing, Map<String, Object> options) {
47+
return rpc.prepareRecipe(listing.getName(), options);
48+
}
49+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Copyright 2025 the original author or authors.
3+
* <p>
4+
* Licensed under the Moderne Source Available License (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* <p>
8+
* https://docs.moderne.io/licensing/moderne-source-available-license
9+
* <p>
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.openrewrite.python.marketplace;
17+
18+
import lombok.RequiredArgsConstructor;
19+
import org.openrewrite.python.rpc.InstallRecipesResponse;
20+
import org.openrewrite.python.rpc.PythonRewriteRpc;
21+
import org.openrewrite.marketplace.RecipeBundle;
22+
import org.openrewrite.marketplace.RecipeBundleReader;
23+
import org.openrewrite.marketplace.RecipeBundleResolver;
24+
25+
import java.nio.file.Files;
26+
import java.nio.file.Path;
27+
import java.nio.file.Paths;
28+
29+
@RequiredArgsConstructor
30+
public class PipRecipeBundleResolver implements RecipeBundleResolver {
31+
private final PythonRewriteRpc rpc;
32+
33+
@Override
34+
public String getEcosystem() {
35+
return "pip";
36+
}
37+
38+
@Override
39+
public RecipeBundleReader resolve(RecipeBundle bundle) {
40+
Path pkgPath = Paths.get(bundle.getPackageName());
41+
InstallRecipesResponse response;
42+
if (Files.exists(pkgPath)) {
43+
response = rpc.installRecipes(pkgPath.toFile());
44+
} else {
45+
response = rpc.installRecipes(bundle.getPackageName(), bundle.getVersion());
46+
}
47+
if (response.getVersion() != null) {
48+
bundle.setVersion(response.getVersion());
49+
}
50+
return new PipRecipeBundleReader(bundle, rpc);
51+
}
52+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright 2025 the original author or authors.
3+
* <p>
4+
* Licensed under the Moderne Source Available License (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* <p>
8+
* https://docs.moderne.io/licensing/moderne-source-available-license
9+
* <p>
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.openrewrite.python.rpc;
17+
18+
import lombok.Value;
19+
import org.openrewrite.rpc.request.RpcRequest;
20+
21+
import java.nio.file.Path;
22+
23+
@Value
24+
class InstallRecipesByFile implements RpcRequest {
25+
Path recipes;
26+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 2025 the original author or authors.
3+
* <p>
4+
* Licensed under the Moderne Source Available License (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* <p>
8+
* https://docs.moderne.io/licensing/moderne-source-available-license
9+
* <p>
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.openrewrite.python.rpc;
17+
18+
import lombok.Value;
19+
import org.jspecify.annotations.Nullable;
20+
import org.openrewrite.rpc.request.RpcRequest;
21+
22+
@Value
23+
class InstallRecipesByPackage implements RpcRequest {
24+
Package recipes;
25+
26+
@Value
27+
public static class Package {
28+
String packageName;
29+
30+
@Nullable
31+
String version;
32+
}
33+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* Copyright 2025 the original author or authors.
3+
* <p>
4+
* Licensed under the Moderne Source Available License (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* <p>
8+
* https://docs.moderne.io/licensing/moderne-source-available-license
9+
* <p>
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.openrewrite.python.rpc;
17+
18+
import lombok.Value;
19+
import org.jspecify.annotations.Nullable;
20+
21+
@Value
22+
public class InstallRecipesResponse {
23+
int recipesInstalled;
24+
@Nullable String version;
25+
}

0 commit comments

Comments
 (0)