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 getters and setters for each option #190

Merged
merged 4 commits into from
Apr 3, 2023

Conversation

melissalinkert
Copy link
Member

As demonstrated by the new testOptionsAPI test, this should allow NGFF-Converter and other applications to configure the converter without constructing a list of command line arguments.

Prompted by glencoesoftware/NGFF-Converter#39 and a discussion with @erindiel, @muhanadz, @sbesson, and @emilroz.

The diff looks like a lot, but really all this is doing is:

  • adding a getter for each option's internal variable
  • adding a setter for each option's internal variable, with some minimal input validation
  • moving the picocli @Option and @Parameter annotations from the variables to the setters
  • adding a test that uses the new setters/getters instead of command line arguments

I had hoped that there was a nicer way to connect picocli options to corresponding API methods, but as far as I can tell this is the least bad approach. Converter is getting to be quite large with these changes, so for a future PR it may be worth refactoring.

It's probably worth double-checking bioformats2raw --help and a few conversions with options you would normally use (or recommend to others). If there are additional unit tests that would be helpful, let me know and I can add them.

I'd expect this PR to conflict with #172, but this PR is higher priority.

As demonstrated by the new 'testOptionsAPI' test, this should allow
NGFF-Converter and other applications to configure the converter
without constructing a list of command line arguments.
Copy link
Member

@sbesson sbesson left a comment

Choose a reason for hiding this comment

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

Generally, the spirit of this PR goes alongside recent improvements in #168 as the usage of this library goes beyond the initial scope of a standalone command-line converter and evolves towards an API that can be used by downstream component like NGFF-Converter.

I had separately briefly investigated the picocli built-in API to introspect and manipulate options but it's either limited or requires to use the internal model which sounds very brittle. In that sense, exposing a proper API allowing to set and retrieve conversion parameters and chain steps together sounds like the correct approach.

As noted in the description, the primary downside of this PR is the increase in terms of lines of code especially as every new property requires a private field, a getter and a setter. There are also different schools of thoughts on the different strateg an annotating public fields would definitely be the change with the least amount of code.
One advantage of the getter/setter approach quickly mentioned in the description is the ability to add validation logic. Should logging warning be emitted when some input does not match the setter condition and is ignored?

In terms of functional testing, I tried to combine this together with glencoesoftware/raw2ometiff#97 and consume then within NGFF-Converter which is the primary driver for this features, more specifically glencoesoftware/NGFF-Converter#39. After building and publishing both libraries, NGFF-Converter was built with the following diff

diff --git a/build.gradle b/build.gradle
index 5b3b5a0..15b6fff 100644
--- a/build.gradle
+++ b/build.gradle
@@ -6,8 +6,8 @@ plugins {
 
 version = '1.1.4'
 String bfversion = "6.12.0"
-String b2rversion = "0.6.1"
-String r2oversion = "0.4.1"
+String b2rversion = "0.7.0-SNAPSHOT"
+String r2oversion = "0.5.0-SNAPSHOT"
 
 mainClassName = 'com.glencoesoftware.convert.Launcher'
 applicationName = 'NGFF-Converter'
diff --git a/src/main/java/com/glencoesoftware/convert/ConverterTask.java b/src/main/java/com/glencoesoftware/convert/ConverterTask.java
index 9608df7..2de9bdd 100644
--- a/src/main/java/com/glencoesoftware/convert/ConverterTask.java
+++ b/src/main/java/com/glencoesoftware/convert/ConverterTask.java
@@ -9,6 +9,7 @@ package com.glencoesoftware.convert;
 
 import ch.qos.logback.classic.Level;
 import com.glencoesoftware.bioformats2raw.Converter;
+import com.glencoesoftware.bioformats2raw.ZarrCompression;
 import com.glencoesoftware.pyramid.PyramidFromDirectoryWriter;
 import javafx.application.Platform;
 import javafx.concurrent.Task;
@@ -49,9 +50,7 @@ class ConverterTask extends Task<Integer> {
         RunnerExecutionExceptionHandler runHandler = new RunnerExecutionExceptionHandler();
         int count = 0;
         for (IOPackage job : inputFileList.getItems()) {
-            CommandLine runner = new CommandLine(new Converter());
-            runner.setParameterExceptionHandler(paramHandler);
-            runner.setExecutionExceptionHandler(runHandler);
+            Converter ngffConverter = new Converter();
             File in = job.fileIn;
             File out;
             if (this.interrupted || job.status == COMPLETED || job.status == ERROR) {
@@ -75,20 +74,26 @@ class ConverterTask extends Task<Integer> {
                     temporaryStorage = job.fileOut.getParent();
                 }
                 out = new File(Paths.get(temporaryStorage, UUID.randomUUID() + ".zarr").toString());
-                Set<String> validArgs = runner.getCommandSpec().optionsMap().keySet();
-                List<String> argsToUse = args.stream().filter(
-                        (arg) -> validArgs.contains(arg.split("=")[0])).toList();
-                params = new ArrayList<>(argsToUse);
+                // Set<String> validArgs = runner.getCommandSpec().optionsMap().keySet();
+                // List<String> argsToUse = args.stream().filter(
+                //         (arg) -> validArgs.contains(arg.split("=")[0])).toList();
+                // params = new ArrayList<>(argsToUse);
             } else {
                 out = job.fileOut;
-                params = new ArrayList<>(args);
+                // params = new ArrayList<>(args);
             }
-
             // Construct args list
+            params = new ArrayList<>(args);
             params.add(0, out.getAbsolutePath());
             params.add(0, in.getAbsolutePath());
             String[] fullParams = params.toArray(new String[args.size()]);
-
+            
+            ngffConverter.setInputPath(Paths.get(in.getAbsolutePath()));
+            ngffConverter.setOutputPath(out.getAbsolutePath());
+            ngffConverter.setCompression(ZarrCompression.raw);
+            CommandLine ngffRunner = new CommandLine(ngffConverter);
+            ngffRunner.setParameterExceptionHandler(paramHandler);
+            ngffRunner.setExecutionExceptionHandler(runHandler);
 
             int result;
             if (in.getAbsolutePath().endsWith(".zarr")) {
@@ -96,24 +101,28 @@ class ConverterTask extends Task<Integer> {
                 result = 0;
                 out = job.fileIn;
             } else {
-                LOGGER.info("Executing bioformats2raw with args " + Arrays.toString(fullParams));
-                result = runner.execute(fullParams);
+                // LOGGER.info("Executing bioformats2raw with args " + Arrays.toString(fullParams));
+                result = ngffRunner.execute(fullParams);
             }

              if (result == 0 && job.outputMode == PrimaryController.OutputMode.TIFF)  {
                 LOGGER.info("NGFF intermediate generated, converting to TIFF");
-                CommandLine converter = new CommandLine(new PyramidFromDirectoryWriter());
-                converter.setParameterExceptionHandler(paramHandler);
-                converter.setExecutionExceptionHandler(runHandler);
-                Set<String> validConverterArgs = converter.getCommandSpec().optionsMap().keySet();
-                List<String> convertArgs = args.stream().filter(
-                        (arg) -> validConverterArgs.contains(arg.split("=")[0])).toList();
-                ArrayList<String> convertParams = new ArrayList<>(convertArgs);
+                PyramidFromDirectoryWriter ometiffConverter = new PyramidFromDirectoryWriter();
+                ometiffConverter.setInputPath(Paths.get(out.getAbsolutePath()));
+                ometiffConverter.setOutputPath(Paths.get(job.fileOut.getAbsolutePath()));
+                ometiffConverter.setCompression("jpeg");
+                // Set<String> validConverterArgs = converter.getCommandSpec().optionsMap().keySet();
+                // List<String> convertArgs = args.stream().filter(
+                //         (arg) -> validConverterArgs.contains(arg.split("=")[0])).toList();
+                ArrayList<String> convertParams = new ArrayList<>();
                 convertParams.add(0, job.fileOut.getAbsolutePath());
                 convertParams.add(0, out.getAbsolutePath());
                 String[] phase2Params = convertParams.toArray(new String[0]);
-                LOGGER.info("Executing raw2ometiff with args " + Arrays.toString(phase2Params));
-                result = converter.execute(phase2Params);
+                // LOGGER.info("Executing raw2ometiff with args " + Arrays.toString(phase2Params));
+                CommandLine ometiffRunner = new CommandLine(ometiffConverter);
+                ometiffRunner.setParameterExceptionHandler(paramHandler);
+                ometiffRunner.setExecutionExceptionHandler(runHandler);
+                result = ometiffRunner.execute(phase2Params);
                 LOGGER.info("Cleaning up intermediate files");
                 if (out != job.fileIn) {
                     FileUtils.deleteDirectory(out);

The tests demonstrate the usage of the Converter callable directly using the setter/getter. Howeer these setters do not seem to be recognized when wrapping the class within a CommandLine API:

  • I received a picocli.CommandLine$MissingParameterException: Missing required parameters: '<inputPath>', '<outputPath>' and needed to pass the input/output as a string array like previously
  • the compression argument were also ignored
    Does that mean CommandLine should exclusively be used with a command-line utility usage in mind and the converter should use the callable directly?

Additionally exposing the setters as APIs reveals some inconsistencies across the converters. Listing a few of these inconsistencies below and we can decide for each of them to address them immediately, discard them and/or capture them as issues:

  • setInputPath/setOutputPath use Path as input except for Converter.setOutputPath which is a String
  • setCompression takes an enum as input for Converter but a string for PyramidFromDirectoryWriter
  • PyramidFromDirectoryWriter uses setCompressionQuality which could be likely be extended to support additional options like Converter.setCompressionProperties

Matches getOutputPath/setOutputPath, and allows for supporting other
input locations in the future if we choose.
@melissalinkert
Copy link
Member Author

Does that mean CommandLine should exclusively be used with a command-line utility usage in mind and the converter should use the callable directly?

Discussed earlier today, but to summarize, CommandLine should not be used when using setters to populate the conversion options. As in the unit test, I'd expect this to behave correctly:

Converter converter = new Converter();
// call whichever setters
converter.call();

setInputPath/setOutputPath use Path as input except for Converter.setOutputPath which is a String

577e920 uses String for both input and output (a similar change in glencoesoftware/raw2ometiff#97 is forthcoming). String was initially used for the output path to allow for writing to s3, so as discussed using String everywhere is probably the most flexible approach.

The setCompression/setCompressionQuality comments are for glencoesoftware/raw2ometiff#97, so I'll update separately there.

Copy link
Member

@sbesson sbesson left a comment

Choose a reason for hiding this comment

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

Retested with the latest changes in this PR as well as glencoesoftware/raw2ometiff#97 and

diff --git a/build.gradle b/build.gradle
index 5b3b5a0..15b6fff 100644
--- a/build.gradle
+++ b/build.gradle
@@ -6,8 +6,8 @@ plugins {
 
 version = '1.1.4'
 String bfversion = "6.12.0"
-String b2rversion = "0.6.1"
-String r2oversion = "0.4.1"
+String b2rversion = "0.7.0-SNAPSHOT"
+String r2oversion = "0.5.0-SNAPSHOT"
 
 mainClassName = 'com.glencoesoftware.convert.Launcher'
 applicationName = 'NGFF-Converter'
diff --git a/src/main/java/com/glencoesoftware/convert/ConverterTask.java b/src/main/java/com/glencoesoftware/convert/ConverterTask.java
index 9608df7..7600ff0 100644
--- a/src/main/java/com/glencoesoftware/convert/ConverterTask.java
+++ b/src/main/java/com/glencoesoftware/convert/ConverterTask.java
@@ -9,6 +9,7 @@ package com.glencoesoftware.convert;
 
 import ch.qos.logback.classic.Level;
 import com.glencoesoftware.bioformats2raw.Converter;
+import com.glencoesoftware.bioformats2raw.ZarrCompression;
 import com.glencoesoftware.pyramid.PyramidFromDirectoryWriter;
 import javafx.application.Platform;
 import javafx.concurrent.Task;
@@ -44,14 +45,10 @@ class ConverterTask extends Task<Integer> {
     }
 
     @Override
-    protected Integer call() throws IOException {
-        RunnerParameterExceptionHandler paramHandler = new RunnerParameterExceptionHandler();
-        RunnerExecutionExceptionHandler runHandler = new RunnerExecutionExceptionHandler();
+    protected Integer call() throws IOException, Exception {
         int count = 0;
         for (IOPackage job : inputFileList.getItems()) {
-            CommandLine runner = new CommandLine(new Converter());
-            runner.setParameterExceptionHandler(paramHandler);
-            runner.setExecutionExceptionHandler(runHandler);
+            Converter ngffConverter = new Converter();
             File in = job.fileIn;
             File out;
             if (this.interrupted || job.status == COMPLETED || job.status == ERROR) {
@@ -75,20 +72,13 @@ class ConverterTask extends Task<Integer> {
                     temporaryStorage = job.fileOut.getParent();
                 }
                 out = new File(Paths.get(temporaryStorage, UUID.randomUUID() + ".zarr").toString());
-                Set<String> validArgs = runner.getCommandSpec().optionsMap().keySet();
-                List<String> argsToUse = args.stream().filter(
-                        (arg) -> validArgs.contains(arg.split("=")[0])).toList();
-                params = new ArrayList<>(argsToUse);
             } else {
                 out = job.fileOut;
-                params = new ArrayList<>(args);
             }
 
-            // Construct args list
-            params.add(0, out.getAbsolutePath());
-            params.add(0, in.getAbsolutePath());
-            String[] fullParams = params.toArray(new String[args.size()]);
-
+            ngffConverter.setInputPath(in.getAbsolutePath());
+            ngffConverter.setOutputPath(out.getAbsolutePath());
+            ngffConverter.setCompression(ZarrCompression.raw);
 
             int result;
             if (in.getAbsolutePath().endsWith(".zarr")) {
@@ -96,24 +86,18 @@ class ConverterTask extends Task<Integer> {
                 result = 0;
                 out = job.fileIn;
             } else {
-                LOGGER.info("Executing bioformats2raw with args " + Arrays.toString(fullParams));
-                result = runner.execute(fullParams);
+                ngffConverter.call();
+                result = 0;
             }
 
             if (result == 0 && job.outputMode == PrimaryController.OutputMode.TIFF)  {
                 LOGGER.info("NGFF intermediate generated, converting to TIFF");
-                CommandLine converter = new CommandLine(new PyramidFromDirectoryWriter());
-                converter.setParameterExceptionHandler(paramHandler);
-                converter.setExecutionExceptionHandler(runHandler);
-                Set<String> validConverterArgs = converter.getCommandSpec().optionsMap().keySet();
-                List<String> convertArgs = args.stream().filter(
-                        (arg) -> validConverterArgs.contains(arg.split("=")[0])).toList();
-                ArrayList<String> convertParams = new ArrayList<>(convertArgs);
-                convertParams.add(0, job.fileOut.getAbsolutePath());
-                convertParams.add(0, out.getAbsolutePath());
-                String[] phase2Params = convertParams.toArray(new String[0]);
-                LOGGER.info("Executing raw2ometiff with args " + Arrays.toString(phase2Params));
-                result = converter.execute(phase2Params);
+                PyramidFromDirectoryWriter ometiffConverter = new PyramidFromDirectoryWriter();
+                ometiffConverter.setInputPath(out.getAbsolutePath());
+                ometiffConverter.setOutputPath(job.fileOut.getAbsolutePath());
+                ometiffConverter.setCompression("JPEG");
+                ometiffConverter.call();
+                result = 0;
                 LOGGER.info("Cleaning up intermediate files");
                 if (out != job.fileIn) {
                     FileUtils.deleteDirectory(out);

With this sequence of calls, I was able to convert a fake file to OME-NGFF and/or OME-TIFF with various compression schemes (uncompresed for Zarr, zlib or JPEG for TIFF).
From the diff above, the outstanding requirement is that in the absence of the CommandLine wrapper, we need some mechanism to communicate the conversion status. Immediate suggestion would be to update the converter class to implement Callable<Integer> and have call return the exit code.

@melissalinkert
Copy link
Member Author

I'd expect call() to throw an exception on pretty much any failed conversion, but a8ac114 now returns an exit code. Right now that is 0 for a successful conversion and -1 if version information was requested.

@melissalinkert melissalinkert requested a review from sbesson March 22, 2023 16:16
@sbesson
Copy link
Member

sbesson commented Mar 23, 2023

I am having second thoughts on the value of the Callable<Integer> change. My request was largely driven by the current implementation of NGFF-Converter which consumes the return value from the CommandLine class - see https://picocli.info/quick-guide.html#_exception_exit_codes.

Reading the documentation, a CommandLine can wrap any Callable or Runnable which do not return a result. Using exceptions to communicate failed processes is inline with the expectation of the command-line wrapper. This leaves the question of whether we would like to differentiate the output of --version from any other conversion. How do you feel about it ?

@melissalinkert
Copy link
Member Author

I don't feel particularly strongly that Callable<Integer> is necessary, but I also don't think it hurts to have extra confirmation that --version was used. I'd be slightly inclined to leave a8ac114 in place, as that gives us the option to return other error codes in the future if we want.

@@ -128,13 +128,107 @@ public class Converter implements Callable<Void> {
/** NGFF specification version.*/
public static final String NGFF_VERSION = "0.4";

private volatile Path inputPath;
private volatile String outputLocation;
Copy link
Member

Choose a reason for hiding this comment

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

Any reason to keep outputLocation or is it also worth unifying the internal variable name with the getter/setter?

Copy link
Member Author

Choose a reason for hiding this comment

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

outputPath is defined later on, and is resolved from outputLocation as either a path on disk or an s3 location. I can rename outputLocation to outputPath if you feel that's clearer, but then would need to rename the current outputPath to something else.

@sbesson sbesson self-requested a review March 27, 2023 20:41
Copy link
Member

@sbesson sbesson left a comment

Choose a reason for hiding this comment

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

Successfully retested the following parameters via command-line

  • resolutions
  • series
  • tile width/height
  • log-level
  • progress bars
  • compression

A couple of last suggestions to unify the logging behavior when invalid values are ignored in the setters.

public void setTileWidth(int width) {
if (width > 0) {
tileWidth = width;
}
Copy link
Member

Choose a reason for hiding this comment

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

Could we add a warning in case the value is ignored (similar to resolution above)?

public void setTileHeight(int height) {
if (height > 0) {
tileHeight = height;
}
Copy link
Member

Choose a reason for hiding this comment

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

Same as above

public void setChunkDepth(int depth) {
if (depth > 0) {
chunkDepth = depth;
}
Copy link
Member

Choose a reason for hiding this comment

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

Same as above

public void setMaxWorkers(int workers) {
if (workers > 0) {
maxWorkers = workers;
}
Copy link
Member

Choose a reason for hiding this comment

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

Same as above

public void setFillValue(Short tileFill) {
if (tileFill == null || (tileFill >= 0 && tileFill <= 255)) {
fillValue = tileFill;
}
Copy link
Member

Choose a reason for hiding this comment

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

Same as above

public void setMinImageSize(int min) {
if (min > 0) {
minSize = min;
}
Copy link
Member

Choose a reason for hiding this comment

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

Same as above

@melissalinkert
Copy link
Member Author

2234a9e should address all of the logging comments.

@melissalinkert melissalinkert requested a review from sbesson March 29, 2023 15:23
Copy link
Member

@sbesson sbesson left a comment

Choose a reason for hiding this comment

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

(java11) sbesson@Sebastiens-MacBook-Pro-2 bioformats2raw % ./bioformats2raw-0.7.0-SNAPSHOT/bin/bioformats2raw test.fake test.zarr -w -1 -h -1 --max_workers -1 --fill-value 512 --target-min-size  -1
2023-03-30 09:30:23,256 [main] WARN  c.g.bioformats2raw.Converter - Ignoring invalid tile width: -1
2023-03-30 09:30:23,259 [main] WARN  c.g.bioformats2raw.Converter - Ignoring invalid tile height: -1
2023-03-30 09:30:23,259 [main] WARN  c.g.bioformats2raw.Converter - Ignoring invalid worker count: -1
2023-03-30 09:30:23,260 [main] WARN  c.g.bioformats2raw.Converter - Ignoring invalid fill value (must be 0-255): 512
2023-03-30 09:30:23,260 [main] WARN  c.g.bioformats2raw.Converter - Ignoring invalid minimum image size: -1
(java11) sbesson@Sebastiens-MacBook-Pro-2 bioformats2raw % ls test.zarr/0/
0	1
(java11) sbesson@Sebastiens-MacBook-Pro-2 bioformats2raw % grep chunks  test.zarr/0/0/.zarray 
  "chunks" : [ 1, 1, 1, 512, 512 ],

@DavidStirling
Copy link
Member

Looks reasonable to me, is there any way to fetch the CLI parameter descriptions out when using API rather than CLI mode? Having access to those would save having to duplicate the text when putting together a GUI.

@melissalinkert
Copy link
Member Author

Looks reasonable to me, is there any way to fetch the CLI parameter descriptions out when using API rather than CLI mode?

This PR doesn't attempt to do anything on that front, but it's a good idea for a next step. Moving the descriptions to a resource bundle as described here:

https://picocli.info/#_description_text_in_resourcebundle
https://picocli.info/#_internationalization

might be one way to do this.

@melissalinkert melissalinkert merged commit 3e9a92e into glencoesoftware:master Apr 3, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: Done
Development

Successfully merging this pull request may close these issues.

3 participants