Improve handling of evaling dependency notation URIs (#1595)

This commit is contained in:
Daniel Chao
2026-05-15 15:51:09 -07:00
committed by GitHub
parent 3ad1cb3645
commit a7a64acbac
17 changed files with 123 additions and 156 deletions
@@ -67,7 +67,7 @@ constructor(
val evaluator = builder.build() val evaluator = builder.build()
evaluator.use { evaluator.use {
evaluator.evaluateCommand( evaluator.evaluateCommand(
uri(resolvedSourceModules.first()), uri(options.normalizedSourceModules.first()),
reservedFlagNames, reservedFlagNames,
reservedFlagShortNames, reservedFlagShortNames,
) { spec -> ) { spec ->
@@ -115,7 +115,7 @@ constructor(
// used just to resolve the `%{moduleName}` placeholder // used just to resolve the `%{moduleName}` placeholder
val moduleResolver = ModuleResolver(moduleKeyFactories(ModulePathResolver.empty())) val moduleResolver = ModuleResolver(moduleKeyFactories(ModulePathResolver.empty()))
return resolvedSourceModules.associateWith { uri -> return options.base.normalizedSourceModules.associateWith { uri ->
val moduleDir: String? = val moduleDir: String? =
IoUtils.toPath(uri)?.let { IoUtils.toPath(uri)?.let {
IoUtils.relativize(it.parent, workingDir).toString().ifEmpty { "." } IoUtils.relativize(it.parent, workingDir).toString().ifEmpty { "." }
@@ -191,7 +191,7 @@ constructor(
} }
} else { } else {
var outputWritten = false var outputWritten = false
for (moduleUri in resolvedSourceModules) { for (moduleUri in options.base.normalizedSourceModules) {
val moduleSource = toModuleSource(moduleUri, inputStream) val moduleSource = toModuleSource(moduleUri, inputStream)
if (options.expression != null) { if (options.expression != null) {
val output = evaluator.evaluateExpressionString(moduleSource, options.expression) val output = evaluator.evaluateExpressionString(moduleSource, options.expression)
@@ -66,7 +66,7 @@ constructor(
try { try {
return builder return builder
.apply { .apply {
for ((idx, sourceModule) in resolvedSourceModules.withIndex()) { for ((idx, sourceModule) in options.base.normalizedSourceModules.withIndex()) {
addExternalProperty("pkl.analyzeImports.$idx", sourceModule.toString()) addExternalProperty("pkl.analyzeImports.$idx", sourceModule.toString())
} }
} }
@@ -47,7 +47,7 @@ constructor(
private fun evalTest(builder: EvaluatorBuilder) { private fun evalTest(builder: EvaluatorBuilder) {
val sources = val sources =
resolvedSourceModules.ifEmpty { project?.tests?.map { it.toUri() } } options.normalizedSourceModules.ifEmpty { project?.tests?.map { it.toUri() } }
?: ?:
// keep in sync with error message thrown by clikt // keep in sync with error message thrown by clikt
throw CliException( throw CliException(
@@ -1754,6 +1754,32 @@ result = someLib.x
) )
} }
@Test
fun `eval dependency notation source`(@TempDir tempDir: Path) {
PackageServer.populateCacheDir(tempDir)
val projectPath =
FileTestUtils.rootProjectDir.resolve(
"pkl-commons-cli/src/main/resources/org/pkl/commons/cli/project1/"
)
val options =
CliEvaluatorOptions(
CliBaseOptions(
sourceModules = listOf(URI("@fruit/catalog/apple.pkl")),
projectDir = projectPath,
moduleCacheDir = tempDir,
)
)
val output = evalToConsole(options)
assertThat(output)
.isEqualTo(
"""
name = "Apple 🍎"
"""
.trimIndent()
)
}
private fun evalModuleThatImportsPackage(certsFile: Path?, testPort: Int = -1) { private fun evalModuleThatImportsPackage(certsFile: Path?, testPort: Int = -1) {
val moduleUri = val moduleUri =
writePklFile( writePklFile(
@@ -31,7 +31,7 @@ class CliJavaCodeGenerator(private val options: CliJavaCodeGeneratorOptions) :
val builder = evaluatorBuilder() val builder = evaluatorBuilder()
try { try {
builder.build().use { evaluator -> builder.build().use { evaluator ->
for (moduleUri in resolvedSourceModules) { for (moduleUri in options.base.normalizedSourceModules) {
val schema = evaluator.evaluateSchema(ModuleSource.uri(moduleUri)) val schema = evaluator.evaluateSchema(ModuleSource.uri(moduleUri))
val codeGenerator = JavaCodeGenerator(schema, options.toJavaCodeGeneratorOptions()) val codeGenerator = JavaCodeGenerator(schema, options.toJavaCodeGeneratorOptions())
try { try {
@@ -32,7 +32,7 @@ class CliKotlinCodeGenerator(private val options: CliKotlinCodeGeneratorOptions)
try { try {
builder.build().use { evaluator -> builder.build().use { evaluator ->
for (moduleUri in resolvedSourceModules) { for (moduleUri in options.base.normalizedSourceModules) {
val schema = evaluator.evaluateSchema(ModuleSource.uri(moduleUri)) val schema = evaluator.evaluateSchema(ModuleSource.uri(moduleUri))
val codeGenerator = KotlinCodeGenerator(schema, options.toKotlinCodeGeneratorOptions()) val codeGenerator = KotlinCodeGenerator(schema, options.toKotlinCodeGeneratorOptions())
try { try {
@@ -190,7 +190,7 @@ data class CliBaseOptions(
sourceModules sourceModules
.map { uri -> .map { uri ->
if (uri.isAbsolute) uri if (uri.isAbsolute) uri
else if (uri.path.startsWith("@") && !noProject && normalizedProjectFile != null) uri else if (uri.path.startsWith("@")) uri
else IoUtils.resolve(normalizedWorkingDir.toUri(), uri) else IoUtils.resolve(normalizedWorkingDir.toUri(), uri)
} }
// sort modules to make cli output independent of source module order // sort modules to make cli output independent of source module order
@@ -86,36 +86,6 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) {
} }
} }
protected fun resolveModuleUri(uri: URI): URI =
if (uri.isAbsolute) uri
else { // must be @dep/mod.pkl notation!!
if (!uri.path.startsWith('@'))
throw CliBugException(
RuntimeException("tried to resolve project URI `$uri` with no @ prefix")
)
if (project == null)
throw CliBugException(
RuntimeException("tried to resolve project URI `$uri` with no project present")
)
val dep = uri.path.substringBefore('/').drop(1)
val path = uri.path.dropWhile { it != '/' }
if (path.isEmpty()) throw CliException("Invalid project dependency URI `$uri`.")
val remoteDep =
project!!.dependencies.remoteDependencies()[dep]
?: if (project!!.dependencies.localDependencies().containsKey(dep))
throw CliException(
"Only remote project dependencies may be referenced using @-notation. Dependency `@$dep` is a local dependency."
)
else throw CliException("Project does not contain dependency `@$dep`.")
remoteDep.packageUri.toPackageAssetUri(path).uri
}
protected val resolvedSourceModules: List<URI> by lazy {
if (project == null) cliOptions.normalizedSourceModules
else cliOptions.normalizedSourceModules.map(::resolveModuleUri)
}
protected fun loadProject(projectFile: Path): Project { protected fun loadProject(projectFile: Path): Project {
val securityManager = val securityManager =
SecurityManagers.standard( SecurityManagers.standard(
@@ -0,0 +1,5 @@
amends "pkl:Project"
dependencies {
["fruit"] { uri = "package://localhost:0/fruit@1.1.0" }
}
@@ -0,0 +1,12 @@
{
"schemaVersion": 1,
"resolvedDependencies": {
"package://localhost:0/fruit@1": {
"type": "remote",
"uri": "projectpackage://localhost:0/fruit@1.1.0",
"checksums": {
"sha256": "$skipChecksumVerification"
}
}
}
}
@@ -17,13 +17,10 @@ package org.pkl.commons.cli
import com.github.ajalt.clikt.core.parse import com.github.ajalt.clikt.core.parse
import com.github.ajalt.clikt.parameters.groups.provideDelegate import com.github.ajalt.clikt.parameters.groups.provideDelegate
import java.net.URI
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.ExperimentalPathApi import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.writeText
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.condition.DisabledOnJre import org.junit.jupiter.api.condition.DisabledOnJre
import org.junit.jupiter.api.condition.JRE import org.junit.jupiter.api.condition.JRE
import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.api.io.TempDir
@@ -49,7 +46,6 @@ class CliCommandTest {
class CliTest(options: CliBaseOptions) : CliCommand(options) { class CliTest(options: CliBaseOptions) : CliCommand(options) {
override fun doRun() = Unit override fun doRun() = Unit
val myResolvedSourceModules = resolvedSourceModules
val myAllowedModules = allowedModules val myAllowedModules = allowedModules
val myAllowedResources = allowedResources val myAllowedResources = allowedResources
val myRootDir = rootDir val myRootDir = rootDir
@@ -98,114 +94,6 @@ class CliCommandTest {
) )
} }
@Test
fun `@-notation package URIs - treated as relative paths when no project present`(
@TempDir tempDir: Path
) {
cmd.parse(arrayOf("--working-dir=$tempDir"))
val opts = cmd.baseOptions.baseOptions(listOf(URI("@foo/bar.pkl")), testMode = true)
val cliTest = CliTest(opts)
assertThat(cliTest.myResolvedSourceModules)
.isEqualTo(listOf(tempDir.toUri().resolve("@foo/bar.pkl")))
}
@Test
fun `@-notation package URIs - empty paths are rejected`(@TempDir tempDir: Path) {
tempDir
.resolve("PklProject")
.writeText(
"""
amends "pkl:Project"
"""
.trimIndent()
)
cmd.parse(arrayOf("--working-dir=$tempDir"))
val opts = cmd.baseOptions.baseOptions(listOf(URI("@no.slash")), testMode = true)
val exc = assertThrows<CliException> { CliTest(opts) }
assertThat(exc.message).isEqualTo("Invalid project dependency URI `@no.slash`.")
}
@Test
fun `@-notation package URIs - missing dependencies are rejected`(@TempDir tempDir: Path) {
tempDir
.resolve("PklProject")
.writeText(
"""
amends "pkl:Project"
"""
.trimIndent()
)
cmd.parse(arrayOf("--working-dir=$tempDir"))
val opts = cmd.baseOptions.baseOptions(listOf(URI("@foo/bar.pkl")), testMode = true)
val exc = assertThrows<CliException> { CliTest(opts) }
assertThat(exc.message).isEqualTo("Project does not contain dependency `@foo`.")
}
@Test
fun `@-notation package URIs - local dependencies are rejected`(
@TempDir tempDir: Path,
@TempDir depDir: Path,
) {
depDir
.resolve("PklProject")
.writeText(
"""
amends "pkl:Project"
package {
name = "foo"
baseUri = "package://example.com/foo"
version = "0.0.1"
packageZipUrl = "https://example.com/foo@\(version).zip"
}
"""
.trimIndent()
)
tempDir
.resolve("PklProject")
.writeText(
"""
amends "pkl:Project"
dependencies {
["foo"] = import("${depDir.toUri().resolve("PklProject")}")
}
"""
.trimIndent()
)
cmd.parse(arrayOf("--working-dir=$tempDir"))
val opts = cmd.baseOptions.baseOptions(listOf(URI("@foo/bar.pkl")), testMode = true)
val exc = assertThrows<CliException> { CliTest(opts) }
assertThat(exc.message)
.isEqualTo(
"Only remote project dependencies may be referenced using @-notation. Dependency `@foo` is a local dependency."
)
}
@Test
fun `@-notation package URIs - remote dependencies are resolved`(@TempDir tempDir: Path) {
tempDir
.resolve("PklProject")
.writeText(
"""
amends "pkl:Project"
dependencies {
["foo"] {
uri = "package://example.com/foo@1.2.3"
}
}
"""
.trimIndent()
)
cmd.parse(arrayOf("--working-dir=$tempDir"))
val opts = cmd.baseOptions.baseOptions(listOf(URI("@foo/bar.pkl")), testMode = true)
val cliTest = CliTest(opts)
assertThat(cliTest.myResolvedSourceModules)
.isEqualTo(listOf(tempDir.toUri().resolve("package://example.com/foo@1.2.3#/bar.pkl")))
}
val projectWithAllEvaluatorSettings = val projectWithAllEvaluatorSettings =
""" """
amends "pkl:Project" amends "pkl:Project"
@@ -16,6 +16,8 @@
package org.pkl.core; package org.pkl.core;
import java.io.IOException; import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Duration; import java.time.Duration;
import java.util.Collection; import java.util.Collection;
@@ -33,9 +35,11 @@ import org.msgpack.core.MessagePack;
import org.pkl.core.ast.ConstantValueNode; import org.pkl.core.ast.ConstantValueNode;
import org.pkl.core.ast.internal.ToStringNodeGen; import org.pkl.core.ast.internal.ToStringNodeGen;
import org.pkl.core.evaluatorSettings.TraceMode; import org.pkl.core.evaluatorSettings.TraceMode;
import org.pkl.core.externalreader.ExternalReaderProcessException;
import org.pkl.core.http.HttpClient; import org.pkl.core.http.HttpClient;
import org.pkl.core.module.ModuleKeyFactory; import org.pkl.core.module.ModuleKeyFactory;
import org.pkl.core.module.ProjectDependenciesManager; import org.pkl.core.module.ProjectDependenciesManager;
import org.pkl.core.packages.PackageLoadError;
import org.pkl.core.packages.PackageResolver; import org.pkl.core.packages.PackageResolver;
import org.pkl.core.project.DeclaredDependencies; import org.pkl.core.project.DeclaredDependencies;
import org.pkl.core.resource.ResourceReader; import org.pkl.core.resource.ResourceReader;
@@ -56,6 +60,7 @@ import org.pkl.core.runtime.VmUtils;
import org.pkl.core.runtime.VmValue; import org.pkl.core.runtime.VmValue;
import org.pkl.core.runtime.VmValueRenderer; import org.pkl.core.runtime.VmValueRenderer;
import org.pkl.core.util.ErrorMessages; import org.pkl.core.util.ErrorMessages;
import org.pkl.core.util.IoUtils;
import org.pkl.core.util.Nullable; import org.pkl.core.util.Nullable;
public final class EvaluatorImpl implements Evaluator { public final class EvaluatorImpl implements Evaluator {
@@ -69,6 +74,7 @@ public final class EvaluatorImpl implements Evaluator {
private final BufferedLogger logger; private final BufferedLogger logger;
private final PackageResolver packageResolver; private final PackageResolver packageResolver;
private final VmValueRenderer vmValueRenderer = VmValueRenderer.singleLine(1000); private final VmValueRenderer vmValueRenderer = VmValueRenderer.singleLine(1000);
private final @Nullable URI projectFileUri;
private @Nullable MessageBufferPacker messagePacker; private @Nullable MessageBufferPacker messagePacker;
public EvaluatorImpl( public EvaluatorImpl(
@@ -94,6 +100,11 @@ public final class EvaluatorImpl implements Evaluator {
moduleResolver = new ModuleResolver(factories); moduleResolver = new ModuleResolver(factories);
this.logger = new BufferedLogger(logger); this.logger = new BufferedLogger(logger);
packageResolver = PackageResolver.getInstance(securityManager, httpClient, moduleCacheDir); packageResolver = PackageResolver.getInstance(securityManager, httpClient, moduleCacheDir);
if (projectDependencies != null) {
this.projectFileUri = projectDependencies.projectFileUri();
} else {
this.projectFileUri = null;
}
polyglotContext = polyglotContext =
VmUtils.createContext( VmUtils.createContext(
() -> { () -> {
@@ -424,10 +435,37 @@ public final class EvaluatorImpl implements Evaluator {
return evalResult; return evalResult;
} }
/** Resolve dependency notation URIs (e.g. `@foo/bar.pkl`) to its resolved absolute URI. */
private ModuleSource normalizeModuleSource(ModuleSource moduleSource) {
if (moduleSource.getContents() != null
|| moduleSource.getUri().isAbsolute()
|| !moduleSource.getUri().getPath().startsWith("@")) {
return moduleSource;
}
try {
if (projectFileUri != null) {
var moduleKey = moduleResolver.resolve(projectFileUri);
var uri = IoUtils.resolve(securityManager, moduleKey, moduleSource.getUri());
return ModuleSource.uri(uri);
} else {
throw new PackageLoadError("cannotResolveDependencyNoProject");
}
} catch (URISyntaxException e) {
// impossible condition
throw PklBugException.unreachableCode();
} catch (IOException e) {
throw new VmExceptionBuilder()
.evalError("ioErrorLoadingModule", moduleSource.getUri())
.build();
} catch (ExternalReaderProcessException | SecurityManagerException | PackageLoadError e) {
throw new VmExceptionBuilder().withCause(e).build();
}
}
private <T> T doEvaluate(ModuleSource moduleSource, Function<VmTyped, T> doEvaluate) { private <T> T doEvaluate(ModuleSource moduleSource, Function<VmTyped, T> doEvaluate) {
return doEvaluate( return doEvaluate(
() -> { () -> {
var moduleKey = moduleResolver.resolve(moduleSource); var moduleKey = moduleResolver.resolve(normalizeModuleSource(moduleSource));
var module = VmLanguage.get(null).loadModule(moduleKey); var module = VmLanguage.get(null).loadModule(moduleKey);
return doEvaluate.apply(module); return doEvaluate.apply(module);
}); });
@@ -817,10 +817,10 @@ invalidModuleOutput=\
Expected `{0}` of module `{3}` to be of type `{1}`, but got type `{2}`. Expected `{0}` of module `{3}` to be of type `{1}`, but got type `{2}`.
cannotResolveDependencyWithoutHierarchicalUris=\ cannotResolveDependencyWithoutHierarchicalUris=\
Cannot import dependency because project URI `{0}` does not have a hierarchical path. Cannot resolve dependency because project URI `{0}` does not have a hierarchical path.
cannotResolveDependencyNoProject=\ cannotResolveDependencyNoProject=\
Cannot import dependency because there is no project found.\n\ Cannot resolve dependency because there is no project found.\n\
\n\ \n\
If you meant to import a path that starts with `@`, prefix the path with `./` (e.g. `import "./@myPath").\n\ If you meant to import a path that starts with `@`, prefix the path with `./` (e.g. `import "./@myPath").\n\
If you meant to import a dependency, ensure that this file is within a directory that contains a PklProject module. If you meant to import a dependency, ensure that this file is within a directory that contains a PklProject module.
@@ -1,5 +1,5 @@
–– Pkl Error –– –– Pkl Error ––
Cannot import dependency because there is no project found. Cannot resolve dependency because there is no project found.
If you meant to import a path that starts with `@`, prefix the path with `./` (e.g. `import "./@myPath"). If you meant to import a path that starts with `@`, prefix the path with `./` (e.g. `import "./@myPath").
If you meant to import a dependency, ensure that this file is within a directory that contains a PklProject module. If you meant to import a dependency, ensure that this file is within a directory that contains a PklProject module.
@@ -507,7 +507,7 @@ class EvaluatorTest {
val evaluator = evaluatorBuilder.setProjectDependencies(project.dependencies).build() val evaluator = evaluatorBuilder.setProjectDependencies(project.dependencies).build()
assertThatCode { evaluator.use { it.evaluateOutputText(uri("foobar:baz")) } } assertThatCode { evaluator.use { it.evaluateOutputText(uri("foobar:baz")) } }
.hasMessageContaining( .hasMessageContaining(
"Cannot import dependency because project URI `foobar:foo/PklProject` does not have a hierarchical path." "Cannot resolve dependency because project URI `foobar:foo/PklProject` does not have a hierarchical path."
) )
} }
@@ -722,6 +722,34 @@ class EvaluatorTest {
} }
} }
@Test
fun `eval dependency notation as a module source`(@TempDir tempDir: Path) {
PackageServer.populateCacheDir(tempDir)
val project = Project.load(modulePath("org/pkl/core/project/project5/PklProject"))
val evaluator =
with(EvaluatorBuilder.preconfigured()) {
moduleCacheDir = tempDir
applyFromProject(project)
build()
}
val outputText = evaluator.evaluateOutputText(uri("@fruit/catalog/apple.pkl"))
assertThat(outputText)
.isEqualTo(
"""
name = "Apple"
"""
.trimIndent()
)
}
@Test
fun `eval dependency notation -- no project configured`() {
val evaluator = Evaluator.preconfigured()
assertThatCode { evaluator.evaluateOutputText(uri("@fruit/catalog/apple.pkl")) }
.hasMessageContaining("Cannot resolve dependency because there is no project found.")
}
private fun checkModule(module: PModule) { private fun checkModule(module: PModule) {
assertThat(module.properties.size).isEqualTo(2) assertThat(module.properties.size).isEqualTo(2)
assertThat(module.getProperty("name")).isEqualTo("pigeon") assertThat(module.getProperty("name")).isEqualTo("pigeon")
@@ -159,7 +159,7 @@ class CliDocGenerator(
val regularModuleUris = mutableListOf<URI>() val regularModuleUris = mutableListOf<URI>()
val pklProjectPaths = mutableSetOf<Path>() val pklProjectPaths = mutableSetOf<Path>()
val packageUris = mutableListOf<PackageUri>() val packageUris = mutableListOf<PackageUri>()
for (moduleUri in resolvedSourceModules) { for (moduleUri in options.base.normalizedSourceModules) {
if (moduleUri.scheme == "file") { if (moduleUri.scheme == "file") {
val dir = moduleUri.toPath().parent val dir = moduleUri.toPath().parent
val projectFile = dir.getProjectFile(options.base.normalizedRootDir) val projectFile = dir.getProjectFile(options.base.normalizedRootDir)