Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add functionality to create jar in zinc wrapper #6094

Merged
merged 11 commits into from
Jul 24, 2018
Merged

Conversation

ity
Copy link
Contributor

@ity ity commented Jul 11, 2018

Problem

Invocations of zinc do not support jars. See #6080 for details

Solution

Add functionality to the zinc wrapper to jar up the contents of _classesDirectory when an option is specified.

Copy link
Contributor

@illicitonion illicitonion left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great :) Didn't manage to plug this into the python code, but will hopefully try this out soon :)


val jarPath = Paths.get(classesDirectory.toString, settings.outputJar.toString)
val target = new JarOutputStream(Files.newOutputStream(jarPath))
val entryTime = System.currentTimeMillis()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably either have a flag to set the time to use (so that pants can set a fixed value, maybe falling back to the current time if no value is specified), or always set this to a fixed value, for determinism.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

absolutely!

FileVisitResult.CONTINUE
}

Files.walkFileTree(classesDirectory.toPath, FileVisitor)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC walkFileTree makes no guarantees about which order it visits files; we may want to manually list directory contents, sort them, and visit in sorted order, for determinism

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

@@ -57,12 +58,13 @@ case class Settings(

lazy val sources: Seq[File] = _sources map normalise

if (_classesDirectory.isEmpty && outputJar.isEmpty) {
throw new RuntimeException(
s"Either ${Settings.DestinationOpt} or ${Settings.JarDestinationOpt} option is required.")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/Either/At least one of/?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think either is accurate here.

@stuhood
Copy link
Member

stuhood commented Jul 11, 2018

Didn't manage to plug this into the python code, but will hopefully try this out soon

Unfortunately, doing that requires doing a local publish: there are instructions in the "dry-run" section here: https://www.pantsbuild.org/release_jvm.html#dry-run

1 similar comment
@stuhood
Copy link
Member

stuhood commented Jul 11, 2018

Didn't manage to plug this into the python code, but will hopefully try this out soon

Unfortunately, doing that requires doing a local publish: there are instructions in the "dry-run" section here: https://www.pantsbuild.org/release_jvm.html#dry-run

/**
* Jar the contents of output classes (settings.classesDirectory) and copy to settings.outputJar
*/
def createClassesJar(settings: Settings, log: Logger) = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be a good idea to put this in a separate object somewhere (could preserve our silly naming convention here and call it OutputUtil or something, heh).

jarPath
}

val jarPath = Paths.get(classesDirectory.toString, settings.outputJar.toString)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...I really can't believe that this is the recommended way to join paths in Java. But it is!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've found the ammonite ops library very neat for hygienic path operations (it has implicits that let you do e.g. Path(classesDirectory) / "intermediate-folder-i-just-made-up-for-this-example" / RelPath(settings.outputJar)). Its process creation mechanism isn't as useful as scala.sys.process for general use though, imho (although quite concise for synchronous process execution for testing). Not sure if I'm missing any reason that can't be used here.

*/
def createClassesJar(settings: Settings, log: Logger) = {
val classesDirectory = settings.classesDirectory
object FileVisitor extends SimpleFileVisitor[Path]() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this has mutable state inside, I'd recommend doing something like:

val jarCaptureVisitier = new SimpleFileVisitor[Path]() {
  ...
}

FileVisitResult.CONTINUE
}

Files.walkFileTree(classesDirectory.toPath, FileVisitor)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

@@ -57,12 +58,13 @@ case class Settings(

lazy val sources: Seq[File] = _sources map normalise

if (_classesDirectory.isEmpty && outputJar.isEmpty) {
throw new RuntimeException(
s"Either ${Settings.DestinationOpt} or ${Settings.JarDestinationOpt} option is required.")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think either is accurate here.

@ity
Copy link
Contributor Author

ity commented Jul 13, 2018

Didn't manage to plug this into the python code, but will hopefully try this out soon

Unfortunately, doing that requires doing a local publish: there are instructions in the "dry-run" section here: https://www.pantsbuild.org/release_jvm.html#dry-run

Just saw this and realized why my test locally wasnt working. Since we need to publish, is there any way to write an integration test for this?

Copy link
Contributor

@illicitonion illicitonion left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great!

I definitely defer to @stuhood for how to write tests here :)

@@ -121,6 +121,11 @@ object Main {
}

log.info("Compile success " + Util.timing(startTime))

// TODO(ity): if compile successful, jar the contents of classesDirectory and copy to outputJar
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drop TODO?

@ity ity changed the title WIP - Add functionality to create jar in zinc wrapper Add functionality to create jar in zinc wrapper Jul 13, 2018
Copy link
Member

@stuhood stuhood left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Ity. Please add a quick unit test and then this should be good.

FileVisitResult.CONTINUE
}
sorted.map(createJar(_))
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not explicitly call close on the JarOutputStream, but should.

// deterministic jar creation
Files.walkFileTree(classesDirectory.toPath, fileSortVisitor)

val jarPath = Paths.get(classesDirectory.toString, settings.outputJar.toString)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this will make this a relative path under the classesDirectory?

Both of these should probably be (assumed to be) absolute, so that they don't need to be located within one another.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^ as mentioned below, I think that this is still a thing... the test is currently assuming this behaviour.

/**
* Jar the contents of output classes (settings.classesDirectory) and copy to settings.outputJar
*/
def createClassesJar(settings: Settings, log: Logger) = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd recommend creating one test that sanity checks this method: to make that easier, I'd recommend replacing Settings with explicit arguments for the classesDirectory and outputJar (and then maybe make the timestamp optional).

An example unit test for this code is over here: https://github.com/pantsbuild/pants/tree/master/tests/scala/org/pantsbuild/zinc/analysis

@@ -47,7 +48,8 @@ case class Settings(
compileOrder: CompileOrder = CompileOrder.Mixed,
sbt: SbtJars = SbtJars(),
_incOptions: IncOptions = IncOptions(),
analysis: AnalysisOptions = AnalysisOptions()
analysis: AnalysisOptions = AnalysisOptions(),
creationTime: Long = System.currentTimeMillis()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't a good default for this arg: would recommend 0 (the unix epoch) or something...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if we want the jars to be deterministic (each JAR entry to have the same creation time), the default needs to be consistent for each JAR created but not the same as every other JAR created. lmk if I misunderstand

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but not the same as every other JAR created

Ideally "when" you create the jar does not matter at all. For the same inputs, creating the same jar twice (one+ second apart) should result in the exact same jar. Hence 0 here.

@stuhood
Copy link
Member

stuhood commented Jul 13, 2018

Since we need to publish, is there any way to write an integration test for this?

Only post merge, unfortunately.

@cosmicexplorer
Copy link
Contributor

Only post merge, unfortunately.

Is this a "can't be done practically for reasons" or "someone needs to make this functionality"? It seems like publishing a jar is something that would be useful to have the ability to test (I'm thinking of a mixin or something which could then be extended for e.g. internal tasks which need to test publishing a jar for some reason). Is there context I'm missing?

Copy link
Contributor

@baroquebobcat baroquebobcat left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've got one question below.

I'd also like to see a test that asserts things about the resulting jar contents. I'd be ok with deferring adding automated testing to the python side, but I have a strong preference for having a scala test if possible.


val fileSortVisitor = new SimpleFileVisitor[Path]() {
override def preVisitDirectory(path: Path, attrs: BasicFileAttributes): FileVisitResult = {
sorted.add(path)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these paths relative to the classDirectory? If they're absolute, they might create the wrong entries in the jar file because they're used as the path for the JarEntry

Copy link
Member

@stuhood stuhood left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @ity !

I think that there is one remaining issue around relative paths. Should update the test to use two separate temp dirs before merging.

* @return
*/
def existsClass(jarPath: Path, clazz: String): Boolean = {
val jis = new JarInputStream(Files.newInputStream(jarPath))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This stream needs to be closed (probably via try { .. } finally { .. }).

}
"JarCreationWithClasses" should {
"succeed when input classes are provided" in {
IO.withTemporaryDirectory { tempDir =>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that the code and this test assume that the jar is always created under the classes directory... but that won't be the case.

Can you change this test to use two temporary directories: one containing the input classes, and another containing the output jar?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got rid of this assumption in the previous revision - added 2 separate dirs, PTAL when you can

val jarOutputPath = Paths.get(tempDirPath.toString, "spec-valid-output.jar")

OutputUtils.createJar(filePaths, jarOutputPath, System.currentTimeMillis())
OutputUtils.existsClass(jarOutputPath, tempFile.toString) must be(true)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for writing the test. Things are a bit clearer to me now.

I think createJar should take a root for the input file paths, that way the entry paths can be relative to that root.

for example, if you had an input dir like temp-dir that looked like this.

temp-dir/org/example/Clazz.class
temp-dir/org/example/inner/Clazz2.class

I think you'd want the resulting jar to contain

org/example/Clazz.class
org/example/inner/Clazz2.class

I'm not sure it does this right now.

Does that make sense? I might be missing something.

val jarOutputPath = Paths.get(tempOutputDir.toString, "spec-valid-output.jar")

OutputUtils.createJar(filePaths, jarOutputPath, System.currentTimeMillis())
OutputUtils.existsClass(jarOutputPath, tempFile.toString) must be(true)
Copy link
Member

@stuhood stuhood Jul 17, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, tempFile.toString is going to be an absolute filename, I think? In this case we're expecting literally Clazz.class.

Copy link
Contributor

@baroquebobcat baroquebobcat left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for updating the test and clearing up the relative path thing. It looks good to me now, apart from a copyright year that might need updating and possibly a method rename.

@@ -0,0 +1,12 @@
# Copyright 2017 Pants project contributors (see CONTRIBUTORS.md).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: 2018


OutputUtils.createJar(tempInputDir.toString, filePaths, jarOutputPath, System.currentTimeMillis())
OutputUtils.existsClass(jarOutputPath, tempFile.toString) must be(false)
OutputUtils.existsClass(jarOutputPath, tempFile.getName) must be(true)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks 👍

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getName is only the final component of a filename, so this test would fail if the classfile were more deeply nested... it looks like the underlying code properly relativizes things though.

* Determines if a Class exists in a JAR provided.
*
* @param jarPath Absolute Path to the JAR being inspected
* @param clazz Name of the Class, the existence of which is to be inspected
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this function checks the existence of files in the jar and not the existence of classes by name. It might make sense to rename it and the clazz parameter to make that clearer.

jarEntry.setTime(jarEntryTime)

target.putNextEntry(jarEntry)
Files.copy(source, target)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm... it looks like you're properly collecting directories above (ie, collecting directories in pre-visit), but I feel like this method will fail if the source: Path is a directory? It's important to actually store directory entries in the zip file, so I think that this case needs to add the jarEntry for a directory as a directory, and then skip appending the content.

You might consider having the FileVisitor store a tuple of (path: Path, isFile: Boolean) (which can still be sorted) to avoid needing to check whether the path is a directory here.

Between this and my comment in the test, I think the test needs an update to create a classfile under a directory.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for catching this - ptal when you can :)

@ity
Copy link
Contributor Author

ity commented Jul 18, 2018

Thanks for updating the test and clearing up the relative path thing. It looks good to me now, apart from a copyright year that might need updating and possibly a method rename.

👍

Copy link
Member

@stuhood stuhood left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I led you astray on one thing, sorry! Once that's fixed, please try out integrating with zinc, and then feel free to merge!


val fileSortVisitor = new SimpleFileVisitor[Path]() {
override def preVisitDirectory(path: Path, attrs: BasicFileAttributes): FileVisitResult = {
sorted.add(path, false)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eek. Sorry. Just looked at the javadocs for this: https://docs.oracle.com/javase/7/docs/api/java/util/zip/ZipEntry.html#isDirectory()

So the boolean isn't really necessary as long as you append a / here, and then later check whether it ends with a slash. Sorry!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(...and the slash is required in order to not confuse consumers of the jar.)

@ity
Copy link
Contributor Author

ity commented Jul 19, 2018

I led you astray on one thing, sorry! Once that's fixed, please try out integrating with zinc, and then feel free to merge!

I should have read that myself really, thanks - been trying to test this locally but hitting some local caching issue with the zinc changes. will push once I have local integration tested.

@ity ity merged commit b95a3a7 into pantsbuild:master Jul 24, 2018
CMLivingston pushed a commit to CMLivingston/pants that referenced this pull request Aug 27, 2018
### Problem
Invocations of zinc do not support jars. 

### Solution
Add functionality to the zinc wrapper to jar up the contents of _classesDirectory when an option is specified.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants