Repository: eoyilmaz/displaycal-py3 (language: python)

Issue:
Fix stdio and createprocess calls
This series fixes subprocess launch and communication on Windows and allows the process to be called with pythonw.
Should also fix remaining encoding and perhaps also path issues.
Windows Installer
It is now possible to create a Windows Installer by using Py2Exe + Inno Setup, the details to create an installer are explained in the README.


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 2abbce4b5..e5e870f77 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,9 +5,10 @@
 /build/
 /dist/
 /venv/
-.venv/
+.venv*/
 /INSTALLED_FILES
 /MANIFEST.in
+MANIFEST.in
 /VERSION
 __pycache__/
 /.pytest_cache/
@@ -27,3 +28,5 @@ __pycache__/
 /DisplayCAL/lib64/RealDisplaySizeMM.cpython-310-darwin.so
 /DisplayCAL/lib64/RealDisplaySizeMM.cpython-311-darwin.so
 /DisplayCAL/lib64/RealDisplaySizeMM.cpython-312-darwin.so
+cacert.pem
+*.code-workspace
\ No newline at end of file
diff --git a/DisplayCAL/argyll_instruments.py b/DisplayCAL/argyll_instruments.py
index 5f17065a5..551ef389a 100644
--- a/DisplayCAL/argyll_instruments.py
+++ b/DisplayCAL/argyll_instruments.py
@@ -473,5 +473,5 @@ def remove_vendor_names(txt):
             )
         elif isinstance(txt, str):
             txt = re.sub(re.compile(re.escape(vendor) + r"\s*", re.I), "", txt)
-    txt = txt.strip()
+            txt = txt.strip()
     return txt
diff --git a/DisplayCAL/audio.py b/DisplayCAL/audio.py
index f2cce70cb..1dc9cbe00 100644
--- a/DisplayCAL/audio.py
+++ b/DisplayCAL/audio.py
@@ -167,7 +167,7 @@ def init(lib=None, samplerate=22050, channels=2, buffersize=2048, reinit=False):
                         pass
             elif sys.platform != "darwin":
                 # Hard-code lib names for Linux
-                libfn = "lib" + libname
+                libfn = f"lib{libname}"
                 if libname.startswith("SDL2"):
                     # SDL 2.0
                     libfn += "-2.0.so.0"
@@ -176,7 +176,7 @@ def init(lib=None, samplerate=22050, channels=2, buffersize=2048, reinit=False):
                     libfn += "-1.2.so.0"
             dll = dlopen(libfn, handle=handle)
             if dll:
-                print("%s:" % libname, libfn)
+                print(f"{libname}:", libfn)
             if libname.endswith("_mixer"):
                 if not dll:
                     continue
@@ -247,7 +247,7 @@ def Sound(filename, loop=False, raise_exceptions=False):
             sound = _Sound(filename, loop)
         except Exception as exception:
             if raise_exceptions:
-                raise
+                raise exception
             print(exception)
             sound = _Sound(None, loop)
         _sounds[(filename, loop)] = sound
@@ -303,8 +303,7 @@ class Mix_Chunk(Structure):
 
 
 class _Sound(object):
-
-    """Sound wrapper class"""
+    """Sound wrapper class."""
 
     def __init__(self, filename, loop=False):
         self._filename = filename
@@ -396,7 +395,6 @@ def fade(self, fade_ms, fade_in=None):
         """Fade in/out.
 
         If fade_in is None, fade in/out depending on current volume.
-
         """
         if fade_in is None:
             fade_in = not self.volume
@@ -440,7 +438,10 @@ def play(self, fade_ms=0, stop_already_playing=True):
                 # and Linux even when seeking to start position which allows
                 # replaying the sound under Windows.
                 if stop_already_playing:
-                    self._ch.delete()
+                    try:
+                        self._ch.delete()
+                    except TypeError:
+                        pass
                 self._ch = pyglet.media.Player()
                 if self._lib_version >= "1.4.0":
                     self._ch.loop = self._loop
diff --git a/DisplayCAL/config.py b/DisplayCAL/config.py
index 7a8a0a204..5cb9a1fff 100644
--- a/DisplayCAL/config.py
+++ b/DisplayCAL/config.py
@@ -82,7 +82,9 @@
     pydir = os.path.normpath(os.path.join(exedir, "..", "Resources"))
 else:
     pyname, pyext = os.path.splitext(os.path.basename(pypath))
-    pydir = os.path.dirname(exe if isexe else os.path.abspath(__file__))
+    pydir = os.path.dirname(
+        exe if isexe else os.path.abspath(os.path.dirname(__file__))
+    )
 
 # TODO: Modifying ``data_dirs`` here was not an elegant solution, and it is not solving
 #       the problem either.
@@ -479,7 +481,7 @@ def getbitmap(name, display_missing_icon=True, scale=True, use_mask=False):
                     if not bmp.IsOk():
                         path = None
             if not path:
-                print("Warning: Missing bitmap '%s'" % name)
+                print(f"Warning: Missing bitmap '{name}'")
                 img = wx.Image(w, h)
                 img.SetMaskColour(0, 0, 0)
                 img.InitAlpha()
@@ -555,7 +557,7 @@ def get_argyll_display_number(geometry):
     for i, display in enumerate(getcfg("displays")):
         if display.find(f"@ {geometry}") > -1:
             if debug:
-                print("[D] Found display %s at index %i" % (geometry, i))
+                print(f"[D] Found display {geometry} at index {i}")
             return i
 
 
@@ -600,7 +602,7 @@ def get_icon_bundle(sizes, name):
     iconbundle = wx.IconBundle()
     if not sizes:
         # Assume ICO format
-        pth = get_data_path("theme/icons/%s.ico" % name)
+        pth = get_data_path(f"theme/icons/{name}.ico")
         if pth:
             ico = wx.Icon(pth)
             if ico.IsOk():
@@ -646,7 +648,6 @@ def get_data_path(relpath, rex=None):
 
     If relpath is a file, return the full path, if relpath is a directory,
     return a list of files in the intersection of searched directories.
-
     """
     if (
         not relpath
@@ -686,8 +687,7 @@ def get_data_path(relpath, rex=None):
                     filelist = listdir_re(curpath, rex)
                 except Exception as exception:
                     print(
-                        "Error - directory '%s' listing failed: %s"
-                        % (curpath, exception)
+                        f"Error - directory '{curpath}' listing failed: {exception}"
                     )
                 else:
                     for filename in filelist:
@@ -1058,7 +1058,7 @@ def runtimeconfig(pyfile):
     "comport.number": 1,
     "comport.number.backup": 1,
     # Note: worker.Worker.enumerate_displays_and_ports() overwrites copyright
-    "copyright": "No copyright. Created with %s %s and ArgyllCMS" % (appname, version),
+    "copyright": f"No copyright. Created with {appname} {version} and ArgyllCMS",
     "dimensions.measureframe": "0.5,0.5,1.0",
     "dimensions.measureframe.unzoomed": "0.5,0.5,1.0",
     "dimensions.measureframe.whitepoint.visual_editor": "0.5,0.5,1.0",
@@ -1192,11 +1192,11 @@ def runtimeconfig(pyfile):
     else "u",  # Linux, OSX
     "profile.license": "Public Domain",
     "profile.load_on_login": 1,
-    "profile.name": " ".join(
+    "profile.name": "_".join(
         [
             "%dns",
             "%out",
-            "%Y-%m-%d %H-%M",
+            "%Y-%m-%d_%H-%M",
             "%cb",
             "%wp",
             "%cB",
@@ -1455,7 +1455,7 @@ def getcfg(name, fallback=True, raw=False, cfg=cfg):
                 value = "g"
             elif name in valid_values and value not in valid_values[name]:
                 if debug:
-                    print("Invalid config value for %s: %s" % (name, value), end=" ")
+                    print(f"Invalid config value for {name}: {value}", end=" ")
                 value = None
             elif name == "copyright":
                 # Make sure DisplayCAL and Argyll version are up-to-date
@@ -1507,7 +1507,7 @@ def getcfg(name, fallback=True, raw=False, cfg=cfg):
         # colorimeter_correction_matrix_file is special because it's
         # not (only) a path
         if debug:
-            print("%s does not exist: %s" % (name, value), end=" ")
+            print(f"{name} does not exist: {value}", end=" ")
         # Normalize path (important, this turns altsep into sep under Windows)
         value = os.path.normpath(value)
         # Check if this is a relative path covered by data_dirs
@@ -1590,7 +1590,7 @@ def get_display_profile(display_no=None):
     except Exception:
         from DisplayCAL.log import log
 
-        print("ICCP.get_display_profile(%s):" % display_no, file=log)
+        print(f"ICCP.get_display_profile({display_no}):", file=log)
 
 
 standard_profiles = []
@@ -1792,8 +1792,8 @@ def makecfgdir(which="user", worker=None):
                 os.makedirs(confighome)
             except Exception as exception:
                 print(
-                    "Warning - could not create configuration directory '%s': %s"
-                    % (confighome, exception)
+                    "Warning - could not create configuration directory "
+                    f"'{confighome}': {exception}"
                 )
                 return False
     elif not os.path.exists(config_sys):
@@ -1814,8 +1814,8 @@ def makecfgdir(which="user", worker=None):
                     raise result
         except Exception as exception:
             print(
-                "Warning - could not create configuration directory '%s': %s"
-                % (config_sys, exception)
+                "Warning - could not create configuration directory "
+                f"'{config_sys}': {exception}"
             )
             return False
     return True
@@ -1831,7 +1831,7 @@ def initcfg(module=None, cfg=cfg, force_load=False):
     settings directory if nonexistent.
     """
     if module:
-        cfgbasename = "%s-%s" % (appbasename, module)
+        cfgbasename = f"{appbasename}-{module}"
     else:
         cfgbasename = appbasename
     makecfgdir()
@@ -1847,7 +1847,7 @@ def initcfg(module=None, cfg=cfg, force_load=False):
         cfgnames.append(cfgbasename)
     else:
         cfgnames.extend(
-            "%s-%s" % (appbasename, othermod) for othermod in ("testchart-editor",)
+            f"{appbasename}-{othermod}" for othermod in ("testchart-editor",)
         )
 
     cfgroots = [confighome]
@@ -1862,7 +1862,7 @@ def initcfg(module=None, cfg=cfg, force_load=False):
                 try:
                     mtime = os.stat(cfgfile).st_mtime
                 except EnvironmentError as exception:
-                    print("Warning - os.stat('%s') failed: %s" % (cfgfile, exception))
+                    print(f"Warning - os.stat('{cfgfile}') failed: {exception}")
                 last_checked = cfginited.get(cfgfile)
                 if force_load or mtime != last_checked:
                     cfginited[cfgfile] = mtime
@@ -1888,7 +1888,9 @@ def initcfg(module=None, cfg=cfg, force_load=False):
     # if it can't be parsed
     except Exception:
         print(
-            "Warning - could not parse configuration files:\n%s" % "\n".join(cfgfiles)
+            "Warning - could not parse configuration files:\n%s".format(
+                "\n".join(cfgfiles)
+            )
         )
         # Fix Python 2.7 ConfigParser option values being lists instead of
         # strings in case of a ParsingError. http://bugs.python.org/issue24142
