Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions rewrite-python/src/main/java/org/openrewrite/python/rpc/Parse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* Copyright 2026 the original author or authors.
* <p>
* Licensed under the Moderne Source Available License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* https://docs.moderne.io/licensing/moderne-source-available-license
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.openrewrite.python.rpc;

import lombok.Value;
import org.jspecify.annotations.Nullable;
import org.openrewrite.rpc.request.RpcRequest;

import java.nio.file.Path;
import java.util.List;
import java.util.Map;

/**
* RPC request to parse an explicit list of Python files.
* <p>
* Unlike {@link ParseProject}, which walks a directory, this request parses exactly the
* files it is given. The key capability is that {@code ty} (the type resolver) is rooted
* at {@link #relativeTo} rather than at the files' own directory, so first-party imports
* resolve against a broader workspace root. This lets a caller parse a handful of files
* (e.g. the {@code .py} files of a single Bazel target) while cross-package first-party
* imports still resolve against the monorepo root, without parsing the rest of the tree.
*/
@Value
class Parse implements RpcRequest {

/**
* The files to parse, each identified by its (absolute or {@link #relativeTo}-relative) path.
*/
List<Input> inputs;

/**
* Project root that {@code ty} is initialized at, so imports resolve relative to it.
* When {@code null}, the server infers a root from the input paths.
*/
@Nullable
Path relativeTo;

/**
* Optional path to a virtual environment with the project's dependencies installed.
* <p>
* The caller provisions this environment and forwards its path so the parser can point
* ty-types at it, allowing supertypes that reach into third-party packages to resolve
* (e.g. a first-party class extending {@code pydantic.BaseModel}). The parser never
* provisions dependencies itself. When {@code null}, parsing proceeds without
* dependency-backed type resolution.
*/
@Nullable
Path dependencyPath;

/**
* Optional, parser-specific options forwarded to the RPC server (e.g.
* {@code {"languageLevel": "2.7"}}). The handler interprets keys it recognizes and
* silently ignores the rest. When {@code null}, the handler falls back to its
* process-wide defaults.
*/
@Nullable
Map<String, String> options;

/**
* A single file to parse. Serializes to {@code {"path": "<path>"}}, one of the input
* shapes accepted by the server's {@code Parse} handler.
*/
@Value
static class Input {
Path path;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import org.openrewrite.rpc.RewriteRpc;
import org.openrewrite.rpc.RewriteRpcProcess;
import org.openrewrite.rpc.RewriteRpcProcessManager;
import org.openrewrite.rpc.request.ParseResponse;
import org.openrewrite.toml.TomlParser;
import org.openrewrite.tree.ParseError;
import org.openrewrite.tree.ParsingEventListener;
Expand Down Expand Up @@ -281,6 +282,119 @@ public int characteristics() {
return Stream.concat(rpcStream, manifestStream);
}

/**
* Parses an explicit list of Python files.
* <p>
* Unlike {@link #parseProject(Path, ParseProjectOptions, ExecutionContext)}, which walks a
* directory and resolves the project's manifests, this parses exactly the files given and does
* no manifest discovery. The key capability is that {@code ty} (the type resolver) is rooted at
* {@code relativeTo} rather than at the files' own directory, so first-party imports resolve
* against a broader workspace root. This lets a caller parse a handful of files (e.g. the
* {@code .py} files of a single Bazel target) while cross-package first-party imports still
* resolve against the monorepo root, without parsing the rest of the tree.
*
* @param inputs The files to parse.
* @param relativeTo Project root that {@code ty} is initialized at, so imports resolve
* relative to it, and that source paths are made relative to. When
* {@code null}, the server infers a root from the input paths.
* @param dependencyPath Optional path to a virtual environment with the project's dependencies
* installed, so supertypes reaching into third-party packages resolve.
* The caller provisions it; the parser never provisions dependencies itself.
* @param ctx Execution context for parsing.
* @return Stream of parsed source files, in the same order as {@code inputs}.
*/
public Stream<SourceFile> parse(List<Path> inputs, @Nullable Path relativeTo,
@Nullable Path dependencyPath, ExecutionContext ctx) {
return parse(inputs, relativeTo, dependencyPath, null, ctx);
}

/**
* Parses an explicit list of Python files, forwarding per-parse options to the server.
*
* @param inputs The files to parse.
* @param relativeTo Project root that {@code ty} is initialized at; see
* {@link #parse(List, Path, Path, ExecutionContext)}.
* @param dependencyPath Optional dependency environment for third-party type resolution; see
* {@link #parse(List, Path, Path, ExecutionContext)}.
* @param options Optional, parser-specific options (e.g. {@code {"languageLevel": "2.7"}}).
* Keys the server does not recognize are silently ignored.
* @param ctx Execution context for parsing.
* @return Stream of parsed source files, in the same order as {@code inputs}.
*/
public Stream<SourceFile> parse(List<Path> inputs, @Nullable Path relativeTo,
@Nullable Path dependencyPath, @Nullable Map<String, String> options,
ExecutionContext ctx) {
if (inputs.isEmpty()) {
return Stream.empty();
}

List<Parse.Input> mappedInputs = new ArrayList<>(inputs.size());
for (Path input : inputs) {
mappedInputs.add(new Parse.Input(input));
}

ParsingEventListener parsingListener = ParsingExecutionContextView.view(ctx).getParsingListener();
String sourceFileType = Py.CompilationUnit.class.getName();

return StreamSupport.stream(new Spliterator<SourceFile>() {
private int index = 0;
private @Nullable List<String> ids;

@Override
public boolean tryAdvance(Consumer<? super SourceFile> action) {
if (ids == null) {
parsingListener.intermediateMessage(String.format("Starting parsing of %,d files", inputs.size()));
ids = send("Parse", new Parse(mappedInputs, relativeTo, dependencyPath, options), ParseResponse.class);
assert ids.size() == inputs.size();
}

if (index >= inputs.size()) {
return false;
}

Path input = inputs.get(index);
String id = ids.get(index);
index++;

SourceFile sourceFile;
try {
sourceFile = getObject(id, sourceFileType);
parsingListener.startedParsing(Parser.Input.fromFile(sourceFile.getSourcePath()));
} catch (Exception e) {
sourceFile = new ParseError(
Tree.randomId(),
new Markers(Tree.randomId(), Collections.singletonList(
ParseExceptionResult.build(PythonParser.class, e, null))),
input,
null,
StandardCharsets.UTF_8.name(),
false,
null,
e.getMessage(),
null
);
}
action.accept(sourceFile);
return true;
}

@Override
public @Nullable Spliterator<SourceFile> trySplit() {
return null;
}

@Override
public long estimateSize() {
return ids == null ? Long.MAX_VALUE : inputs.size() - index;
}

@Override
public int characteristics() {
return ids == null ? ORDERED : ORDERED | SIZED | SUBSIZED;
}
}, false);
}

private @Nullable PythonResolutionResult createSetupPyMarker(Path projectPath, @Nullable Path relativeTo, ExecutionContext ctx) {
Path setupPyPath = projectPath.resolve("setup.py");
if (!Files.exists(setupPyPath)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
* Copyright 2026 the original author or authors.
* <p>
* Licensed under the Moderne Source Available License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* https://docs.moderne.io/licensing/moderne-source-available-license
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.openrewrite.python.rpc;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable;
import org.junit.jupiter.api.io.TempDir;
import org.openrewrite.ExecutionContext;
import org.openrewrite.InMemoryExecutionContext;
import org.openrewrite.SourceFile;
import org.openrewrite.java.tree.J;
import org.openrewrite.java.tree.JavaType;
import org.openrewrite.python.PythonIsoVisitor;
import org.openrewrite.python.tree.Py;

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import static java.util.stream.Collectors.toList;
import static org.assertj.core.api.Assertions.assertThat;

@DisabledIfEnvironmentVariable(named = "CI", matches = "true", disabledReason = "No remote client/server available")
class PythonRewriteRpcParseTest {

/**
* Parse an explicit list of two files where {@code derived.py} imports a class from
* {@code base.py}, with {@code relativeTo} pointing at their common root so that
* {@code ty} resolves the first-party import. Both must come back as attributed
* {@link Py.CompilationUnit}s (not {@code ParseError}), and the cross-file supertype
* must resolve — i.e. {@code self} inside {@code Derived} is assignable to {@code Base}.
*/
@Test
void parseExplicitFilesWithCrossFileTypeResolution(@TempDir Path root) throws Exception {
Path base = root.resolve("base.py");
Path derived = root.resolve("derived.py");
Files.writeString(base,
"""
class Base:
def hello(self):
return 1
""");
Files.writeString(derived,
"""
from base import Base


class Derived(Base):
def go(self):
return self.hello()
""");

ExecutionContext ctx = new InMemoryExecutionContext();
List<SourceFile> parsed = PythonRewriteRpc.getOrStart()
.parse(List.of(base, derived), root, null, ctx)
.collect(toList());

assertThat(parsed).hasSize(2);
assertThat(parsed).allSatisfy(sf -> assertThat(sf).isInstanceOf(Py.CompilationUnit.class));

Py.CompilationUnit derivedCu = parsed.stream()
.filter(sf -> sf.getSourcePath().getFileName().toString().equals("derived.py"))
.map(Py.CompilationUnit.class::cast)
.findFirst()
.orElseThrow();

List<JavaType> selfTypes = new ArrayList<>();
new PythonIsoVisitor<Integer>() {
@Override
public J.Identifier visitIdentifier(J.Identifier identifier, Integer p) {
if ("self".equals(identifier.getSimpleName()) && identifier.getType() != null) {
selfTypes.add(identifier.getType());
}
return super.visitIdentifier(identifier, p);
}
}.visit(derivedCu, 0);

assertThat(selfTypes)
.as("`self` identifiers in Derived must carry a resolved type")
.isNotEmpty();
assertThat(selfTypes)
.as("the `self` receiver's type must resolve as a subclass of the first-party `Base` "
+ "defined in the sibling file, proving `ty` was rooted at the common workspace root")
.anySatisfy(t -> assertThat(hasSupertypeSimpleName(t, "Base")).isTrue());
}

/**
* Walks the supertype/interface graph of {@code type} looking for a class whose
* simple name (last dot-separated segment) equals {@code simpleName}, unwrapping
* generic type variables (e.g. the {@code Self} bound on a {@code self} receiver)
* along the way. This avoids depending on the exact module qualification {@code ty}
* assigns to first-party types.
*/
private static boolean hasSupertypeSimpleName(JavaType type, String simpleName) {
Set<JavaType> seen = new HashSet<>();
List<JavaType> queue = new ArrayList<>();
queue.add(type);
while (!queue.isEmpty()) {
JavaType t = queue.remove(queue.size() - 1);
if (!seen.add(t)) {
continue;
}
if (t instanceof JavaType.GenericTypeVariable) {
queue.addAll(((JavaType.GenericTypeVariable) t).getBounds());
} else if (t instanceof JavaType.FullyQualified) {
JavaType.FullyQualified fq = (JavaType.FullyQualified) t;
String fqn = fq.getFullyQualifiedName();
String lastSegment = fqn.substring(fqn.lastIndexOf('.') + 1);
if (simpleName.equals(lastSegment)) {
return true;
}
if (fq.getSupertype() != null) {
queue.add(fq.getSupertype());
}
queue.addAll(fq.getInterfaces());
}
}
return false;
}
}