From 2ca2aaf9ec0e55bea9e19b5886d8d6512ec7dcf1 Mon Sep 17 00:00:00 2001 From: Jen Basch Date: Thu, 30 Jul 2026 00:26:21 -0700 Subject: [PATCH] Add `pkl project package --install` to local cache (#1795) --- docs/modules/pkl-cli/pages/index.adoc | 6 ++ docs/modules/pkl-gradle/pages/index.adoc | 7 ++ .../kotlin/org/pkl/cli/CliProjectPackager.kt | 5 +- .../org/pkl/cli/commands/ProjectCommand.kt | 11 +++ .../org/pkl/cli/CliProjectPackagerTest.kt | 86 +++++++++++++++++++ .../pkl/core/packages/PackageResolver.java | 3 + .../pkl/core/packages/PackageResolvers.java | 49 +++++++++-- .../org/pkl/core/project/ProjectPackager.java | 48 ++++++++++- .../org/pkl/core/errorMessages.properties | 3 + .../main/java/org/pkl/gradle/PklPlugin.java | 1 + .../pkl/gradle/spec/ProjectPackageSpec.java | 2 + .../pkl/gradle/task/ProjectPackageTask.java | 5 ++ 12 files changed, 215 insertions(+), 11 deletions(-) diff --git a/docs/modules/pkl-cli/pages/index.adoc b/docs/modules/pkl-cli/pages/index.adoc index 94e5aa1f2..0f5a53e2f 100644 --- a/docs/modules/pkl-cli/pages/index.adoc +++ b/docs/modules/pkl-cli/pages/index.adoc @@ -670,6 +670,12 @@ Example: `--test-reporter minimal` + Which test reporter to use for CLI output. Possible values are `spec` and `minimal`. ==== +.--install +[%collapsible] +==== +Install the built package into the module cache dir. +==== + This command also takes <>. [[command-project-resolve]] diff --git a/docs/modules/pkl-gradle/pages/index.adoc b/docs/modules/pkl-gradle/pages/index.adoc index 2cd008ed0..c827b3491 100644 --- a/docs/modules/pkl-gradle/pages/index.adoc +++ b/docs/modules/pkl-gradle/pages/index.adoc @@ -694,6 +694,13 @@ Example: `reporter = "minimal"` + Which test reporter to use for CLI output. Possible values are `"spec"` and `"minimal"`. ==== +.install: Property +[%collapsible] +==== +Default: `false` + +Install the built package into the module cache dir. +==== + Common properties: include::../partials/gradle-common-properties.adoc[] diff --git a/pkl-cli/src/main/kotlin/org/pkl/cli/CliProjectPackager.kt b/pkl-cli/src/main/kotlin/org/pkl/cli/CliProjectPackager.kt index f5a4fdc2f..9dbb9b8a7 100644 --- a/pkl-cli/src/main/kotlin/org/pkl/cli/CliProjectPackager.kt +++ b/pkl-cli/src/main/kotlin/org/pkl/cli/CliProjectPackager.kt @@ -1,5 +1,5 @@ /* - * Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved. + * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,6 +31,7 @@ class CliProjectPackager( private val testOptions: CliTestOptions, private val outputPath: String, private val skipPublishCheck: Boolean, + private val install: Boolean, private val consoleWriter: Writer = System.out.writer(), private val errWriter: Writer = System.err.writer(), ) : CliProjectCommand(baseOptions, projectDirs) { @@ -85,6 +86,8 @@ class CliProjectPackager( securityManager, httpClient, skipPublishCheck, + install, + moduleCacheDir, consoleWriter, ) .createPackages() diff --git a/pkl-cli/src/main/kotlin/org/pkl/cli/commands/ProjectCommand.kt b/pkl-cli/src/main/kotlin/org/pkl/cli/commands/ProjectCommand.kt index b5e48fa20..96d9e448c 100644 --- a/pkl-cli/src/main/kotlin/org/pkl/cli/commands/ProjectCommand.kt +++ b/pkl-cli/src/main/kotlin/org/pkl/cli/commands/ProjectCommand.kt @@ -21,6 +21,7 @@ import com.github.ajalt.clikt.core.subcommands import com.github.ajalt.clikt.parameters.arguments.argument import com.github.ajalt.clikt.parameters.arguments.multiple import com.github.ajalt.clikt.parameters.groups.provideDelegate +import com.github.ajalt.clikt.parameters.options.check import com.github.ajalt.clikt.parameters.options.default import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.option @@ -128,6 +129,15 @@ class PackageCommand : BaseCommand(name = "package", helpLink = helpLink) { .single() .flag() + private val install: Boolean by + option( + names = arrayOf("--install"), + help = "Install the built package into the module cache dir", + ) + .single() + .flag() + .check("not compatible with --no-cache") { !(it && baseOptions.noCache) } + override fun run() { CliProjectPackager( baseOptions.baseOptions(emptyList()), @@ -135,6 +145,7 @@ class PackageCommand : BaseCommand(name = "package", helpLink = helpLink) { testOptions.cliTestOptions, outputPath, skipPublishCheck, + install, ) .run() } diff --git a/pkl-cli/src/test/kotlin/org/pkl/cli/CliProjectPackagerTest.kt b/pkl-cli/src/test/kotlin/org/pkl/cli/CliProjectPackagerTest.kt index 71d1f6cda..1a970b5f5 100644 --- a/pkl-cli/src/test/kotlin/org/pkl/cli/CliProjectPackagerTest.kt +++ b/pkl-cli/src/test/kotlin/org/pkl/cli/CliProjectPackagerTest.kt @@ -60,6 +60,7 @@ class CliProjectPackagerTest { CliTestOptions(), ".out/%{name}@%{version}", skipPublishCheck = true, + install = false, ) val err = assertThrows { packager.run() } assertThat(err).hasMessageStartingWith("No project visible to the working directory.") @@ -74,6 +75,7 @@ class CliProjectPackagerTest { CliTestOptions(), ".out/%{name}@%{version}", skipPublishCheck = true, + install = false, ) val err = assertThrows { packager.run() } assertThat(err).hasMessageStartingWith("Directory $tempDir does not contain a PklProject file.") @@ -96,6 +98,7 @@ class CliProjectPackagerTest { CliTestOptions(), ".out/%{name}@%{version}", skipPublishCheck = true, + install = false, ) val err = assertThrows { packager.run() } assertThat(err) @@ -140,6 +143,7 @@ class CliProjectPackagerTest { CliTestOptions(), ".out/%{name}@%{version}", skipPublishCheck = true, + install = false, consoleWriter = buffer, ) val err = assertThrows { packager.run() } @@ -187,6 +191,7 @@ class CliProjectPackagerTest { CliTestOptions(), ".out/%{name}@%{version}", skipPublishCheck = true, + install = false, consoleWriter = buffer, ) packager.run() @@ -286,6 +291,7 @@ class CliProjectPackagerTest { CliTestOptions(), ".out", skipPublishCheck = true, + install = false, consoleWriter = buffer, ) packager.run() @@ -337,6 +343,7 @@ class CliProjectPackagerTest { CliTestOptions(), ".out/%{name}@%{version}", skipPublishCheck = true, + install = false, consoleWriter = StringWriter(), ) packager.run() @@ -419,6 +426,7 @@ class CliProjectPackagerTest { CliTestOptions(), ".out/%{name}@%{version}", skipPublishCheck = true, + install = false, consoleWriter = StringWriter(), ) .run() @@ -517,6 +525,7 @@ class CliProjectPackagerTest { CliTestOptions(), ".out/%{name}@%{version}", skipPublishCheck = true, + install = false, consoleWriter = StringWriter(), ) .run() @@ -655,6 +664,7 @@ class CliProjectPackagerTest { CliTestOptions(), ".out/%{name}@%{version}", skipPublishCheck = true, + install = false, consoleWriter = StringWriter(), ) .run() @@ -695,6 +705,7 @@ class CliProjectPackagerTest { CliTestOptions(), ".out/%{name}@%{version}", skipPublishCheck = true, + install = false, consoleWriter = StringWriter(), ) .run() @@ -749,6 +760,7 @@ class CliProjectPackagerTest { CliTestOptions(), ".out/%{name}@%{version}", skipPublishCheck = true, + install = false, consoleWriter = StringWriter(), ) .run() @@ -795,6 +807,7 @@ class CliProjectPackagerTest { CliTestOptions(), ".out/%{name}@%{version}", skipPublishCheck = true, + install = false, consoleWriter = StringWriter(), ) .run() @@ -832,6 +845,7 @@ class CliProjectPackagerTest { CliTestOptions(), ".out/%{name}@%{version}", skipPublishCheck = true, + install = false, consoleWriter = StringWriter(), ) .run() @@ -875,6 +889,7 @@ class CliProjectPackagerTest { CliTestOptions(), ".out/%{name}@%{version}", skipPublishCheck = true, + install = false, consoleWriter = StringWriter(), ) .run() @@ -919,6 +934,7 @@ class CliProjectPackagerTest { CliTestOptions(), ".out/%{name}@%{version}", skipPublishCheck = true, + install = false, consoleWriter = out, ) .run() @@ -974,6 +990,7 @@ class CliProjectPackagerTest { CliTestOptions(), ".out/%{name}@%{version}", skipPublishCheck = false, + install = false, consoleWriter = StringWriter(), ) .run() @@ -1018,6 +1035,7 @@ class CliProjectPackagerTest { CliTestOptions(), ".out/%{name}@%{version}", skipPublishCheck = false, + install = false, consoleWriter = out, ) .run() @@ -1062,6 +1080,7 @@ class CliProjectPackagerTest { CliTestOptions(), ".out/%{name}@%{version}", skipPublishCheck = true, + install = false, consoleWriter = StringWriter(), ) packager.run() @@ -1121,6 +1140,73 @@ class CliProjectPackagerTest { ) } + @Test + fun `install package to local cache`(@TempDir tempDir: Path, @TempDir cacheDir: Path) { + tempDir + .resolve("PklProject") + .writeString( + """ + amends "pkl:Project" + + package { + name = "mypackage" + version = "1.0.0" + baseUri = "package://example.com/mypackage" + packageZipUrl = "https://foo.com" + } + """ + .trimIndent() + ) + val packager = + CliProjectPackager( + CliBaseOptions(workingDir = tempDir, moduleCacheDir = cacheDir), + listOf(tempDir), + CliTestOptions(), + ".out/%{name}@%{version}", + skipPublishCheck = true, + install = true, + consoleWriter = StringWriter(), + ) + packager.run() + + assertThat(cacheDir.resolve("package-2/example.com/mypackage@1.0.0/mypackage@1.0.0.json")) + .exists() + assertThat(cacheDir.resolve("package-2/example.com/mypackage@1.0.0/mypackage@1.0.0.zip")) + .exists() + } + + @Test + fun `cannot install with no cache`(@TempDir tempDir: Path) { + tempDir + .resolve("PklProject") + .writeString( + """ + amends "pkl:Project" + + package { + name = "mypackage" + version = "1.0.0" + baseUri = "package://example.com/mypackage" + packageZipUrl = "https://foo.com" + } + """ + .trimIndent() + ) + val packager = + CliProjectPackager( + CliBaseOptions(workingDir = tempDir, noCache = true), + listOf(tempDir), + CliTestOptions(), + ".out/%{name}@%{version}", + skipPublishCheck = true, + install = true, + consoleWriter = StringWriter(), + ) + val exc = assertThrows { packager.run() } + assertThat(exc.message) + .isEqualTo("Cannot install package to module cache dir when module cache is disabled.") + } + private fun Path.zipFilePaths(): List { return FileSystems.newFileSystem(URI("jar:${toUri()}"), emptyMap()).use { fs -> Files.walk(fs.getPath("/")).map(IoUtils::toNormalizedPathString).collect(Collectors.toList()) diff --git a/pkl-core/src/main/java/org/pkl/core/packages/PackageResolver.java b/pkl-core/src/main/java/org/pkl/core/packages/PackageResolver.java index ed34d5b04..58c2f7225 100644 --- a/pkl-core/src/main/java/org/pkl/core/packages/PackageResolver.java +++ b/pkl-core/src/main/java/org/pkl/core/packages/PackageResolver.java @@ -56,4 +56,7 @@ public interface PackageResolver extends Closeable { boolean hasElement(PackageAssetUri uri, @Nullable Checksums checksums) throws IOException, SecurityManagerException; + + Pair writePackage(PackageUri packageUri, Path metadataFile, Path zipFile) + throws IOException; } diff --git a/pkl-core/src/main/java/org/pkl/core/packages/PackageResolvers.java b/pkl-core/src/main/java/org/pkl/core/packages/PackageResolvers.java index b2baefe78..850c0934a 100644 --- a/pkl-core/src/main/java/org/pkl/core/packages/PackageResolvers.java +++ b/pkl-core/src/main/java/org/pkl/core/packages/PackageResolvers.java @@ -317,6 +317,11 @@ final class PackageResolvers { throw new UnsupportedOperationException(); } + @Override + public Pair writePackage(PackageUri packageUri, Path metadataFile, Path zipFile) { + throw new UnsupportedOperationException(); + } + @Override public byte[] getBytes( PackageAssetUri uri, boolean allowDirectories, @Nullable Checksums checksums) @@ -428,6 +433,7 @@ final class PackageResolvers { private final Path tmpDir; + // if updated, also update CliProjectPackagerTest.`install package to local cache` private static final String CACHE_DIR_PREFIX = "package-2"; @GuardedBy("lock") @@ -503,12 +509,16 @@ final class PackageResolvers { } } + private Path doGetMetadataPath(PackageUri packageUri) { + var metadataFileName = getLastSegmentName(packageUri) + ".json"; + var metadataRelativePath = getRelativePath(packageUri).resolve(metadataFileName); + return cacheDir.resolve(metadataRelativePath); + } + private Path getMetadataPath( PackageUri packageUri, URI requestUri, @Nullable Checksums checksums) throws IOException, SecurityManagerException { - var metadataFileName = getLastSegmentName(packageUri) + ".json"; - var metadataRelativePath = getRelativePath(packageUri).resolve(metadataFileName); - var cachePath = cacheDir.resolve(metadataRelativePath); + var cachePath = doGetMetadataPath(packageUri); if (Files.exists(cachePath)) { return cachePath; } @@ -556,11 +566,15 @@ final class PackageResolvers { return metadata; } - private Path getZipFilePath(PackageUri packageUri, DependencyMetadata dependencyMetadata) - throws IOException, SecurityManagerException { + private Path doGetZipFilePath(PackageUri packageUri) { var packageZipName = getLastSegmentName(packageUri) + ".zip"; var relativePath = getRelativePath(packageUri).resolve(packageZipName); - var cachePath = cacheDir.resolve(relativePath); + return cacheDir.resolve(relativePath); + } + + private Path getZipFilePath(PackageUri packageUri, DependencyMetadata dependencyMetadata) + throws IOException, SecurityManagerException { + var cachePath = doGetZipFilePath(packageUri); if (Files.exists(cachePath)) { return cachePath; } @@ -684,5 +698,28 @@ final class PackageResolvers { fileSystems.clear(); } } + + @Override + public Pair writePackage(PackageUri packageUri, Path metadataFile, Path zipFile) + throws IOException { + Files.createDirectories(tmpDir); + var tmpSubpath = IoUtils.encodePath(packageUri.toString().replace("/", "-")); + return Pair.of( + writePackagePart(metadataFile, doGetMetadataPath(packageUri), tmpSubpath, ".json"), + writePackagePart(zipFile, doGetZipFilePath(packageUri), tmpSubpath, ".zip")); + } + + private Path writePackagePart(Path srcFile, Path cacheFile, String tmpSubpath, String suffix) + throws IOException { + var tmpFile = Files.createTempFile(tmpDir, tmpSubpath, suffix); + Files.createDirectories(cacheFile.getParent()); + Files.copy(srcFile, tmpFile, StandardCopyOption.REPLACE_EXISTING); + Files.move( + tmpFile, cacheFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + if (!IoUtils.isWindows()) { + Files.setPosixFilePermissions(cacheFile, FILE_PERMISSIONS); + } + return cacheFile; + } } } diff --git a/pkl-core/src/main/java/org/pkl/core/project/ProjectPackager.java b/pkl-core/src/main/java/org/pkl/core/project/ProjectPackager.java index 6d7c0cab9..135bed013 100644 --- a/pkl-core/src/main/java/org/pkl/core/project/ProjectPackager.java +++ b/pkl-core/src/main/java/org/pkl/core/project/ProjectPackager.java @@ -37,6 +37,7 @@ import java.util.stream.Collectors; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import org.graalvm.collections.EconomicMap; +import org.jspecify.annotations.Nullable; import org.pkl.core.PklBugException; import org.pkl.core.PklException; import org.pkl.core.SecurityManager; @@ -100,6 +101,7 @@ public final class ProjectPackager { private final boolean color; private final SecurityManager securityManager; private final PackageResolver packageResolver; + private final @Nullable PackageResolver packageWriteResolver; private final boolean skipPublishCheck; private final Writer outputWriter; @@ -112,6 +114,8 @@ public final class ProjectPackager { SecurityManager securityManager, HttpClient httpClient, boolean skipPublishCheck, + boolean install, + @Nullable Path cacheDir, Writer outputWriter) { this.projects = projects; this.workingDir = workingDir; @@ -123,6 +127,14 @@ public final class ProjectPackager { this.packageResolver = PackageResolver.getInstance(securityManager, httpClient, null); this.skipPublishCheck = skipPublishCheck; this.outputWriter = outputWriter; + if (install) { + if (cacheDir == null) { + throw new PklException(ErrorMessages.create("cannotInstallPackageWithNoCache")); + } + packageWriteResolver = PackageResolver.getInstance(securityManager, httpClient, cacheDir); + } else { + packageWriteResolver = null; + } } private void writeLine(String line) throws IOException { @@ -137,6 +149,12 @@ public final class ProjectPackager { writeLine(IoUtils.relativize(packageResult.metadataChecksumFile(), workingDir).toString()); writeLine(IoUtils.relativize(packageResult.zipFile(), workingDir).toString()); writeLine(IoUtils.relativize(packageResult.zipChecksumFile(), workingDir).toString()); + if (packageResult.cacheMetadataFile() != null) { + writeLine(packageResult.cacheMetadataFile().normalize().toString()); + } + if (packageResult.cacheZipFile() != null) { + writeLine(packageResult.cacheZipFile().normalize().toString()); + } outputWriter.flush(); } } @@ -174,9 +192,29 @@ public final class ProjectPackager { if (!skipPublishCheck) { checkAlreadyPublishedPackage(pkg, metadataFileChecksum); } - var result = - new PackageResult( - metadataFile, metadataChecksumFile, zipFile, zipChecksumFile, metadataFileChecksum); + PackageResult result; + if (packageWriteResolver != null) { + var cachePaths = packageWriteResolver.writePackage(pkg.uri(), metadataFile, zipFile); + result = + new PackageResult( + metadataFile, + metadataChecksumFile, + zipFile, + zipChecksumFile, + metadataFileChecksum, + cachePaths.getFirst(), + cachePaths.getSecond()); + } else { + result = + new PackageResult( + metadataFile, + metadataChecksumFile, + zipFile, + zipChecksumFile, + metadataFileChecksum, + null, + null); + } packageResults.put(pkg.uri(), result); return result; } @@ -457,7 +495,9 @@ public final class ProjectPackager { Path zipChecksumFile, Path metadataFile, Path metadataChecksumFile, - String metadataChecksum) { + String metadataChecksum, + @Nullable Path cacheMetadataFile, + @Nullable Path cacheZipFile) { /** * @deprecated As of 0.28.0, replaced by {@link #zipFile()}. */ diff --git a/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties b/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties index c9deeef8e..b1e52a977 100644 --- a/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties +++ b/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties @@ -1205,3 +1205,6 @@ Redirected to: `{1}` invalidReferenceTypeAnnotationWithConstraint=\ `Reference` referent type argument may not include type constraints. + +cannotInstallPackageWithNoCache=\ +Cannot install package to module cache dir when module cache is disabled. diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java b/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java index 029d29e7f..1b37976c4 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java @@ -107,6 +107,7 @@ public class PklPlugin implements Plugin { task.getProjectDirectories().from(spec.getProjectDirectories()); task.getOutputPath().set(spec.getOutputPath()); task.getSkipPublishCheck().set(spec.getSkipPublishCheck()); + task.getInstall().set(spec.getInstall()); task.getJunitReportsDir().set(spec.getJunitReportsDir()); task.getOverwrite().set(spec.getOverwrite()); task.getTestReporter().set(spec.getTestReporter()); diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/spec/ProjectPackageSpec.java b/pkl-gradle/src/main/java/org/pkl/gradle/spec/ProjectPackageSpec.java index d4b05562a..6b5240b2d 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/spec/ProjectPackageSpec.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/spec/ProjectPackageSpec.java @@ -30,5 +30,7 @@ public interface ProjectPackageSpec extends BasePklSpec { Property getSkipPublishCheck(); + Property getInstall(); + Property getTestReporter(); } diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/task/ProjectPackageTask.java b/pkl-gradle/src/main/java/org/pkl/gradle/task/ProjectPackageTask.java index fcee3ed9b..5d1ca8078 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/task/ProjectPackageTask.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/task/ProjectPackageTask.java @@ -63,6 +63,10 @@ public abstract class ProjectPackageTask extends BasePklTask { @Optional public abstract Property getSkipPublishCheck(); + @Input + @Optional + public abstract Property getInstall(); + @Input @Optional public abstract Property getTestReporter(); @@ -92,6 +96,7 @@ public abstract class ProjectPackageTask extends BasePklTask { toTestReporter(getTestReporter())), getOutputPath().get().getAsFile().getAbsolutePath(), getSkipPublishCheck().getOrElse(false), + getInstall().getOrElse(false), new PrintWriter(System.out), new PrintWriter(System.err)) .run();