@@ -2128,7 +2130,7 @@ def writecfg(which="user", worker=None, module=None, options=(), cfg=cfg):
     worker: worker instance if ``which == 'system'``
     """
     if module:
-        cfgbasename = "%s-%s" % (appbasename, module)
+        cfgbasename = f"{appbasename}-{module}"
     else:
         cfgbasename = appbasename
     # Remove unknown options
@@ -2154,13 +2156,12 @@ def writecfg(which="user", worker=None, module=None, options=(), cfg=cfg):
                 optionlines = lines[1:]
             # Sorting works as long as config has only one section
             lines = lines[:1] + sorted(optionlines)
-            cfgfile = open(cfgfilename, "wb")
-            cfgfile.write((os.linesep.join(lines) + os.linesep).encode())
-            cfgfile.close()
+            with open(cfgfilename, "wb") as cfgfile:
+                cfgfile.write((os.linesep.join(lines) + os.linesep).encode())
         except Exception as exception:
             print(
                 "Warning - could not write user configuration file "
-                "'%s': %s" % (cfgfilename, exception)
+                f"'{cfgfilename}': {exception}"
             )
             return False
     else:
@@ -2176,12 +2177,11 @@ def writecfg(which="user", worker=None, module=None, options=(), cfg=cfg):
             if getcfg("argyll.dir"):
                 cfgfile.write(
                     (
-                        "%s%s"
-                        % (
+                        "%s%s".format(
                             os.linesep.join(
                                 [
                                     "[Default]",
-                                    "%s = %s" % ("argyll.dir", getcfg("argyll.dir")),
+                                    f"argyll.dir = {getcfg('argyll.dir')}",
                                 ]
                             ),
                             os.linesep,
@@ -2205,8 +2205,8 @@ def writecfg(which="user", worker=None, module=None, options=(), cfg=cfg):
                     raise result
         except Exception as exception:
             print(
-                "Warning - could not write system-wide configuration file '%s': %s"
-                % (cfgfilename2, exception)
+                "Warning - could not write system-wide configuration file "
+                f"'{cfgfilename2}': {exception}"
             )
             return False
     return True
diff --git a/DisplayCAL/display_cal.py b/DisplayCAL/display_cal.py
index 8ce0e45bd..05e099613 100644
--- a/DisplayCAL/display_cal.py
+++ b/DisplayCAL/display_cal.py
@@ -113,6 +113,7 @@
 elif sys.platform == "darwin":
     from DisplayCAL import util_mac
 from DisplayCAL import wexpect
+# import wexpect
 from DisplayCAL.argyll_cgats import (
     cal_to_fake_profile,
     can_update_cal,
@@ -11201,9 +11202,8 @@ def profile_finish(
                     )
                     for key, name, _volume in gamuts:
                         try:
-                            gamut_coverage = float(
-                                profile.tags.meta.getvalue(f"GAMUT_coverage({key})")
-                            )
+                            gamut_coverage = profile.tags.meta.getvalue(f"GAMUT_coverage({key})")
+                            gamut_coverage = float(gamut_coverage) if gamut_coverage is not None else gamut_coverage
                         except (TypeError, ValueError):
                             traceback.print_exc()
                             gamut_coverage = None
diff --git a/DisplayCAL/freeze.py b/DisplayCAL/freeze.py
new file mode 100644
index 000000000..39348c869
--- /dev/null
+++ b/DisplayCAL/freeze.py
@@ -0,0 +1,853 @@
+# -*- coding: utf-8 -*-
+"""This script is used by the py2exe to freeze the library into executables.
+"""
+import sys
+import functools
+import shutil
+import os
+import platform
+from setuptools import Extension, setup
+from distutils.util import change_root, get_platform
+from fnmatch import fnmatch
+from configparser import ConfigParser
+import ctypes.util
+from time import strftime
+
+from py2exe import freeze
+
+
+# Borrowed from setuptools
+def _find_all_simple(path):
+    """Find all files under 'path'."""
+    results = (
+        os.path.join(base, file)
+        for base, dirs, files in os.walk(path, followlinks=True)
+        for file in files
+    )
+    return filter(os.path.isfile, results)
+
+
+def findall(dir=os.curdir):
+    """Find all files under 'dir' and return the list of full filenames.
+
+    Unless dir is '.', return full filenames with dir prepended.
+    """
+    files = _find_all_simple(dir)
+    if dir == os.curdir:
+        make_rel = functools.partial(os.path.relpath, start=dir)
+        files = map(make_rel, files)
+    return list(files)
+
+import distutils.filelist
+
+distutils.filelist.findall = findall  # Fix findall bug in distutils
+
+
+
+bits = platform.architecture()[0][:2]
+pypath = os.path.abspath(__file__)
+pydir = os.path.dirname(pypath)
+source_dir = os.path.dirname(pydir)
+
+print(f"pydir     : {pydir}")
+print(f"source_dir: {source_dir}")
+sys.path.append(source_dir)
+
+
+from DisplayCAL.defaultpaths import autostart, autostart_home
+from DisplayCAL.meta import (
+    appstream_id,
+    author,
+    author_ascii,
+    author_email,
+    description,
+    development_home_page,
+    DOMAIN,
+    longdesc,
+    name,
+    py_maxversion,
+    py_minversion,
+    script2pywname,
+    version,
+    version_tuple,
+    wx_minversion,
+)
+from DisplayCAL.util_list import intlist
+from DisplayCAL.util_os import getenvu, relpath, safe_glob
+from DisplayCAL.util_str import safe_str
+
+appname = name
+
+
+
+
+
+if sys.platform in ("darwin", "win32"):
+    # Adjust PATH so ctypes.util.find_library can find SDL2 DLLs (if present)
+    pth = getenvu("PATH")
+    libpth = os.path.join(pydir, "lib")
+    if not pth.startswith(libpth + os.pathsep):
+        pth = libpth + os.pathsep + pth
+        os.environ["PATH"] = safe_str(pth)
+
+
+config = {
+    "data": ["tests/data/icc/*.icc"],
+    "doc": [
+        "CHANGES.html",
+        "LICENSE.txt",
+        "README.html",
+        "README-fr.html",
+        "screenshots/*.png",
+        "theme/*.png",
+        "theme/*.css",
+        "theme/*.js",
+        "theme/*.svg",
+        "theme/icons/favicon.ico",
+        "theme/slimbox2/*.css",
+        "theme/slimbox2/*.js",
+    ],
+    # Excludes for .app/.exe builds
+    # numpy.lib.utils imports pydoc, which imports Tkinter, but
+    # numpy.lib.utils is not even used by DisplayCAL, so omit all
+    # Tk stuff
+    # Use pyglet with OpenAL as audio backend. We only need
+    # pyglet, pyglet.app and pyglet.media
+    "excludes": {
+        "all": [
+            "Tkconstants",
+            "Tkinter",
+            "pygame",
+            "pyglet.canvas",
+            "pyglet.extlibs",
+            "pyglet.font",
+            "pyglet.gl",
+            "pyglet.graphics",
+            "pyglet.image",
+            "pyglet.input",
+            "pyglet.text",
+            "pyglet.window",
+            "pyo",
+            "setuptools",
+            "tcl",
+            "test",
+            "yaml",
+            "zeroconf",
+        ],
+        "darwin": ["gdbm"],
+        "win32": ["gi", "win32com.client.genpy"],
+    },
+    "package_data": {
+        name: [
+            "beep.wav",
+            "camera_shutter.wav",
+            "ColorLookupTable.fx",
+            "lang/*.yaml",
+            "linear.cal",
+            "pnp.ids",
+            "presets/*.icc",
+            "quirk.json",
+            "ref/*.cie",
+            "ref/*.gam",
+            "ref/*.icm",
+            "ref/*.ti1",
+            "report/*.css",
+            "report/*.html",
+            "report/*.js",
+            "test.cal",
+            "theme/*.png",
+            "theme/*.wav",
+            "theme/icons/10x10/*.png",
+            "theme/icons/16x16/*.png",
+            "theme/icons/32x32/*.png",
+            "theme/icons/48x48/*.png",
+            "theme/icons/72x72/*.png",
+            "theme/icons/128x128/*.png",
+            "theme/icons/256x256/*.png",
+            "theme/icons/512x512/*.png",
+            "theme/jet_anim/*.png",
+            "theme/patch_anim/*.png",
+            "theme/splash_anim/*.png",
+            "theme/shutter_anim/*.png",
+            "ti1/*.ti1",
+            "x3d-viewer/*.css",
+            "x3d-viewer/*.html",
+            "x3d-viewer/*.js",
+            "xrc/*.xrc",
+        ]
+    },
+    "xtra_package_data": {name: {"win32": [f"theme/icons/{name}-uninstall.ico"]}},
+}
+
+
+def add_lib_excludes(key, excludebits):
+    for exclude in excludebits:
+        config["excludes"][key].extend([f"{name}.lib{exclude}", f"lib{exclude}"])
+
+    for exclude in ("32", "64"):
+        for pycompat in ("38", "39", "310", "311"):
+            if key == "win32" and (
+                pycompat == str(sys.version_info[0]) + str(sys.version_info[1])
+                or exclude == excludebits[0]
+            ):
+                continue
+            config["excludes"][key].extend(
+                [
+                    f"{name}.lib{exclude}.python{pycompat}",
+                    f"{name}.lib{exclude}.python{pycompat}.RealDisplaySizeMM",
+                ]
+            )
+
+
+add_lib_excludes("darwin", ["64" if bits == "32" else "32"])
+add_lib_excludes("win32", ["64" if bits == "32" else "32"])
+
+
+msiversion = ".".join(
+    (
+        str(version_tuple[0]),
+        str(version_tuple[1]),
+        str(version_tuple[2]),
+    )
+)
+
+class Target:
+    def __init__(self, **kwargs):
+        self.__dict__.update(kwargs)
+
+
+def get_data(tgt_dir, key, pkgname=None, subkey=None, excludes=None):
+    """Return configured data files."""
+    files = config[key]
+    src_dir = source_dir
+    resource_dir = src_dir
+    if pkgname:
+        files = files[pkgname]
+        resource_dir = os.path.join(src_dir, pkgname)
+        if subkey:
+            if subkey in files:
+                files = files[subkey]
+            else:
+                files = []
+    data = []
+    for pth in files:
+        if not [exclude for exclude in excludes or [] if fnmatch(pth, exclude)]:
+            normalized_path = os.path.normpath(os.path.join(tgt_dir, os.path.dirname(pth)))
+            safe_path = [relpath(p, src_dir) for p in safe_glob(os.path.join(resource_dir, pth))]
+            data.append((normalized_path, safe_path))
+    return data
+
+def get_scripts(excludes=None):
+    # It is required that each script has an accompanying .desktop file
+    scripts_with_desc = []
+    scripts = safe_glob(
+        os.path.join(
+            pydir,
+            "..",
+            "scripts",
+            appname.lower() + "*"
+        )
+    )
+
+    def sortbyname(a, b):
+        a, b = [os.path.splitext(v)[0] for v in (a, b)]
+        if a > b:
+            return 1
+        elif a < b:
+            return -1
+        else:
+            return 0
+
+    import functools
+
+    scripts = sorted(scripts, key=functools.cmp_to_key(sortbyname))
+    for script in scripts:
+        script = os.path.basename(script)
+        if script == appname.lower() + "-apply-profiles-launcher":
+            continue
+        desktopfile = os.path.join(
+            pydir,
+            "..",
+            "misc",
+            f"{script}.desktop"
+        )
+        if os.path.isfile(desktopfile):
+            cfg = ConfigParser()
+            cfg.read(desktopfile)
+            script = cfg.get("Desktop Entry", "Exec").split()[0]
+            desc = cfg.get("Desktop Entry", "Name")
+        else:
+            desc = ""
+        if not [exclude for exclude in excludes or [] if fnmatch(script, exclude)]:
+            scripts_with_desc.append((script, desc))
+    return scripts_with_desc
+
+
+def build_py2exe():
+    """py2exe builder that uses the new freeze API."""
+
+    use_sdl = False
+    sys.path.insert(1, os.path.join(pydir, "..", "util"))
+
+    setuptools = True
+    debug = False
+    dry_run = False
+    do_full_install = False
+
+    doc = "."
+    data = "."
+    # Use CA file from certifi project
+    import certifi
+
+    cacert = certifi.where()
+    if cacert:
+        shutil.copyfile(cacert, os.path.join(pydir, "cacert.pem"))
+        config["package_data"][name].append("cacert.pem")
+    else:
+        print("WARNING: cacert.pem from certifi project not found!")
+
+    # on Mac OS X and Windows, we want data files in the package dir
+    # (package_data will be ignored when using py2exe)
+    package_data = {
+        name: ["theme/icons/22x22/*.png", "theme/icons/24x24/*.png"],
+    }
+    scripts = get_scripts()
+    # Doc files
+    data_files = []
+    data_files += get_data(doc, "doc", excludes=["LICENSE.txt"])
+    if data_files:
+        data_files.append(
+            (
+                doc,
+                [
+                    relpath(
+                        os.path.join(
+                            pydir,
+                            "..",
+                            "LICENSE.txt"
+                        ),
+                        source_dir
+                    )
+                ]
+            )
+        )
+    # metainfo / appdata.xml
+    data_files.append(
+        (
+            os.path.join(os.path.dirname(data), "metainfo"),
+            [
+                relpath(
+                    os.path.normpath(
+                        os.path.join(
+                            pydir,
+                            "..",
+                            "dist",
+                            f"{appstream_id}.appdata.xml"
+                        )
+                    ),
+                    source_dir,
+                )
+            ],
+        )
+    )
+    data_files += get_data(data, "package_data", name, excludes=["theme/icons/*"])
+    data_files += get_data(data, "data")
+    data_files += get_data(data, "xtra_package_data", name, sys.platform)
+    
+    # Add python and pythonw
+    data_files.extend(
+        [
+            (
+                os.path.join(data, "lib"),
+                [
+                    sys.executable,
+                    os.path.join(
+                        os.path.dirname(sys.executable), "pythonw.exe"
+                    ),
+                ],
+            )
+        ]
+    )
+    if use_sdl:
+        # SDL DLLs for audio module
+        sdl2 = ctypes.util.find_library("SDL2")
+        sdl2_mixer = ctypes.util.find_library("SDL2_mixer")
+        if sdl2:
+            sdl2_libs = [sdl2]
+            if sdl2_mixer:
+                sdl2_libs.append(sdl2_mixer)
+                data_files.append((os.path.join(data, "lib"), sdl2_libs))
+                config["excludes"]["all"].append("pyglet")
+            else:
+                print("WARNING: SDL2_mixer not found!")
+        else:
+            print("WARNING: SDL2 not found!")
+    if "pyglet" not in config["excludes"]["all"]:
+        # OpenAL DLLs for pyglet
+        openal32 = ctypes.util.find_library("OpenAL32.dll")
+        wrap_oal = ctypes.util.find_library("wrap_oal.dll")
+        if openal32:
+            oal = [openal32]
+            if wrap_oal:
+                oal.append(wrap_oal)
+            else:
+                print("WARNING: wrap_oal.dll not found!")
+            data_files.append((data, oal))
+        else:
+            print("WARNING: OpenAL32.dll not found!")
+
+    for dname in (
+        "10x10",
+        "16x16",
+        "22x22",
+        "24x24",
+        "32x32",
+        "48x48",
+        "72x72",
+        "128x128",
+        "256x256",
+        "512x512",
+    ):
+        # Get all the icons needed, depending on platform
+        # Only the icon sizes 10, 16, 32, 72, 256 and 512 include icons
+        # that are used exclusively for UI elements.
+        # These should be installed in an app-specific location, e.g.
+        # under Linux $XDG_DATA_DIRS/DisplayCAL/theme/icons/
+        # The app icon sizes 16, 32, 48 and 256 (128 under Mac OS X),
+        # which are used for taskbar icons and the like, as well as the
+        # other sizes can be installed in a generic location, e.g.
+        # under Linux $XDG_DATA_DIRS/icons/hicolor/<size>/apps/
+        # Generally, icon filenames starting with the lowercase app name
+        # should be installed in the generic location.
+        icons = []
+        desktopicons = []
+        if sys.platform == "darwin":
+            largest_iconbundle_icon_size = "128x128"
+        else:
+            largest_iconbundle_icon_size = "256x256"
+        for iconpath in safe_glob(
+            os.path.join(pydir, "theme", "icons", dname, "*.png")
+        ):
+            if not os.path.basename(iconpath).startswith(name.lower()) or (
+                sys.platform in ("darwin", "win32")
+                and dname
+                in ("16x16", "32x32", "48x48", largest_iconbundle_icon_size)
+            ):
+                # In addition to UI element icons, we also need all the app
+                # icons we use in get_icon_bundle under macOS/Windows,
+                # otherwise they wouldn't be included (under Linux, these
+                # are included for installation to the system-wide icon
+                # theme location instead)
+                icons.append(iconpath)
+            elif sys.platform not in ("darwin", "win32"):
+                desktopicons.append(iconpath)
+        if icons:
+            data_files.append((os.path.join(data, "theme", "icons", dname), icons))
+        if desktopicons:
+            data_files.append(
+                (
+                    os.path.join(
+                        os.path.dirname(data), "icons", "hicolor", dname, "apps"
+                    ),
+                    desktopicons,
+                )
+            )
+    sources = [os.path.join(name, "RealDisplaySizeMM.c")]
+    macros = [("NT", None)]
+    libraries = ["user32", "gdi32"]
+    link_args = None
+    extname = f"{name}.lib{bits}.python{sys.version_info[0]}{sys.version_info[1]}.RealDisplaySizeMM"
+    RealDisplaySizeMM = Extension(
+        extname,
+        sources=sources,
+        define_macros=macros,
+        libraries=libraries,
+        extra_link_args=link_args,
+    )
+    ext_modules = [RealDisplaySizeMM]
+    requires = []
+    requires.append("pywin32 (>= 213.0)")
+    packages = [name, f"{name}.lib", f"{name}.lib.agw"]
+    # On Windows we want separate libraries
+    packages.extend(
+        [
+            f"{name}.lib{bits}",
+            f"{name}.lib{bits}.python{sys.version_info[0]}{sys.version_info[1]}",
+        ]
+    )
+
+    attrs = {
+        "author": author_ascii,
+        "author_email": author_email,
+        "classifiers": [
+            "Development Status :: 5 - Production/Stable",
+            "Environment :: MacOS X",
+            "Environment :: Win32 (MS Windows)",
+            "Environment :: X11 Applications",
+            "Intended Audience :: End Users/Desktop",
+            "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
+            "Operating System :: OS Independent",
+            "Programming Language :: Python :: 3.8",
+            "Programming Language :: Python :: 3.9",
+            "Programming Language :: Python :: 3.10",
+            "Programming Language :: Python :: 3.11",
+            "Programming Language :: Python :: 3.12",
+            "Topic :: Multimedia :: Graphics",
+        ],
+        "data_files": data_files,
+        "description": description,
+        "download_url": f"{development_home_page}/releases/download/{version}/{name}-{version}.tar.gz",
+        "ext_modules": ext_modules,
+        "license": "GPL v3",
+        "long_description": longdesc,
+        "long_description_content_type": "text/x-rst",
+        "name": name,
+        "packages": packages,
+        "package_data": package_data,
+        "package_dir": {name: name},
+        "platforms": [
+            "Python >= {} <= {}".format(
+                ".".join(str(n) for n in py_minversion),
+                ".".join(str(n) for n in py_maxversion),
+            ),
+            "Linux/Unix with X11",
+            "Mac OS X >= 10.4",
+            "Windows 2000 and newer",
+        ],
+        "requires": requires,
+        "provides": [name],
+        "scripts": [],
+        "url": f"https://{DOMAIN}/",
+        "version": msiversion if "bdist_msi" in sys.argv[1:] else version,
+    }
+    if setuptools:
+        attrs["entry_points"] = {
+            "gui_scripts": [
+                "{} = {}.main:main{}".format(
+                    script,
+                    name,
+                    (
+                        ""
+                        if script == name.lower()
+                        else script[len(name):].lower().replace("-", "_")
+                    ),
+                )
+                for script, desc in scripts
+            ]
+        }
+        attrs["exclude_package_data"] = {name: ["RealDisplaySizeMM.c"]}
+        attrs["include_package_data"] = False
+        install_requires = [req.replace("(", "").replace(")", "") for req in requires]
+        attrs["install_requires"] = install_requires
+        attrs["zip_safe"] = False
+    else:
+        attrs["scripts"].extend(
+            os.path.join("scripts", script)
+            for script, desc in [
+                script_desc
+                for script_desc in scripts
+                if script_desc[0] != f"{name.lower()}-apply-profiles"
+                or sys.platform != "darwin"
+            ]
+        )
+
+    
+    import wx
+    from winmanifest_util import getmanifestxml
+
+    if platform.architecture()[0] == "64bit":
+        arch = "amd64"
+    else:
+        arch = "x86"
+    manifest_xml = getmanifestxml(
+        os.path.join(
+            pydir,
+            "..",
+            "misc",
+            name
+            + (
+                ".exe.%s.VC90.manifest" % arch
+                if hasattr(sys, "version_info") and sys.version_info[:2] >= (3, 8)
+                else ".exe.manifest"
+            ),
+        )
+    )
+    tmp_scripts_dir = os.path.join(source_dir, "build", "temp.scripts")
+    if not os.path.isdir(tmp_scripts_dir):
+        os.makedirs(tmp_scripts_dir)
+    apply_profiles_launcher = (
+        f"{appname.lower()}-apply-profiles-launcher",
+        f"{appname} Profile Loader Launcher",
+    )
+    for script, desc in scripts + [apply_profiles_launcher]:
+        shutil.copy(
+            os.path.join(source_dir, "scripts", script),
+            os.path.join(tmp_scripts_dir, script2pywname(script)),
+        )
+    attrs["windows"] = [
+        Target(
+            **{
+                "script": os.path.join(tmp_scripts_dir, script2pywname(script)),
+                "icon_resources": [
+                    (
+                        1,
+                        os.path.join(
+                            pydir,
+                            "theme",
+                            "icons",
+                            os.path.splitext(os.path.basename(script))[0] + ".ico",
+                        ),
+                    )
+                ],
+                "other_resources": [(24, 1, manifest_xml)],
+                "copyright": "© %s %s" % (strftime("%Y"), author),
+                "description": desc,
+            }
+        )
+        for script, desc in [
+            script_desc1
+            for script_desc1 in scripts
+            if script_desc1[0] != appname.lower() + "-eecolor-to-madvr-converter"
+            and not script_desc1[0].endswith("-console")
+        ]
+    ]
+
+    # Add profile loader launcher
+    attrs["windows"].append(
+        Target(
+            **{
+                "script": os.path.join(
+                    tmp_scripts_dir, script2pywname(apply_profiles_launcher[0])
+                ),
+                "icon_resources": [
+                    (
+                        1,
+                        os.path.join(
+                            pydir,
+                            "theme",
+                            "icons",
+                            appname + "-apply-profiles" + ".ico",
+                        ),
+                    )
+                ],
+                "other_resources": [(24, 1, manifest_xml)],
+                "copyright": "© %s %s" % (strftime("%Y"), author),
+                "description": apply_profiles_launcher[1],
+            }
+        )
+    )
+
+    # Programs that can run with and without GUI
+    console_scripts = [f"{name}-VRML-to-X3D-converter"]  # No "-console" suffix!
+    for console_script in console_scripts:
+        console_script_path = os.path.join(
+            tmp_scripts_dir, console_script + "-console"
+        )
+        if not os.path.isfile(console_script_path):
+            shutil.copy(
+                os.path.join(
+                    source_dir, "scripts", console_script.lower() + "-console"
+                ),
+                console_script_path,
+            )
+    attrs["console"] = [
+        Target(
+            **{
+                "script": os.path.join(
+                    tmp_scripts_dir, script2pywname(script) + "-console"
+                ),
+                "icon_resources": [
+                    (
+                        1,
+                        os.path.join(
+                            pydir,
+                            "theme",
+                            "icons",
+                            os.path.splitext(os.path.basename(script))[0] + ".ico",
+                        ),
+                    )
+                ],
+                "other_resources": [(24, 1, manifest_xml)],
+                "copyright": "© %s %s" % (strftime("%Y"), author),
+                "description": desc,
+            }
+        )
+        for script, desc in [
+            script_desc2
+            for script_desc2 in scripts
+            if script2pywname(script_desc2[0]) in console_scripts
+        ]
+    ]
+
+    # Programs without GUI
+    attrs["console"].append(
+        Target(
+            **{
+                "script": os.path.join(
+                    tmp_scripts_dir, appname + "-eeColor-to-madVR-converter"
+                ),
+                "icon_resources": [
+                    (
+                        1,
+                        os.path.join(
+                            pydir, "theme", "icons", appname + "-3DLUT-maker.ico"
+                        ),
+                    )
+                ],
+                "other_resources": [(24, 1, manifest_xml)],
+                "copyright": "© %s %s" % (strftime("%Y"), author),
+                "description": "Convert eeColor 65^3 to madVR 256^3 3D LUT "
+                "(video levels in, video levels out)",
+            }
+        )
+    )
+
+    dist_dir = os.path.join(
+        pydir,
+        "..",
+        "dist",
+        f"py2exe.{get_platform()}-py{sys.version_info[0]}.{sys.version_info[1]}",
+        f"{name}-{version}",
+    )
+    os.makedirs(dist_dir, exist_ok=True)
+    attrs["options"] = {
+        "py2exe": {
+            "dist_dir": dist_dir,
+            "dll_excludes": [
+                "iertutil.dll",
+                "MPR.dll",
+                "msvcm90.dll",
+                "msvcp90.dll",
+                "msvcr90.dll",
+                "mswsock.dll",
+                "urlmon.dll",
+                "w9xpopen.exe",
+                "gdiplus.dll",
+                "mfc90.dll",
+            ],
+            "excludes": config["excludes"]["all"] + config["excludes"]["win32"],
+            "bundle_files": 3,  # if wx.VERSION >= (2, 8, 10, 1) else 1,
+            "compressed": 1,
+            "optimize": 0,  # 0 = don’t optimize (generate .pyc)
+            # 1 = normal optimization (like python -O)
+            # 2 = extra optimization (like python -OO)
+        }
+    }
+    if debug:
+        attrs["options"]["py2exe"].update(
+            {"bundle_files": 3, "compressed": 0, "optimize": 0, "skip_archive": 1}
+        )
+    if setuptools:
+        attrs["setup_requires"] = ["py2exe"]
+    attrs["zipfile"] = os.path.join("lib", "library.zip")
+
+    # To have a working sdist and bdist_rpm when using distutils,
+    # we go to the length of generating MANIFEST.in from scratch everytime,
+    # using the information available from setup.
+    manifest_in = ["# This file will be re-generated by setup.py - do not edit"]
+    manifest_in.extend(
+        [
+            "include LICENSE.txt",
+            "include MANIFEST",
+            "include MANIFEST.in",
+            "include README.html",
+            "include README-fr.html",
+            "include CHANGES.html",
+            f"include {name}*.pyw",
+            f"include {name}-*.pyw",
+            f"include {name}-*.py",
+            "include use-distutils",
+        ]
+    )
+    manifest_in.append("include " + os.path.basename(sys.argv[0]))
+    manifest_in.append(
+        "include " + os.path.splitext(os.path.basename(sys.argv[0]))[0] + ".cfg"
+    )
+    for _datadir, datafiles in attrs.get("data_files", []):
+        for datafile in datafiles:
+            manifest_in.append(
+                "include {}".format(
+                    relpath(os.path.sep.join(datafile.split("/")), source_dir)
+                    or datafile
+                )
+            )
+    for extmod in attrs.get("ext_modules", []):
+        manifest_in.extend(
+            "include " + os.path.sep.join(src.split("/")) for src in extmod.sources
+        )
+    for pkg in attrs.get("packages", []):
+        pkg = os.path.join(*pkg.split("."))
+        pkgdir = os.path.sep.join(
+            attrs.get("package_dir", {}).get(pkg, pkg).split("/")
+        )
+        manifest_in.append("include " + os.path.join(pkgdir, "*.py"))
+        # manifest_in.append("include " + os.path.join(pkgdir, "*.pyd"))
+        # manifest_in.append("include " + os.path.join(pkgdir, "*.so"))
+        for obj in attrs.get("package_data", {}).get(pkg, []):
+            print(f"obj: {obj}")
+            manifest_in.append(
+                "include " + os.path.sep.join([pkgdir] + obj.split("/"))
+            )
+    for pymod in attrs.get("py_modules", []):
+        manifest_in.append("include {}".format(os.path.join(*pymod.split("."))))
+    manifest_in.append(
+        "include {}".format(os.path.join(name, "theme", "theme-info.txt"))
+    )
+    manifest_in.append(
+        "recursive-include {} {} {}".format(
+            os.path.join(name, "theme", "icons"), "*.icns", "*.ico"
+        )
+    )
+    manifest_in.append("include {}".format(os.path.join("man", "*.1")))
+    manifest_in.append("recursive-include misc *")
+    # if skip_instrument_conf_files:
+    #     manifest_in.extend(
+    #         [
+    #             "exclude misc/Argyll",
+    #             "exclude misc/*.rules",
+    #             "exclude misc/*.usermap",
+    #         ]
+    #     )
+    manifest_in.append("include {}".format(os.path.join("screenshots", "*.png")))
+    manifest_in.append("include {}".format(os.path.join("scripts", "*")))
+    manifest_in.append("include {}".format(os.path.join("tests", "*")))
+    manifest_in.append("recursive-include theme *")
+    manifest_in.append("recursive-include util *.cmd *.py *.sh")
+    manifest_in.append("global-exclude *~")
+    manifest_in.append("global-exclude *.backup")
+    manifest_in.append("global-exclude *.bak")
+    if not dry_run:
+        manifest = open("MANIFEST.in", "w")
+        manifest.write("\n".join(manifest_in))
+        manifest.close()
+        if os.path.exists("MANIFEST"):
+            os.remove("MANIFEST")
+
+    py2exe_kwargs = {
+        "console": attrs["console"],
+        "windows": attrs["windows"],
+        "data_files": attrs["data_files"],
+        "zipfile": attrs["zipfile"],
+        "options": attrs["options"],
+    }
+
+    print("Running py2exe.freeze!")
+    freeze(**py2exe_kwargs)
+    # setup(**attrs)
+    print("py2exe.freeze DONE!")
+
+    shutil.copy(
+        os.path.join(
+            dist_dir, f"python{sys.version_info[0]}{sys.version_info[1]}.dll"
+        ),
+        os.path.join(
+            dist_dir, "lib", f"python{sys.version_info[0]}{sys.version_info[1]}.dll"
+        ),
+    )
+
+    from vc90crt import name as vc90crt_name, vc90crt_copy_files
+    vc90crt_copy_files(dist_dir)
+    vc90crt_copy_files(os.path.join(dist_dir, "lib"))
+
+if __name__ == "__main__":
+    build_py2exe()
diff --git a/DisplayCAL/log.py b/DisplayCAL/log.py
index 5d3b2e974..84d54cf15 100644
--- a/DisplayCAL/log.py
+++ b/DisplayCAL/log.py
@@ -172,15 +172,15 @@ def write(self, msg):
 
 
 class SafeLogger(SafePrinter):
-    """
-    Print and log safely, avoiding any UnicodeDe-/EncodingErrors on strings
+    """Print and log safely, avoiding any UnicodeDe-/EncodingErrors on strings
     and converting all other objects to safe string representations.
     """
 
-    def __init__(self, log=True, print_=hasattr(sys.stdout, "isatty") and
-                                        sys.stdout.isatty()):
+    def __init__(self, log=True, print_=None):
         SafePrinter.__init__(self)
         self.log = log
+        if print_ is None:
+            print_ = sys.stdout and hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
         self.print_ = print_
 
     def write(self, *args, **kwargs):
diff --git a/DisplayCAL/main.py b/DisplayCAL/main.py
index 2fe911193..d7fa73279 100644
--- a/DisplayCAL/main.py
+++ b/DisplayCAL/main.py
@@ -218,7 +218,7 @@ def _main(module, name, app_lock_file_name, probe_ports=True):
                     lockfile.unlock()
             except EnvironmentError as exception:
                 # This shouldn't happen
-                print("Warning - could not read lockfile %s:" % lockfilename, exception)
+                print(f"Warning - could not read lockfile {lockfilename}:", exception)
         if module not in multi_instance:
             # Check lockfile(s) and probe port(s)
             for lockfilename in [app_lock_file_name]:
@@ -228,7 +228,7 @@ def _main(module, name, app_lock_file_name, probe_ports=True):
                     pid, port = pids_ports[0]
                     appsocket = AppSocket()
                     if appsocket and port:
-                        print("Connecting to %s..." % port)
+                        print(f"Connecting to {port}...")
                         if appsocket.connect(host, port):
                             print("Connected to", port)
                             # Other instance already running?
diff --git a/DisplayCAL/meta.py b/DisplayCAL/meta.py
index fa52e733e..a29a40176 100644
--- a/DisplayCAL/meta.py
+++ b/DisplayCAL/meta.py
@@ -73,7 +73,7 @@
 version_tuple = VERSION  # only ints allowed and must be exactly 3 values
 
 wx_minversion = (2, 8, 11)
-wx_recversion = (4, 1, 1)
+wx_recversion = (4, 2, 2)
 
 
 def get_latest_changelog_entry(readme):
diff --git a/DisplayCAL/multiprocess.py b/DisplayCAL/multiprocess.py
index aac44e621..6fb6fcfac 100644
--- a/DisplayCAL/multiprocess.py
+++ b/DisplayCAL/multiprocess.py
@@ -222,18 +222,22 @@ def __call__(self, data, thread_abort_event, progress_queue, *args, **kwds):
                     # interested in our own exit handler though.
                     # Note all of this only applies to Windows, as it doesn't
                     # have fork().
