diff --git a/.vscode/settings.json b/.vscode/settings.json index 57add4eb..870d1e6a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,6 +15,7 @@ "bdvp", "bigstitcher", "biop", + "caplog", "clij", "Dscijava", "flatfield", diff --git a/TESTING.md b/TESTING.md index 1ab68a0e..d7222a91 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,5 +1,15 @@ # Testing ๐Ÿงช๐Ÿงซ in Fiji / ImageJ2 +## Using ๐ŸŽญ Poetry, pytest ๐Ÿ๐Ÿ”ฌ and Python 3 for plain Python code + +The easiest way to run [`pytest`][pytest] (using Python 3) is when you're +already having a working [poetry] setup. In that case tests can simply be run by +using the `run-poetry.sh` wrapper script, for example: + +```bash +scripts/run-poetry.sh run pytest tests/test_misc.py +``` + ## Using pytest ๐Ÿ๐Ÿ”ฌ and Python 3 for plain Python code Those parts of the package that do not interact / depend on ImageJ objects can @@ -22,7 +32,7 @@ test -d "venv" || python3 -m venv venv source venv/bin/activate # install dependencies / requirements: -MOCKS_REL="0.2.0" +MOCKS_REL="0.14.0" URL_PFX="https://github.com/imcf/imcf-fiji-mocks/releases/download/v$MOCKS_REL" pip install --upgrade \ $URL_PFX/imcf_fiji_mocks-${MOCKS_REL}-py2.py3-none-any.whl \ diff --git a/poetry.lock b/poetry.lock index 9b0cf3b1..5fd0d422 100644 --- a/poetry.lock +++ b/poetry.lock @@ -137,13 +137,13 @@ test = ["pytest (>=6)"] [[package]] name = "imcf-fiji-mocks" -version = "0.14.0" +version = "0.15.0a0" description = "Mocks collection for Fiji-Python. Zero functional code." optional = false python-versions = ">=2.7" files = [ - {file = "imcf_fiji_mocks-0.14.0-py2.py3-none-any.whl", hash = "sha256:df50de4e6eb9ba9b2134b67da949f88a74b0e4129824bee37425a72c4a3c4829"}, - {file = "imcf_fiji_mocks-0.14.0.tar.gz", hash = "sha256:2c297a0a24e8e48b05772acb739bb3c57d5621ae3bd40dad37370d033aff1a7b"}, + {file = "imcf_fiji_mocks-0.15.0a0-py2.py3-none-any.whl", hash = "sha256:7fe5bf2c42480a317c8a5b917972aab274f3666be8f5f78df4e4b8d7bc794747"}, + {file = "imcf_fiji_mocks-0.15.0a0.tar.gz", hash = "sha256:799421d5bcdd77d4ffa36263a3eae052a0bd4709315871805438833f5de8d859"}, ] [[package]] @@ -345,4 +345,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.10" -content-hash = "c97e2a509e4d74b95df8d58a80c5dd69787a34099243c0b6fb5ce7d333404aad" +content-hash = "b5c585f0f534edb6fc529ea20eb74bfe978ef1270aff7a3510431b79eb7780e5" diff --git a/pyproject.toml b/pyproject.toml index 054dba3d..884551bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ version = "0.0.0" [tool.poetry.dependencies] # IMPORTANT: see the "poetry.lock.md" file when changing dependencies!!! -imcf-fiji-mocks = ">=0.14.0" +imcf-fiji-mocks = ">=0.15.0.a0" python = ">=2.7" python-micrometa = "^15.2.3" sjlogging = ">=0.5.2" diff --git a/scripts/run-poetry.sh b/scripts/run-poetry.sh index a7ecdeae..b82d5a8b 100755 --- a/scripts/run-poetry.sh +++ b/scripts/run-poetry.sh @@ -21,6 +21,19 @@ if [ -z "$RUN_ON_UNCLEAN" ]; then fi fi +TOML_STATUS=$(git status --porcelain pyproject.toml) +if [ -n "$TOML_STATUS" ]; then + echo "==== ERROR: stopping to preserve changes in 'pyproject.toml'! ====" + echo + git status pyproject.toml --porcelain + echo + echo "--------" + echo "Refusing to continue as 'pyproject.toml' would be re-set at the end" + echo "of this script. Please stash your changes and re-run the script!" + echo + exit 2 +fi + ### clean up old poetry artifacts: rm -rf dist/ diff --git a/src/imcflibs/imagej/misc.py b/src/imcflibs/imagej/misc.py index e0ba5b02..045301db 100644 --- a/src/imcflibs/imagej/misc.py +++ b/src/imcflibs/imagej/misc.py @@ -766,3 +766,110 @@ def bytes_to_human_readable(size): # If the value is larger than the largest unit, fall back to TB with # the current value (already divided accordingly). return "%3.1f %s" % (size, "TB") + + +def _is_password_style(item): # pragma: no cover (jython) + """Check if a script-parameter item is declared with `style="password"`. + + Parameters + ---------- + item : org.scijava.module.ModuleItem + The module item to check, obtained e.g. by calling `inputs()` on an + instance of `org.scijava.script.ScriptInfo`. + + Returns + ------- + bool + """ + return WidgetStyle.isStyle(item, TextWidget.PASSWORD_STYLE) + + +def save_script_parameters( + script_globals, destination, save_file_name="script_parameters.txt" +): + """Save all Fiji script parameters to a text file. + + Record all input parameters defined in the Fiji script header (e.g. + `#@ String`) to a text file such that they can be stored e.g. next to the + input data and the analysis results in order to document how a specific + processing run was executed. + + The following parameters are excluded: + + - Parameters explicitly declared with `style="password"`. + - Runtime keys (case insensitive): + - `USERNAME` + - `SJLOG` (SciJava LogService) + - `COMMAND` (SciJava CommandService) + - `RM` (RoiManager) + + Parameters + ---------- + script_globals : dict + The globals dictionary from the running Fiji instance. Must be passed + explicitly as `globals()` by the calling code. + destination : str + Directory where the script parameters file will be saved. + save_file_name : str, optional + Name of the script parameters file, by default "script_parameters.txt". + + Examples + -------- + In a Fiji script, you can call this function as follows to save the parameters: + + >>> save_script_parameters(script_globals=globals(), destination="/data") + Saved script parameters to: /data/script_parameters.txt + """ + try: + module = script_globals.get("org.scijava.script.ScriptModule") + # Access script metadata and inputs + script_info = module.getInfo() + inputs = module.getInputs() + except: + timed_log("ScriptModule inspection failed - skipping saving of parameters.") + return + + # NOTE: the two parameters are intentionally kept separate for (1) consistency + # reasons with other scripts and (b) as this allows for easier modification of just + # the output file e.g. in subsequent runs. + destination = str(destination) + out_path = os.path.join(destination, save_file_name) + + # Keys to skip explicitly + skip_keys = ["USERNAME", "SJLOG", "COMMAND", "RM"] + + saved = skipped = passwords = 0 + with open(out_path, "w") as f: + for item in script_info.inputs(): + key = item.getName() + + # Skip if any keys are in the skip list + if any(skip in key.upper() for skip in skip_keys): + log.info("Skipping parameter from skip-list: %s", key) + skipped += 1 + continue + + # Skip if parameter is declared with password style + if _is_password_style(item): + log.info("Skipping password-style parameter: %s", key) + passwords += 1 + continue + + # TODO: discuss if this approach is fine within Fiji/Jython + try: + val = inputs.get(key) + if val is None: # required for testing in CPython + raise KeyError("failure looking up value for '%s'" % key) + f.write("%s: %s\n" % (key, str(val))) + saved += 1 + except: + log.warning("Unable to fetch value for parameter: %s", key) + pass + + log.info( + "Saved %i parameters (skipped %i password-style and %i others).", + saved, + passwords, + skipped, + ) + timed_log("Saved %i script parameters to: %s" % (saved, out_path)) diff --git a/tests/interactive-imagej/save-script-parameters.py b/tests/interactive-imagej/save-script-parameters.py new file mode 100644 index 00000000..1d0dcc1e --- /dev/null +++ b/tests/interactive-imagej/save-script-parameters.py @@ -0,0 +1,20 @@ +#@ String(label="Username") USERNAME +#@ String(label="Password", style="password") PASSWORD +#@ File(label="Path for results", style="directory") outputPath +#@ Integer threshold +#@ Boolean(label="Yes/No?") choice +#@ RoiManager rm +#@ CommandService command +#@ LogService sjlog + +import os + +import imcflibs.log +from imcflibs.imagej import misc + +imcflibs.log.enable_console_logging() +log = imcflibs.log.LOG + +log.warning("Starting...") +misc.save_script_parameters(script_globals=globals(), destination=outputPath) +log.warning("Saved parameters to: %s\script_parameters.txt", outputPath) diff --git a/tests/test_misc.py b/tests/test_misc.py index cabe80e6..9f137cb7 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -1,6 +1,54 @@ """Tests for `imcflibs.imagej.misc` utility functions.""" +import logging + +from org.scijava.script import ScriptInfo, ScriptModule + +import imcflibs.imagej.misc + from imcflibs.imagej.misc import bytes_to_human_readable +from imcflibs.imagej.misc import save_script_parameters + + +PASSWORD_ITEMS = ["OMERO_PASSWD"] + + +def test_save_script_parameters_fail(caplog): + """Tests save_script_parameters with an invalid script_globals object.""" + caplog.clear() + + save_script_parameters(script_globals=None, destination="") + assert "ScriptModule inspection failed" in caplog.messages[0] + + +def test_save_script_parameters(tmp_path, monkeypatch, caplog): + """Tests save_script_parameters.""" + caplog.set_level(logging.DEBUG) + caplog.clear() + + base = tmp_path / "saved_parameters" + base.mkdir() + + def _is_password_style(item): + return item.getName() in PASSWORD_ITEMS + + monkeypatch.setattr(imcflibs.imagej.misc, "_is_password_style", _is_password_style) + + script_module = ScriptModule( + input_names=["AAA", "BBB", "OMERO_PASSWD", "SJLOG", "NOT_THERE"], + inputs={"AAA": "aaa", "BBB": "bbb", "OMERO_PASSWD": "ultra-secret"}, + ) + script_globals = {"org.scijava.script.ScriptModule": script_module} + save_script_parameters(script_globals, destination=base) + assert "Skipping parameter from skip-list" in caplog.text + assert "Skipping password-style parameter" in caplog.text + assert "Unable to fetch value for parameter: NOT_THERE" in caplog.text + assert "Saved 2 parameters (skipped 1 password-style and 1 others)." in caplog.text + assert "Saved 2 script parameters to" in caplog.text + + with open(str(base) + "/script_parameters.txt", "r") as f: + contents = f.read() + assert contents == "AAA: aaa\nBBB: bbb\n" def test_bytes_to_human_readable_simple():