From e197de9544ca7fcfd34327f621879e6a6211812e Mon Sep 17 00:00:00 2001 From: Larry Gritz Date: Thu, 10 Sep 2026 16:40:08 -0700 Subject: [PATCH 1/4] testing: runtest.py refactor, simplification, new features Refactor of runtest.py: * Enhance the workhorse run_app() that assembles command lines: add a failureok parameter and handle that logic internally; automatically call oiio_app() on the command name if it's one of the OIIO command line apps, so the caller doesn't need to. * Rewrite many of the other utilities like info_command, diff_command, maketx_command, rw_command, testtex_command, iconvert, oiiotool, to (a) use the new oiio_app() underneath and push a lot of the redundant logic into this core function; (b) use Python "f-strings" as a more compact notation for assembling the command strings. This combination greatly simpifies these functions into fairly thin wrappers around run_app(). * New run_commands that can take a string of multiple commands, split on newlines, and concatenate the run_app() result of each. Blank lines or comments (lines whose first non-whitespace charcter is `#`) are safely ignored. * Remove the "successmessage" parameter to iconvert -- we only used it in a couple places, and those can use "&& echo ..." instead. * Compare test text output vs reference in a way that tolerates trailing whitespace changes (helped resolve some tricky Windows corner cases related to the "&& echo" idiom mentioned above. So what does all this buy us, especially the run_commands()? It can transform a sequence like this, ``` command += oiiotool("-pattern constant:color=.25,.5,.75 64x64 3 -d half -o rgb64.exr") command += oiiotool("-pattern constant:color=.25,.5,.75 64x64 3 -pattern constant:color=42 64x64 1 --chnames Z --siappend -d half -o rgb-z-parts64.exr") ``` into this: ``` command += run_commands(""" oiiotool -pattern constant:color=.25,.5,.75 64x64 3 -d half -o rgb64.exr oiiotool -pattern constant:color=.25,.5,.75 64x64 3 -pattern constant:color=42 64x64 1 --chnames Z --siappend -d half -o rgb-z-parts64.exr """) ``` Nice, yeah? Within multi-line strings, we can just have shell commands exactly as how we would type them... ... and also exactly how we'd like them to appear in documentation! So this allows us to do with command line examples in the oiiotool.md documentation chapter what we have been doing with C++ and Python code examples in the other chapters -- have the code actually execute as part of our testing (so we know they always work), and have the docs incorporate specific lines by reference, without duplication. For example, in a test: ``` command += run_commands(""" oiiotool -pattern constant:color=.25,.5,.75 64x64 3 -d half -o rgb64.exr # BEGIN-oiiotool-example oiiotool -pattern constant:color=.25,.5,.75 64x64 3 -pattern constant:color=42 64x64 1 --chnames Z --siappend -d half -o rgb-z-parts64.exr # END-oiiotool-example """) ``` and then in the docs, ```{literalinclude} ../../testsuite/oiiotool/run.py :language: bash :start-after: BEGIN-oiiotool-example :end-before: END-oiiotool-example ``` and it will insert just that one line between the BEGIN/END comment pair. The line right from the test, that actually executes in the testsuite, so it never is incorrect or stale. Signed-off-by: Larry Gritz --- testsuite/jpeg-corrupt/run.py | 18 ++--- testsuite/oiiotool-copy/run.py | 6 +- testsuite/oiiotool/run.py | 10 ++- testsuite/runtest.py | 139 +++++++++++++++------------------ testsuite/webp/run.py | 2 +- 5 files changed, 83 insertions(+), 92 deletions(-) diff --git a/testsuite/jpeg-corrupt/run.py b/testsuite/jpeg-corrupt/run.py index 12ddea92a9..bb9d53b3b4 100755 --- a/testsuite/jpeg-corrupt/run.py +++ b/testsuite/jpeg-corrupt/run.py @@ -71,16 +71,14 @@ # These files have short APP1/APP2 metadata marker payloads that used to be # read past their saved-marker buffers before being ignored. Use iconvert to # a null output to force a full input read. -command += iconvert("short-exif-app1-len4.jpg out.null", - successmessage="short-exif-app1-len4-ok") -command += iconvert("short-exif-app1-len5.jpg out.null", - successmessage="short-exif-app1-len5-ok") -command += iconvert("short-icc-app2-len11.jpg out.null", - successmessage="short-icc-app2-len11-ok") -command += iconvert("short-icc-app2-len12.jpg out.null", - successmessage="short-icc-app2-len12-ok") -command += iconvert("short-icc-app2-len13.jpg out.null", - successmessage="short-icc-app2-len13-ok") +command += run_commands( +""" +iconvert short-exif-app1-len4.jpg out.null && echo short-exif-app1-len4-ok +iconvert short-exif-app1-len5.jpg out.null && echo short-exif-app1-len5-ok +iconvert short-icc-app2-len11.jpg out.null && echo short-icc-app2-len11-ok +iconvert short-icc-app2-len12.jpg out.null && echo short-icc-app2-len12-ok +iconvert short-icc-app2-len13.jpg out.null && echo short-icc-app2-len13-ok +""") # This file had corrupted IPTC data command += oiiotool("-oiioattrib imageinput:strict 1 -info -v src/corrupt-iptc-8011.jpg") diff --git a/testsuite/oiiotool-copy/run.py b/testsuite/oiiotool-copy/run.py index a2520353f1..eb7804a735 100755 --- a/testsuite/oiiotool-copy/run.py +++ b/testsuite/oiiotool-copy/run.py @@ -14,8 +14,10 @@ redirect = " >> out.txt 2>&1 " # Create some test images we need -command += oiiotool("-pattern constant:color=.25,.5,.75 64x64 3 -d half -o rgb64.exr") -command += oiiotool("-pattern constant:color=.25,.5,.75 64x64 3 -pattern constant:color=42 64x64 1 --chnames Z --siappend -d half -o rgb-z-parts64.exr") +command += run_commands(""" + oiiotool -pattern constant:color=.25,.5,.75 64x64 3 -d half -o rgb64.exr + oiiotool -pattern constant:color=.25,.5,.75 64x64 3 -pattern constant:color=42 64x64 1 --chnames Z --siappend -d half -o rgb-z-parts64.exr + """) # Test -i to read specific channels diff --git a/testsuite/oiiotool/run.py b/testsuite/oiiotool/run.py index f182ae19d2..803ce4a694 100755 --- a/testsuite/oiiotool/run.py +++ b/testsuite/oiiotool/run.py @@ -10,10 +10,12 @@ failureok = True # Create some test images we need -command += oiiotool ("--create 320x240 3 -d uint8 -o black.tif") -command += oiiotool ("--pattern constant:color=0.5,0.5,0.5 128x128 3 -d half -o grey128.exr") -command += oiiotool ("--pattern constant:color=0.5,0.5,0.5 64x64 3 -d half -o grey64.exr") -command += oiiotool ("--create 256x256 3 --fill:color=1,.5,.5 256x256 --fill:color=0,1,0 80x80+100+100 -d uint8 -o filled.tif") +command += run_commands(""" + oiiotool --create 320x240 3 -d uint8 -o black.tif + oiiotool --pattern constant:color=0.5,0.5,0.5 128x128 3 -d half -o grey128.exr + oiiotool --pattern constant:color=0.5,0.5,0.5 64x64 3 -d half -o grey64.exr + oiiotool --create 256x256 3 --fill:color=1,.5,.5 256x256 --fill:color=0,1,0 80x80+100+100 -d uint8 -o filled.tif + """) # test --autotrim diff --git a/testsuite/runtest.py b/testsuite/runtest.py index ff1971260c..96c6fbaae1 100755 --- a/testsuite/runtest.py +++ b/testsuite/runtest.py @@ -47,6 +47,7 @@ tmpdir = os.path.abspath (tmpdir) redirect = " >> out.txt " wrapper_cmd = "" +oiio_app_list = ("oiiotool", "iinfo", "idiff", "maketx", "iconvert", "igrep", "testtex", "iv") def make_relpath (path: str, start: str=os.curdir) -> str: "Wrapper around os.path.relpath which always uses '/' as the separator." @@ -170,6 +171,15 @@ def newsymlink(src: str, dst: str): # Handy functions... +# Strip trailing spaces/tabs from a line, but leave its line ending (if any) +# alone. Used to make text_diff tolerant of trailing whitespace, which can +# vary by platform (e.g. cmd.exe's `echo` bakes in a trailing space that a +# Unix shell would not) without being a meaningful difference in output. +def _rstrip_line (line: str) -> str: + ending = line[len (line.rstrip ('\r\n')):] + return line.rstrip () + ending + + # Compare two text files. Returns 0 if they are equal otherwise returns # a non-zero value and writes the differences to "diff_file". # Based on the command-line interface to difflib example from the Python @@ -179,8 +189,8 @@ def text_diff (fromfile: str, tofile: str, diff_file: str=None) -> int: try: fromdate = time.ctime (os.stat (fromfile).st_mtime) todate = time.ctime (os.stat (tofile).st_mtime) - fromlines = open (fromfile, 'r').readlines() - tolines = open (tofile, 'r').readlines() + fromlines = [_rstrip_line(l) for l in open (fromfile, 'r').readlines()] + tolines = [_rstrip_line(l) for l in open (tofile, 'r').readlines()] # if replace_relative: # tolines = replace_relative(tolines) except: @@ -206,13 +216,35 @@ def text_diff (fromfile: str, tofile: str, diff_file: str=None) -> int: return 1 -def run_app(app: str, silent: bool=False, concat: bool=True) -> str: - command = app +def run_app(app: str, silent: bool=False, failureok: bool=False, + concat: bool=True) -> str: + cmd = app.strip() + # If the command starts with the name of an OIIO app, substitute the + # full path to the built app. + words = cmd.split(maxsplit=1) + if words[0] in oiio_app_list: + cmd = oiio_app(words[0]).strip() + (" " + words[1] if len(words) > 1 else "") if not silent: - command += redirect + cmd += redirect + if failureok : + cmd += " || true " if concat: - command += " ;\n" - return command + cmd += " ;\n" + return cmd + + +# Take shell `commands`, split at newlines, adorn each with redirects, etc., +# then re-join with semicolons to make a single command. +def run_commands(commands : str, silent: bool=False, + failureok: bool=failureok, concat: bool=True) -> str : + result = "" + for line in commands.splitlines(): + cmd = line.strip() + # Skip empty lines or comments + if cmd == "" or cmd.startswith("#"): + continue + result += run_app(cmd, silent=silent, failureok=failureok, concat=concat) + return result # Construct a command that will print info for an image, appending output to @@ -230,15 +262,8 @@ def info_command (file: str, extraargs: str="", safematch: bool=False, hash: boo args += " --no-metamatch \"DateTime|Software|OriginatingProgram|ImageHistory\"" if hash : args += " --hash" - cmd = (oiio_app(info_program) + args + " " + extraargs - + " " + make_relpath(file,tmpdir)) - if not silent : - cmd += redirect - if failureok : - cmd += " || true " - if concat: - cmd += " ;\n" - return cmd + return run_app(f"{info_program} {args} {extraargs} {make_relpath(file,tmpdir)}", + silent=silent, failureok=failureok, concat=concat) # Construct a command that will compare two images, appending output to @@ -246,20 +271,12 @@ def info_command (file: str, extraargs: str="", safematch: bool=False, hash: boo # 1 LSB (8 bit) error, it's very hard to make different platforms and # compilers always match to every last floating point bit. def diff_command (fileA: str, fileB: str, extraargs: str="", silent: bool=False, concat: bool=True) -> str : - command = (oiio_app("idiff") + "-a" - + " -fail " + str(failthresh) - + " -failpercent " + str(failpercent) - + " -hardfail " + str(hardfail) - + " -allowfailures " + str(allowfailures) - + " -warn " + str(2*failthresh) - + " -warnpercent " + str(failpercent) - + " " + extraargs + " " + make_relpath(fileA,tmpdir) - + " " + make_relpath(fileB,tmpdir)) - if not silent : - command += redirect - if concat: - command += " ;\n" - return command + return run_app (f"idiff -a -fail {failthresh} -failpercent {failpercent}" + f" -hardfail {hardfail} -allowfailures {allowfailures}" + f" -warn {2*failthresh} -warnpercent {failpercent}" + f" {extraargs} {make_relpath(fileA,tmpdir)} " + f" {make_relpath(fileB,tmpdir)}", + silent=silent, concat=concat) # Construct a command that will create a texture, appending console @@ -267,14 +284,10 @@ def diff_command (fileA: str, fileB: str, extraargs: str="", silent: bool=False, def maketx_command (infile: str, outfile: str, extraargs: str="", showinfo: bool=False, showinfo_extra: str="", silent: str=False, concat: str=True) -> str : - command = (oiio_app("maketx") - + " " + make_relpath(infile,tmpdir) - + " " + extraargs - + " -o " + make_relpath(outfile,tmpdir)) - if not silent : - command += redirect - if concat: - command += " ;\n" + infile_relpath = make_relpath(infile,tmpdir) + outfile_relpath = make_relpath(outfile,tmpdir) + command = run_app(f"maketx {infile_relpath} {extraargs} -o {outfile_relpath}", + silent=silent, concat=concat) if showinfo: command += info_command (outfile, extraargs=showinfo_extra, safematch=1) return command @@ -291,62 +304,38 @@ def rw_command (dir: str, filename: str, testwrite: bool=True, use_oiiotool: boo preargs: str="", idiffextraargs: str="", output_filename: str="", safematch: bool=False, printinfo: bool=True) -> str: fn = make_relpath (dir + "/" + filename, tmpdir) + cmd = "" if printinfo : - cmd = info_command (fn, safematch=safematch) - else : - cmd = "" + cmd += info_command (fn, safematch=safematch) if output_filename == "" : output_filename = filename tool = "oiiotool" if use_oiiotool else "iconvert" if testwrite : - cmd = (cmd + oiio_app(tool) + preargs + " " + fn - + " " + extraargs + " -o " + output_filename + redirect + ";\n") - cmd = (cmd + oiio_app("idiff") + " -a " + fn - + " -fail " + str(failthresh) - + " -failpercent " + str(failpercent) - + " -hardfail " + str(hardfail) - + " -allowfailures " + str(allowfailures) - + " -warn " + str(2*failthresh) - + " " + idiffextraargs + " " + output_filename + redirect + ";\n") + cmd += run_app(f"{tool} {preargs} {fn} {extraargs} -o {output_filename}") + cmd += run_app(f"idiff -a {fn} -fail {failthresh} -failpercent {failpercent}" + + f" -hardfail {hardfail} -allowfailures {allowfailures}" + + f" -warn {2*failthresh} {idiffextraargs} {output_filename}") return cmd # Construct a command that will testtex def testtex_command (file: str, extraargs: str="", silent: bool=False, concat: bool=True) -> str: - cmd = oiio_app("testtex") + " " + file + " " + extraargs + " " - if not silent : - cmd += redirect - if concat: - cmd += " ;\n" - return cmd + return run_commands(f"testtex {file} {extraargs}", + silent=silent, failureok=failureok, concat=concat) # Construct a command that will run iconvert and append its output to out.txt def iconvert (args: str, silent: bool=False, concat: bool=True, - failureok: bool=False, successmessage: str="") -> str: - cmd = (oiio_app("iconvert") + " " + args) - if successmessage: - cmd = "(" + cmd + " && echo " + successmessage + ")" - if not silent : - cmd += redirect - if failureok : - cmd += " || true " - if concat: - cmd += " ;\n" - return cmd + failureok: bool=False) -> str: + return run_app(f"iconvert {args}", + silent=silent, failureok=failureok, concat=concat) # Construct a command that will run oiiotool and append its output to out.txt def oiiotool (args: str, silent: bool=False, concat: bool=True, failureok: bool=False) -> str: - cmd = (oiio_app("oiiotool") + " " + args) - if not silent : - cmd += redirect - if failureok : - cmd += " || true " - if concat: - cmd += " ;\n" - return cmd + return run_commands(f"oiiotool {args}", + silent=silent, failureok=failureok, concat=concat) diff --git a/testsuite/webp/run.py b/testsuite/webp/run.py index dbd1108e20..0988cdf146 100755 --- a/testsuite/webp/run.py +++ b/testsuite/webp/run.py @@ -28,7 +28,7 @@ "short-exif-len13.webp", ] for f in short_exif_files: - command += iconvert(f + " out.null", successmessage=f + "-ok") + command += iconvert(f"{f} out.null && echo {f}-ok") # Regression test: a 76-byte WebP whose VP8X header declares a 16383x16383 # canvas inconsistent with its tiny frame. Must be rejected cleanly (libwebp's From cc39d85a92f9c10e046cc9b8bf54b8108c05e851 Mon Sep 17 00:00:00 2001 From: Larry Gritz Date: Fri, 11 Sep 2026 22:52:01 -0700 Subject: [PATCH 2/4] Minor review fixes Signed-off-by: Larry Gritz --- testsuite/runtest.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/testsuite/runtest.py b/testsuite/runtest.py index 96c6fbaae1..e6ea1d5e3b 100755 --- a/testsuite/runtest.py +++ b/testsuite/runtest.py @@ -320,8 +320,8 @@ def rw_command (dir: str, filename: str, testwrite: bool=True, use_oiiotool: boo # Construct a command that will testtex def testtex_command (file: str, extraargs: str="", silent: bool=False, concat: bool=True) -> str: - return run_commands(f"testtex {file} {extraargs}", - silent=silent, failureok=failureok, concat=concat) + return run_app(f"testtex {file} {extraargs}", + silent=silent, concat=concat) # Construct a command that will run iconvert and append its output to out.txt @@ -334,8 +334,8 @@ def iconvert (args: str, silent: bool=False, concat: bool=True, # Construct a command that will run oiiotool and append its output to out.txt def oiiotool (args: str, silent: bool=False, concat: bool=True, failureok: bool=False) -> str: - return run_commands(f"oiiotool {args}", - silent=silent, failureok=failureok, concat=concat) + return run_app(f"oiiotool {args}", + silent=silent, failureok=failureok, concat=concat) From 147b226ccf81bf0114d39e73b831eb0413389249 Mon Sep 17 00:00:00 2001 From: Larry Gritz Date: Sat, 12 Sep 2026 17:51:40 -0700 Subject: [PATCH 3/4] Update testsuite/runtest.py Co-authored-by: Nathan Rusch Signed-off-by: Larry Gritz --- testsuite/runtest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testsuite/runtest.py b/testsuite/runtest.py index e6ea1d5e3b..a2406e0d6a 100755 --- a/testsuite/runtest.py +++ b/testsuite/runtest.py @@ -235,7 +235,7 @@ def run_app(app: str, silent: bool=False, failureok: bool=False, # Take shell `commands`, split at newlines, adorn each with redirects, etc., # then re-join with semicolons to make a single command. -def run_commands(commands : str, silent: bool=False, +def run_commands(commands: str, silent: bool=False, failureok: bool=failureok, concat: bool=True) -> str : result = "" for line in commands.splitlines(): From ebe1827d0b84dc442960b9a3b95dfdbfa1c5ab2e Mon Sep 17 00:00:00 2001 From: Larry Gritz Date: Sun, 13 Sep 2026 10:58:04 -0700 Subject: [PATCH 4/4] Address review comments -- minor bugs in run_app and run_commands Signed-off-by: Larry Gritz --- testsuite/runtest.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/testsuite/runtest.py b/testsuite/runtest.py index a2406e0d6a..6c51551bee 100755 --- a/testsuite/runtest.py +++ b/testsuite/runtest.py @@ -222,6 +222,8 @@ def run_app(app: str, silent: bool=False, failureok: bool=False, # If the command starts with the name of an OIIO app, substitute the # full path to the built app. words = cmd.split(maxsplit=1) + if not words: + return "" if words[0] in oiio_app_list: cmd = oiio_app(words[0]).strip() + (" " + words[1] if len(words) > 1 else "") if not silent: @@ -235,8 +237,12 @@ def run_app(app: str, silent: bool=False, failureok: bool=False, # Take shell `commands`, split at newlines, adorn each with redirects, etc., # then re-join with semicolons to make a single command. +# Note: `failureok` defaults to None, meaning "use the global `failureok` +# value at the time this is called", which a run.py may have set. def run_commands(commands: str, silent: bool=False, - failureok: bool=failureok, concat: bool=True) -> str : + failureok=None, concat: bool=True) -> str : + if failureok is None : + failureok = globals()["failureok"] result = "" for line in commands.splitlines(): cmd = line.strip() @@ -411,7 +417,7 @@ def runtest (command: str, outputs: list[str], failureok: int=0) -> int : for sub_command in [c.strip() for c in command.split(';') if c.strip()]: cmdret = subprocess.call (sub_command, shell=True, env=test_environ) - if cmdret != 0 and failureok == 0 : + if cmdret != 0 and not failureok : print ("#### Error: this command failed: ", sub_command) print ("FAIL") err = 1