Repository: sylikc/pyexiftool (language: python)

Issue:
update url for documentation
This change fixes the documentation URL in `README.rst`.

The URL for the library documentation no longer works. `*.github.com` TLD has been deprecated and replaced by `*.github.io`. 


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 8f5a041..5429489 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,12 @@ dist/
 MANIFEST
 
 *.egg-info/
+
+# pytest-cov db
+.coverage
+
+# tests will be made to write to temp directories with this prefix
+tests/exiftool-tmp-*
+
+# IntelliJ
+.idea
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 81ee9bb..d54a96e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,7 +3,7 @@
 Date (Timezone)              | Version | Comment
 ---------------------------- | ------- | -------
 07/17/2019 12:26:16 AM (PDT) | 0.2.0   | Source was pulled directly from https://github.com/smarnach/pyexiftool with a complete bare clone to preserve all history.  Because it's no longer being updated, I will pull all merge requests in and make updates accordingly
-07/17/2019 12:50:20 AM (PDT) | 0.2.1   | Convert leading spaces to tabs
+07/17/2019 12:50:20 AM (PDT) | 0.2.1   | Convert leading spaces to tabs.  (I'm aware of [PEP 8](https://www.python.org/dev/peps/pep-0008/#tabs-or-spaces) recommending spaces over tabs, but I <3 tabs)
 07/17/2019 12:52:33 AM (PDT) | 0.2.2   | Merge [Pull request #10 "add copy_tags method"](https://github.com/smarnach/pyexiftool/pull/10) by [Maik Riechert (letmaik) Cambridge, UK](https://github.com/letmaik) on May 28, 2014<br> *This adds a small convenience method to copy any tags from one file to another. I use it for several month now and it works fine for me.*
 07/17/2019 01:05:37 AM (PDT) | 0.2.3   | Merge [Pull request #25 "Added option for keeping print conversion active. #25"](https://github.com/smarnach/pyexiftool/pull/25) by [Bernhard Bliem (bbliem)](https://github.com/bbliem) on Jan 17, 2019<br> *For some tags, disabling print conversion (as was the default before) would not make much sense. For example, if print conversion is deactivated, the value of the Composite:LensID tag could be reported as something like "8D 44 5C 8E 34 3C 8F 0E". It is doubtful whether this is useful here, as we would then need to look up what this means in a table supplied with exiftool. We would probably like the human-readable value, which is in this case "AF-S DX Zoom-Nikkor 18-70mm f/3.5-4.5G IF-ED".*<br>*Disabling print conversion makes sense for a lot of tags (e.g., it's nicer to get as the exposure time not the string "1/2" but the number 0.5). In such cases, even if we enable print conversion, we can disable it for individual tags by appending a # symbol to the tag name.*
 07/17/2019 01:20:15 AM (PDT) | 0.2.4   | Merge with slight modifications to variable names for clarity (sylikc) [Pull request #27 "Add "shell" keyword argument to ExifTool initialization"](https://github.com/smarnach/pyexiftool/pull/27) by [Douglas Lassance (douglaslassance) Los Angeles, CA](https://github.com/douglaslassance) on 5/29/2019<br>*On Windows this will allow to run exiftool without showing the DOS shell.*<br>**This might break Linux but I don't know for sure**<br>Alternative source location with only this patch: https://github.com/blurstudio/pyexiftool/tree/shell-option
@@ -35,18 +35,22 @@ Date (Timezone)              | Version | Comment
 08/22/2021 08:34:45 PM (PDT) | 0.4.11  | no functional code changes.  Changed setup.py with updated version and Documentation link pointed to sylikc.github.io -- as per issue #27 by @derMart
 08/22/2021 09:02:33 PM (PDT) | 0.4.12  | fixed a bug ExifTool.terminate() where there was a typo.  Kept the unused outs, errs though. -- from suggestion in pull request #26 by @aaronkollasch
 02/13/2022 03:38:45 PM (PST) | 0.4.13  | (NOTE: Barring any critical bug, this is expected to be the LAST Python 2 supported release!)  added GitHub actions.  fixed bug in execute_json_wrapper() 'error' was not defined syntactically properly -- merged pull request #30 by https://github.com/jangop
+03/13/2021 01:54:44 PM (PST) | 0.5.0a0 | no functional code changes ... yet.  this is currently on a separate branch referring to [Break down Exiftool into 2+ classes, a raw Exiftool, and helper classes](https://github.com/sylikc/pyexiftool/discussions/10) and [Deprecating Python 2.x compatibility](https://github.com/sylikc/pyexiftool/discussions/9) .  In time this refactor will be the future of PyExifTool, once it stabilizes.  I'll make code-breaking updates in this branch from build to build and take comments to make improvements.  Consider the 0.5.0 "nightly" quality.  Also, changelog versions were modified because I noticed that the LAST release from smarnach is tagged with v0.2.0
+02/28/2022 12:39:57 PM (PST) | 0.5.0   | complete refactor of the PyExifTool code.  Lots of changes.  Some code breaking changes.  Not directly backwards-compatible with v0.4.x.  See COMPATIBILITY.TXT to understand all the code-breaking changes.
 
 
-On version changes, update setup.py to reflect version
+On version changes, update __int__.py to reflect version
+
 
 # Changes around the web
 
-Check for changes at the following resources to make sure we have the latest and greatest.  While we have the most active fork, I'm just one of the many forks, spoons, and knives!
+Check for changes at the following resources to see if anyone has added some nifty features.  While we have the most active fork, I'm just one of the many forks, spoons, and knives!
+
+We can also direct users here or answer existing questions as to how to use the original version of ExifTool.
 
-(last checked 5/19/2021 all)
+(last checked 2/28/2022 all)
 
 search "pyexiftool github" to see if you find any more random ports/forks
 check for updates https://github.com/smarnach/pyexiftool/pulls
-check for updates on elodie https://github.com/jmathai/elodie/commits/master/elodie/external/pyexiftool.py
 check for new open issues https://github.com/smarnach/pyexiftool/issues?q=is%3Aissue+is%3Aopen
 
diff --git a/COMPATIBILITY.txt b/COMPATIBILITY.txt
new file mode 100644
index 0000000..824596e
--- /dev/null
+++ b/COMPATIBILITY.txt
@@ -0,0 +1,55 @@
+PyExifTool does not guarantee source-level compatibility from one release to the next.
+
+That said, efforts will be made to provide well-documented API-level compatibility,
+and if there are major API changes, migration documentation will be provided, when
+possible.
+
+----
+
+v0.1.x - v0.2.0  = smarnach code, API compatible
+v0.2.1 - v0.4.13 = code with all PRs, a superset of functionality on Exiftool class
+v0.5.0 -         = not API compatible with the v0.4.x series.  See comments below:
+
+
+----
+API changes between v0.4.x and v0.5.0:
+
+	PYTHON CHANGE: Old: Python 2.6 supported.  New: Python 3.6+ required
+
+	CHANGED: Exiftool constructor:
+		RENAME:   "executable_" parameter to "executable"
+		DEFAULT BEHAVIOR: "common_args" defaults to ["-G", "-n"] instead of None.  Old behavior set -G and -n if "common_args" is None.  New behavior "common_args" = [] if common_args is None.
+		DEFAULT: Old: "win_shell" defaults to True.  New: "win_shell" defaults to False.
+		NEW: "encoding" parameter
+		NEW: "logger" parameter
+
+	NEW PROPERTY GET/SET: a lot of properties were added to do get/set validation, and parameters can be changed outside of the constructor.
+
+	METHOD RENAME: starting the process was renamed from "start" to "run"
+
+	MINIMUM TOOL VERSION: exiftool command line utility minimum requirements.  Old: 8.60.  New: 12.15
+
+	ENCODING CHANGE: execute() and execute_json() no longer take bytes, but is guided by the encoding set in constructor/property
+
+	ERROR CHANGE: execute_json() when no json was not returned (such as a set metadata operation) => Old: raised an error.  New: returns custom ExifToolException
+
+	FEATURE REMOVAL: execute_json() no longer detects the '-w' flag being passed used in common_args.
+		If a user uses this flag, expect no output.
+		(detection in common_args was clunky anyways because -w can be passed as a per-run param for the same effect)
+
+
+	all methods other than execute() and execute_json() moved to ExifToolHelper or ExifToolAlpha class.
+
+	ExifToolHelper adds methods:
+		get_metadata()
+		get_tags()
+
+		NEW CONVENTION: all methods take "files" first, "tags" second (if needed) and "params" last
+
+
+	ExifToolAlpha adds all remaining methods in an alpha-quality way
+
+	NOTE: ExifToolAlpha has not been updated yet to use the new convention, and the edge case code may be removed/changed at any time.
+		If you depend on functionality provided by ExifToolAlpha, please submit an Issue to start a discussion on cleaning up the code and moving it into ExifToolHelper
+----
+
diff --git a/LICENSE b/LICENSE
index 4dff229..e114eb0 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
 PyExifTool <http://github.com/sylikc/pyexiftool>
 
-Copyright 2012 Sven Marnach, Kevin M (sylikc)
+Copyright 2012-2014 Sven Marnach, 2019-2022 Kevin M (sylikc)
 
 PyExifTool is free software: you can redistribute it and/or modify
 it under the terms of the GNU General Public License as published by
diff --git a/README.rst b/README.rst
index 1600d7a..a2d152e 100644
--- a/README.rst
+++ b/README.rst
@@ -1,5 +1,6 @@
+**********
 PyExifTool
-==========
+**********
 
 PyExifTool is a Python library to communicate with an instance of Phil
 Harvey's excellent ExifTool_ command-line application.  The library
@@ -11,10 +12,42 @@ single instance needs to be launched and can be reused for many
 queries.  This is much more efficient than launching a separate
 process for every single query.
 
-.. _ExifTool: http://www.sno.phy.queensu.ca/~phil/exiftool/
+.. _ExifTool: https://exiftool.org/
+
+Example Usage
+=============
+
+.. code-block:: python
+	:caption: Quick Sample
+	:linenos:
+
+	import exiftool
+
+	files = ["a.jpg", "b.png", "c.tif"]
+	with exiftool.ExifToolHelper() as et:
+		metadata = et.get_metadata(files)
+	for d in metadata:
+		print("{:20.20} {:20.20}".format(d["SourceFile"],
+										 d["EXIF:DateTimeOriginal"]))
+
 
 Getting PyExifTool
-------------------
+==================
+
+PyPI
+------------
+
+Easiest: Install a version from the official `PyExifTool PyPI`_
+
+::
+
+    python -m pip install -U pyexiftool
+
+.. _PyExifTool PyPI: https://pypi.org/project/PyExifTool/
+
+
+From Source
+------------
 
 The source code can be checked out from the github repository with
 
@@ -22,59 +55,157 @@ The source code can be checked out from the github repository with
 
     git clone git://github.com/sylikc/pyexiftool.git
 
-Alternatively, you can download a tarball_.  
+Alternatively, you can download a tarball_.
+
+.. _tarball: https://github.com/sylikc/pyexiftool/tarball/master
 
-Official releases are on PyPI
+Run
 
 ::
 
-    https://pypi.org/project/PyExifTool/
+    python setup.py install [--user|--prefix=<installation-prefix]
 
+to automatically install this module from source.
 
-.. _tarball: https://github.com/sylikc/pyexiftool/tarball/master
 
-Installation
-------------
+PyExifTool Dependencies
+=======================
+
+Python
+------
+
+PyExifTool runs on **Python 3.6+**.  (If you need Python 2.6 support,
+please use version v0.4.x).  PyExifTool has been tested on Windows and
+Linux, and probably also runs on other Unix-like platforms.
 
-PyExifTool runs on Python 2.6 and above, including 3.x.  It has been
-tested on Windows and Linux, and probably also runs on other Unix-like
-platforms.
+Phil Harvey's exiftool
+----------------------
 
-You need an installation of the ``exiftool`` command-line tool.  The
-code has been tested with version 8.60, but should work with version
-8.40 or above (which was the first production version of exiftool
-featuring the ``-stay_open`` option for batch mode).
+For PyExifTool to function, ``exiftool`` command-line tool must exist on
+the system.  If ``exiftool`` is not on the ``PATH``, you can specify the full
+pathname to it by using ``ExifTool(executable=<full path>)``.
 
-PyExifTool currently only consists of a single module, so you can
-simply copy or link this module to a place where Python finds it, or
-you can call
+PyExifTool requires a **minimum version of 12.15** (which was the first
+production version of exiftool featuring the options to allow exit status
+checks used in conjuction with ``-echo3`` and ``-echo4`` parameters).
 
-(It is being slowly re-factored to a module to keep exiftool.py from 
-growing astronomically)
+To check your ``exiftool`` version:
 
 ::
 
-    python setup.py install [--user|--prefix=<installation-prefix]
+    exiftool -ver
 
-to automatically install that module.
 
-Testing
--------------
+Windows/Mac
+^^^^^^^^^^^
 
-Run tests to make sure it's functional
+Windows/Mac users can download the latest version of exiftool:
 
 ::
 
-    python -m unittest -v tests/test_exiftool.py
+    https://exiftool.org
+
+Linux
+^^^^^
+
+Most current Linux distributions have a package which will install ``exiftool``.
+Unfortunately, some do not have the minimum required version, in which case you
+will have to `build from source`_.
+
+* Ubuntu
+  ::
+
+    sudo apt install libimage-exiftool-perl
+
+* CentOS/RHEL
+  ::
+
+    yum install perl-Image-ExifTool
+
+.. _build from source: https://exiftool.org/install.html#Unix
+
 
 Documentation
--------------
+=============
+
+The documentation is available at `sylikc.github.io`_.
+It is slightly outdated at the moment but will be improved as the
+project moves forward
+
+::
+
+    http://sylikc.github.io/pyexiftool/
+
+.. _sylikc.github.io: http://sylikc.github.io/pyexiftool/
+
+
+Package Structure
+-----------------
+
+PyExifTool consists of a few modules, each with increasingly more features.
+
+The base ``ExifTool`` class is the most rudimentary, and each successive class
+inherits and adds functionality.
+
+* ``ExifTool`` is the base class with functionality which will not likely change.
+  It contains the core features with no extra fluff.  The API is considered stable
+  and should not change much with new versions.
+
+* ``ExifToolHelper`` adds the most commonly used functionality.  It overloads
+  some functions to turn common errors into warnings or makes checks to make
+  ``ExifTool`` easier to use.  More methods may be added or slight tweaks may
+  come with new versions.
+
+* ``ExifToolAlpha`` includes some of the community functionality that contributors
+  added for edge use cases.  It is *not* up to the rigorous testing standard of both
+  ``ExifTool`` or ``ExifToolHelper``.  There may be old or defunct code at any time.
+  This is the least polished of the classes and functionality/API may be
+  changed/added/removed at any time.
+
+
+Brief History
+=============
+
+PyExifTool was originally developed by `Sven Marnach`_ in 2012 to answer a
+stackoverflow question `Call exiftool from a python script?`_.  Over time,
+Sven refined the code, added tests, documentation, and a slew of improvements.
+While PyExifTool gained popularity, Sven `never intended to maintain it`_ as
+an active project.  The `original repository`_ was last updated in 2014.
+
+In early 2019, `Martin Čarnogurský`_ created a `PyPI release`_ from the
+2014 code.  Coincidentally in mid 2019, `Kevin M (sylikc)`_ forked the original
+repository and started merging PR and issues which were reported on Sven's
+issues/PR page.
+
+In late 2019 and early 2020 there was a discussion started to
+`Provide visibility for an active fork`_.  There was a conversation to
+transfer ownership of the original repository, have a coordinated plan to
+communicate to PyExifTool users, amongst other things, but it never materialized.
+
+Kevin M (sylikc) made the first release to PyPI repository in early 2021.
+At the same time, discussions were starting revolving around
+`Deprecating Python 2.x compatibility`_ and `refactoring the code and classes`_.
+
+The latest version is the result of all of those discussions, designs,
+and development.  Special thanks to the community contributions, especially
+`Jan Philip Göpfert`_, `Seth P`_, and `Kolen Cheung`_.
 
-The documentation is available at
-http://sylikc.github.io/pyexiftool/.
+.. _Sven Marnach: https://github.com/smarnach/pyexiftool
+.. _Call exiftool from a python script?: https://stackoverflow.com/questions/10075115/call-exiftool-from-a-python-script/10075210#10075210
+.. _never intended to maintain it: https://github.com/smarnach/pyexiftool/pull/31#issuecomment-569238073
+.. _original repository: https://github.com/smarnach/pyexiftool
+.. _Martin Čarnogurský: https://github.com/RootLUG
+.. _PyPI release: https://pypi.org/project/PyExifTool/0.1.1/#history
+.. _Kevin M (sylikc): https://github.com/sylikc
+.. _Provide visibility for an active fork: https://github.com/smarnach/pyexiftool/pull/31
+.. _Deprecating Python 2.x compatibility: https://github.com/sylikc/pyexiftool/discussions/9
+.. _refactoring the code and classes: https://github.com/sylikc/pyexiftool/discussions/10
+.. _Jan Philip Göpfert: https://github.com/jangop
+.. _Seth P: https://github.com/csparker247
+.. _Kolen Cheung: https://github.com/ickc
 
 Licence
--------
+=======
 
 PyExifTool is free software: you can redistribute it and/or modify
 it under the terms of the GNU General Public License as published by
@@ -85,4 +216,4 @@ PyExifTool is distributed in the hope that it will be useful,
 but WITHOUT ANY WARRANTY; without even the implied warranty of
 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 
-See COPYING.GPL or COPYING.BSD for more details.
+See ``LICENSE`` for more details.
diff --git a/doc/index.rst b/doc/index.rst
deleted file mode 100644
index e147b50..0000000
--- a/doc/index.rst
+++ /dev/null
@@ -1,8 +0,0 @@
-.. PyExifTool documentation master file, created by
-   sphinx-quickstart on Thu Apr 12 17:42:54 2012.
-
-PyExifTool -- A Python wrapper for Phil Harvey's  ExifTool
-==========================================================
-
-.. automodule:: exiftool
-   :members:
diff --git a/doc/.gitignore b/docs/.gitignore
similarity index 100%
rename from doc/.gitignore
rename to docs/.gitignore
diff --git a/doc/Makefile b/docs/Makefile
similarity index 99%
rename from doc/Makefile
rename to docs/Makefile
index 87dda06..c29201a 100644
--- a/doc/Makefile
+++ b/docs/Makefile
@@ -5,6 +5,7 @@
 SPHINXOPTS    =
 SPHINXBUILD   = sphinx-build
 PAPER         =
+SOURCEDIR     = source
 BUILDDIR      = _build
 
 # Internal variables.
diff --git a/docs/make.bat b/docs/make.bat
new file mode 100644
index 0000000..5001b3f
--- /dev/null
+++ b/docs/make.bat
@@ -0,0 +1,35 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+	set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=source
+set BUILDDIR=_build
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+	echo.
+	echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+	echo.installed, then set the SPHINXBUILD environment variable to point
+	echo.to the full path of the 'sphinx-build' executable. Alternatively you
+	echo.may add the Sphinx directory to PATH.
+	echo.
+	echo.If you don't have Sphinx installed, grab it from
+	echo.http://sphinx-doc.org/
+	exit /b 1
+)
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+
+:end
+popd
diff --git a/doc/conf.py b/docs/source/conf.py
similarity index 88%
rename from doc/conf.py
rename to docs/source/conf.py
index 83cf656..e89e41a 100644
--- a/doc/conf.py
+++ b/docs/source/conf.py
@@ -11,12 +11,38 @@
 # All configuration values have a default; values that are commented out
 # serve to show the default.
 
-import sys, os
+import sys
+from pathlib import Path
 
 # If extensions (or modules to document with autodoc) are in another directory,
 # add these directories to sys.path here. If the directory is relative to the
 # documentation root, use os.path.abspath to make it absolute, like shown here.
-sys.path.insert(1, os.path.abspath('..'))
+#
+# https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.parent
+# "Path.parent is a purely lexical operation
+# If you want to walk an arbitrary filesystem path upwards,
+# it is recommended to first call Path.resolve() so as to
+# resolve symlinks and eliminate �..� components."
+sys.path.insert(1, Path(__file__).resolve().parent.parent)
+
+
+
+# -- Project information -----------------------------------------------------
+
+# General information about the project.
+project = u'PyExifTool'
+copyright = u'2012, Sven Marnach. 2021, Kevin M'
+author = 'Sven Marnach, Kevin M'
+
+# The version info for the project you're documenting, acts as replacement for
+# |version| and |release|, also used in various other places throughout the
+# built documents.
+#
+# The short X.Y version.
+version = '0.5'
+# The full version, including alpha/beta/rc tags.
+release = '0.5.0a1'
+
 
 # -- General configuration -----------------------------------------------------
 
@@ -25,7 +51,18 @@
 
 # Add any Sphinx extension module names here, as strings. They can be extensions
 # coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
-extensions = ['sphinx.ext.autodoc']
+extensions = [
+	'sphinx.ext.autodoc', # Core library for html generation from docstrings
+	'sphinx.ext.autosummary', # Create neat summary tables
+	'autoapi.extension', # pip install sphinx-autoapi
+]
+autosummary_generate = True # Turn on sphinx.ext.autosummary
+
+autoapi_type = 'python'
+autoapi_dirs = [ str(Path(__file__).parent.parent.parent / 'exiftool') ]
+
+
+
 
 # Add any paths that contain templates here, relative to this directory.
 templates_path = ['_templates']
@@ -39,18 +76,6 @@
 # The master toctree document.
 master_doc = 'index'
 
-# General information about the project.
-project = u'PyExifTool'
-copyright = u'2012, Sven Marnach'
-
-# The version info for the project you're documenting, acts as replacement for
-# |version| and |release|, also used in various other places throughout the
-# built documents.
-#
-# The short X.Y version.
-version = '0.1'
-# The full version, including alpha/beta/rc tags.
-release = '0.1'
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
@@ -91,7 +116,9 @@
 
 # The theme to use for HTML and HTML Help pages.  See the documentation for
 # a list of builtin themes.
-html_theme = 'default'
+#html_theme = 'default'
+# pip install sphinx_rtd_theme
+html_theme = 'sphinx_rtd_theme'
 
 # Theme options are theme-specific and customize the look and feel of a theme
 # further.  For a list of options available for each theme, see the
diff --git a/docs/source/index.rst b/docs/source/index.rst
new file mode 100644
index 0000000..690d103
--- /dev/null
+++ b/docs/source/index.rst
@@ -0,0 +1,39 @@
+.. PyExifTool documentation master file, created by
+   sphinx-quickstart on Thu Apr 12 17:42:54 2012.
+
+PyExifTool -- A Python wrapper for Phil Harvey's  ExifTool
+==========================================================
+
+.. automodule:: exiftool
+   :members:
+   :undoc-members:
+   :private-members:
+   :special-members:
+   :show-inheritance:
+
+   .. automethod:: __init__
+
+
+.. autosummary::
+   :toctree: _autosummary
+   :recursive:
+
+   exiftool
+
+..
+   look up info using this https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html
+
+..
+   automodule:: exiftool.helper
+   :members:
+   :undoc-members:
+
+.. toctree::
+   :maxdepth: 2
+   :caption: Contents:
+
+Indices and tables
+==================
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
diff --git a/exiftool/__init__.py b/exiftool/__init__.py
index 854ec1b..5e3cb89 100644
--- a/exiftool/__init__.py
+++ b/exiftool/__init__.py
@@ -1,6 +1,20 @@
 # -*- coding: utf-8 -*-
 
-# import as directory
+# version number using Semantic Versioning 2.0.0 https://semver.org/
+# may not be PEP-440 compliant https://www.python.org/dev/peps/pep-0440/#semantic-versioning
+__version__ = "0.5.0"
+
+
+# while we COULD import all the exceptions into the base library namespace,
+# it's best that it lives as exiftool.exceptions, to not pollute the base namespace
+from . import exceptions
+
 
 # make all of the original exiftool stuff available in this namespace
-from .exiftool import *
+from .exiftool import ExifTool
+from .helper import ExifToolHelper
+from .experimental import ExifToolAlpha
+
+# an old feature of the original class that exposed this variable at the library level
+# TODO may remove and deprecate at a later time
+from .constants import DEFAULT_EXECUTABLE
diff --git a/exiftool/constants.py b/exiftool/constants.py
new file mode 100644
index 0000000..bd8d64f
--- /dev/null
+++ b/exiftool/constants.py
@@ -0,0 +1,80 @@
+# -*- coding: utf-8 -*-
+# This file is part of PyExifTool.
+#
+# PyExifTool is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the licence, or
+# (at your option) any later version, or the BSD licence.
+#
+# PyExifTool is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+#
+# See COPYING.GPL or COPYING.BSD for more details.
+
+"""
+This file defines constants which are used by others in the package
+"""
+
+
+import sys
+
+
+##################################
+############# HELPERS ############
+##################################
+
+# instead of comparing everywhere sys.platform, do it all here in the constants (less typo chances)
+# True if Windows
+PLATFORM_WINDOWS: bool = (sys.platform == 'win32')
+# Prior to Python 3.3, the value for any Linux version is always linux2; after, it is linux.
+# https://stackoverflow.com/a/13874620/15384838
+PLATFORM_LINUX: bool = (sys.platform == 'linux' or sys.platform == 'linux2')
+
+
+
+##################################
+####### PLATFORM DEFAULTS ########
+##################################
+
+
+# specify the extension so exiftool doesn't default to running "exiftool.py" on windows (which could happen)
+DEFAULT_EXECUTABLE: str
+
+if PLATFORM_WINDOWS:
+	DEFAULT_EXECUTABLE = "exiftool.exe"
+else:  # pytest-cov:windows: no cover
+	DEFAULT_EXECUTABLE = "exiftool"
+"""The name of the executable to run.
+
+If the executable is not located in one of the paths listed in the
+``PATH`` environment variable, the full path should be given here.
+"""
+
+
+
+##################################
+####### STARTUP CONSTANTS ########
+##################################
+
+# for Windows STARTUPINFO
+SW_FORCEMINIMIZE: int = 11  # from win32con
+
+# for Linux preexec_fn
+PR_SET_PDEATHSIG: int = 1  # taken from linux/prctl.h
+
+
+
+##################################
+######## GLOBAL DEFAULTS #########
+##################################
+
+# The default block size when reading from exiftool.  The standard value
+# should be fine, though other values might give better performance in
+# some cases.
+DEFAULT_BLOCK_SIZE: int = 4096
+
+# this is the minimum version required at this time
+# 8.40 / 8.60 (production): implemented the -stay_open flag
+# 12.10 / 12.15 (production): implemented exit status on -echo4
+EXIFTOOL_MINIMUM_VERSION = "12.15"
diff --git a/exiftool/exceptions.py b/exiftool/exceptions.py
new file mode 100644
index 0000000..ab8c92a
--- /dev/null
+++ b/exiftool/exceptions.py
@@ -0,0 +1,51 @@
+# -*- coding: utf-8 -*-
+
+"""
+
+This module holds all the custom exceptions thrown by PyExifTool
+
+"""
+
+class ExifToolException(Exception):
+	"""
+	Generic Base class for all ExifTool error classes
+	"""
+
+
+class ExifToolVersionError(ExifToolException):
+	"""
+	Generic Error to represent some version mismatch.
+	PyExifTool is coded to work with a range of exiftool versions.  If the advanced params change in functionality and break PyExifTool, this error will be thrown
+	"""
+
+
+class ProcessStateError(ExifToolException):
+	"""
+	Base class for all errors related to the invalid state of `exiftool` subprocess
+	"""
+
+class ExifToolRunning(ProcessStateError):
+	"""
+	ExifTool is already running
+	"""
+	def __init__(self, message):
+		super().__init__(f"ExifTool instance is running: {message}")
+
+class ExifToolNotRunning(ProcessStateError):
+	"""
+	ExifTool is not running
+	"""
+	def __init__(self, message):
+		super().__init__(f"ExifTool instance not running: {message}")
+
+
+
+class OutputEmpty(ExifToolException):
+	"""
+	ExifTool did not return output, only thrown by execute_json()
+	"""
+
+class OutputNotJSON(ExifToolException):
+	"""
+	ExifTool did not return valid JSON, only thrown by execute_json()
+	"""
diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py
index 3c238d5..a5ae6d2 100644
--- a/exiftool/exiftool.py
+++ b/exiftool/exiftool.py
@@ -1,5 +1,5 @@
 # -*- coding: utf-8 -*-
-# PyExifTool <http://github.com/smarnach/pyexiftool>
+# PyExifTool <http://github.com/sylikc/pyexiftool>
 # Copyright 2012 Sven Marnach.
 # Copyright 2021 Kevin M (sylikc)
 
@@ -29,234 +29,165 @@
 queries.  This is much more efficient than launching a separate
 process for every single query.
 
-.. _ExifTool: http://www.sno.phy.queensu.ca/~phil/exiftool/
+.. _ExifTool: https://exiftool.org
 
 The source code can be checked out from the github repository with
 
 ::
 
-	git clone git://github.com/smarnach/pyexiftool.git
+	git clone git://github.com/sylikc/pyexiftool.git
 
 Alternatively, you can download a tarball_.  There haven't been any
 releases yet.
 
-.. _tarball: https://github.com/smarnach/pyexiftool/tarball/master
+.. _tarball: https://github.com/sylikc/pyexiftool/tarball/master
 
-PyExifTool is licenced under GNU GPL version 3 or later.
+PyExifTool is licenced under GNU GPL version 3 or later, or BSD license.
 
 Example usage::
 
 	import exiftool
 
 	files = ["a.jpg", "b.png", "c.tif"]
-	with exiftool.ExifTool() as et:
-		metadata = et.get_metadata_batch(files)
+	with exiftool.ExifToolHelper() as et:
+		metadata = et.get_metadata(files)
 	for d in metadata:
 		print("{:20.20} {:20.20}".format(d["SourceFile"],
 										 d["EXIF:DateTimeOriginal"]))
 """
 
-from __future__ import unicode_literals
-
+# ---------- standard Python imports ----------
 import select
-import sys
 import subprocess
 import os
-try:
-	import ujson as json
-except ImportError:
-	import json
-import warnings
-import logging
-import codecs
+import shutil
+from pathlib import Path  # requires Python 3.4+
+import random
+import locale
 
 # for the pdeathsig
 import signal
 import ctypes
 
-try:        # Py3k compatibility
-	basestring
-except NameError:
-	basestring = (bytes, str)
-
-
 
+# ---------- UltraJSON overloaded import ----------
 
+try:
+	# Optional UltraJSON library - ultra-fast JSON encoder/decoder, drop-in replacement
+	import ujson as json
+except ImportError:
+	import json  # type: ignore   # comment related to https://github.com/python/mypy/issues/1153
+import warnings
 
 
-# specify the extension so exiftool doesn't default to running "exiftool.py" on windows (which could happen)
-if sys.platform == 'win32':
-	DEFAULT_EXECUTABLE = "exiftool.exe"
-else:
-	DEFAULT_EXECUTABLE = "exiftool"
-"""The name of the executable to run.
 
-If the executable is not located in one of the paths listed in the
-``PATH`` environment variable, the full path should be given here.
-"""
+# ---------- Typing Imports ----------
+# for static analysis / type checking - Python 3.5+
+from collections.abc import Callable
+from typing import Optional, List, Union
 
-# Sentinel indicating the end of the output of a sequence of commands.
-# The standard value should be fine.
-sentinel = b"{ready}"
 
-# The block size when reading from exiftool.  The standard value
-# should be fine, though other values might give better performance in
-# some cases.
-block_size = 4096
 
-# constants related to keywords manipulations
-KW_TAGNAME = "IPTC:Keywords"
-KW_REPLACE, KW_ADD, KW_REMOVE = range(3)
+# ---------- Library Package Imports ----------
 
-#------------------------------------------------------------------------------------------------
+from . import constants
+from .exceptions import ExifToolVersionError, ExifToolRunning, ExifToolNotRunning, OutputEmpty, OutputNotJSON
 
 
-# This code has been adapted from Lib/os.py in the Python source tree
-# (sha1 265e36e277f3)
-def _fscodec():
-	encoding = sys.getfilesystemencoding()
-	errors = "strict"
-	if encoding != "mbcs":
-		try:
-			codecs.lookup_error("surrogateescape")
-		except LookupError:
-			pass
-		else:
-			errors = "surrogateescape"
+# ======================================================================================================================
 
-	def fsencode(filename):
-		"""
-		Encode filename to the filesystem encoding with 'surrogateescape' error
-		handler, return bytes unchanged. On Windows, use 'strict' error handler if
-		the file system encoding is 'mbcs' (which is the default encoding).
-		"""
-		if isinstance(filename, bytes):
-			return filename
-		else:
-			return filename.encode(encoding, errors)
 
-	return fsencode
+# constants to make typos obsolete!
+ENCODING_UTF8: str = "utf-8"
+#ENCODING_LATIN1: str = "latin-1"
 
-fsencode = _fscodec()
-del _fscodec
 
-#------------------------------------------------------------------------------------------------
+# ======================================================================================================================
 
-def set_pdeathsig(sig=signal.SIGTERM):
+def _set_pdeathsig(sig) -> Optional[Callable]:
 	"""
 	Use this method in subprocess.Popen(preexec_fn=set_pdeathsig()) to make sure,
 	the exiftool childprocess is stopped if this process dies.
 	However, this only works on linux.
 	"""
-	if sys.platform == "linux" or sys.platform == "linux2":
+	if constants.PLATFORM_LINUX:
 		def callable_method():
-			# taken from linux/prctl.h
-			pr_set_pdeathsig = 1
 			libc = ctypes.CDLL("libc.so.6")
-			return libc.prctl(pr_set_pdeathsig, sig)
+			return libc.prctl(constants.PR_SET_PDEATHSIG, sig)
 
 		return callable_method
 	else:
-		return None
+		return None  # pragma: no cover
 
 
+# ======================================================================================================================
 
+def _read_fd_endswith(fd, b_endswith, block_size: int):
+	""" read an fd and keep reading until it endswith the seq_ends
 
-#string helper
-def strip_nl (s):
-	return ' '.join(s.splitlines())
+		this allows a consolidated read function that is platform indepdent
 
-
-# Error checking function
-# very rudimentary checking
-# Note: They are quite fragile, because this just parse the output text from exiftool
-def check_ok (result):
-	"""Evaluates the output from a exiftool write operation (e.g. `set_tags`)
-
-	The argument is the result from the execute method.
-
-	The result is True or False.
-	"""
-	return not result is None and (not "due to errors" in result)
-
-def format_error (result):
-	"""Evaluates the output from a exiftool write operation (e.g. `set_tags`)
-
-	The argument is the result from the execute method.
-
-	The result is a human readable one-line string.
+		if you're not careful, on windows, this will block
 	"""
-	if check_ok (result):
-		return 'exiftool finished probably properly. ("%s")' % strip_nl(result)
-	else:
-		if result is None:
-			return "exiftool operation can't be evaluated: No result given"
-		else:
-			return 'exiftool finished with error: "%s"' % strip_nl(result)
+	output = b""
 
+	# if we're only looking at the last few bytes, make it meaningful.  4 is max size of \r\n? (or 2)
+	# this value can be bigger to capture more bytes at the "tail" of the read, but if it's too small, the whitespace might miss the detection
+	endswith_count = len(b_endswith) + 4
 
+	# I believe doing a splice, then a strip is more efficient in memory hence the original code did it this way.
+	# need to benchmark to see if in large strings, strip()[-endswithcount:] is more expensive or not
+	while not output[-endswith_count:].strip().endswith(b_endswith):
+		if constants.PLATFORM_WINDOWS:
+			# windows does not support select() for anything except sockets
+			# https://docs.python.org/3.7/library/select.html
+			output += os.read(fd, block_size)
+		else:  # pytest-cov:windows: no cover
+			# this does NOT work on windows... and it may not work on other systems... in that case, put more things to use the original code above
+			inputready, outputready, exceptready = select.select([fd], [], [])
+			for i in inputready:
+				if i == fd:
+					output += os.read(fd, block_size)
 
+	return output
 
-#------------------------------------------------------------------------------------------------
 
 
-# https://gist.github.com/techtonik/4368898
-# Public domain code by anatoly techtonik <techtonik@gmail.com>
-# AKA Linux `which` and Windows `where`
 
-def find_executable(executable, path=None):
-	"""Find if 'executable' can be run. Looks for it in 'path'
-	(string that lists directories separated by 'os.pathsep';
-	defaults to os.environ['PATH']). Checks for all executable
-	extensions. Returns full path or None if no command is found.
-	"""
-	if path is None:
-		path = os.environ['PATH']
-	paths = path.split(os.pathsep)
-	extlist = ['']
-	
-	if os.name == 'os2':
-		(base, ext) = os.path.splitext(executable)
-		# executable files on OS/2 can have an arbitrary extension, but
-		# .exe is automatically appended if no dot is present in the name
-		if not ext:
-			executable = executable + ".exe"
-	elif sys.platform == 'win32':
-		pathext = os.environ['PATHEXT'].lower().split(os.pathsep)
-		(base, ext) = os.path.splitext(executable)
-		if ext.lower() not in pathext:
-			extlist = pathext
-	
-	for ext in extlist:
-		execname = executable + ext
-		#print(execname)
-		if os.path.isfile(execname):
-			return execname
-		else:
-			for p in paths:
-				f = os.path.join(p, execname)
-				if os.path.isfile(f):
-					return f
-	else:
-		return None
 
 
+# ======================================================================================================================
 
-#------------------------------------------------------------------------------------------------
 class ExifTool(object):
-	"""Run the `exiftool` command-line tool and communicate to it.
+	"""Run the `exiftool` command-line tool and communicate with it.
 
-	The argument ``print_conversion`` determines whether exiftool should
-	perform print conversion, which prints values in a human-readable way but
+	There argument ``print_conversion`` no longer exists.  Use ``common_args``
+	to enable/disable print conversion by specifying or not ``-n``.
+	This determines whether exiftool should perform print conversion,
+	which prints values in a human-readable way but
 	may be slower. If print conversion is enabled, appending ``#`` to a tag
 	name disables the print conversion for this particular tag.
+	See Exiftool documentation for more details:  https://exiftool.org/faq.html#Q6
 
-	You can pass two arguments to the constructor:
-	- ``common_args`` (list of strings): contains additional paramaters for
-	  the stay-open instance of exiftool
+	You can pass optional arguments to the constructor:
 	- ``executable`` (string): file name of the ``exiftool`` executable.
 	  The default value ``exiftool`` will only work if the executable
 	  is in your ``PATH``
+	  You can also specify the full path to the ``exiftool`` executable.
+	  See :py:attr:`executable` property for more details.
+	- ``common_args`` (list of strings): contains additional paramaters for
+	  the stay-open instance of exiftool.  The default is ``-G`` and ``-n``.
+	  Read the exiftool documenation to get further information on what the
+	  args do:  https://exiftool.org/exiftool_pod.html
+	- ``win_shell``
+	- ``config_file`` (string): file path to ``-config`` parameter when
+	  starting process.
+	  See :py:attr:`config_file` property for more details.
+	- ``encoding`` (string): encoding to be used when communicating with
+	  exiftool process.  By default, will use ``locale.getpreferredencoding()``
+	  See :py:attr:`encoding` property for more details
+	- ``logger`` (object):  Set a custom logger to log status and debug messages to.
+	  See :py:meth:``_set_logger()` for more details.
 
 	Most methods of this class are only available after calling
 	:py:meth:`start()`, which will actually launch the subprocess.  To
@@ -279,143 +210,563 @@ class ExifTool(object):
 	   options will be silently ignored by exiftool, so there's not
 	   much that can be done in that regard.  You should avoid passing
 	   non-existent files to any of the methods, since this will lead
-	   to undefied behaviour.
+	   to undefined behaviour.
 
-	.. py:attribute:: running
+	.. py:attribute:: _running
 
 	   A Boolean value indicating whether this instance is currently
 	   associated with a running subprocess.
 	"""
 
-	def __init__(self, executable_=None, common_args=None, win_shell=True, config_file=None):
+	##############################################################################
+	#################################### INIT ####################################
+	##############################################################################
+
+	# ----------------------------------------------------------------------------------------------------------------------
+
+	def __init__(self,
+	  executable: Optional[str] = None,
+	  common_args: Optional[List[str]] = ["-G", "-n"],
+	  win_shell: bool = False,
+	  config_file: Optional[str] = None,
+	  encoding: Optional[str] = None,
+	  logger = None) -> None:
+		""" common_args defaults to -G -n as this is the most common use case.
+		-n improves the speed, and consistency of output is more machine-parsable
+		-G separates the grouping
+		"""
+
+		# --- default settings / declare member variables ---
+		self._running: bool = False  # is it running?
+		self._win_shell: bool = win_shell  # do you want to see the shell on Windows?
+
+		self._process = None  # this is set to the process to interact with when _running=True
+		self._ver: Optional[str] = None  # this is set to be the exiftool -v -ver when running
+
+		self._last_stdout: Optional[str] = None  # previous output
+		self._last_stderr: Optional[str] = None  # previous stderr
+		self._last_status: Optional[int] = None  # previous exit status from exiftool (look up EXIT STATUS in exiftool documentation for more information)
+
+		self._block_size: int = constants.DEFAULT_BLOCK_SIZE  # set to default block size
+
+
+		# these are set via properties
+		self._executable: Optional[str] = None  # executable absolute path
+		self._config_file: Optional[str] = None  # config file that can only be set when exiftool is not running
+		self._common_args: Optional[List[str]] = None
+		self._logger = None
+		self._encoding: Optional[str] = None
+
+
+
+		# --- run external library initialization code ---
+		random.seed(None)  # initialize random number generator
+
+
+
+
+		# --- set variables via properties (which do the error checking) --
+
+		# set first, so that debug and info messages get logged
+		self.logger = logger
+
+		# use the passed in parameter, or the default if not set
+		# error checking is done in the property.setter
+		self.executable = executable or constants.DEFAULT_EXECUTABLE
+		self.encoding = encoding
+		self.common_args = common_args
+
+		# set the property, error checking happens in the property.setter
+		self.config_file = config_file
+
+
+
+
+	#######################################################################################
+	#################################### MAGIC METHODS ####################################
+	#######################################################################################
+
+	# ----------------------------------------------------------------------------------------------------------------------
+
+	def __enter__(self):
+		self.run()
+		return self
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+		if self.running:
+			self.terminate()
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	def __del__(self) -> None:
+		if self.running:
+			# indicate that __del__ has been started - allows running alternate code path in terminate()
+			self.terminate(_del=True)
+
+
+
+
+	########################################################################################
+	#################################### PROPERTIES R/w ####################################
+	########################################################################################
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	@property
+	def executable(self) -> str:
+		return self._executable
+
+	@executable.setter
+	def executable(self, new_executable: Union[str, Path]) -> None:
+		"""
+		Set the executable.  Does error checking.
+		You can specify just the executable name, or a full path
+		"""
+		# cannot set executable when process is running
+		if self.running:
+			raise ExifToolRunning("Cannot set new executable")
 
-		self.win_shell = win_shell
+		abs_path: Optional[str] = None
 
-		if executable_ is None:
-			self.executable = DEFAULT_EXECUTABLE
+		# in testing, shutil.which() will work if a complete path is given,
+		# but this isn't clear from documentation, so we explicitly check and
+		# don't search if path exists
+		if Path(new_executable).exists():
+			abs_path = new_executable
 		else:
-			self.executable = executable_
-		
-		# error checking
-		if find_executable(self.executable) is None:
-			raise FileNotFoundError( '"{}" is not found, on path or as absolute path'.format(self.executable) )
-		
-		self.running = False
-
-		self._common_args = common_args
-		# it can't be none, check if it's a list, if not, error
-
-		if config_file and not os.path.exists(config_file):
-			raise FileNotFoundError("The config file could not be found")
+			# Python 3.3+ required
+			abs_path = shutil.which(new_executable)
+
+			if abs_path is None:
+				raise FileNotFoundError(f'"{new_executable}" is not found, on path or as absolute path')
+
+		# absolute path is returned
+		self._executable = str(abs_path)
+
+		if self._logger: self._logger.info(f"Property 'executable': set to \"{abs_path}\"")
+
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	@property
+	def encoding(self) -> Optional[str]:
+		return self._encoding
+
+	@encoding.setter
+	def encoding(self, new_encoding: Optional[str]) -> None:
+		"""
+		Set the encoding of Popen() communication with exiftool process.  Does error checking.
+
+		if new_encoding is None, will detect it from locale.getpreferredencoding(do_setlocale=False)
+		do_setlocale is set to False as not to affect a caller.  will default to UTF-8 if nothing comes back
+
+		this does NOT validate the encoding for validity.  It is passed verbatim into subprocess.Popen()
+		"""
+
+		# cannot set encoding when process is running
+		if self.running:
+			raise ExifToolRunning("Cannot set new encoding")
+
+		# auto-detect system specific
+		self._encoding = new_encoding or (locale.getpreferredencoding(do_setlocale=False) or ENCODING_UTF8)
 
-		self._config_file = config_file
 
-		self._process = None
+	# ----------------------------------------------------------------------------------------------------------------------
+	@property
+	def block_size(self) -> int:
+		return self._block_size
 
-		if common_args is None:
+	@block_size.setter
+	def block_size(self, new_block_size: int) -> None:
+		"""
+		Set the block_size.  Does error checking.
+		"""
+		if new_block_size <= 0:
+			raise ValueError("Block Size doesn't make sense to be <= 0")
+
+		self._block_size = new_block_size
+
+		if self._logger: self._logger.info(f"Property 'block_size': set to \"{new_block_size}\"")
+
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	@property
+	def common_args(self) -> Optional[List[str]]:
+		return self._common_args
+
+	@common_args.setter
+	def common_args(self, new_args: Optional[List[str]]) -> None:
+		""" set the common_args parameter
+
+			this is the common_args that is passed when the Exiftool process is STARTED
+			see "-common_args" parameter in Exiftool documentation https://exiftool.org/exiftool_pod.html
+
+			so, if running==True, it will throw an error.  Can only set common_args when exiftool is not running
+
+			If new_args is None, will set to []
+		"""
+
+		if self.running:
+			raise ExifToolRunning("Cannot set new common_args")
+
+		if new_args is None:
+			self._common_args = []
+		elif isinstance(new_args, list):
 			# default parameters to exiftool
 			# -n = disable print conversion (speedup)
-			self.common_args = ["-G", "-n"]
-		elif type(common_args) is list:
-			self.common_args = common_args
+			self._common_args = new_args
 		else:
 			raise TypeError("common_args not a list of strings")
 
-		self.no_output = '-w' in self.common_args
+		if self._logger: self._logger.info(f"Property 'common_args': set to \"{self._common_args}\"")
+
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	@property
+	def config_file(self) -> Optional[str]:
+		""" Return currently set config file """
+		return self._config_file
 
-		# sets logger with name rather than using the root logger
-		self.logger = logging.getLogger(__name__)
+	@config_file.setter
+	def config_file(self, new_config_file: Optional[Union[str, Path]]) -> None:
+		""" set the config_file parameter
+
+		set to None to disable the -config parameter to exiftool
+		set to "" has special meaning and disables loading of default config file.  See exiftool documentation for more info
+
+		if :py:attr:`running` == True, it will throw an error.  Can only set config_file when exiftool is not running
+		"""
+		if self.running:
+			raise ExifToolRunning("Cannot set a new config_file")
+
+		if new_config_file is None:
+			self._config_file = None
+		elif new_config_file == "":
+			# this is VALID usage of -config parameter
+			# As per exiftool documentation:  Loading of the default config file may be disabled by setting CFGFILE to an empty string (ie. "")
+			self._config_file = ""
+		elif not Path(new_config_file).exists():
+			raise FileNotFoundError("The config file could not be found")
+		else:
+			self._config_file = str(new_config_file)
+
+		if self._logger: self._logger.info(f"Property 'config_file': set to \"{self._config_file}\"")
+
+
+
+	##############################################################################################
+	#################################### PROPERTIES Read only ####################################
+	##############################################################################################
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	@property
+	def running(self) -> bool:
+		"""
+		Read-only property which indicates whether the ExifTool instance is running or not
+
+		.. note::
+			This checks to make sure the process is still alive.
+
+			If the process has died since last `running` detection, this property
+			will detect that and reset the status accordingly
+		"""
+		if self._running:
+			# check if the process is actually alive
+			if self._process.poll() is not None:
+				# process died
+				warnings.warn("ExifTool process was previously running but died")
+				self._flag_running_false()
+
+				if self._logger: self._logger.warning(f"Property 'running': ExifTool process was previously running but died")
+
+		return self._running
+
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	@property
+	def version(self) -> str:
+		"""
+		Read-only property which is the string returned by `exiftool -ver`
+
+		The `-ver` command is ran once at process startup and cached.
+
+		This property is only valid when :py:attr:`running` == True
+		"""
+
+		if not self.running:
+			raise ExifToolNotRunning("Can't get ExifTool version")
+
+		return self._ver
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	@property
+	def last_stdout(self) -> Optional[str]:
+		"""
+		STDOUT for most recent result from execute()
+
+		.. note::
+			This property can be read at any time, and is not dependent on running state of ExifTool.
+
+			It is INTENTIONALLY *NOT* CLEARED on exiftool termination, to allow
+			for executing a command and terminating, but still have result available.
+		"""
+		return self._last_stdout
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	@property
+	def last_stderr(self) -> Optional[str]:
+		"""
+		STDERR for most recent result from execute()
 
+		.. note::
+			This property can be read at any time, and is not dependent on running state of ExifTool.
 
-	def start(self):
+			It is INTENTIONALLY *NOT* CLEARED on exiftool termination, to allow
+			for executing a command and terminating, but still have result available.
+		"""
+		return self._last_stderr
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	@property
+	def last_status(self) -> Optional[int]:
+		"""
+		Exit Status Code for most recent result from execute()
+
+		.. note::
+			This property can be read at any time, and is not dependent on running state of ExifTool.
+
+			It is INTENTIONALLY *NOT* CLEARED on exiftool termination, to allow
+			for executing a command and terminating, but still have result available.
+		"""
+		return self._last_status
+
+
+
+
+	###############################################################################################
+	#################################### PROPERTIES Write only ####################################
+	###############################################################################################
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	def _set_logger(self, new_logger) -> None:
+		""" set a new user-created logging.Logger object
+			can be set at any time to start logging.
+
+			Set to None at any time to stop logging
+		"""
+		if new_logger is None:
+			self._logger = None
+			return
+
+		# can't check this in case someone passes a drop-in replacement, like loguru, which isn't type logging.Logger
+		#elif not isinstance(new_logger, logging.Logger):
+		#	raise TypeError("logger needs to be of type logging.Logger")
+
+
+		# do some basic checks on methods available in the "logger" provided
+		check = True
+		try:
+			# ExifTool will probably use all of these logging method calls at some point
+			# check all these are callable methods
+			check = callable(new_logger.info) and \
+				callable(new_logger.warning) and \
+				callable(new_logger.error) and \
+				callable(new_logger.critical) and \
+				callable(new_logger.exception)
+		except AttributeError as e:
+			check = False
+
+		if not check:
+			raise TypeError("logger needs to implement methods (info,warning,error,critical,exception)")
+
+		self._logger = new_logger
+
+	# have to run this at the class level to create a special write-only property
+	# https://stackoverflow.com/questions/17576009/python-class-property-use-setter-but-evade-getter
+	# https://docs.python.org/3/howto/descriptor.html#properties
+	# can have it named same or different
+	logger = property(fset=_set_logger, doc="""
+		Write-only property to set the class of logging.Logger
+
+		If this is set, then status messages will log out to the given class.
+	""")
+
+
+
+
+
+
+	#########################################################################################
+	#################################### PROCESS CONTROL ####################################
+	#########################################################################################
+
+
+	# ----------------------------------------------------------------------------------------------------------------------
+
+	def run(self) -> None:
 		"""Start an ``exiftool`` process in batch mode for this instance.
 
 		This method will issue a ``UserWarning`` if the subprocess is
-		already running.  The process is by default started with the ``-G`` 
+		already running.  The process is by default started with the ``-G``
 		and ``-n`` (print conversion disabled) as common arguments,
 		which are automatically included in every command you run with
 		:py:meth:`execute()`.
 
-		However, you can override these default arguments with the 
+		However, you can override these default arguments with the
 		``common_args`` parameter in the constructor.
+
+		If it doesn't run successfully, an error will be raised, otherwise, the ``exiftool`` process has started
+
+		If the minimum required version check fails, a RuntimeError will be raised, and exiftool is automatically terminated.
+
+		(if you have another executable named exiftool which isn't exiftool, then you're shooting yourself in the foot as there's no error checking for that)
 		"""
 		if self.running:
-			warnings.warn("ExifTool already running; doing nothing.")
+			warnings.warn("ExifTool already running; doing nothing.", UserWarning)
 			return
 
-		proc_args = [self.executable, ]
+		# first the executable ...
+		proc_args = [self._executable, ]
+
 		# If working with a config file, it must be the first argument after the executable per: https://exiftool.org/config.html
-		if self._config_file:
+		if self._config_file is not None:
+			# must check explicitly for None, as "" is valid
 			proc_args.extend(["-config", self._config_file])
-		proc_args.extend(["-stay_open", "True", "-@", "-", "-common_args"])
-		proc_args.extend(self.common_args)  # add the common arguments
 
-		self.logger.debug(proc_args)
-		
-		with open(os.devnull, "w") as devnull:
-			try:
-				if sys.platform == 'win32':
-					startup_info = subprocess.STARTUPINFO()
-					if not self.win_shell:
-						SW_FORCEMINIMIZE = 11 # from win32con
-						# Adding enum 11 (SW_FORCEMINIMIZE in win32api speak) will
-						# keep it from throwing up a DOS shell when it launches.
-						startup_info.dwFlags |= 11
-
-					self._process = subprocess.Popen(
-						proc_args,
-						stdin=subprocess.PIPE, stdout=subprocess.PIPE,
-						stderr=devnull, startupinfo=startup_info)
-					# TODO check error before saying it's running
-				else:
-					# assume it's linux
-					self._process = subprocess.Popen(
-						proc_args,
-						stdin=subprocess.PIPE, stdout=subprocess.PIPE,
-						stderr=devnull, preexec_fn=set_pdeathsig(signal.SIGTERM))
-						# Warning: The preexec_fn parameter is not safe to use in the presence of threads in your application. 
-						# https://docs.python.org/3/library/subprocess.html#subprocess.Popen
-			except FileNotFoundError as fnfe:
-				raise fnfe
-			except OSError as oe:
-				raise oe
-			except ValueError as ve:
-				raise ve
-			except subprocess.CalledProcessError as cpe:
-				raise cpe
-		
+		# this is the required stuff for the stay_open that makes pyexiftool so great!
+		proc_args.extend(["-stay_open", "True", "-@", "-"])
+
+		# only if there are any common_args.  [] and None are skipped equally with this
+		if self._common_args:
+			proc_args.append("-common_args")  # add this param only if there are common_args
+			proc_args.extend(self._common_args)  # add the common arguments
+
+
+		# ---- set platform-specific kwargs for Popen ----
+		kwargs: dict = {}
+
+		if constants.PLATFORM_WINDOWS:
+			# TODO: I don't think this code actually does anything ... I've never seen a console pop up on Windows
+			# Perhaps need to specify subprocess.STARTF_USESHOWWINDOW to actually have any console pop up?
+			# https://docs.python.org/3/library/subprocess.html#windows-popen-helpers
+			startup_info = subprocess.STARTUPINFO()
+			if not self._win_shell:
+				# Adding enum 11 (SW_FORCEMINIMIZE in win32api speak) will
+				# keep it from throwing up a DOS shell when it launches.
+				startup_info.dwFlags |= constants.SW_FORCEMINIMIZE
+
+			kwargs['startupinfo'] = startup_info
+		else:  # pytest-cov:windows: no cover
+			# assume it's linux
+			kwargs['preexec_fn'] = _set_pdeathsig(signal.SIGTERM)
+			# Warning: The preexec_fn parameter is not safe to use in the presence of threads in your application.
+			# https://docs.python.org/3/library/subprocess.html#subprocess.Popen
+
+
+		try:
+			# unify both platform calls into one subprocess.Popen call
+			self._process = subprocess.Popen(
+				proc_args,
+				stdin=subprocess.PIPE,
+				stdout=subprocess.PIPE,
+				stderr=subprocess.PIPE,
+				encoding=self._encoding,
+				**kwargs)
+		except FileNotFoundError as fnfe:
+			raise
+		except OSError as oe:
+			raise
+		except ValueError as ve:
+			raise
+		except subprocess.CalledProcessError as cpe:
+			raise
+		# TODO print out more useful error messages to these different errors above
+
 		# check error above before saying it's running
-		self.running = True
+		if self._process.poll() is not None:
+			# the Popen launched, then process terminated
+			self._process = None  # unset it as it's now terminated
+			raise RuntimeError("exiftool did not execute successfully")
+
+
+		# have to set this before doing the checks below, or else execute() will fail
+		self._running = True
+
+		# get ExifTool version here and any Exiftool metadata
+		# this can also verify that it is really ExifTool we ran, not some other random process
+		try:
+			# apparently because .execute() has code that already depends on v12.15+ functionality, this will throw a ValueError immediately with
+			#   ValueError: invalid literal for int() with base 10: '${status}'
+			self._ver = self._parse_ver()
+		except ValueError:
+			# trap the error and return it as a minimum version problem
+			self.terminate()
+			raise ExifToolVersionError(f"Error retrieving Exiftool info.  Is your Exiftool version ('exiftool -ver') >= required version ('{constants.EXIFTOOL_MINIMUM_VERSION}')?")
+
+		if self._logger: self._logger.info(f"Method 'run': Exiftool version '{self._ver}' (pid {self._process.pid}) launched with args '{proc_args}'")
 
-	def terminate(self, wait_timeout=30):
+
+		# currently not needed... if it passes -ver check, the rest is OK
+		# may use in the future again if another version feature is needed but the -ver check passes
+		"""
+		# check that the minimum required version is met, if not, terminate...
+		# if you run against a version which isn't supported, strange errors come up during execute()
+		if not self._exiftool_version_check():
+			self.terminate()
+			if self._logger: self._logger.error(f"Method 'run': Exiftool version '{self._ver}' did not meet the required minimum version '{constants.EXIFTOOL_MINIMUM_VERSION}'")
+			raise ExifToolVersionError(f"exiftool version '{self._ver}' < required '{constants.EXIFTOOL_MINIMUM_VERSION}'")
+		"""
+
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	def terminate(self, timeout: int = 30, _del: bool = False) -> None:
 		"""Terminate the ``exiftool`` process of this instance.
 
-		If the subprocess isn't running, this method will do nothing.
+		If the subprocess isn't running, this method will throw a warning, and do nothing.
 		"""
 		if not self.running:
+			warnings.warn("ExifTool not running; doing nothing.", UserWarning)
 			return
-		self._process.stdin.write(b"-stay_open\nFalse\n")
-		self._process.stdin.flush()
-		try:
-			self._process.communicate(timeout=wait_timeout)
-		except subprocess.TimeoutExpired: # this is new in Python 3.3 (for python 2.x, use the PyPI subprocess32 module)
+
+		if _del and constants.PLATFORM_WINDOWS:
+			# don't cleanly exit on windows, during __del__ as it'll freeze at communicate()
 			self._process.kill()
-			outs, errs = self._process.communicate()
-			# err handling code from https://docs.python.org/3/library/subprocess.html#subprocess.Popen.communicate
-			
-		del self._process
-		self.running = False
+			#print("before comm", self._process.poll(), self._process)
+			self._process.poll()
+			# TODO freezes here on windows if subprocess zombie remains
+			outs, errs = self._process.communicate()  # have to cleanup the process or else .poll() will return None
+			#print("after comm")
+			# TODO a bug filed with Python, or user error... this doesn't seem to work at all ... .communicate() still hangs
+			# https://bugs.python.org/issue43784 ... Windows-specific issue affecting Python 3.8-3.10 (as of this time)
+		else:
+			try:
+				"""
+					On Windows, running this after __del__ freezes at communicate(), regardless of timeout
+						this is possibly because the file descriptors are no longer valid or were closed at __del__
+
+						test yourself with simple code that calls .run() and then end of script
+
+					On Linux, this runs as is, and the process terminates properly
+				"""
+				self._process.communicate(input="-stay_open\nFalse\n", timeout=timeout)  # TODO these are constants which should be elsewhere defined
+				self._process.kill()
+			except subprocess.TimeoutExpired:  # this is new in Python 3.3 (for python 2.x, use the PyPI subprocess32 module)
+				self._process.kill()
+				outs, errs = self._process.communicate()
+				# err handling code from https://docs.python.org/3/library/subprocess.html#subprocess.Popen.communicate
+
+		self._flag_running_false()
+
+		# TODO log / return exit status from exiftool?
+		if self._logger: self._logger.info("Method 'terminate': Exiftool terminated successfully.")
 
-	def __enter__(self):
-		self.start()
-		return self
 
-	def __exit__(self, exc_type, exc_val, exc_tb):
-		self.terminate()
 
-	def __del__(self):
-		self.terminate()
 
+
+	##################################################################################
+	#################################### EXECUTE* ####################################
+	##################################################################################
+
+	# ----------------------------------------------------------------------------------------------------------------------
 	def execute(self, *params):
 		"""Execute the given batch of parameters with ``exiftool``.
 
@@ -428,7 +779,7 @@ def execute(self, *params):
 		end-of-output sentinel and returned as a raw ``bytes`` object,
 		excluding the sentinel.
 
-		The parameters must also be raw ``bytes``, in whatever
+		The parameters must be in ``str``, use the `encoding` property to change to
 		encoding exiftool accepts.  For filenames, this should be the
 		system's filesystem encoding.
 
@@ -436,66 +787,90 @@ def execute(self, *params):
 		   rarely be needed by application developers.
 		"""
 		if not self.running:
-			raise ValueError("ExifTool instance not running.")
-		
-		cmd_text = b"\n".join(params + (b"-execute\n",))
-		# cmd_text.encode("utf-8") # a commit put this in the next line, but i can't get it to work TODO
-		# might look at something like this https://stackoverflow.com/questions/7585435/best-way-to-convert-string-to-bytes-in-python-3
+			raise ExifToolNotRunning("Cannot execute()")
+
+
+		# ---------- build the special params to execute ----------
+
+		# there's a special usage of execute/ready specified in the manual which make almost ensure we are receiving the right signal back
+		# from exiftool man pages:  When this number is added, -q no longer suppresses the "{ready}"
+		signal_num = random.randint(100000, 999999)  # arbitrary create a 6 digit number (keep it down to save memory maybe)
+
+		# constant special sequences when running -stay_open mode
+		seq_execute = f"-execute{signal_num}\n"  # the default string is b"-execute\n"
+		seq_ready = f"{{ready{signal_num}}}"  # the default string is b"{ready}"
+
+		# these are special sequences to help with synchronization.  It will print specific text to STDERR before and after processing
+		#SEQ_STDERR_PRE_FMT = "pre{}" # can have a PRE sequence too but we don't need it for syncing
+		seq_err_post = f"post{signal_num}"  # default there isn't any string
+
+		SEQ_ERR_STATUS_DELIM = "="  # this can be configured to be one or more chacters... the code below will accomodate for longer sequences: len() >= 1
+		seq_err_status = "${status}"  # a special sequence, ${status} returns EXIT STATUS as per exiftool documentation - only supported on exiftool v12.10+
+
+		# f-strings are faster than concatentation of multiple strings -- https://stackoverflow.com/questions/59180574/string-concatenation-with-vs-f-string
+		cmd_text = "\n".join(params + ("-echo4", f"{SEQ_ERR_STATUS_DELIM}{seq_err_status}{SEQ_ERR_STATUS_DELIM}{seq_err_post}", seq_execute))
+
+
+		# ---------- write to the pipe connected with exiftool process ----------
+
 		self._process.stdin.write(cmd_text)
 		self._process.stdin.flush()
-		output = b""
-		fd = self._process.stdout.fileno()
-		while not output[-32:].strip().endswith(sentinel):
-			if sys.platform == 'win32':
-				# windows does not support select() for anything except sockets
-				# https://docs.python.org/3.7/library/select.html
-				output += os.read(fd, block_size)
-			else:
-				# this does NOT work on windows... and it may not work on other systems... in that case, put more things to use the original code above
-				inputready,outputready,exceptready = select.select([fd],[],[])
-				for i in inputready:
-					if i == fd:
-						output += os.read(fd, block_size)
-		return output.strip()[:-len(sentinel)]
-
-
-	# i'm not sure if the verification works, but related to pull request (#11)
-	def execute_json_wrapper(self, filenames, params=None, retry_on_error=True):
-		# make sure the argument is a list and not a single string
-		# which would lead to strange errors
-		if isinstance(filenames, basestring):
-			raise TypeError("The argument 'filenames' must be an iterable of strings")
-
-		execute_params = []
-
-		if params:
-			execute_params.extend(params)
-		execute_params.extend(filenames)
-
-		result = self.execute_json(execute_params)
-
-		if result:
-			try:
-				ExifTool._check_sanity_of_result(filenames, result)
-			except IOError as error:
-				# Restart the exiftool child process in these cases since something is going wrong
-				self.terminate()
-				self.start()
-
-				if retry_on_error:
-					result = self.execute_json_filenames(filenames, params, retry_on_error=False)
-				else:
-					raise error
-		else:
-			# Reasons for exiftool to provide an empty result, could be e.g. file not found, etc.
-			# What should we do in these cases? We don't have any information what went wrong, therefore
-			# we just return empty dictionaries.
-			result = [{} for _ in filenames]
 
-		return result
+		if self._logger: self._logger.info("Method 'execute': Command sent = {}".format(cmd_text.split('\n')[:-1]))
+
+
+		# ---------- read output from exiftool process until special sequences reached ----------
+
+		fdout = self._process.stdout.fileno()
+		raw_stdout = _read_fd_endswith(fdout, seq_ready.encode(self._encoding), self._block_size).decode(self._encoding)
 
+		# when it's ready, we can safely read all of stderr out, as the command is already done
+		fderr = self._process.stderr.fileno()
+		raw_stderr = _read_fd_endswith(fderr, seq_err_post.encode(self._encoding), self._block_size).decode(self._encoding)
 
 
+		# ---------- parse output ----------
+
+		# save the outputs to some variables first
+		cmd_stdout = raw_stdout.strip()[:-len(seq_ready)]
+		cmd_stderr = raw_stderr.strip()[:-len(seq_err_post)]  # save it in case the error below happens and output can be checked easily
+
+		# sanity check the status code from the stderr output
+		delim_len = len(SEQ_ERR_STATUS_DELIM)
+		if cmd_stderr[-delim_len:] != SEQ_ERR_STATUS_DELIM:
+			# exiftool is expected to dump out the status code within the delims... if it doesn't, the class is broken
+			raise ExifToolVersionError(f"Exiftool expected to return status on stderr, but got unexpected character: {cmd_stderr[-delim_len:]} != {SEQ_ERR_STATUS_DELIM}")
+
+		# look for the previous delim (we could use regex here to do all this in one step, but it's probably overkill, and could slow down the code significantly)
+		# the other simplification that can be done is that, Exiftool is expected to only return 0, 1, or 2 as per documentation
+		# you could just lop the last 3 characters off... but if the return status changes in the future, then this code would break
+		err_delim_1 = cmd_stderr.rfind(SEQ_ERR_STATUS_DELIM, 0, -delim_len)
+		cmd_status = cmd_stderr[err_delim_1 + delim_len : -delim_len]
+
+
+		# ---------- save the output to class vars for later retrieval ----------
+
+		# lop off the actual status code from stderr
+		self._last_stderr = cmd_stderr[:err_delim_1]
+		self._last_stdout = cmd_stdout
+		# can check .isnumeric() here, but best just to duck-type cast it
+		self._last_status = int(cmd_status)
+
+
+
+		if self._logger:
+			self._logger.debug(f"Method 'execute': Reply stdout = \"{self._last_stdout}\"")
+			self._logger.debug(f"Method 'execute': Reply stderr = \"{self._last_stderr}\"")
+			self._logger.debug(f"Method 'execute': Reply status = {self._last_status}")
+
+
+		# the standard return: just stdout
+		# if you need other output, retrieve from properties
+		return self._last_stdout
+
+
+
+	# ----------------------------------------------------------------------------------------------------------------------
 	def execute_json(self, *params):
 		"""Execute the given batch of parameters and parse the JSON output.
 
@@ -518,259 +893,123 @@ def execute_json(self, *params):
 		respective Python version – as raw strings in Python 2.x and
 		as Unicode strings in Python 3.x.
 		"""
-		params = map(fsencode, params)
-		# Some latin bytes won't decode to utf-8.
-		# Try utf-8 and fallback to latin.
-		# http://stackoverflow.com/a/5552623/1318758
-		# https://github.com/jmathai/elodie/issues/127
-		res = self.execute(b"-j", *params)
-		try:
-			res_decoded = res.decode("utf-8")
-		except UnicodeDecodeError:
-			res_decoded = res.decode("latin-1")
-		# res_decoded can be invalid json if `-w` flag is specified in common_args
-		# which will return something like
-		# image files read
-		# output files created
-		if self.no_output:
-			print(res_decoded)
-		else:
-			# TODO: if len(res_decoded) == 0, then there's obviously an error here
-			return json.loads(res_decoded)
 
-	# allows adding additional checks (#11)
-	def get_metadata_batch_wrapper(self, filenames, params=None):
-		return self.execute_json_wrapper(filenames=filenames, params=params)
+		result = self.execute("-j", *params)  # stdout
 
-	def get_metadata_batch(self, filenames, params=None):
-		"""Return all meta-data for the given files.
-
-		The return value will have the format described in the
-		documentation of :py:meth:`execute_json()`.
-		"""
-		if not params:
-			params = []
-		return self.execute_json(*filenames, *params)
-
-	# (#11)
-	def get_metadata_wrapper(self, filename, params=None):
-		return self.execute_json_wrapper(filenames=[filename], params=params)[0]
-
-	def get_metadata(self, filename, params=None):
-		"""Return meta-data for a single file.
-
-		The returned dictionary has the format described in the
-		documentation of :py:meth:`execute_json()`.
-		"""
-		if not params:
-			params = []
-		return self.execute_json(filename, *params)[0]
-
-	# (#11)
-	def get_tags_batch_wrapper(self, tags, filenames, params=None):
-		params = (params if params else []) + ["-" + t for t in tags]
-		return self.execute_json_wrapper(filenames=filenames, params=params)
-
-	def get_tags_batch(self, tags, filenames):
-		"""Return only specified tags for the given files.
-
-		The first argument is an iterable of tags.  The tag names may
-		include group names, as usual in the format <group>:<tag>.
-
-		The second argument is an iterable of file names.
-
-		The format of the return value is the same as for
-		:py:meth:`execute_json()`.
+		# TODO check status code or have caller do it?
 		"""
-		# Explicitly ruling out strings here because passing in a
-		# string would lead to strange and hard-to-find errors
-		if isinstance(tags, basestring):
-			raise TypeError("The argument 'tags' must be "
-							"an iterable of strings")
-		if isinstance(filenames, basestring):
-			raise TypeError("The argument 'filenames' must be "
-							"an iterable of strings")
-		params = ["-" + t for t in tags]
-		params.extend(filenames)
-		return self.execute_json(*params)
-
-	# (#11)
-	def get_tags_wrapper(self, tags, filename, params=None):
-		return self.get_tags_batch_wrapper(tags, [filename], params=params)[0]
-
-	def get_tags(self, tags, filename):
-		"""Return only specified tags for a single file.
-
-		The returned dictionary has the format described in the
-		documentation of :py:meth:`execute_json()`.
+		status_code = self._last_status  # status code
+		if status_code != 0:
+			warnings.warn(f"ExifTool returned a non-zero status code: {status_code}", UserWarning)
 		"""
-		return self.get_tags_batch(tags, [filename])[0]
-
-	# (#11)
-	def get_tag_batch_wrapper(self, tag, filenames, params=None):
-		data = self.get_tags_batch_wrapper([tag], filenames, params=params)
-		result = []
-		for d in data:
-			d.pop("SourceFile")
-			result.append(next(iter(d.values()), None))
-		return result
 
 
-	def get_tag_batch(self, tag, filenames):
-		"""Extract a single tag from the given files.
+		if len(result) == 0:
+			# the output from execute() can be empty under many relatively ambiguous situations
+			# * command has no files it worked on
+			# * a file specified or files does not exist
+			# * some other type of error
+			# * a command that does not return anything (like metadata manipulation/setting tags)
+			#
+			# There's no easy way to check which params are files, or else we have to reproduce the parser exiftool does (so it's hard to detect to raise a FileNotFoundError)
 
-		The first argument is a single tag name, as usual in the
-		format <group>:<tag>.
+			# Returning [] could be ambiguous if Exiftool changes the returned JSON structure in the future
+			# Returning None was preferred, because it's the safest as it clearly indicates that nothing came back from execute(), but it means execute_json() doesn't always return JSON
+			raise OutputEmpty("ExifTool did not return any stdout")
 
-		The second argument is an iterable of file names.
 
-		The return value is a list of tag values or ``None`` for
-		non-existent tags, in the same order as ``filenames``.
-		"""
-		data = self.get_tags_batch([tag], filenames)
-		result = []
-		for d in data:
-			d.pop("SourceFile")
-			result.append(next(iter(d.values()), None))
-		return result
-
-	# (#11)
-	def get_tag_wrapper(self, tag, filename, params=None):
-		return self.get_tag_batch_wrapper(tag, [filename], params=params)[0]
-
-	def get_tag(self, tag, filename):
-		"""Extract a single tag from a single file.
-
-		The return value is the value of the specified tag, or
-		``None`` if this tag was not found in the file.
-		"""
-		return self.get_tag_batch(tag, [filename])[0]
-
-	def copy_tags(self, fromFilename, toFilename):
-		"""Copy all tags from one file to another."""
-		params = ["-overwrite_original", "-TagsFromFile", fromFilename, toFilename]
-		params_utf8 = [x.encode('utf-8') for x in params]
-		self.execute(*params_utf8)
+		try:
+			parsed = json.loads(result)
+		except json.JSONDecodeError as e:
+			# if `-w` flag is specified in common_args or params, stdout will not be JSON parseable
+			#
+			# which will return something like:
+			#   x image files read
+			#   x output files created
 
+			# the user is expected to know this ahead of time, and if -w exists in common_args or as a param, this error will be thrown
 
-	def set_tags_batch(self, tags, filenames):
-		"""Writes the values of the specified tags for the given files.
+			# explicit chaining https://www.python.org/dev/peps/pep-3134/
+			raise OutputNotJSON() from e
 
-		The first argument is a dictionary of tags and values.  The tag names may
-		include group names, as usual in the format <group>:<tag>.
+		return parsed
 
-		The second argument is an iterable of file names.
 
-		The format of the return value is the same as for
-		:py:meth:`execute()`.
+	#########################################################################################
+	#################################### PRIVATE METHODS ####################################
+	#########################################################################################
 
-		It can be passed into `check_ok()` and `format_error()`.
+	# ----------------------------------------------------------------------------------------------------------------------
+	def _flag_running_false(self) -> None:
+		""" private method that resets the "running" state
+			It used to be that there was only self._running to unset, but now it's a trio of variables
 
-		tags items can be lists, in which case, the tag will be passed 
-		with each item in the list, in the order given
-		"""
-		# Explicitly ruling out strings here because passing in a
-		# string would lead to strange and hard-to-find errors
-		if isinstance(tags, basestring):
-			raise TypeError("The argument 'tags' must be dictionary "
-							"of strings")
-		if isinstance(filenames, basestring):
-			raise TypeError("The argument 'filenames' must be "
-							"an iterable of strings")
-
-		params = []
-		params_utf8 = []
-		for tag, value in tags.items():
-			# contributed by @daviddorme in https://github.com/sylikc/pyexiftool/issues/12#issuecomment-821879234
-			# allows setting things like Keywords which require separate directives 
-			# > exiftool -Keywords=keyword1 -Keywords=keyword2 -Keywords=keyword3 file.jpg
-			# which are not supported as duplicate keys in a dictionary
-			if isinstance(value, list):
-				for item in value:
-					params.append(u'-%s=%s' % (tag, item))
-			else:
-				params.append(u'-%s=%s' % (tag, value))
-
-		params.extend(filenames)
-		params_utf8 = [x.encode('utf-8') for x in params]
-		return self.execute(*params_utf8)
-
-	def set_tags(self, tags, filename):
-		"""Writes the values of the specified tags for the given file.
-
-		This is a convenience function derived from `set_tags_batch()`.
-		Only difference is that it takes as last arugemnt only one file name
-		as a string.
+			This method makes it less likely a maintainer will leave off a variable if other ones are added in the future
 		"""
-		return self.set_tags_batch(tags, [filename])
-
-	def set_keywords_batch(self, mode, keywords, filenames):
-		"""Modifies the keywords tag for the given files.
-
-		The first argument is the operation mode:
-		KW_REPLACE: Replace (i.e. set) the full keywords tag with `keywords`.
-		KW_ADD:     Add `keywords` to the keywords tag.
-					If a keyword is present, just keep it.
-		KW_REMOVE:  Remove `keywords` from the keywords tag.
-					If a keyword wasn't present, just leave it.
+		self._process = None  # don't delete, just leave as None
+		self._ver = None  # unset the version
+		self._running = False
 
-		The second argument is an iterable of key words.
+		# as an FYI, as per the last_* properties, they are intentionally not cleared when process closes
 
-		The third argument is an iterable of file names.
 
-		The format of the return value is the same as for
-		:py:meth:`execute()`.
-
-		It can be passed into `check_ok()` and `format_error()`.
+	# ----------------------------------------------------------------------------------------------------------------------
+	def _parse_ver(self):
+		""" private method to run exiftool -ver
+			and parse out the information
 		"""
-		# Explicitly ruling out strings here because passing in a
-		# string would lead to strange and hard-to-find errors
-		if isinstance(keywords, basestring):
-			raise TypeError("The argument 'keywords' must be "
-							"an iterable of strings")
-		if isinstance(filenames, basestring):
-			raise TypeError("The argument 'filenames' must be "
-							"an iterable of strings")
-
-		params = []
-		params_utf8 = []
-
-		kw_operation = {KW_REPLACE:"-%s=%s",
-						KW_ADD:"-%s+=%s",
-						KW_REMOVE:"-%s-=%s"}[mode]
+		if not self.running:
+			raise ExifToolNotRunning("Cannot get version")
 
-		kw_params = [ kw_operation % (KW_TAGNAME, w)  for w in keywords ]
 
-		params.extend(kw_params)
-		params.extend(filenames)
-		self.logger.debug(params)
+		# -ver is just the version
+		# -v gives you more info (perl version, platform, libraries) but isn't helpful for this library
+		# -v2 gives you even more, but it's less useful at that point
+		return self.execute("-ver").strip()
 
-		params_utf8 = [x.encode('utf-8') for x in params]
-		return self.execute(*params_utf8)
+	# ----------------------------------------------------------------------------------------------------------------------
+	"""
+	def _exiftool_version_check(self) -> bool:
+		"" " private method to check the minimum required version of ExifTool
 
-	def set_keywords(self, mode, keywords, filename):
-		"""Modifies the keywords tag for the given file.
+		returns false if the version check fails
+		returns true if it's OK
 
-		This is a convenience function derived from `set_keywords_batch()`.
-		Only difference is that it takes as last argument only one file name
-		as a string.
-		"""
-		return self.set_keywords_batch(mode, keywords, [filename])
+		"" "
 
+		# parse (major, minor) with integers... so far Exiftool versions are all ##.## with no exception
+		# this isn't entirely tested... possibly a version with more "." or something might break this parsing
+		arr: List = self._ver.split(".", 1)  # split to (major).(whatever)
 
+		version_nums: List = []
+		try:
+			for v in arr:
+				res.append(int(v))
+		except ValueError:
+			raise ValueError(f"Error parsing ExifTool version for version check: '{self._ver}'")
+
+		if len(version_nums) != 2:
+			raise ValueError(f"Expected Major.Minor len()==2, got: {version_nums}")
+
+		curr_major, curr_minor = version_nums
+
+
+		# same logic above except on one line
+		req_major, req_minor = [int(x) for x in constants.EXIFTOOL_MINIMUM_VERSION.split(".", 1)]
+
+		if curr_major > req_major:
+			# major version is bigger
+			return True
+		elif curr_major < req_major:
+			# major version is smaller
+			return False
+		elif curr_minor >= req_minor:
+			# major version is equal
+			# current minor is equal or better
+			return True
+		else:
+			# anything else is False
+			return False
+	"""
 
-	@staticmethod
-	def _check_sanity_of_result(file_paths, result):
-		"""
-		Checks if the given file paths matches the 'SourceFile' entries in the result returned by
-		exiftool. This is done to find possible mix ups in the streamed responses.
-		"""
-		# do some sanity checks on the results to make sure nothing was mixed up during reading from stdout
-		if len(result) != len(file_paths):
-			raise IOError("exiftool did return %d results, but expected was %d" % (len(result), len(file_paths)))
-		for i in range(0, len(file_paths)):
-			returned_source_file = result[i]['SourceFile']
-			requested_file = file_paths[i]
-			if returned_source_file != requested_file:
-				raise IOError('exiftool returned data for file %s, but expected was %s'
-							  % (returned_source_file, requested_file))
+	# ----------------------------------------------------------------------------------------------------------------------
diff --git a/exiftool/experimental.py b/exiftool/experimental.py
new file mode 100644
index 0000000..d922b0c
--- /dev/null
+++ b/exiftool/experimental.py
@@ -0,0 +1,355 @@
+# -*- coding: utf-8 -*-
+# PyExifTool <http://github.com/sylikc/pyexiftool>
+# Copyright 2021 Kevin M (sylikc)
+
+# More contributors in the CHANGELOG for the pull requests
+
+# This file is part of PyExifTool.
+#
+# PyExifTool is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the licence, or
+# (at your option) any later version, or the BSD licence.
+#
+# PyExifTool is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+#
+# See COPYING.GPL or COPYING.BSD for more details.
+
+"""
+
+This contains the "experimental" functionality.  In the grand scheme of things, this class
+contains "untested" functionality, or those that are less used, and may merge in with the
+ExifToolHelper() class at some point.
+
+The starting point of this class was to remove all the "less used" functionality that was merged in
+on some arbitrary pull requests to the pre-fork repository.  This code is brittle and contains
+a lot of "hacks" for a niche set of use cases.  As such, it shouldn't crowd the core functionality
+of the ExifTool() class or the stable extended functionality of the ExifToolHelper() class.
+
+The class heirarchy:  ExifTool -> ExifToolHelper -> ExifToolAlpha,
+* ExifTool - stable base class with CORE functionality
+* ExifToolHelper - user friendly class that extends the base class with general functionality not found in the core
+* ExifToolAlpha - alpha-quality code which extends the ExifToolHelper to add functionality that is niche, brittle, or not well tested
+
+Because of this heirarchy, you could always use/extend the ExifToolAlpha() class to have all functionality,
+or at your discretion, use one of the more stable classes above.
+
+"""
+
+from .helper import ExifToolHelper
+
+
+try:        # Py3k compatibility
+	basestring
+except NameError:
+	basestring = (bytes, str)
+
+# ======================================================================================================================
+
+#def atexit_handler
+
+# constants related to keywords manipulations
+KW_TAGNAME = "IPTC:Keywords"
+KW_REPLACE, KW_ADD, KW_REMOVE = range(3)
+
+
+
+
+
+# ======================================================================================================================
+
+
+
+#string helper
+def strip_nl(s):
+	return ' '.join(s.splitlines())
+
+# ======================================================================================================================
+
+# Error checking function
+# very rudimentary checking
+# Note: They are quite fragile, because this just parse the output text from exiftool
+def check_ok(result):
+	"""Evaluates the output from a exiftool write operation (e.g. `set_tags`)
+
+	The argument is the result from the execute method.
+
+	The result is True or False.
+	"""
+	return not result is None and (not "due to errors" in result)
+
+# ======================================================================================================================
+
+def format_error(result):
+	"""Evaluates the output from a exiftool write operation (e.g. `set_tags`)
+
+	The argument is the result from the execute method.
+
+	The result is a human readable one-line string.
+	"""
+	if check_ok(result):
+		return 'exiftool finished probably properly. ("%s")' % strip_nl(result)
+	else:
+		if result is None:
+			return "exiftool operation can't be evaluated: No result given"
+		else:
+			return 'exiftool finished with error: "%s"' % strip_nl(result)
+
+
+
+# ======================================================================================================================
+
+class ExifToolAlpha(ExifToolHelper):
+	""" this class extends the ExifToolHelper class with alpha-quality code, which,
+	may add functionality, but may introduce bugs or add in unneeded bloat which may
+	be specific to niche use cases
+
+	lots of these methods are from the original incarnation of pyexiftool
+	with miscellaneous added pull requests.
+
+	There's a lot of extra functionality, but the code quality leaves a lot to be desired
+
+	And in some cases, there are edge cases which return unexpected results... so
+	they will be placed into this class until the functionality can be standardized in a "stable" way
+
+
+	Please issue PR to this class to add functionality, even if not tested well.  This class is for experimental code after all!
+	"""
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	# i'm not sure if the verification works, but related to pull request (#11)
+	def execute_json_wrapper(self, filenames, params=None, retry_on_error=True):
+		# make sure the argument is a list and not a single string
+		# which would lead to strange errors
+		if isinstance(filenames, basestring):
+			raise TypeError("The argument 'filenames' must be an iterable of strings")
+
+		execute_params = []
+
+		if params:
+			execute_params.extend(params)
+		execute_params.extend(filenames)
+
+		result = self.execute_json(execute_params)
+
+		if result:
+			try:
+				ExifToolAlpha._check_sanity_of_result(filenames, result)
+			except IOError as error:
+				# Restart the exiftool child process in these cases since something is going wrong
+				self.terminate()
+				self.run()
+
+				if retry_on_error:
+					result = self.execute_json_filenames(filenames, params, retry_on_error=False)
+				else:
+					raise error
+		else:
+			# Reasons for exiftool to provide an empty result, could be e.g. file not found, etc.
+			# What should we do in these cases? We don't have any information what went wrong, therefore
+			# we just return empty dictionaries.
+			result = [{} for _ in filenames]
+
+		return result
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	# allows adding additional checks (#11)
+	def get_metadata_batch_wrapper(self, filenames, params=None):
+		return self.execute_json_wrapper(filenames=filenames, params=params)
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	# (#11)
+	def get_metadata_wrapper(self, filename, params=None):
+		return self.execute_json_wrapper(filenames=[filename], params=params)[0]
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	# (#11)
+	def get_tags_batch_wrapper(self, tags, filenames, params=None):
+		params = (params if params else []) + ["-" + t for t in tags]
+		return self.execute_json_wrapper(filenames=filenames, params=params)
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	# (#11)
+	def get_tags_wrapper(self, tags, filename, params=None):
+		return self.get_tags_batch_wrapper(tags, [filename], params=params)[0]
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	# (#11)
+	def get_tag_batch_wrapper(self, tag, filenames, params=None):
+		data = self.get_tags_batch_wrapper([tag], filenames, params=params)
+		result = []
+		for d in data:
+			d.pop("SourceFile")
+			result.append(next(iter(d.values()), None))
+		return result
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	# this was a method with good intentions by the original author, but returns some inconsistent results in some cases
+	# for example, if you passed in a single tag, or a group name, it would return the first tag back instead of the whole group
+	# try calling get_tag_batch("*.mp4", "QuickTime") or "QuickTime:all" ... the expected results is a dictionary but a single tag is returned
+	def get_tag_batch(self, filenames, tag):
+		"""Extract a single tag from the given files.
+
+		The first argument is a single tag name, as usual in the
+		format <group>:<tag>.
+
+		The second argument is an iterable of file names.
+
+		The return value is a list of tag values or ``None`` for
+		non-existent tags, in the same order as ``filenames``.
+		"""
+		data = self.get_tags(filenames, [tag])
+		result = []
+		for d in data:
+			d.pop("SourceFile")
+			result.append(next(iter(d.values()), None))
+		return result
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	# (#11)
+	def get_tag_wrapper(self, tag, filename, params=None):
+		return self.get_tag_batch_wrapper(tag, [filename], params=params)[0]
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	def get_tag(self, filename, tag):
+		"""Extract a single tag from a single file.
+
+		The return value is the value of the specified tag, or
+		``None`` if this tag was not found in the file.
+		"""
+		return self.get_tag_batch([filename], tag)[0]
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	def copy_tags(self, from_filename, to_filename):
+		"""Copy all tags from one file to another."""
+		params = ["-overwrite_original", "-TagsFromFile", str(from_filename), str(to_filename)]
+		self.execute(*params)
+
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	def set_tags_batch(self, filenames, tags):
+		"""Writes the values of the specified tags for the given files.
+
+		The first argument is a dictionary of tags and values.  The tag names may
+		include group names, as usual in the format <group>:<tag>.
+
+		The second argument is an iterable of file names.
+
+		The format of the return value is the same as for
+		:py:meth:`execute()`.
+
+		It can be passed into `check_ok()` and `format_error()`.
+
+		tags items can be lists, in which case, the tag will be passed
+		with each item in the list, in the order given
+		"""
+		# Explicitly ruling out strings here because passing in a
+		# string would lead to strange and hard-to-find errors
+		if isinstance(tags, basestring):
+			raise TypeError("The argument 'tags' must be dictionary "
+							"of strings")
+		if isinstance(filenames, basestring):
+			raise TypeError("The argument 'filenames' must be "
+							"an iterable of strings")
+
+		params = []
+		for tag, value in tags.items():
+			# contributed by @daviddorme in https://github.com/sylikc/pyexiftool/issues/12#issuecomment-821879234
+			# allows setting things like Keywords which require separate directives
+			# > exiftool -Keywords=keyword1 -Keywords=keyword2 -Keywords=keyword3 file.jpg
+			# which are not supported as duplicate keys in a dictionary
+			if isinstance(value, list):
+				for item in value:
+					params.append(f"-{tag}={item}")
+			else:
+				params.append(f"-{tag}={value}")
+
+		params.extend(filenames)
+		return self.execute(*params)
+
+		#TODO if execute returns data, then error?
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	def set_tags(self, filename, tags):
+		"""Writes the values of the specified tags for the given file.
+
+		This is a convenience function derived from `set_tags_batch()`.
+		Only difference is that it takes as last arugemnt only one file name
+		as a string.
+		"""
+		return self.set_tags_batch([filename], tags)
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	def set_keywords_batch(self, filenames, mode, keywords):
+		"""Modifies the keywords tag for the given files.
+
+		The first argument is the operation mode:
+		KW_REPLACE: Replace (i.e. set) the full keywords tag with `keywords`.
+		KW_ADD:     Add `keywords` to the keywords tag.
+					If a keyword is present, just keep it.
+		KW_REMOVE:  Remove `keywords` from the keywords tag.
+					If a keyword wasn't present, just leave it.
+
+		The second argument is an iterable of key words.
+
+		The third argument is an iterable of file names.
+
+		The format of the return value is the same as for
+		:py:meth:`execute()`.
+
+		It can be passed into `check_ok()` and `format_error()`.
+		"""
+		# Explicitly ruling out strings here because passing in a
+		# string would lead to strange and hard-to-find errors
+		if isinstance(keywords, basestring):
+			raise TypeError("The argument 'keywords' must be "
+							"an iterable of strings")
+		if isinstance(filenames, basestring):
+			raise TypeError("The argument 'filenames' must be "
+							"an iterable of strings")
+
+		params = []
+
+		kw_operation = {KW_REPLACE: "-%s=%s",
+						KW_ADD: "-%s+=%s",
+						KW_REMOVE: "-%s-=%s"}[mode]
+
+		kw_params = [kw_operation % (KW_TAGNAME, w)  for w in keywords]
+
+		params.extend(kw_params)
+		params.extend(filenames)
+		if self._logger: self._logger.debug(params)
+
+		return self.execute(*params)
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	def set_keywords(self, filename, mode, keywords):
+		"""Modifies the keywords tag for the given file.
+
+		This is a convenience function derived from `set_keywords_batch()`.
+		Only difference is that it takes as last argument only one file name
+		as a string.
+		"""
+		return self.set_keywords_batch([filename], mode, keywords)
+
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	@staticmethod
+	def _check_sanity_of_result(file_paths, result):
+		"""
+		Checks if the given file paths matches the 'SourceFile' entries in the result returned by
+		exiftool. This is done to find possible mix ups in the streamed responses.
+		"""
+		# do some sanity checks on the results to make sure nothing was mixed up during reading from stdout
+		if len(result) != len(file_paths):
+			raise IOError("exiftool did return %d results, but expected was %d" % (len(result), len(file_paths)))
+		for i in range(0, len(file_paths)):
+			returned_source_file = result[i]['SourceFile']
+			requested_file = file_paths[i]
+			if returned_source_file != requested_file:
+				raise IOError('exiftool returned data for file %s, but expected was %s'
+							  % (returned_source_file, requested_file))
+
+	# ----------------------------------------------------------------------------------------------------------------------
diff --git a/exiftool/helper.py b/exiftool/helper.py
new file mode 100644
index 0000000..8d42354
--- /dev/null
+++ b/exiftool/helper.py
@@ -0,0 +1,231 @@
+# -*- coding: utf-8 -*-
+# PyExifTool <http://github.com/sylikc/pyexiftool>
+# Copyright 2021 Kevin M (sylikc)
+
+# More contributors in the CHANGELOG for the pull requests
+
+# This file is part of PyExifTool.
+#
+# PyExifTool is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the licence, or
+# (at your option) any later version, or the BSD licence.
+#
+# PyExifTool is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+#
+# See COPYING.GPL or COPYING.BSD for more details.
+
+"""
+
+This contains a helper class, which makes it easier to use the low-level ExifTool class
+
+"""
+
+import logging
+
+from .exiftool import ExifTool
+from .exceptions import OutputEmpty, OutputNotJSON
+
+try:        # Py3k compatibility
+	basestring
+except NameError:
+	basestring = (bytes, str)
+
+
+#from pathlib import PurePath  # Python 3.4 required
+
+from typing import Any
+
+
+
+
+# ======================================================================================================================
+
+
+def _is_iterable(in_param: Any) -> bool:
+	"""
+	Checks if this item is iterable, instead of using isinstance(list), anything iterable can be ok
+
+	NOTE: STRINGS ARE CONSIDERED ITERABLE by Python
+
+	if you need to consider a code path for strings first, check that before checking if a parameter is iterable via this function
+	"""
+	# a different type of test of iterability, instead of using isinstance(list)
+	# https://stackoverflow.com/questions/1952464/in-python-how-do-i-determine-if-an-object-is-iterable
+	try:
+		iterator = iter(in_param)
+	except TypeError:
+		return False
+
+	return True
+
+
+
+# ======================================================================================================================
+
+class ExifToolHelper(ExifTool):
+	""" this class extends the low-level class with 'wrapper'/'helper' functionality
+	It keeps low-level functionality with the base class but adds helper functions on top of it
+	"""
+
+	##########################################################################################
+	#################################### OVERRIDE METHODS ####################################
+	##########################################################################################
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	def __init__(self, auto_start=True, **kwargs):
+		"""
+		auto_start = BOOLEAN.  will autostart the exiftool process on first command run
+
+		all other parameters are passed directly to super-class' constructor: ExifTool(**)
+		"""
+		# call parent's constructor
+		super().__init__(**kwargs)
+
+		self._auto_start: bool = auto_start
+
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	def execute(self, *params):
+		""" override the execute() method so that it checks if it's running first, and if not, start it """
+		if self._auto_start and not self.running:
+			self.run()
+
+		return super().execute(*params)
+
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	def run(self) -> None:
+		""" override the run() method so that if it's running, won't call super() method (so no warning about 'ExifTool already running' will trigger) """
+		if self.running:
+			return
+
+		super().run()
+
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	def terminate(self, **opts) -> None:
+		""" override the terminate() method so that if it's not running, won't call super() method (so no warning about 'ExifTool not running' will trigger)
+
+		options are passed directly to the parent verbatim
+		"""
+		if not self.running:
+			return
+
+		super().terminate(**opts)
+
+
+
+
+
+	#####################################################################################
+	#################################### NEW METHODS ####################################
+	#####################################################################################
+
+
+	# all generic helper functions will follow a convention of
+	# function(files to be worked on, ... , params=)
+
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	def get_metadata(self, files, params=None):
+		"""Return all meta-data for the given files.
+
+			This will returns a list, or None
+
+			files parameter matches :py:meth:`get_tags()`
+
+			wildcard strings are accepted as it's passed straight to exiftool
+
+		The return value will have the format described in the
+		documentation of :py:meth:`get_tags()`.
+		"""
+		return self.get_tags(files, None, params=params)
+
+
+	# ----------------------------------------------------------------------------------------------------------------------
+	def get_tags(self, files, tags, params=None):
+		"""Return only specified tags for the given files.
+
+		The first argument is the files to be worked on.  It can be:
+		* an iterable of strings/bytes
+		* string/bytes
+
+		The list is copied and any non-basestring elements are converted to str (to support PurePath and other similar objects)
+
+		Filenames are NOT checked for existence, that is left up to the caller.
+		It is passed directly to exiftool, which supports wildcards, etc.  Please refer to the exiftool documentation
+
+
+		The second argument is an iterable of tags.  The tag names may
+		include group names, as usual in the format <group>:<tag>.
+
+		If tags is None, or [], then returns all tags
+
+
+		The format of the return value is the same as for
+		:py:meth:`execute_json()`.
+		"""
+
+		final_tags = None
+		final_files = None
+
+		if tags is None:
+			# all tags
+			final_tags = []
+		elif isinstance(tags, basestring):
+			final_tags = [tags]
+		elif _is_iterable(tags):
+			final_tags = tags
+		else:
+			raise TypeError(f"{self.__class__.__name__}.get_tags: argument 'tags' must be a str/bytes or a list")
+
+		if not files:
+			# Exiftool process would return None anyways
+			raise ValueError(f"{self.__class__.__name__}.get_tags: argument 'files' cannot be empty")
+		elif isinstance(files, basestring):
+			final_files = [files]
+		elif not _is_iterable(files):
+			final_files = [str(files)]
+		else:
+			# duck-type any iterable given, and str() it
+			# this was originally to support Path() but it's now generic to support any object that str() to something useful
+
+			# Thanks @jangop for the single line contribution!
+			final_files = [x if isinstance(x, basestring) else str(x) for x in files]
+
+			# TODO: this list copy could be expensive if the input is a very huge list.  Perhaps in the future have a flag that takes the lists in verbatim without any processing?
+
+		exec_params = []
+
+		if params:
+			if isinstance(params, basestring):
+				# if params is a string, append it as is
+				exec_params.append(params)
+			elif _is_iterable(params):
+				# this is done to avoid accidentally modifying the reference object params
+				exec_params.extend(params)
+			else:
+				raise TypeError(f"{self.__class__.__name__}.get_tags: argument 'params' must be a str or a list")
+
+		# tags is always a list by this point.  It will always be iterable... don't have to check for None
+		exec_params.extend([f"-{t}" for t in final_tags])
+
+		exec_params.extend(final_files)
+
+		try:
+			ret = self.execute_json(*exec_params)
+		except OutputEmpty:
+			raise
+			#raise RuntimeError(f"{self.__class__.__name__}.get_tags: exiftool returned no data")
+		except OutputNotJSON:
+			# TODO if last_status is <> 0, raise a warning that one or more files failed?
+			raise
+
+		return ret
+
+
+
+	# ----------------------------------------------------------------------------------------------------------------------
diff --git a/mypy.ini b/mypy.ini
new file mode 100644
index 0000000..613a598
--- /dev/null
+++ b/mypy.ini
@@ -0,0 +1,2 @@
+;[mypy-json.*]
+;ignore_no_redef = True
diff --git a/scripts/README.txt b/scripts/README.txt
new file mode 100644
index 0000000..6b4204a
--- /dev/null
+++ b/scripts/README.txt
@@ -0,0 +1,7 @@
+These are standardized scripts/batch files which run tests or code reviews in the same way
+
+_requirements.txt files are what extra pip requirements are required to run these
+
+
+install with:
+python -m pip install -U -r <requirements file>
diff --git a/scripts/flake8.bat b/scripts/flake8.bat
new file mode 100644
index 0000000..ef52f98
--- /dev/null
+++ b/scripts/flake8.bat
@@ -0,0 +1,21 @@
+@echo off
+
+pushd %~dp0..
+
+echo ______________________
+echo *** PyExifTool automation ***
+echo Flake8 Script
+echo;
+echo pip's flake8 version
+python.exe -m pip show flake8 | findstr /l /c:"Version:"
+echo pip's flake8 pep8-naming plugin version
+python.exe -m pip show pep8-naming | findstr /l /c:"Version:"
+echo flake8 version
+python.exe -m flake8 --version
+echo ______________________
+
+REM python.exe -m flake8 -v --config "%~dp0flake8.ini" "exiftool" "tests"
+
+python.exe -m flake8 -v --config "%~dp0flake8.ini" --output-file "%~dp0~tmpout.txt" "exiftool" "tests"
+
+popd
diff --git a/scripts/flake8.ini b/scripts/flake8.ini
new file mode 100644
index 0000000..72d9bd6
--- /dev/null
+++ b/scripts/flake8.ini
@@ -0,0 +1,9 @@
+[flake8]
+; W191 = warning - indentation contains tabs
+
+; E501 = error - line too long (* > 79 characters)
+
+;x E302 expected 2 blank lines
+; E303 too many blank lines
+; E266 too many leading '#' for block comment
+ignore = E303,E266,E501,W191
diff --git a/scripts/flake8_requirements.txt b/scripts/flake8_requirements.txt
new file mode 100644
index 0000000..ff10806
--- /dev/null
+++ b/scripts/flake8_requirements.txt
@@ -0,0 +1,6 @@
+flake8
+# check for PEP8 Naming Conventions
+pep8-naming
+# warnings, not necessarily PEP8, but warn certain things
+# https://github.com/PyCQA/flake8-bugbear
+#flake8-bugbear
diff --git a/scripts/mypy.bat b/scripts/mypy.bat
new file mode 100644
index 0000000..63a9c70
--- /dev/null
+++ b/scripts/mypy.bat
@@ -0,0 +1,16 @@
+@echo off
+
+pushd %~dp0..
+
+echo ______________________
+echo *** PyExifTool automation ***
+echo MyPy Static Analysis Script
+echo;
+echo pip's MyPy version
+python.exe -m pip show mypy | findstr /l /c:"Version:"
+echo ______________________
+
+python.exe -m mypy --config-file mypy.ini --strict exiftool/
+
+
+popd
diff --git a/scripts/mypy_reqiuirements.txt b/scripts/mypy_reqiuirements.txt
new file mode 100644
index 0000000..f0aa93a
--- /dev/null
+++ b/scripts/mypy_reqiuirements.txt
@@ -0,0 +1,1 @@
+mypy
diff --git a/scripts/windows.coveragerc b/scripts/windows.coveragerc
new file mode 100644
index 0000000..5d8b588
--- /dev/null
+++ b/scripts/windows.coveragerc
@@ -0,0 +1,9 @@
+; some exclusions so it doesn't print stuff for Linux, since you can't get that coverage on windows, ever
+; the list of regex is hardcoded to skip things for coverage report
+[report]
+exclude_lines =
+	pragma: no cover
+	if constants.PLATFORM_LINUX
+	def set_pdeathsig
+	DEFAULT_EXECUTABLE = "exiftool"
+	pytest-cov:windows: no cover
diff --git a/setup.cfg b/setup.cfg
index b46d8e7..3459e35 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,2 +1,5 @@
+[metadata]
+version = attr: exiftool.__version__
+
 [bdist_rpm]
 requires = exiftool
diff --git a/setup.py b/setup.py
index d24c53d..a7692ed 100644
--- a/setup.py
+++ b/setup.py
@@ -27,57 +27,64 @@
 setup(
 	# detailed list of options:
 	# https://packaging.python.org/guides/distributing-packages-using-setuptools/
-	
+
 	# overview
 	name="PyExifTool",
-	version="0.4.13",
+	# version is configured in setup.cfg - https://packaging.python.org/en/latest/guides/single-sourcing-package-version/
+	#version=,
 	license="GPLv3+/BSD",
 	url="http://github.com/sylikc/pyexiftool",
-	python_requires=">=2.6",
-	
+	python_requires=">=3.6",
+
 	# authors
 	author="Sven Marnach, Kevin M (sylikc), various contributors",
 	author_email="sylikc@gmail.com",
-	
+
 	# info
 	description="Python wrapper for exiftool",
 	long_description=long_desc,
 	long_description_content_type="text/x-rst",
 	keywords="exiftool image exif metadata photo video photography",
-	
+
 	project_urls={
 		"Documentation": "https://sylikc.github.io/pyexiftool/",
 		"Tracker": "https://github.com/sylikc/pyexiftool/issues",
 		"Source": "https://github.com/sylikc/pyexiftool",
 	},
-	
-	
+
+
 	classifiers=[
 		# list is here:
 		# https://pypi.org/classifiers/
-		
+
 		"Development Status :: 3 - Alpha",
-		
+
 		"Intended Audience :: Developers",
-		
+
 		"License :: OSI Approved :: BSD License",
 		"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
-		
+
 		"Operating System :: OS Independent",
-		
-		"Programming Language :: Python :: 2.6",
-		"Programming Language :: Python :: 2.7",
+
 		"Programming Language :: Python :: 3",
-		
+		"Programming Language :: Python :: 3.6",
+
 		"Topic :: Multimedia",
 		"Topic :: Utilities",
 	],
-	
-	
+
+
 	packages=find_packages(
 		where=".",
 		exclude = ['test*',]
 	),
-	
+
+	extras_require={
+			"test": ["packaging"],  # dependencies to do tests
+			"docs": ["sphinx", "sphinx-autoapi", "sphinx-rtd-theme", "sphinx-autodoc-typehints"],  # dependencies to build docs
+		},
+
+	#package_dir={'exiftool': 'exiftool'},
+
 	#py_modules=["exiftool"], - it is now the exiftool module
 )


Test patch:
diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml
index 97d2698..1a8a9a6 100644
--- a/.github/workflows/lint-and-test.yml
+++ b/.github/workflows/lint-and-test.yml
@@ -9,7 +9,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        python-version: [3.7, 3.8, 3.9]
+        python-version: [3.6, 3.7, 3.8, 3.9]
 
     steps:
     - uses: actions/checkout@v2
@@ -22,7 +22,28 @@ jobs:
         python -m pip install --upgrade pip
         python -m pip install flake8 pytest
         if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
+
+        # latest version not yet available on Ubuntu Focal 20.04 LTS, but it's better to install it with all dependencies first
         sudo apt-get install -qq libimage-exiftool-perl
+        exiftool -ver
+
+        # get just the minimum version to build and compile, later we can go with latest version to test
+        export EXIFTOOL_VER=12.15
+        wget http://backpan.perl.org/authors/id/E/EX/EXIFTOOL/Image-ExifTool-$EXIFTOOL_VER.tar.gz
+        tar xf Image-ExifTool-$EXIFTOOL_VER.tar.gz
+        cd Image-ExifTool-$EXIFTOOL_VER/
+
+        # https://exiftool.org/install.html#Unix
+        perl Makefile.PL
+        make test
+
+        export PATH=`pwd`:$PATH
+        cd ..
+        exiftool -ver
+
+        # save this environment for subsequent steps
+        # https://brandur.org/fragments/github-actions-env-vars-in-env-vars
+        echo "PATH=`pwd`:$PATH" >> $GITHUB_ENV
     - name: Install pyexiftool
       run: |
         python -m pip install .
diff --git a/scripts/pytest.bat b/scripts/pytest.bat
new file mode 100644
index 0000000..9c85ff9
--- /dev/null
+++ b/scripts/pytest.bat
@@ -0,0 +1,19 @@
+@echo off
+
+pushd %~dp0..
+
+echo ______________________
+echo *** PyExifTool automation ***
+echo PyTest Coverage Script
+echo;
+echo pip's PyTest version
+python.exe -m pip show pytest | findstr /l /c:"Version:"
+echo pip's PyTest-cov version
+python.exe -m pip show pytest-cov | findstr /l /c:"Version:"
+echo ______________________
+
+REM added the --cov= so that it doesn't try to test coverage on the virtualenv directory
+REM add -s to print out stuff from pytest class (don't capture output) -- https://docs.pytest.org/en/latest/how-to/capture-stdout-stderr.html#setting-capturing-methods-or-disabling-capturing
+python.exe -m pytest -v --cov-config=%~dp0windows.coveragerc --cov=exiftool --cov-report term-missing tests/
+
+popd
diff --git a/scripts/pytest_requirements.txt b/scripts/pytest_requirements.txt
new file mode 100644
index 0000000..8986bfe
--- /dev/null
+++ b/scripts/pytest_requirements.txt
@@ -0,0 +1,4 @@
+# pytest can run unittest scripts
+pytest
+# coverage addon
+pytest-cov
diff --git a/scripts/unittest.bat b/scripts/unittest.bat
new file mode 100644
index 0000000..1fe7b86
--- /dev/null
+++ b/scripts/unittest.bat
@@ -0,0 +1,12 @@
+@echo off
+
+pushd %~dp0..
+
+echo ______________________
+echo *** PyExifTool automation ***
+echo Python Built-in Unittest Script
+echo ______________________
+
+python.exe -m unittest -v
+
+popd
diff --git a/tests/test_alpha.py b/tests/test_alpha.py
new file mode 100644
index 0000000..5bd3022
--- /dev/null
+++ b/tests/test_alpha.py
@@ -0,0 +1,241 @@
+# -*- coding: utf-8 -*-
+
+import shutil
+import tempfile
+import unittest
+from pathlib import Path
+
+from packaging import version
+
+import exiftool
+
+SCRIPT_PATH = Path(__file__).resolve().parent
+PERSISTENT_TMP_DIR = False  # if set to true, will not delete temp dir on exit (useful for debugging output)
+
+
+class TestTagCopying(unittest.TestCase):
+    """
+    We duplicate an image with metadata, erase all metadata in the copy, and then copy the tags.
+    """
+
+    def setUp(self):
+        # Prepare exiftool
+        self.exiftool = exiftool.ExifToolAlpha(encoding="UTF-8")
+        self.exiftool.run()
+
+        # Prepare temporary directory for copy.
+        kwargs = {"prefix": "exiftool-tmp-", "dir": SCRIPT_PATH}
+        # mkdtemp requires cleanup or else it remains on the system
+        if PERSISTENT_TMP_DIR:
+            self.temp_obj = None
+            self.tmp_dir = Path(tempfile.mkdtemp(**kwargs))
+        else:
+            # have to save the object or else garbage collection cleans it up and dir gets deleted
+            # https://simpleit.rocks/python/test-files-creating-a-temporal-directory-in-python-unittests/
+            self.temp_obj = tempfile.TemporaryDirectory(**kwargs)
+            self.tmp_dir = Path(self.temp_obj.name)
+
+        # Find example image.
+        self.tag_source = SCRIPT_PATH / "rose.jpg"
+
+        # Prepare path of copy.
+        self.tag_target = self.tmp_dir / "rose-tagcopy.jpg"
+
+        # Copy image.
+        shutil.copyfile(self.tag_source, self.tag_target)
+
+        # Clear tags in copy.
+        params = ["-overwrite_original", "-all=", str(self.tag_target)]
+        self.exiftool.execute(*params)
+
+    def tearDown(self):
+        self.exiftool.terminate()  # Apparently, Windows needs this.  CPython bug
+
+    def test_tag_copying(self):
+        tag = "XMP:Subject"
+        expected_value = "Röschen"
+
+        # Ensure source image has correct tag.
+        original_value = self.exiftool.get_tag(self.tag_source, tag)
+        self.assertEqual(original_value, expected_value)
+
+        # Ensure target image does not already have that tag.
+        value_before_copying = self.exiftool.get_tag(self.tag_target, tag)
+        self.assertNotEqual(value_before_copying, expected_value)
+
+        # Copy tags.
+        self.exiftool.copy_tags(self.tag_source, self.tag_target)
+
+        value_after_copying = self.exiftool.get_tag(self.tag_target, tag)
+        self.assertEqual(value_after_copying, expected_value)
+
+
+class TestExifToolAlpha(unittest.TestCase):
+    def setUp(self):
+        self.et = exiftool.ExifToolAlpha(
+            common_args=["-G", "-n", "-overwrite_original"], encoding="UTF-8"
+        )
+
+        # Prepare temporary directory for copy.
+        kwargs = {"prefix": "exiftool-tmp-", "dir": SCRIPT_PATH}
+        if PERSISTENT_TMP_DIR:
+            self.temp_obj = None
+            self.tmp_dir = Path(tempfile.mkdtemp(**kwargs))
+        else:
+            self.temp_obj = tempfile.TemporaryDirectory(**kwargs)
+            self.tmp_dir = Path(self.temp_obj.name)
+
+    def tearDown(self):
+        if hasattr(self, "et"):
+            if self.et.running:
+                self.et.terminate()
+        if hasattr(self, "process"):
+            if self.process.poll() is None:
+                self.process.terminate()
+
+    def test_get_metadata(self):
+        expected_data = [
+            {
+                "SourceFile": Path("rose.jpg"),
+                "File:FileType": "JPEG",
+                "File:ImageWidth": 70,
+                "File:ImageHeight": 46,
+                "XMP:Subject": "Röschen",
+                "Composite:ImageSize": "70 46",
+            },
+            {
+                "SourceFile": Path("skyblue.png"),
+                "File:FileType": "PNG",
+                "PNG:ImageWidth": 64,
+                "PNG:ImageHeight": 64,
+                "Composite:ImageSize": "64 64",
+            },
+        ]
+        source_files = []
+
+        for d in expected_data:
+            path = SCRIPT_PATH / d["SourceFile"]
+            d["SourceFile"] = path
+            self.assertTrue(path.exists())
+            source_files.append(path)
+
+        with self.et:
+            actual_data = self.et.get_metadata(source_files)
+            tags0 = self.et.get_tags(source_files[0], ["XMP:Subject"])[0]
+            tag0 = self.et.get_tag(source_files[0], "XMP:Subject")
+
+        required_version = version.parse("8.40")
+        for expected, actual in zip(expected_data, actual_data):
+            actual_version = version.parse(str(actual["ExifTool:ExifToolVersion"]))
+            self.assertGreaterEqual(actual_version, required_version)
+            actual["SourceFile"] = Path(actual["SourceFile"]).resolve()
+            for k, v in expected.items():
+                self.assertEqual(actual[k], v)
+
+        tags0["SourceFile"] = Path(tags0["SourceFile"]).resolve()
+        self.assertEqual(
+            tags0, dict((k, expected_data[0][k]) for k in ["SourceFile", "XMP:Subject"])
+        )
+        self.assertEqual(tag0, "Röschen")
+
+    def test_set_metadata(self):
+        mod_prefix = "newcap_"
+        expected_data = [
+            {
+                "SourceFile": Path("rose.jpg"),
+                "Caption-Abstract": "Ein Röschen ganz allein",
+            },
+            {"SourceFile": Path("skyblue.png"), "Caption-Abstract": "Blauer Himmel"},
+        ]
+        source_files = []
+
+        for d in expected_data:
+            d["SourceFile"] = f = SCRIPT_PATH / d["SourceFile"]
+            self.assertTrue(f.exists())
+
+            f_mod = self.tmp_dir / (mod_prefix + f.name)
+            f_mod_str = str(f_mod)
+
+            self.assertFalse(
+                f_mod.exists(),
+                f"{f_mod} should not exist before the test. Please delete.",
+            )
+            shutil.copyfile(f, f_mod)
+            source_files.append(f_mod)
+            with self.et:
+                self.et.set_tags(f_mod_str, {"Caption-Abstract": d["Caption-Abstract"]})
+                tag0 = self.et.get_tag(f_mod_str, "IPTC:Caption-Abstract")
+            f_mod.unlink()
+            self.assertEqual(tag0, d["Caption-Abstract"])
+
+    def test_set_keywords(self):
+        kw_to_add = ["added"]
+        mod_prefix = "newkw_"
+        expected_data = [
+            {"SourceFile": Path("rose.jpg"), "Keywords": ["nature", "red plant"]}
+        ]
+        source_files = []
+
+        for d in expected_data:
+            d["SourceFile"] = f = SCRIPT_PATH / d["SourceFile"]
+            self.assertTrue(f.exists())
+            f_mod = self.tmp_dir / (mod_prefix + f.name)
+            f_mod_str = str(f_mod)
+            self.assertFalse(
+                f_mod.exists(),
+                "%s should not exist before the test. Please delete." % f_mod,
+            )
+
+            shutil.copyfile(f, f_mod)
+            source_files.append(f_mod)
+            with self.et:
+                self.et.set_keywords(
+                    f_mod_str, exiftool.experimental.KW_REPLACE, d["Keywords"]
+                )
+                kwtag0 = self.et.get_tag(f_mod_str, "IPTC:Keywords")
+                kwrest = d["Keywords"][1:]
+                self.et.set_keywords(f_mod_str, exiftool.experimental.KW_REMOVE, kwrest)
+                kwtag1 = self.et.get_tag(f_mod_str, "IPTC:Keywords")
+                self.et.set_keywords(f_mod_str, exiftool.experimental.KW_ADD, kw_to_add)
+                kwtag2 = self.et.get_tag(f_mod_str, "IPTC:Keywords")
+            f_mod.unlink()
+            self.assertEqual(kwtag0, d["Keywords"])
+            self.assertEqual(kwtag1, d["Keywords"][0])
+            self.assertEqual(kwtag2, [d["Keywords"][0]] + kw_to_add)
+
+
+    """
+    # TODO: write a test that covers keywords in set_tags_batch() and not using the keywords functionality directly
+    def test_set_list_keywords(self):
+        mod_prefix = "newkw_"
+        expected_data = [{"SourceFile": "rose.jpg",
+                          "Keywords": ["nature", "red plant"]}]
+        source_files = []
+
+        for d in expected_data:
+            d["SourceFile"] = f = SCRIPT_PATH / d["SourceFile"]
+            self.assertTrue(f.exists())
+            f_mod = self.tmp_dir / (mod_prefix + f.name)
+            f_mod_str = str(f_mod)
+            self.assertFalse(f_mod.exists(), "%s should not exist before the test. Please delete." % f_mod)
+
+            shutil.copyfile(f, f_mod)
+            source_files.append(f_mod)
+
+            with self.et:
+                self.et.set_keywords(exiftool.helper.KW_REPLACE, d["Keywords"], f_mod_str)
+                kwtag0 = self.et.get_tag("IPTC:Keywords", f_mod_str)
+                kwrest = d["Keywords"][1:]
+                self.et.set_keywords(exiftool.helper.KW_REMOVE, kwrest, f_mod_str)
+                kwtag1 = self.et.get_tag("IPTC:Keywords", f_mod_str)
+                self.et.set_keywords(exiftool.helper.KW_ADD, kw_to_add, f_mod_str)
+                kwtag2 = self.et.get_tag("IPTC:Keywords", f_mod_str)
+            f_mod.unlink()
+            self.assertEqual(kwtag0, d["Keywords"])
+            self.assertEqual(kwtag1, d["Keywords"][0])
+            self.assertEqual(kwtag2, [d["Keywords"][0]] + kw_to_add)
+    """
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/test_exiftool.py b/tests/test_exiftool.py
index 8761de9..db736d5 100644
--- a/tests/test_exiftool.py
+++ b/tests/test_exiftool.py
@@ -3,217 +3,323 @@
 from __future__ import unicode_literals
 
 import unittest
+import tempfile
 import exiftool
+from exiftool.exceptions import ExifToolRunning, ExifToolNotRunning
 import warnings
-import os
-import shutil
+
+import logging  # to test logger
+#import os
+#import shutil
 import sys
-import tempfile
+from pathlib import Path
+
+
+PLATFORM_WINDOWS: bool = (sys.platform == 'win32')
+
+
+SCRIPT_PATH = Path(__file__).resolve().parent
+PERSISTENT_TMP_DIR = False  # if set to true, will not delete temp dir on exit (useful for debugging output)
 
 
-class TestTagCopying(unittest.TestCase):
+class TestExifTool(unittest.TestCase):
+
+	# ---------------------------------------------------------------------------------------------------------
 	def setUp(self):
-		# Prepare exiftool.
-		self.exiftool = exiftool.ExifTool()
-		self.exiftool.start()
+		self.et = exiftool.ExifTool(common_args=["-G", "-n", "-overwrite_original"])
 
-		# Prepare temporary directory for copy.
-		directory = tempfile.mkdtemp(prefix='exiftool-test-')
+	def tearDown(self):
+		if hasattr(self, "et"):
+			if self.et.running:
+				self.et.terminate()
+		if hasattr(self, "process"):
+			if self.process.poll() is None:
+				self.process.terminate()
+	# ---------------------------------------------------------------------------------------------------------
+	def test_running_attribute(self):
+		# test if we can read "running" but can't set it
+		self.assertFalse(self.et.running)
+		with self.assertRaises(AttributeError):
+			self.et.running = True
+	# ---------------------------------------------------------------------------------------------------------
+	def test_executable_attribute(self):
+		# test if we can read "running" but can't set it
+		self.assertFalse(self.et.running)
+		self.et.run()
+		self.assertTrue(self.et.running)
 
-		# Find example image.
-		this_path = os.path.dirname(__file__)
-		self.tag_source = os.path.join(this_path, 'rose.jpg')
+		# if it's running, the executable has to exist (test coverage on reading the property)
+		e = self.et.executable
+		self.assertTrue(Path(e).exists())
 
-		# Prepare path of copy.
-		self.tag_target = os.path.join(directory, 'copy.jpg')
+		with self.assertRaises(ExifToolRunning):
+			self.et.executable = "foo.bar"
+		self.et.terminate()
 
-		# Copy image.
-		shutil.copyfile(self.tag_source, self.tag_target)
+		with self.assertRaises(FileNotFoundError):
+			self.et.executable = "foo.bar"
 
-		# Clear tags in copy.
-		params = ['-overwrite_original', '-all=', self.tag_target]
-		params_utf8 = [x.encode('utf-8') for x in params]
-		self.exiftool.execute(*params_utf8)
+		# specify the executable explicitly with the one known to exist (test coverage)
+		self.et.executable = e
+		self.assertEqual(self.et.executable, e)  # absolute path set should not change
 
-	def test_tag_copying(self):
-		tag = 'XMP:Subject'
-		expected_value = 'Röschen'
+		self.assertFalse(self.et.running)
 
-		# Ensure source image has correct tag.
-		original_value = self.exiftool.get_tag(tag, self.tag_source)
-		self.assertEqual(original_value, expected_value)
+	# ---------------------------------------------------------------------------------------------------------
+	def test_blocksize_attribute(self):
+		current = self.et.block_size
 
-		# Ensure target image does not already have that tag.
-		value_before_copying = self.exiftool.get_tag(tag, self.tag_target)
-		self.assertNotEqual(value_before_copying, expected_value)
+		# arbitrary
+		self.et.block_size = 4
+		self.assertEqual(self.et.block_size, 4)
 
-		# Copy tags.
-		self.exiftool.copy_tags(self.tag_source, self.tag_target)
+		with self.assertRaises(ValueError):
+			self.et.block_size = -1
 
-		value_after_copying = self.exiftool.get_tag(tag, self.tag_target)
-		self.assertEqual(value_after_copying, expected_value)
+		with self.assertRaises(ValueError):
+			self.et.block_size = 0
 
+		# restore
+		self.et.block_size = current
 
-class TestExifTool(unittest.TestCase):
+	# ---------------------------------------------------------------------------------------------------------
+	def test_encoding_attribute(self):
+		current = self.et.encoding
 
-	#---------------------------------------------------------------------------------------------------------
-	def setUp(self):
-		self.et = exiftool.ExifTool(common_args=["-G", "-n", "-overwrite_original"])
-	def tearDown(self):
-		if hasattr(self, "et"):
-			self.et.terminate()
-		if hasattr(self, "process"):
-			if self.process.poll() is None:
-				self.process.terminate()
-	#---------------------------------------------------------------------------------------------------------
+		self.et.run()
+
+		# cannot set when running
+		with self.assertRaises(ExifToolRunning):
+			self.et.encoding = "foo.bar"
+		self.et.terminate()
+
+		self.et.encoding = "foo"
+		self.assertEqual(self.et.encoding, "foo")
+
+		# restore
+		self.et.encoding = current
+
+
+
+	# ---------------------------------------------------------------------------------------------------------
+	def test_common_args_attribute(self):
+
+		self.et.run()
+		with self.assertRaises(ExifToolRunning):
+			self.et.common_args = []
+
+
+	# ---------------------------------------------------------------------------------------------------------
+	def test_get_version_protected(self):
+		""" test the protected method which can't be called when exiftool not running """
+		self.assertFalse(self.et.running)
+		self.assertRaises(ExifToolNotRunning, self.et._parse_ver)
+
+	# ---------------------------------------------------------------------------------------------------------
+	def test_version_attribute(self):
+		self.et.run()
+		# no error
+		a = self.et.version
+
+		self.et.terminate()
+
+		# version is invalid when not running
+		with self.assertRaises(ExifToolNotRunning):
+			a = self.et.version
+
+	# ---------------------------------------------------------------------------------------------------------
 	def test_termination_cm(self):
 		# Test correct subprocess start and termination when using
 		# self.et as a context manager
 		self.assertFalse(self.et.running)
-		self.assertRaises(ValueError, self.et.execute)
+		self.assertRaises(ExifToolNotRunning, self.et.execute)
 		with self.et:
 			self.assertTrue(self.et.running)
 			with warnings.catch_warnings(record=True) as w:
-				self.et.start()
-				self.assertEquals(len(w), 1)
+				self.et.run()
+				self.assertEqual(len(w), 1)
 				self.assertTrue(issubclass(w[0].category, UserWarning))
 			self.process = self.et._process
 			self.assertEqual(self.process.poll(), None)
 		self.assertFalse(self.et.running)
 		self.assertNotEqual(self.process.poll(), None)
-	#---------------------------------------------------------------------------------------------------------
+	# ---------------------------------------------------------------------------------------------------------
 	def test_termination_explicit(self):
 		# Test correct subprocess start and termination when
 		# explicitly using start() and terminate()
-		self.et.start()
+		self.et.run()
 		self.process = self.et._process
 		self.assertEqual(self.process.poll(), None)
 		self.et.terminate()
 		self.assertNotEqual(self.process.poll(), None)
-	#---------------------------------------------------------------------------------------------------------
+
+		# terminate when not running
+		with warnings.catch_warnings(record=True) as w:
+			self.et.terminate()
+			self.assertEqual(len(w), 1)
+			self.assertTrue(issubclass(w[0].category, UserWarning))
+
+	# ---------------------------------------------------------------------------------------------------------
 	def test_termination_implicit(self):
 		# Test implicit process termination on garbage collection
-		self.et.start()
+
+		# QUICKFIX: take out the method that is called on load (see test_process_died_running_status())
+		if PLATFORM_WINDOWS:
+			self.et._parse_ver = lambda: None
+
+		self.et.run()
 		self.process = self.et._process
+		# TODO freze here on windows for same reason as in test_process_died_running_status() as a zombie process remains
 		del self.et
 		self.assertNotEqual(self.process.poll(), None)
-	#---------------------------------------------------------------------------------------------------------
+	# ---------------------------------------------------------------------------------------------------------
+	def test_process_died_running_status(self):
+		""" Test correct .running status if process dies by itself """
+
+		# There is a very weird bug triggered on WINDOWS only which I've described here: https://exiftool.org/forum/index.php?topic=12472.0
+		# it happens specifically when you forcefully kill the process, but at least one command has run since launching, the exiftool wrapper on windows does not terminate the child process
+		# it's a very strange interaction and causes a zombie process to remain, and python hangs
+		#
+		# either kill the tree with psutil, or do it this way...
+
+		# QUICKFIX: take out the method that is called on load (probably not the way to do this well... you can take out this line and watch Python interpreter hang at .kill() below
+		if PLATFORM_WINDOWS:
+			self.et._parse_ver = lambda: None
+
+
+		self.et.run()
+		self.process = self.et._process
+		self.assertTrue(self.et.running)
+
+		# kill the process, out of ExifTool's control
+		self.process.kill()
+		# TODO freeze here on windows if there is a zombie process b/c killing immediate exiftool does not kill the spawned subprocess
+		outs, errs = self.process.communicate()
+
+		with warnings.catch_warnings(record=True) as w:
+			self.assertFalse(self.et.running)
+			self.assertEqual(len(w), 1)
+			self.assertTrue(issubclass(w[0].category, UserWarning))
+
+		# after removing that function, delete the object so it gets recreated cleanly
+		del self.et
+	# ---------------------------------------------------------------------------------------------------------
 	def test_invalid_args_list(self):
 		# test to make sure passing in an invalid args list will cause it to error out
 		with self.assertRaises(TypeError):
 			exiftool.ExifTool(common_args="not a list")
-	#---------------------------------------------------------------------------------------------------------
-	def test_get_metadata(self):
-		expected_data = [{"SourceFile": "rose.jpg",
-						  "File:FileType": "JPEG",
-						  "File:ImageWidth": 70,
-						  "File:ImageHeight": 46,
-						  "XMP:Subject": "Röschen",
-						  "Composite:ImageSize": "70 46"}, # older versions of exiftool used to display 70x46
-						 {"SourceFile": "skyblue.png",
-						  "File:FileType": "PNG",
-						  "PNG:ImageWidth": 64,
-						  "PNG:ImageHeight": 64,
-						  "Composite:ImageSize": "64 64"}] # older versions of exiftool used to display 64x64
-		script_path = os.path.dirname(__file__)
-		source_files = []
-		for d in expected_data:
-			d["SourceFile"] = f = os.path.join(script_path, d["SourceFile"])
-			self.assertTrue(os.path.exists(f))
-			source_files.append(f)
-		with self.et:
-			actual_data = self.et.get_metadata_batch(source_files)
-			tags0 = self.et.get_tags(["XMP:Subject"], source_files[0])
-			tag0 = self.et.get_tag("XMP:Subject", source_files[0])
-		for expected, actual in zip(expected_data, actual_data):
-			et_version = actual["ExifTool:ExifToolVersion"]
-			self.assertTrue(isinstance(et_version, float))
-			if isinstance(et_version, float):    # avoid exception in Py3k
-				self.assertTrue(
-					et_version >= 8.40,
-					"you should at least use ExifTool version 8.40")
-			actual["SourceFile"] = os.path.normpath(actual["SourceFile"])
-			for k, v in expected.items():
-				self.assertEqual(actual[k], v)
-		tags0["SourceFile"] = os.path.normpath(tags0["SourceFile"])
-		self.assertEqual(tags0, dict((k, expected_data[0][k])
-									 for k in ["SourceFile", "XMP:Subject"]))
-		self.assertEqual(tag0, "Röschen")
-
-	#---------------------------------------------------------------------------------------------------------
-	def test_set_metadata(self):
-		mod_prefix = "newcap_"
-		expected_data = [{"SourceFile": "rose.jpg",
-						  "Caption-Abstract": "Ein Röschen ganz allein"},
-						 {"SourceFile": "skyblue.png",
-						  "Caption-Abstract": "Blauer Himmel"}]
-		script_path = os.path.dirname(__file__)
-		source_files = []
-		for d in expected_data:
-			d["SourceFile"] = f = os.path.join(script_path, d["SourceFile"])
-			self.assertTrue(os.path.exists(f))
-			f_mod = os.path.join(os.path.dirname(f), mod_prefix + os.path.basename(f))
-			self.assertFalse(os.path.exists(f_mod), "%s should not exist before the test. Please delete." % f_mod)
-			shutil.copyfile(f, f_mod)
-			source_files.append(f_mod)
-			with self.et:
-				self.et.set_tags({"Caption-Abstract":d["Caption-Abstract"]}, f_mod)
-				tag0 = self.et.get_tag("IPTC:Caption-Abstract", f_mod)
-			os.remove(f_mod)
-			self.assertEqual(tag0, d["Caption-Abstract"])
-
-	#---------------------------------------------------------------------------------------------------------
-	def test_set_keywords(self):
-		kw_to_add = ["added"]
-		mod_prefix = "newkw_"
-		expected_data = [{"SourceFile": "rose.jpg",
-						  "Keywords": ["nature", "red plant"]}]
-		script_path = os.path.dirname(__file__)
-		source_files = []
-		for d in expected_data:
-			d["SourceFile"] = f = os.path.join(script_path, d["SourceFile"])
-			self.assertTrue(os.path.exists(f))
-			f_mod = os.path.join(os.path.dirname(f), mod_prefix + os.path.basename(f))
-			self.assertFalse(os.path.exists(f_mod), "%s should not exist before the test. Please delete." % f_mod)
-			shutil.copyfile(f, f_mod)
-			source_files.append(f_mod)
-			with self.et:
-				self.et.set_keywords(exiftool.KW_REPLACE, d["Keywords"], f_mod)
-				kwtag0 = self.et.get_tag("IPTC:Keywords", f_mod)
-				kwrest = d["Keywords"][1:]
-				self.et.set_keywords(exiftool.KW_REMOVE, kwrest, f_mod)
-				kwtag1 = self.et.get_tag("IPTC:Keywords", f_mod)
-				self.et.set_keywords(exiftool.KW_ADD, kw_to_add, f_mod)
-				kwtag2 = self.et.get_tag("IPTC:Keywords", f_mod)
-			os.remove(f_mod)
-			self.assertEqual(kwtag0, d["Keywords"])
-			self.assertEqual(kwtag1, d["Keywords"][0])
-			self.assertEqual(kwtag2, [d["Keywords"][0]] + kw_to_add)
-
-
-	#---------------------------------------------------------------------------------------------------------
-	def test_executable_found(self):
-		# test if executable is found on path
-		save_sys_path = os.environ['PATH']
-
-		if sys.platform == 'win32':
-			test_path = "C:\\"
+	# ---------------------------------------------------------------------------------------------------------
+	def test_common_args(self):
+		# test to make sure passing in an invalid args list will cause it to error out
+		with self.assertRaises(TypeError):
+			exiftool.ExifTool(common_args={})
+
+		# set to common_args=None == []
+		self.assertEqual(exiftool.ExifTool(common_args=None).common_args, [])
+	# ---------------------------------------------------------------------------------------------------------
+	def test_logger(self):
+		""" TODO improve this test, currently very rudimentary """
+		log = logging.getLogger("log_test")
+		#log.level = logging.WARNING
+
+		#logpath = TMP_DIR / 'exiftool_test.log'
+		#fh = logging.FileHandler(logpath)
+
+		#log.addHandler(fh)
+
+		self.et.logger = log
+		# no errors
+
+		log = "bad log" # not a logger object
+		with self.assertRaises(TypeError):
+			self.et.logger = log
+
+		self.et.run()  # get some coverage by doing stuff
+
+	# ---------------------------------------------------------------------------------------------------------
+	def test_run_twice(self):
+		""" test that a UserWarning is thrown when run() is called twice """
+		self.assertFalse(self.et.running)
+		self.et.run()
+
+		with warnings.catch_warnings(record=True) as w:
+			self.assertTrue(self.et.running)
+			self.et.run()
+			self.assertEqual(len(w), 1)
+			self.assertTrue(issubclass(w[0].category, UserWarning))
+
+
+	# ---------------------------------------------------------------------------------------------------------
+
+
+
+
+class TestExifToolConfigFile(unittest.TestCase):
+
+	# ---------------------------------------------------------------------------------------------------------
+	def setUp(self):
+		self.et = exiftool.ExifTool(common_args=["-G", "-n", "-overwrite_original"])
+
+		# Prepare temporary directory for copy.
+		kwargs = {"prefix": "exiftool-tmp-", "dir": SCRIPT_PATH}
+		# mkdtemp requires cleanup or else it remains on the system
+		if PERSISTENT_TMP_DIR:
+			self.temp_obj = None
+			self.tmp_dir = Path(tempfile.mkdtemp(**kwargs))
 		else:
-			test_path = "/"
-
-		test_exec = exiftool.DEFAULT_EXECUTABLE
-		
-		# should be found in path as is
-		self.assertTrue(exiftool.find_executable(test_exec, path=None))
-		
-		# modify path and search again
-		self.assertFalse(exiftool.find_executable(test_exec, path=test_path))
-		os.environ['PATH'] = test_path
-		self.assertFalse(exiftool.find_executable(test_exec, path=None))
-		
-		# restore it
-		os.environ['PATH'] = save_sys_path
-
-#---------------------------------------------------------------------------------------------------------
+			# have to save the object or else garbage collection cleans it up and dir gets deleted
+			# https://simpleit.rocks/python/test-files-creating-a-temporal-directory-in-python-unittests/
+			self.temp_obj = tempfile.TemporaryDirectory(**kwargs)
+			self.tmp_dir = Path(self.temp_obj.name)
+
+	def tearDown(self):
+		if self.et.running:
+			self.et.terminate()
+
+	# ---------------------------------------------------------------------------------------------------------
+
+	def test_configfile_attribute(self):
+		current = self.et.config_file
+
+		with self.assertRaises(FileNotFoundError):
+			self.et.config_file = "lkasjdflkjasfd"
+
+		# see if Python 3.9.5 fixed this ... raises OSError right now and is a pathlib glitch https://bugs.python.org/issue35306
+		#self.et.config_file = "\"C:\\\"\"C:\\"
+
+		# then restore current config_file
+		self.et.config_file = current
+
+		self.assertFalse(self.et.running)
+		self.et.run()
+		self.assertTrue(self.et.running)
+
+		with self.assertRaises(ExifToolRunning):
+			self.et.config_file = None
+
+		self.et.terminate()
+
+	# ---------------------------------------------------------------------------------------------------------
+	def test_configfile_set(self):
+		# set config file to empty, which is valid (should not throw error)
+		self.et.config_file = ""
+
+		# create a config file, and set it and test that it works
+		# a file that returns 1 is valid as a config file
+		tmp_config_file = self.tmp_dir / "config_test.txt"
+		with open(tmp_config_file, 'w') as f:
+			f.write("1;\n")
+
+		self.et.config_file = tmp_config_file
+
+		self.et.run()
+		self.assertTrue(self.et.running)
+
+		self.et.terminate()
+
+
+
+
+# ---------------------------------------------------------------------------------------------------------
 if __name__ == '__main__':
 	unittest.main()
diff --git a/tests/test_helper.py b/tests/test_helper.py
new file mode 100644
index 0000000..4183756
--- /dev/null
+++ b/tests/test_helper.py
@@ -0,0 +1,154 @@
+# -*- coding: utf-8 -*-
+
+import unittest
+import exiftool
+from exiftool.exceptions import ExifToolNotRunning, OutputEmpty, OutputNotJSON
+import shutil
+import tempfile
+from pathlib import Path
+
+SCRIPT_PATH = Path(__file__).resolve().parent
+PERSISTENT_TMP_DIR = False  # if set to true, will not delete temp dir on exit (useful for debugging output)
+
+# Find example image.
+EXAMPLE_FILE = SCRIPT_PATH / "rose.jpg"
+
+
+
+class InitializationTest(unittest.TestCase):
+	def test_initialization(self):
+		"""
+		Initialization with all arguments at their default values.
+		"""
+		exif_tool_helper = exiftool.ExifToolHelper()
+		exif_tool_helper.run()
+
+
+class ReadingTest(unittest.TestCase):
+	@classmethod
+	def setUpClass(cls) -> None:
+		cls.exif_tool_helper = exiftool.ExifToolHelper(common_args=['-G', '-n', '-overwrite_original'])
+		cls.exif_tool_helper.run()
+
+
+		# Prepare temporary directory.
+		kwargs = {"prefix": "exiftool-tmp-", "dir": SCRIPT_PATH}
+		# mkdtemp requires cleanup or else it remains on the system
+		if PERSISTENT_TMP_DIR:
+			cls.temp_obj = None
+			cls.tmp_dir = Path(tempfile.mkdtemp(**kwargs))
+		else:
+			# have to save the object or else garbage collection cleans it up and dir gets deleted
+			cls.temp_obj = tempfile.TemporaryDirectory(**kwargs)
+			cls.tmp_dir = Path(cls.temp_obj.name)
+
+
+	def test_read_all_from_nonexistent_file(self):
+		"""
+		`get_metadata`/`get_tags` raises an error if None comes back from execute_json()
+
+		ExifToolHelper DOES NOT check each individual file in the list for existence.  If you pass invalid files to exiftool, undefined behavior can occur
+		"""
+		with self.assertRaises(OutputEmpty):
+			self.exif_tool_helper.get_metadata(['foo.bar'])
+
+		with self.assertRaises(OutputEmpty):
+			self.exif_tool_helper.get_tags('foo.bar', 'DateTimeOriginal')
+
+	def test_w_flag(self):
+		"""
+		test passing a -w flag to write some output
+		"""
+		with self.assertRaises(OutputNotJSON):
+			self.exif_tool_helper.get_metadata(EXAMPLE_FILE, params=["-w", f"{self.tmp_dir}/%f.txt"])
+
+
+
+class TestExifToolHelper(unittest.TestCase):
+
+	# ---------------------------------------------------------------------------------------------------------
+	def setUp(self):
+		self.et = exiftool.ExifToolHelper(common_args=["-G", "-n", "-overwrite_original"])
+
+	def tearDown(self):
+		if hasattr(self, "et"):
+			self.et.terminate()
+
+	# ---------------------------------------------------------------------------------------------------------
+
+	def test_run(self):
+		# no warnings when terminating when not running
+		self.assertFalse(self.et.running)
+		self.et.run()
+		self.assertTrue(self.et.running)
+		self.et.run()
+
+	# ---------------------------------------------------------------------------------------------------------
+
+	def test_terminate(self):
+		# no warnings when terminating when not running
+		self.assertFalse(self.et.running)
+		self.et.terminate()
+
+	# ---------------------------------------------------------------------------------------------------------
+	def test_get_tags(self):
+		"""
+		`get_metadata`/`get_tags` should return an error when no files specified.
+		"""
+
+		with self.assertRaises(ValueError):
+			# files can't be None
+			self.et.get_tags(None, None)
+			self.et.get_tags([], None)
+
+	# ---------------------------------------------------------------------------------------------------------
+	def test_invalid_tags_arg(self):
+		with self.assertRaises(TypeError):
+			self.et.get_tags(EXAMPLE_FILE, object())
+
+
+
+	# ---------------------------------------------------------------------------------------------------------
+	def test_auto_start(self):
+
+		# test that a RuntimeError gets thrown if auto_start is false
+		self.et = exiftool.ExifToolHelper(auto_start=False)
+		with self.assertRaises(ExifToolNotRunning):
+			self.et.get_metadata(EXAMPLE_FILE)
+
+		# test that no errors returned if auto_start=True
+		self.et = exiftool.ExifToolHelper(auto_start=True)
+		metadata = self.et.get_metadata(EXAMPLE_FILE)
+		self.assertEqual(type(metadata), list)
+
+
+	# ---------------------------------------------------------------------------------------------------------
+
+	def test_get_tags_params(self):
+		""" test params argument """
+		# lots of metadata on the file
+		full_metadata = self.et.get_metadata(EXAMPLE_FILE)[0]
+
+		# use params to get tag (basically using params to specify a tag)
+		param_metadata = self.et.get_metadata(EXAMPLE_FILE, params="-XMPToolkit")[0]
+
+		# the list should be significantly smaller
+		self.assertGreater(len(full_metadata), len(param_metadata))
+
+
+		param_metadata = self.et.get_metadata(EXAMPLE_FILE, params=["-XMPToolkit"])[0]
+		self.assertGreater(len(full_metadata), len(param_metadata))
+
+
+		with self.assertRaises(TypeError):
+			# test invalid
+			self.et.get_metadata(EXAMPLE_FILE, params=object())
+
+
+	# ---------------------------------------------------------------------------------------------------------
+
+
+
+# ---------------------------------------------------------------------------------------------------------
+if __name__ == '__main__':
+	unittest.main()