-                    for func, targs, kargs in atexit._exithandlers:
-                        # Find our lockfile removal exit handler
-                        if (
-                            targs
-                            and isinstance(targs[0], str)
-                            and targs[0].endswith(".lock")
-                        ):
-                            print("Removing lockfile", targs[0])
-                            try:
-                                func(*targs, **kargs)
-                            except Exception as exception:
-                                print("Could not remove lockfile:", exception)
+
+                    # This is not working with Ptyhon 3 as atexit is reimplemented in C
+                    # and atexit._exithandlers are not available.
+                    # for func, targs, kargs in atexit._exithandlers:
+                    #     # Find our lockfile removal exit handler
+                    #     if (
+                    #         targs
+                    #         and isinstance(targs[0], str)
+                    #         and targs[0].endswith(".lock")
+                    #     ):
+                    #         print("Removing lockfile", targs[0])
+                    #         try:
+                    #             func(*targs, **kargs)
+                    #         except Exception as exception:
+                    #             print("Could not remove lockfile:", exception)
+
                     # Logging is normally shutdown by atexit, as well. Do
                     # it explicitly instead.
                     logging.shutdown()
diff --git a/DisplayCAL/network.py b/DisplayCAL/network.py
index 2808905cd..c3183dfbe 100644
--- a/DisplayCAL/network.py
+++ b/DisplayCAL/network.py
@@ -126,7 +126,7 @@ def __exit__(self, etype, value, tb):
 
     def __init__(self):
         socket.socket.__init__(self)
-        self.recv_buffer = ""
+        self.recv_buffer = b""
 
     def disconnect(self):
         try:
@@ -141,16 +141,16 @@ def disconnect(self):
     def get_single_response(self):
         # Buffer received data until EOT (response end marker) and return
         # single response (additional data will still be in the buffer)
-        while "\4" not in self.recv_buffer:
+        while b"\4" not in self.recv_buffer:
             incoming = self.recv(4096)
-            if incoming == "":
+            if incoming == b"":
                 raise socket.error(lang.getstr("connection.broken"))
             self.recv_buffer += incoming
-        end = self.recv_buffer.find("\4")
+        end = self.recv_buffer.find(b"\4")
         single_response = self.recv_buffer[:end]
         self.recv_buffer = self.recv_buffer[end + 1:]
-        return str(single_response)
+        return single_response
 
     def send_command(self, command):
         # Automatically append newline (command end marker)
-        self.sendall(safe_str(command, "UTF-8") + "\n")
+        self.sendall((safe_str(command, "utf-8") + "\n").encode("utf-8"))
diff --git a/DisplayCAL/postinstall.py b/DisplayCAL/postinstall.py
index f071a60fc..1bc7ecc5e 100644
--- a/DisplayCAL/postinstall.py
+++ b/DisplayCAL/postinstall.py
@@ -13,7 +13,7 @@
 
 recordfile_name = "INSTALLED_FILES"
 
-if not sys.stdout.isatty():
+if sys.stdout and hasattr(sys.stdout, "isatty") and not sys.stdout.isatty():
     sys.stdout = StringIO()
 
 if sys.platform == "win32":
diff --git a/DisplayCAL/profile_loader.py b/DisplayCAL/profile_loader.py
index 989420a44..deca0ef48 100644
--- a/DisplayCAL/profile_loader.py
+++ b/DisplayCAL/profile_loader.py
@@ -26,7 +26,6 @@
     import ctypes
     import math
     import re
-    import struct
     import subprocess as sp
     import traceback
     import warnings
@@ -117,7 +116,7 @@
 
     def setup_profile_loader_task(exe, exedir, pydir):
         if sys.getwindowsversion() >= (6,):
-            import taskscheduler
+            from DisplayCAL import taskscheduler
 
             taskname = appname + " Profile Loader Launcher"
 
@@ -2466,9 +2465,9 @@ def _check_display_changed(self, first_run=False, dry_run=False):
             try:
                 display = winreg.QueryValueEx(subkey, "SetId")[0]
                 value_name = "Timestamp"
-                timestamp = struct.unpack(
-                    "<Q", winreg.QueryValueEx(subkey, "Timestamp")[0].rjust(8, "0")
-                )
+                # winreg.QueryValuesEx will directly return the int value,
+                # so no need to convert the binary data as in Python2
+                timestamp = winreg.QueryValueEx(subkey, "Timestamp")[0]
             except WindowsError as exception:
                 warnings.warn(
                     r"Registry access failed: %s: %s (HKLM\%s\%s)"
@@ -2781,13 +2780,13 @@ def _check_display_conf(self):
                                 )
                             )
                         # Fall back to linear calibration
-                        tagData = "vcgt"
-                        tagData += "\0" * 4  # Reserved
-                        tagData += "\0\0\0\x01"  # Formula type
+                        tagData = b"vcgt"
+                        tagData += b"\0" * 4  # Reserved
+                        tagData += b"\0\0\0\x01"  # Formula type
                         for channel in range(3):
-                            tagData += "\0\x01\0\0"  # Gamma 1.0
-                            tagData += "\0" * 4  # Min 0.0
-                            tagData += "\0\x01\0\0"  # Max 1.0
+                            tagData += b"\0\x01\0\0"  # Gamma 1.0
+                            tagData += b"\0" * 4  # Min 0.0
+                            tagData += b"\0\x01\0\0"  # Max 1.0
                         vcgt = ICCP.VideoCardGammaFormulaType(tagData, "vcgt")
                         vcgt_values = vcgt.get_values()[:3]
                         if self._reset_gamma_ramps:
@@ -3037,7 +3036,7 @@ def _check_display_conf(self):
             timestamp = time.time()
             localtime = list(time.localtime(self._timestamp))
             localtime[3:6] = 23, 59, 59
-            midnight = time.mktime(localtime) + 1
+            midnight = time.mktime(tuple(localtime)) + 1
             if timestamp >= midnight:
                 self.reload_count = 0
                 self._timestamp = timestamp
diff --git a/DisplayCAL/setup.py b/DisplayCAL/setup.py
index 27425a187..c469b1992 100644
--- a/DisplayCAL/setup.py
+++ b/DisplayCAL/setup.py
@@ -21,14 +21,14 @@
 the wrapper script in the root directory of the source tar.gz/zip
 
 """
-import functools
+import codecs
+import ctypes.util
 from configparser import ConfigParser
 from distutils.command.install import install
+import distutils.core
 from distutils.util import change_root, get_platform
 from fnmatch import fnmatch
-import codecs
-import ctypes.util
-import distutils.core
+import functools
 import os
 import platform
 import re
@@ -38,7 +38,7 @@
 from time import strftime
 
 
-# # Borrowed from setuptools
+# Borrowed from setuptools
 def _find_all_simple(path):
     """
     Find all files under 'path'
@@ -70,21 +70,21 @@ def findall(dir=os.curdir):
 
 from DisplayCAL.defaultpaths import autostart, autostart_home
 from DisplayCAL.meta import (
+    appstream_id,
     author,
     author_ascii,
+    author_email,
     description,
-    longdesc,
-    DOMAIN,
     development_home_page,
+    DOMAIN,
+    longdesc,
     name,
     py_maxversion,
     py_minversion,
+    script2pywname,
     version,
     version_tuple,
     wx_minversion,
-    author_email,
-    script2pywname,
-    appstream_id,
 )
 from DisplayCAL.util_list import intlist
 from DisplayCAL.util_os import getenvu, relpath, safe_glob
@@ -190,7 +190,7 @@ def findall(dir=os.curdir):
             "xrc/*.xrc",
         ]
     },
-    "xtra_package_data": {name: {"win32": ["theme/icons/%s-uninstall.ico" % name]}},
+    "xtra_package_data": {name: {"win32": [f"theme/icons/{name}-uninstall.ico"]}},
 }
 
 
@@ -207,8 +207,8 @@ def add_lib_excludes(key, excludebits):
                 continue
             config["excludes"][key].extend(
                 [
-                    name + ".lib%s.python%s" % (exclude, pycompat),
-                    name + ".lib%s.python%s.RealDisplaySizeMM" % (exclude, pycompat),
+                    f"{name}.lib{exclude}.python{pycompat}",
+                    f"{name}.lib{exclude}.python{pycompat}.RealDisplaySizeMM",
                 ]
             )
 
@@ -236,7 +236,7 @@ def add_lib_excludes(key, excludebits):
     "CFBundleShortVersionString": version,
     "CFBundleSignature": "????",
     "CFBundleVersion": ".".join(map(str, version_tuple)),
-    "NSHumanReadableCopyright": "© %s %s" % (strftime("%Y"), author),
+    "NSHumanReadableCopyright": f"© {strftime('%Y')} {author}",
     "LSMinimumSystemVersion": "10.6.0",
 }
 
@@ -319,8 +319,8 @@ def create_app_symlinks(dist_dir, scripts):
                         continue
                     if subentry == name + ".icns":
                         shutil.copy(
-                            os.path.join(pydir, "theme", "icons", "%s.icns" % script),
-                            os.path.join(toolcontents, entry, "%s.icns" % script),
+                            os.path.join(pydir, "theme", "icons", f"{script}.icns"),
+                            os.path.join(toolcontents, entry, f"{script}.icns"),
                         )
                         continue
                     if subentry == script:
@@ -346,11 +346,11 @@ def create_app_symlinks(dist_dir, scripts):
                 )
                 # CFBundleIdentifier
                 infoxml = infoxml.replace(
-                    ".%s</string>" % name, ".%s</string>" % script
+                    f".{name}</string>", f".{script}</string>"
                 )
                 # CFBundleIconFile
                 infoxml = infoxml.replace(
-                    "%s.icns</string>" % name, "%s.icns</string>" % script
+                    f"{name}.icns</string>", f"{script}.icns</string>"
                 )
                 # CFBundleExecutable
                 infoxml = re.sub(
@@ -411,9 +411,8 @@ def sortbyname(a, b):
         script = os.path.basename(script)
         if script == appname.lower() + "-apply-profiles-launcher":
             continue
-        desktopfile = os.path.join(pydir, "..", "misc", script + ".desktop")
+        desktopfile = os.path.join(pydir, "..", "misc",  f"{script}.desktop")
         if os.path.isfile(desktopfile):
-            print("desktopfile: %s" % desktopfile)
             cfg = ConfigParser()
             cfg.read(desktopfile)
             script = cfg.get("Desktop Entry", "Exec").split()[0]
@@ -976,14 +975,16 @@ def findall(
     if not setuptools or sys.platform != "win32":
         # wxPython windows installer doesn't add egg-info entry, so
         # a dependency check from pkg_resources would always fail
-        requires.append("wxPython (>= %s)" % ".".join(str(n) for n in wx_minversion))
+        requires.append(
+            "wxPython (>= {})".format(".".join(str(n) for n in wx_minversion))
+        )
     if sys.platform == "win32":
         requires.append("pywin32 (>= 213.0)")
 
     packages = [name, f"{name}.lib", f"{name}.lib.agw"]
     if sdist:
         # For source distributions we want all libraries
-        for pycompat in ("38", "39", "310", "311"):
+        for pycompat in ("38", "39", "310", "311", "312"):
             packages.extend([f"{name}.lib{bits}", f"{name}.lib{bits}.python{pycompat}"])
     elif sys.platform == "darwin":
         # On Mac OS X we only want the universal binaries
@@ -1014,6 +1015,8 @@ def findall(
             "Programming Language :: Python :: 3.8",
             "Programming Language :: Python :: 3.9",
             "Programming Language :: Python :: 3.10",
+            "Programming Language :: Python :: 3.11",
+            "Programming Language :: Python :: 3.12",
             "Topic :: Multimedia :: Graphics",
         ],
         "data_files": data_files,
@@ -1091,7 +1094,7 @@ def findall(
             pydir,
             "..",
             "dist",
-            f"py2app.{get_platform()}-py{sys.version[:3]}",
+            f"py2app.{get_platform()}-py{sys.version_info[0]}.{sys.version_info[1]}",
             f"{name}-{version}",
         )
         from py2app.build_app import py2app as py2app_cls
@@ -1133,7 +1136,7 @@ def copy_package_data(self, package, target_dir):
                 "misc",
                 name
                 + (
-                    ".exe.%s.VC90.manifest" % arch
+                    f".exe.{arch}.VC90.manifest"
                     if hasattr(sys, "version_info") and sys.version_info[:2] >= (3, 8)
                     else ".exe.manifest"
                 ),
@@ -1143,8 +1146,8 @@ def copy_package_data(self, package, target_dir):
         if not os.path.isdir(tmp_scripts_dir):
             os.makedirs(tmp_scripts_dir)
         apply_profiles_launcher = (
-            appname.lower() + "-apply-profiles-launcher",
-            appname + " Profile Loader Launcher",
+            f"{appname.lower()}-apply-profiles-launcher",
+            f"{appname} Profile Loader Launcher",
         )
         for script, desc in scripts + [apply_profiles_launcher]:
             shutil.copy(
@@ -1167,7 +1170,7 @@ def copy_package_data(self, package, target_dir):
                         )
                     ],
                     "other_resources": [(24, 1, manifest_xml)],
-                    "copyright": "© %s %s" % (strftime("%Y"), author),
+                    "copyright": f"© {strftime('%Y')} {author}",
                     "description": desc,
                 }
             )
@@ -1198,7 +1201,7 @@ def copy_package_data(self, package, target_dir):
                         )
                     ],
                     "other_resources": [(24, 1, manifest_xml)],
-                    "copyright": "© %s %s" % (strftime("%Y"), author),
+                    "copyright": f"© {strftime('%Y')} {author}",
                     "description": apply_profiles_launcher[1],
                 }
             )
@@ -1235,7 +1238,7 @@ def copy_package_data(self, package, target_dir):
                         )
                     ],
                     "other_resources": [(24, 1, manifest_xml)],
-                    "copyright": "© %s %s" % (strftime("%Y"), author),
+                    "copyright": f"© {strftime('%Y')} {author}",
                     "description": desc,
                 }
             )
@@ -1262,7 +1265,7 @@ def copy_package_data(self, package, target_dir):
                         )
                     ],
                     "other_resources": [(24, 1, manifest_xml)],
-                    "copyright": "© %s %s" % (strftime("%Y"), author),
+                    "copyright": f"© {strftime('%Y')} {author}",
                     "description": "Convert eeColor 65^3 to madVR 256^3 3D LUT "
                     "(video levels in, video levels out)",
                 }
@@ -1273,7 +1276,7 @@ def copy_package_data(self, package, target_dir):
             pydir,
             "..",
             "dist",
-            f"py2exe.{get_platform()}-py{sys.version[:3]}",
+            f"py2exe.{get_platform()}-py{sys.version_info[0]}.{sys.version_info[1]}",
             f"{name}-{version}",
         )
         attrs["options"] = {
@@ -1422,8 +1425,8 @@ def copy_package_data(self, package, target_dir):
                     # setuptools
                     paths += (
                         safe_glob(path)
-                        + safe_glob(f"{path}-{version}-py{sys.version[:3]}*.egg")
-                        + safe_glob(f"{path}-{version}-py{sys.version[:3]}*.egg-info")
+                        + safe_glob(f"{path}-{version}-py{sys.version_info[0]}.{sys.version_info[1]}*.egg")
+                        + safe_glob(f"{path}-{version}-py{sys.version_info[0]}.{sys.version_info[1]}*.egg-info")
                     )
 
             if os.path.isabs(data) and data not in paths:
@@ -1634,7 +1637,7 @@ def copy_package_data(self, package, target_dir):
                     pydir,
                     "..",
                     "dist",
-                    "bbfreeze.{}-py{}".format(get_platform(), sys.version[:3]),
+                    f"bbfreeze.{get_platform()}-py{sys.version_info[0]}.{sys.version_info[1]}",
                 )
                 sys.argv.insert(i + 1, f"--dist-dir={dist_dir}")
             if "egg_info" not in sys.argv[1:i]:
@@ -1668,10 +1671,10 @@ def copy_package_data(self, package, target_dir):
         if do_py2exe:
             shutil.copy(
                 os.path.join(
-                    dist_dir, "python%s.dll" % (sys.version[0] + sys.version[2])
+                    dist_dir, f"python{sys.version_info[0]}{sys.version_info[1]}.dll"
                 ),
                 os.path.join(
-                    dist_dir, "lib", "python%s.dll" % (sys.version[0] + sys.version[2])
+                    dist_dir, "lib", f"python{sys.version_info[0]}{sys.version_info[1]}.dll"
                 ),
             )
 
@@ -1743,20 +1746,6 @@ def isSystemDLL(pathname):
 
     py2exe.build_exe.isSystemDLL = isSystemDLL
 
-    # Numpy DLL paths fix
-    def numpy_dll_paths_fix():
-        import numpy
-
-        paths = set()
-        numpy_path = numpy.__path__[0]
-        for dirpath, dirnames, filenames in os.walk(numpy_path):
-            for item in filenames:
-                if item.lower().endswith(".dll"):
-                    paths.add(dirpath)
-        sys.path.extend(paths)
-
-    numpy_dll_paths_fix()
-
 
 if __name__ == "__main__":
     setup()
diff --git a/DisplayCAL/taskscheduler.py b/DisplayCAL/taskscheduler.py
index 8ca37b49f..3bed1e721 100644
--- a/DisplayCAL/taskscheduler.py
+++ b/DisplayCAL/taskscheduler.py
@@ -169,6 +169,8 @@ def __init__(self, *args, **kwargs):
 
 class ExecAction(_Dict2XML):
     def __init__(self, cmd, args=None):
