From 16323716698caba9648cf04648d88e9e632c1527 Mon Sep 17 00:00:00 2001 From: Barry Warsaw Date: Wed, 17 Apr 2019 12:28:08 -0700 Subject: [PATCH] Revert "Reverting Zipapp Container refactoring." This reverts commit 374197e9c3c6af456d41d29f768bc30c7bec99dc. --- docs/developers.md | 10 +- .../ParallelWheelsIntegrationTest.groovy | 2 +- .../python/plugin/PexIntegrationTest.groovy | 2 + .../gradle/python/PythonExtension.groovy | 49 ++++++- .../gradle/python/extension/PexExtension.java | 33 ++++- .../plugin/PythonCliDistributionPlugin.java | 16 ++- .../python/plugin/PythonContainerPlugin.java | 130 ++++++++++++++++++ .../python/plugin/PythonFlyerPlugin.java | 4 +- .../plugin/PythonPexDistributionPlugin.java | 54 +------- .../plugin/PythonWebApplicationPlugin.java | 11 +- .../gradle/python/tasks/BuildPexTask.java | 4 +- .../gradle/python/tasks/BuildWebAppTask.java | 38 +++-- .../gradle/python/tasks/NoopBuildPexTask.java | 46 +++++++ .../python/tasks/PythonContainerTask.java | 23 ++++ .../python/util/ApplicationContainer.java | 48 +++++++ .../gradle/python/util/ExtensionUtils.java | 2 +- .../ZipappContainer.java} | 5 +- .../util/entrypoint/EntryPointWriter.java | 4 +- .../DefaultPexEntryPointTemplateProvider.java | 14 +- ...faultWebappEntryPointTemplateProvider.java | 37 +++++ .../gradle/python/PythonExtensionTest.groovy | 36 +++++ .../PythonPexDistributionPluginTest.groovy | 13 +- 22 files changed, 478 insertions(+), 103 deletions(-) create mode 100644 pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/plugin/PythonContainerPlugin.java create mode 100644 pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/tasks/NoopBuildPexTask.java create mode 100644 pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/tasks/PythonContainerTask.java create mode 100644 pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/ApplicationContainer.java rename pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/{extension/ZipappExtension.java => util/ZipappContainer.java} (89%) rename pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/{pex => zipapp}/DefaultPexEntryPointTemplateProvider.java (68%) create mode 100644 pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/zipapp/DefaultWebappEntryPointTemplateProvider.java diff --git a/docs/developers.md b/docs/developers.md index 6ccb75a6..6e73d9e7 100644 --- a/docs/developers.md +++ b/docs/developers.md @@ -18,13 +18,13 @@ test and add `mavenLocal()` to the repositories. This will configure your project to look in `~/.m2` in addition to other repositories you have configured when pulling artifacts. -If you are building on Windows, PyGradle will avoid using your system temporary -folder for integration tests and instead create and use the folder `c:\tmp`. This -is to avoid issues with Windows's 260 character path limit. Make sure your user +If you are building on Windows, PyGradle will avoid using your system temporary +folder for integration tests and instead create and use the folder `c:\tmp`. This +is to avoid issues with Windows's 260 character path limit. Make sure your user account has the ablity to create and use this folder or your integration tests will fail. -If you are using Windows 10, it is possible to go beyond that 260 character limit, -but not with this product. That policy change only applies to managed applications. +If you are using Windows 10, it is possible to go beyond that 260 character limit, +but not with this product. That policy change only applies to managed applications. PyGradle's unit tests are not managed applications. diff --git a/pygradle-plugin/src/integTest/groovy/com/linkedin/gradle/python/plugin/ParallelWheelsIntegrationTest.groovy b/pygradle-plugin/src/integTest/groovy/com/linkedin/gradle/python/plugin/ParallelWheelsIntegrationTest.groovy index 98c7067e..e0c565f6 100644 --- a/pygradle-plugin/src/integTest/groovy/com/linkedin/gradle/python/plugin/ParallelWheelsIntegrationTest.groovy +++ b/pygradle-plugin/src/integTest/groovy/com/linkedin/gradle/python/plugin/ParallelWheelsIntegrationTest.groovy @@ -75,7 +75,7 @@ class ParallelWheelsIntegrationTest extends Specification { result.task(':foo:pytest').outcome == TaskOutcome.SUCCESS result.task(':foo:check').outcome == TaskOutcome.SUCCESS result.task(':foo:build').outcome == TaskOutcome.SUCCESS - result.task(':foo:buildPex').outcome == TaskOutcome.SUCCESS + result.task(':foo:assembleContainers').outcome == TaskOutcome.SUCCESS deployablePath.resolve('hello_world').toFile().exists() deployablePath.resolve(PexFileUtil.createThinPexFilename('foo')).toFile().exists() diff --git a/pygradle-plugin/src/integTest/groovy/com/linkedin/gradle/python/plugin/PexIntegrationTest.groovy b/pygradle-plugin/src/integTest/groovy/com/linkedin/gradle/python/plugin/PexIntegrationTest.groovy index 78a6df95..876d6c34 100644 --- a/pygradle-plugin/src/integTest/groovy/com/linkedin/gradle/python/plugin/PexIntegrationTest.groovy +++ b/pygradle-plugin/src/integTest/groovy/com/linkedin/gradle/python/plugin/PexIntegrationTest.groovy @@ -75,6 +75,7 @@ class PexIntegrationTest extends Specification { result.task(':foo:check').outcome == TaskOutcome.SUCCESS result.task(':foo:build').outcome == TaskOutcome.SUCCESS result.task(':foo:buildPex').outcome == TaskOutcome.SUCCESS + result.task(':foo:assembleContainers').outcome == TaskOutcome.SUCCESS deployablePath.resolve('hello_world').toFile().exists() deployablePath.resolve(PexFileUtil.createThinPexFilename('foo')).toFile().exists() @@ -129,6 +130,7 @@ class PexIntegrationTest extends Specification { result.task(':foo:check').outcome == TaskOutcome.SUCCESS result.task(':foo:build').outcome == TaskOutcome.SUCCESS result.task(':foo:buildPex').outcome == TaskOutcome.SUCCESS + result.task(':foo:assembleContainers').outcome == TaskOutcome.SUCCESS Path deployablePath = testProjectDir.root.toPath().resolve(Paths.get('foo', 'build', 'deployable', "bin")) def pexFile = deployablePath.resolve(PexFileUtil.createFatPexFilename('hello_world')) diff --git a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/PythonExtension.groovy b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/PythonExtension.groovy index e4ea90c1..1a93cf0f 100644 --- a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/PythonExtension.groovy +++ b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/PythonExtension.groovy @@ -15,8 +15,10 @@ */ package com.linkedin.gradle.python +import com.linkedin.gradle.python.extension.PexExtension import com.linkedin.gradle.python.extension.PythonDetails import com.linkedin.gradle.python.extension.PythonDetailsFactory +import com.linkedin.gradle.python.util.ApplicationContainer import org.gradle.api.GradleException import org.gradle.api.Project @@ -80,6 +82,28 @@ class PythonExtension { /* Container of the details related to the venv/python instance */ private final PythonDetails details + /* + * "Application container" defines the format for bundling the application + * into a single file distribution. Examples include pex, shiv, and xar. + * Not all plugins using this extension support such containers, but for + * u/i purposes, it's convenient to add this here. This allows the + * following in a build.gradle file: + * + * python { + * container = "shiv" + * } + * + * These will simply be ignored in extension clients that don't need it. + * + * Downstream consumers can extend the map between container short names + * appropriate for the build.gradle UI, and the container class + * this maps to. They can also set the default container, which allows + * them e.g. to choose shivs over pexes. + */ + public Map containers + String container + ApplicationContainer defaultContainer + PythonExtension(Project project) { this.details = PythonDetailsFactory.makePythonDetails(project, null) docsDir = Paths.get(project.projectDir.absolutePath, "docs").toFile().path @@ -92,10 +116,18 @@ class PythonExtension { def applicationDirectory = PythonDetailsFactory.getPythonApplicationDirectory() pythonEnvironment = [ - 'PATH': "${ -> details.virtualEnv.toPath().resolve(applicationDirectory).toAbsolutePath().toString() }" + File.pathSeparator + System.getenv('PATH'),] + 'PATH': "${ -> details.virtualEnv.toPath().resolve(applicationDirectory).toAbsolutePath().toString() }" + + File.pathSeparator + + System.getenv('PATH'), + ] - pythonEnvironmentDistgradle = ['PYGRADLE_PROJECT_NAME' : project.name, - 'PYGRADLE_PROJECT_VERSION': "${ -> project.version }",] + pythonEnvironmentDistgradle = [ + 'PYGRADLE_PROJECT_NAME' : project.name, + 'PYGRADLE_PROJECT_VERSION': "${ -> project.version }", + ] + + defaultContainer = new PexExtension(project) + containers = [pex: defaultContainer] /* * NOTE: Do lots of sanity checking and validation here. @@ -155,4 +187,15 @@ class PythonExtension { void setPinnedFile(File pinnedFile) { this.pinnedFile = pinnedFile } + + /* + * Use this as the programmatic API for getting the current container extension. + */ + ApplicationContainer getApplicationContainer() { + // Why am I doing it this way? Because if the container isn't set + // (i.e. it is null) I want to return the default container. But if + // it's set to a bogus value, I want it to return the null so the + // caller will know they have a bogus value. + return container == null ? defaultContainer : containers.get(container) + } } diff --git a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/extension/PexExtension.java b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/extension/PexExtension.java index f4da8dbb..13373f9f 100644 --- a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/extension/PexExtension.java +++ b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/extension/PexExtension.java @@ -15,13 +15,29 @@ */ package com.linkedin.gradle.python.extension; +import com.linkedin.gradle.python.PythonExtension; +import com.linkedin.gradle.python.tasks.BuildPexTask; +import com.linkedin.gradle.python.util.ApplicationContainer; +import com.linkedin.gradle.python.util.ExtensionUtils; import com.linkedin.gradle.python.util.OperatingSystem; +import com.linkedin.gradle.python.util.StandardTextValues; import org.gradle.api.Project; import java.io.File; -public class PexExtension implements ZipappExtension { +public class PexExtension implements ApplicationContainer { + // 2019-04-01(warsaw): For backward compatibility, we must expose a no-op + // buildPex task unconditionally. This will be created in + // PythonContainerPlugin and tied into the task hierarchy in the right + // place. realBuildPex task is the actual pex building task, but these + // are only needed if pexes are selected (and won't get created until + // after build.gradle evaluation). + // + // Yes, this is gross and we should deprecate this mess at our earliest convenience. + public static final String TASK_BUILD_PEX = "realBuildPex"; + public static final String TASK_BUILD_NOOP_PEX = "buildPex"; + private File cache; // Default to fat zipapps on Windows, since our wrappers are fairly POSIX specific. private boolean isFat = OperatingSystem.current().isWindows(); @@ -93,4 +109,19 @@ public File getCache() { public void setCache(File cache) { this.cache = cache; } + + public void addExtensions(Project project) { + ExtensionUtils.maybeCreatePexExtension(project); + } + + public void addDependencies(Project project) { + final PythonExtension extension = ExtensionUtils.getPythonExtension(project); + + project.getDependencies().add(StandardTextValues.CONFIGURATION_BUILD_REQS.getValue(), + extension.forcedVersions.get("pex")); + } + + public void makeTasks(Project project) { + project.getTasks().create(TASK_BUILD_PEX, BuildPexTask.class); + } } diff --git a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/plugin/PythonCliDistributionPlugin.java b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/plugin/PythonCliDistributionPlugin.java index a38820b7..9c0f6c4b 100644 --- a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/plugin/PythonCliDistributionPlugin.java +++ b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/plugin/PythonCliDistributionPlugin.java @@ -15,25 +15,29 @@ */ package com.linkedin.gradle.python.plugin; +import com.linkedin.gradle.python.util.ApplicationContainer; import com.linkedin.gradle.python.tasks.GenerateCompletionsTask; import com.linkedin.gradle.python.util.ExtensionUtils; import com.linkedin.gradle.python.util.StandardTextValues; import org.gradle.api.Project; +import org.gradle.api.tasks.TaskContainer; -public class PythonCliDistributionPlugin extends PythonBasePlugin { +public class PythonCliDistributionPlugin extends PythonContainerPlugin { public static final String TASK_GENERATE_COMPLETIONS = "generateCompletions"; @Override public void applyTo(Project project) { - project.getPlugins().apply(PythonPexDistributionPlugin.class); + project.getPlugins().apply(PythonContainerPlugin.class); ExtensionUtils.maybeCreateCliExtension(project); - GenerateCompletionsTask completionsTask = project.getTasks().create(TASK_GENERATE_COMPLETIONS, GenerateCompletionsTask.class); - completionsTask.dependsOn(project.getTasks().getByName(StandardTextValues.TASK_INSTALL_PROJECT.getValue())); + TaskContainer tasks = project.getTasks(); - project.getTasks().getByName(PythonPexDistributionPlugin.TASK_BUILD_PEX).dependsOn(project.getTasks().getByName(TASK_GENERATE_COMPLETIONS)); - } + GenerateCompletionsTask completionsTask = tasks.create(TASK_GENERATE_COMPLETIONS, GenerateCompletionsTask.class); + completionsTask.dependsOn(tasks.getByName(StandardTextValues.TASK_INSTALL_PROJECT.getValue())); + tasks.getByName(ApplicationContainer.TASK_ASSEMBLE_CONTAINERS) + .dependsOn(project.getTasks().getByName(TASK_GENERATE_COMPLETIONS)); + } } diff --git a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/plugin/PythonContainerPlugin.java b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/plugin/PythonContainerPlugin.java new file mode 100644 index 00000000..2a2057a2 --- /dev/null +++ b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/plugin/PythonContainerPlugin.java @@ -0,0 +1,130 @@ +/* + * Copyright 2016 LinkedIn Corp. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 com.linkedin.gradle.python.plugin; + +import com.linkedin.gradle.python.PythonExtension; +import com.linkedin.gradle.python.extension.DeployableExtension; +import com.linkedin.gradle.python.extension.PexExtension; +import com.linkedin.gradle.python.tasks.BuildWheelsTask; +import com.linkedin.gradle.python.tasks.NoopBuildPexTask; +import com.linkedin.gradle.python.tasks.PythonContainerTask; +import com.linkedin.gradle.python.util.ApplicationContainer; +import com.linkedin.gradle.python.util.ExtensionUtils; +import com.linkedin.gradle.python.util.StandardTextValues; +import org.gradle.api.Project; +import org.gradle.api.Task; +import org.gradle.api.tasks.TaskContainer; +import org.gradle.api.tasks.bundling.Compression; +import org.gradle.api.tasks.bundling.Tar; + + +public class PythonContainerPlugin extends PythonBasePlugin { + @Override + public void applyTo(final Project project) { + + project.getPlugins().apply(PythonPlugin.class); + final PythonExtension pythonExtension = ExtensionUtils.getPythonExtension(project); + ExtensionUtils.maybeCreateWheelExtension(project); + + final DeployableExtension deployableExtension = ExtensionUtils.maybeCreateDeployableExtension(project); + final ApplicationContainer applicationContainer = pythonExtension.getApplicationContainer(); + + applicationContainer.addExtensions(project); + + /* + * Build wheels, first of dependencies, then of the current project. + * However, it's possible that we will have multiple containers + * (e.g. pex and shiv), so be sure to only build the wheels once. + * + * TODO 2019-03-19: Adapt this to on-host layered caching. + */ + TaskContainer tasks = project.getTasks(); + + + // Add this no-op task for backward compatibility. See PexExtension for details. + Task noop = tasks.findByName(PexExtension.TASK_BUILD_NOOP_PEX); + if (noop == null) { + noop = tasks.create(PexExtension.TASK_BUILD_NOOP_PEX, NoopBuildPexTask.class); + } + + if (tasks.findByName(ApplicationContainer.TASK_ASSEMBLE_CONTAINERS) == null) { + BuildWheelsTask buildWheelsTask = tasks.create(ApplicationContainer.TASK_BUILD_WHEELS, BuildWheelsTask.class); + buildWheelsTask.setInstallFileCollection(project.getConfigurations().getByName("python")); + buildWheelsTask.dependsOn(tasks.getByName(StandardTextValues.TASK_INSTALL_PROJECT.getValue())); + + BuildWheelsTask projectWheelsTask = tasks.create(ApplicationContainer.TASK_BUILD_PROJECT_WHEEL, BuildWheelsTask.class); + projectWheelsTask.setInstallFileCollection(project.files(project.file(project.getProjectDir()))); + projectWheelsTask.setEnvironment(pythonExtension.pythonEnvironmentDistgradle); + projectWheelsTask.dependsOn(tasks.getByName(ApplicationContainer.TASK_BUILD_WHEELS)); + + /* + * This is just a lifecycle task which provides a convenient place + * to add specific container dependencies on, without those + * extensions having to know too many intimate details about + * generic Python builds. E.g. we make the pex task depend on it. + */ + Task assemble = tasks.create(ApplicationContainer.TASK_ASSEMBLE_CONTAINERS); + assemble.dependsOn(noop); + + Tar tar = tasks.create(ApplicationContainer.TASK_PACKAGE_DEPLOYABLE, Tar.class); + tar.setCompression(Compression.GZIP); + tar.setBaseName(project.getName()); + tar.setExtension("tar.gz"); + tar.from(deployableExtension.getDeployableBuildDir()); + tar.dependsOn(assemble); + project.getArtifacts().add(StandardTextValues.CONFIGURATION_DEFAULT.getValue(), tar); + } + + // This must happen after build.gradle file evaluation. + project.afterEvaluate(it -> { + // The application container might have changed. + final ApplicationContainer postContainer = pythonExtension.getApplicationContainer(); + + if (postContainer == null) { + throw new IllegalArgumentException( + "Unknown Python application container: " + + pythonExtension.getContainer()); + } + + /* + * Plumb the container tasks into the task hierarchy. The + * assemble task depends on all the implementers of + * PythonContainerTask, and the deployable task depends on the + * assemble task. + * + * While we're doing this though, suppress the deprecation warning + * normally thrown in NoopBuildPexTask when user code calls its + * .dependsOn(). + */ + postContainer.addDependencies(project); + postContainer.makeTasks(project); + + NoopBuildPexTask noopTask = (NoopBuildPexTask) tasks.findByName(PexExtension.TASK_BUILD_NOOP_PEX); + noopTask.suppressWarning = true; + + Task assemble = tasks.getByName(ApplicationContainer.TASK_ASSEMBLE_CONTAINERS); + Task parent = tasks.getByName(ApplicationContainer.TASK_BUILD_PROJECT_WHEEL); + + for (Task task : tasks.withType(PythonContainerTask.class)) { + assemble.dependsOn(task); + task.dependsOn(parent); + } + + // Turn the warning back on. + noopTask.suppressWarning = false; + }); + } +} diff --git a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/plugin/PythonFlyerPlugin.java b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/plugin/PythonFlyerPlugin.java index fc26308b..c934bc6b 100644 --- a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/plugin/PythonFlyerPlugin.java +++ b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/plugin/PythonFlyerPlugin.java @@ -15,6 +15,7 @@ */ package com.linkedin.gradle.python.plugin; +import com.linkedin.gradle.python.util.ApplicationContainer; import com.linkedin.gradle.python.extension.DeployableExtension; import com.linkedin.gradle.python.util.ExtensionUtils; import com.linkedin.gradle.python.util.FileSystemUtils; @@ -102,8 +103,9 @@ public void apply(final Project project) { copy.from(resourceConf); copy.into(deployableExtension.getDeployableBuildDir().toPath().resolve("resource")); }); + // Make sure we've copied all the files before running the task: packageDeployable - project.getTasks().getByName(PythonPexDistributionPlugin.TASK_PACKAGE_DEPLOYABLE) + project.getTasks().getByName(ApplicationContainer.TASK_PACKAGE_DEPLOYABLE) .dependsOn(project.getTasks().getByName(TASK_PACKAGE_RESOURCE_FILES)); } } diff --git a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/plugin/PythonPexDistributionPlugin.java b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/plugin/PythonPexDistributionPlugin.java index 17c76a9e..77a79e9d 100644 --- a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/plugin/PythonPexDistributionPlugin.java +++ b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/plugin/PythonPexDistributionPlugin.java @@ -16,61 +16,17 @@ package com.linkedin.gradle.python.plugin; import com.linkedin.gradle.python.PythonExtension; -import com.linkedin.gradle.python.extension.DeployableExtension; -import com.linkedin.gradle.python.tasks.BuildPexTask; -import com.linkedin.gradle.python.tasks.BuildWheelsTask; import com.linkedin.gradle.python.util.ExtensionUtils; -import com.linkedin.gradle.python.util.StandardTextValues; import org.gradle.api.Project; -import org.gradle.api.tasks.bundling.Compression; -import org.gradle.api.tasks.bundling.Tar; -public class PythonPexDistributionPlugin extends PythonBasePlugin { - - public static final String TASK_BUILD_WHEELS = "buildWheels"; - public static final String TASK_BUILD_PROJECT_WHEEL = "buildProjectWheel"; - public static final String TASK_BUILD_PEX = "buildPex"; - public static final String TASK_PACKAGE_DEPLOYABLE = "packageDeployable"; +public class PythonPexDistributionPlugin extends PythonContainerPlugin { @Override public void applyTo(final Project project) { + final PythonExtension pythonExtension = ExtensionUtils.getPythonExtension(project); - project.getPlugins().apply(PythonPlugin.class); - final PythonExtension extension = ExtensionUtils.getPythonExtension(project); - ExtensionUtils.maybeCreatePexExtension(project); - ExtensionUtils.maybeCreateWheelExtension(project); - final DeployableExtension deployableExtension = ExtensionUtils.maybeCreateDeployableExtension(project); - - project.getDependencies().add(StandardTextValues.CONFIGURATION_BUILD_REQS.getValue(), - extension.forcedVersions.get("pex")); - - /* - * Build wheels. - * - * We need wheels to build pex files. - */ - project.getTasks().create(TASK_BUILD_WHEELS, BuildWheelsTask.class, task -> { - task.dependsOn(project.getTasks().getByName(StandardTextValues.TASK_INSTALL_PROJECT.getValue())); - task.setInstallFileCollection(project.getConfigurations().getByName("python")); - }); - - project.getTasks().create(TASK_BUILD_PROJECT_WHEEL, BuildWheelsTask.class, task -> { - task.dependsOn(project.getTasks().getByName(TASK_BUILD_WHEELS)); - task.setInstallFileCollection(project.files(project.file(project.getProjectDir()))); - task.setEnvironment(extension.pythonEnvironmentDistgradle); - }); - - project.getTasks().create(TASK_BUILD_PEX, BuildPexTask.class, - task -> task.dependsOn(project.getTasks().getByName(TASK_BUILD_PROJECT_WHEEL))); - - Tar packageDeployable = project.getTasks().create(TASK_PACKAGE_DEPLOYABLE, Tar.class, tar -> { - tar.setCompression(Compression.GZIP); - tar.setBaseName(project.getName()); - tar.setExtension("tar.gz"); - tar.from(deployableExtension.getDeployableBuildDir()); - }); - packageDeployable.dependsOn(project.getTasks().getByName(TASK_BUILD_PEX)); - - project.getArtifacts().add(StandardTextValues.CONFIGURATION_DEFAULT.getValue(), packageDeployable); + // Even though this is the default, explicit is better than implicit. + pythonExtension.setContainer("pex"); + super.applyTo(project); } } diff --git a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/plugin/PythonWebApplicationPlugin.java b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/plugin/PythonWebApplicationPlugin.java index 14c9b80f..fb09c9f3 100644 --- a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/plugin/PythonWebApplicationPlugin.java +++ b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/plugin/PythonWebApplicationPlugin.java @@ -15,6 +15,7 @@ */ package com.linkedin.gradle.python.plugin; +import com.linkedin.gradle.python.util.ApplicationContainer; import com.linkedin.gradle.python.extension.DeployableExtension; import com.linkedin.gradle.python.tasks.BuildWebAppTask; import com.linkedin.gradle.python.util.ExtensionUtils; @@ -24,7 +25,6 @@ public class PythonWebApplicationPlugin extends PythonBasePlugin { - public static final String TASK_BUILD_PEX = "buildPex"; public static final String TASK_BUILD_WEB_APPLICATION = "buildWebApplication"; public static final String TASK_PACKAGE_WEB_APPLICATION = "packageWebApplication"; public static final String GUNICORN_ENTRYPOINT = "gunicorn.app.wsgiapp:run"; @@ -34,7 +34,7 @@ public class PythonWebApplicationPlugin extends PythonBasePlugin { @Override public void applyTo(final Project project) { - project.getPlugins().apply(PythonPexDistributionPlugin.class); + project.getPlugins().apply(PythonContainerPlugin.class); final DeployableExtension deployableExtension = ExtensionUtils.maybeCreateDeployableExtension(project); @@ -47,13 +47,13 @@ public void applyTo(final Project project) { */ project.getTasks().create(TASK_BUILD_WEB_APPLICATION, BuildWebAppTask.class, task -> { task.setDescription("Build a web app, by default using gunicorn, but it's configurable."); - task.dependsOn(TASK_BUILD_PEX); + task.mustRunAfter(ApplicationContainer.TASK_ASSEMBLE_CONTAINERS); task.setExecutable(new File(deployableExtension.getDeployableBinDir(), GUNICORN)); task.setEntryPoint(GUNICORN_ENTRYPOINT); }); // Make packaging task wait on this task so that gunicorn is packed into the app. - project.getTasks().getByName(PythonPexDistributionPlugin.TASK_PACKAGE_DEPLOYABLE) + project.getTasks().getByName(ApplicationContainer.TASK_PACKAGE_DEPLOYABLE) .dependsOn(project.getTasks().getByName(TASK_BUILD_WEB_APPLICATION)); /* @@ -65,6 +65,7 @@ public void applyTo(final Project project) { task.setEnabled(false); }); - project.getTasks().getByName(PythonPexDistributionPlugin.TASK_PACKAGE_DEPLOYABLE).dependsOn(project.getTasks().getByName(TASK_PACKAGE_WEB_APPLICATION)); + project.getTasks().getByName(ApplicationContainer.TASK_PACKAGE_DEPLOYABLE) + .dependsOn(project.getTasks().getByName(TASK_PACKAGE_WEB_APPLICATION)); } } diff --git a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/tasks/BuildPexTask.java b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/tasks/BuildPexTask.java index 91b1c215..a195a9b9 100644 --- a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/tasks/BuildPexTask.java +++ b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/tasks/BuildPexTask.java @@ -22,7 +22,7 @@ import com.linkedin.gradle.python.util.ExtensionUtils; import com.linkedin.gradle.python.util.internal.pex.FatPexGenerator; import com.linkedin.gradle.python.util.internal.pex.ThinPexGenerator; -import com.linkedin.gradle.python.util.pex.DefaultPexEntryPointTemplateProvider; +import com.linkedin.gradle.python.util.zipapp.DefaultPexEntryPointTemplateProvider; import com.linkedin.gradle.python.util.zipapp.EntryPointTemplateProvider; import org.apache.commons.io.FileUtils; import org.gradle.api.DefaultTask; @@ -56,7 +56,7 @@ * The pexOptions allow passing of additional options to the pex command, such as '--pre' to allow pre-release packages. * This is useful because the default behavior changed in pex-1.2.0 without bumping of major version. */ -public class BuildPexTask extends DefaultTask implements FailureReasonProvider { +public class BuildPexTask extends DefaultTask implements FailureReasonProvider, PythonContainerTask { private Map additionalProperties; private EntryPointTemplateProvider templateProvider = new DefaultPexEntryPointTemplateProvider(); diff --git a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/tasks/BuildWebAppTask.java b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/tasks/BuildWebAppTask.java index db591ba5..243cc545 100644 --- a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/tasks/BuildWebAppTask.java +++ b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/tasks/BuildWebAppTask.java @@ -15,13 +15,15 @@ */ package com.linkedin.gradle.python.tasks; +import com.linkedin.gradle.python.PythonExtension; import com.linkedin.gradle.python.extension.PexExtension; import com.linkedin.gradle.python.util.ExtensionUtils; import com.linkedin.gradle.python.util.PexFileUtil; import com.linkedin.gradle.python.util.entrypoint.EntryPointWriter; import com.linkedin.gradle.python.util.internal.pex.FatPexGenerator; -import com.linkedin.gradle.python.util.internal.pex.ThinPexGenerator; -import org.apache.commons.io.IOUtils; +import com.linkedin.gradle.python.util.internal.zipapp.DefaultTemplateProviderOptions; +import com.linkedin.gradle.python.util.zipapp.DefaultWebappEntryPointTemplateProvider; +import com.linkedin.gradle.python.util.zipapp.EntryPointTemplateProvider; import org.gradle.api.DefaultTask; import org.gradle.api.Project; import org.gradle.api.tasks.Input; @@ -41,6 +43,7 @@ public class BuildWebAppTask extends DefaultTask { private File executable; private String entryPoint; private List pexOptions = new ArrayList<>(); + private EntryPointTemplateProvider templateProvider = new DefaultWebappEntryPointTemplateProvider(); @Input @Optional @@ -65,18 +68,22 @@ public String getEntryPoint() { @TaskAction public void buildWebapp() throws IOException, ClassNotFoundException { Project project = getProject(); - PexExtension pexExtension = ExtensionUtils.getPythonComponentExtension(project, PexExtension.class); + PexExtension extension = ExtensionUtils.getPythonComponentExtension(project, PexExtension.class); + PythonExtension pythonExtension = ExtensionUtils.getPythonExtension(project); - if (pexExtension.isFat()) { + if (extension.isFat()) { new FatPexGenerator(project, pexOptions).buildEntryPoint( PexFileUtil.createFatPexFilename(executable.getName()), entryPoint, null); } else { - HashMap options = new HashMap<>(); - options.put("entryPoint", entryPoint); - options.put("realPex", PexFileUtil.createThinPexFilename(project.getName())); - String template = IOUtils.toString( - ThinPexGenerator.class.getResourceAsStream("/templates/pex_non_cli_entrypoint.sh.template")); - new EntryPointWriter(project, template).writeEntryPoint(executable, options); + HashMap substitutions = new HashMap<>(); + substitutions.put("entryPoint", entryPoint); + substitutions.put("realPex", PexFileUtil.createThinPexFilename(project.getName())); + substitutions.put("toolName", project.getName()); + String template = templateProvider.retrieveTemplate( + // Use the shell wrapper for web applications. + new DefaultTemplateProviderOptions(project, pythonExtension, entryPoint), + false); + new EntryPointWriter(project, template).writeEntryPoint(executable, substitutions); } } @@ -87,4 +94,15 @@ public void setExecutable(File executable) { public void setEntryPoint(String entryPoint) { this.entryPoint = entryPoint; } + + @Input + @Optional + public EntryPointTemplateProvider getTemplateProvider() { + return templateProvider; + } + + public void setTemplateProvider(EntryPointTemplateProvider templateProvider) { + this.templateProvider = templateProvider; + } + } diff --git a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/tasks/NoopBuildPexTask.java b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/tasks/NoopBuildPexTask.java new file mode 100644 index 00000000..58599acf --- /dev/null +++ b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/tasks/NoopBuildPexTask.java @@ -0,0 +1,46 @@ +/* + * Copyright 2016 LinkedIn Corp. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 com.linkedin.gradle.python.tasks; + +import org.gradle.api.DefaultTask; +import org.gradle.api.Task; +import org.gradle.api.logging.Logger; +import org.gradle.api.logging.Logging; +import org.gradle.api.tasks.TaskAction; + + +public class NoopBuildPexTask extends DefaultTask implements PythonContainerTask { + private static final Logger LOG = Logging.getLogger(NoopBuildPexTask.class); + private static final String DISABLED_MESSAGE = + "######################### WARNING ##########################\n" + + "The buildPex task has been deprecated.\n" + + "Please use the assembleContainers task instead.\n" + + "############################################################"; + + // This is used to suppress the warning when PythonContainerPlugin plumbs + // this task into the task hierarchy, which isn't user code. + public boolean suppressWarning = false; + + @TaskAction + public void noOp() { } + + public Task dependsOn(Object... paths) { + if (!suppressWarning) { + LOG.warn(DISABLED_MESSAGE); + } + return super.dependsOn(paths); + } +} diff --git a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/tasks/PythonContainerTask.java b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/tasks/PythonContainerTask.java new file mode 100644 index 00000000..705fe5b2 --- /dev/null +++ b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/tasks/PythonContainerTask.java @@ -0,0 +1,23 @@ +/* + * Copyright 2016 LinkedIn Corp. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 com.linkedin.gradle.python.tasks; + +import org.gradle.api.Task; + + +public interface PythonContainerTask extends Task { +} diff --git a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/ApplicationContainer.java b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/ApplicationContainer.java new file mode 100644 index 00000000..51d8520b --- /dev/null +++ b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/ApplicationContainer.java @@ -0,0 +1,48 @@ +/* + * Copyright 2016 LinkedIn Corp. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 com.linkedin.gradle.python.util; + +import org.gradle.api.Project; + + +public interface ApplicationContainer { + public static final String TASK_BUILD_PROJECT_WHEEL = "buildProjectWheel"; + public static final String TASK_BUILD_WHEELS = "buildWheels"; + public static final String TASK_PACKAGE_DEPLOYABLE = "packageDeployable"; + public static final String TASK_ASSEMBLE_CONTAINERS = "assembleContainers"; + + /** + * Add any extensions that your container exposes. This runs when the + * plugin is applied. + */ + public void addExtensions(Project project); + + /** + * Add any additional dependencies your application container format + * needs. This runs after the project is evaluated. + */ + public void addDependencies(Project project); + + /** + * Create any additional tasks the extension needs. Such tasks must + * implement the PythonContainerTask interface. All tasks implementing + * this interface will automatically be inserted into the task dependency + * graph. Implementers of this interface should *not* explicitly add + * their tasks to the graph. This runs after the project is evaluated. + */ + public void makeTasks(Project project); +} diff --git a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/ExtensionUtils.java b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/ExtensionUtils.java index 7eec2e6a..e8497870 100644 --- a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/ExtensionUtils.java +++ b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/ExtensionUtils.java @@ -28,7 +28,7 @@ public class ExtensionUtils { private ExtensionUtils() { - //private constructor for util class + // Private constructor for utils class. } public static T maybeCreate(Project project, String name, Class type, Object... args) { diff --git a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/extension/ZipappExtension.java b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/ZipappContainer.java similarity index 89% rename from pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/extension/ZipappExtension.java rename to pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/ZipappContainer.java index 6ef856dd..22e3a9f8 100644 --- a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/extension/ZipappExtension.java +++ b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/ZipappContainer.java @@ -13,10 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.linkedin.gradle.python.extension; +package com.linkedin.gradle.python.util; - -public interface ZipappExtension { +public interface ZipappContainer extends ApplicationContainer { /** * @return when true, then skinny pex's will be used. */ diff --git a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/entrypoint/EntryPointWriter.java b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/entrypoint/EntryPointWriter.java index f9e10a92..195a6bc3 100644 --- a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/entrypoint/EntryPointWriter.java +++ b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/entrypoint/EntryPointWriter.java @@ -20,7 +20,7 @@ import org.apache.commons.io.FileUtils; import org.gradle.api.Project; import com.linkedin.gradle.python.extension.CliExtension; -import com.linkedin.gradle.python.extension.ZipappExtension; +import com.linkedin.gradle.python.util.ZipappContainer; import java.io.File; import java.io.IOException; @@ -36,7 +36,7 @@ public EntryPointWriter(Project project, String template) { this.template = template; this.isCliTool = ExtensionUtils.findPythonComponentExtension(project, CliExtension.class) != null; - this.isZipapp = ExtensionUtils.findPythonComponentExtension(project, ZipappExtension.class) != null; + this.isZipapp = ExtensionUtils.findPythonComponentExtension(project, ZipappContainer.class) != null; } public void writeEntryPoint(File location, Map properties) throws IOException, ClassNotFoundException { diff --git a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/pex/DefaultPexEntryPointTemplateProvider.java b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/zipapp/DefaultPexEntryPointTemplateProvider.java similarity index 68% rename from pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/pex/DefaultPexEntryPointTemplateProvider.java rename to pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/zipapp/DefaultPexEntryPointTemplateProvider.java index f967d9ce..52ab65b8 100644 --- a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/pex/DefaultPexEntryPointTemplateProvider.java +++ b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/zipapp/DefaultPexEntryPointTemplateProvider.java @@ -13,13 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.linkedin.gradle.python.util.pex; +package com.linkedin.gradle.python.util.zipapp; import com.linkedin.gradle.python.PythonExtension; import com.linkedin.gradle.python.extension.CliExtension; import com.linkedin.gradle.python.util.ExtensionUtils; -import com.linkedin.gradle.python.util.zipapp.EntryPointTemplateProvider; -import com.linkedin.gradle.python.util.zipapp.TemplateProviderOptions; import org.apache.commons.io.IOUtils; import java.io.IOException; @@ -30,10 +28,10 @@ public class DefaultPexEntryPointTemplateProvider implements EntryPointTemplateP public String retrieveTemplate(TemplateProviderOptions options, boolean isPythonWrapper) throws IOException { PythonExtension extension = options.getExtension(); CliExtension cliExtension = ExtensionUtils.findPythonComponentExtension(extension, CliExtension.class); - if (cliExtension != null && isPythonWrapper) { - return IOUtils.toString(DefaultPexEntryPointTemplateProvider.class.getResourceAsStream("/templates/pex_cli_entrypoint.py.template")); - } else { - return IOUtils.toString(DefaultPexEntryPointTemplateProvider.class.getResourceAsStream("/templates/pex_non_cli_entrypoint.sh.template")); - } + String template = (cliExtension != null && isPythonWrapper) + ? "/templates/pex_cli_entrypoint.py.template" + : "/templates/pex_non_cli_entrypoint.sh.template"; + + return IOUtils.toString(DefaultPexEntryPointTemplateProvider.class.getResourceAsStream(template)); } } diff --git a/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/zipapp/DefaultWebappEntryPointTemplateProvider.java b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/zipapp/DefaultWebappEntryPointTemplateProvider.java new file mode 100644 index 00000000..6b0b4f84 --- /dev/null +++ b/pygradle-plugin/src/main/groovy/com/linkedin/gradle/python/util/zipapp/DefaultWebappEntryPointTemplateProvider.java @@ -0,0 +1,37 @@ +/* + * Copyright 2016 LinkedIn Corp. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 com.linkedin.gradle.python.util.zipapp; + +import com.linkedin.gradle.python.PythonExtension; +import com.linkedin.gradle.python.extension.CliExtension; +import com.linkedin.gradle.python.util.ExtensionUtils; +import org.apache.commons.io.IOUtils; + +import java.io.IOException; + +public class DefaultWebappEntryPointTemplateProvider implements EntryPointTemplateProvider { + + @Override + public String retrieveTemplate(TemplateProviderOptions options, boolean isPythonWrapper) throws IOException { + PythonExtension extension = options.getExtension(); + CliExtension cliExtension = ExtensionUtils.findPythonComponentExtension(extension, CliExtension.class); + String template = (cliExtension != null && isPythonWrapper) + ? "/templates/pex_cli_entrypoint.py.template" + : "/templates/pex_non_cli_entrypoint.sh.template"; + + return IOUtils.toString(DefaultPexEntryPointTemplateProvider.class.getResourceAsStream(template)); + } +} diff --git a/pygradle-plugin/src/test/groovy/com/linkedin/gradle/python/PythonExtensionTest.groovy b/pygradle-plugin/src/test/groovy/com/linkedin/gradle/python/PythonExtensionTest.groovy index 6a5a8eb7..e49cfcc9 100644 --- a/pygradle-plugin/src/test/groovy/com/linkedin/gradle/python/PythonExtensionTest.groovy +++ b/pygradle-plugin/src/test/groovy/com/linkedin/gradle/python/PythonExtensionTest.groovy @@ -15,12 +15,26 @@ */ package com.linkedin.gradle.python +import com.linkedin.gradle.python.extension.PexExtension import com.linkedin.gradle.python.extension.PythonDetailsFactory +import com.linkedin.gradle.python.util.ApplicationContainer +import org.gradle.api.Project import org.gradle.testfixtures.ProjectBuilder import spock.lang.Specification import java.nio.file.Paths + +class TestableApplicationContainer implements ApplicationContainer { + public void addExtensions(Project project) { + } + public void addDependencies(Project project) { + } + public void makeTasks(Project project) { + } +} + + class PythonExtensionTest extends Specification { def project = new ProjectBuilder().build() @@ -96,4 +110,26 @@ class PythonExtensionTest extends Specification { noExceptionThrown() } + def 'play with the container extension'() { + def settings = new PythonExtension(project) + + when: + settings.container = null + + then: + PexExtension.isInstance(settings.applicationContainer) + + when: + settings.container = 'bogus' + + then: + settings.applicationContainer == null + + when: + settings.containers.put('test', new TestableApplicationContainer()) + settings.container = 'test' + + then: + TestableApplicationContainer.isInstance(settings.applicationContainer) + } } diff --git a/pygradle-plugin/src/test/groovy/com/linkedin/gradle/python/plugin/PythonPexDistributionPluginTest.groovy b/pygradle-plugin/src/test/groovy/com/linkedin/gradle/python/plugin/PythonPexDistributionPluginTest.groovy index 37423014..aebfaf6f 100644 --- a/pygradle-plugin/src/test/groovy/com/linkedin/gradle/python/plugin/PythonPexDistributionPluginTest.groovy +++ b/pygradle-plugin/src/test/groovy/com/linkedin/gradle/python/plugin/PythonPexDistributionPluginTest.groovy @@ -19,12 +19,13 @@ package com.linkedin.gradle.python.plugin import org.gradle.testfixtures.ProjectBuilder import spock.lang.Specification + class PythonPexDistributionPluginTest extends Specification { - def 'can apply python pex plugin resource'() { - when: - def project = new ProjectBuilder().build() - then: - project.plugins.apply('com.linkedin.python-pex') - } + def 'can apply python pex plugin resource'() { + when: + def project = new ProjectBuilder().build() + then: + project.plugins.apply('com.linkedin.python-pex') + } }