+        # Filter any None values
+        args = [arg for arg in args if arg is not None]
         _Dict2XML.__init__(
             self,
             command=cmd,
diff --git a/DisplayCAL/util_str.py b/DisplayCAL/util_str.py
index b95e64707..78561eab3 100644
--- a/DisplayCAL/util_str.py
+++ b/DisplayCAL/util_str.py
@@ -512,7 +512,7 @@ def replace_control_chars(txt, replacement=" ", collapse=False):
 
 
 def safe_basestring(obj, enc="utf-8", errors="replace"):
-    """Return a unicode or string representation of obj
+    """Return a string or bytes representation of obj.
 
     Return obj if isinstance(obj, basestring). Otherwise, return unicode(obj),
     string(obj), or repr(obj), whichever succeeds first.
diff --git a/DisplayCAL/wexpect.py b/DisplayCAL/wexpect.py
index e25382f97..275e4196c 100644
--- a/DisplayCAL/wexpect.py
+++ b/DisplayCAL/wexpect.py
@@ -64,18 +64,21 @@
 $Id: pexpect.py 507 2007-12-27 02:40:52Z noah $
 """
 
+import copy
+import errno
+import logging
+import re
 import os
-import sys
-import time
 import select
+import signal
 import string
-import re
 import struct
-import types
-import errno
-import traceback
-import signal
 import subprocess
+import sys
+import tempfile
+import time
+import traceback
+import types
 
 if sys.platform != "win32":
     import pty
@@ -89,10 +92,55 @@
     import pywintypes
     from win32com.shell.shellcon import CSIDL_APPDATA
     from win32com.shell.shell import SHGetSpecialFolderPath
-    from win32console import *
-    from win32process import *
-    from win32con import *
-    from win32gui import *
+    from win32console import (
+        AllocConsole,
+        AttachConsole,
+        FreeConsole,
+        GetConsoleProcessList,
+        GetConsoleWindow,
+        GetStdHandle,
+        KEY_EVENT,
+        PyConsoleScreenBufferType,
+        PyCOORDType,
+        PyINPUT_RECORDType,
+        PySMALL_RECTType,
+        SetConsoleOutputCP,
+        SetConsoleTitle,
+        STD_INPUT_HANDLE,
+    )
+    from win32process import (
+        CreateProcess,
+        GetCurrentProcessId,
+        GetExitCodeProcess,
+        GetStartupInfo,
+        GetWindowThreadProcessId,
+        ResumeThread,
+        SuspendThread,
+        TerminateProcess,
+    )
+    from win32con import (
+        CREATE_NEW_CONSOLE,
+        CREATE_NEW_PROCESS_GROUP,
+        CTRL_BREAK_EVENT,
+        FILE_SHARE_READ,
+        FILE_SHARE_WRITE,
+        GENERIC_READ,
+        GENERIC_WRITE,
+        OPEN_EXISTING,
+        PM_REMOVE,
+        PROCESS_QUERY_INFORMATION,
+        PROCESS_TERMINATE,
+        SW_HIDE,
+        SW_SHOW,
+        STARTF_USESHOWWINDOW,
+        STILL_ACTIVE,
+        THREAD_SUSPEND_RESUME,
+        WM_USER,
+    )
+    from win32gui import (
+        PeekMessage,
+        ShowWindow,
+    )
     import win32api
     import win32file
     import winerror
@@ -121,7 +169,6 @@ class ExceptionPexpect(Exception):
     """Base class for all exceptions raised by this module."""
 
     def __init__(self, value):
-
         self.value = value
 
     def __str__(self):
@@ -129,7 +176,6 @@ def __str__(self):
         return str(self.value)
 
     def get_trace(self):
-
         """This returns an abbreviated stack trace with lines that only concern
         the caller. In other words, the stack trace inside the Pexpect module
         is not included."""
@@ -141,7 +187,6 @@ def get_trace(self):
         return "".join(tblist)
 
     def __filter_not_pexpect(self, trace_list_item):
-
         """This returns True if list item 0 the string 'pexpect.py' in it."""
 
         if trace_list_item[0].find("pexpect.py") == -1:
@@ -248,7 +293,6 @@ def print_ticks(d):
     the child. 'extra_args' is not used by directly run(). It provides a way to
     pass data to a callback function through run() through the locals
     dictionary passed to a callback."""
-
     if timeout == -1:
         child = spawn(command, maxread=2000, logfile=logfile, cwd=cwd, env=env)
     else:
@@ -313,15 +357,17 @@ def spawn(
         args = []
 
     log("=" * 80)
-    log("Buffer size: %s" % maxread)
+    log(f"Buffer size: {maxread}")
     if searchwindowsize:
-        log("Search window size: %s" % searchwindowsize)
-    log("Timeout: %ss" % timeout)
+        log(f"Search window size: {searchwindowsize}")
+    log(f"Timeout: {timeout}s")
     if env:
         log("Environment:")
         for name in env:
-            log("\t%s=%s" % (name, env[name]))
+            log(f"\t{name}={env[name]}")
     if cwd:
+        if isinstance(cwd, bytes):
+            cwd = cwd.decode("utf-8")
         log(f"Working directory: {cwd}")
     log("Spawning {}".format(join_args([command] + args)))
     if sys.platform == "win32":
@@ -347,113 +393,113 @@ def spawn(
 class spawn_unix(object):
     """This is the main class interface for Pexpect. Use this class to start and control child applications.
 
-        The command parameter may be a string that
-        includes a command and any arguments to the command. For example::
-
-            child = pexpect.spawn('/usr/bin/ftp')
-            child = pexpect.spawn('/usr/bin/ssh user@example.com')
-            child = pexpect.spawn('ls -latr /tmp')
-
-        You may also construct it with a list of arguments like so::
-
-            child = pexpect.spawn('/usr/bin/ftp', [])
-            child = pexpect.spawn('/usr/bin/ssh', ['user@example.com'])
-            child = pexpect.spawn('ls', ['-latr', '/tmp'])
-
-        After this the child application will be created and will be ready to
-        talk to. For normal use, see expect() and send() and sendline().
-
-        Remember that Pexpect does NOT interpret shell meta characters such as
-        redirect, pipe, or wild cards (>, |, or *). This is a common mistake.
-        If you want to run a command and pipe it through another command then
-        you must also start a shell. For example::
-
-            child = pexpect.spawn('/bin/bash -c "ls -l | grep LOG > log_list.txt"')
-            child.expect(pexpect.EOF)
-
-        The second form of spawn (where you pass a list of arguments) is useful
-        in situations where you wish to spawn a command and pass it its own
-        argument list. This can make syntax more clear. For example, the
-        following is equivalent to the previous example::
-
-            shell_cmd = 'ls -l | grep LOG > log_list.txt'
-            child = pexpect.spawn('/bin/bash', ['-c', shell_cmd])
-            child.expect(pexpect.EOF)
-
-        The maxread attribute sets the read buffer size. This is maximum number
-        of bytes that Pexpect will try to read from a TTY at one time. Setting
-        the maxread size to 1 will turn off buffering. Setting the maxread
-        value higher may help performance in cases where large amounts of
-        output are read back from the child. This feature is useful in
-        conjunction with searchwindowsize.
-
-        The searchwindowsize attribute sets the how far back in the incomming
-        seach buffer Pexpect will search for pattern matches. Every time
-        Pexpect reads some data from the child it will append the data to the
-        incomming buffer. The default is to search from the beginning of the
-        imcomming buffer each time new data is read from the child. But this is
-        very inefficient if you are running a command that generates a large
-        amount of data where you want to match The searchwindowsize does not
-        effect the size of the incomming data buffer. You will still have
-        access to the full buffer after expect() returns.
-
-        The logfile member turns on or off logging. All input and output will
-        be copied to the given file object. Set logfile to None to stop
-        logging. This is the default. Set logfile to sys.stdout to echo
-        everything to standard output. The logfile is flushed after each write.
-
-        Example log input and output to a file::
-
-            child = pexpect.spawn('some_command')
-            fout = file('mylog.txt','w')
-            child.logfile = fout
-
-        Example log to stdout::
-
-            child = pexpect.spawn('some_command')
-            child.logfile = sys.stdout
-
-        The logfile_read and logfile_send members can be used to separately log
-        the input from the child and output sent to the child. Sometimes you
-        don't want to see everything you write to the child. You only want to
-        log what the child sends back. For example::
-
-            child = pexpect.spawn('some_command')
-            child.logfile_read = sys.stdout
-
-        To separately log output sent to the child use logfile_send::
-
-            self.logfile_send = fout
-
-        The delaybeforesend helps overcome a weird behavior that many users
-        were experiencing. The typical problem was that a user would expect() a
-        "Password:" prompt and then immediately call sendline() to send the
-        password. The user would then see that their password was echoed back
-        to them. Passwords don't normally echo. The problem is caused by the
-        fact that most applications print out the "Password" prompt and then
-        turn off stdin echo, but if you send your password before the
-        application turned off echo, then you get your password echoed.
-        Normally this wouldn't be a problem when interacting with a human at a
-        real keyboard. If you introduce a slight delay just before writing then
-        this seems to clear up the problem. This was such a common problem for
-        many users that I decided that the default pexpect behavior should be
-        to sleep just before writing to the child application. 1/20th of a
-        second (50 ms) seems to be enough to clear up the problem. You can set
-        delaybeforesend to 0 to return to the old behavior. Most Linux machines
-        don't like this to be below 0.03. I don't know why.
-
-        Note that spawn is clever about finding commands on your path.
-        It uses the same logic that "which" uses to find executables.
-
-        If you wish to get the exit status of the child you must call the
-        close() method. The exit or signal status of the child will be stored
-        in self.exitstatus or self.signalstatus. If the child exited normally
-        then exitstatus will store the exit return code and signalstatus will
-        be None. If the child was terminated abnormally with a signal then a
-        signalstatus will store the signal value and exitstatus will be None.
-        If you need more detail you can also read the self.status member which
-        stores the status returned by os.waitpid. You can interpret this using
-        os.WIFEXITED/os.WEXITSTATUS or os.WIFSIGNALED/os.TERMSIG.
+    The command parameter may be a string that
+    includes a command and any arguments to the command. For example::
+
+        child = pexpect.spawn('/usr/bin/ftp')
+        child = pexpect.spawn('/usr/bin/ssh user@example.com')
+        child = pexpect.spawn('ls -latr /tmp')
+
+    You may also construct it with a list of arguments like so::
+
+        child = pexpect.spawn('/usr/bin/ftp', [])
+        child = pexpect.spawn('/usr/bin/ssh', ['user@example.com'])
+        child = pexpect.spawn('ls', ['-latr', '/tmp'])
+
+    After this the child application will be created and will be ready to
+    talk to. For normal use, see expect() and send() and sendline().
+
+    Remember that Pexpect does NOT interpret shell meta characters such as
+    redirect, pipe, or wild cards (>, |, or *). This is a common mistake.
+    If you want to run a command and pipe it through another command then
+    you must also start a shell. For example::
+
+        child = pexpect.spawn('/bin/bash -c "ls -l | grep LOG > log_list.txt"')
+        child.expect(pexpect.EOF)
+
+    The second form of spawn (where you pass a list of arguments) is useful
+    in situations where you wish to spawn a command and pass it its own
+    argument list. This can make syntax more clear. For example, the
+    following is equivalent to the previous example::
+
+        shell_cmd = 'ls -l | grep LOG > log_list.txt'
+        child = pexpect.spawn('/bin/bash', ['-c', shell_cmd])
+        child.expect(pexpect.EOF)
+
+    The maxread attribute sets the read buffer size. This is maximum number
+    of bytes that Pexpect will try to read from a TTY at one time. Setting
+    the maxread size to 1 will turn off buffering. Setting the maxread
+    value higher may help performance in cases where large amounts of
+    output are read back from the child. This feature is useful in
+    conjunction with searchwindowsize.
+
+    The searchwindowsize attribute sets the how far back in the incomming
+    seach buffer Pexpect will search for pattern matches. Every time
+    Pexpect reads some data from the child it will append the data to the
+    incomming buffer. The default is to search from the beginning of the
+    imcomming buffer each time new data is read from the child. But this is
+    very inefficient if you are running a command that generates a large
+    amount of data where you want to match The searchwindowsize does not
+    effect the size of the incomming data buffer. You will still have
+    access to the full buffer after expect() returns.
+
+    The logfile member turns on or off logging. All input and output will
+    be copied to the given file object. Set logfile to None to stop
+    logging. This is the default. Set logfile to sys.stdout to echo
+    everything to standard output. The logfile is flushed after each write.
+
+    Example log input and output to a file::
+
+        child = pexpect.spawn('some_command')
+        fout = file('mylog.txt','w')
+        child.logfile = fout
+
+    Example log to stdout::
+
+        child = pexpect.spawn('some_command')
+        child.logfile = sys.stdout
+
+    The logfile_read and logfile_send members can be used to separately log
+    the input from the child and output sent to the child. Sometimes you
+    don't want to see everything you write to the child. You only want to
+    log what the child sends back. For example::
+
+        child = pexpect.spawn('some_command')
+        child.logfile_read = sys.stdout
+
+    To separately log output sent to the child use logfile_send::
+
+        self.logfile_send = fout
+
+    The delaybeforesend helps overcome a weird behavior that many users
+    were experiencing. The typical problem was that a user would expect() a
+    "Password:" prompt and then immediately call sendline() to send the
+    password. The user would then see that their password was echoed back
+    to them. Passwords don't normally echo. The problem is caused by the
+    fact that most applications print out the "Password" prompt and then
+    turn off stdin echo, but if you send your password before the
+    application turned off echo, then you get your password echoed.
+    Normally this wouldn't be a problem when interacting with a human at a
+    real keyboard. If you introduce a slight delay just before writing then
+    this seems to clear up the problem. This was such a common problem for
+    many users that I decided that the default pexpect behavior should be
+    to sleep just before writing to the child application. 1/20th of a
+    second (50 ms) seems to be enough to clear up the problem. You can set
+    delaybeforesend to 0 to return to the old behavior. Most Linux machines
+    don't like this to be below 0.03. I don't know why.
+
+    Note that spawn is clever about finding commands on your path.
+    It uses the same logic that "which" uses to find executables.
+
+    If you wish to get the exit status of the child you must call the
+    close() method. The exit or signal status of the child will be stored
+    in self.exitstatus or self.signalstatus. If the child exited normally
+    then exitstatus will store the exit return code and signalstatus will
+    be None. If the child was terminated abnormally with a signal then a
+    signalstatus will store the signal value and exitstatus will be None.
+    If you need more detail you can also read the self.status member which
+    stores the status returned by os.waitpid. You can interpret this using
+    os.WIFEXITED/os.WEXITSTATUS or os.WIFSIGNALED/os.TERMSIG.
 
 
     """
@@ -505,7 +551,7 @@ def __init__(
         self.delayafterclose = 0.1  # Sets delay in close() method to allow kernel time to update process status. Time in seconds.
         self.delayafterterminate = 0.1  # Sets delay in terminate() method to allow kernel time to update process status. Time in seconds.
         self.softspace = False  # File-like object.
-        self.name = "<" + repr(self) + ">"  # File-like object.
+        self.name = f"<{repr(self)}>"  # File-like object.
         self.encoding = None  # File-like object.
         self.closed = True  # File-like object.
         self.ocwd = os.getcwd()
@@ -536,7 +582,6 @@ def __del__(self):
         objects, so they must be handled explicitly. If the child file
         descriptor was opened outside this class (passed to the constructor)
         then this does not close it."""
-
         if not self.closed:
             # It is possible for __del__ methods to execute during the
             # teardown of the Python VM itself. Thus, self.close() may
@@ -682,7 +727,6 @@ def _spawn(self, command, args=None):
         self.closed = False
 
     def __fork_pty(self):
-
         """This implements a substitute for the forkpty system call. This
         should be more portable than the pty.fork() function. Specifically,
         this should work on Solaris.
@@ -695,7 +739,6 @@ def __fork_pty(self):
             http://mail.python.org/pipermail/python-dev/2003-May/035281.html
 
         """
-
         parent_fd, child_fd = os.openpty()
         if parent_fd < 0 or child_fd < 0:
             raise ExceptionPexpect("Error! Could not open pty with os.openpty().")
@@ -721,11 +764,9 @@ def __fork_pty(self):
         return pid, parent_fd
 
     def __pty_make_controlling_tty(self, tty_fd):
-
         """This makes the pseudo-terminal the controlling tty. This should be
         more portable than the pty.fork() function. Specifically, this should
         work on Solaris."""
-
         child_name = os.ttyname(tty_fd)
 
         # Disconnect from controlling tty if still connected.
@@ -762,19 +803,15 @@ def __pty_make_controlling_tty(self, tty_fd):
             os.close(fd)
 
     def fileno(self):  # File-like object.
-
         """This returns the file descriptor of the pty for the child."""
-
         return self.child_fd
 
     def close(self, force=True):  # File-like object.
-
         """This closes the connection with the child application. Note that
         calling close() more than once is valid. This emulates standard Python
         behavior with files. Set force to True if you want to make sure that
         the child is terminated (SIGKILL is sent if the child ignores SIGHUP
         and SIGINT)."""
-
         if not self.closed:
             self.flush()
             os.close(self.child_fd)
@@ -791,21 +828,16 @@ def close(self, force=True):  # File-like object.
             # self.pid = None
 
     def flush(self):  # File-like object.
-
         """This does nothing. It is here to support the interface for a
         File-like object."""
-
         pass
 
     def isatty(self):  # File-like object.
-
         """This returns True if the file descriptor is open and connected to a
         tty(-like) device, else False."""
-
         return os.isatty(self.child_fd)
 
     def waitnoecho(self, timeout=-1):
-
         """This waits until the terminal ECHO flag is set False. This returns
         True if the echo mode is off. This returns False if the ECHO flag was
         not set False before the timeout. This can be used to detect when the
@@ -822,7 +854,6 @@ def waitnoecho(self, timeout=-1):
         False.
 
         """
-
         if timeout == -1:
             timeout = self.timeout
         if timeout is not None:
@@ -837,18 +868,15 @@ def waitnoecho(self, timeout=-1):
             time.sleep(0.1)
 
     def getecho(self):
-
         """This returns the terminal echo mode. This returns True if echo is
         on or False if echo is off. Child applications that are expecting you
         to enter a password often set ECHO False. See waitnoecho()."""
-
         attr = termios.tcgetattr(self.child_fd)
         if attr[3] & termios.ECHO:
             return True
         return False
 
     def setecho(self, state):
-
         """This sets the terminal echo mode on or off. Note that anything the
         child sent before the echo will be lost, so you should be sure that
         your input buffer is empty before you call setecho(). For example, the
@@ -877,7 +905,6 @@ def setecho(self, state):
             p.expect (['abcd'])
             p.expect (['wxyz'])
         """
-
         self.child_fd
         attr = termios.tcgetattr(self.child_fd)
         if state:
@@ -889,7 +916,6 @@ def setecho(self, state):
         termios.tcsetattr(self.child_fd, termios.TCSANOW, attr)
 
     def read_nonblocking(self, size=1, timeout=-1):
-
         """This reads at most size characters from the child application. It
         includes a timeout. If the read does not complete within the timeout
         period then a TIMEOUT exception is raised. If the end of file is read
@@ -909,7 +935,6 @@ def read_nonblocking(self, size=1, timeout=-1):
 
         This is a wrapper around os.read(). It uses select.select() to
         implement the timeout."""
-
         if self.closed:
             raise ValueError("I/O operation on closed file in read_nonblocking().")
 
@@ -982,7 +1007,6 @@ def read(self, size=-1):  # File-like object.
         omitted, read all data until EOF is reached. The bytes are returned as
         a string object. An empty string is returned when EOF is encountered
         immediately."""
-
         if size == 0:
             return ""
         if size < 0:
@@ -1011,7 +1035,6 @@ def readline(self, size=-1):  # File-like object.
         is returned when EOF is hit immediately. Currently, the size argument is
         mostly ignored, so this behavior is not standard for a file-like
         object. If size is 0 then an empty string is returned."""
-
         if size == 0:
             return ""
         index = self.expect(["\r\n", self.delimiter])  # delimiter default is EOF
@@ -1058,7 +1081,6 @@ def send(self, s):
         """This sends a string to the child process. This returns the number of
         bytes written. If a log file was set then the data is also written to
         the log."""
-
         time.sleep(self.delaybeforesend)
         if self.logfile is not None:
             self.logfile.write(s)
@@ -1162,7 +1184,6 @@ def terminate(self, force=False):
         SIGHUP and SIGINT. If "force" is True then moves onto SIGKILL. This
         returns True if the child was terminated. This returns False if the
         child could not be terminated."""
-
         if not self.isalive():
             return True
         try:
@@ -1203,7 +1224,6 @@ def wait(self):
         child has unread output and has terminated. In other words, the child
         may have printed output then called exit(); but, technically, the child
         is still alive until its output is read."""
-
         if self.isalive():
             pid, status = os.waitpid(self.pid, 0)
         else:
@@ -1232,7 +1252,6 @@ def isalive(self):
         exitstatus or signalstatus of the child. This returns True if the child
         process appears to be running or False if not. It can take literally
         SECONDS for Solaris to return the right status."""
-
         if self.terminated:
             return False
 
@@ -1300,17 +1319,14 @@ def isalive(self):
         return False
 
     def kill(self, sig):
-
         """This sends the given signal to the child application. In keeping
         with UNIX tradition it has a misleading name. It does not necessarily
         kill the child unless you send the right signal."""
-
         # Same as os.kill, but the pid is given for you.
         if self.isalive():
             os.kill(self.pid, sig)
 
     def compile_pattern_list(self, patterns):
-
         """This compiles a pattern-string or a list of pattern-strings.
         Patterns must be a StringType, EOF, TIMEOUT, SRE_Pattern, or a list of
         those. Patterns may also be None which results in an empty list (you
@@ -1333,7 +1349,6 @@ def compile_pattern_list(self, patterns):
                 i = self.expect_list(clp, timeout)
                 ...
         """
-
         if patterns is None:
             return []
         if not isinstance(patterns, list):
@@ -1435,7 +1450,6 @@ def expect(self, pattern, timeout=-1, searchwindowsize=None):
 
         If you are trying to optimize for speed then see expect_list().
         """
-
         compiled_pattern_list = self.compile_pattern_list(pattern)
         return self.expect_list(compiled_pattern_list, timeout, searchwindowsize)
 
@@ -1475,7 +1489,6 @@ def expect_loop(self, searcher, timeout=-1, searchwindowsize=-1):
         to search for in the input.
 
         See expect() for other arguments, return value and exceptions."""
-
         self.searcher = searcher
 
         end_time = -1
@@ -1493,9 +1506,9 @@ def expect_loop(self, searcher, timeout=-1, searchwindowsize=-1):
             while True:  # Keep reading until exception or return.
                 index = searcher.search(incoming, freshlen, searchwindowsize)
                 if index >= 0:
-                    self.buffer = incoming[searcher.end:]
+                    self.buffer = incoming[searcher.end :]
                     self.before = incoming[: searcher.start]
-                    self.after = incoming[searcher.start: searcher.end]
+                    self.after = incoming[searcher.start : searcher.end]
                     self.match = searcher.match
                     self.match_index = index
                     return self.match_index
@@ -1506,7 +1519,10 @@ def expect_loop(self, searcher, timeout=-1, searchwindowsize=-1):
                 c = self.read_nonblocking(self.maxread, timeout)
                 freshlen = len(c)
                 time.sleep(0.0001)
-                incoming = incoming + c.decode()
+                if sys.platform == "win32":
+                    incoming += c
+                else:
+                    incoming += c.decode()
                 if timeout is not None:
                     timeout = end_time - time.time()
         except EOF as e:
@@ -1556,7 +1572,6 @@ def setwinsize(self, r, c):
         physical window size. It changes the size reported to TTY-aware
         applications like vi or curses -- applications that respond to the
         SIGWINCH signal."""
-
         # Check for buggy platforms. Some Python versions on some platforms
         # (notably OSF1 Alpha and RedHat 7.1) truncate the value for
         # termios.TIOCSWINSZ. It is not clear why this happens.
@@ -1635,7 +1650,6 @@ def __interact_copy(
         self, escape_character=None, input_filter=None, output_filter=None
     ):
         """This is used by the ``interact()`` method."""
-
         while self.isalive():
             r, w, e = self.__select([self.child_fd, self.STDIN_FILENO], [], [])
             if self.child_fd in r:
@@ -1665,7 +1679,6 @@ def __select(self, iwtd, owtd, ewtd, timeout=None):
         select.select raises a select.error exception and errno is an EINTR
         error then it is ignored. Mainly this is used to ignore sigwinch
         (terminal resize)."""
-
         # if select() is interrupted by a signal (errno==EINTR) then
         # we loop back and enter the select() again.
         end_time = -1
@@ -1753,7 +1766,7 @@ def __init__(
         self.delayafterclose = 0.1  # Sets delay in close() method to allow kernel time to update process status. Time in seconds.
         self.delayafterterminate = 0.1  # Sets delay in terminate() method to allow kernel time to update process status. Time in seconds.
         self.softspace = False  # File-like object.
-        self.name = "<" + repr(self) + ">"  # File-like object.
+        self.name = f"<{repr(self)}>"  # File-like object.
         self.encoding = None  # File-like object.
         self.closed = True  # File-like object.
         self.ocwd = os.getcwd()
@@ -1762,6 +1775,7 @@ def __init__(
         self.codepage = codepage
         self.columns = columns
         self.rows = rows
+        self.wtty = None
 
         if args is None:
             args = []
@@ -1791,7 +1805,6 @@ def _spawn(self, command, args=None):
         fork/exec type of stuff for a pty. This is called by __init__. If args
         is empty then command will be parsed (split on spaces) and args will be
         set to parsed arguments."""
-
         if args is None:
             args = []
 
@@ -1832,7 +1845,7 @@ def _spawn(self, command, args=None):
         self.command = command_with_path
         self.args[0] = self.command
 
-        self.name = "<" + " ".join(self.args) + ">"
+        self.name = f'<{" ".join(self.args)}>'
 
         # assert self.pid is None, 'The pid member should be None.'
         # assert self.command is not None, 'The command member should not be None.'
@@ -1905,7 +1918,6 @@ def read_nonblocking(self, size=1, timeout=-1):
             timeout = self.timeout
 
         s = self.wtty.read_nonblocking(timeout, size)
-
         if s == "":
             if not self.wtty.isalive():
                 self.flag_eof = True
@@ -1956,7 +1968,6 @@ def sendintr(self):
 
     def terminate(self, force=False):
         """Terminate the child. Force not used."""
-
         if not self.isalive():
             return True
 
@@ -1969,7 +1980,6 @@ def terminate(self, force=False):
 
     def kill(self, sig):
         """Sig == sigint for ctrl-c otherwise the child is terminated."""
-
         if sig == signal.SIGINT:
             self.wtty.sendintr()
         else:
@@ -1982,7 +1992,6 @@ def wait(self):
         may have printed output then called exit(); but, technically, the child
         is still alive until its output is read.
         """
-
         if not self.isalive():
             raise ExceptionPexpect("Cannot wait for dead child process.")
 
@@ -1996,7 +2005,6 @@ def wait(self):
 
     def isalive(self):
         """Determines if the child is still alive."""
-
         if self.terminated:
             return False
 
@@ -2030,12 +2038,9 @@ def stop_interact(self):
         self.wtty.stop_interact()
 
 
-##############################################################################
-# End of spawn_windows class
-##############################################################################
-
+class Wtty(object):
+    """"""
 
-class Wtty:
     def __init__(self, timeout=30, codepage=None, columns=None, rows=None, cwd=None):
         self.__buffer = StringIO()
         self.__bufferY = 0
@@ -2051,8 +2056,11 @@ def __init__(self, timeout=30, codepage=None, columns=None, rows=None, cwd=None)
             or windll.kernel32.GetConsoleOutputCP()
             or windll.kernel32.GetOEMCP()
         )
-        log("Code page: %s" % self.codepage)
+        log(f"Code page: {self.codepage}")
+        log(f"hasattr(sys, 'frozen'): {hasattr(sys, 'frozen')}")
         self.columns = columns
+        if isinstance(cwd, bytes):
+            cwd = cwd.decode("utf-8")
         self.cwd = cwd
         self.rows = rows
         self.console = False
@@ -2094,12 +2102,10 @@ def spawn(self, command, args=None, env=None):
             time.sleep(0.05)
 
         if not self.__childProcess:
-            raise ExceptionPexpect("The process " + args[0] + " could not be started.")
+            raise ExceptionPexpect(f"The process {args[0]} could not be started.")
 
         winHandle = int(GetConsoleWindow())
-
         self.__switch = True
-
         if winHandle != 0:
             self.__parentPid = GetWindowThreadProcessId(winHandle)[1]
             # Do we have a console attached? Do not rely on winHandle, because
@@ -2117,13 +2123,13 @@ def spawn(self, command, args=None, env=None):
     def startChild(self, args, env):
         si = GetStartupInfo()
         si.dwFlags = STARTF_USESHOWWINDOW
-        si.wShowWindow = SW_HIDE
+        si.wShowWindow = SW_HIDE  # SW_SHOW
         # Determine the directory of wexpect.py or, if we are running 'frozen'
         # (eg. py2exe deployment), of the packed executable
         dirname = os.path.dirname(
             sys.executable
             if getattr(sys, "frozen", False)
-            else os.path.abspath(__file__)
+            else os.path.abspath(os.path.dirname(__file__))
         )
         if getattr(sys, "frozen", False):
             logdir = appname
@@ -2151,14 +2157,20 @@ def startChild(self, args, env):
         # py2exe: The python executable can be included via setup script by
         # adding it to 'data_files'
         commandLine = '"%s" %s "%s"' % (
-            os.path.join(dirname, "python.exe")
-            if getattr(sys, "frozen", False)
-            else os.path.join(os.path.dirname(sys.executable), "python.exe"),
+            (
+                os.path.join(dirname, "python.exe")
+                if getattr(sys, "frozen", False)
+                else os.path.join(os.path.dirname(sys.executable), "python.exe")
+            ),
             " ".join(pyargs),
-            "import sys; sys.path = %s + sys.path;"
+            "import sys;%ssys.path = %s + sys.path;"
             "args = %s; from DisplayCAL import wexpect;"
-            "wexpect.ConsoleReader(wexpect.join_args(args), %i, %i, cp=%i, c=%s, r=%s, logdir=%r)"
+            "wexpect.ConsoleReader(wexpect.join_args(args), %i, %i, cp=%s, c=%s, r=%s, logdir=%r)"
             % (
+                # this fixes running Argyll commands through py2exe frozen python
+                "setattr(sys, 'frozen', '%s'); ".format(
+                    getattr(sys, "frozen")
+                ) if hasattr(sys, "frozen") else "",
                 ("%r" % spath).replace('"', r"\""),
                 ("%r" % args).replace('"', r"\""),
                 pid,
@@ -2176,8 +2188,7 @@ def startChild(self, args, env):
         )
 
     def switchTo(self, attached=True):
-        """Releases from the current console and attatches
-        to the childs."""
+        """Release from the current console and attatches to the childs."""
         if not self.__switch or not self.__oproc_isalive():
             return
 
@@ -2191,7 +2202,6 @@ def switchTo(self, attached=True):
     def switchBack(self):
         """Releases from the current console and attaches
         to the parents."""
-
         if not self.__switch or not self.__oproc_isalive():
             return
 
@@ -2237,7 +2247,6 @@ def terminate_child(self):
     def createKeyEvent(self, char):
         """Creates a single key record corresponding to
         the ascii character char."""
-
         evt = PyINPUT_RECORDType(KEY_EVENT)
         evt.KeyDown = True
         evt.Char = char
@@ -2248,7 +2257,6 @@ def createKeyEvent(self, char):
 
     def write(self, s):
         """Writes input into the child consoles input buffer."""
-
         if len(s) == 0:
             return 0
         records = [self.createKeyEvent(c) for c in str(s)]
@@ -2275,8 +2283,7 @@ def getOffset(self, x, y):
         return x + y * consinfo["Size"].X
 
     def readConsole(self, startCo, endCo):
-        """Reads the console area from startCo to endCo and returns it
-        as a string."""
+        """Reads the console area from startCo to endCo and returns it as a string."""
         buff = []
         self.lastRead = 0
 
@@ -2301,7 +2308,7 @@ def readConsole(self, startCo, endCo):
             self.totalRead += ln
             buff.append(s)
 
-            startCo.X, startCo.Y = endPoint[0], endPoint[1]
+            startCo.X, startCo.Y = int(endPoint[0]), int(endPoint[1])
             if readlen <= 0 or (startCo.X >= endX and startCo.Y >= endY):
                 break
 
@@ -2311,7 +2318,6 @@ def parseData(self, s):
         """Ensures that special characters are interpreted as
         newlines or blanks, depending on if there written over
         characters or screen-buffer-fill characters."""
-
         consinfo = self.__consout.GetConsoleScreenBufferInfo()
         strlist = []
         for i, c in enumerate(s):
@@ -2321,12 +2327,12 @@ def parseData(self, s):
 
         s = "\r\n".join([line.rstrip(" ") for line in "".join(strlist).split("\r\n")])
         try:
-            return s.encode("cp%i" % self.codepage, "replace")
+            return s  # .encode("cp%i" % self.codepage, "replace")
         except LookupError:
-            return s.encode(
-                getattr(sys.stdout, "encoding", None) or sys.getdefaultencoding(),
-                "replace",
-            )
+            return s  # .encode(
+            #    getattr(sys.stdout, "encoding", None) or sys.getdefaultencoding(),
+            #    "replace",
+            # )
 
     def readConsoleToCursor(self):
         """Reads from the current read position to the current cursor
@@ -2368,7 +2374,7 @@ def readConsoleToCursor(self):
         rawlist = []
         while raw:
             rawlist.append(raw[: consinfo["Size"].X])
-            raw = raw[consinfo["Size"].X:]
+            raw = raw[consinfo["Size"].X :]
         raw = "".join(rawlist)
         s = self.parseData(raw)
         for i, line in enumerate(reversed(rawlist)):
@@ -2399,7 +2405,7 @@ def readConsoleToCursor(self):
                 # log('raw: %r' % raw)
                 if raw.startswith(buf):
                     # Line has grown
-                    rawslice = raw[len(buf):]
+                    rawslice = raw[len(buf) :]
                     # Update last read bytes so line breaks can be detected in parseData
                     lastRead = self.lastRead
                     self.lastRead = len(rawslice)
@@ -2437,7 +2443,7 @@ def read_nonblocking(self, timeout, size):
                     reset = True
                     time.sleep(0.2)
 
-                start = time.clock()
+                start = time.time()
                 s = self.readConsoleToCursor()
 
                 if reset:
@@ -2465,7 +2471,7 @@ def read_nonblocking(self, timeout, size):
                     time.sleep(0.1)
 
                 time.sleep(0.001)
-                end = time.clock()
+                end = time.time()
                 timeout -= end - start
 
         except Exception as e:
@@ -2478,7 +2484,6 @@ def refreshConsole(self):
         """Clears the console after pausing the child and
         reading all the data currently on the console.
         The last line before clearing becomes the first line after clearing."""
-
         consinfo = self.__consout.GetConsoleScreenBufferInfo()
         cursorPos = consinfo["CursorPosition"]
         startCo = PyCOORDType(0, cursorPos.Y)
@@ -2514,7 +2519,6 @@ def setecho(self, state):
 
     def getecho(self):
         """Returns the echo mode of the child console."""
-
         self.switchTo()
         try:
             mode = self.__consin.GetConsoleMode()
@@ -2528,7 +2532,6 @@ def getecho(self):
     def getwinsize(self):
         """Returns the size of the child console as a tuple of
         (rows, columns)."""
-
         self.switchTo()
         try:
             size = self.__consout.GetConsoleScreenBufferInfo()["Size"]
@@ -2595,23 +2598,22 @@ class ConsoleReader(object):
     def __init__(self, path, pid, tid, env=None, cp=None, c=None, r=None, logdir=None):
         self.logdir = logdir
         log("=" * 80, "consolereader", logdir)
-        log("OEM code page: %s" % windll.kernel32.GetOEMCP(), "consolereader", logdir)
+        log(f"OEM code page: {windll.kernel32.GetOEMCP()}", "consolereader", logdir)
         consolecp = windll.kernel32.GetConsoleOutputCP()
-        log("Console output code page: %s" % consolecp, "consolereader", logdir)
+        log(f"Console output code page: {consolecp}", "consolereader", logdir)
         if consolecp != cp:
-            log("Setting console output code page to %s" % cp, "consolereader", logdir)
+            log(f"Setting console output code page to {cp}", "consolereader", logdir)
             try:
                 SetConsoleOutputCP(cp)
             except Exception as e:
                 log(e, "consolereader_exceptions", logdir)
             else:
                 log(
-                    "Console output code page: %s"
-                    % windll.kernel32.GetConsoleOutputCP(),
+                    f"Console output code page: {windll.kernel32.GetConsoleOutputCP()}",
                     "consolereader",
                     logdir,
                 )
-        log("Spawning %s" % path, "consolereader", logdir)
+        log(f"Spawning {path}", "consolereader", logdir)
         try:
             try:
                 consout = self.getConsoleOut()
@@ -2619,12 +2621,21 @@ def __init__(self, path, pid, tid, env=None, cp=None, c=None, r=None, logdir=Non
                 SetConsoleTitle(path)
 
                 si = GetStartupInfo()
+                # We do not actually need stdio inherited - Wtty reads from the console.
+                # Python 3.x sets STARTF_USESTDHANDLES flag but the stdio handles do not
+                # correspond to console.
+                # That causes Argyll to not write anything to console for us.
+                si.dwFlags = STARTF_USESHOWWINDOW
+                si.wShowWindow = SW_HIDE
+                # si.wShowWindow = SW_SHOW
                 self.__childProcess, _, childPid, self.__tid = CreateProcess(
                     None,
                     path,
                     None,
                     None,
                     False,
+                    # The console has been created in the script that launches this
+                    # class, and reconfigured from here.
                     CREATE_NEW_PROCESS_GROUP,
                     None,
                     None,
@@ -2665,7 +2676,7 @@ def __init__(self, path, pid, tid, env=None, cp=None, c=None, r=None, logdir=Non
                         # log('suspendThread', 'consolereader', logdir)
                         self.suspendThread()
                         paused = True
-                        SetConsoleTitle(path + " (suspended)")
+                        SetConsoleTitle(f"{path} (suspended)")
                         # Hide cursor
                         consout.SetConsoleCursorInfo(cursorinfo[0], 0)
 
@@ -2683,7 +2694,7 @@ def __init__(self, path, pid, tid, env=None, cp=None, c=None, r=None, logdir=Non
                     # Only let child react to CTRL+C, ignore in ConsoleReader
                     pass
 
-            SetConsoleTitle(path + " (terminated)")
+            SetConsoleTitle(f"ConsoleReader: {path} (terminated)")
             consout.SetConsoleCursorInfo(cursorinfo[0], 0)  # Hide cursor
 
             while GetExitCodeProcess(parent) == STILL_ACTIVE:
@@ -2691,7 +2702,7 @@ def __init__(self, path, pid, tid, env=None, cp=None, c=None, r=None, logdir=Non
         except Exception as e:
             log(e, "consolereader_exceptions", logdir)
 
-    def handler(self, sig):
+    def handler(self, sig, logdir):
         log(sig, "consolereader", logdir)
         return False
 
@@ -2726,13 +2737,11 @@ def initConsole(self, consout, c=None, r=None):
 
     def suspendThread(self):
         """Pauses the main thread of the child process."""
-
         handle = windll.kernel32.OpenThread(THREAD_SUSPEND_RESUME, 0, self.__tid)
         SuspendThread(handle)
 
     def resumeThread(self):
         """Un-pauses the main thread of the child process."""
-
         handle = windll.kernel32.OpenThread(THREAD_SUSPEND_RESUME, 0, self.__tid)
         ResumeThread(handle)
 
@@ -2757,7 +2766,6 @@ def __init__(self, strings):
         """This creates an instance of searcher_string. This argument 'strings'
         may be a list; a sequence of strings; or the EOF or TIMEOUT types.
         """
-
         self.eof_index = -1
         self.timeout_index = -1
         self._strings = []
@@ -2793,7 +2801,6 @@ def search(self, buffer, freshlen, searchwindowsize=None):
 
         If there is a match this returns the index of that string, and sets
         'start', 'end' and 'match'. Otherwise, this returns -1."""
-
         absurd_match = len(buffer)
         first_match = absurd_match
 
@@ -2832,7 +2839,6 @@ def search(self, buffer, freshlen, searchwindowsize=None):
 
 
 class searcher_re(object):
-
     """This is regular expression string search helper for the
     spawn.expect_any() method.
 
@@ -2891,7 +2897,8 @@ def search(self, buffer, freshlen, searchwindowsize=None):
         See class spawn for the 'searchwindowsize' argument.
 
         If there is a match this returns the index of that string, and sets
-        'start', 'end' and 'match'. Otherwise, returns -1."""
+        'start', 'end' and 'match'. Otherwise, returns -1.
+        """
         absurd_match = len(buffer)
         first_match = absurd_match
         the_match = None
@@ -3005,7 +3012,6 @@ def which(filename):
     then checks if it is executable. This returns the full path to the filename
     if found and executable. Otherwise this returns None.
     """
-
     # Special case where filename already contains a path.
     if os.path.dirname(filename) != "":
         if os.access(filename, os.X_OK):
diff --git a/DisplayCAL/worker.py b/DisplayCAL/worker.py
index 05aaa407b..141d8558a 100644
--- a/DisplayCAL/worker.py
+++ b/DisplayCAL/worker.py
@@ -318,11 +318,12 @@
 workers = []
 
 
-WAIT_FILE_TEMPLATE = """import os, sys, time
+WAIT_FILE_TEMPLATE = """# coding=utf-8
+import os, sys, time
 if sys.platform != "win32":
     print(*(["\\nCurrent RGB"] + sys.argv[1:]))
-abortfilename = os.path.join("{script_dir}", ".abort")
-okfilename = os.path.join("{script_dir}", ".ok")
+abortfilename = os.path.join(r"{script_dir}", ".abort")
+okfilename = os.path.join(r"{script_dir}", ".ok")
 while True:
     if os.path.isfile(abortfilename):
         break
@@ -1698,7 +1699,6 @@ def set_argyll_bin(parent=None, silent=False, callafter=None, callafter_args=())
 
 
 class EvalFalse(object):
-
     """Evaluate to False in boolean comparisons"""
 
     def __init__(self, wrapped_object):
@@ -1861,7 +1861,9 @@ def __call__(self, *args, **kwargs):
         except Exception as exception:
             if debug:
                 messages = traceback.format_exception(exception)
-                print("[D] Worker raised an unhandled exception: \n" + "\n".join(messages))
+                print(
+                    "[D] Worker raised an unhandled exception: \n" + "\n".join(messages)
+                )
             raise
         if not self.continue_next and self.worker._progress_wnd:
             if hasattr(
@@ -2395,7 +2397,7 @@ def add_measurement_features(
             if (
                 (self.argyll_version <= [1, 0, 4] and not get_arg("-p", args))
                 or (self.argyll_version > [1, 0, 4] and not get_arg("-P", args))
-                and "-d%s" % self.argyll_virtual_display not in args
+                and f"-d{self.argyll_virtual_display}" not in args
             ):
                 if (
                     config.get_display_name() == "Resolve" or non_argyll_prisma
@@ -2763,7 +2765,7 @@ def blend_profile_blackpoint(
                 rgb_space = colormath.get_rgb_space(rgb_space)
                 self.recent.write(desc + "\n")
                 linebuffered_logfiles = []
-                if sys.stdout.isatty():
+                if sys.stdout and hasattr(sys.stdout, "isatty") and sys.stdout.isatty():
                     linebuffered_logfiles.append(print)
                 else:
                     linebuffered_logfiles.append(log)
@@ -3328,7 +3330,7 @@ def check_instrument_sensor_position(self, txt):
         if "read failed due to the sensor being in the wrong position" in txt.lower():
             self.instrument_sensor_position_msg = True
         if self.instrument_sensor_position_msg and " or q to " in txt.lower():
-            self.log("%s: Detected read failed due to wrong sensor position" % appname)
+            self.log(f"{appname}: Detected read failed due to wrong sensor position")
             self.instrument_sensor_position_msg = False
             self.instrument_reposition_sensor()
 
@@ -5495,12 +5497,9 @@ def enumerate_displays_and_ports(
                         if argyll_version_string != self.argyll_version_string:
                             self.set_argyll_version_from_string(argyll_version_string)
                         print(f"ArgyllCMS {self.argyll_version_string}")
-                        defaults[
-                            "copyright"
-                        ] = "No copyright. Created with %s %s and Argyll CMS %s" % (
-                            appname,
-                            version,
-                            argyll_version_string,
+                        defaults["copyright"] = (
+                            f"No copyright. Created with {appname} {version} and "
+                            f"Argyll CMS {argyll_version_string}"
                         )
 
                         if self.argyll_version > [1, 0, 4]:
@@ -5953,7 +5952,10 @@ def exec_cmd(
         # If dry_run is explicitly set to False, ignore dry_run config value
         dry_run = dry_run is not False and (dry_run or getcfg("dry_run"))
         if not capture_output:
-            capture_output = not sys.stdout.isatty()
+            capture_output = False
+            if sys.stdout and hasattr(sys.stdout, "isatty") and not sys.stdout.isatty():
+                capture_output = True
+
         self.clear_cmd_output()
         if None in [cmd, args]:
             if verbose >= 1 and not silent:
@@ -6608,12 +6610,12 @@ def exec_cmd(
                     if os.path.exists(path):
                         pythonpath[i] = win32api.GetShortPathName(path)
                 # Write out .wait.py file
-                scriptfilename =  f"{waitfilename}.py"
-                with open(scriptfilename, "w") as scriptfile:
+                scriptfilename = f"{waitfilename}.py"
+                with open(scriptfilename, "w", encoding="utf-8") as scriptfile:
                     scriptfile.write(pythonscript)
                 scriptfilename = win32api.GetShortPathName(scriptfilename)
                 # Write out .wait.cmd file
-                with open(f"{waitfilename}.cmd", "w") as waitfile:
+                with open(f"{waitfilename}.cmd", "w", encoding="utf-8") as waitfile:
                     waitfile.write("@echo off\n")
                     waitfile.write("echo.\n")
                     waitfile.write("echo Current RGB %*\n")
@@ -6631,7 +6633,7 @@ def exec_cmd(
                 with open(waitfilename, "w") as waitfile:
                     waitfile.write(f"#!/usr/bin/env python3\n{pythonscript}")
                 os.chmod(waitfilename, 0o755)
-                args[index] += '%s ./%s' % (
+                args[index] += "%s ./%s" % (
                     strtr(safe_str(python), {'"': r"\"", "$": r"\$"}),
                     os.path.basename(waitfilename),
                 )
@@ -6898,7 +6900,7 @@ def exec_cmd(
                     "Warning - error during shell script creation:", str(exception)
                 )
         cmdline = [safe_str(arg, fs_enc) for arg in cmdline]
-        working_dir = None if not working_dir else working_dir.encode(fs_enc)
+        working_dir = None if not working_dir else working_dir
         try:
             if not self.measure_cmd and self.argyll_version >= [1, 2]:
                 # Argyll tools will no longer respond to keys
@@ -6920,41 +6922,37 @@ def exec_cmd(
                     ("MIN_DISPLAY_UPDATE_DELAY_MS", [1, 5]),
                     ("DISPLAY_SETTLE_TIME_MULT", [1, 7]),
                 ):
-                    backup = os.getenv("ARGYLL_%s_BACKUP" % name)
+                    backup = os.getenv(f"ARGYLL_{name}_BACKUP")
                     value = None
                     if (
-                        getcfg("measure.override_%s" % name.lower())
+                        getcfg(f"measure.override_{name.lower()}")
                         and self.argyll_version >= version
                     ):
                         if backup is None:
                             # Backup current value if any
-                            current = os.getenv("ARGYLL_%s" % name, "")
-                            os.environ["ARGYLL_%s_BACKUP" % name] = current
+                            current = os.getenv(f"ARGYLL_{name}", "")
+                            os.environ[f"ARGYLL_{name}_BACKUP"] = current
                         else:
                             current = backup
                         if current:
-                            self.log(
-                                "%s: Overriding ARGYLL_%s %s" % (appname, name, current)
-                            )
+                            self.log(f"{appname}: Overriding ARGYLL_{name} {current}")
                         # Override
-                        value = str(getcfg("measure.%s" % name.lower()))
-                        self.log("%s: Setting ARGYLL_%s %s" % (appname, name, value))
+                        value = str(getcfg(f"measure.{name.lower()}"))
+                        self.log(f"{appname}: Setting ARGYLL_{name} {value}")
                     elif backup is not None:
                         value = backup
-                        del os.environ["ARGYLL_%s_BACKUP" % name]
+                        del os.environ[f"ARGYLL_{name}_BACKUP"]
                         if value:
-                            self.log(
-                                "%s: Restoring ARGYLL_%s %s" % (appname, name, value)
-                            )
-                        elif "ARGYLL_%s" % name in os.environ:
-                            del os.environ["ARGYLL_%s" % name]
-                    elif "ARGYLL_%s" % name in os.environ:
+                            self.log(f"{appname}: Restoring ARGYLL_{name} {value}")
+                        elif f"ARGYLL_{name}" in os.environ:
+                            del os.environ[f"ARGYLL_{name}"]
+                    elif f"ARGYLL_{name}" in os.environ:
                         self.log(
-                            "%s: ARGYLL_%s" % (appname, name),
-                            os.getenv("ARGYLL_%s" % name),
+                            f"{appname}: ARGYLL_{name}",
+                            os.getenv(f"ARGYLL_{name}"),
                         )
                     if value:
-                        os.environ["ARGYLL_%s" % name] = value
+                        os.environ[f"ARGYLL_{name}"] = value
             elif cmdname in (
                 get_argyll_utilname("iccgamut"),
                 get_argyll_utilname("tiffgamut"),
@@ -6979,7 +6977,9 @@ def exec_cmd(
                     stderr = tempfile.SpooledTemporaryFile()
                 if capture_output:
                     stdout = tempfile.SpooledTemporaryFile()
-                elif sys.stdout.isatty():
+                elif (
+                    sys.stdout and hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
+                ):
                     stdout = sys.stdout
                 else:
                     stdout = sp.PIPE
@@ -6993,7 +6993,9 @@ def exec_cmd(
                 data_encoding = self.pty_encoding
                 kwargs = dict(timeout=20, cwd=working_dir, env=os.environ)
                 if sys.platform == "win32":
-                    kwargs["codepage"] = windll.kernel32.GetACP()
+                    # FIX: stdio cp1252 vs utf-8 (cp65001) issue under Windows 10/11+
+                    # os.environ["PYTHONLEGACYWINDOWSSTDIO"] = "1"
+                    kwargs["codepage"] = windll.kernel32.GetACP() #1252  # 65001 if capture_output else 1252
                     # As Windows' console always hard wraps at the
                     # rightmost column, increase the buffer width
                     kwargs["columns"] = 160
@@ -7013,7 +7015,11 @@ def exec_cmd(
                     )
                 if log_output:
                     linebuffered_logfiles = []
-                    if sys.stdout.isatty():
+                    if (
+                        sys.stdout
+                        and hasattr(sys.stdout, "isatty")
+                        and sys.stdout.isatty()
+                    ):
                         linebuffered_logfiles.append(print)
                     else:
                         linebuffered_logfiles.append(log)
@@ -7078,13 +7084,17 @@ def exec_cmd(
                         # Minimum Windows version: XP or Server 2003
                         if sys.platform == "win32" and sys.getwindowsversion() < (5, 1):
                             raise Error(lang.getstr("windows.version.unsupported"))
+
                         try:
+                            # print(f"cmdline: {cmdline}")
+                            # print(f"kwargs : {kwargs}")
                             self.subprocess = wexpect.spawn(
                                 cmdline[0], cmdline[1:], **kwargs
                             )
                         except wexpect.ExceptionPexpect as exception:
                             self.retcode = -1
                             raise Error(str(exception))
+
                         if sys.platform == "darwin" and self.measure_cmd:
                             # Caffeinate (prevent sleep/screensaver)
                             caffeinate = which("caffeinate")
@@ -7140,10 +7150,10 @@ def caffeinate_wait():
                     if self.measure_cmd:
                         keyhit_strs = [" or Q to ", r"8\) Exit"]
                         patterns = keyhit_strs + ["Current", r" \d+ of \d+"]
-                        self.log("%s: Starting interaction with subprocess" % appname)
+                        self.log(f"{appname}: Starting interaction with subprocess")
                     else:
                         patterns = []
-                        self.log("%s: Waiting for EOF" % appname)
+                        self.log(f"{appname}: Waiting for EOF")
                     loop = 0
                     pwdsent = False
                     authfailed = False
@@ -7159,12 +7169,12 @@ def caffeinate_wait():
                             curpatterns + [wexpect.EOF, wexpect.TIMEOUT], timeout=1
                         )
                         if self.subprocess.after is wexpect.EOF:
-                            self.log("%s: Reached EOF (OK)" % appname)
+                            self.log(f"{appname}: Reached EOF (OK)")
                             break
                         elif self.subprocess.after is wexpect.TIMEOUT:
                             if not self.subprocess.isalive():
                                 self.log(
-                                    "%s: Subprocess no longer alive (timeout)" % appname
+                                    f"{appname}: Subprocess no longer alive (timeout)"
                                 )
                                 if eof:
                                     break
@@ -7185,25 +7195,22 @@ def caffeinate_wait():
                                 break
                             continue
                         elif self.measure_cmd:
-
                             if [
                                 keyhit_str
                                 for keyhit_str in keyhit_strs
                                 if re.search(keyhit_str, self.subprocess.after)
                             ]:
                                 # Wait for the keypress
-                                self.log("%s: Waiting for send buffer" % appname)
+                                self.log(f"{appname}: Waiting for send buffer")
                                 while not self.send_buffer:
                                     if not self.subprocess.isalive():
                                         self.log(
-                                            "%s: Subprocess no longer alive (unknown reason)"
-                                            % appname
+                                            f"{appname}: Subprocess no longer alive (unknown reason)"
                                         )
                                         break
                                     sleep(0.05)
                                 self.log(
-                                    "%s: Send buffer received: %s"
-                                    % (appname, self.send_buffer)
+                                    f"{appname}: Send buffer received: {self.send_buffer}"
                                 )
                             if (
                                 self.send_buffer is not None
@@ -7217,8 +7224,7 @@ def caffeinate_wait():
                                     # Restore madTPG OSD and fullscreen
                                     self.madtpg_restore_settings(False)
                                 self.log(
-                                    "%s: Sending buffer: %r"
-                                    % (appname, self.send_buffer)
+                                    f"{appname}: Sending buffer: {self.send_buffer}"
                                 )
                                 self._safe_send(self.send_buffer)
                                 self.send_buffer = None
@@ -7229,10 +7235,10 @@ def caffeinate_wait():
                     # We can't use wait() because it might block in the
                     # case of a timeout
                     if self.subprocess.isalive():
-                        self.log("%s: Checking subprocess status" % appname)
+                        self.log(f"{appname}: Checking subprocess status")
                         while self.subprocess.isalive():
                             sleep(0.1)
-                        self.log("%s: Subprocess no longer alive (OK)" % appname)
+                        self.log(f"{appname}: Subprocess no longer alive (OK)")
                     self.retcode = self.subprocess.exitstatus
                     if authfailed:
                         raise Error(lang.getstr("auth.failed"))
@@ -7410,13 +7416,13 @@ def caffeinate_wait():
                         self.log(cd_exception)
                         profiling_inhibit = False
                     else:
-                        self.log(appname + ": Uninhibited display device")
+                        self.log(f"{appname}: Uninhibited display device")
                 if not profiling_inhibit:
                     # Fallback - restore display profile
-                    self.log(appname + ": Re-assigning display profile...")
+                    self.log(f"{appname}: Re-assigning display profile...")
                     cdinstall = self._attempt_install_profile_colord(display_profile)
                     if cdinstall is True:
-                        self.log(appname + ": Successfully re-assigned display profile")
+                        self.log(f"{appname}: Successfully re-assigned display profile")
                     elif not cdinstall:
                         self.log(lang.getstr("calibration.load_error"))
                     # Remove temp sRGB profile
@@ -8769,7 +8775,6 @@ def get_display(self):
         """Get the currently configured display.
 
         Returned is the Argyll CMS dispcal/dispread -d argument
-
         """
         display_name = config.get_display_name(None, True)
         if display_name == "Web @ localhost":
@@ -8873,7 +8878,6 @@ def get_display_name_short(self, prepend_manufacturer=False, prefer_edid=False):
 
         If name can't be shortened (e.g. because it's already 10 characters
         or less), return full string
-
         """
         display_name = self.get_display_name(prepend_manufacturer, prefer_edid)
         if len(display_name) > 10:
@@ -8897,14 +8901,15 @@ def get_display_name_short(self, prepend_manufacturer=False, prefer_edid=False):
                     # Weigh parts further to the right higher
                     display_name = re.sub(r"^[^([{\w]+", "", part)
                     maxweight = weight
+
+        # Shortname should not contain any spaces.
+        display_name = re.sub(r"[\s]+", "_", display_name)
         return display_name
 
     def get_dispwin_display_profile_argument(self, display_no=0):
-        """Return argument corresponding to the display profile for use
-        with dispwin.
-
-        Will either return '-L' (use current profile) or a filename
+        """Return argument corresponding to the display profile for use with dispwin.
 
+        Will either return '-L' (use current profile) or a filename.
         """
         arg = "-L"
         try:
@@ -11197,7 +11202,11 @@ def create_profile(
                 ):
                     # Smooth existing B2A tables
                     linebuffered_logfiles = []
-                    if sys.stdout.isatty():
+                    if (
+                        sys.stdout
+                        and hasattr(sys.stdout, "isatty")
+                        and sys.stdout.isatty()
+                    ):
                         linebuffered_logfiles.append(print)
                     else:
                         linebuffered_logfiles.append(log)
@@ -12375,7 +12384,7 @@ def update_profile(
 
     def get_logfiles(self, include_progress_buffers=True):
         linebuffered_logfiles = []
-        if sys.stdout.isatty():
+        if sys.stdout and hasattr(sys.stdout, "isatty") and sys.stdout.isatty():
             linebuffered_logfiles.append(print)
         else:
             linebuffered_logfiles.append(log)
@@ -13141,12 +13150,12 @@ def prepare_colprof(
         if not os.path.exists(f"{in_out_file}.ti3"):
             return (
                 Error(
-                    lang.getstr("error.measurement.file_missing",  f"{in_out_file}.ti3")
+                    lang.getstr("error.measurement.file_missing", f"{in_out_file}.ti3")
                 ),
                 None,
             )
         if not os.path.isfile(f"{in_out_file}.ti3"):
-            return Error(lang.getstr("file_notfile",  f"{in_out_file}.ti3")), None
+            return Error(lang.getstr("file_notfile", f"{in_out_file}.ti3")), None
 
         cmd = get_argyll_util("colprof")
         args = ["-v", f"-q{getcfg('profile.quality')}", f"-a{getcfg('profile.type')}"]
@@ -13397,7 +13406,6 @@ def prepare_dispcal(self, calibrate=True, verify=False, dry_run=False):
         All options are read from the user configuration.
         You can choose if you want to calibrate and/or verify by passing
         the corresponding arguments.
-
         """
         cmd = get_argyll_util("dispcal")
         args = ["-v2"]
@@ -13510,8 +13518,9 @@ def prepare_dispcal(self, calibrate=True, verify=False, dry_run=False):
             else:
                 args.append("-w%s,%s" % (whitepoint_x, whitepoint_y))
             luminance = getcfg("calibration.luminance", False)
-            if luminance:
-                args.append("-b%s" % luminance)
+            self.log(f"{appname}: luminance: {luminance}")
+            if luminance is not None:
+                args.append("-b{luminance}")
             if getcfg("trc"):
                 args.append("-" + getcfg("trc.type") + str(getcfg("trc")))
                 args.append("-f%s" % getcfg("calibration.black_output_offset"))
@@ -13550,7 +13559,9 @@ def prepare_dispcal(self, calibrate=True, verify=False, dry_run=False):
                 # above zero device input if set up correctly. Using this option
                 # with a display that is not well behaved may result in a loss
                 # of shadow detail.
-                args.append("-b")
+                self.log("skipping -b parameter")
+                # args.append("-b")
+                pass
             if verify:
                 if calibrate and type(verify) == int:
                     args.append("-e%s" % verify)  # Verify final computed curves
@@ -13588,7 +13599,6 @@ def prepare_dispread(self, apply_calibration=True):
         either the previously by dispcal created one by passing in True, by
         passing in a valid path to a .cal file, or by passing in None
         (current video card gamma table).
-
         """
         self.lastcmdname = get_argyll_utilname("dispread")
         inoutfile = self.setup_inout()
@@ -13962,7 +13972,7 @@ def prepare_dispwin(self, cal=None, profile_path=None, install=True):
                         # avoid profile install issues
                         profile_tmp_path = os.path.join(
                             tmp_dir,
-                            safe_asciize(profile_name).decode("utf-8", "ignore")
+                            safe_asciize(profile_name).decode("utf-8", "ignore"),
                         )
                     else:
                         profile_tmp_path = os.path.join(tmp_dir, profile_name)
@@ -14447,7 +14457,7 @@ def safe_send(self, bytes_):
         return True
 
     def _safe_send(self, bytes_, retry=3, obfuscate=False):
-        """Safely send a keystroke to the current subprocess"""
+        """Safely send a keystroke to the current subprocess."""
         for i in range(0, retry):
             if obfuscate:
                 logbytes = "***"
@@ -14455,11 +14465,15 @@ def _safe_send(self, bytes_, retry=3, obfuscate=False):
                 logbytes = bytes_
             self.logger.info("Sending key(s) %r (%i)" % (logbytes, i + 1))
             try:
-                self.subprocess.send(bytes_.encode())
+                if sys.platform == "win32":
+                    # directly send the bytes_ without encoding to anything
+                    self.subprocess.send(bytes_)
+                else:
+                    # for Linux and MacOS encode to bytes
+                    self.subprocess.send(bytes_.encode())
             except Exception as exception:
                 self.logger.exception("Exception: %s" % str(exception))
             else:
-                # if wrote == len(bytes_):
                 return True
             sleep(0.25)
         return False
@@ -14553,7 +14567,7 @@ def set_terminal_cgats(self, cgats):
         self.terminal.cgats = cgats
 
     def setup_inout(self, basename=None):
-        """Setup in/outfile basename and session logfile"""
+        """Setup in/outfile basename and session logfile."""
         dirname = self.create_tempdir()
         if not dirname or isinstance(dirname, Exception):
             return dirname
@@ -15032,7 +15046,7 @@ def calculate_gamut(
                     src = os.path.splitext(os.path.basename(src))[0]
                 else:
                     if mods:
-                        src += " " + "".join([f"[{mod.upper()}]"  for mod in mods])
+                        src += " " + "".join([f"[{mod.upper()}]" for mod in mods])
                     src_path = get_data_path(f"ref/{src}.gam")
                 if not src_path:
                     continue
@@ -15083,6 +15097,7 @@ def calculate_gamut(
                     filename = tmpfilename
 
                 try:
+
                     def tweak_vrml(vrml):
                         # Set viewpoint further away
                         vrml = re.sub(
@@ -15105,7 +15120,9 @@ def tweak_vrml(vrml):
                             )
                         # Add range to axes
                         vrml = re.sub(
-                            rb'(string\s*\[")(\+?)(L\*)("\])', rb'\1\3", b"\2\0$\4', vrml
+                            rb'(string\s*\[")(\+?)(L\*)("\])',
+                            rb'\1\3", b"\2\0$\4',
+                            vrml,
                         )
                         vrml = re.sub(
                             rb'(string\s*\[")([+\-]?)(a\*)("\])',
@@ -15191,10 +15208,15 @@ def calibrate(self, remove=False):
         result = self.detect_video_levels()
         if isinstance(result, Exception) or not result:
             return result
-        capture_output = not sys.stdout.isatty()
+        capture_output = False
+        if sys.stdout and hasattr(sys.stdout, "isatty") and not sys.stdout.isatty():
+            capture_output = True
         cmd, args = self.prepare_dispcal()
         if not isinstance(cmd, Exception):
+            print(f"cmd: {cmd}")
+            print(f"args: {args}")
             result = self.exec_cmd(cmd, args, capture_output=capture_output)
+            print(f"result: {result}")
         else:
             result = cmd
         if not isinstance(result, Exception) and result and getcfg("trc"):
@@ -15203,10 +15225,13 @@ def calibrate(self, remove=False):
                 getcfg("profile.name.expanded"),
                 getcfg("profile.name.expanded"),
             )
+            print(f"dst_pathname: {dst_pathname}")
             cal = args[-1] + ".cal"
+            print(f"cal         : {cal}")
             result = check_cal_isfile(
                 cal, lang.getstr("error.calibration.file_not_created")
             )
+            print(f"result      : {result}")
             if not isinstance(result, Exception) and result:
                 cal_cgats = add_dispcal_options_to_cal(cal, self.options_dispcal)
                 if cal_cgats:
@@ -15996,9 +16021,13 @@ def ti1_lookup_to_ti3(
                 ofile.write(b"\n")
                 ofile.write(b"NUMBER_OF_FIELDS ")
                 if include_sample_name:
-                    ofile.write(bytes(str(2 + len(icolor) + len(color_rep)), "utf-8") + b"\n")
+                    ofile.write(
+                        bytes(str(2 + len(icolor) + len(color_rep)), "utf-8") + b"\n"
+                    )
                 else:
-                    ofile.write(bytes(str(1 + len(icolor) + len(color_rep)), "utf-8") + b"\n")
+                    ofile.write(
+                        bytes(str(1 + len(icolor) + len(color_rep)), "utf-8") + b"\n"
+                    )
                 ofile.write(b"BEGIN_DATA_FORMAT\n")
                 ofile.write(b"SAMPLE_ID ")
                 if include_sample_name:
@@ -16007,7 +16036,9 @@ def ti1_lookup_to_ti3(
                     ofile.write(olabel + b" " + ilabel + b"\n")
                 ofile.write(b"END_DATA_FORMAT\n")
                 ofile.write(b"\n")
-                ofile.write(b"NUMBER_OF_SETS " + bytes(str(len(odata)), "utf-8") + b"\n")
+                ofile.write(
+                    b"NUMBER_OF_SETS " + bytes(str(len(odata)), "utf-8") + b"\n"
+                )
                 ofile.write(b"BEGIN_DATA\n")
             if pcs == "x":
                 # Need to scale XYZ coming from xicclu, Lab is already scaled
@@ -16217,8 +16248,10 @@ def ti3_lookup_to_ti1(
                 # add required fields to DATA_FORMAT if not yet present
                 if (
                     not bytes(required[0], "utf-8") in list(ti3v.DATA_FORMAT.values())
-                    and not bytes(required[1], "utf-8") in list(ti3v.DATA_FORMAT.values())
-                    and not bytes(required[2], "utf-8") in list(ti3v.DATA_FORMAT.values())
+                    and not bytes(required[1], "utf-8")
+                    in list(ti3v.DATA_FORMAT.values())
+                    and not bytes(required[2], "utf-8")
+                    in list(ti3v.DATA_FORMAT.values())
                 ):
                     ti3v.DATA_FORMAT.add_data(required)
                 ti1out.write(b'KEYWORD "COLOR_REP"\n')
@@ -16261,7 +16294,11 @@ def ti3_lookup_to_ti1(
                 )
                 ti1out.write(cie_.encode("utf-8"))
             else:
-                cie_ = b"%s %s %s\n" % (bytes(str(i + 1), "utf-8"), b" ".join(device), b" ".join(cie))
+                cie_ = b"%s %s %s\n" % (
+                    bytes(str(i + 1), "utf-8"),
+                    b" ".join(device),
+                    b" ".join(cie),
+                )
                 ti1out.write(cie_)
             if i > len(wp) - 1:  # don't include whitepoint patches in ti3
                 # set device values in ti3
@@ -16269,7 +16306,9 @@ def ti3_lookup_to_ti1(
                     # Assuming 0..100, 4 decimal digits is
                     # enough for roughly 19 bits integer
                     # device values
-                    ti3v.DATA[i - len(wp)][v.decode("utf-8")] = round(float(device[n]), 4)
+                    ti3v.DATA[i - len(wp)][v.decode("utf-8")] = round(
+                        float(device[n]), 4
+                    )
                 # set PCS values in ti3
                 for n, v in enumerate(cie):
                     ti3v.DATA[i - len(wp)][required[n]] = float(v)
@@ -16385,7 +16424,7 @@ def _download(self, uri, force=False, download_dir=None):
             uri = response.geturl()
             filename = Path(Path(uri).name)
             actualhash = sha256()
-            if hashes and False:  # skip this for now
+            if hashes and False:  # skip this for now
                 # Read max. 64 KB hashes
                 hashesdata = hashes.read(1024 * 64)
                 hashes.close()
@@ -16789,7 +16828,7 @@ def measure_ti1(
             if getcfg("extra_args.dispread").strip():
                 args += parse_argument_string(getcfg("extra_args.dispread"))
         result = self.add_measurement_features(
-            args,
+            quote_args(args) if sys.platform == "win32" else args,
             cmd == get_argyll_util("dispread"),
             allow_nondefault_observer=is_ccxx_testchart(),
             allow_video_levels=allow_video_levels,
@@ -17029,7 +17068,7 @@ def wrapup(self, copy=True, remove=True, dst_path=None, ext_filter=None):
                                 )
                         else:
                             if verbose >= 2:
-                                print(appname + ": Removing temporary file", src)
+                                print(f"{appname}: Removing temporary file", src)
                             try:
                                 os.remove(src)
                             except Exception as exception:
@@ -17128,22 +17167,18 @@ def _write(self, txt):
             and hasattr(self.patterngenerator, "conn")
         )
         if use_patterngenerator or self.use_madnet_tpg or self._use_patternwindow:
-            # sometimes the data is not fully received
+            # sometimes the data is not fully received
             if self.partial_data != "":
-                # combine the partial data with the currently received one
+                # combine the partial data with the currently received one
                 self.log(f"Combining previous data of: {self.partial_data}")
                 self.log(f"with                      : {txt}")
                 txt = self.partial_data + txt
                 self.log(f"to                        : {txt}")
 
-            rgb = re.search(
-                r"Current RGB(?:\s+\d+){3}((?:\s+\d+(?:\.\d+)){3})", txt
-            )
+            rgb = re.search(r"Current RGB(?:\s+\d+){3}((?:\s+\d+(?:\.\d+)){3})", txt)
             # Check if the data is partial
             if "Current RGB" in txt and rgb is None:
-                self.log(
-                    f"Data is not fully received, storing partial data: {txt}"
-                )
+                self.log(f"Data is not fully received, storing partial data: {txt}")
                 self.partial_data = txt
 
             if rgb:
diff --git a/DisplayCAL/wxProfileInfo.py b/DisplayCAL/wxProfileInfo.py
index 6fc7d9dfc..edd8acc18 100644
--- a/DisplayCAL/wxProfileInfo.py
+++ b/DisplayCAL/wxProfileInfo.py
@@ -1459,9 +1459,8 @@ def LoadProfile(self, profile, reset=True):
             )
             for key, name, _volume in gamuts:
                 try:
-                    gamut_coverage = float(
-                        profile.tags.meta.getvalue(f"GAMUT_coverage({key})")
-                    )
+                    gamut_coverage = profile.tags.meta.getvalue(f"GAMUT_coverage({key})")
+                    gamut_coverage = float(gamut_coverage) if gamut_coverage is not None else gamut_coverage
                 except (TypeError, ValueError):
                     traceback.print_exc()
                     gamut_coverage = None
diff --git a/DisplayCAL/wxScriptingClient.py b/DisplayCAL/wxScriptingClient.py
index 0904ec253..0072eacf7 100644
--- a/DisplayCAL/wxScriptingClient.py
+++ b/DisplayCAL/wxScriptingClient.py
@@ -12,6 +12,7 @@
 from DisplayCAL.meta import name as appname
 from DisplayCAL.util_str import safe_str, universal_newlines
 from DisplayCAL.wexpect import split_command_line
+# from wexpect import split_command_line
 from DisplayCAL.wxaddons import wx
 from DisplayCAL.wxfixes import GenBitmapButton
 from DisplayCAL.wxwindows import BaseApp, SimpleTerminal, numpad_keycodes
@@ -120,7 +121,9 @@ def OnClose(self, event):
             with open(self.historyfilename, "wb") as historyfile:
                 for command in self.history:
                     if command:
-                        historyfile.write(safe_str(command, "UTF-8") + os.linesep)
+                        historyfile.write(
+                            (safe_str(command, "UTF-8") + os.linesep).encode("utf-8")
+                        )
         except EnvironmentError as exception:
             print("Warning - couldn't write history file:", exception)
         self.listening = False
@@ -291,7 +294,7 @@ def get_last_line(self):
 
     def get_response(self):
         try:
-            return "< " + "\n< ".join(self.conn.get_single_response().splitlines())
+            return b"< " + b"\n< ".join(self.conn.get_single_response().splitlines())
         except socket.error as exception:
             return exception
 
diff --git a/DisplayCAL/wxSynthICCFrame.py b/DisplayCAL/wxSynthICCFrame.py
index ea29e0fac..a2ff54a01 100644
--- a/DisplayCAL/wxSynthICCFrame.py
+++ b/DisplayCAL/wxSynthICCFrame.py
@@ -1002,7 +1002,7 @@ def create_profile(
                     rgb_space[0] = 1.0  # Set gamma to 1.0 (not actually used)
                     rgb_space = colormath.get_rgb_space(rgb_space)
                     linebuffered_logfiles = []
-                    if sys.stdout.isatty():
+                    if sys.stdout and hasattr(sys.stdout, "isatty") and sys.stdout.isatty():
                         linebuffered_logfiles.append(print)
                     else:
                         linebuffered_logfiles.append(log)
diff --git a/DisplayCAL/wxVRML2X3D.py b/DisplayCAL/wxVRML2X3D.py
index 7877c73db..1b4c29721 100644
--- a/DisplayCAL/wxVRML2X3D.py
+++ b/DisplayCAL/wxVRML2X3D.py
@@ -157,7 +157,12 @@ def main():
                 )
         if result is None:
             print("No filename given.")
-        if sys.stdout.isatty() and "--batch" not in sys.argv[1:]:
+        if (
+            sys.stdout
+            and hasattr(sys.stdout, "isatty")
+            and sys.stdout.isatty()
+            and "--batch" not in sys.argv[1:]
+        ):
             input("Press RETURN to exit")
         sys.exit(int(not result))
     else:
@@ -264,9 +269,11 @@ def vrmlfile2x3dfile(
     if worker:
         worker.clear_cmd_output()
         worker.start(
-            lambda result: show_result_dialog(result, wx.GetApp().GetTopWindow())
-            if isinstance(result, Exception)
-            else result and view and launch_file(finalpath),
+            lambda result: (
+                show_result_dialog(result, wx.GetApp().GetTopWindow())
+                if isinstance(result, Exception)
+                else result and view and launch_file(finalpath)
+            ),
             x3dom.vrmlfile2x3dfile,
             wargs=(vrmlpath, x3dpath, html, embed, force, cache, worker),
             progress_title=lang.getstr("vrml_to_x3d_converter"),
diff --git a/DisplayCAL/wxenhancedplot.py b/DisplayCAL/wxenhancedplot.py
index 0314c383c..0c569293f 100755
--- a/DisplayCAL/wxenhancedplot.py
+++ b/DisplayCAL/wxenhancedplot.py
@@ -807,10 +807,10 @@ def getTitle(self, title=""):
 
     def draw(self, dc):
         for o in self.objects:
-            # t=_time.clock()          # profile info
+            # t=_time.time()          # profile info
             o._pointSize = self._pointSize
             o.draw(dc, self.printerScale)
-            # dt= _time.clock()-t
+            # dt= _time.time() - t
             # print o, "time=", dt
 
     def getSymExtent(self, printerScale):
@@ -1790,7 +1790,7 @@ def _Draw(self, graphics, xAxis=None, yAxis=None, dc=None):
         # Draw extras
         self._drawExtras(dc, p1, p2, scale, shift)
         # Draw the lines and markers
-        # start = _time.clock()
+        # start = _time.time()
         graphics.draw(dc)
 
         # draw legend makers and text (if inside) so that it overlaps graphics
@@ -1800,7 +1800,7 @@ def _Draw(self, graphics, xAxis=None, yAxis=None, dc=None):
                 dc, graphics, rhsW, topH, legendBoxWH, legendSymExt, legendTextExt
             )
 
-        # print "entire graphics drawing took: %f second"%(_time.clock() - start)
+        # print "entire graphics drawing took: %f second"%(_time.time() - start)
         # remove the clipping region
         dc.DestroyClippingRegion()
 
@@ -3146,10 +3146,10 @@ def OnPlotDraw4(self, event):
         self.client.Draw(drawObj)
 
         # # profile
-        # start = _time.clock()
+        # start = _time.time()
         # for x in range(10):
         #    self.client.Draw(drawObj)
-        # print "10 plots of Draw4 took: %f sec."%(_time.clock() - start)
+        # print "10 plots of Draw4 took: %f sec."%(_time.time() - start)
         # # profile end
 
     def OnPlotDraw5(self, event):
diff --git a/DisplayCAL/wxfixes.py b/DisplayCAL/wxfixes.py
index 474b447ab..3682044c2 100644
--- a/DisplayCAL/wxfixes.py
+++ b/DisplayCAL/wxfixes.py
@@ -139,10 +139,10 @@ def IconFromBitmap(bmp):
     wx.IconFromBitmap = wx.Icon
     wx.ImageFromStream = wx.Image
     wx.ListCtrl.InsertStringItem = lambda self, index, label: self.InsertItem(
-        index, label
+        int(index), str(label)
     )
     wx.ListCtrl.SetStringItem = lambda self, index, col, label: self.SetItem(
-        index, col, label
+        int(index), int(col), str(label)
     )
     wx.Menu.RemoveItem = lambda self, item: self.Remove(item)
     wx.NamedColour = wx.Colour
@@ -1352,7 +1352,7 @@ def DrawLabel(self, dc, width, height, dx=0, dy=0):
         tw, th = dc.GetTextExtent(label)
         if sys.platform != "win32" and not self.up:
             dx = dy = self.labelDelta
-        dc.DrawText(label, (width - tw) / 2 + dx, (height - th) / 2 + dy)
+        dc.DrawText(label, int((width - tw) / 2) + dx, int((height - th) / 2) + dy)
 
     def Enable(self, enable=True):
         if enable != self.Enabled:
@@ -1738,14 +1738,14 @@ def DrawLabel(self, dc, width, height, dx=0, dy=0):
         if sys.platform != "win32" and not self.up:
             dx = dy = self.labelDelta
 
-        pos_x = (width - bw - sw - tw) / 2 + dx  # adjust for bitmap and text to centre
+        pos_x = int((width - bw - sw - tw) / 2) + dx  # adjust for bitmap and text to centre
         if bmp is not None:
             dc.DrawBitmap(
-                bmp, pos_x, (height - bh) / 2 + dy, hasMask
+                bmp, pos_x, int((height - bh) / 2) + dy, hasMask
             )  # draw bitmap if available
             pos_x = pos_x + sw  # extra spacing from bitmap
 
-        dc.DrawText(label, pos_x + dx + bw, (height - th) / 2 + dy)  # draw the text
+        dc.DrawText(label, pos_x + dx + bw, int((height - th) / 2) + dy)  # draw the text
 
 
 class ThemedGenBitmapTextButton(ThemedGenButton, GenBitmapTextButton):
diff --git a/DisplayCAL/wxwindows.py b/DisplayCAL/wxwindows.py
index 49435bbf6..4c9e06500 100644
--- a/DisplayCAL/wxwindows.py
+++ b/DisplayCAL/wxwindows.py
@@ -76,6 +76,7 @@
     EVT_BETTERTIMER,
 )
 from DisplayCAL.wexpect import split_command_line
+# from wexpect import split_command_line
 from DisplayCAL.wxfixes import (
     GenBitmapButton,
     GenButton,
@@ -2014,19 +2015,18 @@ def send_command(self, scripting_host_name_suffix, command):
                     if response == scripting_host:
                         conn.send_command(command)
                         response = conn.get_single_response()
-                        print("%s %s returned" % (scripting_host, command), response)
+                        print(f"{scripting_host} {command} returned", response)
                     else:
                         print(
-                            "Warning - %s not running "
-                            "under expected port" % scripting_host,
+                            f"Warning - {scripting_host} not running under expected port",
                             port,
                         )
                     del conn
                     return response
             else:
-                print("Warning - %s not running?" % scripting_host)
+                print(f"Warning - {scripting_host} not running?")
         except Exception as exception:
-            print("Warning - couldn't talk to %s:" % scripting_host, exception)
+            print(f"Warning - couldn't talk to {scripting_host}:", exception)
             return exception
 
     def focus_handler(self, event=None):
diff --git a/Makefile b/Makefile
index 465f29e80..ba864718b 100644
--- a/Makefile
+++ b/Makefile
@@ -4,7 +4,7 @@ SETUP_PY_FLAGS = --use-distutils
 VERSION := $(shell cat VERSION_BASE)
 VERSION_FILE=$(CURDIR)/VERSION_BASE
 VIRTUALENV_DIR:=.venv
-SYSTEM_PYTHON:=python3
+SYSTEM_PYTHON?=python3
 
 all: build FORCE
 
@@ -16,30 +16,37 @@ help:
 
 .PHONY: venv
 venv:
-	$(SYSTEM_PYTHON) -m venv $(VIRTUALENV_DIR); \
-	source ./$(VIRTUALENV_DIR)/bin/activate; \
-	pip install -r requirements.txt; \
-	pip install -r requirements-dev.txt;
+	@printf "\n\033[36m--- $@: Creating Local virtualenv '$(VIRTUALENV_DIR)' using '$(SYSTEM_PYTHON)' ---\033[0m\n"
+	$(SYSTEM_PYTHON) -m venv $(VIRTUALENV_DIR)
 
-build: venv FORCE
+build:
+	@printf "\n\033[36m--- $@: Building ---\033[0m\n"
+	echo -e "\n\033[36m--- $@: Local install into virtualenv '$(VIRTUALENV_DIR)' ---\033[0m\n";
 	source ./$(VIRTUALENV_DIR)/bin/activate; \
-	python3 -m build;
+	echo -e "\n\033[36m--- $@: Using python interpretter '`which python`' ---\033[0m\n"; \
+	pip install -r requirements.txt; \
+	pip install -r requirements-dev.txt; \
+	python -m build;
 
 install:
+	@printf "\n\033[36m--- $@: Installing displaycal to virtualenv at '$(VIRTUALENV_DIR)' using '$(SYSTEM_PYTHON)' ---\033[0m\n"
 	source ./$(VIRTUALENV_DIR)/bin/activate; \
 	pip install ./dist/DisplayCAL-$(VERSION)-*.whl --force-reinstall;
 
 launch:
-	source ./.venv/bin/activate; \
+	@printf "\n\033[36m--- $@: Launching DisplayCAL ---\033[0m\n"
+	source ./$(VIRTUALENV_DIR)/bin/activate; \
 	displaycal
 
 clean: FORCE
+	@printf "\n\033[36m--- $@: Clean ---\033[0m\n"
 	-rm -rf .pytest_cache
-	-rm -rf .venv
+	-rm -rf $(VIRTUALENV_DIR)
 	-rm -rf dist
 	-rm -rf build
 
 clean-all: clean
+	@printf "\n\033[36m--- $@: Clean All---\033[0m\n"
 	-rm -f INSTALLED_FILES
 	-rm -f setuptools-*.egg
 	-rm -f use-distutils
@@ -54,6 +61,7 @@ html:
 	./setup.py readme
 
 new-release:
+	@printf "\n\033[36m--- $@: Generating New Release ---\033[0m\n"
 	git add $(VERSION_FILE)
 	git commit -m "Version $(VERSION)"
 	git push
@@ -62,14 +70,20 @@ new-release:
 	git merge develop
 	git tag $(VERSION)
 	git push origin main --tags
-	python3 -m build
-# 	twine check dist/DisplayCAL-$(VERSION).whl
-	twine check dist/DisplayCAL-$(VERSION).tar.gz
-# 	twine upload dist/DisplayCAL-$(VERSION).whl
-	twine upload dist/DisplayCAL-$(VERSION).tar.gz
+	source ./$(VIRTUALENV_DIR)/bin/activate; \
+	echo -e "\n\033[36m--- $@: Using python interpretter '`which python`' ---\033[0m\n"; \
+	pip install -r requirements.txt; \
+	pip install -r requirements-dev.txt; \
+	python -m build; \
+	twine check dist/DisplayCAL-$(VERSION).tar.gz; \
+	twine upload dist/DisplayCAL-$(VERSION).tar.gz;
 
-tests: venv build install
-	source $(VIRTUALENV_DIR)/bin/activate; \
+.PHONY: tests
+tests:
+	@printf "\n\033[36m--- $@: Run Tests ---\033[0m\n"
+	echo -e "\n\033[36m--- $@: Using virtualenv at '$(VIRTUALENV_DIR)' ---\033[0m\n";
+	source ./$(VIRTUALENV_DIR)/bin/activate; \
+	echo -e "\n\033[36m--- $@: Using python interpretter '`which python`' ---\033[0m\n"; \
 	pytest -v -n auto -W ignore --color=yes --cov-report term;
 
 # https://www.gnu.org/software/make/manual/html_node/Force-Targets.html
diff --git a/README.md b/README.md
index 9920f683c..1f12452fa 100644
--- a/README.md
+++ b/README.md
@@ -14,51 +14,239 @@ understandably, he lost his passion to the project. Now, it is time for us, the
 DisplayCAL community, to contribute back to this great tool.
 
 This project is based on the ``HEAD`` of the Sourceforge version, which had 5 extra
-commits that Florian has created after the ``3.8.9.3`` release on 14 Jan 2020.
+commits that Florian has created over the ``3.8.9.3`` release on 14 Jan 2020.
 
-Status Update (18 May 2022)
+Thanks to all the efforts put by the community DisplayCAL is now working with Python
+3.8+:
+
+![image](screenshots/DisplayCAL-screenshot-GNOME-3.9.5-running_on_python3.10.png)
+
+Installation Instructions
+=========================
+
+Follow the instructions depending on you OS:
+
+- Windows:
+  - [Install with Installer](#install-with-installer-windows)
+  - [Install through PyPI](#install-through-pypi-windows)
+  - [Build from source](#build-from-source-windows)
+  - [Build the Installer](#build-the-installer-windows)
+- Linux and MacOS:
+  - [Prerequisites](#prerequisites-linux-and-macos)
+  - [Instal through PyPI](#install-through-pypi-linux--macos)
+  - [Build From Source (Makefile Workflow)](#build-from-source-makefile-workflow-linux--macos)
+  - [Build From Source (Manual)](#build-from-source-manual-linux--macos)
+
+Installation Instructions (Windows)
+===================================
+
+Install with Installer (Windows)
+--------------------------------
+
+We now have a proper [installer](https://www.github.com/eoyilmaz/releases) for Windows
+and this is the preffered way of running DisplayCAL under Windows (unless you want to
+test the latest code).
+
+Install through PyPI (Windows)
+------------------------------
+
+If you desire so, you can install DisplayCAL through PyPI. You need to use Python 3.9,
+3.10 or 3.11 and use the system Python, so no Virtual Environments. We recommend using
+Python 3.11. Here is the installation procedure:
+
+1- Download and install one of Python 3.9, 3.10 or 3.11. Unfortunatelly Python 3.12 is
+   not currently working:
+
+   Here is some download links that are now hidden in Python's home page:
+   - [python-3.9.13-amd64.exe](https://www.python.org/ftp/python/3.9.13/python-3.9.13-amd64.exe)
+   - [python-3.10.11-amd64.exe](https://www.python.org/ftp/python/3.10.11/python-3.10.11-amd64.exe)
+   - Python 3.11 can be downloaded directly from [Python.org](https://www.python.org/downloads/windows/).
+   - Python 3.12 is not supported currently.
+
+   Some of the libraries that DisplayCAL depends on are not working or not supported
+   with Python 3.12. So, stick with Python 3.9, 3.10 or 3.11 until we find a solution.
+
+   Also don't forget to select "Add Python 3.xx to PATH" in the installer.
+
+   ![image](screenshots/Python_3.9_Installation_Windows.jpg)
+
+2- Download and install Visual Studio Build Tools:
+
+   Download from https://visualstudio.microsoft.com/visual-cpp-build-tools/
+
+   Select "Desktop development with C++" only:
+
+   ![image](screenshots/Visual_Studio_Build_Tools.jpg)
+
+3- Install DisplayCAL through PyPI:
+
+   After both Python and Visual Studio Build Tools are installed run the following in
+   the command prompt:
+
+   ```shell
+   pip install displaycal
+   ```
+
+4- Run DisplayCAL:
+
+   ```shell
+   python -m DisplayCAL
+   ```
+
+> [!WARNING]
+> Under Windows use the system Python installation instead of a virtual environment as
+> Wexpect module cannot read ArgyllCMS command outputs from inside a virtual
+> environment.
+
+> [!WARNING]
+> Under Windows don't run DisplayCAL inside the IDE (Vscode, Pycharm etc.) terminal as
+> most of the IDE's are creating virtual terminals and it is not possible to capture the
+> command outputs with Wexpect.
+
+Build From Source (Windows)
 ---------------------------
 
-DisplayCAL is in [PyPI](https://pypi.org/project/DisplayCAL/) now (yay!).
+Under Windows the `makefile` workflow will not work, using a virtual environment is also
+breaking Wexpect module, so you need to use your system Python installation. Currently,
+DisplayCAL will run with Python 3.9, 3.10 and 3.11, but Python 3.12 is not supported. To
+build DisplayCAL from source under Windows follow these steps:
+
+1- Download and install one of Python 3.9, 3.10 or 3.11. Unfortunatelly Python 3.12 is
+   not currently working:
+
+   Here is some download links that are now hidden in Python's home page:
+   - [python-3.9.13-amd64.exe](https://www.python.org/ftp/python/3.9.13/python-3.9.13-amd64.exe)
+   - [python-3.10.11-amd64.exe](https://www.python.org/ftp/python/3.10.11/python-3.10.11-amd64.exe)
+   - Python 3.11 can be downloaded directly from [Python.org](https://www.python.org/downloads/windows/).
+   - Python 3.12 is not supported currently.
+
+   Some of the libraries that DisplayCAL depends on are not working or supported with
+   Python 3.12. So, stick with Python 3.9, 3.10 or 3.11 until we find a solution.
+
+   Also don't forget to select "Add Python 3.xx to PATH" in the installer.
+
+   ![image](screenshots/Python_3.9_Installation_Windows.jpg)
+
+2- Download and install Visual Studio Build Tools:
+
+   Download from https://visualstudio.microsoft.com/visual-cpp-build-tools/
+
+   Select "Desktop development with C++" only:
+
+   ![image](screenshots/Visual_Studio_Build_Tools.jpg)
+
+3- Download and install Git:
+
+   https://www.git-scm.com/download/win
+
+   When installer asks, the default settings are okay.
+
+4- Clone DisplayCAL repository, build and install it:
+
+   Open up a command prompt and run the following:
+
+   ```shell
+   cd %HOME%
+   git clone https://github.com/eoyilmaz/displaycal-py3.git
+   cd displaycal-py3
+   ```
+
+   Then we suggest switching to the `develop` branch as we would have fixes introduced
+   to that branch the earliest. To do that run:
+
+   ```shell
+   git checkout develop
+   ```
+
+  > [!TIP]
+  > If you want to switch to some other branches to test the code you can replace
+  > `develop` in the previous command with the branch name:
+  > ```shell
+  > git checkout 367-compiled-sucessfully-in-w10-py311-but-createprocess-fails-call-to-dispread-to-measure
+  > ```
 
-Here is a screenshots showing the tool working with Python 3.10:
+   Let's install the requirements, build displaycal and install it:
 
-![image](https://user-images.githubusercontent.com/1786804/169152229-e06ff549-55fe-4149-8742-405446e6b01f.png)
+   ```shell
+   pip install -r requirements.txt -r requirements-dev.txt
+   python -m build
+   pip install dist/DisplayCAL-3.9.*.whl
+   ```
 
-Currently, DisplayCAL is working with Python 3.8 to 3.11 and wxPython 4.1.1 to 4.2.1.
+5- Run DisplayCAL:
 
-Here is a list of things that is working:
+   ```shell
+   python -m DisplayCAL
+   ```
 
-- The UI and general functionality.
-- Calibration + Characterization (Profiling).
-- Installing the created ICC profile both locally and system-wide (requires root
-  permissions).
-- Profile Info window is now fully working (on some systems we still have an issue
-  related to default values [#67](https://github.com/eoyilmaz/displaycal-py3/issues/67)).
-- Measurement report creation.
-- Creating, displaying and uploading Colorimeter Corrections.
-- Measuring and reporting display uniformity.
-- Creating charts with Test Chart Editor and creating diagnostic 3d data.
-- Creating 3D LUTs.
-- Creating synthetic ICC profiles.
-- and a lot of other stuff are working properly.
+6- To rebuild and install it again:
 
-What is not working
--------------------
+   First remove the old installation:
 
-- Everything should be working now. But, incase you encounter any bugs please create
-  [issues](https://github.com/eoyilmaz/displaycal-py3/issues).
+   ```shell
+   pip uninstall displaycal
+   ```
 
-How to install
---------------
+   Build and install it again:
 
-Currently, there is no ``RPM``, ``DEB``, ``APP`` or ``MSI`` packages. These are coming
-soon.
+   ```shell
+   python -m build
+   pip install dist/DisplayCAL-3.9.*.whl
+   ```
 
-To test the code you can either run it directly from the source or install it as a
-``sdist`` package.  To do this: 
+Build The Installer (Windows)
+-----------------------------
 
-Prerequisites:
+To build the installer for your own use you can follow these steps:
+
+1- Follow the instructions explained in
+   [Build From Source (Windows)](#build-from-source-windows) to build DisplayCAL from
+   its source.
+
+2- Use the `DisplayCAL\freeze.py` script to generate the frozen executables. Under the
+   `displaycal-py3` folder run the following:
+
+   ```shell
+   python DisplayCAL\freeze.py
+   ```
+
+   This should generate a folder under the `dist` folder with a name similar to
+   `py2exe.win32-py3.11-DisplayCAL-3.9.12`.
+
+   All the executables and resources to run DisplayCAL are placed under this folder. So,
+   you can directly run the executables under this folder.
+
+3- Download and install [Inno Setup](https://jrsoftware.org/isdl.php#stable):
+
+4- Generate the Inno Setup script:
+
+   ```shell
+   python setup.py inno
+   ```
+
+   This will generate a file called `py2exe.win32-py3.11-Setup-inno.iss`
+
+5- Run Inno Setup to build the script:
+
+   ```shell
+   cd dist
+   "C:\Program Files (x86)\Inno Setup 6\iscc" py2exe.win32-py3.11-Setup-inno.iss
+   ```
+
+6- This should now generate the installer with the name
+   `DisplayCAL-3.9.12-Setup.exe` that you can use to install DisplayCAL to
+   any Windows computer.
+
+Installation Instructions (Linux and MacOS)
+===========================================
+
+Prerequisites (Linux and MacOS)
+-------------------------------
+
+Currently, the only way of installing DisplayCAL on Linux and MacOS is to install it
+through **PyPI** or to build it from source. Proper installers are coming soon!
+
+To install DisplayCAL there are some prerequisites:
 
 * Assorted C/C++ builder tools
 * dbus
@@ -72,7 +260,7 @@ Please install these from your package manager.
 
 ```shell
 # Brew on MacOS
-brew install glib gtk+3 python@3.10
+brew install glib gtk+3 python@3.11
 
 # Debian installs
 apt-get install build-essential dbus libglib2.0-dev pkg-config libgtk-3-dev libxxf86vm-dev
@@ -81,9 +269,58 @@ apt-get install build-essential dbus libglib2.0-dev pkg-config libgtk-3-dev libx
 dnf install gcc glibc-devel dbus pkgconf gtk3-devel libXxf86vm-devel python3-devel
 ```
 
-Note, if your system's default python is outside the supported range you will need to install a supported version and its related devel package. 
+> [!NOTE]
+> Note, if your system's default python is outside the supported range you will need to
+> install a supported version and its related devel package.
+
+Install through PyPI (Linux & MacOS)
+------------------------------------
+
+Installing through PyPI is straight forward. We highly suggest using a virtual
+environment and not installing it to the system python:
+
+Be sure that you are using the correct Python version:
+
+```shell
+python --version
+```
+
+Outputs:
+
+```shell
+Python 3.11.9
+```
+
+Currently Python 3.12+ is not supported.
+
+Create a virtual environment:
+
+```shell
+python -m venv venv-displaycal
+source venv-diplaycal/bin/activate
+pip install displaycal
+```
+
+and now you can basically run `displaycal`:
+
+```shell
+displaycal
+```
+
+If you close the current terminal and run a new one, you need to activate the virtual
+environment before calling `displaycal`:
 
-Then pull the source:
+```shell
+source venv-diplaycal/bin/activate
+displaycal
+```
+
+Build From Source (Makefile Workflow) (Linux & MacOS)
+-----------------------------------------------------
+
+To test the latest code you can build DisplayCAL from its source. To do that:
+
+Pull the source:
 
 ```shell
 git clone https://github.com/eoyilmaz/displaycal-py3
@@ -103,20 +340,22 @@ Then you can build and install DisplayCAL using:
 make build
 make install
 ```
-The build step assumes your system has a python3 binary available that is within the correct range. If your system python3 is not supported and you installed a new one, you can try passing it to the build command.
-eg
+
+The build step assumes your system has a `python3` binary available that is
+within the correct range. If your system `python3` is not supported and you
+installed a new one, you can try passing it to the build command:
 
 ```shell
 $ python3 --version
 # Python 3.12.2
-make build # this will fail
+$ make build # this will fail
 $ python3.11 --version
 # Python 3.11.8
-make SYSTEM_PYTHON=python3.11 build # should work
+$ make SYSTEM_PYTHON=python3.11 build # should work
 ```
 
-If this errors out for you, you can follow the [Manual Setup](https://github.com/eoyilmaz/displaycal-py3#manually-setup)
-section below.
+If this errors out for you, you can follow the
+[Build From Source (Linux & MacOS)](#build-from-source-linux--macos) section below.
 
 Otherwise, this should install DisplayCAL. To run the UI:
 
@@ -124,16 +363,16 @@ Otherwise, this should install DisplayCAL. To run the UI:
 make launch
 ```
 
-Manually Setup
---------------
+Build From Source (Manual) (Linux & MacOS)
+------------------------------------------
 
 If the `makefile` workflow doesn't work for you, you can setup the virtual environment
 manually. Ensure the python binary you're using is supported:
 
 ```shell
 python -m venv .venv # python3.11 -m venv .venv if system python is not a supported version
-source .venv/bin/activate  # Windows: .venv\Scripts\activate.bat
-pip install -r requirements.txt
+source .venv/bin/activate
+pip install -r requirements.txt -r requirements-dev.txt
 python -m build
 pip install dist/DisplayCAL-3.9.*.whl
 ```
@@ -172,11 +411,9 @@ Here are some ideas on where to focus the future development effort:
   work, with the latest commits we have around 200 tests (which is super low, should be
   thousands) and the code coverage is around 26% (again this is super low, should be
   over 99%).
-- Replace the ``wexpect.py`` with the latest release of ``Pexpect``. There is no comment
+- Replace the ``wexpect.py`` with the latest release of ``Wexpect``. There is no comment
   in the code on why we have a ``wexpect.py`` instead of using the PyPI version of
-  ``Pexpect``. Update: we believe it is because ``Pexpect`` doesn't support Windows.
-  Then it is a good idea to port the DisplayCAL implementation to the ``Pexpect``
-  project.
+  ``Wexpect``.
 - Replace ``os.path`` related code with ``pathlib.Path`` class.
 - Organize the module structure, move UI related stuff in to ``ui`` module etc., move
   data files into their own folders.
diff --git a/misc/DisplayCAL-Setup-0install-per-user.iss b/misc/DisplayCAL-Setup-0install-per-user.iss
index 7993a5076..8fe7fdbf5 100644
--- a/misc/DisplayCAL-Setup-0install-per-user.iss
+++ b/misc/DisplayCAL-Setup-0install-per-user.iss
@@ -15,6 +15,8 @@ AppPublisherURL=%(AppPublisherURL)s
 AppReadmeFile=%(URL)s
 AppSupportURL=%(AppSupportURL)s
 AppUpdatesURL=%(AppUpdatesURL)s
+ArchitecturesInstallIn64BitMode=x64
+ArchitecturesAllowed=x64
 DefaultDirName={userpf}\%(AppName)s
 DefaultGroupName=%(AppName)s
 LicenseFile=..\LICENSE.txt
@@ -33,7 +35,7 @@ UninstallDisplayName={cm:UninstallProgram,%(AppName)s}
 UninstallDisplayIcon={app}\%(AppName)s-uninstall.ico
 AlwaysShowComponentsList=false
 ShowLanguageDialog=auto
-MinVersion=0,5.1.2600
+MinVersion=0,6.1.2600
 DisableDirPage=yes
 UsePreviousGroup=False
 DisableProgramGroupPage=yes
diff --git a/misc/DisplayCAL-Setup-0install.iss b/misc/DisplayCAL-Setup-0install.iss
index c0ba86a2b..d7cf890a2 100644
--- a/misc/DisplayCAL-Setup-0install.iss
+++ b/misc/DisplayCAL-Setup-0install.iss
@@ -15,6 +15,8 @@ AppPublisherURL=%(AppPublisherURL)s
 AppReadmeFile=%(URL)s
 AppSupportURL=%(AppSupportURL)s
 AppUpdatesURL=%(AppUpdatesURL)s
+ArchitecturesInstallIn64BitMode=x64
+ArchitecturesAllowed=x64
 DefaultDirName={pf}\%(AppName)s
 DefaultGroupName=%(AppName)s
 LicenseFile=..\LICENSE.txt
@@ -33,7 +35,7 @@ UninstallDisplayName={cm:UninstallProgram,%(AppName)s}
 UninstallDisplayIcon={app}\%(AppName)s-uninstall.ico
 AlwaysShowComponentsList=false
 ShowLanguageDialog=auto
-MinVersion=0,5.1.2600
+MinVersion=0,6.1.2600
 DisableDirPage=yes
 UsePreviousGroup=False
 DisableProgramGroupPage=yes
diff --git a/misc/DisplayCAL-Setup-py2exe.iss b/misc/DisplayCAL-Setup-py2exe.iss
index 9143adcc5..4d04c992b 100644
--- a/misc/DisplayCAL-Setup-py2exe.iss
+++ b/misc/DisplayCAL-Setup-py2exe.iss
@@ -14,6 +14,8 @@ AppPublisherURL=%(AppPublisherURL)s
 AppReadmeFile={app}\README.html
 AppSupportURL=%(AppSupportURL)s
 AppUpdatesURL=%(AppUpdatesURL)s
+ArchitecturesInstallIn64BitMode=x64
+ArchitecturesAllowed=x64
 DefaultDirName={pf}\%(AppName)s
 DefaultGroupName=%(AppName)s
 LicenseFile=..\LICENSE.txt
@@ -32,9 +34,9 @@ UninstallDisplayName=%(AppName)s
 UninstallDisplayIcon={app}\%(AppName)s.exe
 AlwaysShowComponentsList=false
 ShowLanguageDialog=auto
-MinVersion=0,5.1.2600
-SignTool=sign.cmd
-SignedUninstaller=yes
+MinVersion=0,6.1.2600
+;SignTool=sign.cmd
+;SignedUninstaller=yes
 
 [Languages]
 Name: english; MessagesFile: ..\misc\InnoSetup\v5\Default.isl; 
diff --git a/misc/DisplayCAL.pyi.spec b/misc/DisplayCAL.pyi.spec
index d779ccba5..7423743e5 100644
--- a/misc/DisplayCAL.pyi.spec
+++ b/misc/DisplayCAL.pyi.spec
@@ -124,8 +124,8 @@ if sys.platform == 'win32':
     python = os.path.join(build_dir, 'python')
     with open(python, 'wb') as py:
         py.write('\n'.join(['if __name__ == "__main__":',
-                            '    import %s.wexpect' % appname,
-                            '    sys.modules["wexpect"] = sys.modules["%s.wexpect"]' % appname,
+                            f'    import {appname}.wexpect',
+                            f'    sys.modules["wexpect"] = sys.modules["{appname}.wexpect"]',
                             '    for i, arg in enumerate(sys.argv[1:]):',
                             '        if arg == "-c":',
                             '            exec("".join(sys.argv[i + 2]))']))
diff --git a/pyproject.toml b/pyproject.toml
index 97b0a4270..303f6602c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -2,3 +2,8 @@
 requires = ["setuptools",
 	'pywin32; platform_system=="Windows"']
 build-backend = "setuptools.build_meta"
+
+[tool.pytest.ini_options]
+pythonpath = [
+	"."
+]
\ No newline at end of file
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 5fb5f3df5..e52c6c814 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -10,7 +10,8 @@ flake8-pep3101
 flake8-spellcheck
 matplotlib
 ppdir
-py2app
+py2app; sys_platform=='darwin'
+py2exe; sys_platform=='win32'
 pyglet
 pytest
 pytest-cov
@@ -19,5 +20,4 @@ pytest-xdist
 snowballstemmer
 twine
 yappi
-py2app
 wheel
diff --git a/requirements.txt b/requirements.txt
index 302f630a5..2c832dc74 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,10 +1,12 @@
 build
 certifi
+comtypes; sys_platform=='win32'
 dbus-python; sys_platform=='linux'
 distro
 numpy
 Pillow
 PyChromecast
+pywin32; sys_platform=='win32'
 Send2Trash
 wxPython>=4.2.2
 zeroconf
diff --git a/screenshots/DisplayCAL-screenshot-GNOME-3.9.5-running_on_python3.10.png b/screenshots/DisplayCAL-screenshot-GNOME-3.9.5-running_on_python3.10.png
new file mode 100644
index 000000000..682e65fbf
Binary files /dev/null and b/screenshots/DisplayCAL-screenshot-GNOME-3.9.5-running_on_python3.10.png differ
diff --git a/screenshots/Python_3.10_Installation_Windows.jpg b/screenshots/Python_3.10_Installation_Windows.jpg
new file mode 100644
index 000000000..3c378815c
Binary files /dev/null and b/screenshots/Python_3.10_Installation_Windows.jpg differ
diff --git a/screenshots/Python_3.9_Installation_Windows.jpg b/screenshots/Python_3.9_Installation_Windows.jpg
new file mode 100644
index 000000000..cdc4fd9f5
Binary files /dev/null and b/screenshots/Python_3.9_Installation_Windows.jpg differ
diff --git a/screenshots/Visual_Studio_Build_Tools.jpg b/screenshots/Visual_Studio_Build_Tools.jpg
new file mode 100644
index 000000000..69c97b7d2
Binary files /dev/null and b/screenshots/Visual_Studio_Build_Tools.jpg differ
diff --git a/setup.cfg b/setup.cfg
index f9929b397..26259456e 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -8,12 +8,15 @@ packages = find:
 install_requires =
     build
     certifi
+    comtypes; sys_platform=='win32'
     dbus-python;sys_platform == 'linux'
     distro
     numpy
     Pillow
     PyChromecast
+    pywin32; sys_platform=='win32'
     Send2Trash
+    #wexpect; sys_platform=='win32'
     wxPython
     zeroconf
 
diff --git a/setup.py b/setup.py
index ca67f5e62..9bc0c0b5f 100755
--- a/setup.py
+++ b/setup.py
@@ -33,7 +33,7 @@ def create_appdmg(zeroinstall=False):
         srcdir = "0install"
     else:
         dmgname = name + "-" + version
-        srcdir = f"py2app.{get_platform()}-py{sys.version[:3]}"
+        srcdir = f"py2app.{get_platform()}-py{sys.version_info[0]}.{sys.version_info[1]}"
 
     retcode = subprocess.call(
         [
@@ -745,29 +745,31 @@ def setup():
 
         for tmpl_type in tmpl_types:
             inno_template_path = Path(pydir, "misc", f"{name}-Setup-{tmpl_type}.iss")
-            inno_template = open(inno_template_path, "r")
-            inno_script = inno_template.read() % {
-                "AppCopyright": "© %s %s" % (strftime("%Y"), author),
-                "AppName": name,
-                "AppVerName": version,
-                "AppPublisher": author,
-                "AppPublisherURL": f"https://{DOMAIN}/",
-                "AppSupportURL": f"https://{DOMAIN}/",
-                "AppUpdatesURL": f"https://{DOMAIN}/",
-                "VersionInfoVersion": ".".join(map(str, version_tuple)),
-                "VersionInfoTextVersion": version,
-                "AppVersion": version,
-                "Platform": get_platform(),
-                "PythonVersion": sys.version[:3],
-                "URL": f"https://{DOMAIN}/",
-                "HTTPURL": f"http://{DOMAIN}/",
-            }
-            inno_template.close()
+            with open(inno_template_path, "r") as inno_template:
+                print(f"inno_template_path: {inno_template_path}")
+                template = inno_template.read()
+                # print(template)
+                inno_script = template % {
+                    "AppCopyright": f"© {strftime('%Y')} {author}",
+                    "AppName": name,
+                    "AppVerName": version,
+                    "AppPublisher": author,
+                    "AppPublisherURL": f"https://{DOMAIN}/",
+                    "AppSupportURL": f"https://{DOMAIN}/",
+                    "AppUpdatesURL": f"https://{DOMAIN}/",
+                    "VersionInfoVersion": ".".join(map(str, version_tuple)),
+                    "VersionInfoTextVersion": version,
+                    "AppVersion": version,
+                    "Platform": get_platform(),
+                    "PythonVersion": f"{sys.version_info[0]}.{sys.version_info[1]}",
+                    "URL": f"https://{DOMAIN}/",
+                    "HTTPURL": f"http://{DOMAIN}/",
+                }
             inno_path = Path(
                 "dist",
                 inno_template_path.name.replace(
                     bdist_cmd,
-                    "%s.%s-py%s" % (bdist_cmd, get_platform(), sys.version[:3]),
+                    f"{bdist_cmd}.{get_platform()}-py{sys.version_info[0]}.{sys.version_info[1]}",
                 ),
             )
 
@@ -777,9 +779,8 @@ def setup():
                 if not dist_path.exists():
                     os.makedirs(dist_path)
 
-                inno_file = open(inno_path, "wb")
-                inno_file.write(inno_script.encode("MBCS", "replace"))
-                inno_file.close()
+                with open(inno_path, "wb") as inno_file:
+                    inno_file.write(inno_script.encode("MBCS", "replace"))
 
         sys.argv.remove("inno")
 
@@ -788,7 +789,7 @@ def setup():
 
     if "finalize_msi" in sys.argv[1:]:
         db = msilib.OpenDatabase(
-            rf"dist\{name}-{msiversion}.win32-py{sys.version[:3]}.msi",
+            rf"dist\{name}-{msiversion}.win32-py{sys.version_info[0]}.{sys.version_info[1]}.msi",
             msilib.MSIDBOPEN_TRANSACT,
         )
         view = db.OpenView("SELECT Value FROM Property WHERE Property = 'ProductCode'")
@@ -1107,10 +1108,10 @@ def setup():
                 sys.executable,
                 Path(pydir, "pyinstaller", "pyinstaller.py"),
                 "--workpath",
-                Path(pydir, "build", f"pyi.{get_platform()}-{sys.version[:3]}"),
+                Path(pydir, "build", f"pyi.{get_platform()}-{sys.version_info[0]}.{sys.version_info[1]}"),
                 "--distpath",
-                Path(pydir, "dist", f"pyi.{get_platform()}-{sys.version[:3]}"),
-                Path(pydir, "misc", "%s.pyi.spec" % name),
+                Path(pydir, "dist", f"pyi.{get_platform()}-{sys.version_info[0]}.{sys.version_info[1]}"),
+                Path(pydir, "misc", f"{name}.pyi.spec"),
             ]
         )
 
@@ -1406,6 +1407,7 @@ def setup():
 
                     if passphrase_path.is_file():
                         from DisplayCAL import wexpect
+                        # import wexpect
 
                         with open(passphrase_path) as passphrase_file:
                             passphrase = passphrase_file.read().strip()
@@ -1558,7 +1560,7 @@ def setup():
             Path(
                 pydir,
                 "dist",
-                f"py2app.{get_platform()}-py{sys.version[:3]}",
+                f"py2app.{get_platform()}-py{sys.version_info[0]}.{sys.version_info[1]}",
                 f"{name}-{version}",
             ),
             version_dir,
diff --git a/util/vc90crt.py b/util/vc90crt.py
index 0d3d9f492..84e0e00ff 100644
--- a/util/vc90crt.py
+++ b/util/vc90crt.py
@@ -18,7 +18,7 @@ def vc90crt_find_files():
         processorArchitecture=arch,
         name=name,
         publicKeyToken="1fc8b3b9a1e18e3b",
-        version=[9, 0, 21022, 8],
+        version=[9, 0, 30729, 9625],
     ).find_files()
 
 
diff --git a/util/winmanifest_util.py b/util/winmanifest_util.py
index 5feaef80e..171674736 100644
--- a/util/winmanifest_util.py
+++ b/util/winmanifest_util.py
@@ -20,7 +20,11 @@ def getmanifest(manifest_template_path):
     manifest = ManifestFromXMLFile(manifest_template_path)
     manifest.description = description
     manifest.name = name
-    manifest.version = version_tuple
+    # Windows 10+ requires a 4 part version number listed in the manifest file
+    version = list(version_tuple)
+    if len(version) < 4:
+        version.append(0)
+    manifest.version = tuple(version)
     return manifest
 
 
diff --git a/util/winversion.py b/util/winversion.py
index 6ebf4e7e2..5d7c86a1a 100644
--- a/util/winversion.py
+++ b/util/winversion.py
@@ -24,12 +24,12 @@ def mktempver(
         "prodvers": str(version_tuple),
         "CompanyName": DOMAIN,
         "FileDescription": description_,
-        "FileVersion": version,
+        "FileVersion": f"{version}",
         "InternalName": name_,
-        "LegalCopyright": "© " + author,
-        "OriginalFilename": name_ + ".exe",
+        "LegalCopyright": f"© {author}",
+        "OriginalFilename": f"{name_}.exe",
         "ProductName": name_,
-        "ProductVersion": version,
+        "ProductVersion": f"{version}",
     }
     version_template.close()
     tempdir = tempfile.mkdtemp()


Test patch:
diff --git a/tests/test_taskscheduler.py b/tests/test_taskscheduler.py
new file mode 100644
index 000000000..86409a8a2
--- /dev/null
+++ b/tests/test_taskscheduler.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+
+import pytest
+import sys
+
+
+@pytest.mark.skipif(sys.platform != "win32", reason="requires Windows")
+def test_execaction_filters_none_values_in_args_and_will_not_raise_errors():
+    """ExecAction will filter any None values in the args list and will not raise any errors."""
+    from DisplayCAL.taskscheduler import ExecAction
+
+    ea = ExecAction(cmd="", args=[None])  # This should not raise any errors.
diff --git a/tests/test_worker.py b/tests/test_worker.py
index a6818730f..5cf3bb7cd 100644
--- a/tests/test_worker.py
+++ b/tests/test_worker.py
@@ -5,6 +5,7 @@
 import pathlib
 import os
 import shutil
+import tempfile
 from typing import Tuple, Dict
 
 import pytest
@@ -22,6 +23,7 @@
     check_ti3_criteria1,
 )
 
+from DisplayCAL.worker_base import get_argyll_util
 from tests.data.display_data import DisplayData
 
 
@@ -97,7 +99,7 @@ def test_worker_instrument_supports_css_1():
     assert result == expected_result
 
 
-@pytest.mark.skip(reason="Test segfaults with python 3.12 - further investigation required.")
+# @pytest.mark.skip(reason="Test segfaults with python 3.12 - further investigation required.")
 def test_generate_b2a_from_inverse_table(data_files, argyll):
     """Test Worker.generate_B2A_from_inverse_table() method"""
     worker = Worker()
@@ -513,3 +515,26 @@ def patched_os_path_isfile(filepath):
     # This should not raise the:
     # TypeError: startswith first arg must be bytes or a tuple of bytes, not str
     worker.prepare_colprof()
+
+
+def test_prepare_dispcal_1():
+    """Worker.prepare_dispcal() return value should be quoted properly."""
+    worker = Worker()
+    return_val = worker.prepare_dispcal()
+    expected_result = [
+        '-v2',
+        '-d0',
+        '-c1',
+        '-yl',
+        '-p0.5,0.5,1.0',
+        '-ql',
+        '-t',
+        '-g2.2',
+        '-f1.0',
+        '-k0.0',
+        '/var/folders/8l/xy1__ym94nn35x86xyg56xq80000gn/T/DisplayCAL-2fdjtyql/'
+    ]
+    assert return_val[0] == get_argyll_util("dispcal")
+    assert isinstance(return_val[1], list)
+    assert return_val[1][:-1] == expected_result[:-1]  # don't check the final part
+    assert tempfile.gettempdir() in return_val[1][-1]  # this should be in a temp path