From f15c91d341db7875017dde1a3ba4e5072e726797 Mon Sep 17 00:00:00 2001 From: "SylikC (admin)" Date: Wed, 17 Jul 2019 00:42:38 -0700 Subject: [PATCH 001/251] added a changelog to track the changes, since I won't be using any issue tracking methods. --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2d6e815 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# PyExifTool Changelog + + +Date | Version | Comment +---------------------- | ------- | ------- +07/17/2019 12:26:16 AM | 0.1 | 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 + From 6a9124e40819a198c2ed2c1860332a9cdf412cfd Mon Sep 17 00:00:00 2001 From: "SylikC (admin)" Date: Wed, 17 Jul 2019 00:51:29 -0700 Subject: [PATCH 002/251] convert all leading spaces to tabs in Python code --- CHANGELOG.md | 9 +- doc/conf.py | 14 +- exiftool.py | 484 +++++++++++++++++++++--------------------- setup.py | 32 +-- test/test_exiftool.py | 148 ++++++------- 5 files changed, 344 insertions(+), 343 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d6e815..9b13207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ # PyExifTool Changelog +Current dates are in PST/PDT -Date | Version | Comment ----------------------- | ------- | ------- -07/17/2019 12:26:16 AM | 0.1 | 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 - +Date (Timezone) | Version | Comment +---------------------------- | ------- | ------- +07/17/2019 12:26:16 AM (PDT) | 0.1 | 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.1 | Convert leading spaces to tabs diff --git a/doc/conf.py b/doc/conf.py index 65e6a8a..83cf656 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -183,8 +183,8 @@ # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'PyExifTool.tex', u'PyExifTool Documentation', - u'Sven Marnach', 'manual'), + ('index', 'PyExifTool.tex', u'PyExifTool Documentation', + u'Sven Marnach', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -213,8 +213,8 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'pyexiftool', u'PyExifTool Documentation', - [u'Sven Marnach'], 1) + ('index', 'pyexiftool', u'PyExifTool Documentation', + [u'Sven Marnach'], 1) ] # If true, show URL addresses after external links. @@ -227,9 +227,9 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'PyExifTool', u'PyExifTool Documentation', - u'Sven Marnach', 'PyExifTool', 'One line description of project.', - 'Miscellaneous'), + ('index', 'PyExifTool', u'PyExifTool Documentation', + u'Sven Marnach', 'PyExifTool', 'One line description of project.', + 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. diff --git a/exiftool.py b/exiftool.py index 8a11daa..102c2d3 100644 --- a/exiftool.py +++ b/exiftool.py @@ -32,7 +32,7 @@ :: - git clone git://github.com/smarnach/pyexiftool.git + git clone git://github.com/smarnach/pyexiftool.git Alternatively, you can download a tarball_. There haven't been any releases yet. @@ -43,14 +43,14 @@ Example usage:: - import exiftool + import exiftool - files = ["a.jpg", "b.png", "c.tif"] - with exiftool.ExifTool() as et: - metadata = et.get_metadata_batch(files) - for d in metadata: - print("{:20.20} {:20.20}".format(d["SourceFile"], - d["EXIF:DateTimeOriginal"])) + files = ["a.jpg", "b.png", "c.tif"] + with exiftool.ExifTool() as et: + metadata = et.get_metadata_batch(files) + for d in metadata: + print("{:20.20} {:20.20}".format(d["SourceFile"], + d["EXIF:DateTimeOriginal"])) """ from __future__ import unicode_literals @@ -63,9 +63,9 @@ import codecs try: # Py3k compatibility - basestring + basestring except NameError: - basestring = (bytes, str) + basestring = (bytes, str) executable = "exiftool" """The name of the executable to run. @@ -86,240 +86,240 @@ # 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 + 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 fsencode = _fscodec() del _fscodec class ExifTool(object): - """Run the `exiftool` command-line tool and communicate to it. - - You can pass the file name of the ``exiftool`` executable as an - argument to the constructor. The default value ``exiftool`` will - only work if the executable is in your ``PATH``. - - Most methods of this class are only available after calling - :py:meth:`start()`, which will actually launch the subprocess. To - avoid leaving the subprocess running, make sure to call - :py:meth:`terminate()` method when finished using the instance. - This method will also be implicitly called when the instance is - garbage collected, but there are circumstance when this won't ever - happen, so you should not rely on the implicit process - termination. Subprocesses won't be automatically terminated if - the parent process exits, so a leaked subprocess will stay around - until manually killed. - - A convenient way to make sure that the subprocess is terminated is - to use the :py:class:`ExifTool` instance as a context manager:: - - with ExifTool() as et: - ... - - .. warning:: Note that there is no error handling. Nonsensical - 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. - - .. py:attribute:: running - - A Boolean value indicating whether this instance is currently - associated with a running subprocess. - """ - - def __init__(self, executable_=None): - if executable_ is None: - self.executable = executable - else: - self.executable = executable_ - self.running = False - - def start(self): - """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 started with the ``-G`` and - ``-n`` as common arguments, which are automatically included - in every command you run with :py:meth:`execute()`. - """ - if self.running: - warnings.warn("ExifTool already running; doing nothing.") - return - with open(os.devnull, "w") as devnull: - self._process = subprocess.Popen( - [self.executable, "-stay_open", "True", "-@", "-", - "-common_args", "-G", "-n"], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=devnull) - self.running = True - - def terminate(self): - """Terminate the ``exiftool`` process of this instance. - - If the subprocess isn't running, this method will do nothing. - """ - if not self.running: - return - self._process.stdin.write(b"-stay_open\nFalse\n") - self._process.stdin.flush() - self._process.communicate() - del self._process - self.running = False - - def __enter__(self): - self.start() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - self.terminate() - - def __del__(self): - self.terminate() - - def execute(self, *params): - """Execute the given batch of parameters with ``exiftool``. - - This method accepts any number of parameters and sends them to - the attached ``exiftool`` process. The process must be - running, otherwise ``ValueError`` is raised. The final - ``-execute`` necessary to actually run the batch is appended - automatically; see the documentation of :py:meth:`start()` for - the common options. The ``exiftool`` output is read up to the - end-of-output sentinel and returned as a raw ``bytes`` object, - excluding the sentinel. - - The parameters must also be raw ``bytes``, in whatever - encoding exiftool accepts. For filenames, this should be the - system's filesystem encoding. - - .. note:: This is considered a low-level method, and should - rarely be needed by application developers. - """ - if not self.running: - raise ValueError("ExifTool instance not running.") - self._process.stdin.write(b"\n".join(params + (b"-execute\n",))) - self._process.stdin.flush() - output = b"" - fd = self._process.stdout.fileno() - while not output[-32:].strip().endswith(sentinel): - output += os.read(fd, block_size) - return output.strip()[:-len(sentinel)] - - def execute_json(self, *params): - """Execute the given batch of parameters and parse the JSON output. - - This method is similar to :py:meth:`execute()`. It - automatically adds the parameter ``-j`` to request JSON output - from ``exiftool`` and parses the output. The return value is - a list of dictionaries, mapping tag names to the corresponding - values. All keys are Unicode strings with the tag names - including the ExifTool group name in the format :. - The values can have multiple types. All strings occurring as - values will be Unicode strings. Each dictionary contains the - name of the file it corresponds to in the key ``"SourceFile"``. - - The parameters to this function must be either raw strings - (type ``str`` in Python 2.x, type ``bytes`` in Python 3.x) or - Unicode strings (type ``unicode`` in Python 2.x, type ``str`` - in Python 3.x). Unicode strings will be encoded using - system's filesystem encoding. This behaviour means you can - pass in filenames according to the convention of the - respective Python version – as raw strings in Python 2.x and - as Unicode strings in Python 3.x. - """ - params = map(fsencode, params) - return json.loads(self.execute(b"-j", *params).decode("utf-8")) - - def get_metadata_batch(self, filenames): - """Return all meta-data for the given files. - - The return value will have the format described in the - documentation of :py:meth:`execute_json()`. - """ - return self.execute_json(*filenames) - - def get_metadata(self, filename): - """Return meta-data for a single file. - - The returned dictionary has the format described in the - documentation of :py:meth:`execute_json()`. - """ - return self.execute_json(filename)[0] - - 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 :. - - The second argument is an iterable of file names. - - The format of the return value is the same as for - :py:meth:`execute_json()`. - """ - # 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) - - 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()`. - """ - return self.get_tags_batch(tags, [filename])[0] - - def get_tag_batch(self, tag, filenames): - """Extract a single tag from the given files. - - The first argument is a single tag name, as usual in the - format :. - - 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 - - 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] + """Run the `exiftool` command-line tool and communicate to it. + + You can pass the file name of the ``exiftool`` executable as an + argument to the constructor. The default value ``exiftool`` will + only work if the executable is in your ``PATH``. + + Most methods of this class are only available after calling + :py:meth:`start()`, which will actually launch the subprocess. To + avoid leaving the subprocess running, make sure to call + :py:meth:`terminate()` method when finished using the instance. + This method will also be implicitly called when the instance is + garbage collected, but there are circumstance when this won't ever + happen, so you should not rely on the implicit process + termination. Subprocesses won't be automatically terminated if + the parent process exits, so a leaked subprocess will stay around + until manually killed. + + A convenient way to make sure that the subprocess is terminated is + to use the :py:class:`ExifTool` instance as a context manager:: + + with ExifTool() as et: + ... + + .. warning:: Note that there is no error handling. Nonsensical + 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. + + .. py:attribute:: running + + A Boolean value indicating whether this instance is currently + associated with a running subprocess. + """ + + def __init__(self, executable_=None): + if executable_ is None: + self.executable = executable + else: + self.executable = executable_ + self.running = False + + def start(self): + """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 started with the ``-G`` and + ``-n`` as common arguments, which are automatically included + in every command you run with :py:meth:`execute()`. + """ + if self.running: + warnings.warn("ExifTool already running; doing nothing.") + return + with open(os.devnull, "w") as devnull: + self._process = subprocess.Popen( + [self.executable, "-stay_open", "True", "-@", "-", + "-common_args", "-G", "-n"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=devnull) + self.running = True + + def terminate(self): + """Terminate the ``exiftool`` process of this instance. + + If the subprocess isn't running, this method will do nothing. + """ + if not self.running: + return + self._process.stdin.write(b"-stay_open\nFalse\n") + self._process.stdin.flush() + self._process.communicate() + del self._process + self.running = False + + def __enter__(self): + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.terminate() + + def __del__(self): + self.terminate() + + def execute(self, *params): + """Execute the given batch of parameters with ``exiftool``. + + This method accepts any number of parameters and sends them to + the attached ``exiftool`` process. The process must be + running, otherwise ``ValueError`` is raised. The final + ``-execute`` necessary to actually run the batch is appended + automatically; see the documentation of :py:meth:`start()` for + the common options. The ``exiftool`` output is read up to the + end-of-output sentinel and returned as a raw ``bytes`` object, + excluding the sentinel. + + The parameters must also be raw ``bytes``, in whatever + encoding exiftool accepts. For filenames, this should be the + system's filesystem encoding. + + .. note:: This is considered a low-level method, and should + rarely be needed by application developers. + """ + if not self.running: + raise ValueError("ExifTool instance not running.") + self._process.stdin.write(b"\n".join(params + (b"-execute\n",))) + self._process.stdin.flush() + output = b"" + fd = self._process.stdout.fileno() + while not output[-32:].strip().endswith(sentinel): + output += os.read(fd, block_size) + return output.strip()[:-len(sentinel)] + + def execute_json(self, *params): + """Execute the given batch of parameters and parse the JSON output. + + This method is similar to :py:meth:`execute()`. It + automatically adds the parameter ``-j`` to request JSON output + from ``exiftool`` and parses the output. The return value is + a list of dictionaries, mapping tag names to the corresponding + values. All keys are Unicode strings with the tag names + including the ExifTool group name in the format :. + The values can have multiple types. All strings occurring as + values will be Unicode strings. Each dictionary contains the + name of the file it corresponds to in the key ``"SourceFile"``. + + The parameters to this function must be either raw strings + (type ``str`` in Python 2.x, type ``bytes`` in Python 3.x) or + Unicode strings (type ``unicode`` in Python 2.x, type ``str`` + in Python 3.x). Unicode strings will be encoded using + system's filesystem encoding. This behaviour means you can + pass in filenames according to the convention of the + respective Python version – as raw strings in Python 2.x and + as Unicode strings in Python 3.x. + """ + params = map(fsencode, params) + return json.loads(self.execute(b"-j", *params).decode("utf-8")) + + def get_metadata_batch(self, filenames): + """Return all meta-data for the given files. + + The return value will have the format described in the + documentation of :py:meth:`execute_json()`. + """ + return self.execute_json(*filenames) + + def get_metadata(self, filename): + """Return meta-data for a single file. + + The returned dictionary has the format described in the + documentation of :py:meth:`execute_json()`. + """ + return self.execute_json(filename)[0] + + 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 :. + + The second argument is an iterable of file names. + + The format of the return value is the same as for + :py:meth:`execute_json()`. + """ + # 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) + + 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()`. + """ + return self.get_tags_batch(tags, [filename])[0] + + def get_tag_batch(self, tag, filenames): + """Extract a single tag from the given files. + + The first argument is a single tag name, as usual in the + format :. + + 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 + + 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] diff --git a/setup.py b/setup.py index 03e9436..90ef3b4 100644 --- a/setup.py +++ b/setup.py @@ -16,19 +16,19 @@ from distutils.core import setup -setup(name="PyExifTool", - version="0.1", - description="Python wrapper for exiftool", - license="GPLv3+", - author="Sven Marnach", - author_email="sven@marnach.net", - url="http://github.com/smarnach/pyexiftool", - classifiers=[ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", - "Programming Language :: Python :: 2.6", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3", - "Topic :: Multimedia"], - py_modules=["exiftool"]) +setup( name="PyExifTool", + version="0.1", + description="Python wrapper for exiftool", + license="GPLv3+", + author="Sven Marnach", + author_email="sven@marnach.net", + url="http://github.com/smarnach/pyexiftool", + classifiers=[ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Programming Language :: Python :: 2.6", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Topic :: Multimedia"], + py_modules=["exiftool"]) diff --git a/test/test_exiftool.py b/test/test_exiftool.py index 17778d8..9471da0 100644 --- a/test/test_exiftool.py +++ b/test/test_exiftool.py @@ -8,79 +8,79 @@ import os class TestExifTool(unittest.TestCase): - def setUp(self): - self.et = exiftool.ExifTool() - def tearDown(self): - if hasattr(self, "et"): - self.et.terminate() - if hasattr(self, "process"): - if self.process.poll() is None: - self.process.terminate() - 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) - 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.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.process = self.et._process - self.assertEqual(self.process.poll(), None) - self.et.terminate() - self.assertNotEqual(self.process.poll(), None) - def test_termination_implicit(self): - # Test implicit process termination on garbage collection - self.et.start() - self.process = self.et._process - del self.et - self.assertNotEqual(self.process.poll(), None) - 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": "70x46"}, - {"SourceFile": "skyblue.png", - "File:FileType": "PNG", - "PNG:ImageWidth": 64, - "PNG:ImageHeight": 64, - "Composite:ImageSize": "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 setUp(self): + self.et = exiftool.ExifTool() + def tearDown(self): + if hasattr(self, "et"): + self.et.terminate() + if hasattr(self, "process"): + if self.process.poll() is None: + self.process.terminate() + 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) + 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.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.process = self.et._process + self.assertEqual(self.process.poll(), None) + self.et.terminate() + self.assertNotEqual(self.process.poll(), None) + def test_termination_implicit(self): + # Test implicit process termination on garbage collection + self.et.start() + self.process = self.et._process + del self.et + self.assertNotEqual(self.process.poll(), None) + 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": "70x46"}, + {"SourceFile": "skyblue.png", + "File:FileType": "PNG", + "PNG:ImageWidth": 64, + "PNG:ImageHeight": 64, + "Composite:ImageSize": "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") if __name__ == '__main__': - unittest.main() + unittest.main() From 8bb5f0cb7f85b124f01638f74dcf5b04afd1f65d Mon Sep 17 00:00:00 2001 From: "SylikC (admin)" Date: Wed, 17 Jul 2019 00:54:12 -0700 Subject: [PATCH 003/251] Merge pull request 10 https://github.com/smarnach/pyexiftool/pull/10 --- CHANGELOG.md | 1 + exiftool.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b13207..16ef8c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,3 +6,4 @@ Date (Timezone) | Version | Comment ---------------------------- | ------- | ------- 07/17/2019 12:26:16 AM (PDT) | 0.1 | 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.1 | Convert leading spaces to tabs +07/17/2019 12:52:33 AM (PDT) | 0.1.1 | 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) May 28, 2014
*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.* diff --git a/exiftool.py b/exiftool.py index 102c2d3..2a9cb82 100644 --- a/exiftool.py +++ b/exiftool.py @@ -323,3 +323,7 @@ def get_tag(self, tag, filename): ``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.""" + self.execute("-overwrite_original", "-TagsFromFile", fromFilename, toFilename) From 4d60d493a742e914c0b8b93f7b6dfcbbaa80dd71 Mon Sep 17 00:00:00 2001 From: "SylikC (admin)" Date: Wed, 17 Jul 2019 01:06:39 -0700 Subject: [PATCH 004/251] Merge Pull request 25 https://github.com/smarnach/pyexiftool/pull/25 --- CHANGELOG.md | 7 ++++++- exiftool.py | 23 +++++++++++++++++------ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16ef8c4..71aa296 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,4 +6,9 @@ Date (Timezone) | Version | Comment ---------------------------- | ------- | ------- 07/17/2019 12:26:16 AM (PDT) | 0.1 | 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.1 | Convert leading spaces to tabs -07/17/2019 12:52:33 AM (PDT) | 0.1.1 | 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) May 28, 2014
*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 12:52:33 AM (PDT) | 0.1.1 | 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
*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.1.2 | 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
*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".*
*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.* + + + + diff --git a/exiftool.py b/exiftool.py index 2a9cb82..327d17f 100644 --- a/exiftool.py +++ b/exiftool.py @@ -115,6 +115,11 @@ def fsencode(filename): class ExifTool(object): """Run the `exiftool` command-line tool and communicate to it. + The argument ``print_conversion`` 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. + You can pass the file name of the ``exiftool`` executable as an argument to the constructor. The default value ``exiftool`` will only work if the executable is in your ``PATH``. @@ -148,7 +153,8 @@ class ExifTool(object): associated with a running subprocess. """ - def __init__(self, executable_=None): + def __init__(self, executable_=None, print_conversion=False): + self.print_conversion = print_conversion if executable_ is None: self.executable = executable else: @@ -159,17 +165,22 @@ def start(self): """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 started with the ``-G`` and - ``-n`` as common arguments, which are automatically included - in every command you run with :py:meth:`execute()`. + already running. The process is started with the ``-G`` (and, + if print conversion was disabled, ``-n``) as common arguments, + which are automatically included in every command you run with + :py:meth:`execute()`. """ if self.running: warnings.warn("ExifTool already running; doing nothing.") return + + command = [self.executable, "-stay_open", "True", "-@", "-", "-common_args", "-G"] + if not self.print_conversion: + command.append("-n") + with open(os.devnull, "w") as devnull: self._process = subprocess.Popen( - [self.executable, "-stay_open", "True", "-@", "-", - "-common_args", "-G", "-n"], + command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=devnull) self.running = True From d6db5b36b9d69f0ed7b7fe53c7d2ea208a19ff78 Mon Sep 17 00:00:00 2001 From: "SylikC (admin)" Date: Wed, 17 Jul 2019 01:21:41 -0700 Subject: [PATCH 005/251] Merge pull request 27 https://github.com/smarnach/pyexiftool/pull/27 which also exists in a different repo https://github.com/blurstudio/pyexiftool/tree/shell-option --- CHANGELOG.md | 1 + exiftool.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71aa296..7473dd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Date (Timezone) | Version | Comment 07/17/2019 12:50:20 AM (PDT) | 0.1 | Convert leading spaces to tabs 07/17/2019 12:52:33 AM (PDT) | 0.1.1 | 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
*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.1.2 | 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
*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".*
*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.1.3 | Merge with slight modifications to variable names for clarity (Kevin Mak) [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
*On Windows this will allow to run exiftool without showing the DOS shell.*
**This might break Linux but I don't know for sure**
Alternative source location with only this patch: https://github.com/blurstudio/pyexiftool/tree/shell-option diff --git a/exiftool.py b/exiftool.py index 327d17f..f464f57 100644 --- a/exiftool.py +++ b/exiftool.py @@ -153,7 +153,8 @@ class ExifTool(object): associated with a running subprocess. """ - def __init__(self, executable_=None, print_conversion=False): + def __init__(self, executable_=None, win_shell=True, print_conversion=False): + self.win_shell = win_shell self.print_conversion = print_conversion if executable_ is None: self.executable = executable @@ -179,10 +180,17 @@ def start(self): command.append("-n") with open(os.devnull, "w") as devnull: + 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( command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=devnull) + stderr=devnull, startupinfo=startup_info) self.running = True def terminate(self): From 01f8ed2d3ff201b138538fd4a2ddaa0f230666ce Mon Sep 17 00:00:00 2001 From: "SylikC (admin)" Date: Wed, 17 Jul 2019 01:24:58 -0700 Subject: [PATCH 006/251] Merge pull request 19 https://github.com/smarnach/pyexiftool/pull/19 --- CHANGELOG.md | 2 ++ setup.cfg | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 setup.cfg diff --git a/CHANGELOG.md b/CHANGELOG.md index 7473dd9..bbc167f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ Date (Timezone) | Version | Comment 07/17/2019 12:52:33 AM (PDT) | 0.1.1 | 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
*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.1.2 | 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
*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".*
*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.1.3 | Merge with slight modifications to variable names for clarity (Kevin Mak) [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
*On Windows this will allow to run exiftool without showing the DOS shell.*
**This might break Linux but I don't know for sure**
Alternative source location with only this patch: https://github.com/blurstudio/pyexiftool/tree/shell-option +07/17/2019 01:24:32 AM (PDT) | 0.1.4 | Merge [Pull request #19 "Correct dependency for building an RPM."](https://github.com/smarnach/pyexiftool/pull/19) by [Achim Herwig (Achimh3011) Munich, Germany](https://github.com/Achimh3011) on Aug 25, 2016
**I'm not sure if this is entirely necessary, but merging it anyways** + diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..b46d8e7 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[bdist_rpm] +requires = exiftool From 86d724425cef35071dc5dbaec07a7b8ba9f4f446 Mon Sep 17 00:00:00 2001 From: "SylikC (admin)" Date: Wed, 17 Jul 2019 02:10:37 -0700 Subject: [PATCH 007/251] Merge pull request 15 https://github.com/smarnach/pyexiftool/pull/15 --- CHANGELOG.md | 6 ++---- exiftool.py | 9 ++++++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbc167f..5a91cea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,8 @@ Date (Timezone) | Version | Comment 07/17/2019 12:50:20 AM (PDT) | 0.1 | Convert leading spaces to tabs 07/17/2019 12:52:33 AM (PDT) | 0.1.1 | 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
*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.1.2 | 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
*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".*
*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.1.3 | Merge with slight modifications to variable names for clarity (Kevin Mak) [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
*On Windows this will allow to run exiftool without showing the DOS shell.*
**This might break Linux but I don't know for sure**
Alternative source location with only this patch: https://github.com/blurstudio/pyexiftool/tree/shell-option +07/17/2019 01:20:15 AM (PDT) | 0.1.3 | 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
*On Windows this will allow to run exiftool without showing the DOS shell.*
**This might break Linux but I don't know for sure**
Alternative source location with only this patch: https://github.com/blurstudio/pyexiftool/tree/shell-option 07/17/2019 01:24:32 AM (PDT) | 0.1.4 | Merge [Pull request #19 "Correct dependency for building an RPM."](https://github.com/smarnach/pyexiftool/pull/19) by [Achim Herwig (Achimh3011) Munich, Germany](https://github.com/Achimh3011) on Aug 25, 2016
**I'm not sure if this is entirely necessary, but merging it anyways** - - - +07/17/2019 02:09:40 AM (PDT) | 0.1.5 | Merge [Pull request #15 "handling Errno:11 Resource temporarily unavailable"](https://github.com/smarnach/pyexiftool/pull/15) by [shoyebi](https://github.com/shoyebi) on Jun 12, 2015 diff --git a/exiftool.py b/exiftool.py index f464f57..2fb86ea 100644 --- a/exiftool.py +++ b/exiftool.py @@ -55,6 +55,7 @@ from __future__ import unicode_literals +import select import sys import subprocess import os @@ -242,7 +243,13 @@ def execute(self, *params): output = b"" fd = self._process.stdout.fileno() while not output[-32:].strip().endswith(sentinel): - output += os.read(fd, block_size) + #output += os.read(fd, block_size) + + # not sure if this works on windows + inputready,outputready,exceptready = select.select([fd],[],[]) + for i in inputready: + if i == fd: + output += os.read(fd, block_size) return output.strip()[:-len(sentinel)] def execute_json(self, *params): From 9c15d4250f6ae2c907bbefa108e1d377b775503c Mon Sep 17 00:00:00 2001 From: "SylikC (admin)" Date: Thu, 18 Jul 2019 03:47:10 -0700 Subject: [PATCH 008/251] merge in a jmathai elodie version with modified stuff from pull request 5 https://github.com/jmathai/elodie/blob/6114328f325660287d1998338a6d5e6ba4ccf069/elodie/external/pyexiftool.py --- CHANGELOG.md | 2 +- exiftool.py | 162 ++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 154 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a91cea..cf20de3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,5 +11,5 @@ Date (Timezone) | Version | Comment 07/17/2019 01:20:15 AM (PDT) | 0.1.3 | 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
*On Windows this will allow to run exiftool without showing the DOS shell.*
**This might break Linux but I don't know for sure**
Alternative source location with only this patch: https://github.com/blurstudio/pyexiftool/tree/shell-option 07/17/2019 01:24:32 AM (PDT) | 0.1.4 | Merge [Pull request #19 "Correct dependency for building an RPM."](https://github.com/smarnach/pyexiftool/pull/19) by [Achim Herwig (Achimh3011) Munich, Germany](https://github.com/Achimh3011) on Aug 25, 2016
**I'm not sure if this is entirely necessary, but merging it anyways** 07/17/2019 02:09:40 AM (PDT) | 0.1.5 | Merge [Pull request #15 "handling Errno:11 Resource temporarily unavailable"](https://github.com/smarnach/pyexiftool/pull/15) by [shoyebi](https://github.com/shoyebi) on Jun 12, 2015 - +07/18/2019 03:40:39 AM (PDT) | 0.1.6 | Merge in the first set of changes by Leo Broska related to [Pull request #5 "add set_tags_batch, set_tags + constructor takes added options"](https://github.com/smarnach/pyexiftool/pull/5) by [halloleo](https://github.com/halloleo) on Aug 1, 2012
but this is sourced from [jmathai/elodie's 6114328 Jun 22,2016 commit](https://github.com/jmathai/elodie/blob/6114328f325660287d1998338a6d5e6ba4ccf069/elodie/external/pyexiftool.py) diff --git a/exiftool.py b/exiftool.py index 2fb86ea..2a83a08 100644 --- a/exiftool.py +++ b/exiftool.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # PyExifTool -# Copyright 2012 Sven Marnach +# Copyright 2012 Sven Marnach. +# More contributors in the CHANGELOG for the pull requests # This file is part of PyExifTool. # @@ -61,6 +62,7 @@ import os import json import warnings +import logging import codecs try: # Py3k compatibility @@ -84,6 +86,10 @@ # some cases. block_size = 4096 +# constants related to keywords manipulations +KW_TAGNAME = "IPTC:Keywords" +KW_REPLACE, KW_ADD, KW_REMOVE = range(3) + # This code has been adapted from Lib/os.py in the Python source tree # (sha1 265e36e277f3) def _fscodec(): @@ -113,6 +119,38 @@ def fsencode(filename): fsencode = _fscodec() del _fscodec + +#string helper +def strip_nl (s): + return ' '.join(s.splitlines()) + + +# Error checking function +# Note: They are quite fragile, beacsue teh 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 ExifTool(object): """Run the `exiftool` command-line tool and communicate to it. @@ -121,9 +159,12 @@ class ExifTool(object): may be slower. If print conversion is enabled, appending ``#`` to a tag name disables the print conversion for this particular tag. - You can pass the file name of the ``exiftool`` executable as an - argument to the constructor. The default value ``exiftool`` will - only work if the executable is in your ``PATH``. + You can pass two arguments to the constructor: + - ``addedargs`` (list of strings): contains additional paramaters for + the stay-open instance of exiftool + - ``executable`` (string): file name of the ``exiftool`` executable. + The default value ``exiftool`` will only work if the executable + is in your ``PATH`` Most methods of this class are only available after calling :py:meth:`start()`, which will actually launch the subprocess. To @@ -154,15 +195,24 @@ class ExifTool(object): associated with a running subprocess. """ - def __init__(self, executable_=None, win_shell=True, print_conversion=False): + def __init__(self, executable_=None, addedargs=None, win_shell=True, print_conversion=False): + self.win_shell = win_shell self.print_conversion = print_conversion + if executable_ is None: self.executable = executable else: self.executable = executable_ self.running = False + if addedargs is None: + self.addedargs = [] + elif type(addedargs) is list: + self.addedargs = addedargs + else: + raise TypeError("addedargs not a list of strings") + def start(self): """Start an ``exiftool`` process in batch mode for this instance. @@ -176,9 +226,13 @@ def start(self): warnings.warn("ExifTool already running; doing nothing.") return - command = [self.executable, "-stay_open", "True", "-@", "-", "-common_args", "-G"] + procargs = [self.executable, "-stay_open", "True", "-@", "-", "-common_args", "-G"] + # may remove this and just have it added to extra args if not self.print_conversion: - command.append("-n") + procargs.append("-n") + + procargs.extend(self.addedargs) + logging.debug(procargs) with open(os.devnull, "w") as devnull: startup_info = subprocess.STARTUPINFO() @@ -189,7 +243,7 @@ def start(self): startup_info.dwFlags |= 11 self._process = subprocess.Popen( - command, + procargs, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=devnull, startupinfo=startup_info) self.running = True @@ -238,7 +292,8 @@ def execute(self, *params): """ if not self.running: raise ValueError("ExifTool instance not running.") - self._process.stdin.write(b"\n".join(params + (b"-execute\n",))) + cmd_text = b"\n".join(params + (b"-execute\n",)) + self._process.stdin.write(cmd_text.encode("utf-8")) self._process.stdin.flush() output = b"" fd = self._process.stdout.fileno() @@ -353,3 +408,92 @@ def get_tag(self, tag, filename): def copy_tags(self, fromFilename, toFilename): """Copy all tags from one file to another.""" self.execute("-overwrite_original", "-TagsFromFile", fromFilename, toFilename) + + + def set_tags_batch(self, tags, filenames): + """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 :. + + 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()`. + """ + # 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(): + params.append(u'-%s=%s' % (tag, value)) + + params.extend(filenames) + return self.execute(*params) + + 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. + """ + 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. + + 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) + logging.debug (params) + return self.execute(*params) + + def set_keywords(self, mode, keywords, filename): + """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(mode, keywords, [filename]) From 28a89f8348be4d22a52c22c2beb30a95a04918a4 Mon Sep 17 00:00:00 2001 From: "SylikC (admin)" Date: Thu, 18 Jul 2019 04:00:25 -0700 Subject: [PATCH 009/251] merge some changes from jmathai/elodie https://github.com/jmathai/elodie/blob/af36de091e1746b490bed0adb839adccd4f6d2ef/elodie/external/pyexiftool.py --- CHANGELOG.md | 4 +++- exiftool.py | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf20de3..8f30a41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,5 +11,7 @@ Date (Timezone) | Version | Comment 07/17/2019 01:20:15 AM (PDT) | 0.1.3 | 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
*On Windows this will allow to run exiftool without showing the DOS shell.*
**This might break Linux but I don't know for sure**
Alternative source location with only this patch: https://github.com/blurstudio/pyexiftool/tree/shell-option 07/17/2019 01:24:32 AM (PDT) | 0.1.4 | Merge [Pull request #19 "Correct dependency for building an RPM."](https://github.com/smarnach/pyexiftool/pull/19) by [Achim Herwig (Achimh3011) Munich, Germany](https://github.com/Achimh3011) on Aug 25, 2016
**I'm not sure if this is entirely necessary, but merging it anyways** 07/17/2019 02:09:40 AM (PDT) | 0.1.5 | Merge [Pull request #15 "handling Errno:11 Resource temporarily unavailable"](https://github.com/smarnach/pyexiftool/pull/15) by [shoyebi](https://github.com/shoyebi) on Jun 12, 2015 -07/18/2019 03:40:39 AM (PDT) | 0.1.6 | Merge in the first set of changes by Leo Broska related to [Pull request #5 "add set_tags_batch, set_tags + constructor takes added options"](https://github.com/smarnach/pyexiftool/pull/5) by [halloleo](https://github.com/halloleo) on Aug 1, 2012
but this is sourced from [jmathai/elodie's 6114328 Jun 22,2016 commit](https://github.com/jmathai/elodie/blob/6114328f325660287d1998338a6d5e6ba4ccf069/elodie/external/pyexiftool.py) +07/18/2019 03:40:39 AM (PDT) | 0.1.6 | set_tags and UTF-8 cmdline - Merge in the first set of changes by Leo Broska related to [Pull request #5 "add set_tags_batch, set_tags + constructor takes added options"](https://github.com/smarnach/pyexiftool/pull/5) by [halloleo](https://github.com/halloleo) on Aug 1, 2012
but this is sourced from [jmathai/elodie's 6114328 Jun 22,2016 commit](https://github.com/jmathai/elodie/blob/6114328f325660287d1998338a6d5e6ba4ccf069/elodie/external/pyexiftool.py) +07/18/2019 03:59:02 AM (PDT) | 0.1.7 | Merge another commit fromt he jmathai/elodie [zserg on Mar 12, 2016](https://github.com/jmathai/elodie/blob/af36de091e1746b490bed0adb839adccd4f6d2ef/elodie/external/pyexiftool.py)
seems to do UTF-8 encoding on set_tags + diff --git a/exiftool.py b/exiftool.py index 2a83a08..fd3c4d7 100644 --- a/exiftool.py +++ b/exiftool.py @@ -293,7 +293,7 @@ def execute(self, *params): if not self.running: raise ValueError("ExifTool instance not running.") cmd_text = b"\n".join(params + (b"-execute\n",)) - self._process.stdin.write(cmd_text.encode("utf-8")) + self._process.stdin.write(cmd_text.encode("utf-8")) # a commit reverted this to the original where it's not encoded in UTF-8, will see if there are conflicts later self._process.stdin.flush() output = b"" fd = self._process.stdout.fileno() @@ -433,11 +433,13 @@ def set_tags_batch(self, tags, filenames): "an iterable of strings") params = [] + params_b = [] for tag, value in tags.items(): params.append(u'-%s=%s' % (tag, value)) - + params.extend(filenames) - return self.execute(*params) + params_b = [x.encode('utf-8') for x in params] + return self.execute(*params_b) def set_tags(self, tags, filename): """Writes the values of the specified tags for the given file. From c1f2ab88008b9d3b5736e2fa7d0f2f8991c16e07 Mon Sep 17 00:00:00 2001 From: "SylikC (admin)" Date: Thu, 18 Jul 2019 04:02:43 -0700 Subject: [PATCH 010/251] same elodie zserg commit that changed the naming of the variables slightly https://github.com/jmathai/elodie/blob/ad1cbefb15077844a6f64dca567ea5600477dd52/elodie/external/pyexiftool.py --- CHANGELOG.md | 2 +- exiftool.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f30a41..d63ba50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,5 +13,5 @@ Date (Timezone) | Version | Comment 07/17/2019 02:09:40 AM (PDT) | 0.1.5 | Merge [Pull request #15 "handling Errno:11 Resource temporarily unavailable"](https://github.com/smarnach/pyexiftool/pull/15) by [shoyebi](https://github.com/shoyebi) on Jun 12, 2015 07/18/2019 03:40:39 AM (PDT) | 0.1.6 | set_tags and UTF-8 cmdline - Merge in the first set of changes by Leo Broska related to [Pull request #5 "add set_tags_batch, set_tags + constructor takes added options"](https://github.com/smarnach/pyexiftool/pull/5) by [halloleo](https://github.com/halloleo) on Aug 1, 2012
but this is sourced from [jmathai/elodie's 6114328 Jun 22,2016 commit](https://github.com/jmathai/elodie/blob/6114328f325660287d1998338a6d5e6ba4ccf069/elodie/external/pyexiftool.py) 07/18/2019 03:59:02 AM (PDT) | 0.1.7 | Merge another commit fromt he jmathai/elodie [zserg on Mar 12, 2016](https://github.com/jmathai/elodie/blob/af36de091e1746b490bed0adb839adccd4f6d2ef/elodie/external/pyexiftool.py)
seems to do UTF-8 encoding on set_tags - +07/18/2019 04:01:18 AM (PDT) | 0.1.7 | minor change it looks like a rename to match PEP8 coding standards by [zserg on Aug 21, 2016](https://github.com/jmathai/elodie/blob/ad1cbefb15077844a6f64dca567ea5600477dd52/elodie/external/pyexiftool.py) diff --git a/exiftool.py b/exiftool.py index fd3c4d7..a519778 100644 --- a/exiftool.py +++ b/exiftool.py @@ -433,13 +433,13 @@ def set_tags_batch(self, tags, filenames): "an iterable of strings") params = [] - params_b = [] + params_utf8 = [] for tag, value in tags.items(): params.append(u'-%s=%s' % (tag, value)) params.extend(filenames) - params_b = [x.encode('utf-8') for x in params] - return self.execute(*params_b) + 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. From c9b15e22f88386821d2c58f70dc0e9230b87ff11 Mon Sep 17 00:00:00 2001 From: "SylikC (admin)" Date: Thu, 18 Jul 2019 04:06:17 -0700 Subject: [PATCH 011/251] another elodie commit for fixing latin-1 fallback https://github.com/jmathai/elodie/commit/fe70227c7170e01c8377de7f9770e761eab52036#diff-f9cf0f3eed27e85c9c9469d0e0d431d5 --- CHANGELOG.md | 1 + exiftool.py | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d63ba50..5eaa14a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,4 +14,5 @@ Date (Timezone) | Version | Comment 07/18/2019 03:40:39 AM (PDT) | 0.1.6 | set_tags and UTF-8 cmdline - Merge in the first set of changes by Leo Broska related to [Pull request #5 "add set_tags_batch, set_tags + constructor takes added options"](https://github.com/smarnach/pyexiftool/pull/5) by [halloleo](https://github.com/halloleo) on Aug 1, 2012
but this is sourced from [jmathai/elodie's 6114328 Jun 22,2016 commit](https://github.com/jmathai/elodie/blob/6114328f325660287d1998338a6d5e6ba4ccf069/elodie/external/pyexiftool.py) 07/18/2019 03:59:02 AM (PDT) | 0.1.7 | Merge another commit fromt he jmathai/elodie [zserg on Mar 12, 2016](https://github.com/jmathai/elodie/blob/af36de091e1746b490bed0adb839adccd4f6d2ef/elodie/external/pyexiftool.py)
seems to do UTF-8 encoding on set_tags 07/18/2019 04:01:18 AM (PDT) | 0.1.7 | minor change it looks like a rename to match PEP8 coding standards by [zserg on Aug 21, 2016](https://github.com/jmathai/elodie/blob/ad1cbefb15077844a6f64dca567ea5600477dd52/elodie/external/pyexiftool.py) +07/18/2019 04:05:36 AM (PDT) | 0.1.8 | [Fallback to latin if utf-8 decode fails in pyexiftool.py](https://github.com/jmathai/elodie/commit/fe70227c7170e01c8377de7f9770e761eab52036#diff-f9cf0f3eed27e85c9c9469d0e0d431d5) by [jmathai](https://github.com/jmathai/elodie/commits?author=jmathai) on Sep 7, 2016 diff --git a/exiftool.py b/exiftool.py index a519778..dbf81d3 100644 --- a/exiftool.py +++ b/exiftool.py @@ -330,7 +330,14 @@ def execute_json(self, *params): as Unicode strings in Python 3.x. """ params = map(fsencode, params) - return json.loads(self.execute(b"-j", *params).decode("utf-8")) + # 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 + try: + return json.loads(self.execute(b"-j", *params).decode("utf-8")) + except UnicodeDecodeError as e: + return json.loads(self.execute(b"-j", *params).decode("latin-1")) def get_metadata_batch(self, filenames): """Return all meta-data for the given files. From 63782bfe5ea6d6292ffd0747793052c7670ae41c Mon Sep 17 00:00:00 2001 From: "SylikC (admin)" Date: Thu, 18 Jul 2019 04:16:04 -0700 Subject: [PATCH 012/251] completely merge the pull request 5 and fix a typo --- CHANGELOG.md | 1 + exiftool.py | 3 ++- test/test_exiftool.py | 51 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eaa14a..8456abb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,4 +15,5 @@ Date (Timezone) | Version | Comment 07/18/2019 03:59:02 AM (PDT) | 0.1.7 | Merge another commit fromt he jmathai/elodie [zserg on Mar 12, 2016](https://github.com/jmathai/elodie/blob/af36de091e1746b490bed0adb839adccd4f6d2ef/elodie/external/pyexiftool.py)
seems to do UTF-8 encoding on set_tags 07/18/2019 04:01:18 AM (PDT) | 0.1.7 | minor change it looks like a rename to match PEP8 coding standards by [zserg on Aug 21, 2016](https://github.com/jmathai/elodie/blob/ad1cbefb15077844a6f64dca567ea5600477dd52/elodie/external/pyexiftool.py) 07/18/2019 04:05:36 AM (PDT) | 0.1.8 | [Fallback to latin if utf-8 decode fails in pyexiftool.py](https://github.com/jmathai/elodie/commit/fe70227c7170e01c8377de7f9770e761eab52036#diff-f9cf0f3eed27e85c9c9469d0e0d431d5) by [jmathai](https://github.com/jmathai/elodie/commits?author=jmathai) on Sep 7, 2016 +07/18/2019 04:14:32 AM (PDT) | 0.1.9 | Merge the test cases from the [Pull request #5 "add set_tags_batch, set_tags + constructor takes added options"](https://github.com/smarnach/pyexiftool/pull/5) by [halloleo](https://github.com/halloleo) on Aug 1, 2012 diff --git a/exiftool.py b/exiftool.py index dbf81d3..e453869 100644 --- a/exiftool.py +++ b/exiftool.py @@ -126,7 +126,8 @@ def strip_nl (s): # Error checking function -# Note: They are quite fragile, beacsue teh just parse the output text from exiftool +# 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`) diff --git a/test/test_exiftool.py b/test/test_exiftool.py index 9471da0..37094c9 100644 --- a/test/test_exiftool.py +++ b/test/test_exiftool.py @@ -6,10 +6,11 @@ import exiftool import warnings import os +import shutil class TestExifTool(unittest.TestCase): def setUp(self): - self.et = exiftool.ExifTool() + self.et = exiftool.ExifTool(addedargs=["-overwrite_original"]) def tearDown(self): if hasattr(self, "et"): self.et.terminate() @@ -82,5 +83,53 @@ def test_get_metadata(self): 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) + if __name__ == '__main__': unittest.main() From 06954e971817ee4b4c46fedabecc8eb6c4fcdf3b Mon Sep 17 00:00:00 2001 From: "SylikC (admin)" Date: Thu, 18 Jul 2019 04:41:19 -0700 Subject: [PATCH 013/251] changed the version number, added resources to thceck, and modified the way the license displays. Now it's time to hack at it --- CHANGELOG.md | 17 +++++++++++++++++ setup.py | 6 +++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8456abb..4318ca3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,4 +16,21 @@ Date (Timezone) | Version | Comment 07/18/2019 04:01:18 AM (PDT) | 0.1.7 | minor change it looks like a rename to match PEP8 coding standards by [zserg on Aug 21, 2016](https://github.com/jmathai/elodie/blob/ad1cbefb15077844a6f64dca567ea5600477dd52/elodie/external/pyexiftool.py) 07/18/2019 04:05:36 AM (PDT) | 0.1.8 | [Fallback to latin if utf-8 decode fails in pyexiftool.py](https://github.com/jmathai/elodie/commit/fe70227c7170e01c8377de7f9770e761eab52036#diff-f9cf0f3eed27e85c9c9469d0e0d431d5) by [jmathai](https://github.com/jmathai/elodie/commits?author=jmathai) on Sep 7, 2016 07/18/2019 04:14:32 AM (PDT) | 0.1.9 | Merge the test cases from the [Pull request #5 "add set_tags_batch, set_tags + constructor takes added options"](https://github.com/smarnach/pyexiftool/pull/5) by [halloleo](https://github.com/halloleo) on Aug 1, 2012 +07/18/2019 04:34:46 AM (PDT) | 0.3.0 | changed the setup.py licensing and updated the version numbering as in changelog
changed the version number scheme, as it appears the "official last release" was 0.2.0 tagged. There's going to be a lot of things broken in this current build, and I'll fix it as they come up. I'm going to start playing with the library and the included tests and such.
There's one more pull request #11 which would be pending, but it duplicates the extra arguments option.
I'm also likely to remove the print conversion as it's now covered by the extra args. I'll also rename some variable names with the addedargs patch
**for my changes (sylikc), I can only guarantee they will work on Python 3.7, because that's my environment... and while I'll try to maintain compatibility, there's no guarantees** + + + + +# Changes around the web + +Check for changes at the following resources to make sure we have the latest and greatest. After all, I'm "unofficially forked" here offline. I intend to publish the changes once I get it into a working state for my DV Suite + +(last checked 7/17/2019 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 https://github.com/blurstudio/pyexiftool/tree/shell-option (#27) +check for updates https://github.com/RootLUG/pyexiftool (#27) +check for updates https://pypi.org/project/PyExifTool/0.1.1/#files (#15) +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/setup.py b/setup.py index 90ef3b4..1409e4f 100644 --- a/setup.py +++ b/setup.py @@ -17,10 +17,10 @@ from distutils.core import setup setup( name="PyExifTool", - version="0.1", + version="0.3.0", description="Python wrapper for exiftool", - license="GPLv3+", - author="Sven Marnach", + license="GPLv3+/BSD", + author="Sven Marnach + various contributors", author_email="sven@marnach.net", url="http://github.com/smarnach/pyexiftool", classifiers=[ From aead19385102e3fccdc0176b6fd2178ae18c2e54 Mon Sep 17 00:00:00 2001 From: SylikC <1364494+sylikc@users.noreply.github.com> Date: Thu, 18 Jul 2019 05:08:15 -0700 Subject: [PATCH 014/251] minor change to the variable names to make it more readable --- CHANGELOG.md | 2 +- exiftool.py | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4318ca3..78b25e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ Date (Timezone) | Version | Comment 07/18/2019 04:05:36 AM (PDT) | 0.1.8 | [Fallback to latin if utf-8 decode fails in pyexiftool.py](https://github.com/jmathai/elodie/commit/fe70227c7170e01c8377de7f9770e761eab52036#diff-f9cf0f3eed27e85c9c9469d0e0d431d5) by [jmathai](https://github.com/jmathai/elodie/commits?author=jmathai) on Sep 7, 2016 07/18/2019 04:14:32 AM (PDT) | 0.1.9 | Merge the test cases from the [Pull request #5 "add set_tags_batch, set_tags + constructor takes added options"](https://github.com/smarnach/pyexiftool/pull/5) by [halloleo](https://github.com/halloleo) on Aug 1, 2012 07/18/2019 04:34:46 AM (PDT) | 0.3.0 | changed the setup.py licensing and updated the version numbering as in changelog
changed the version number scheme, as it appears the "official last release" was 0.2.0 tagged. There's going to be a lot of things broken in this current build, and I'll fix it as they come up. I'm going to start playing with the library and the included tests and such.
There's one more pull request #11 which would be pending, but it duplicates the extra arguments option.
I'm also likely to remove the print conversion as it's now covered by the extra args. I'll also rename some variable names with the addedargs patch
**for my changes (sylikc), I can only guarantee they will work on Python 3.7, because that's my environment... and while I'll try to maintain compatibility, there's no guarantees** - +07/18/2019 05:06:19 AM (PDT) | 0.3.1 | make some minor tweaks to the naming of the extra args variable. The other pull request 11 names them params, and when I decide how to merge that pull request, I'll probably change the variable names again. diff --git a/exiftool.py b/exiftool.py index e453869..be67cf4 100644 --- a/exiftool.py +++ b/exiftool.py @@ -161,7 +161,7 @@ class ExifTool(object): name disables the print conversion for this particular tag. You can pass two arguments to the constructor: - - ``addedargs`` (list of strings): contains additional paramaters for + - ``added_args`` (list of strings): contains additional paramaters for the stay-open instance of exiftool - ``executable`` (string): file name of the ``exiftool`` executable. The default value ``exiftool`` will only work if the executable @@ -196,7 +196,7 @@ class ExifTool(object): associated with a running subprocess. """ - def __init__(self, executable_=None, addedargs=None, win_shell=True, print_conversion=False): + def __init__(self, executable_=None, added_args=None, win_shell=True, print_conversion=False): self.win_shell = win_shell self.print_conversion = print_conversion @@ -207,12 +207,12 @@ def __init__(self, executable_=None, addedargs=None, win_shell=True, print_conve self.executable = executable_ self.running = False - if addedargs is None: - self.addedargs = [] - elif type(addedargs) is list: - self.addedargs = addedargs + if added_args is None: + self.added_args = [] + elif type(added_args) is list: + self.added_args = added_args else: - raise TypeError("addedargs not a list of strings") + raise TypeError("added_args not a list of strings") def start(self): """Start an ``exiftool`` process in batch mode for this instance. @@ -227,13 +227,13 @@ def start(self): warnings.warn("ExifTool already running; doing nothing.") return - procargs = [self.executable, "-stay_open", "True", "-@", "-", "-common_args", "-G"] + proc_args = [self.executable, "-stay_open", "True", "-@", "-", "-common_args", "-G"] # may remove this and just have it added to extra args if not self.print_conversion: - procargs.append("-n") + proc_args.append("-n") - procargs.extend(self.addedargs) - logging.debug(procargs) + proc_args.extend(self.added_args) + logging.debug(proc_args) with open(os.devnull, "w") as devnull: startup_info = subprocess.STARTUPINFO() @@ -244,7 +244,7 @@ def start(self): startup_info.dwFlags |= 11 self._process = subprocess.Popen( - procargs, + proc_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=devnull, startupinfo=startup_info) self.running = True From 074d43cc026cef6f8a0cf9ec742bed27e0905536 Mon Sep 17 00:00:00 2001 From: SylikC <1364494+sylikc@users.noreply.github.com> Date: Thu, 18 Jul 2019 05:08:15 -0700 Subject: [PATCH 015/251] minor change to the variable names for the added args stuff to make it more readable --- CHANGELOG.md | 2 +- exiftool.py | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4318ca3..78b25e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ Date (Timezone) | Version | Comment 07/18/2019 04:05:36 AM (PDT) | 0.1.8 | [Fallback to latin if utf-8 decode fails in pyexiftool.py](https://github.com/jmathai/elodie/commit/fe70227c7170e01c8377de7f9770e761eab52036#diff-f9cf0f3eed27e85c9c9469d0e0d431d5) by [jmathai](https://github.com/jmathai/elodie/commits?author=jmathai) on Sep 7, 2016 07/18/2019 04:14:32 AM (PDT) | 0.1.9 | Merge the test cases from the [Pull request #5 "add set_tags_batch, set_tags + constructor takes added options"](https://github.com/smarnach/pyexiftool/pull/5) by [halloleo](https://github.com/halloleo) on Aug 1, 2012 07/18/2019 04:34:46 AM (PDT) | 0.3.0 | changed the setup.py licensing and updated the version numbering as in changelog
changed the version number scheme, as it appears the "official last release" was 0.2.0 tagged. There's going to be a lot of things broken in this current build, and I'll fix it as they come up. I'm going to start playing with the library and the included tests and such.
There's one more pull request #11 which would be pending, but it duplicates the extra arguments option.
I'm also likely to remove the print conversion as it's now covered by the extra args. I'll also rename some variable names with the addedargs patch
**for my changes (sylikc), I can only guarantee they will work on Python 3.7, because that's my environment... and while I'll try to maintain compatibility, there's no guarantees** - +07/18/2019 05:06:19 AM (PDT) | 0.3.1 | make some minor tweaks to the naming of the extra args variable. The other pull request 11 names them params, and when I decide how to merge that pull request, I'll probably change the variable names again. diff --git a/exiftool.py b/exiftool.py index e453869..be67cf4 100644 --- a/exiftool.py +++ b/exiftool.py @@ -161,7 +161,7 @@ class ExifTool(object): name disables the print conversion for this particular tag. You can pass two arguments to the constructor: - - ``addedargs`` (list of strings): contains additional paramaters for + - ``added_args`` (list of strings): contains additional paramaters for the stay-open instance of exiftool - ``executable`` (string): file name of the ``exiftool`` executable. The default value ``exiftool`` will only work if the executable @@ -196,7 +196,7 @@ class ExifTool(object): associated with a running subprocess. """ - def __init__(self, executable_=None, addedargs=None, win_shell=True, print_conversion=False): + def __init__(self, executable_=None, added_args=None, win_shell=True, print_conversion=False): self.win_shell = win_shell self.print_conversion = print_conversion @@ -207,12 +207,12 @@ def __init__(self, executable_=None, addedargs=None, win_shell=True, print_conve self.executable = executable_ self.running = False - if addedargs is None: - self.addedargs = [] - elif type(addedargs) is list: - self.addedargs = addedargs + if added_args is None: + self.added_args = [] + elif type(added_args) is list: + self.added_args = added_args else: - raise TypeError("addedargs not a list of strings") + raise TypeError("added_args not a list of strings") def start(self): """Start an ``exiftool`` process in batch mode for this instance. @@ -227,13 +227,13 @@ def start(self): warnings.warn("ExifTool already running; doing nothing.") return - procargs = [self.executable, "-stay_open", "True", "-@", "-", "-common_args", "-G"] + proc_args = [self.executable, "-stay_open", "True", "-@", "-", "-common_args", "-G"] # may remove this and just have it added to extra args if not self.print_conversion: - procargs.append("-n") + proc_args.append("-n") - procargs.extend(self.addedargs) - logging.debug(procargs) + proc_args.extend(self.added_args) + logging.debug(proc_args) with open(os.devnull, "w") as devnull: startup_info = subprocess.STARTUPINFO() @@ -244,7 +244,7 @@ def start(self): startup_info.dwFlags |= 11 self._process = subprocess.Popen( - procargs, + proc_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=devnull, startupinfo=startup_info) self.running = True From b8a1ce2622b0606875e9f7ed21e92cc3b6499e00 Mon Sep 17 00:00:00 2001 From: SylikC Date: Fri, 19 Jul 2019 00:02:37 -0700 Subject: [PATCH 016/251] add Testing section in the README because I've never done it before, so it's probably good to document for other people --- README.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.rst b/README.rst index b2c6a64..bb3b1b8 100644 --- a/README.rst +++ b/README.rst @@ -49,6 +49,14 @@ you can call to automatically install that module. +Testing +------------- + +Run tests to make sure it's functional + +:: + python -m unittest -v test/test_exiftool.py + Documentation ------------- From 03a8595a2eafc61ac21deaa1cf5e109c6469b17c Mon Sep 17 00:00:00 2001 From: SylikC Date: Fri, 19 Jul 2019 00:03:01 -0700 Subject: [PATCH 017/251] 0.3.2 fix select() for windows, and now all tests pass --- CHANGELOG.md | 2 +- exiftool.py | 28 ++++++++++++++++++---------- test/test_exiftool.py | 2 +- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78b25e2..63b209b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ Date (Timezone) | Version | Comment 07/18/2019 04:14:32 AM (PDT) | 0.1.9 | Merge the test cases from the [Pull request #5 "add set_tags_batch, set_tags + constructor takes added options"](https://github.com/smarnach/pyexiftool/pull/5) by [halloleo](https://github.com/halloleo) on Aug 1, 2012 07/18/2019 04:34:46 AM (PDT) | 0.3.0 | changed the setup.py licensing and updated the version numbering as in changelog
changed the version number scheme, as it appears the "official last release" was 0.2.0 tagged. There's going to be a lot of things broken in this current build, and I'll fix it as they come up. I'm going to start playing with the library and the included tests and such.
There's one more pull request #11 which would be pending, but it duplicates the extra arguments option.
I'm also likely to remove the print conversion as it's now covered by the extra args. I'll also rename some variable names with the addedargs patch
**for my changes (sylikc), I can only guarantee they will work on Python 3.7, because that's my environment... and while I'll try to maintain compatibility, there's no guarantees** 07/18/2019 05:06:19 AM (PDT) | 0.3.1 | make some minor tweaks to the naming of the extra args variable. The other pull request 11 names them params, and when I decide how to merge that pull request, I'll probably change the variable names again. - +07/19/2019 12:01:22 AM (PTD) | 0.3.2 | fix the select() problem for windows, and fix all tests # Changes around the web diff --git a/exiftool.py b/exiftool.py index be67cf4..ebae68d 100644 --- a/exiftool.py +++ b/exiftool.py @@ -294,18 +294,23 @@ def execute(self, *params): if not self.running: raise ValueError("ExifTool instance not running.") cmd_text = b"\n".join(params + (b"-execute\n",)) - self._process.stdin.write(cmd_text.encode("utf-8")) # a commit reverted this to the original where it's not encoded in UTF-8, will see if there are conflicts later + # 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 + self._process.stdin.write(cmd_text) self._process.stdin.flush() output = b"" fd = self._process.stdout.fileno() while not output[-32:].strip().endswith(sentinel): - #output += os.read(fd, block_size) - - # not sure if this works on windows - inputready,outputready,exceptready = select.select([fd],[],[]) - for i in inputready: - if i == fd: - output += os.read(fd, block_size) + 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)] def execute_json(self, *params): @@ -486,7 +491,8 @@ def set_keywords_batch(self, mode, keywords, filenames): raise TypeError("The argument 'filenames' must be " "an iterable of strings") - params = [] + params = [] + params_utf8 = [] kw_operation = {KW_REPLACE:"-%s=%s", KW_ADD:"-%s+=%s", @@ -497,7 +503,9 @@ def set_keywords_batch(self, mode, keywords, filenames): params.extend(kw_params) params.extend(filenames) logging.debug (params) - return self.execute(*params) + + params_utf8 = [x.encode('utf-8') for x in params] + return self.execute(*params_utf8) def set_keywords(self, mode, keywords, filename): """Modifies the keywords tag for the given file. diff --git a/test/test_exiftool.py b/test/test_exiftool.py index 37094c9..83059c4 100644 --- a/test/test_exiftool.py +++ b/test/test_exiftool.py @@ -10,7 +10,7 @@ class TestExifTool(unittest.TestCase): def setUp(self): - self.et = exiftool.ExifTool(addedargs=["-overwrite_original"]) + self.et = exiftool.ExifTool(added_args=["-overwrite_original"]) def tearDown(self): if hasattr(self, "et"): self.et.terminate() From cc542d2481fee33433ee003dba3fe0b7b815c6f7 Mon Sep 17 00:00:00 2001 From: SylikC Date: Fri, 19 Jul 2019 00:58:56 -0700 Subject: [PATCH 018/251] remove print_conversion enhancement (from pull 25), combined the added_args and common_args into one thing, and also an enhancement on linux https://github.com/smarnach/pyexiftool/pull/11 --- CHANGELOG.md | 3 +- exiftool.py | 101 ++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 77 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63b209b..db0a416 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,8 @@ Date (Timezone) | Version | Comment 07/18/2019 04:14:32 AM (PDT) | 0.1.9 | Merge the test cases from the [Pull request #5 "add set_tags_batch, set_tags + constructor takes added options"](https://github.com/smarnach/pyexiftool/pull/5) by [halloleo](https://github.com/halloleo) on Aug 1, 2012 07/18/2019 04:34:46 AM (PDT) | 0.3.0 | changed the setup.py licensing and updated the version numbering as in changelog
changed the version number scheme, as it appears the "official last release" was 0.2.0 tagged. There's going to be a lot of things broken in this current build, and I'll fix it as they come up. I'm going to start playing with the library and the included tests and such.
There's one more pull request #11 which would be pending, but it duplicates the extra arguments option.
I'm also likely to remove the print conversion as it's now covered by the extra args. I'll also rename some variable names with the addedargs patch
**for my changes (sylikc), I can only guarantee they will work on Python 3.7, because that's my environment... and while I'll try to maintain compatibility, there's no guarantees** 07/18/2019 05:06:19 AM (PDT) | 0.3.1 | make some minor tweaks to the naming of the extra args variable. The other pull request 11 names them params, and when I decide how to merge that pull request, I'll probably change the variable names again. -07/19/2019 12:01:22 AM (PTD) | 0.3.2 | fix the select() problem for windows, and fix all tests +07/19/2019 12:01:22 AM (PDT) | 0.3.2 | fix the select() problem for windows, and fix all tests +07/19/2019 12:54:39 AM (PDT) | 0.3.3 | Merge a piece of [Pull request #11 "Robustness enhancements](https://github.com/smarnach/pyexiftool/pull/11) by [Matthias Kiefer (kiefermat)](https://github.com/kiefermat) on Oct 27, 2014
*On linux call prctl in subprocess to be sure that the exiftool child process is killed even if the parent process is killed by itself*
also removed print_conversion
also merged the common_args and added_args into one args list # Changes around the web diff --git a/exiftool.py b/exiftool.py index ebae68d..3be2e3d 100644 --- a/exiftool.py +++ b/exiftool.py @@ -65,6 +65,10 @@ import logging import codecs +# for the pdeathsig +import signal +import ctypes + try: # Py3k compatibility basestring except NameError: @@ -90,6 +94,9 @@ KW_TAGNAME = "IPTC:Keywords" KW_REPLACE, KW_ADD, KW_REMOVE = range(3) +#------------------------------------------------------------------------------------------------ + + # This code has been adapted from Lib/os.py in the Python source tree # (sha1 265e36e277f3) def _fscodec(): @@ -119,6 +126,27 @@ def fsencode(filename): fsencode = _fscodec() del _fscodec +#------------------------------------------------------------------------------------------------ + +def set_pdeathsig(sig=signal.SIGTERM): + """ + 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": + 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 callable_method + else: + return None + + + #string helper def strip_nl (s): @@ -152,6 +180,10 @@ def format_error (result): else: return 'exiftool finished with error: "%s"' % strip_nl(result) + + + +#------------------------------------------------------------------------------------------------ class ExifTool(object): """Run the `exiftool` command-line tool and communicate to it. @@ -161,7 +193,7 @@ class ExifTool(object): name disables the print conversion for this particular tag. You can pass two arguments to the constructor: - - ``added_args`` (list of strings): contains additional paramaters for + - ``common_args`` (list of strings): contains additional paramaters for the stay-open instance of exiftool - ``executable`` (string): file name of the ``exiftool`` executable. The default value ``exiftool`` will only work if the executable @@ -196,57 +228,74 @@ class ExifTool(object): associated with a running subprocess. """ - def __init__(self, executable_=None, added_args=None, win_shell=True, print_conversion=False): + def __init__(self, executable_=None, common_args=None, win_shell=True): self.win_shell = win_shell - self.print_conversion = print_conversion if executable_ is None: self.executable = executable else: self.executable = executable_ self.running = False - - if added_args is None: - self.added_args = [] - elif type(added_args) is list: - self.added_args = added_args + + self._common_args = common_args + # it can't be none, check if it's a list, if not, error + + self._process = None + + if common_args is None: + # 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 else: - raise TypeError("added_args not a list of strings") + raise TypeError("common_args not a list of strings") + def start(self): """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 started with the ``-G`` (and, + already running. The process is by default started with the ``-G`` (and, if print conversion was disabled, ``-n``) 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 common_args parameter in the constructor. """ if self.running: warnings.warn("ExifTool already running; doing nothing.") return - proc_args = [self.executable, "-stay_open", "True", "-@", "-", "-common_args", "-G"] - # may remove this and just have it added to extra args - if not self.print_conversion: - proc_args.append("-n") + proc_args = [self.executable, "-stay_open", "True", "-@", "-", "-common_args"] + proc_args.extend(self.common_args) # add the common arguments - proc_args.extend(self.added_args) logging.debug(proc_args) + with open(os.devnull, "w") as devnull: - 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) + 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)) + # TODO check error before saying it's running + self.running = True def terminate(self): From 6e4f6dbf784525ad95205c5f8fd8f234f8ccb37e Mon Sep 17 00:00:00 2001 From: SylikC Date: Fri, 19 Jul 2019 01:20:41 -0700 Subject: [PATCH 019/251] turned the pull request 11 additional features into _wrapper functions until I decide what to do with them. Removed a bit of whitespace from end of lines... and merged/changed what needs to, to get it working --- CHANGELOG.md | 2 + exiftool.py | 165 +++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 128 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db0a416..0ff519c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ Date (Timezone) | Version | Comment 07/18/2019 05:06:19 AM (PDT) | 0.3.1 | make some minor tweaks to the naming of the extra args variable. The other pull request 11 names them params, and when I decide how to merge that pull request, I'll probably change the variable names again. 07/19/2019 12:01:22 AM (PDT) | 0.3.2 | fix the select() problem for windows, and fix all tests 07/19/2019 12:54:39 AM (PDT) | 0.3.3 | Merge a piece of [Pull request #11 "Robustness enhancements](https://github.com/smarnach/pyexiftool/pull/11) by [Matthias Kiefer (kiefermat)](https://github.com/kiefermat) on Oct 27, 2014
*On linux call prctl in subprocess to be sure that the exiftool child process is killed even if the parent process is killed by itself*
also removed print_conversion
also merged the common_args and added_args into one args list +07/19/2019 01:18:26 AM (PDT) | 0.3.4 | Merge the rest of Pull request #11. Added the other pieces, however, I added them as "wrappers" instead of modifying the interface of the original code. I feel like the additions here are overly done, and as I understand the code more, I'll either remove it or incorporate it into single functions
from #11 *When getting json results, verify that the results returned by exiftool actually belong to the correct file by checking the SourceFile property of the returned result*
and also *Added possibility to provide different exiftools params for each file separately* + # Changes around the web diff --git a/exiftool.py b/exiftool.py index 3be2e3d..706f51f 100644 --- a/exiftool.py +++ b/exiftool.py @@ -90,7 +90,7 @@ # some cases. block_size = 4096 -# constants related to keywords manipulations +# constants related to keywords manipulations KW_TAGNAME = "IPTC:Keywords" KW_REPLACE, KW_ADD, KW_REMOVE = range(3) @@ -158,27 +158,27 @@ def strip_nl (s): # 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: + 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) + return 'exiftool finished with error: "%s"' % strip_nl(result) @@ -229,19 +229,19 @@ class ExifTool(object): """ def __init__(self, executable_=None, common_args=None, win_shell=True): - + self.win_shell = win_shell - + if executable_ is None: self.executable = executable else: self.executable = executable_ self.running = False - + self._common_args = common_args # it can't be none, check if it's a list, if not, error - - self._process = None + + self._process = None if common_args is None: # default parameters to exiftool @@ -257,23 +257,23 @@ def start(self): """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`` (and, - if print conversion was disabled, ``-n``) as common arguments, - which are automatically included in every command you run with + already running. The process is by default started with the ``-G`` (and, + if print conversion was disabled, ``-n``) 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 common_args parameter in the constructor. """ if self.running: warnings.warn("ExifTool already running; doing nothing.") return - + proc_args = [self.executable, "-stay_open", "True", "-@", "-", "-common_args"] proc_args.extend(self.common_args) # add the common arguments - - logging.debug(proc_args) - - + + logging.debug(proc_args) + + with open(os.devnull, "w") as devnull: if sys.platform == 'win32': startup_info = subprocess.STARTUPINFO() @@ -282,7 +282,7 @@ def start(self): # 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, @@ -295,7 +295,7 @@ def start(self): stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=devnull, preexec_fn=set_pdeathsig(signal.SIGTERM)) # TODO check error before saying it's running - + self.running = True def terminate(self): @@ -362,6 +362,44 @@ def execute(self, *params): 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, 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 + + + def execute_json(self, *params): """Execute the given batch of parameters and parse the JSON output. @@ -394,6 +432,10 @@ def execute_json(self, *params): except UnicodeDecodeError as e: return json.loads(self.execute(b"-j", *params).decode("latin-1")) + # allows adding additional checks (#11) + def get_metadata_batch_wrapper(self, filenames, params=None): + return self.execute_json_wrapper(filenames=filenames, params=params) + def get_metadata_batch(self, filenames): """Return all meta-data for the given files. @@ -402,6 +444,10 @@ def get_metadata_batch(self, filenames): """ return self.execute_json(*filenames) + # (#11) + def get_metadata_wrapper(self, filename, params=None): + return self.execute_json_wrapper(filenames=[filename], params=params)[0] + def get_metadata(self, filename): """Return meta-data for a single file. @@ -410,6 +456,11 @@ def get_metadata(self, filename): """ return self.execute_json(filename)[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. @@ -433,6 +484,10 @@ def get_tags_batch(self, tags, filenames): 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. @@ -441,6 +496,16 @@ def get_tags(self, tags, filename): """ 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. @@ -459,6 +524,10 @@ def get_tag_batch(self, tag, filenames): 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. @@ -482,7 +551,7 @@ def set_tags_batch(self, tags, filenames): 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 @@ -493,12 +562,12 @@ def set_tags_batch(self, tags, filenames): if isinstance(filenames, basestring): raise TypeError("The argument 'filenames' must be " "an iterable of strings") - + params = [] params_utf8 = [] for tag, value in tags.items(): 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) @@ -508,27 +577,27 @@ def set_tags(self, tags, filename): 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. + as a string. """ 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. + KW_ADD: Add `keywords` to the keywords tag. If a keyword is present, just keep it. - KW_REMOVE: Remove `keywords` from the keywords tag. + 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 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 @@ -539,28 +608,46 @@ def set_keywords_batch(self, mode, keywords, filenames): 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] kw_params = [ kw_operation % (KW_TAGNAME, w) for w in keywords ] - - params.extend(kw_params) + + params.extend(kw_params) params.extend(filenames) logging.debug (params) - + params_utf8 = [x.encode('utf-8') for x in params] return self.execute(*params_utf8) - + def set_keywords(self, mode, keywords, filename): """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. + as a string. """ return self.set_keywords_batch(mode, keywords, [filename]) + + + + @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)) From 7ee4a07e8497202fded1f401041521925bf553bb Mon Sep 17 00:00:00 2001 From: SylikC Date: Fri, 19 Jul 2019 01:23:31 -0700 Subject: [PATCH 020/251] fixed the tests so they pass again --- CHANGELOG.md | 1 + exiftool.py | 2 +- test/test_exiftool.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ff519c..09ea97e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Date (Timezone) | Version | Comment 07/19/2019 12:01:22 AM (PDT) | 0.3.2 | fix the select() problem for windows, and fix all tests 07/19/2019 12:54:39 AM (PDT) | 0.3.3 | Merge a piece of [Pull request #11 "Robustness enhancements](https://github.com/smarnach/pyexiftool/pull/11) by [Matthias Kiefer (kiefermat)](https://github.com/kiefermat) on Oct 27, 2014
*On linux call prctl in subprocess to be sure that the exiftool child process is killed even if the parent process is killed by itself*
also removed print_conversion
also merged the common_args and added_args into one args list 07/19/2019 01:18:26 AM (PDT) | 0.3.4 | Merge the rest of Pull request #11. Added the other pieces, however, I added them as "wrappers" instead of modifying the interface of the original code. I feel like the additions here are overly done, and as I understand the code more, I'll either remove it or incorporate it into single functions
from #11 *When getting json results, verify that the results returned by exiftool actually belong to the correct file by checking the SourceFile property of the returned result*
and also *Added possibility to provide different exiftools params for each file separately* +07/19/2019 01:22:48 AM (PDT) | 0.3.5 | changed a bit of the test_exiftool so all the tests pass again diff --git a/exiftool.py b/exiftool.py index 706f51f..41bfda0 100644 --- a/exiftool.py +++ b/exiftool.py @@ -381,7 +381,7 @@ def execute_json_wrapper(self, filenames, params=None, retry_on_error=True): if result: try: ExifTool._check_sanity_of_result(filenames, result) - except IOError, error: + except (IOError, error): # Restart the exiftool child process in these cases since something is going wrong self.terminate() self.start() diff --git a/test/test_exiftool.py b/test/test_exiftool.py index 83059c4..46e1132 100644 --- a/test/test_exiftool.py +++ b/test/test_exiftool.py @@ -10,7 +10,7 @@ class TestExifTool(unittest.TestCase): def setUp(self): - self.et = exiftool.ExifTool(added_args=["-overwrite_original"]) + self.et = exiftool.ExifTool(common_args=["-G", "-n", "-overwrite_original"]) def tearDown(self): if hasattr(self, "et"): self.et.terminate() From 114b648a455a737531911593b0a74c3dbb1c4f54 Mon Sep 17 00:00:00 2001 From: SylikC Date: Fri, 19 Jul 2019 01:31:41 -0700 Subject: [PATCH 021/251] added a test for parameter input, fixed readme rst --- README.rst | 1 + test/test_exiftool.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/README.rst b/README.rst index bb3b1b8..0a1d9c1 100644 --- a/README.rst +++ b/README.rst @@ -55,6 +55,7 @@ Testing Run tests to make sure it's functional :: + python -m unittest -v test/test_exiftool.py Documentation diff --git a/test/test_exiftool.py b/test/test_exiftool.py index 46e1132..d4f9d7e 100644 --- a/test/test_exiftool.py +++ b/test/test_exiftool.py @@ -9,6 +9,8 @@ import shutil class TestExifTool(unittest.TestCase): + + #--------------------------------------------------------------------------------------------------------- def setUp(self): self.et = exiftool.ExifTool(common_args=["-G", "-n", "-overwrite_original"]) def tearDown(self): @@ -17,6 +19,7 @@ def tearDown(self): if hasattr(self, "process"): if self.process.poll() is None: self.process.terminate() + #--------------------------------------------------------------------------------------------------------- def test_termination_cm(self): # Test correct subprocess start and termination when using # self.et as a context manager @@ -32,6 +35,7 @@ def test_termination_cm(self): 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() @@ -40,12 +44,19 @@ def test_termination_explicit(self): self.assertEqual(self.process.poll(), None) self.et.terminate() self.assertNotEqual(self.process.poll(), None) + #--------------------------------------------------------------------------------------------------------- def test_termination_implicit(self): # Test implicit process termination on garbage collection self.et.start() self.process = self.et._process del self.et self.assertNotEqual(self.process.poll(), None) + #--------------------------------------------------------------------------------------------------------- + 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", @@ -83,6 +94,7 @@ def test_get_metadata(self): for k in ["SourceFile", "XMP:Subject"])) self.assertEqual(tag0, "Röschen") + #--------------------------------------------------------------------------------------------------------- def test_set_metadata(self): mod_prefix = "newcap_" expected_data = [{"SourceFile": "rose.jpg", @@ -104,6 +116,7 @@ def test_set_metadata(self): os.remove(f_mod) self.assertEqual(tag0, d["Caption-Abstract"]) + #--------------------------------------------------------------------------------------------------------- def test_set_keywords(self): kw_to_add = ["added"] mod_prefix = "newkw_" @@ -131,5 +144,8 @@ def test_set_keywords(self): self.assertEqual(kwtag1, d["Keywords"][0]) self.assertEqual(kwtag2, [d["Keywords"][0]] + kw_to_add) + + +#--------------------------------------------------------------------------------------------------------- if __name__ == '__main__': unittest.main() From 4dbd9c2969a5b395f6fe9d417722a1ca3dfaf78c Mon Sep 17 00:00:00 2001 From: SylikC Date: Fri, 19 Jul 2019 10:39:27 -0700 Subject: [PATCH 022/251] remove whitespace from end of lines --- test/test_exiftool.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/test_exiftool.py b/test/test_exiftool.py index d4f9d7e..d86275a 100644 --- a/test/test_exiftool.py +++ b/test/test_exiftool.py @@ -106,12 +106,12 @@ def test_set_metadata(self): 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)) + 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) + 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"]) @@ -127,21 +127,21 @@ def test_set_keywords(self): 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)) + 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) + 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) + 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) + 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(kwtag1, d["Keywords"][0]) self.assertEqual(kwtag2, [d["Keywords"][0]] + kw_to_add) From 97f4a3178393a3d173da79982f1ff9121e0e9992 Mon Sep 17 00:00:00 2001 From: SylikC Date: Tue, 23 Jul 2019 03:53:30 -0700 Subject: [PATCH 023/251] add find_executable() code from github with a public domain license. And then write a test case to test it... --- exiftool.py | 62 ++++++++++++++++++++++++++++++++++++++++--- test/test_exiftool.py | 23 ++++++++++++++++ 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/exiftool.py b/exiftool.py index 41bfda0..89e8ead 100644 --- a/exiftool.py +++ b/exiftool.py @@ -183,6 +183,54 @@ def format_error (result): +#------------------------------------------------------------------------------------------------ + + +# https://gist.github.com/techtonik/4368898 +# Public domain code by anatoly techtonik +# 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. @@ -236,6 +284,10 @@ def __init__(self, executable_=None, common_args=None, win_shell=True): self.executable = executable else: self.executable = executable_ + + if find_executable(self.executable) is None: + raise FileNotFoundError + self.running = False self._common_args = common_args @@ -257,12 +309,13 @@ def start(self): """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`` (and, - if print conversion was disabled, ``-n``) as common arguments, + 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 common_args parameter in the constructor. + However, you can override these default arguments with the + ``common_args`` parameter in the constructor. """ if self.running: warnings.warn("ExifTool already running; doing nothing.") @@ -273,7 +326,6 @@ def start(self): logging.debug(proc_args) - with open(os.devnull, "w") as devnull: if sys.platform == 'win32': startup_info = subprocess.STARTUPINFO() @@ -294,6 +346,8 @@ def start(self): 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 # TODO check error before saying it's running self.running = True diff --git a/test/test_exiftool.py b/test/test_exiftool.py index d86275a..ab62fa5 100644 --- a/test/test_exiftool.py +++ b/test/test_exiftool.py @@ -7,6 +7,7 @@ import warnings import os import shutil +import sys class TestExifTool(unittest.TestCase): @@ -145,6 +146,28 @@ def test_set_keywords(self): 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:\\" + test_exec = "exiftool.exe" + else: + test_path = "/" + test_exec = "exiftool" + + # 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 #--------------------------------------------------------------------------------------------------------- if __name__ == '__main__': From 7b0fbfb8e5a48a5a85dfa24022db47c91bad0246 Mon Sep 17 00:00:00 2001 From: SylikC Date: Tue, 23 Jul 2019 04:15:47 -0700 Subject: [PATCH 024/251] make it a bit more robust with more error checking, and the win32/linux executable naming differences, to make it explicit --- exiftool.py | 86 ++++++++++++++++++++++++++++--------------- test/test_exiftool.py | 4 +- 2 files changed, 58 insertions(+), 32 deletions(-) diff --git a/exiftool.py b/exiftool.py index 89e8ead..febfd40 100644 --- a/exiftool.py +++ b/exiftool.py @@ -74,7 +74,16 @@ except NameError: basestring = (bytes, str) -executable = "exiftool" + + + + + +# 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 @@ -281,12 +290,13 @@ def __init__(self, executable_=None, common_args=None, win_shell=True): self.win_shell = win_shell if executable_ is None: - self.executable = executable + self.executable = DEFAULT_EXECUTABLE else: self.executable = executable_ + # error checking if find_executable(self.executable) is None: - raise FileNotFoundError + raise FileNotFoundError( '"{}" is not found, on path or as absolute path'.format(self.executable) ) self.running = False @@ -321,38 +331,47 @@ def start(self): warnings.warn("ExifTool already running; doing nothing.") return - proc_args = [self.executable, "-stay_open", "True", "-@", "-", "-common_args"] + proc_args = [self.executable+'e', "-stay_open", "True", "-@", "-", "-common_args"] proc_args.extend(self.common_args) # add the common arguments logging.debug(proc_args) - + with open(os.devnull, "w") as devnull: - 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 - # TODO check error before saying it's running - + 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 + + # check error above before saying it's running self.running = True - def terminate(self): + def terminate(self, wait_timeout=30): """Terminate the ``exiftool`` process of this instance. If the subprocess isn't running, this method will do nothing. @@ -361,7 +380,13 @@ def terminate(self): return self._process.stdin.write(b"-stay_open\nFalse\n") self._process.stdin.flush() - self._process.communicate() + try: + self._process.communicate(timeout=wait_timeout) + except subprocess.TimeoutExpired: + self._process.kill() + outs, errs = proc.communicate() + # err handling code from https://docs.python.org/3/library/subprocess.html#subprocess.Popen.communicate + del self._process self.running = False @@ -396,6 +421,7 @@ def execute(self, *params): """ 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 diff --git a/test/test_exiftool.py b/test/test_exiftool.py index ab62fa5..73c259e 100644 --- a/test/test_exiftool.py +++ b/test/test_exiftool.py @@ -153,10 +153,10 @@ def test_executable_found(self): if sys.platform == 'win32': test_path = "C:\\" - test_exec = "exiftool.exe" else: test_path = "/" - test_exec = "exiftool" + + test_exec = exiftool.DEFAULT_EXECUTABLE # should be found in path as is self.assertTrue(exiftool.find_executable(test_exec, path=None)) From adb946b601ff551cd9de26c81685af71a160cd7e Mon Sep 17 00:00:00 2001 From: Kolen Cheung Date: Tue, 24 Dec 2019 15:19:14 -0800 Subject: [PATCH 025/251] exiftool.py: use ujson when it exists --- exiftool.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/exiftool.py b/exiftool.py index febfd40..5cdcfde 100644 --- a/exiftool.py +++ b/exiftool.py @@ -60,7 +60,10 @@ import sys import subprocess import os -import json +try: + import ujson as json +except ImportError: + import json import warnings import logging import codecs From da6d084effd9b3231e96c18d4d215d64b74bfab3 Mon Sep 17 00:00:00 2001 From: Kolen Cheung Date: Fri, 27 Dec 2019 19:56:16 -0800 Subject: [PATCH 026/251] exiftool.py: support the case where '-w' is in common_args --- exiftool.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/exiftool.py b/exiftool.py index febfd40..04b74d5 100644 --- a/exiftool.py +++ b/exiftool.py @@ -314,6 +314,8 @@ def __init__(self, executable_=None, common_args=None, win_shell=True): else: raise TypeError("common_args not a list of strings") + self.no_output = '-w' in common_args + def start(self): """Start an ``exiftool`` process in batch mode for this instance. @@ -507,10 +509,16 @@ def execute_json(self, *params): # Try utf-8 and fallback to latin. # http://stackoverflow.com/a/5552623/1318758 # https://github.com/jmathai/elodie/issues/127 - try: - return json.loads(self.execute(b"-j", *params).decode("utf-8")) - except UnicodeDecodeError as e: - return json.loads(self.execute(b"-j", *params).decode("latin-1")) + res = self.execute(b"-j", *params) + # res can be invalid json if `-w` flag is specified in common_args + # which will return something like + # image files read + # output files created + if not self.no_output: + try: + return json.loads(res.decode("utf-8")) + except UnicodeDecodeError as e: + return json.loads(res.decode("latin-1")) # allows adding additional checks (#11) def get_metadata_batch_wrapper(self, filenames, params=None): From 13c69a93f4d26b8982add21ab7b7ebeb5a0ee865 Mon Sep 17 00:00:00 2001 From: Kolen Cheung Date: Fri, 27 Dec 2019 20:00:37 -0800 Subject: [PATCH 027/251] exiftool.py: print to stdout when '-w' is specified --- exiftool.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/exiftool.py b/exiftool.py index 04b74d5..93dbd99 100644 --- a/exiftool.py +++ b/exiftool.py @@ -514,7 +514,9 @@ def execute_json(self, *params): # which will return something like # image files read # output files created - if not self.no_output: + if self.no_output: + print(res) + else: try: return json.loads(res.decode("utf-8")) except UnicodeDecodeError as e: From 9a79c50d22791f019f5afcf9a8958c9333916243 Mon Sep 17 00:00:00 2001 From: Kolen Cheung Date: Fri, 27 Dec 2019 20:18:53 -0800 Subject: [PATCH 028/251] exiftool.py: decode before printing --- exiftool.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/exiftool.py b/exiftool.py index 93dbd99..c2e7cc5 100644 --- a/exiftool.py +++ b/exiftool.py @@ -510,17 +510,18 @@ def execute_json(self, *params): # http://stackoverflow.com/a/5552623/1318758 # https://github.com/jmathai/elodie/issues/127 res = self.execute(b"-j", *params) - # res can be invalid json if `-w` flag is specified in common_args + 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) + print(res_decoded) else: - try: - return json.loads(res.decode("utf-8")) - except UnicodeDecodeError as e: - return json.loads(res.decode("latin-1")) + return json.loads(res_decoded) # allows adding additional checks (#11) def get_metadata_batch_wrapper(self, filenames, params=None): From f44e00bc645823cbb2bb56f6a0015501b3df743c Mon Sep 17 00:00:00 2001 From: SylikC Date: Sat, 4 Jan 2020 11:58:37 -0800 Subject: [PATCH 029/251] remove the debug (looks like debug) code that was causing the tests to fail. Updated the tests to work with the latest output of exiftool --- exiftool.py | 2 +- test/test_exiftool.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/exiftool.py b/exiftool.py index febfd40..8d561bb 100644 --- a/exiftool.py +++ b/exiftool.py @@ -331,7 +331,7 @@ def start(self): warnings.warn("ExifTool already running; doing nothing.") return - proc_args = [self.executable+'e', "-stay_open", "True", "-@", "-", "-common_args"] + proc_args = [self.executable, "-stay_open", "True", "-@", "-", "-common_args"] proc_args.extend(self.common_args) # add the common arguments logging.debug(proc_args) diff --git a/test/test_exiftool.py b/test/test_exiftool.py index 73c259e..a9de328 100644 --- a/test/test_exiftool.py +++ b/test/test_exiftool.py @@ -64,12 +64,12 @@ def test_get_metadata(self): "File:ImageWidth": 70, "File:ImageHeight": 46, "XMP:Subject": "Röschen", - "Composite:ImageSize": "70x46"}, + "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": "64x64"}] + "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: From de22a9624c4ff256a05616fbded54494af86b61e Mon Sep 17 00:00:00 2001 From: SylikC Date: Sat, 4 Jan 2020 12:20:58 -0800 Subject: [PATCH 030/251] 0.4.0 rename to pyexiftool and make sure tests pass --- CHANGELOG.md | 3 ++- exiftool.py => pyexiftool.py | 0 test/{test_exiftool.py => test_pyexiftool.py} | 20 +++++++++---------- 3 files changed, 12 insertions(+), 11 deletions(-) rename exiftool.py => pyexiftool.py (100%) rename test/{test_exiftool.py => test_pyexiftool.py} (91%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09ea97e..1a94e68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,8 @@ Date (Timezone) | Version | Comment 07/19/2019 12:54:39 AM (PDT) | 0.3.3 | Merge a piece of [Pull request #11 "Robustness enhancements](https://github.com/smarnach/pyexiftool/pull/11) by [Matthias Kiefer (kiefermat)](https://github.com/kiefermat) on Oct 27, 2014
*On linux call prctl in subprocess to be sure that the exiftool child process is killed even if the parent process is killed by itself*
also removed print_conversion
also merged the common_args and added_args into one args list 07/19/2019 01:18:26 AM (PDT) | 0.3.4 | Merge the rest of Pull request #11. Added the other pieces, however, I added them as "wrappers" instead of modifying the interface of the original code. I feel like the additions here are overly done, and as I understand the code more, I'll either remove it or incorporate it into single functions
from #11 *When getting json results, verify that the results returned by exiftool actually belong to the correct file by checking the SourceFile property of the returned result*
and also *Added possibility to provide different exiftools params for each file separately* 07/19/2019 01:22:48 AM (PDT) | 0.3.5 | changed a bit of the test_exiftool so all the tests pass again - +01/04/2020 11:59:14 AM (PST) | 0.3.6 | made the tests work with the latest output of ExifTool. This is the final version which is named "exiftool" +01/04/2020 12:16:51 PM (PST) | 0.4.0 | pyexiftool rename (and make all tests work again) ... I also think that the pyexiftool.py has gotten too big. I'll probably break it out into a directory structure later to make it more maintainable # Changes around the web diff --git a/exiftool.py b/pyexiftool.py similarity index 100% rename from exiftool.py rename to pyexiftool.py diff --git a/test/test_exiftool.py b/test/test_pyexiftool.py similarity index 91% rename from test/test_exiftool.py rename to test/test_pyexiftool.py index a9de328..748d26b 100644 --- a/test/test_exiftool.py +++ b/test/test_pyexiftool.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import unittest -import exiftool +import pyexiftool import warnings import os import shutil @@ -13,7 +13,7 @@ class TestExifTool(unittest.TestCase): #--------------------------------------------------------------------------------------------------------- def setUp(self): - self.et = exiftool.ExifTool(common_args=["-G", "-n", "-overwrite_original"]) + self.et = pyexiftool.ExifTool(common_args=["-G", "-n", "-overwrite_original"]) def tearDown(self): if hasattr(self, "et"): self.et.terminate() @@ -56,7 +56,7 @@ def test_termination_implicit(self): 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") + pyexiftool.ExifTool(common_args="not a list") #--------------------------------------------------------------------------------------------------------- def test_get_metadata(self): expected_data = [{"SourceFile": "rose.jpg", @@ -133,12 +133,12 @@ def test_set_keywords(self): shutil.copyfile(f, f_mod) source_files.append(f_mod) with self.et: - self.et.set_keywords(exiftool.KW_REPLACE, d["Keywords"], f_mod) + self.et.set_keywords(pyexiftool.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) + self.et.set_keywords(pyexiftool.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) + self.et.set_keywords(pyexiftool.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"]) @@ -156,15 +156,15 @@ def test_executable_found(self): else: test_path = "/" - test_exec = exiftool.DEFAULT_EXECUTABLE + test_exec = pyexiftool.DEFAULT_EXECUTABLE # should be found in path as is - self.assertTrue(exiftool.find_executable(test_exec, path=None)) + self.assertTrue(pyexiftool.find_executable(test_exec, path=None)) # modify path and search again - self.assertFalse(exiftool.find_executable(test_exec, path=test_path)) + self.assertFalse(pyexiftool.find_executable(test_exec, path=test_path)) os.environ['PATH'] = test_path - self.assertFalse(exiftool.find_executable(test_exec, path=None)) + self.assertFalse(pyexiftool.find_executable(test_exec, path=None)) # restore it os.environ['PATH'] = save_sys_path From 79c17cd9700f33435641cf8f157aabc3c1997a95 Mon Sep 17 00:00:00 2001 From: SylikC Date: Sun, 2 Feb 2020 02:52:38 -0800 Subject: [PATCH 031/251] version bump because of the merged PRs --- CHANGELOG.md | 2 ++ setup.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a94e68..964a103 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,9 @@ Date (Timezone) | Version | Comment 07/19/2019 01:22:48 AM (PDT) | 0.3.5 | changed a bit of the test_exiftool so all the tests pass again 01/04/2020 11:59:14 AM (PST) | 0.3.6 | made the tests work with the latest output of ExifTool. This is the final version which is named "exiftool" 01/04/2020 12:16:51 PM (PST) | 0.4.0 | pyexiftool rename (and make all tests work again) ... I also think that the pyexiftool.py has gotten too big. I'll probably break it out into a directory structure later to make it more maintainable +02/01/2020 05:09:43 PM (PST) | 0.4.1 | incorporated pull request #2 and #3 by ickc which added a "no_output" feature and an import for ujson if it's installed. Thanks for the updates! +On version changes, update setup.py to reflect version # Changes around the web diff --git a/setup.py b/setup.py index 1409e4f..bce7112 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ from distutils.core import setup setup( name="PyExifTool", - version="0.3.0", + version="0.4.1", description="Python wrapper for exiftool", license="GPLv3+/BSD", author="Sven Marnach + various contributors", From 58eaf2af4a764c202802dd28bcc1484eeb9cbdac Mon Sep 17 00:00:00 2001 From: Seth Parker Date: Fri, 7 Feb 2020 10:24:42 -0500 Subject: [PATCH 032/251] Fix NoneType error in common args check --- pyexiftool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyexiftool.py b/pyexiftool.py index 0b2cc62..b80270b 100644 --- a/pyexiftool.py +++ b/pyexiftool.py @@ -317,7 +317,7 @@ def __init__(self, executable_=None, common_args=None, win_shell=True): else: raise TypeError("common_args not a list of strings") - self.no_output = '-w' in common_args + self.no_output = '-w' in self.common_args def start(self): From 8eabe6c4a8804477d685decc700c43a5c44e10e7 Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 9 Apr 2020 04:29:15 -0700 Subject: [PATCH 033/251] Revert "0.4.0 rename to pyexiftool and make sure tests pass" This reverts commit de22a9624c4ff256a05616fbded54494af86b61e. This reverts the rename of "exiftool" to "pyexiftool" at the suggestion of the community. The exiftool name will remain for all of eternity. --- CHANGELOG.md | 2 ++ pyexiftool.py => exiftool.py | 0 test/{test_pyexiftool.py => test_exiftool.py} | 20 +++++++++---------- 3 files changed, 12 insertions(+), 10 deletions(-) rename pyexiftool.py => exiftool.py (100%) rename test/{test_pyexiftool.py => test_exiftool.py} (91%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 964a103..6af0a43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ Date (Timezone) | Version | Comment 01/04/2020 11:59:14 AM (PST) | 0.3.6 | made the tests work with the latest output of ExifTool. This is the final version which is named "exiftool" 01/04/2020 12:16:51 PM (PST) | 0.4.0 | pyexiftool rename (and make all tests work again) ... I also think that the pyexiftool.py has gotten too big. I'll probably break it out into a directory structure later to make it more maintainable 02/01/2020 05:09:43 PM (PST) | 0.4.1 | incorporated pull request #2 and #3 by ickc which added a "no_output" feature and an import for ujson if it's installed. Thanks for the updates! +04/09/2020 04:25:31 AM (PDT) | 0.4.2 | rool back 0.4.0's pyexiftool rename. It appears there's no specific PEP to have to to name PyPI projects to be py. The only convention I found was https://www.python.org/dev/peps/pep-0423/#use-standard-pattern-for-community-contributions which I might look at in more detail + On version changes, update setup.py to reflect version diff --git a/pyexiftool.py b/exiftool.py similarity index 100% rename from pyexiftool.py rename to exiftool.py diff --git a/test/test_pyexiftool.py b/test/test_exiftool.py similarity index 91% rename from test/test_pyexiftool.py rename to test/test_exiftool.py index 748d26b..a9de328 100644 --- a/test/test_pyexiftool.py +++ b/test/test_exiftool.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import unittest -import pyexiftool +import exiftool import warnings import os import shutil @@ -13,7 +13,7 @@ class TestExifTool(unittest.TestCase): #--------------------------------------------------------------------------------------------------------- def setUp(self): - self.et = pyexiftool.ExifTool(common_args=["-G", "-n", "-overwrite_original"]) + self.et = exiftool.ExifTool(common_args=["-G", "-n", "-overwrite_original"]) def tearDown(self): if hasattr(self, "et"): self.et.terminate() @@ -56,7 +56,7 @@ def test_termination_implicit(self): 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): - pyexiftool.ExifTool(common_args="not a list") + exiftool.ExifTool(common_args="not a list") #--------------------------------------------------------------------------------------------------------- def test_get_metadata(self): expected_data = [{"SourceFile": "rose.jpg", @@ -133,12 +133,12 @@ def test_set_keywords(self): shutil.copyfile(f, f_mod) source_files.append(f_mod) with self.et: - self.et.set_keywords(pyexiftool.KW_REPLACE, d["Keywords"], f_mod) + 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(pyexiftool.KW_REMOVE, kwrest, f_mod) + self.et.set_keywords(exiftool.KW_REMOVE, kwrest, f_mod) kwtag1 = self.et.get_tag("IPTC:Keywords", f_mod) - self.et.set_keywords(pyexiftool.KW_ADD, kw_to_add, 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"]) @@ -156,15 +156,15 @@ def test_executable_found(self): else: test_path = "/" - test_exec = pyexiftool.DEFAULT_EXECUTABLE + test_exec = exiftool.DEFAULT_EXECUTABLE # should be found in path as is - self.assertTrue(pyexiftool.find_executable(test_exec, path=None)) + self.assertTrue(exiftool.find_executable(test_exec, path=None)) # modify path and search again - self.assertFalse(pyexiftool.find_executable(test_exec, path=test_path)) + self.assertFalse(exiftool.find_executable(test_exec, path=test_path)) os.environ['PATH'] = test_path - self.assertFalse(pyexiftool.find_executable(test_exec, path=None)) + self.assertFalse(exiftool.find_executable(test_exec, path=None)) # restore it os.environ['PATH'] = save_sys_path From e77ef5e4c7092248fdd1ad59f6d43e720a6f0aa2 Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 9 Apr 2020 05:22:07 -0700 Subject: [PATCH 034/251] start the initial legwork of breaking the single exiftool.py into logical chunks in a directory. This should help with code review clearer moving forward. I'm not a big fan of huge source files... The static helper functions (which rarely/should not change) will be isolated from the ExifTool-specific class (tests pass) --- CHANGELOG.md | 3 ++- exiftool/__init__.py | 6 ++++++ exiftool.py => exiftool/exiftool.py | 0 setup.py | 2 +- 4 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 exiftool/__init__.py rename exiftool.py => exiftool/exiftool.py (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6af0a43..b5df743 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,8 @@ Date (Timezone) | Version | Comment 01/04/2020 11:59:14 AM (PST) | 0.3.6 | made the tests work with the latest output of ExifTool. This is the final version which is named "exiftool" 01/04/2020 12:16:51 PM (PST) | 0.4.0 | pyexiftool rename (and make all tests work again) ... I also think that the pyexiftool.py has gotten too big. I'll probably break it out into a directory structure later to make it more maintainable 02/01/2020 05:09:43 PM (PST) | 0.4.1 | incorporated pull request #2 and #3 by ickc which added a "no_output" feature and an import for ujson if it's installed. Thanks for the updates! -04/09/2020 04:25:31 AM (PDT) | 0.4.2 | rool back 0.4.0's pyexiftool rename. It appears there's no specific PEP to have to to name PyPI projects to be py. The only convention I found was https://www.python.org/dev/peps/pep-0423/#use-standard-pattern-for-community-contributions which I might look at in more detail +04/09/2020 04:25:31 AM (PDT) | 0.4.2 | roll back 0.4.0's pyexiftool rename. It appears there's no specific PEP to have to to name PyPI projects to be py. The only convention I found was https://www.python.org/dev/peps/pep-0423/#use-standard-pattern-for-community-contributions which I might look at in more detail +04/09/2020 05:15:40 AM (PDT) | 0.4.3 | initial work of moving the exiftool.py into a directory preparing to break it down into separate files to make the codebase more manageable On version changes, update setup.py to reflect version diff --git a/exiftool/__init__.py b/exiftool/__init__.py new file mode 100644 index 0000000..854ec1b --- /dev/null +++ b/exiftool/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- + +# import as directory + +# make all of the original exiftool stuff available in this namespace +from .exiftool import * diff --git a/exiftool.py b/exiftool/exiftool.py similarity index 100% rename from exiftool.py rename to exiftool/exiftool.py diff --git a/setup.py b/setup.py index bce7112..45164f1 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ from distutils.core import setup setup( name="PyExifTool", - version="0.4.1", + version="0.4.3", description="Python wrapper for exiftool", license="GPLv3+/BSD", author="Sven Marnach + various contributors", From d5971eb5cb8fc0841ed8053f85c3092eb8cffcb2 Mon Sep 17 00:00:00 2001 From: "SylikC (admin)" Date: Sun, 12 Apr 2020 02:29:45 -0700 Subject: [PATCH 035/251] add a comment at this line which uses a Python 3.3+ exclusive feature... so when it crashes here, there's no guessing why --- exiftool/exiftool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index b80270b..fe0e953 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -387,7 +387,7 @@ def terminate(self, wait_timeout=30): self._process.stdin.flush() try: self._process.communicate(timeout=wait_timeout) - except subprocess.TimeoutExpired: + except subprocess.TimeoutExpired: # this is new in Python 3.3 (for python 2.x, use the PyPI subprocess32 module) self._process.kill() outs, errs = proc.communicate() # err handling code from https://docs.python.org/3/library/subprocess.html#subprocess.Popen.communicate From 94e65a504d40d7c3f1d5bbc1561d97ca818a6c2f Mon Sep 17 00:00:00 2001 From: "SylikC (admin)" Date: Sun, 12 Apr 2020 06:06:44 -0700 Subject: [PATCH 036/251] put an extra TODO as we were debugging the code... might also need to check some type of errorlevel from exiftool --- exiftool/exiftool.py | 1 + 1 file changed, 1 insertion(+) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index fe0e953..187c9d2 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -524,6 +524,7 @@ def execute_json(self, *params): 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) From fa7539f3b63a84866bea2eec4bf9a3146181fbd3 Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 4 Jan 2021 19:45:29 -0800 Subject: [PATCH 037/251] Added support for config files as outlined here: https://exiftool.org/config.html Also added an optional params argument to the get_metadata method. This can be used to support output formating flags --- exiftool/exiftool.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index b80270b..9e7eceb 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -288,7 +288,7 @@ class ExifTool(object): associated with a running subprocess. """ - def __init__(self, executable_=None, common_args=None, win_shell=True): + def __init__(self, executable_=None, common_args=None, win_shell=True, config_file=None): self.win_shell = win_shell @@ -306,6 +306,11 @@ def __init__(self, executable_=None, common_args=None, win_shell=True): 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") + + self._config_file = config_file + self._process = None if common_args is None: @@ -336,8 +341,12 @@ def start(self): warnings.warn("ExifTool already running; doing nothing.") return - proc_args = [self.executable, "-stay_open", "True", "-@", "-", "-common_args"] - proc_args.extend(self.common_args) # add the common arguments + 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: + 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 logging.debug(proc_args) @@ -530,25 +539,25 @@ def execute_json(self, *params): def get_metadata_batch_wrapper(self, filenames, params=None): return self.execute_json_wrapper(filenames=filenames, params=params) - def get_metadata_batch(self, filenames): + def get_metadata_batch(self, filenames, params=[]): """Return all meta-data for the given files. The return value will have the format described in the documentation of :py:meth:`execute_json()`. """ - return self.execute_json(*filenames) + 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): + def get_metadata(self, filename, params=[]): """Return meta-data for a single file. The returned dictionary has the format described in the documentation of :py:meth:`execute_json()`. """ - return self.execute_json(filename)[0] + return self.execute_json(filename, *params)[0] # (#11) def get_tags_batch_wrapper(self, tags, filenames, params=None): From 91b81e1f4f9811cb4d75408312a2764130a00c61 Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 4 Jan 2021 19:52:42 -0800 Subject: [PATCH 038/251] Added support for config files as outlined here: https://exiftool.org/config.html Also added an optional params argument to the get_metadata method. This can be used to support output formating flags --- exiftool/exiftool.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 9e7eceb..e9ca636 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -539,24 +539,28 @@ def execute_json(self, *params): def get_metadata_batch_wrapper(self, filenames, params=None): return self.execute_json_wrapper(filenames=filenames, params=params) - def get_metadata_batch(self, filenames, params=[]): + 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=[]): + 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 = None return self.execute_json(filename, *params)[0] # (#11) From cb4d0e5460de06368e7e68f4771299cbcc842826 Mon Sep 17 00:00:00 2001 From: "SylikC (admin)" Date: Fri, 12 Mar 2021 13:43:44 -0800 Subject: [PATCH 039/251] v0.4.4 - ready for release to PyPI --- .gitignore | 2 ++ CHANGELOG.md | 2 +- LICENSE | 14 ++++++++ README.rst | 11 ++++-- exiftool/exiftool.py | 2 ++ setup.py | 82 ++++++++++++++++++++++++++++++++++---------- 6 files changed, 90 insertions(+), 23 deletions(-) create mode 100644 LICENSE diff --git a/.gitignore b/.gitignore index 6830690..8f5a041 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ __pycache__/ build/ dist/ MANIFEST + +*.egg-info/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b5df743..6d740cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ Date (Timezone) | Version | Comment 02/01/2020 05:09:43 PM (PST) | 0.4.1 | incorporated pull request #2 and #3 by ickc which added a "no_output" feature and an import for ujson if it's installed. Thanks for the updates! 04/09/2020 04:25:31 AM (PDT) | 0.4.2 | roll back 0.4.0's pyexiftool rename. It appears there's no specific PEP to have to to name PyPI projects to be py. The only convention I found was https://www.python.org/dev/peps/pep-0423/#use-standard-pattern-for-community-contributions which I might look at in more detail 04/09/2020 05:15:40 AM (PDT) | 0.4.3 | initial work of moving the exiftool.py into a directory preparing to break it down into separate files to make the codebase more manageable - +03/12/2021 01:37:30 PM (PDT) | 0.4.4 | no functional code changes. Revamped the setup.py and related files to release to PyPI. Added all necessary and recommended files into release On version changes, update setup.py to reflect version diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4dff229 --- /dev/null +++ b/LICENSE @@ -0,0 +1,14 @@ +PyExifTool + +Copyright 2012 Sven Marnach, 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 +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. diff --git a/README.rst b/README.rst index 0a1d9c1..ef99dea 100644 --- a/README.rst +++ b/README.rst @@ -20,10 +20,15 @@ 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_. + +Official releases are on PyPI + +:: + https://pypi.org/project/PyExifTool/ -Alternatively, you can download a tarball_. There haven't been any -releases yet. .. _tarball: https://github.com/smarnach/pyexiftool/tarball/master diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 187c9d2..08b111a 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- # PyExifTool # Copyright 2012 Sven Marnach. +# Copyright 2021 Kevin M (sylikc) + # More contributors in the CHANGELOG for the pull requests # This file is part of PyExifTool. diff --git a/setup.py b/setup.py index 45164f1..9e2fcde 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,5 @@ -# PyExifTool -# Copyright 2012 Sven Marnach +# PyExifTool +# Copyright 2012 Sven Marnach, Kevin M (sylikc) # This file is part of PyExifTool. # @@ -14,21 +14,65 @@ # # See COPYING.GPL or COPYING.BSD for more details. -from distutils.core import setup +# this "could" still be used, but not the industry recommended option -- https://stackoverflow.com/questions/25337706/setuptools-vs-distutils-why-is-distutils-still-a-thing +#from distutils.core import setup -setup( name="PyExifTool", - version="0.4.3", - description="Python wrapper for exiftool", - license="GPLv3+/BSD", - author="Sven Marnach + various contributors", - author_email="sven@marnach.net", - url="http://github.com/smarnach/pyexiftool", - classifiers=[ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", - "Programming Language :: Python :: 2.6", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3", - "Topic :: Multimedia"], - py_modules=["exiftool"]) +# recommended packager, though must be installed via PyPI +# https://packaging.python.org/tutorials/packaging-projects/#configuring-metadata +from setuptools import setup, find_packages + +with open("README.rst", "r", encoding="utf-8") as fh: + long_desc = fh.read() + +setup( + # detailed list of options: + # https://packaging.python.org/guides/distributing-packages-using-setuptools/ + + # overview + name="PyExifTool", + version="0.4.4", + license="GPLv3+/BSD", + url="http://github.com/sylikc/pyexiftool", + python_requires=">=2.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": "http://smarnach.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", + + "Topic :: Multimedia", + "Topic :: Utilities", + ], + packages=find_packages(where="."), + + #py_modules=["exiftool"], - it is now the exiftool module +) From 5d37a913fc13e240447b524d3648f68ea448f7e7 Mon Sep 17 00:00:00 2001 From: "SylikC (admin)" Date: Fri, 12 Mar 2021 13:50:44 -0800 Subject: [PATCH 040/251] fix formatting error in README.rst ... I'm new to a non .md format! --- README.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.rst b/README.rst index ef99dea..9c84784 100644 --- a/README.rst +++ b/README.rst @@ -27,6 +27,7 @@ Alternatively, you can download a tarball_. Official releases are on PyPI :: + https://pypi.org/project/PyExifTool/ @@ -48,6 +49,9 @@ 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 +(It is being slowly re-factored to a module to keep exiftool.py from +growing astronomically) + :: python setup.py install [--user|--prefix= Date: Fri, 12 Mar 2021 17:10:01 -0800 Subject: [PATCH 041/251] v0.4.5 more setup changes to get PyPI package in order --- CHANGELOG.md | 1 + README.rst | 2 +- setup.py | 9 +++++++-- {test => tests}/__init__.py | 0 {test => tests}/rose.jpg | Bin {test => tests}/skyblue.png | Bin {test => tests}/test_exiftool.py | 0 7 files changed, 9 insertions(+), 3 deletions(-) rename {test => tests}/__init__.py (100%) rename {test => tests}/rose.jpg (100%) rename {test => tests}/skyblue.png (100%) rename {test => tests}/test_exiftool.py (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d740cb..843f58c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Date (Timezone) | Version | Comment 04/09/2020 04:25:31 AM (PDT) | 0.4.2 | roll back 0.4.0's pyexiftool rename. It appears there's no specific PEP to have to to name PyPI projects to be py. The only convention I found was https://www.python.org/dev/peps/pep-0423/#use-standard-pattern-for-community-contributions which I might look at in more detail 04/09/2020 05:15:40 AM (PDT) | 0.4.3 | initial work of moving the exiftool.py into a directory preparing to break it down into separate files to make the codebase more manageable 03/12/2021 01:37:30 PM (PDT) | 0.4.4 | no functional code changes. Revamped the setup.py and related files to release to PyPI. Added all necessary and recommended files into release +03/12/2021 02:03:38 PM (PDT) | 0.4.5 | no functional code changes. re-release with new version because I accidentally included the "test" package with the PyPI 0.4.4 release. I deleted it instead of yanking or doing a post release this time... just bumped the version. "test" folder renamed to "tests" as per convention, so the build will automatically ignore it On version changes, update setup.py to reflect version diff --git a/README.rst b/README.rst index 9c84784..b857f67 100644 --- a/README.rst +++ b/README.rst @@ -65,7 +65,7 @@ Run tests to make sure it's functional :: - python -m unittest -v test/test_exiftool.py + python -m unittest -v tests/test_exiftool.py Documentation ------------- diff --git a/setup.py b/setup.py index 9e2fcde..ebe0306 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ # overview name="PyExifTool", - version="0.4.4", + version="0.4.5", license="GPLv3+/BSD", url="http://github.com/sylikc/pyexiftool", python_requires=">=2.6", @@ -72,7 +72,12 @@ "Topic :: Multimedia", "Topic :: Utilities", ], - packages=find_packages(where="."), + + + packages=find_packages( + where=".", + exclude = ['test*',] + ), #py_modules=["exiftool"], - it is now the exiftool module ) diff --git a/test/__init__.py b/tests/__init__.py similarity index 100% rename from test/__init__.py rename to tests/__init__.py diff --git a/test/rose.jpg b/tests/rose.jpg similarity index 100% rename from test/rose.jpg rename to tests/rose.jpg diff --git a/test/skyblue.png b/tests/skyblue.png similarity index 100% rename from test/skyblue.png rename to tests/skyblue.png diff --git a/test/test_exiftool.py b/tests/test_exiftool.py similarity index 100% rename from test/test_exiftool.py rename to tests/test_exiftool.py From bec818c67fce2f07addb297deb312d41a8874f24 Mon Sep 17 00:00:00 2001 From: SylikC Date: Sat, 13 Mar 2021 14:17:40 -0800 Subject: [PATCH 042/251] v0.5.0-alpha.0 new branch for upcoming changes. See CHANGELOG.md for more information --- CHANGELOG.md | 32 ++++++++++++++++---------------- COMPATIBILITY.txt | 11 +++++++++++ setup.py | 4 +++- 3 files changed, 30 insertions(+), 17 deletions(-) create mode 100644 COMPATIBILITY.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 843f58c..9a876e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,21 +1,19 @@ # PyExifTool Changelog -Current dates are in PST/PDT - Date (Timezone) | Version | Comment ---------------------------- | ------- | ------- -07/17/2019 12:26:16 AM (PDT) | 0.1 | 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.1 | Convert leading spaces to tabs -07/17/2019 12:52:33 AM (PDT) | 0.1.1 | 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
*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.1.2 | 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
*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".*
*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.1.3 | 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
*On Windows this will allow to run exiftool without showing the DOS shell.*
**This might break Linux but I don't know for sure**
Alternative source location with only this patch: https://github.com/blurstudio/pyexiftool/tree/shell-option -07/17/2019 01:24:32 AM (PDT) | 0.1.4 | Merge [Pull request #19 "Correct dependency for building an RPM."](https://github.com/smarnach/pyexiftool/pull/19) by [Achim Herwig (Achimh3011) Munich, Germany](https://github.com/Achimh3011) on Aug 25, 2016
**I'm not sure if this is entirely necessary, but merging it anyways** -07/17/2019 02:09:40 AM (PDT) | 0.1.5 | Merge [Pull request #15 "handling Errno:11 Resource temporarily unavailable"](https://github.com/smarnach/pyexiftool/pull/15) by [shoyebi](https://github.com/shoyebi) on Jun 12, 2015 -07/18/2019 03:40:39 AM (PDT) | 0.1.6 | set_tags and UTF-8 cmdline - Merge in the first set of changes by Leo Broska related to [Pull request #5 "add set_tags_batch, set_tags + constructor takes added options"](https://github.com/smarnach/pyexiftool/pull/5) by [halloleo](https://github.com/halloleo) on Aug 1, 2012
but this is sourced from [jmathai/elodie's 6114328 Jun 22,2016 commit](https://github.com/jmathai/elodie/blob/6114328f325660287d1998338a6d5e6ba4ccf069/elodie/external/pyexiftool.py) -07/18/2019 03:59:02 AM (PDT) | 0.1.7 | Merge another commit fromt he jmathai/elodie [zserg on Mar 12, 2016](https://github.com/jmathai/elodie/blob/af36de091e1746b490bed0adb839adccd4f6d2ef/elodie/external/pyexiftool.py)
seems to do UTF-8 encoding on set_tags -07/18/2019 04:01:18 AM (PDT) | 0.1.7 | minor change it looks like a rename to match PEP8 coding standards by [zserg on Aug 21, 2016](https://github.com/jmathai/elodie/blob/ad1cbefb15077844a6f64dca567ea5600477dd52/elodie/external/pyexiftool.py) -07/18/2019 04:05:36 AM (PDT) | 0.1.8 | [Fallback to latin if utf-8 decode fails in pyexiftool.py](https://github.com/jmathai/elodie/commit/fe70227c7170e01c8377de7f9770e761eab52036#diff-f9cf0f3eed27e85c9c9469d0e0d431d5) by [jmathai](https://github.com/jmathai/elodie/commits?author=jmathai) on Sep 7, 2016 -07/18/2019 04:14:32 AM (PDT) | 0.1.9 | Merge the test cases from the [Pull request #5 "add set_tags_batch, set_tags + constructor takes added options"](https://github.com/smarnach/pyexiftool/pull/5) by [halloleo](https://github.com/halloleo) on Aug 1, 2012 +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.0 | 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.1 | 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
*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.2 | 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
*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".*
*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.3 | 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
*On Windows this will allow to run exiftool without showing the DOS shell.*
**This might break Linux but I don't know for sure**
Alternative source location with only this patch: https://github.com/blurstudio/pyexiftool/tree/shell-option +07/17/2019 01:24:32 AM (PDT) | 0.2.4 | Merge [Pull request #19 "Correct dependency for building an RPM."](https://github.com/smarnach/pyexiftool/pull/19) by [Achim Herwig (Achimh3011) Munich, Germany](https://github.com/Achimh3011) on Aug 25, 2016
**I'm not sure if this is entirely necessary, but merging it anyways** +07/17/2019 02:09:40 AM (PDT) | 0.2.5 | Merge [Pull request #15 "handling Errno:11 Resource temporarily unavailable"](https://github.com/smarnach/pyexiftool/pull/15) by [shoyebi](https://github.com/shoyebi) on Jun 12, 2015 +07/18/2019 03:40:39 AM (PDT) | 0.2.6 | set_tags and UTF-8 cmdline - Merge in the first set of changes by Leo Broska related to [Pull request #5 "add set_tags_batch, set_tags + constructor takes added options"](https://github.com/smarnach/pyexiftool/pull/5) by [halloleo](https://github.com/halloleo) on Aug 1, 2012
but this is sourced from [jmathai/elodie's 6114328 Jun 22,2016 commit](https://github.com/jmathai/elodie/blob/6114328f325660287d1998338a6d5e6ba4ccf069/elodie/external/pyexiftool.py) +07/18/2019 03:59:02 AM (PDT) | 0.2.7 | Merge another commit fromt he jmathai/elodie [zserg on Mar 12, 2016](https://github.com/jmathai/elodie/blob/af36de091e1746b490bed0adb839adccd4f6d2ef/elodie/external/pyexiftool.py)
seems to do UTF-8 encoding on set_tags +07/18/2019 04:01:18 AM (PDT) | 0.2.7 | minor change it looks like a rename to match PEP8 coding standards by [zserg on Aug 21, 2016](https://github.com/jmathai/elodie/blob/ad1cbefb15077844a6f64dca567ea5600477dd52/elodie/external/pyexiftool.py) +07/18/2019 04:05:36 AM (PDT) | 0.2.8 | [Fallback to latin if utf-8 decode fails in pyexiftool.py](https://github.com/jmathai/elodie/commit/fe70227c7170e01c8377de7f9770e761eab52036#diff-f9cf0f3eed27e85c9c9469d0e0d431d5) by [jmathai](https://github.com/jmathai/elodie/commits?author=jmathai) on Sep 7, 2016 +07/18/2019 04:14:32 AM (PDT) | 0.2.9 | Merge the test cases from the [Pull request #5 "add set_tags_batch, set_tags + constructor takes added options"](https://github.com/smarnach/pyexiftool/pull/5) by [halloleo](https://github.com/halloleo) on Aug 1, 2012 07/18/2019 04:34:46 AM (PDT) | 0.3.0 | changed the setup.py licensing and updated the version numbering as in changelog
changed the version number scheme, as it appears the "official last release" was 0.2.0 tagged. There's going to be a lot of things broken in this current build, and I'll fix it as they come up. I'm going to start playing with the library and the included tests and such.
There's one more pull request #11 which would be pending, but it duplicates the extra arguments option.
I'm also likely to remove the print conversion as it's now covered by the extra args. I'll also rename some variable names with the addedargs patch
**for my changes (sylikc), I can only guarantee they will work on Python 3.7, because that's my environment... and while I'll try to maintain compatibility, there's no guarantees** 07/18/2019 05:06:19 AM (PDT) | 0.3.1 | make some minor tweaks to the naming of the extra args variable. The other pull request 11 names them params, and when I decide how to merge that pull request, I'll probably change the variable names again. 07/19/2019 12:01:22 AM (PDT) | 0.3.2 | fix the select() problem for windows, and fix all tests @@ -27,8 +25,10 @@ Date (Timezone) | Version | Comment 02/01/2020 05:09:43 PM (PST) | 0.4.1 | incorporated pull request #2 and #3 by ickc which added a "no_output" feature and an import for ujson if it's installed. Thanks for the updates! 04/09/2020 04:25:31 AM (PDT) | 0.4.2 | roll back 0.4.0's pyexiftool rename. It appears there's no specific PEP to have to to name PyPI projects to be py. The only convention I found was https://www.python.org/dev/peps/pep-0423/#use-standard-pattern-for-community-contributions which I might look at in more detail 04/09/2020 05:15:40 AM (PDT) | 0.4.3 | initial work of moving the exiftool.py into a directory preparing to break it down into separate files to make the codebase more manageable -03/12/2021 01:37:30 PM (PDT) | 0.4.4 | no functional code changes. Revamped the setup.py and related files to release to PyPI. Added all necessary and recommended files into release -03/12/2021 02:03:38 PM (PDT) | 0.4.5 | no functional code changes. re-release with new version because I accidentally included the "test" package with the PyPI 0.4.4 release. I deleted it instead of yanking or doing a post release this time... just bumped the version. "test" folder renamed to "tests" as per convention, so the build will automatically ignore it +03/12/2021 01:37:30 PM (PST) | 0.4.4 | no functional code changes. Revamped the setup.py and related files to release to PyPI. Added all necessary and recommended files into release +03/12/2021 02:03:38 PM (PST) | 0.4.5 | no functional code changes. re-release with new version because I accidentally included the "test" package with the PyPI 0.4.4 release. I deleted it instead of yanking or doing a post release this time... just bumped the version. "test" folder renamed to "tests" as per convention, so the build will automatically ignore it +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 + On version changes, update setup.py to reflect version diff --git a/COMPATIBILITY.txt b/COMPATIBILITY.txt new file mode 100644 index 0000000..2928193 --- /dev/null +++ b/COMPATIBILITY.txt @@ -0,0 +1,11 @@ +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.5 = code with all PRs, a superset of functionality on Exiftool class +v0.5.0 - = "should be" API compatible with v0.2.0, but moves functionality added in the versions before to other classes diff --git a/setup.py b/setup.py index ebe0306..288dbec 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ # overview name="PyExifTool", - version="0.4.5", + version="0.5.0-alpha.0", license="GPLv3+/BSD", url="http://github.com/sylikc/pyexiftool", python_requires=">=2.6", @@ -79,5 +79,7 @@ exclude = ['test*',] ), + #package_dir={'exiftool': 'exiftool'}, + #py_modules=["exiftool"], - it is now the exiftool module ) From b565c5376d1a9ea0755eb2662515bfbd5e4a463e Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 17 Mar 2021 03:57:27 -0700 Subject: [PATCH 043/251] breaks compatibility of constructor. executable_ -> executable fix inconsistent naming of class variables based on python conventions. use of _ (underscores) to designate private variables, and attributes to get and set attributes rather than direct with variable moving some of the default set lines to the top to see the defined class variables available to us added some comment breaks so the code is at least more readable to me added a "constants" file so we can refer to constants outside of the exiftool.py file --- COMPATIBILITY.txt | 4 ++ exiftool/constants.py | 32 ++++++++++++++++ exiftool/exiftool.py | 88 ++++++++++++++++++++++++++----------------- 3 files changed, 89 insertions(+), 35 deletions(-) create mode 100644 exiftool/constants.py diff --git a/COMPATIBILITY.txt b/COMPATIBILITY.txt index 2928193..2839352 100644 --- a/COMPATIBILITY.txt +++ b/COMPATIBILITY.txt @@ -9,3 +9,7 @@ possible. v0.1.x - v0.2.0 = smarnach code, API compatible v0.2.1 - v0.4.5 = code with all PRs, a superset of functionality on Exiftool class v0.5.0 - = "should be" API compatible with v0.2.0, but moves functionality added in the versions before to other classes + + +API changes between v0.2.0 and v0.5.0 + Exiftool constructor, "executable_" parameter renamed to "executable" diff --git a/exiftool/constants.py b/exiftool/constants.py new file mode 100644 index 0000000..e009da9 --- /dev/null +++ b/exiftool/constants.py @@ -0,0 +1,32 @@ +# -*- 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 +""" + + +# 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. +""" + + +SW_FORCEMINIMIZE = 11 # from win32con diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 08b111a..7f32f1f 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -80,21 +80,11 @@ basestring = (bytes, str) +from . import constants -# 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. -""" - # Sentinel indicating the end of the output of a sequence of commands. # The standard value should be fine. sentinel = b"{ready}" @@ -108,7 +98,7 @@ KW_TAGNAME = "IPTC:Keywords" KW_REPLACE, KW_ADD, KW_REMOVE = range(3) -#------------------------------------------------------------------------------------------------ +# ====================================================================================================================== # This code has been adapted from Lib/os.py in the Python source tree @@ -140,7 +130,7 @@ def fsencode(filename): fsencode = _fscodec() del _fscodec -#------------------------------------------------------------------------------------------------ +# ====================================================================================================================== def set_pdeathsig(sig=signal.SIGTERM): """ @@ -159,6 +149,7 @@ def callable_method(): else: return None +# ====================================================================================================================== @@ -166,6 +157,7 @@ def callable_method(): def strip_nl (s): return ' '.join(s.splitlines()) +# ====================================================================================================================== # Error checking function # very rudimentary checking @@ -179,6 +171,8 @@ def check_ok (result): """ 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`) @@ -197,13 +191,12 @@ def format_error (result): -#------------------------------------------------------------------------------------------------ +# ====================================================================================================================== # https://gist.github.com/techtonik/4368898 # Public domain code by anatoly techtonik # 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'; @@ -245,7 +238,7 @@ def find_executable(executable, path=None): -#------------------------------------------------------------------------------------------------ +# ====================================================================================================================== class ExifTool(object): """Run the `exiftool` command-line tool and communicate to it. @@ -290,26 +283,26 @@ class ExifTool(object): associated with a running subprocess. """ - def __init__(self, executable_=None, common_args=None, win_shell=True): + # ---------------------------------------------------------------------------------------------------------------------- + def __init__(self, executable=None, common_args=None, win_shell=True): - self.win_shell = win_shell + # default settings + self._executable = constants.DEFAULT_EXECUTABLE # executable absolute path TODO + self._win_shell = win_shell # do you want to see the shell on Windows? + self._process = None + self._running = False # is it running? - if executable_ is None: - self.executable = DEFAULT_EXECUTABLE - else: - self.executable = executable_ + if executable is not None: + 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) ) + 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 - self._process = None - if common_args is None: # default parameters to exiftool # -n = disable print conversion (speedup) @@ -322,6 +315,7 @@ def __init__(self, executable_=None, common_args=None, win_shell=True): self.no_output = '-w' in self.common_args + # ---------------------------------------------------------------------------------------------------------------------- def start(self): """Start an ``exiftool`` process in batch mode for this instance. @@ -334,7 +328,7 @@ def start(self): However, you can override these default arguments with the ``common_args`` parameter in the constructor. """ - if self.running: + if self._running: warnings.warn("ExifTool already running; doing nothing.") return @@ -347,11 +341,10 @@ def start(self): try: if sys.platform == 'win32': startup_info = subprocess.STARTUPINFO() - if not self.win_shell: - SW_FORCEMINIMIZE = 11 # from win32con + 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 |= 11 + startup_info.dwFlags |= constants.SW_FORCEMINIMIZE self._process = subprocess.Popen( proc_args, @@ -376,14 +369,15 @@ def start(self): raise cpe # check error above before saying it's running - self.running = True + self._running = True + # ---------------------------------------------------------------------------------------------------------------------- def terminate(self, wait_timeout=30): """Terminate the ``exiftool`` process of this instance. If the subprocess isn't running, this method will do nothing. """ - if not self.running: + if not self._running: return self._process.stdin.write(b"-stay_open\nFalse\n") self._process.stdin.flush() @@ -395,18 +389,22 @@ def terminate(self, wait_timeout=30): # err handling code from https://docs.python.org/3/library/subprocess.html#subprocess.Popen.communicate del self._process - self.running = False + self._running = False + # ---------------------------------------------------------------------------------------------------------------------- def __enter__(self): self.start() return self + # ---------------------------------------------------------------------------------------------------------------------- def __exit__(self, exc_type, exc_val, exc_tb): self.terminate() + # ---------------------------------------------------------------------------------------------------------------------- def __del__(self): self.terminate() + # ---------------------------------------------------------------------------------------------------------------------- def execute(self, *params): """Execute the given batch of parameters with ``exiftool``. @@ -426,7 +424,7 @@ def execute(self, *params): .. note:: This is considered a low-level method, and should rarely be needed by application developers. """ - if not self.running: + if not self._running: raise ValueError("ExifTool instance not running.") cmd_text = b"\n".join(params + (b"-execute\n",)) @@ -450,6 +448,7 @@ def execute(self, *params): 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 @@ -487,6 +486,7 @@ def execute_json_wrapper(self, filenames, params=None, retry_on_error=True): + # ---------------------------------------------------------------------------------------------------------------------- def execute_json(self, *params): """Execute the given batch of parameters and parse the JSON output. @@ -529,10 +529,12 @@ def execute_json(self, *params): # 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) + # ---------------------------------------------------------------------------------------------------------------------- def get_metadata_batch(self, filenames): """Return all meta-data for the given files. @@ -541,10 +543,12 @@ def get_metadata_batch(self, filenames): """ return self.execute_json(*filenames) + # ---------------------------------------------------------------------------------------------------------------------- # (#11) def get_metadata_wrapper(self, filename, params=None): return self.execute_json_wrapper(filenames=[filename], params=params)[0] + # ---------------------------------------------------------------------------------------------------------------------- def get_metadata(self, filename): """Return meta-data for a single file. @@ -553,11 +557,13 @@ def get_metadata(self, filename): """ return self.execute_json(filename)[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. @@ -581,10 +587,12 @@ def get_tags_batch(self, tags, filenames): 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. @@ -593,6 +601,7 @@ def get_tags(self, tags, filename): """ 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) @@ -603,6 +612,7 @@ def get_tag_batch_wrapper(self, tag, filenames, params=None): return result + # ---------------------------------------------------------------------------------------------------------------------- def get_tag_batch(self, tag, filenames): """Extract a single tag from the given files. @@ -621,10 +631,12 @@ def get_tag_batch(self, tag, filenames): 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. @@ -633,11 +645,13 @@ def get_tag(self, tag, filename): """ return self.get_tag_batch(tag, [filename])[0] + # ---------------------------------------------------------------------------------------------------------------------- def copy_tags(self, fromFilename, toFilename): """Copy all tags from one file to another.""" self.execute("-overwrite_original", "-TagsFromFile", fromFilename, toFilename) + # ---------------------------------------------------------------------------------------------------------------------- def set_tags_batch(self, tags, filenames): """Writes the values of the specified tags for the given files. @@ -669,6 +683,7 @@ def set_tags_batch(self, tags, 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. @@ -678,6 +693,7 @@ def set_tags(self, tags, filename): """ return self.set_tags_batch(tags, [filename]) + # ---------------------------------------------------------------------------------------------------------------------- def set_keywords_batch(self, mode, keywords, filenames): """Modifies the keywords tag for the given files. @@ -722,6 +738,7 @@ def set_keywords_batch(self, mode, keywords, filenames): params_utf8 = [x.encode('utf-8') for x in params] return self.execute(*params_utf8) + # ---------------------------------------------------------------------------------------------------------------------- def set_keywords(self, mode, keywords, filename): """Modifies the keywords tag for the given file. @@ -733,6 +750,7 @@ def set_keywords(self, mode, keywords, filename): + # ---------------------------------------------------------------------------------------------------------------------- @staticmethod def _check_sanity_of_result(file_paths, result): """ From efbf835f69352a6ccc477788ee88ff539ab658b1 Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 17 Mar 2021 04:37:55 -0700 Subject: [PATCH 044/251] get the tests to pass in the way that makes sense move the setting of the executable to be a property set, which can be changed restore the behavior which you can read-only the "running" property, but prevent setting it added test of attribute "running" and "executable" --- exiftool/__init__.py | 2 ++ exiftool/constants.py | 3 +++ exiftool/exiftool.py | 40 ++++++++++++++++++++++++++++++++-------- tests/test_exiftool.py | 18 ++++++++++++++++++ 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/exiftool/__init__.py b/exiftool/__init__.py index 854ec1b..5a5baa0 100644 --- a/exiftool/__init__.py +++ b/exiftool/__init__.py @@ -4,3 +4,5 @@ # make all of the original exiftool stuff available in this namespace from .exiftool import * + +from .constants import DEFAULT_EXECUTABLE diff --git a/exiftool/constants.py b/exiftool/constants.py index e009da9..ed15cbf 100644 --- a/exiftool/constants.py +++ b/exiftool/constants.py @@ -17,6 +17,9 @@ """ +import sys + + # 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" diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 7f32f1f..745c082 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -287,19 +287,15 @@ class ExifTool(object): def __init__(self, executable=None, common_args=None, win_shell=True): # default settings - self._executable = constants.DEFAULT_EXECUTABLE # executable absolute path TODO + self._executable = None # executable absolute path self._win_shell = win_shell # do you want to see the shell on Windows? self._process = None self._running = False # is it running? - if executable is not None: - self._executable = executable + # use the passed in parameter, or the default if not set + # error checking is done in the property.setter + self.executable = executable if executable is not None else constants.DEFAULT_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._common_args = common_args # it can't be none, check if it's a list, if not, error @@ -404,6 +400,34 @@ def __exit__(self, exc_type, exc_val, exc_tb): def __del__(self): self.terminate() + # ---------------------------------------------------------------------------------------------------------------------- + @property + def executable(self): + return self._executable + + @executable.setter + def executable(self, new_executable): + """ + Set the executable. Does error checking. + """ + # cannot set executable when process is running + if self._running: + raise RuntimeError( 'Cannot set new executable while Exiftool is running' ) + + abs_path = find_executable(new_executable) + + if abs_path is None: + raise FileNotFoundError( '"{}" is not found, on path or as absolute path'.format(new_executable) ) + + # absolute path is returned + self._executable = abs_path + + # ---------------------------------------------------------------------------------------------------------------------- + @property + def running(self): + # read-only property + return self._running + # ---------------------------------------------------------------------------------------------------------------------- def execute(self, *params): """Execute the given batch of parameters with ``exiftool``. diff --git a/tests/test_exiftool.py b/tests/test_exiftool.py index a9de328..49c4767 100644 --- a/tests/test_exiftool.py +++ b/tests/test_exiftool.py @@ -21,6 +21,24 @@ def tearDown(self): 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.start() + self.assertTrue(self.et.running) + with self.assertRaises(RuntimeError): + self.et.executable = "x" + self.et.terminate() + with self.assertRaises(FileNotFoundError): + self.et.executable = "lkajsdfoleiawjfasv" + self.assertFalse(self.et.running) + #--------------------------------------------------------------------------------------------------------- def test_termination_cm(self): # Test correct subprocess start and termination when using # self.et as a context manager From 52d60cae60d758a773356c8f8b0a9284c342fbcd Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 17 Mar 2021 04:54:11 -0700 Subject: [PATCH 045/251] be explicit in the warning returned in start() make platform a constant so that there won't be a chance of typos comparing it in code --- exiftool/constants.py | 10 +++++++++- exiftool/exiftool.py | 14 ++++++++------ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/exiftool/constants.py b/exiftool/constants.py index ed15cbf..3a1efad 100644 --- a/exiftool/constants.py +++ b/exiftool/constants.py @@ -19,9 +19,17 @@ import sys +# instead of comparing everywhere sys.platform, do it all here in the constants (less typo chances) +# True if Windows +PLATFORM_WINDOWS = (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 = (sys.platform == 'linux' or sys.platform == 'linux2') + + # specify the extension so exiftool doesn't default to running "exiftool.py" on windows (which could happen) -if sys.platform == 'win32': +if PLATFORM_WINDOWS: DEFAULT_EXECUTABLE = "exiftool.exe" else: DEFAULT_EXECUTABLE = "exiftool" diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 745c082..dd445d1 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -138,7 +138,7 @@ def set_pdeathsig(sig=signal.SIGTERM): 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 @@ -214,7 +214,7 @@ def find_executable(executable, path=None): # .exe is automatically appended if no dot is present in the name if not ext: executable = executable + ".exe" - elif sys.platform == 'win32': + elif constants.PLATFORM_WINDOWS: pathext = os.environ['PATHEXT'].lower().split(os.pathsep) (base, ext) = os.path.splitext(executable) if ext.lower() not in pathext: @@ -289,13 +289,15 @@ def __init__(self, executable=None, common_args=None, win_shell=True): # default settings self._executable = None # executable absolute path self._win_shell = win_shell # do you want to see the shell on Windows? - self._process = None + self._process = None # this gets del by terminate() TODO change to None if it doesn't affect code self._running = False # is it running? # use the passed in parameter, or the default if not set # error checking is done in the property.setter self.executable = executable if executable is not None else constants.DEFAULT_EXECUTABLE + + self._common_args = common_args # it can't be none, check if it's a list, if not, error @@ -325,7 +327,7 @@ def start(self): ``common_args`` parameter in the constructor. """ if self._running: - warnings.warn("ExifTool already running; doing nothing.") + warnings.warn("ExifTool already running; doing nothing.", UserWarning) return proc_args = [self.executable, "-stay_open", "True", "-@", "-", "-common_args"] @@ -335,7 +337,7 @@ def start(self): with open(os.devnull, "w") as devnull: try: - if sys.platform == 'win32': + if constants.PLATFORM_WINDOWS: startup_info = subprocess.STARTUPINFO() if not self._win_shell: # Adding enum 11 (SW_FORCEMINIMIZE in win32api speak) will @@ -459,7 +461,7 @@ def execute(self, *params): output = b"" fd = self._process.stdout.fileno() while not output[-32:].strip().endswith(sentinel): - if sys.platform == 'win32': + 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) From beb2085b82c1abd054743078235b5d32c10391d6 Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 17 Mar 2021 13:43:37 -0700 Subject: [PATCH 046/251] make the rest of the class consistently named for now --- exiftool/exiftool.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index dd445d1..05b2324 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -82,6 +82,7 @@ from . import constants +#from pathlib import Path # requires Python 3.4+ @@ -304,13 +305,13 @@ def __init__(self, executable=None, common_args=None, win_shell=True): if common_args is None: # default parameters to exiftool # -n = disable print conversion (speedup) - self.common_args = ["-G", "-n"] + self._common_args = ["-G", "-n"] elif type(common_args) is list: - self.common_args = common_args + self._common_args = common_args else: raise TypeError("common_args not a list of strings") - self.no_output = '-w' in self.common_args + self._no_output = '-w' in self._common_args # ---------------------------------------------------------------------------------------------------------------------- @@ -331,7 +332,7 @@ def start(self): return proc_args = [self.executable, "-stay_open", "True", "-@", "-", "-common_args"] - proc_args.extend(self.common_args) # add the common arguments + proc_args.extend(self._common_args) # add the common arguments logging.debug(proc_args) @@ -549,7 +550,7 @@ def execute_json(self, *params): # which will return something like # image files read # output files created - if self.no_output: + if self._no_output: print(res_decoded) else: # TODO: if len(res_decoded) == 0, then there's obviously an error here From 64d44deab696eefd780290fd6f1261b3ac02660d Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 17 Mar 2021 13:55:11 -0700 Subject: [PATCH 047/251] add some TODOs to check code and consider whether to refactor later fixed self._process deletion. To be consistent, it is always defined, and will be None if the process is not running --- exiftool/exiftool.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 05b2324..6e2c222 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -290,7 +290,7 @@ def __init__(self, executable=None, common_args=None, win_shell=True): # default settings self._executable = None # executable absolute path self._win_shell = win_shell # do you want to see the shell on Windows? - self._process = None # this gets del by terminate() TODO change to None if it doesn't affect code + self._process = None # this is set to the process to interact with when _running=True self._running = False # is it running? # use the passed in parameter, or the default if not set @@ -331,6 +331,7 @@ def start(self): warnings.warn("ExifTool already running; doing nothing.", UserWarning) return + # TODO changing common args means it needs a restart, or error, have a restart=True for change common_args or error if running proc_args = [self.executable, "-stay_open", "True", "-@", "-", "-common_args"] proc_args.extend(self._common_args) # add the common arguments @@ -378,7 +379,7 @@ def terminate(self, wait_timeout=30): """ if not self._running: return - self._process.stdin.write(b"-stay_open\nFalse\n") + self._process.stdin.write(b"-stay_open\nFalse\n") # TODO these are constants which should be elsewhere defined self._process.stdin.flush() try: self._process.communicate(timeout=wait_timeout) @@ -387,7 +388,7 @@ def terminate(self, wait_timeout=30): outs, errs = proc.communicate() # err handling code from https://docs.python.org/3/library/subprocess.html#subprocess.Popen.communicate - del self._process + self._process = None # don't delete, just leave as None self._running = False # ---------------------------------------------------------------------------------------------------------------------- From f41405780753a133f612823c125fabb415fbdbdc Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 1 Apr 2021 02:38:31 -0700 Subject: [PATCH 048/251] make block_size a property which can be set to define a read block size --- exiftool/constants.py | 6 ++++++ exiftool/exiftool.py | 35 +++++++++++++++++++++++++---------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/exiftool/constants.py b/exiftool/constants.py index 3a1efad..5416268 100644 --- a/exiftool/constants.py +++ b/exiftool/constants.py @@ -41,3 +41,9 @@ SW_FORCEMINIMIZE = 11 # from win32con + + +# 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 = 4096 diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 6e2c222..2a80753 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -90,11 +90,6 @@ # 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) @@ -297,7 +292,8 @@ def __init__(self, executable=None, common_args=None, win_shell=True): # error checking is done in the property.setter self.executable = executable if executable is not None else constants.DEFAULT_EXECUTABLE - + # set to default block size + self._block_size = DEFAULT_BLOCK_SIZE self._common_args = common_args # it can't be none, check if it's a list, if not, error @@ -378,9 +374,12 @@ def terminate(self, wait_timeout=30): If the subprocess isn't running, this method will do nothing. """ if not self._running: + # TODO, might raise an error, or add an optional parameter that says ignore_running return + self._process.stdin.write(b"-stay_open\nFalse\n") # TODO these are constants which should be elsewhere defined 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) @@ -426,6 +425,22 @@ def executable(self, new_executable): # absolute path is returned self._executable = abs_path + + # ---------------------------------------------------------------------------------------------------------------------- + @property + def block_size(self): + return self._block_size + + @block_size.setter + def block_size(self, new_block_size): + """ + 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 + # ---------------------------------------------------------------------------------------------------------------------- @property def running(self): @@ -453,9 +468,9 @@ def execute(self, *params): rarely be needed by application developers. """ if not self._running: - raise ValueError("ExifTool instance not running.") + raise RuntimeError("ExifTool instance not running.") - cmd_text = b"\n".join(params + (b"-execute\n",)) + cmd_text = b"\n".join(params + (b"-execute\n",)) #TODO constant # 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 self._process.stdin.write(cmd_text) @@ -466,13 +481,13 @@ def execute(self, *params): 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) + output += os.read(fd, self._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) + output += os.read(fd, self._block_size) return output.strip()[:-len(sentinel)] From 51837da6b72cdba6f65da66965fdd9345b73b8e8 Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 1 Apr 2021 02:50:51 -0700 Subject: [PATCH 049/251] as per comment https://gist.github.com/techtonik/4368898#gistcomment-2344225 , remove the Python 2.x code with one liner (requires Python 3.3) --- exiftool/exiftool.py | 46 ++++---------------------------------------- 1 file changed, 4 insertions(+), 42 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 2a80753..467cbd3 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -62,6 +62,8 @@ import sys import subprocess import os +import shutil + try: import ujson as json except ImportError: @@ -187,47 +189,6 @@ def format_error (result): -# ====================================================================================================================== - - -# https://gist.github.com/techtonik/4368898 -# Public domain code by anatoly techtonik -# 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 constants.PLATFORM_WINDOWS: - 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 @@ -417,7 +378,8 @@ def executable(self, new_executable): if self._running: raise RuntimeError( 'Cannot set new executable while Exiftool is running' ) - abs_path = find_executable(new_executable) + # Python 3.3+ required + abs_path = shutil.which(new_executable) if abs_path is None: raise FileNotFoundError( '"{}" is not found, on path or as absolute path'.format(new_executable) ) From 85e9d3a2d53a6277309603c932389e2b69236da1 Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 1 Apr 2021 02:54:16 -0700 Subject: [PATCH 050/251] change the targeting of Python version to 3.6+ --- setup.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 288dbec..03b49ee 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ version="0.5.0-alpha.0", 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", @@ -65,9 +65,8 @@ "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", From 46711d42269b45aeaeb4aff8d0395f06115de6b6 Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 1 Apr 2021 03:00:10 -0700 Subject: [PATCH 051/251] as per discussion https://github.com/sylikc/pyexiftool/discussions/10#discussioncomment-503125 , change .start() to .run() to match the way subprocess library does it. (standardize the naming) fix all tests to pass remove the test that tests the find_executable() as it has been replaced with the standard library shutil.which() --- exiftool/exiftool.py | 8 ++++---- tests/test_exiftool.py | 33 +++++---------------------------- 2 files changed, 9 insertions(+), 32 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 467cbd3..ffd7848 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -254,7 +254,7 @@ def __init__(self, executable=None, common_args=None, win_shell=True): self.executable = executable if executable is not None else constants.DEFAULT_EXECUTABLE # set to default block size - self._block_size = DEFAULT_BLOCK_SIZE + self._block_size = constants.DEFAULT_BLOCK_SIZE self._common_args = common_args # it can't be none, check if it's a list, if not, error @@ -272,7 +272,7 @@ def __init__(self, executable=None, common_args=None, win_shell=True): # ---------------------------------------------------------------------------------------------------------------------- - def start(self): + def run(self): """Start an ``exiftool`` process in batch mode for this instance. This method will issue a ``UserWarning`` if the subprocess is @@ -353,7 +353,7 @@ def terminate(self, wait_timeout=30): # ---------------------------------------------------------------------------------------------------------------------- def __enter__(self): - self.start() + self.run() return self # ---------------------------------------------------------------------------------------------------------------------- @@ -475,7 +475,7 @@ def execute_json_wrapper(self, filenames, params=None, retry_on_error=True): except (IOError, error): # Restart the exiftool child process in these cases since something is going wrong self.terminate() - self.start() + self.run() if retry_on_error: result = self.execute_json_filenames(filenames, params, retry_on_error=False) diff --git a/tests/test_exiftool.py b/tests/test_exiftool.py index 49c4767..8ea8fd9 100644 --- a/tests/test_exiftool.py +++ b/tests/test_exiftool.py @@ -30,7 +30,7 @@ def test_running_attribute(self): def test_executable_attribute(self): # test if we can read "running" but can't set it self.assertFalse(self.et.running) - self.et.start() + self.et.run() self.assertTrue(self.et.running) with self.assertRaises(RuntimeError): self.et.executable = "x" @@ -43,11 +43,11 @@ 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(RuntimeError, self.et.execute) with self.et: self.assertTrue(self.et.running) with warnings.catch_warnings(record=True) as w: - self.et.start() + self.et.run() self.assertEquals(len(w), 1) self.assertTrue(issubclass(w[0].category, UserWarning)) self.process = self.et._process @@ -58,7 +58,7 @@ def test_termination_cm(self): 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() @@ -66,7 +66,7 @@ def test_termination_explicit(self): #--------------------------------------------------------------------------------------------------------- def test_termination_implicit(self): # Test implicit process termination on garbage collection - self.et.start() + self.et.run() self.process = self.et._process del self.et self.assertNotEqual(self.process.poll(), None) @@ -164,29 +164,6 @@ def test_set_keywords(self): 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:\\" - 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 - #--------------------------------------------------------------------------------------------------------- if __name__ == '__main__': unittest.main() From e94eccf9889881f432037f6deeb066bd5c83c859 Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 1 Apr 2021 17:27:55 -0700 Subject: [PATCH 052/251] (temporary until decided) return None right now when JSON result comes back empty. add warning when terminating when Exiftool isn't running and now because of that, check before calling terminate() --- exiftool/exiftool.py | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index ffd7848..6f29aca 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -86,7 +86,7 @@ #from pathlib import Path # requires Python 3.4+ - +import random # Sentinel indicating the end of the output of a sequence of commands. # The standard value should be fine. @@ -242,7 +242,9 @@ class ExifTool(object): # ---------------------------------------------------------------------------------------------------------------------- def __init__(self, executable=None, common_args=None, win_shell=True): - + + random.seed(None) # initialize random number generator + # default settings self._executable = None # executable absolute path self._win_shell = win_shell # do you want to see the shell on Windows? @@ -335,7 +337,8 @@ def terminate(self, wait_timeout=30): If the subprocess isn't running, this method will do nothing. """ if not self._running: - # TODO, might raise an error, or add an optional parameter that says ignore_running + warnings.warn("ExifTool not running; doing nothing.", UserWarning) + # TODO, maybe add an optional parameter that says ignore_running/check/force or something which will not warn return self._process.stdin.write(b"-stay_open\nFalse\n") # TODO these are constants which should be elsewhere defined @@ -358,11 +361,13 @@ def __enter__(self): # ---------------------------------------------------------------------------------------------------------------------- def __exit__(self, exc_type, exc_val, exc_tb): - self.terminate() + if self._running: + self.terminate() # ---------------------------------------------------------------------------------------------------------------------- def __del__(self): - self.terminate() + if self._running: + self.terminate() # ---------------------------------------------------------------------------------------------------------------------- @property @@ -437,9 +442,11 @@ def execute(self, *params): # might look at something like this https://stackoverflow.com/questions/7585435/best-way-to-convert-string-to-bytes-in-python-3 self._process.stdin.write(cmd_text) self._process.stdin.flush() - output = b"" + fd = self._process.stdout.fileno() - while not output[-32:].strip().endswith(sentinel): + + output = b"" + while not output[-32:].strip().endswith(sentinel): #TODO this is arbitrary number 32 if constants.PLATFORM_WINDOWS: # windows does not support select() for anything except sockets # https://docs.python.org/3.7/library/select.html @@ -450,6 +457,8 @@ def execute(self, *params): for i in inputready: if i == fd: output += os.read(fd, self._block_size) + #print(output[-32:]) + return output.strip()[:-len(sentinel)] @@ -520,6 +529,18 @@ def execute_json(self, *params): # http://stackoverflow.com/a/5552623/1318758 # https://github.com/jmathai/elodie/issues/127 res = self.execute(b"-j", *params) + + if len(res) == 0: + # if the command has no files it's worked on, or some other type of error + # we can either return None, or [], or FileNotFoundError .. + + # but, since it's technically not an error to have no files, + # returning None is the best. + # Even [] could be ambugious if Exiftool changes the returned JSON structure in the future + # TODO haven't decidedd on [] or None yet + return None + + try: res_decoded = res.decode("utf-8") except UnicodeDecodeError: From 05986d5fcd735bdac5a4c31c5cd984deff8ce910 Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 1 Apr 2021 17:49:30 -0700 Subject: [PATCH 053/251] make the execute() work with a synchronization number. This ensures that the return of {ready} is absolutely correct... in case a tag reads out "{ready}" or something, this is just a pre-emptive improvement --- exiftool/exiftool.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 6f29aca..6e480bb 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -88,14 +88,14 @@ import random -# Sentinel indicating the end of the output of a sequence of commands. -# The standard value should be fine. -sentinel = b"{ready}" - # constants related to keywords manipulations KW_TAGNAME = "IPTC:Keywords" KW_REPLACE, KW_ADD, KW_REMOVE = range(3) + +ENCODING_UTF8 = "utf-8" +ENCODING_LATIN1 = "latin-1" + # ====================================================================================================================== @@ -437,7 +437,19 @@ def execute(self, *params): if not self._running: raise RuntimeError("ExifTool instance not running.") - cmd_text = b"\n".join(params + (b"-execute\n",)) #TODO constant + # constant special sequences when running -stay_open mode + SEQ_EXECUTE_FMT = "-execute{}\n" # this is the PYFORMAT ... the actual string is b"-execute\n" + SEQ_READY_FMT = "{{ready{}}}" # this is the PYFORMAT ... the actual string is b"{ready}" + + + # there's a special usage of execute/ready specified in the manual which make almost ensure we are receiving the right signal back + signal_num = random.randint(10000000, 99999999) # arbitrary create a 8 digit number + seq_execute = SEQ_EXECUTE_FMT.format(signal_num).encode(ENCODING_UTF8) + seq_ready = SEQ_READY_FMT.format(signal_num).encode(ENCODING_UTF8) + endswith_count = len(seq_ready) + 4 # if we're only looking at the last few bytes, make it meaningful. 4 is max size of \r\n? (or 2) + + + cmd_text = b"\n".join(params + (seq_execute,)) # 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 self._process.stdin.write(cmd_text) @@ -446,7 +458,7 @@ def execute(self, *params): fd = self._process.stdout.fileno() output = b"" - while not output[-32:].strip().endswith(sentinel): #TODO this is arbitrary number 32 + while not output[-endswith_count:].strip().endswith(seq_ready): if constants.PLATFORM_WINDOWS: # windows does not support select() for anything except sockets # https://docs.python.org/3.7/library/select.html @@ -457,9 +469,8 @@ def execute(self, *params): for i in inputready: if i == fd: output += os.read(fd, self._block_size) - #print(output[-32:]) - return output.strip()[:-len(sentinel)] + return output.strip()[:-len(seq_ready)] # ---------------------------------------------------------------------------------------------------------------------- @@ -542,9 +553,9 @@ def execute_json(self, *params): try: - res_decoded = res.decode("utf-8") + res_decoded = res.decode(ENCODING_UTF8) except UnicodeDecodeError: - res_decoded = res.decode("latin-1") + res_decoded = res.decode(ENCODING_LATIN1) # res_decoded can be invalid json if `-w` flag is specified in common_args # which will return something like # image files read From 56af0a94e2b6a5e90a4583166eb2e02ff57aa832 Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 1 Apr 2021 18:33:14 -0700 Subject: [PATCH 054/251] added code to read from stderr as well. I think I'll be able to detect errors better by having an STDERR stream This has not yet been tested on LINUX. I will write tests later which tests this functionality in full on linux --- exiftool/exiftool.py | 78 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 67 insertions(+), 11 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 6e480bb..a257cb6 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -241,7 +241,7 @@ class ExifTool(object): """ # ---------------------------------------------------------------------------------------------------------------------- - def __init__(self, executable=None, common_args=None, win_shell=True): + def __init__(self, executable=None, common_args=None, win_shell=True, return_tuple=False): random.seed(None) # initialize random number generator @@ -250,6 +250,10 @@ def __init__(self, executable=None, common_args=None, win_shell=True): self._win_shell = 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._running = False # is it running? + + self._return_tuple = return_tuple # are we returning a tuple in the execute? + self._last_stdout = None # previous output + self._last_stderr = None # previous stderr # use the passed in parameter, or the default if not set # error checking is done in the property.setter @@ -296,7 +300,7 @@ def run(self): logging.debug(proc_args) - with open(os.devnull, "w") as devnull: + with open(os.devnull, "w") as devnull: # TODO can probably remove or make it a parameter try: if constants.PLATFORM_WINDOWS: startup_info = subprocess.STARTUPINFO() @@ -308,14 +312,14 @@ def run(self): self._process = subprocess.Popen( proc_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=devnull, startupinfo=startup_info) + stderr=subprocess.PIPE, startupinfo=startup_info) #stderr=devnull # 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)) + stderr=subprocess.PIPE, preexec_fn=set_pdeathsig(signal.SIGTERM)) #stderr=devnull # 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: @@ -413,6 +417,20 @@ def block_size(self, new_block_size): def running(self): # read-only property return self._running + + + # ---------------------------------------------------------------------------------------------------------------------- + @property + def last_stdout(self): + """last output stdout from execute()""" + return self._last_stdout + + # ---------------------------------------------------------------------------------------------------------------------- + @property + def last_stderr(self): + """last output stderr from execute()""" + return self._last_stderr + # ---------------------------------------------------------------------------------------------------------------------- def execute(self, *params): @@ -443,6 +461,7 @@ def execute(self, *params): # 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(10000000, 99999999) # arbitrary create a 8 digit number seq_execute = SEQ_EXECUTE_FMT.format(signal_num).encode(ENCODING_UTF8) seq_ready = SEQ_READY_FMT.format(signal_num).encode(ENCODING_UTF8) @@ -455,22 +474,54 @@ def execute(self, *params): self._process.stdin.write(cmd_text) self._process.stdin.flush() - fd = self._process.stdout.fileno() + fdout = self._process.stdout.fileno() output = b"" while not output[-endswith_count:].strip().endswith(seq_ready): 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, self._block_size) + output += os.read(fdout, self._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],[],[]) + inputready,outputready,exceptready = select.select([fdout], [], []) for i in inputready: - if i == fd: - output += os.read(fd, self._block_size) + if i == fdout: + output += os.read(fdout, self._block_size) + - return output.strip()[:-len(seq_ready)] + # when it's ready, we can safely read all of stderr out, as the command is already done + fderr = self._process.stderr.fileno() + + # TODO THIS CODE IS NOT YET TESTED ON LINUX, TEST BEFORE PUBLISH + outerr = b"" + eof_signal = False + while not eof_signal: + if constants.PLATFORM_WINDOWS: + outtmp = os.read(fderr, self._block_size) + if len(outtmp) == 0 or len(outtmp) < self._block_size: # TODO currently using a "hack" that if we read less bytes than we requested, EOF is coming ... getting a non-blocking Windows read is a complex endeavor + # break loop + eof_signal = True + + outerr += outtmp + else: + inputready,outputready,exceptready = select.select([fderr], [], [], 0) # set a timeout to 0, so this never blocks + if len(inputready) == 0: + eof_signal = True + else: + for i in inputready: + if i == fderr: + outerr += os.read(fderr, self._block_size) + + # save the output to class vars for retrieval + self._last_stdout = output.strip()[:-len(seq_ready)] + self._last_stderr = outerr + + if self._return_tuple: + return (self._last_stdout, self._last_stderr,) + else: + # this was the standard return before, just stdout + return self._last_stdout # ---------------------------------------------------------------------------------------------------------------------- @@ -539,7 +590,12 @@ def execute_json(self, *params): # 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) + std = self.execute(b"-j", *params) + + if self._return_tuple: + res = std[0] + else: + res = std if len(res) == 0: # if the command has no files it's worked on, or some other type of error From 5a87388a50e7a8f0dbbaef7e46db5fe9c7c8f941 Mon Sep 17 00:00:00 2001 From: SylikC Date: Tue, 6 Apr 2021 14:50:16 -0700 Subject: [PATCH 055/251] change how terminate() works on Windows when __del__ is in progress. communicate() freezes and never finishes execution in this case on win32 platform --- exiftool/exiftool.py | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index a257cb6..2f6097a 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -335,26 +335,37 @@ def run(self): self._running = True # ---------------------------------------------------------------------------------------------------------------------- - def terminate(self, wait_timeout=30): + def terminate(self, wait_timeout=30, _del=False): """Terminate the ``exiftool`` process of this instance. If the subprocess isn't running, this method will do nothing. """ + print("terminate") if not self._running: warnings.warn("ExifTool not running; doing nothing.", UserWarning) # TODO, maybe add an optional parameter that says ignore_running/check/force or something which will not warn return - self._process.stdin.write(b"-stay_open\nFalse\n") # TODO these are constants which should be elsewhere defined - 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 = proc.communicate() - # err handling code from https://docs.python.org/3/library/subprocess.html#subprocess.Popen.communicate - + 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=b"-stay_open\nFalse\n", timeout=wait_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 = proc.communicate() + # err handling code from https://docs.python.org/3/library/subprocess.html#subprocess.Popen.communicate + self._process = None # don't delete, just leave as None self._running = False @@ -371,7 +382,8 @@ def __exit__(self, exc_type, exc_val, exc_tb): # ---------------------------------------------------------------------------------------------------------------------- def __del__(self): if self._running: - self.terminate() + # indicate that __del__ has been started - allows running alternate code path in terminate() + self.terminate(_del=True) # ---------------------------------------------------------------------------------------------------------------------- @property From 7a2818800a54ddf0c0bacd824615c53cc70c75ae Mon Sep 17 00:00:00 2001 From: SylikC Date: Tue, 6 Apr 2021 14:51:10 -0700 Subject: [PATCH 056/251] change the function signature of terminate() to match subprocess API, timeout instead of wait_timeout --- exiftool/exiftool.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 2f6097a..955df0d 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -335,7 +335,7 @@ def run(self): self._running = True # ---------------------------------------------------------------------------------------------------------------------- - def terminate(self, wait_timeout=30, _del=False): + def terminate(self, timeout=30, _del=False): """Terminate the ``exiftool`` process of this instance. If the subprocess isn't running, this method will do nothing. @@ -359,7 +359,7 @@ def terminate(self, wait_timeout=30, _del=False): On Linux, this runs as is, and the process terminates properly """ - self._process.communicate(input=b"-stay_open\nFalse\n", timeout=wait_timeout) # TODO these are constants which should be elsewhere defined + self._process.communicate(input=b"-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() From c8167e8dd19c8f4f80134f746c989e8ef524133a Mon Sep 17 00:00:00 2001 From: SylikC Date: Tue, 6 Apr 2021 23:51:27 -0700 Subject: [PATCH 057/251] change the way ExifTool.running is detected. In the event that exiftool dies or is killed, this is now detected by the property, rather than just relying on self._running --- exiftool/exiftool.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 955df0d..7be62f6 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -290,7 +290,7 @@ def run(self): However, you can override these default arguments with the ``common_args`` parameter in the constructor. """ - if self._running: + if self.running: warnings.warn("ExifTool already running; doing nothing.", UserWarning) return @@ -322,6 +322,7 @@ def run(self): stderr=subprocess.PIPE, preexec_fn=set_pdeathsig(signal.SIGTERM)) #stderr=devnull # 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 + # TODO check error before saying it's running except FileNotFoundError as fnfe: raise fnfe except OSError as oe: @@ -340,8 +341,7 @@ def terminate(self, timeout=30, _del=False): If the subprocess isn't running, this method will do nothing. """ - print("terminate") - if not self._running: + if not self.running: warnings.warn("ExifTool not running; doing nothing.", UserWarning) # TODO, maybe add an optional parameter that says ignore_running/check/force or something which will not warn return @@ -376,12 +376,12 @@ def __enter__(self): # ---------------------------------------------------------------------------------------------------------------------- def __exit__(self, exc_type, exc_val, exc_tb): - if self._running: + if self.running: self.terminate() # ---------------------------------------------------------------------------------------------------------------------- def __del__(self): - if self._running: + if self.running: # indicate that __del__ has been started - allows running alternate code path in terminate() self.terminate(_del=True) @@ -396,7 +396,7 @@ def executable(self, new_executable): Set the executable. Does error checking. """ # cannot set executable when process is running - if self._running: + if self.running: raise RuntimeError( 'Cannot set new executable while Exiftool is running' ) # Python 3.3+ required @@ -428,6 +428,15 @@ def block_size(self, new_block_size): @property def running(self): # read-only property + + 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._process = None + self._running = False + return self._running @@ -464,7 +473,7 @@ def execute(self, *params): .. note:: This is considered a low-level method, and should rarely be needed by application developers. """ - if not self._running: + if not self.running: raise RuntimeError("ExifTool instance not running.") # constant special sequences when running -stay_open mode From 8700d32d5c3b939753b61e0cc4c6a5c945700f25 Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 7 Apr 2021 18:04:56 -0700 Subject: [PATCH 058/251] fixed the bug that freezes on windows because stderr is empty on os.read() by telling exiftool to print out a synchronization token on stderr at the end of processing. This is similar to the {ready} sequence. This ensures there is ALWAYS data on stderr, and in turn, Windows will no longer block on the stderr read moved out the code for the fd read to a static function so we can use the same one for stdout and stderr --- exiftool/exiftool.py | 78 +++++++++++++++++++++++--------------------- 1 file changed, 41 insertions(+), 37 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 7be62f6..e359e5d 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -480,63 +480,37 @@ def execute(self, *params): SEQ_EXECUTE_FMT = "-execute{}\n" # this is the PYFORMAT ... the actual string is b"-execute\n" SEQ_READY_FMT = "{{ready{}}}" # this is the PYFORMAT ... the actual 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{}" + SEQ_STDERR_POST_FMT = "post{}" + # 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(10000000, 99999999) # arbitrary create a 8 digit number seq_execute = SEQ_EXECUTE_FMT.format(signal_num).encode(ENCODING_UTF8) seq_ready = SEQ_READY_FMT.format(signal_num).encode(ENCODING_UTF8) - endswith_count = len(seq_ready) + 4 # if we're only looking at the last few bytes, make it meaningful. 4 is max size of \r\n? (or 2) + #seq_err_pre = SEQ_STDERR_PRE_FMT.format(signal_num).encode(ENCODING_UTF8) + seq_err_post = SEQ_STDERR_POST_FMT.format(signal_num).encode(ENCODING_UTF8) - cmd_text = b"\n".join(params + (seq_execute,)) + cmd_text = b"\n".join(params + (b"-echo4",seq_err_post, seq_execute,)) # 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 self._process.stdin.write(cmd_text) self._process.stdin.flush() fdout = self._process.stdout.fileno() - - output = b"" - while not output[-endswith_count:].strip().endswith(seq_ready): - 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(fdout, self._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([fdout], [], []) - for i in inputready: - if i == fdout: - output += os.read(fdout, self._block_size) - + output = ExifTool._read_fd_endswith(fdout, seq_ready, self._block_size) # when it's ready, we can safely read all of stderr out, as the command is already done fderr = self._process.stderr.fileno() - - # TODO THIS CODE IS NOT YET TESTED ON LINUX, TEST BEFORE PUBLISH - outerr = b"" - eof_signal = False - while not eof_signal: - if constants.PLATFORM_WINDOWS: - outtmp = os.read(fderr, self._block_size) - if len(outtmp) == 0 or len(outtmp) < self._block_size: # TODO currently using a "hack" that if we read less bytes than we requested, EOF is coming ... getting a non-blocking Windows read is a complex endeavor - # break loop - eof_signal = True - - outerr += outtmp - else: - inputready,outputready,exceptready = select.select([fderr], [], [], 0) # set a timeout to 0, so this never blocks - if len(inputready) == 0: - eof_signal = True - else: - for i in inputready: - if i == fderr: - outerr += os.read(fderr, self._block_size) + outerr = ExifTool._read_fd_endswith(fderr, seq_err_post, self._block_size) # save the output to class vars for retrieval self._last_stdout = output.strip()[:-len(seq_ready)] - self._last_stderr = outerr + self._last_stderr = outerr.strip()[:-len(seq_err_post)] + print(self._last_stderr) if self._return_tuple: return (self._last_stdout, self._last_stderr,) @@ -880,3 +854,33 @@ def _check_sanity_of_result(file_paths, result): if returned_source_file != requested_file: raise IOError('exiftool returned data for file %s, but expected was %s' % (returned_source_file, requested_file)) + + + + # ---------------------------------------------------------------------------------------------------------------------- + @staticmethod + def _read_fd_endswith(fd, b_endswith, block_size): + """ read an fd and keep reading until it endswith the seq_ends + + this allows a consolidated read function that is platform indepdent + + if you're not careful, on windows, this will block + """ + output = b"" + endswith_count = len(b_endswith) + 4 # if we're only looking at the last few bytes, make it meaningful. 4 is max size of \r\n? (or 2) + + # 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 + 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: + # 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 From 1cff835e580f5f70c5f30aac7555f76e293e8850 Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 7 Apr 2021 20:30:08 -0700 Subject: [PATCH 059/251] extracted all high-level functionality out of the ExifTool class. the ExifToolHelper class currently contains the previous functionality in a verbatim way. No functionality was changed. --- exiftool/__init__.py | 3 +- exiftool/exiftool.py | 288 +------------------------------------ exiftool/helper.py | 331 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 336 insertions(+), 286 deletions(-) create mode 100644 exiftool/helper.py diff --git a/exiftool/__init__.py b/exiftool/__init__.py index 5a5baa0..772b7e4 100644 --- a/exiftool/__init__.py +++ b/exiftool/__init__.py @@ -3,6 +3,7 @@ # import as directory # make all of the original exiftool stuff available in this namespace -from .exiftool import * +from .exiftool import ExifTool +from .helper import ExifToolHelper from .constants import DEFAULT_EXECUTABLE diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index e359e5d..f5670b4 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -88,11 +88,7 @@ import random -# constants related to keywords manipulations -KW_TAGNAME = "IPTC:Keywords" -KW_REPLACE, KW_ADD, KW_REMOVE = range(3) - - +# constants to make typos obsolete! ENCODING_UTF8 = "utf-8" ENCODING_LATIN1 = "latin-1" @@ -510,7 +506,6 @@ def execute(self, *params): # save the output to class vars for retrieval self._last_stdout = output.strip()[:-len(seq_ready)] self._last_stderr = outerr.strip()[:-len(seq_err_post)] - print(self._last_stderr) if self._return_tuple: return (self._last_stdout, self._last_stderr,) @@ -519,44 +514,6 @@ def execute(self, *params): return self._last_stdout - # ---------------------------------------------------------------------------------------------------------------------- - # 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, 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 - - - # ---------------------------------------------------------------------------------------------------------------------- def execute_json(self, *params): """Execute the given batch of parameters and parse the JSON output. @@ -599,7 +556,7 @@ def execute_json(self, *params): # but, since it's technically not an error to have no files, # returning None is the best. # Even [] could be ambugious if Exiftool changes the returned JSON structure in the future - # TODO haven't decidedd on [] or None yet + # TODO haven't decided on [] or None yet return None @@ -607,7 +564,7 @@ def execute_json(self, *params): res_decoded = res.decode(ENCODING_UTF8) except UnicodeDecodeError: res_decoded = res.decode(ENCODING_LATIN1) - # res_decoded can be invalid json if `-w` flag is specified in common_args + # TODO 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 @@ -617,245 +574,6 @@ def execute_json(self, *params): # 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) - - # ---------------------------------------------------------------------------------------------------------------------- - def get_metadata_batch(self, filenames): - """Return all meta-data for the given files. - - The return value will have the format described in the - documentation of :py:meth:`execute_json()`. - """ - return self.execute_json(*filenames) - - # ---------------------------------------------------------------------------------------------------------------------- - # (#11) - def get_metadata_wrapper(self, filename, params=None): - return self.execute_json_wrapper(filenames=[filename], params=params)[0] - - # ---------------------------------------------------------------------------------------------------------------------- - def get_metadata(self, filename): - """Return meta-data for a single file. - - The returned dictionary has the format described in the - documentation of :py:meth:`execute_json()`. - """ - return self.execute_json(filename)[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 :. - - The second argument is an iterable of file names. - - The format of the return value is the same as for - :py:meth:`execute_json()`. - """ - # 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()`. - """ - 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. - - The first argument is a single tag name, as usual in the - format :. - - 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.""" - self.execute("-overwrite_original", "-TagsFromFile", fromFilename, toFilename) - - - # ---------------------------------------------------------------------------------------------------------------------- - def set_tags_batch(self, tags, filenames): - """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 :. - - 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()`. - """ - # 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(): - 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. - """ - 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. - - 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 = [] - params_utf8 = [] - - 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) - logging.debug (params) - - params_utf8 = [x.encode('utf-8') for x in params] - return self.execute(*params_utf8) - - # ---------------------------------------------------------------------------------------------------------------------- - def set_keywords(self, mode, keywords, filename): - """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(mode, keywords, [filename]) - - - - # ---------------------------------------------------------------------------------------------------------------------- - @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)) - - # ---------------------------------------------------------------------------------------------------------------------- @staticmethod diff --git a/exiftool/helper.py b/exiftool/helper.py new file mode 100644 index 0000000..4787014 --- /dev/null +++ b/exiftool/helper.py @@ -0,0 +1,331 @@ +# -*- coding: utf-8 -*- +# 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 + +""" + + +from .exiftool import ExifTool + + + +# ====================================================================================================================== + +#def atexit_handler + +# constants related to keywords manipulations +KW_TAGNAME = "IPTC:Keywords" +KW_REPLACE, KW_ADD, KW_REMOVE = range(3) + + +# ====================================================================================================================== + +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 + """ + + # ---------------------------------------------------------------------------------------------------------------------- + def __init__(self, executable=None, common_args=None, win_shell=True, return_tuple=False): + # call parent's constructor + super().__init__(executable=executable, common_args=common_args, win_shell=win_shell, return_tuple=return_tuple) + + + + # ---------------------------------------------------------------------------------------------------------------------- + #def metadata_json(self, filenames): + # pass + + # ---------------------------------------------------------------------------------------------------------------------- + # 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: + ExifToolHelper._check_sanity_of_result(filenames, result) + except (IOError, 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) + + # ---------------------------------------------------------------------------------------------------------------------- + def get_metadata_batch(self, filenames): + """Return all meta-data for the given files. + + The return value will have the format described in the + documentation of :py:meth:`execute_json()`. + """ + return self.execute_json(*filenames) + + # ---------------------------------------------------------------------------------------------------------------------- + # (#11) + def get_metadata_wrapper(self, filename, params=None): + return self.execute_json_wrapper(filenames=[filename], params=params)[0] + + # ---------------------------------------------------------------------------------------------------------------------- + def get_metadata(self, filename): + """Return meta-data for a single file. + + The returned dictionary has the format described in the + documentation of :py:meth:`execute_json()`. + """ + return self.execute_json(filename)[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 :. + + The second argument is an iterable of file names. + + The format of the return value is the same as for + :py:meth:`execute_json()`. + """ + # 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()`. + """ + 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. + + The first argument is a single tag name, as usual in the + format :. + + 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.""" + self.execute("-overwrite_original", "-TagsFromFile", fromFilename, toFilename) + + + # ---------------------------------------------------------------------------------------------------------------------- + def set_tags_batch(self, tags, filenames): + """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 :. + + 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()`. + """ + # 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(): + 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. + """ + 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. + + 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 = [] + params_utf8 = [] + + 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) + logging.debug (params) + + params_utf8 = [x.encode('utf-8') for x in params] + return self.execute(*params_utf8) + + # ---------------------------------------------------------------------------------------------------------------------- + def set_keywords(self, mode, keywords, filename): + """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(mode, keywords, [filename]) + + + + # ---------------------------------------------------------------------------------------------------------------------- + @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)) + From 06a8fac8b5f34e8a4a878aa25ac6dbfdea3feb91 Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 7 Apr 2021 20:57:15 -0700 Subject: [PATCH 060/251] consolidate the get_metadata functionality to take either a str or a list(str) and ALWAYS return a list straight from execute_json. This fixes a bug that if a wildcard is passed as the input to filename, the old way would just return the first file ([0]) which is incorrect. --- exiftool/exiftool.py | 6 ------ exiftool/helper.py | 28 +++++++++++++++++----------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index f5670b4..33aa4eb 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -76,12 +76,6 @@ import signal import ctypes -try: # Py3k compatibility - basestring -except NameError: - basestring = (bytes, str) - - from . import constants #from pathlib import Path # requires Python 3.4+ diff --git a/exiftool/helper.py b/exiftool/helper.py index 4787014..fc51f57 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -26,6 +26,10 @@ from .exiftool import ExifTool +try: # Py3k compatibility + basestring +except NameError: + basestring = (bytes, str) # ====================================================================================================================== @@ -97,28 +101,30 @@ def get_metadata_batch_wrapper(self, filenames, params=None): return self.execute_json_wrapper(filenames=filenames, params=params) # ---------------------------------------------------------------------------------------------------------------------- - def get_metadata_batch(self, filenames): + def get_metadata(self, in_param): """Return all meta-data for the given files. + + This will ALWAYS return a list + + in_param can be a list(strings) or a string. + + 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:`execute_json()`. """ - return self.execute_json(*filenames) + if isinstance(in_param, basestring): + return self.execute_json(in_param) + elif isinstance(in_param, list): + return self.execute_json(*in_param) + else: + raise TypeError("get_metadata only accepts a str/bytes or a list") # ---------------------------------------------------------------------------------------------------------------------- # (#11) def get_metadata_wrapper(self, filename, params=None): return self.execute_json_wrapper(filenames=[filename], params=params)[0] - # ---------------------------------------------------------------------------------------------------------------------- - def get_metadata(self, filename): - """Return meta-data for a single file. - - The returned dictionary has the format described in the - documentation of :py:meth:`execute_json()`. - """ - return self.execute_json(filename)[0] - # ---------------------------------------------------------------------------------------------------------------------- # (#11) def get_tags_batch_wrapper(self, tags, filenames, params=None): From f36acb209f611c1ca8a0a98db81ff9db05d87796 Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 7 Apr 2021 21:21:51 -0700 Subject: [PATCH 061/251] fixed a bug where if the filename is a number (it doesn't happen often but it can), filename is an int, and the the fsencode blows up --- exiftool/exiftool.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 33aa4eb..f92795c 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -111,7 +111,8 @@ def fsencode(filename): if isinstance(filename, bytes): return filename else: - return filename.encode(encoding, errors) + # cannot assume that filename will be a str. In the off-chance we're using a filename which is a number, this will throw an error + return str(filename).encode(encoding, errors) return fsencode From ca5141b9ec09e7bd9a17874fe77df3956baa80e0 Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 7 Apr 2021 21:57:16 -0700 Subject: [PATCH 062/251] remove get_tags and rename get_tags_batch to get_tags, similar to the get_metadata. changed the way to check for an iterable, so that you can pass arbitrary iterables to the function --- exiftool/helper.py | 80 ++++++++++++++++++++++++++++++---------------- 1 file changed, 53 insertions(+), 27 deletions(-) diff --git a/exiftool/helper.py b/exiftool/helper.py index fc51f57..59dd55f 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -23,6 +23,7 @@ """ +import logging from .exiftool import ExifTool @@ -101,24 +102,26 @@ def get_metadata_batch_wrapper(self, filenames, params=None): return self.execute_json_wrapper(filenames=filenames, params=params) # ---------------------------------------------------------------------------------------------------------------------- - def get_metadata(self, in_param): + def get_metadata(self, in_files): """Return all meta-data for the given files. This will ALWAYS return a list - in_param can be a list(strings) or a string. + in_files can be an iterable(strings) or a string. 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:`execute_json()`. """ - if isinstance(in_param, basestring): - return self.execute_json(in_param) - elif isinstance(in_param, list): - return self.execute_json(*in_param) + if isinstance(in_files, basestring): + return self.execute_json(in_files) else: - raise TypeError("get_metadata only accepts a str/bytes or a list") + if not ExifToolHelper._check_iterable(in_files): + raise TypeError("The argument 'in_files' must be a str/bytes or an iterable") + + return self.execute_json(*in_files) + # ---------------------------------------------------------------------------------------------------------------------- # (#11) @@ -132,7 +135,7 @@ def get_tags_batch_wrapper(self, tags, filenames, params=None): return self.execute_json_wrapper(filenames=filenames, params=params) # ---------------------------------------------------------------------------------------------------------------------- - def get_tags_batch(self, tags, filenames): + def get_tags(self, in_tags, in_files): """Return only specified tags for the given files. The first argument is an iterable of tags. The tag names may @@ -143,32 +146,36 @@ def get_tags_batch(self, tags, filenames): The format of the return value is the same as for :py:meth:`execute_json()`. """ - # 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") + + tags = None + files = None + + if isinstance(in_tags, basestring): + tags = [in_tags] + elif ExifToolHelper._check_iterable(in_tags): + tags = in_tags + else: + raise TypeError("The argument 'in_tags' must be a str/bytes or a list") + + + if isinstance(in_files, basestring): + files = [in_files] + elif ExifToolHelper._check_iterable(in_files): + files = in_files + else: + raise TypeError("The argument 'in_files' must be a str/bytes or a list") + params = ["-" + t for t in tags] - params.extend(filenames) + params.extend(files) + 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()`. - """ - return self.get_tags_batch(tags, [filename])[0] - # ---------------------------------------------------------------------------------------------------------------------- # (#11) def get_tag_batch_wrapper(self, tag, filenames, params=None): @@ -192,7 +199,7 @@ def get_tag_batch(self, tag, filenames): 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) + data = self.get_tags([tag], filenames) result = [] for d in data: d.pop("SourceFile") @@ -335,3 +342,22 @@ def _check_sanity_of_result(file_paths, result): raise IOError('exiftool returned data for file %s, but expected was %s' % (returned_source_file, requested_file)) + # ---------------------------------------------------------------------------------------------------------------------- + @staticmethod + def _check_iterable(in_param): + """ + 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 + \ No newline at end of file From a4b5710aca7e5468003b2dd04cc42721d957c825 Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 7 Apr 2021 21:58:58 -0700 Subject: [PATCH 063/251] fix tests to isolate the ExifTool and ExifToolHelper tests changes the way tests are run against both the base class and the helper class --- tests/test_exiftool.py | 87 ------------------------------- tests/test_helper.py | 114 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 87 deletions(-) create mode 100644 tests/test_helper.py diff --git a/tests/test_exiftool.py b/tests/test_exiftool.py index 8ea8fd9..0cc4780 100644 --- a/tests/test_exiftool.py +++ b/tests/test_exiftool.py @@ -75,93 +75,6 @@ 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) #--------------------------------------------------------------------------------------------------------- diff --git a/tests/test_helper.py b/tests/test_helper.py new file mode 100644 index 0000000..51cf9b4 --- /dev/null +++ b/tests/test_helper.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +import unittest +import exiftool +import warnings +import os +import shutil +import sys + +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() + if hasattr(self, "process"): + if self.process.poll() is None: + self.process.terminate() + #--------------------------------------------------------------------------------------------------------- + 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(source_files) + tags0 = self.et.get_tags(["XMP:Subject"], source_files[0])[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.helper.KW_REPLACE, d["Keywords"], f_mod) + kwtag0 = self.et.get_tag("IPTC:Keywords", f_mod) + kwrest = d["Keywords"][1:] + self.et.set_keywords(exiftool.helper.KW_REMOVE, kwrest, f_mod) + kwtag1 = self.et.get_tag("IPTC:Keywords", f_mod) + self.et.set_keywords(exiftool.helper.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) + + +#--------------------------------------------------------------------------------------------------------- +if __name__ == '__main__': + unittest.main() From dbc4c188c3743cc3d1eb4511455ae72c5ca6de12 Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 7 Apr 2021 22:05:05 -0700 Subject: [PATCH 064/251] moved for extraneous functionality over from exiftool to helper --- exiftool/exiftool.py | 47 ---------------------------------------- exiftool/helper.py | 51 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 47 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index f92795c..90f34ff 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -138,53 +138,6 @@ def callable_method(): else: return None -# ====================================================================================================================== - - - -#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 ExifTool(object): diff --git a/exiftool/helper.py b/exiftool/helper.py index 59dd55f..d0c06bf 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -42,6 +42,57 @@ 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 ExifToolHelper(ExifTool): From 46411dc5205e7d638b162ab69e24ffc6f6cecc96 Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 8 Apr 2021 01:17:06 -0700 Subject: [PATCH 065/251] fixed the tests and now it's entirely working on Windows with the kill() --- exiftool/exiftool.py | 4 +++- tests/test_exiftool.py | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 90f34ff..ef3f860 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -140,6 +140,7 @@ def callable_method(): # ====================================================================================================================== + class ExifTool(object): """Run the `exiftool` command-line tool and communicate to it. @@ -290,9 +291,10 @@ def terminate(self, timeout=30, _del=False): # TODO, maybe add an optional parameter that says ignore_running/check/force or something which will not warn return - if _del and constants.PLATFORM_WINDOWS: + if _del and constants.PLATFORM_WINDOWS and 0: # don't cleanly exit on windows, during __del__ as it'll freeze at communicate() self._process.kill() + outs, errs = proc.communicate() # have to cleanup the process or else .poll() will return None else: try: """ diff --git a/tests/test_exiftool.py b/tests/test_exiftool.py index 0cc4780..0472081 100644 --- a/tests/test_exiftool.py +++ b/tests/test_exiftool.py @@ -16,7 +16,8 @@ def setUp(self): self.et = exiftool.ExifTool(common_args=["-G", "-n", "-overwrite_original"]) def tearDown(self): if hasattr(self, "et"): - self.et.terminate() + if self.et.running: + self.et.terminate() if hasattr(self, "process"): if self.process.poll() is None: self.process.terminate() From 17cf6eaaeed2a371c177d2011ac32b6d4d2288cf Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 8 Apr 2021 15:30:26 -0700 Subject: [PATCH 066/251] merge the handling of of get_metadata and get_tags... since they essentially do the same thing. get_metadata is a special case alias of get_tags --- exiftool/helper.py | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/exiftool/helper.py b/exiftool/helper.py index d0c06bf..6a44e45 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -106,11 +106,6 @@ def __init__(self, executable=None, common_args=None, win_shell=True, return_tup super().__init__(executable=executable, common_args=common_args, win_shell=win_shell, return_tuple=return_tuple) - - # ---------------------------------------------------------------------------------------------------------------------- - #def metadata_json(self, filenames): - # pass - # ---------------------------------------------------------------------------------------------------------------------- # 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): @@ -165,14 +160,7 @@ def get_metadata(self, in_files): The return value will have the format described in the documentation of :py:meth:`execute_json()`. """ - if isinstance(in_files, basestring): - return self.execute_json(in_files) - else: - if not ExifToolHelper._check_iterable(in_files): - raise TypeError("The argument 'in_files' must be a str/bytes or an iterable") - - return self.execute_json(*in_files) - + self.get_tags(None, in_files) # ---------------------------------------------------------------------------------------------------------------------- # (#11) @@ -191,6 +179,8 @@ def get_tags(self, in_tags, in_files): The first argument is an iterable of tags. The tag names may include group names, as usual in the format :. + + If in_tags is None, or [], then returns all tags The second argument is an iterable of file names. @@ -201,7 +191,10 @@ def get_tags(self, in_tags, in_files): tags = None files = None - if isinstance(in_tags, basestring): + if in_tags is None: + # all tags + tags = [] + elif isinstance(in_tags, basestring): tags = [in_tags] elif ExifToolHelper._check_iterable(in_tags): tags = in_tags @@ -216,7 +209,11 @@ def get_tags(self, in_tags, in_files): else: raise TypeError("The argument 'in_files' must be a str/bytes or a list") - params = ["-" + t for t in tags] + if tags is None: + params = [] + else: + params = ["-" + t for t in tags] + params.extend(files) return self.execute_json(*params) From 370b7a73c746a33f5e245e41e3db0fb5dd3051cf Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 8 Apr 2021 15:30:45 -0700 Subject: [PATCH 067/251] fix the warning on helper_test so it doesn't terminate if not running --- tests/test_helper.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_helper.py b/tests/test_helper.py index 51cf9b4..81aa5ad 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -16,7 +16,8 @@ def setUp(self): self.et = exiftool.ExifToolHelper(common_args=["-G", "-n", "-overwrite_original"]) def tearDown(self): if hasattr(self, "et"): - self.et.terminate() + if self.et.running: + self.et.terminate() if hasattr(self, "process"): if self.process.poll() is None: self.process.terminate() From 7bf3c17452b3221d702b8dbe05c8a80d11c1484c Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 8 Apr 2021 15:38:46 -0700 Subject: [PATCH 068/251] fix the bug as discussed on Pull request #7 --- exiftool/exiftool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 580e340..d7615b8 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -563,7 +563,7 @@ def get_metadata(self, filename, params=None): documentation of :py:meth:`execute_json()`. """ if not params: - params = None + params = [] return self.execute_json(filename, *params)[0] # (#11) From 319704980a441ed6c02c30c170aed576fc9ae9b6 Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 8 Apr 2021 21:30:25 -0700 Subject: [PATCH 069/251] fix small bugs in the merging and make sure all tests pass --- exiftool/exiftool.py | 2 +- exiftool/helper.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 56f247b..04decf5 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -250,7 +250,7 @@ def run(self): if self._config_file: 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 + proc_args.extend(self._common_args) # add the common arguments logging.debug(proc_args) diff --git a/exiftool/helper.py b/exiftool/helper.py index 092d175..bc50ab4 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -163,7 +163,7 @@ def get_metadata(self, in_files, params=None): if not params: params = [] - self.get_tags(None, in_files, params=params) + return self.get_tags(None, in_files, params=params) # ---------------------------------------------------------------------------------------------------------------------- # (#11) @@ -220,7 +220,7 @@ def get_tags(self, in_tags, in_files, params=None): else: exec_params.extend(params) - if tags is not None: + if tags is None: pass else: exec_params.extend( ["-" + t for t in tags] ) From e65775f67f83e9b94131e5ee6bcf6fe55092dac3 Mon Sep 17 00:00:00 2001 From: SylikC Date: Fri, 9 Apr 2021 02:32:16 -0700 Subject: [PATCH 070/251] add the test that warns when the process dies after it was detected as running --- tests/test_exiftool.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_exiftool.py b/tests/test_exiftool.py index 0472081..fbd2f1e 100644 --- a/tests/test_exiftool.py +++ b/tests/test_exiftool.py @@ -72,6 +72,20 @@ def test_termination_implicit(self): 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 + self.et.run() + self.process = self.et._process + self.assertTrue(self.et.running) + # kill the process, out of ExifTool's control + self.process.kill() + outs, errs = self.process.communicate() + + with warnings.catch_warnings(record=True) as w: + self.assertFalse(self.et.running) + self.assertEquals(len(w), 1) + self.assertTrue(issubclass(w[0].category, UserWarning)) + #--------------------------------------------------------------------------------------------------------- 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): From b85ac1ac2dcbdacf54756d9d5af94902644227b3 Mon Sep 17 00:00:00 2001 From: SylikC Date: Sat, 17 Apr 2021 00:41:12 -0700 Subject: [PATCH 071/251] added the config_file property to do error checking changed the config_file setting code, to use the property.setter to do error checking added a check of exiftool running prior to clearing the .run() method --- exiftool/exiftool.py | 77 ++++++++++++++++++++++++++++++++++++-------- exiftool/helper.py | 6 ++-- 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 04decf5..539d3be 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -78,7 +78,7 @@ from . import constants -#from pathlib import Path # requires Python 3.4+ +from pathlib import Path # requires Python 3.4+ import random @@ -191,10 +191,12 @@ def __init__(self, executable=None, common_args=None, win_shell=True, return_tup random.seed(None) # initialize random number generator # default settings + self._running = False # is it running? self._executable = None # executable absolute path self._win_shell = 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._running = False # is it running? + self._config_file = None # config file that can only be set when exiftool is not running self._return_tuple = return_tuple # are we returning a tuple in the execute? self._last_stdout = None # previous output @@ -207,13 +209,16 @@ def __init__(self, executable=None, common_args=None, win_shell=True, return_tup # set to default block size self._block_size = constants.DEFAULT_BLOCK_SIZE - self._common_args = common_args - # it can't be none, check if it's a list, if not, error + # set the property, error checking happens in the property.setter + self.config_file = config_file - if config_file and not os.path.exists(config_file): - raise FileNotFoundError("The config file could not be found") - self._config_file = config_file + + + # TODO set this as a property, and may not use these defaults if they cause errors (I recall seeing an issue filed) + + # it can't be none, check if it's a list, if not, error + self._common_args = common_args if common_args is None: # default parameters to exiftool @@ -227,6 +232,7 @@ def __init__(self, executable=None, common_args=None, win_shell=True, return_tup self._no_output = '-w' in self._common_args + # ---------------------------------------------------------------------------------------------------------------------- def run(self): """Start an ``exiftool`` process in batch mode for this instance. @@ -246,9 +252,11 @@ def run(self): # TODO changing common args means it needs a restart, or error, have a restart=True for change common_args or error if running 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: 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 @@ -262,12 +270,11 @@ def run(self): # 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 - + self._process = subprocess.Popen( proc_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, startupinfo=startup_info) #stderr=devnull - # TODO check error before saying it's running else: # assume it's linux self._process = subprocess.Popen( @@ -276,7 +283,6 @@ def run(self): stderr=subprocess.PIPE, preexec_fn=set_pdeathsig(signal.SIGTERM)) #stderr=devnull # 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 - # TODO check error before saying it's running except FileNotFoundError as fnfe: raise fnfe except OSError as oe: @@ -285,8 +291,13 @@ def run(self): raise ve except subprocess.CalledProcessError as cpe: raise cpe + # TODO print out more useful error messages to these different errors above # check error above before saying it's running + if self._process.poll() is not None: + # the Popen launched, then process terminated + raise RuntimeError("exiftool did not execute successfully") + self._running = True # ---------------------------------------------------------------------------------------------------------------------- @@ -300,10 +311,14 @@ def terminate(self, timeout=30, _del=False): # TODO, maybe add an optional parameter that says ignore_running/check/force or something which will not warn return - if _del and constants.PLATFORM_WINDOWS and 0: + 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 = proc.communicate() # have to cleanup the process or else .poll() will return None + #print("before comm", self._process.poll(), self._process) + self._process.kill() + 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 else: try: """ @@ -324,6 +339,10 @@ def terminate(self, timeout=30, _del=False): self._process = None # don't delete, just leave as None self._running = False + + + + # ---------------------------------------------------------------------------------------------------------------------- def __enter__(self): self.run() @@ -340,6 +359,10 @@ def __del__(self): # indicate that __del__ has been started - allows running alternate code path in terminate() self.terminate(_del=True) + + + + # ---------------------------------------------------------------------------------------------------------------------- @property def executable(self): @@ -395,6 +418,31 @@ def running(self): return self._running + # ---------------------------------------------------------------------------------------------------------------------- + @property + def config_file(self): + return self._config_file + + @config_file.setter + def config_file(self, new_config_file): + """ set the config_file parameter + + if running==True, it will throw an error. Can only set config_file when exiftool is not running + """ + if self.running: + raise RuntimeError("cannot set a new config_file while exiftool is running!") + + if new_config_file is None: + self._config_file = None + elif not Path(new_config_file).exists(): + raise FileNotFoundError("The config file could not be found") + else: + self._config_file = new_config_file + + + + + # ---------------------------------------------------------------------------------------------------------------------- @property def last_stdout(self): @@ -407,6 +455,8 @@ def last_stderr(self): """last output stderr from execute()""" return self._last_stderr + + # ---------------------------------------------------------------------------------------------------------------------- def execute(self, *params): @@ -504,6 +554,7 @@ def execute_json(self, *params): std = self.execute(b"-j", *params) if self._return_tuple: + # get stdout only res = std[0] else: res = std @@ -523,7 +574,7 @@ def execute_json(self, *params): res_decoded = res.decode(ENCODING_UTF8) except UnicodeDecodeError: res_decoded = res.decode(ENCODING_LATIN1) - # TODO res_decoded can be invalid json if `-w` flag is specified in common_args + # TODO res_decoded can be invalid json (test this) if `-w` flag is specified in common_args # which will return something like # image files read # output files created diff --git a/exiftool/helper.py b/exiftool/helper.py index bc50ab4..6b7285e 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -220,10 +220,8 @@ def get_tags(self, in_tags, in_files, params=None): else: exec_params.extend(params) - if tags is None: - pass - else: - exec_params.extend( ["-" + t for t in tags] ) + # tags is always a list by this point. It will always be iterable... don't have to check for None + exec_params.extend( ["-" + t for t in tags] ) exec_params.extend(files) From 9ad8706618341714698d5326b98ecd741d6f7b7d Mon Sep 17 00:00:00 2001 From: SylikC Date: Sat, 17 Apr 2021 00:46:03 -0700 Subject: [PATCH 072/251] commented out the fscodec() code that was first introduced in commit 843895ec6 back in 2012 I was unable to find the reference to where the code comes from the cpython source tree. I would anticipate the old code from that commit was to deal with Python 2's str and unicode types that are no longer relevant. tests still all pass. If it ever is a problem, can add the code right back in since it was just commented out, not removed --- exiftool/exiftool.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 539d3be..c4997e5 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -88,7 +88,7 @@ # ====================================================================================================================== - +""" # This code has been adapted from Lib/os.py in the Python source tree # (sha1 265e36e277f3) def _fscodec(): @@ -103,11 +103,11 @@ def _fscodec(): 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: @@ -118,6 +118,7 @@ def fsencode(filename): fsencode = _fscodec() del _fscodec +""" # ====================================================================================================================== @@ -546,7 +547,7 @@ 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) + params = map(os.fsencode, params) # Some latin bytes won't decode to utf-8. # Try utf-8 and fallback to latin. # http://stackoverflow.com/a/5552623/1318758 From 8abc387f4d0b5ae2e500a6963575ae52c2c8fa8b Mon Sep 17 00:00:00 2001 From: SylikC Date: Sat, 17 Apr 2021 15:15:47 -0700 Subject: [PATCH 073/251] as per recommendations in consistent testing, I wrote some Windows batch runner scripts to test various aspects... someone can help write the equivalent Linux scripts (or I probably will at some point) These should help CI/CD later --- scripts/mypy.bat | 16 ++++++++++++++++ scripts/pytest-cov.bat | 16 ++++++++++++++++ scripts/unittest.bat | 14 ++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 scripts/mypy.bat create mode 100644 scripts/pytest-cov.bat create mode 100644 scripts/unittest.bat diff --git a/scripts/mypy.bat b/scripts/mypy.bat new file mode 100644 index 0000000..7014872 --- /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 -m pip show mypy | findstr /l /c:"Version:" +echo ______________________ + +mypy --config-file mypy.ini --strict exiftool/ + + +popd \ No newline at end of file diff --git a/scripts/pytest-cov.bat b/scripts/pytest-cov.bat new file mode 100644 index 0000000..803b965 --- /dev/null +++ b/scripts/pytest-cov.bat @@ -0,0 +1,16 @@ +@echo off + +pushd %~dp0.. + +echo ______________________ +echo *** PyExifTool automation *** +echo PyTest Coverage Script +echo; +echo pip's PyTest version +python -m pip show pytest | findstr /l /c:"Version:" +echo ______________________ + + +python.exe -m pytest -v --cov --cov-report term-missing + +popd \ No newline at end of file diff --git a/scripts/unittest.bat b/scripts/unittest.bat new file mode 100644 index 0000000..6f639cb --- /dev/null +++ b/scripts/unittest.bat @@ -0,0 +1,14 @@ +@echo off + +pushd %~dp0.. + +echo ______________________ +echo *** PyExifTool automation *** +echo Python Built-in Unittest Script +echo ______________________ + + +python -m unittest -v + +popd + From 42c794b1e8e2c007bbd1271c37b62d1868c29871 Mon Sep 17 00:00:00 2001 From: SylikC Date: Sat, 17 Apr 2021 15:16:10 -0700 Subject: [PATCH 074/251] start with legwork of getting annotations and such working with mypy, and using monkeytype to get some initial recommendations on typing --- exiftool/exiftool.py | 26 +++++++++++++++++--------- mypy.ini | 2 ++ 2 files changed, 19 insertions(+), 9 deletions(-) create mode 100644 mypy.ini diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index c4997e5..3870418 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -67,7 +67,7 @@ try: import ujson as json except ImportError: - import json + import json # type: ignore # comment related to https://github.com/python/mypy/issues/1153 import warnings import logging import codecs @@ -82,6 +82,11 @@ import random +# for type checking - Python 3.5+ +from collections.abc import Callable +from typing import Optional + + # constants to make typos obsolete! ENCODING_UTF8 = "utf-8" ENCODING_LATIN1 = "latin-1" @@ -122,7 +127,7 @@ def fsencode(filename): # ====================================================================================================================== -def set_pdeathsig(sig=signal.SIGTERM): +def set_pdeathsig(sig=signal.SIGTERM) -> Optional[Callable]: """ Use this method in subprocess.Popen(preexec_fn=set_pdeathsig()) to make sure, the exiftool childprocess is stopped if this process dies. @@ -187,7 +192,7 @@ class ExifTool(object): """ # ---------------------------------------------------------------------------------------------------------------------- - def __init__(self, executable=None, common_args=None, win_shell=True, return_tuple=False, config_file=None): + def __init__(self, executable: Optional[str] = None, common_args=None, win_shell:bool=True, return_tuple:bool=False, config_file:Optional[str]=None) -> None: random.seed(None) # initialize random number generator @@ -235,7 +240,7 @@ def __init__(self, executable=None, common_args=None, win_shell=True, return_tup # ---------------------------------------------------------------------------------------------------------------------- - def run(self): + def run(self) -> None: """Start an ``exiftool`` process in batch mode for this instance. This method will issue a ``UserWarning`` if the subprocess is @@ -246,6 +251,9 @@ def run(self): 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 you have another executable named exiftool which isn't exiftool, that's your fault """ if self.running: warnings.warn("ExifTool already running; doing nothing.", UserWarning) @@ -302,7 +310,7 @@ def run(self): self._running = True # ---------------------------------------------------------------------------------------------------------------------- - def terminate(self, timeout=30, _del=False): + 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. @@ -345,17 +353,17 @@ def terminate(self, timeout=30, _del=False): # ---------------------------------------------------------------------------------------------------------------------- - def __enter__(self): + def __enter__(self) -> None: self.run() return self # ---------------------------------------------------------------------------------------------------------------------- - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__(self, exc_type, exc_val, exc_tb) -> None: if self.running: self.terminate() # ---------------------------------------------------------------------------------------------------------------------- - def __del__(self): + def __del__(self) -> None: if self.running: # indicate that __del__ has been started - allows running alternate code path in terminate() self.terminate(_del=True) @@ -405,7 +413,7 @@ def block_size(self, new_block_size): # ---------------------------------------------------------------------------------------------------------------------- @property - def running(self): + def running(self) -> bool: # read-only property if self._running: 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 From 00df51b6f80d5ef1afcf21f56e054398412822fa Mon Sep 17 00:00:00 2001 From: SylikC Date: Sat, 17 Apr 2021 15:41:38 -0700 Subject: [PATCH 075/251] mypy helped fix a bug in terminate() referring to a bad variable in TimeoutExpired clause --- exiftool/exiftool.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 3870418..2b37aaf 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -84,7 +84,7 @@ # for type checking - Python 3.5+ from collections.abc import Callable -from typing import Optional +from typing import Optional, List # constants to make typos obsolete! @@ -192,28 +192,28 @@ class ExifTool(object): """ # ---------------------------------------------------------------------------------------------------------------------- - def __init__(self, executable: Optional[str] = None, common_args=None, win_shell:bool=True, return_tuple:bool=False, config_file:Optional[str]=None) -> None: + def __init__(self, executable: Optional[str] = None, common_args=None, win_shell: bool = True, return_tuple: bool = False, config_file: Optional[str] = None) -> None: random.seed(None) # initialize random number generator # default settings - self._running = False # is it running? - self._executable = None # executable absolute path - self._win_shell = win_shell # do you want to see the shell on Windows? + self._running: bool = False # is it running? + self._executable: Optional[str] = None # executable absolute path + 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._config_file = None # config file that can only be set when exiftool is not running + self._config_file: Optional[str] = None # config file that can only be set when exiftool is not running - self._return_tuple = return_tuple # are we returning a tuple in the execute? - self._last_stdout = None # previous output - self._last_stderr = None # previous stderr + self._return_tuple: bool = return_tuple # are we returning a tuple in the execute? + self._last_stdout: Optional[str] = None # previous output + self._last_stderr: Optional[str] = None # previous stderr # use the passed in parameter, or the default if not set # error checking is done in the property.setter self.executable = executable if executable is not None else constants.DEFAULT_EXECUTABLE # set to default block size - self._block_size = constants.DEFAULT_BLOCK_SIZE + self._block_size: int = constants.DEFAULT_BLOCK_SIZE # set the property, error checking happens in the property.setter self.config_file = config_file @@ -224,7 +224,7 @@ def __init__(self, executable: Optional[str] = None, common_args=None, win_shell # TODO set this as a property, and may not use these defaults if they cause errors (I recall seeing an issue filed) # it can't be none, check if it's a list, if not, error - self._common_args = common_args + self._common_args: List[str] if common_args is None: # default parameters to exiftool @@ -342,7 +342,7 @@ def terminate(self, timeout: int = 30, _del: bool = False) -> None: 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 = proc.communicate() + outs, errs = self._process.communicate() # err handling code from https://docs.python.org/3/library/subprocess.html#subprocess.Popen.communicate self._process = None # don't delete, just leave as None @@ -387,7 +387,7 @@ def executable(self, new_executable): raise RuntimeError( 'Cannot set new executable while Exiftool is running' ) # Python 3.3+ required - abs_path = shutil.which(new_executable) + abs_path: Optional[str] = shutil.which(new_executable) if abs_path is None: raise FileNotFoundError( '"{}" is not found, on path or as absolute path'.format(new_executable) ) @@ -402,7 +402,7 @@ def block_size(self): return self._block_size @block_size.setter - def block_size(self, new_block_size): + def block_size(self, new_block_size: int): """ Set the block_size. Does error checking. """ @@ -454,13 +454,13 @@ def config_file(self, new_config_file): # ---------------------------------------------------------------------------------------------------------------------- @property - def last_stdout(self): + def last_stdout(self) -> Optional[str]: """last output stdout from execute()""" return self._last_stdout # ---------------------------------------------------------------------------------------------------------------------- @property - def last_stderr(self): + def last_stderr(self) -> Optional[str]: """last output stderr from execute()""" return self._last_stderr @@ -596,7 +596,7 @@ def execute_json(self, *params): # ---------------------------------------------------------------------------------------------------------------------- @staticmethod - def _read_fd_endswith(fd, b_endswith, block_size): + def _read_fd_endswith(fd, b_endswith, block_size: int): """ read an fd and keep reading until it endswith the seq_ends this allows a consolidated read function that is platform indepdent From fc234d5366d08682b4364c6cec9a2604cd6a174a Mon Sep 17 00:00:00 2001 From: SylikC Date: Sat, 17 Apr 2021 22:20:19 -0700 Subject: [PATCH 076/251] simplify the format of the sequences with Python 3.6 f-strings --- exiftool/exiftool.py | 35 +++++++++++++++-------------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 2b37aaf..8af0f40 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -353,7 +353,7 @@ def terminate(self, timeout: int = 30, _del: bool = False) -> None: # ---------------------------------------------------------------------------------------------------------------------- - def __enter__(self) -> None: + def __enter__(self): self.run() return self @@ -378,7 +378,7 @@ def executable(self): return self._executable @executable.setter - def executable(self, new_executable): + def executable(self, new_executable) -> None: """ Set the executable. Does error checking. """ @@ -390,7 +390,7 @@ def executable(self, new_executable): abs_path: Optional[str] = shutil.which(new_executable) if abs_path is None: - raise FileNotFoundError( '"{}" is not found, on path or as absolute path'.format(new_executable) ) + raise FileNotFoundError( f'"{new_executable}" is not found, on path or as absolute path' ) # absolute path is returned self._executable = abs_path @@ -398,11 +398,11 @@ def executable(self, new_executable): # ---------------------------------------------------------------------------------------------------------------------- @property - def block_size(self): + def block_size(self) -> int: return self._block_size @block_size.setter - def block_size(self, new_block_size: int): + def block_size(self, new_block_size: int) -> None: """ Set the block_size. Does error checking. """ @@ -429,11 +429,11 @@ def running(self) -> bool: # ---------------------------------------------------------------------------------------------------------------------- @property - def config_file(self): + def config_file(self) -> Optional[str]: return self._config_file @config_file.setter - def config_file(self, new_config_file): + def config_file(self, new_config_file) -> None: """ set the config_file parameter if running==True, it will throw an error. Can only set config_file when exiftool is not running @@ -490,23 +490,18 @@ def execute(self, *params): if not self.running: raise RuntimeError("ExifTool instance not running.") - # constant special sequences when running -stay_open mode - SEQ_EXECUTE_FMT = "-execute{}\n" # this is the PYFORMAT ... the actual string is b"-execute\n" - SEQ_READY_FMT = "{{ready{}}}" # this is the PYFORMAT ... the actual 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{}" - SEQ_STDERR_POST_FMT = "post{}" - # 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(10000000, 99999999) # arbitrary create a 8 digit number - seq_execute = SEQ_EXECUTE_FMT.format(signal_num).encode(ENCODING_UTF8) - seq_ready = SEQ_READY_FMT.format(signal_num).encode(ENCODING_UTF8) + signal_num = random.randint(100000, 999999) # arbitrary create a 6 digit number (keep it down to save memory maybe) - #seq_err_pre = SEQ_STDERR_PRE_FMT.format(signal_num).encode(ENCODING_UTF8) - seq_err_post = SEQ_STDERR_POST_FMT.format(signal_num).encode(ENCODING_UTF8) + # constant special sequences when running -stay_open mode + seq_execute = f"-execute{signal_num}\n".encode(ENCODING_UTF8) # the default string is b"-execute\n" + seq_ready = f"{{ready{signal_num}}}".encode(ENCODING_UTF8) # 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}".encode(ENCODING_UTF8) # default there isn't any string cmd_text = b"\n".join(params + (b"-echo4",seq_err_post, seq_execute,)) # cmd_text.encode("utf-8") # a commit put this in the next line, but i can't get it to work TODO From ed07bbd36ab8a3984c8b8c6830c1292e7dbfdf38 Mon Sep 17 00:00:00 2001 From: SylikC Date: Sun, 18 Apr 2021 04:25:22 -0700 Subject: [PATCH 077/251] more type hints --- exiftool/constants.py | 10 ++++++---- exiftool/exiftool.py | 11 +++++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/exiftool/constants.py b/exiftool/constants.py index 5416268..37c7fc5 100644 --- a/exiftool/constants.py +++ b/exiftool/constants.py @@ -21,14 +21,16 @@ # instead of comparing everywhere sys.platform, do it all here in the constants (less typo chances) # True if Windows -PLATFORM_WINDOWS = (sys.platform == 'win32') +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 = (sys.platform == 'linux' or sys.platform == 'linux2') +PLATFORM_LINUX: bool = (sys.platform == 'linux' or sys.platform == 'linux2') # 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: @@ -40,10 +42,10 @@ """ -SW_FORCEMINIMIZE = 11 # from win32con +SW_FORCEMINIMIZE: int = 11 # from win32con # 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 = 4096 +DEFAULT_BLOCK_SIZE: int = 4096 diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 8af0f40..bfa6b23 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -82,14 +82,14 @@ import random -# for type checking - Python 3.5+ +# for static analysis / type checking - Python 3.5+ from collections.abc import Callable from typing import Optional, List # constants to make typos obsolete! -ENCODING_UTF8 = "utf-8" -ENCODING_LATIN1 = "latin-1" +ENCODING_UTF8: str = "utf-8" +ENCODING_LATIN1: str = "latin-1" # ====================================================================================================================== @@ -433,7 +433,7 @@ def config_file(self) -> Optional[str]: return self._config_file @config_file.setter - def config_file(self, new_config_file) -> None: + def config_file(self, new_config_file: Optional[str]) -> None: """ set the config_file parameter if running==True, it will throw an error. Can only set config_file when exiftool is not running @@ -560,8 +560,10 @@ def execute_json(self, *params): if self._return_tuple: # get stdout only res = std[0] + res_err = std[1] else: res = std + res_err = self._last_stderr if len(res) == 0: # if the command has no files it's worked on, or some other type of error @@ -584,6 +586,7 @@ def execute_json(self, *params): # output files created if self._no_output: print(res_decoded) + # TODO: test why is this not returning anything from this function?? what if we are SETTING something and not GETTING? else: # TODO: if len(res_decoded) == 0, then there's obviously an error here return json.loads(res_decoded) From 74884369a679e8c95d6d60ab5f1ee6f9a0441793 Mon Sep 17 00:00:00 2001 From: SylikC Date: Sun, 18 Apr 2021 11:55:16 -0700 Subject: [PATCH 078/251] added stub for logging no functional changes otherwise rearranged the layout of the methods to be more organized, and makes more sense (easier to find methods/properties) rearranged __init__ code to make it explicit the variable declarations and the ones set by property pulled out the @staticmethod to the module-level since it's really not a method tied to ExifTool... only used by it. it doesn't use the class/instance, so Pythonic way is to have it as a module function trim trailing spaces across the board passed the test suite, meaning none of the changes above broke anything --- exiftool/exiftool.py | 447 +++++++++++++++++++++++++------------------ 1 file changed, 260 insertions(+), 187 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index bfa6b23..86c5f30 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -147,6 +147,34 @@ def callable_method(): # ====================================================================================================================== +def _read_fd_endswith(fd, b_endswith, block_size: int): + """ read an fd and keep reading until it endswith the seq_ends + + this allows a consolidated read function that is platform indepdent + + if you're not careful, on windows, this will block + """ + output = b"" + endswith_count = len(b_endswith) + 4 # if we're only looking at the last few bytes, make it meaningful. 4 is max size of \r\n? (or 2) + + # 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 + 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: + # 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 + +# ====================================================================================================================== + class ExifTool(object): """Run the `exiftool` command-line tool and communicate to it. @@ -191,38 +219,55 @@ class ExifTool(object): associated with a running subprocess. """ + ############################################################################## + #################################### INIT #################################### + ############################################################################## + # ---------------------------------------------------------------------------------------------------------------------- - def __init__(self, executable: Optional[str] = None, common_args=None, win_shell: bool = True, return_tuple: bool = False, config_file: Optional[str] = None) -> None: - - random.seed(None) # initialize random number generator - - # default settings + + def __init__(self, + executable: Optional[str] = None, + common_args=None, + win_shell: bool = True, + return_tuple: bool = False, + config_file: Optional[str] = None, + logger = None) -> None: + + # --- default settings / declare member variables --- self._running: bool = False # is it running? - self._executable: Optional[str] = None # executable absolute path 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._config_file: Optional[str] = None # config file that can only be set when exiftool is not running - + self._return_tuple: bool = return_tuple # are we returning a tuple in the execute? self._last_stdout: Optional[str] = None # previous output self._last_stderr: Optional[str] = None # previous stderr + 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._logger = None + + + + + # --- set variables via properties (which do the error checking) -- + # use the passed in parameter, or the default if not set # error checking is done in the property.setter self.executable = executable if executable is not None else constants.DEFAULT_EXECUTABLE - - # set to default block size - self._block_size: int = constants.DEFAULT_BLOCK_SIZE - + # set the property, error checking happens in the property.setter self.config_file = config_file + self.logger = logger # TODO set this as a property, and may not use these defaults if they cause errors (I recall seeing an issue filed) - + # it can't be none, check if it's a list, if not, error self._common_args: List[str] @@ -238,20 +283,186 @@ def __init__(self, executable: Optional[str] = None, common_args=None, win_shell self._no_output = '-w' in self._common_args + # --- run any remaining initialization code --- + + random.seed(None) # initialize random number generator + + + + + ####################################################################################### + #################################### 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): + return self._executable + + @executable.setter + def executable(self, new_executable) -> None: + """ + Set the executable. Does error checking. + """ + # cannot set executable when process is running + if self.running: + raise RuntimeError( 'Cannot set new executable while Exiftool is running' ) + + # Python 3.3+ required + abs_path: Optional[str] = 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 = abs_path + + + # ---------------------------------------------------------------------------------------------------------------------- + @property + def block_size(self) -> int: + return self._block_size + + @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 + + + # ---------------------------------------------------------------------------------------------------------------------- + @property + def config_file(self) -> Optional[str]: + return self._config_file + + @config_file.setter + def config_file(self, new_config_file: Optional[str]) -> None: + """ set the config_file parameter + + if running==True, it will throw an error. Can only set config_file when exiftool is not running + """ + if self.running: + raise RuntimeError("cannot set a new config_file while exiftool is running!") + + if new_config_file is None: + self._config_file = None + elif not Path(new_config_file).exists(): + raise FileNotFoundError("The config file could not be found") + else: + self._config_file = new_config_file + + + + ############################################################################################## + #################################### PROPERTIES Read only #################################### + ############################################################################################## + + # ---------------------------------------------------------------------------------------------------------------------- + @property + def running(self) -> bool: + # read-only property + + 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._process = None + self._running = False + + return self._running + + + # ---------------------------------------------------------------------------------------------------------------------- + @property + def last_stdout(self) -> Optional[str]: + """last output stdout from execute()""" + return self._last_stdout + + # ---------------------------------------------------------------------------------------------------------------------- + @property + def last_stderr(self) -> Optional[str]: + """last output stderr from execute()""" + return self._last_stderr + + + + + + ############################################################################################### + #################################### PROPERTIES Write only #################################### + ############################################################################################### + + # ---------------------------------------------------------------------------------------------------------------------- + def setlogger(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 + elif not isinstance(new_logger, logging.Logger): + raise TypeError("logger needs to be of type logging.Logger") + + self._logger = new_logger + + # have to run this at the class level to create a special write-only property + logger = property(fset=setlogger, doc="'logger' property to set to the class logging.Logger") + + + + + + + ######################################################################################### + #################################### 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 you have another executable named exiftool which isn't exiftool, that's your fault """ @@ -261,16 +472,16 @@ def run(self) -> None: # TODO changing common args means it needs a restart, or error, have a restart=True for change common_args or error if running 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: 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 logging.debug(proc_args) - + with open(os.devnull, "w") as devnull: # TODO can probably remove or make it a parameter try: if constants.PLATFORM_WINDOWS: @@ -279,7 +490,7 @@ def run(self) -> None: # 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 - + self._process = subprocess.Popen( proc_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, @@ -290,7 +501,7 @@ def run(self) -> None: proc_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, preexec_fn=set_pdeathsig(signal.SIGTERM)) #stderr=devnull - # Warning: The preexec_fn parameter is not safe to use in the presence of threads in your application. + # 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 @@ -301,12 +512,12 @@ def run(self) -> None: except subprocess.CalledProcessError as cpe: raise cpe # TODO print out more useful error messages to these different errors above - + # check error above before saying it's running if self._process.poll() is not None: # the Popen launched, then process terminated raise RuntimeError("exiftool did not execute successfully") - + self._running = True # ---------------------------------------------------------------------------------------------------------------------- @@ -319,7 +530,7 @@ def terminate(self, timeout: int = 30, _del: bool = False) -> None: warnings.warn("ExifTool not running; doing nothing.", UserWarning) # TODO, maybe add an optional parameter that says ignore_running/check/force or something which will not warn return - + if _del and constants.PLATFORM_WINDOWS: # don't cleanly exit on windows, during __del__ as it'll freeze at communicate() self._process.kill() @@ -333,9 +544,9 @@ def terminate(self, timeout: int = 30, _del: bool = False) -> None: """ 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=b"-stay_open\nFalse\n", timeout=timeout) # TODO these are constants which should be elsewhere defined @@ -344,7 +555,7 @@ def terminate(self, timeout: int = 30, _del: bool = False) -> None: self._process.kill() outs, errs = self._process.communicate() # err handling code from https://docs.python.org/3/library/subprocess.html#subprocess.Popen.communicate - + self._process = None # don't delete, just leave as None self._running = False @@ -352,121 +563,10 @@ def terminate(self, timeout: int = 30, _del: bool = False) -> None: - # ---------------------------------------------------------------------------------------------------------------------- - 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) - - - - - - # ---------------------------------------------------------------------------------------------------------------------- - @property - def executable(self): - return self._executable - - @executable.setter - def executable(self, new_executable) -> None: - """ - Set the executable. Does error checking. - """ - # cannot set executable when process is running - if self.running: - raise RuntimeError( 'Cannot set new executable while Exiftool is running' ) - - # Python 3.3+ required - abs_path: Optional[str] = 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 = abs_path - - - # ---------------------------------------------------------------------------------------------------------------------- - @property - def block_size(self) -> int: - return self._block_size - - @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 - - # ---------------------------------------------------------------------------------------------------------------------- - @property - def running(self) -> bool: - # read-only property - - 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._process = None - self._running = False - - return self._running - - - # ---------------------------------------------------------------------------------------------------------------------- - @property - def config_file(self) -> Optional[str]: - return self._config_file - - @config_file.setter - def config_file(self, new_config_file: Optional[str]) -> None: - """ set the config_file parameter - - if running==True, it will throw an error. Can only set config_file when exiftool is not running - """ - if self.running: - raise RuntimeError("cannot set a new config_file while exiftool is running!") - - if new_config_file is None: - self._config_file = None - elif not Path(new_config_file).exists(): - raise FileNotFoundError("The config file could not be found") - else: - self._config_file = new_config_file - - - - - - # ---------------------------------------------------------------------------------------------------------------------- - @property - def last_stdout(self) -> Optional[str]: - """last output stdout from execute()""" - return self._last_stdout - - # ---------------------------------------------------------------------------------------------------------------------- - @property - def last_stderr(self) -> Optional[str]: - """last output stderr from execute()""" - return self._last_stderr - - + ################################################################################## + #################################### EXECUTE* #################################### + ################################################################################## - # ---------------------------------------------------------------------------------------------------------------------- def execute(self, *params): """Execute the given batch of parameters with ``exiftool``. @@ -489,37 +589,37 @@ def execute(self, *params): """ if not self.running: raise RuntimeError("ExifTool instance not running.") - - + + # 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".encode(ENCODING_UTF8) # the default string is b"-execute\n" seq_ready = f"{{ready{signal_num}}}".encode(ENCODING_UTF8) # 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}".encode(ENCODING_UTF8) # default there isn't any string - + cmd_text = b"\n".join(params + (b"-echo4",seq_err_post, seq_execute,)) # 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 self._process.stdin.write(cmd_text) self._process.stdin.flush() - + fdout = self._process.stdout.fileno() - output = ExifTool._read_fd_endswith(fdout, seq_ready, self._block_size) - + output = _read_fd_endswith(fdout, seq_ready, self._block_size) + # when it's ready, we can safely read all of stderr out, as the command is already done fderr = self._process.stderr.fileno() - outerr = ExifTool._read_fd_endswith(fderr, seq_err_post, self._block_size) - + outerr = _read_fd_endswith(fderr, seq_err_post, self._block_size) + # save the output to class vars for retrieval self._last_stdout = output.strip()[:-len(seq_ready)] self._last_stderr = outerr.strip()[:-len(seq_err_post)] - + if self._return_tuple: return (self._last_stdout, self._last_stderr,) else: @@ -556,7 +656,7 @@ def execute_json(self, *params): # http://stackoverflow.com/a/5552623/1318758 # https://github.com/jmathai/elodie/issues/127 std = self.execute(b"-j", *params) - + if self._return_tuple: # get stdout only res = std[0] @@ -564,18 +664,18 @@ def execute_json(self, *params): else: res = std res_err = self._last_stderr - + if len(res) == 0: # if the command has no files it's worked on, or some other type of error # we can either return None, or [], or FileNotFoundError .. - - # but, since it's technically not an error to have no files, - # returning None is the best. + + # but, since it's technically not an error to have no files, + # returning None is the best. # Even [] could be ambugious if Exiftool changes the returned JSON structure in the future # TODO haven't decided on [] or None yet return None - - + + try: res_decoded = res.decode(ENCODING_UTF8) except UnicodeDecodeError: @@ -592,30 +692,3 @@ def execute_json(self, *params): return json.loads(res_decoded) - # ---------------------------------------------------------------------------------------------------------------------- - @staticmethod - def _read_fd_endswith(fd, b_endswith, block_size: int): - """ read an fd and keep reading until it endswith the seq_ends - - this allows a consolidated read function that is platform indepdent - - if you're not careful, on windows, this will block - """ - output = b"" - endswith_count = len(b_endswith) + 4 # if we're only looking at the last few bytes, make it meaningful. 4 is max size of \r\n? (or 2) - - # 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 - 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: - # 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 From 738b00ce50bc6d6d19ea78cd8e63bb26dc529ad6 Mon Sep 17 00:00:00 2001 From: SylikC Date: Mon, 19 Apr 2021 03:51:58 -0700 Subject: [PATCH 079/251] add in empty placeholder for the tests/tmp dir --- tests/tmp/PLACEHOLDER.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/tmp/PLACEHOLDER.txt diff --git a/tests/tmp/PLACEHOLDER.txt b/tests/tmp/PLACEHOLDER.txt new file mode 100644 index 0000000..d2b4ca5 --- /dev/null +++ b/tests/tmp/PLACEHOLDER.txt @@ -0,0 +1 @@ +empty placeholder to make sure the directory exists on checkout \ No newline at end of file From 18519b68d54241fba5d6f3200e24e0ba5be467dd Mon Sep 17 00:00:00 2001 From: SylikC Date: Mon, 19 Apr 2021 03:52:23 -0700 Subject: [PATCH 080/251] update gitignore for the tests/tmp/ dir --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 8f5a041..ab8ab99 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,9 @@ dist/ MANIFEST *.egg-info/ + +# pytest-cov db +.coverage + +# tests will be made to write to a tmp directory for modifications instead of putting into the tests directory +tests/tmp/ From 5db3efd92d2265d5a1992916856beadd3963c537 Mon Sep 17 00:00:00 2001 From: SylikC Date: Mon, 19 Apr 2021 03:58:26 -0700 Subject: [PATCH 081/251] convert helper to using pathlib.Path for better clarity. also, remove extraneous imports from test_exiftool --- tests/test_exiftool.py | 6 ++-- tests/test_helper.py | 67 +++++++++++++++++++++++++----------------- 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/tests/test_exiftool.py b/tests/test_exiftool.py index fbd2f1e..8c51a7c 100644 --- a/tests/test_exiftool.py +++ b/tests/test_exiftool.py @@ -5,9 +5,9 @@ import unittest import exiftool import warnings -import os -import shutil -import sys +#import os +#import shutil +#import sys class TestExifTool(unittest.TestCase): diff --git a/tests/test_helper.py b/tests/test_helper.py index 81aa5ad..706cf3a 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -5,15 +5,20 @@ import unittest import exiftool import warnings -import os +#import os import shutil import sys +from pathlib import Path + +TMP_DIR = Path(__file__).parent / 'tmp' + class TestExifToolHelper(unittest.TestCase): #--------------------------------------------------------------------------------------------------------- def setUp(self): self.et = exiftool.ExifToolHelper(common_args=["-G", "-n", "-overwrite_original"]) + def tearDown(self): if hasattr(self, "et"): if self.et.running: @@ -21,6 +26,7 @@ def tearDown(self): if hasattr(self, "process"): if self.process.poll() is None: self.process.terminate() + #--------------------------------------------------------------------------------------------------------- def test_get_metadata(self): expected_data = [{"SourceFile": "rose.jpg", @@ -34,12 +40,13 @@ def test_get_metadata(self): "PNG:ImageWidth": 64, "PNG:ImageHeight": 64, "Composite:ImageSize": "64 64"}] # older versions of exiftool used to display 64x64 - script_path = os.path.dirname(__file__) + script_path = Path(__file__).parent 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) + d["SourceFile"] = f = script_path / d["SourceFile"] + self.assertTrue(f.exists()) + source_files.append(str(f)) with self.et: actual_data = self.et.get_metadata(source_files) tags0 = self.et.get_tags(["XMP:Subject"], source_files[0])[0] @@ -51,10 +58,10 @@ def test_get_metadata(self): self.assertTrue( et_version >= 8.40, "you should at least use ExifTool version 8.40") - actual["SourceFile"] = os.path.normpath(actual["SourceFile"]) + actual["SourceFile"] = Path(actual["SourceFile"]).resolve() for k, v in expected.items(): self.assertEqual(actual[k], v) - tags0["SourceFile"] = os.path.normpath(tags0["SourceFile"]) + 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") @@ -66,19 +73,23 @@ def test_set_metadata(self): "Caption-Abstract": "Ein Röschen ganz allein"}, {"SourceFile": "skyblue.png", "Caption-Abstract": "Blauer Himmel"}] - script_path = os.path.dirname(__file__) + script_path = Path(__file__).parent 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) + d["SourceFile"] = f = script_path / d["SourceFile"] + self.assertTrue(f.exists()) + + f_mod = 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_tags({"Caption-Abstract":d["Caption-Abstract"]}, f_mod) - tag0 = self.et.get_tag("IPTC:Caption-Abstract", f_mod) - os.remove(f_mod) + self.et.set_tags({"Caption-Abstract":d["Caption-Abstract"]}, f_mod_str) + tag0 = self.et.get_tag("IPTC:Caption-Abstract", f_mod_str) + f_mod.unlink() self.assertEqual(tag0, d["Caption-Abstract"]) #--------------------------------------------------------------------------------------------------------- @@ -87,24 +98,26 @@ def test_set_keywords(self): mod_prefix = "newkw_" expected_data = [{"SourceFile": "rose.jpg", "Keywords": ["nature", "red plant"]}] - script_path = os.path.dirname(__file__) + script_path = Path(__file__).parent 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) + d["SourceFile"] = f = script_path / d["SourceFile"] + self.assertTrue(f.exists()) + f_mod = 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) - kwtag0 = self.et.get_tag("IPTC:Keywords", f_mod) + 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) - kwtag1 = self.et.get_tag("IPTC:Keywords", f_mod) - self.et.set_keywords(exiftool.helper.KW_ADD, kw_to_add, f_mod) - kwtag2 = self.et.get_tag("IPTC:Keywords", f_mod) - os.remove(f_mod) + 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) From 953177756e1521d94b28d10fc97752790d47a00c Mon Sep 17 00:00:00 2001 From: SylikC Date: Mon, 19 Apr 2021 04:15:51 -0700 Subject: [PATCH 082/251] update tests for more coverage --- tests/test_exiftool.py | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/tests/test_exiftool.py b/tests/test_exiftool.py index 8c51a7c..d8d2675 100644 --- a/tests/test_exiftool.py +++ b/tests/test_exiftool.py @@ -14,6 +14,7 @@ class TestExifTool(unittest.TestCase): #--------------------------------------------------------------------------------------------------------- def setUp(self): self.et = exiftool.ExifTool(common_args=["-G", "-n", "-overwrite_original"]) + def tearDown(self): if hasattr(self, "et"): if self.et.running: @@ -39,6 +40,38 @@ def test_executable_attribute(self): with self.assertRaises(FileNotFoundError): self.et.executable = "lkajsdfoleiawjfasv" self.assertFalse(self.et.running) + #--------------------------------------------------------------------------------------------------------- + def test_blocksize_attribute(self): + current = self.et.block_size + + # arbitrary + self.et.block_size = 4 + self.assertEqual(self.et.block_size, 4) + + with self.assertRaises(ValueError): + self.et.block_size = -1 + + # restore + self.et.block_size = current + + #--------------------------------------------------------------------------------------------------------- + + def test_configfile_attribute(self): + current = self.et.config_file + + + # TODO create a config file, and set it and test that it works + + self.assertFalse(self.et.running) + self.et.run() + self.assertTrue(self.et.running) + + with self.assertRaises(RuntimeError): + self.et.config_file = None + + self.et.terminate() + + #--------------------------------------------------------------------------------------------------------- def test_termination_cm(self): # Test correct subprocess start and termination when using @@ -64,6 +97,13 @@ def test_termination_explicit(self): 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.assertEquals(len(w), 1) + self.assertTrue(issubclass(w[0].category, UserWarning)) + #--------------------------------------------------------------------------------------------------------- def test_termination_implicit(self): # Test implicit process termination on garbage collection @@ -80,7 +120,7 @@ def test_process_died_running_status(self): # kill the process, out of ExifTool's control self.process.kill() outs, errs = self.process.communicate() - + with warnings.catch_warnings(record=True) as w: self.assertFalse(self.et.running) self.assertEquals(len(w), 1) From 8281a5eee9e5fcb5a7d8d194fe86804502c94fa5 Mon Sep 17 00:00:00 2001 From: SylikC Date: Mon, 19 Apr 2021 04:16:23 -0700 Subject: [PATCH 083/251] fix pytest-cov script to only show coverage for exiftool, and not other included libraries from virtualenv --- scripts/pytest-cov.bat | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/pytest-cov.bat b/scripts/pytest-cov.bat index 803b965..60978ba 100644 --- a/scripts/pytest-cov.bat +++ b/scripts/pytest-cov.bat @@ -8,9 +8,11 @@ echo PyTest Coverage Script echo; echo pip's PyTest version python -m pip show pytest | findstr /l /c:"Version:" +echo pip's PyTest-cov version +python -m pip show pytest-cov | findstr /l /c:"Version:" echo ______________________ - -python.exe -m pytest -v --cov --cov-report term-missing +REM added the --cov= so that it doesn't try to test coverage on the virtualenv directory +python.exe -m pytest -v --cov=exiftool --cov-report term-missing tests/ popd \ No newline at end of file From ebbc4659a51e73473f728994f2975a273f90c4de Mon Sep 17 00:00:00 2001 From: SylikC Date: Mon, 19 Apr 2021 04:36:11 -0700 Subject: [PATCH 084/251] exiftool logger is still a WIP, not really implemented yet add some testing for more coverage created a windows.coveragerc and some comments to reduce clutter on things that can never get coverage (Linux-related lines) --- exiftool/constants.py | 2 +- exiftool/exiftool.py | 16 ++++++++++++---- scripts/pytest-cov.bat | 2 +- scripts/windows.coveragerc | 9 +++++++++ tests/test_exiftool.py | 2 ++ 5 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 scripts/windows.coveragerc diff --git a/exiftool/constants.py b/exiftool/constants.py index 37c7fc5..21a7b18 100644 --- a/exiftool/constants.py +++ b/exiftool/constants.py @@ -33,7 +33,7 @@ if PLATFORM_WINDOWS: DEFAULT_EXECUTABLE = "exiftool.exe" -else: +else: # pytest-cov:windows: no cover DEFAULT_EXECUTABLE = "exiftool" """The name of the executable to run. diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 86c5f30..851d6e8 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -164,7 +164,7 @@ def _read_fd_endswith(fd, b_endswith, block_size: int): # 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: + 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: @@ -173,6 +173,11 @@ def _read_fd_endswith(fd, b_endswith, block_size: int): return output + + + + + # ====================================================================================================================== class ExifTool(object): @@ -423,7 +428,7 @@ def last_stderr(self) -> Optional[str]: ############################################################################################### # ---------------------------------------------------------------------------------------------------------------------- - def setlogger(self, new_logger) -> None: + def _set_logger(self, new_logger) -> None: """ set a new user-created logging.Logger object can be set at any time to start logging. @@ -437,7 +442,10 @@ def setlogger(self, new_logger) -> None: self._logger = new_logger # have to run this at the class level to create a special write-only property - logger = property(fset=setlogger, doc="'logger' property to set to the class logging.Logger") + # 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="'logger' property to set to the class logging.Logger") @@ -495,7 +503,7 @@ def run(self) -> None: proc_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, startupinfo=startup_info) #stderr=devnull - else: + else: # pytest-cov:windows: no cover # assume it's linux self._process = subprocess.Popen( proc_args, diff --git a/scripts/pytest-cov.bat b/scripts/pytest-cov.bat index 60978ba..b4ff667 100644 --- a/scripts/pytest-cov.bat +++ b/scripts/pytest-cov.bat @@ -13,6 +13,6 @@ python -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 -python.exe -m pytest -v --cov=exiftool --cov-report term-missing tests/ +python.exe -m pytest -v --cov-config=%~dp0windows.coveragerc --cov=exiftool --cov-report term-missing tests/ popd \ No newline at end of file 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/tests/test_exiftool.py b/tests/test_exiftool.py index d8d2675..c2a5ed7 100644 --- a/tests/test_exiftool.py +++ b/tests/test_exiftool.py @@ -59,6 +59,8 @@ def test_blocksize_attribute(self): def test_configfile_attribute(self): current = self.et.config_file + with self.assertRaises(FileNotFoundError): + self.et.config_file = "lkasjdflkjasfd" # TODO create a config file, and set it and test that it works From 6ecb2f7e49291cbbdf0b77dc9aeee4716f542b61 Mon Sep 17 00:00:00 2001 From: "SylikC (admin)" Date: Mon, 19 Apr 2021 14:43:59 -0700 Subject: [PATCH 085/251] add support for lists in tags.items in set_tags_batch() (check CHANGELOG.md for more information) fixes GitHub issue #12 , code contributed by @davidorme in https://github.com/sylikc/pyexiftool/issues/12#issuecomment-821879234 --- CHANGELOG.md | 7 +++++-- exiftool/exiftool.py | 13 ++++++++++++- setup.py | 2 +- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 843f58c..9e5ea27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,8 +27,11 @@ Date (Timezone) | Version | Comment 02/01/2020 05:09:43 PM (PST) | 0.4.1 | incorporated pull request #2 and #3 by ickc which added a "no_output" feature and an import for ujson if it's installed. Thanks for the updates! 04/09/2020 04:25:31 AM (PDT) | 0.4.2 | roll back 0.4.0's pyexiftool rename. It appears there's no specific PEP to have to to name PyPI projects to be py. The only convention I found was https://www.python.org/dev/peps/pep-0423/#use-standard-pattern-for-community-contributions which I might look at in more detail 04/09/2020 05:15:40 AM (PDT) | 0.4.3 | initial work of moving the exiftool.py into a directory preparing to break it down into separate files to make the codebase more manageable -03/12/2021 01:37:30 PM (PDT) | 0.4.4 | no functional code changes. Revamped the setup.py and related files to release to PyPI. Added all necessary and recommended files into release -03/12/2021 02:03:38 PM (PDT) | 0.4.5 | no functional code changes. re-release with new version because I accidentally included the "test" package with the PyPI 0.4.4 release. I deleted it instead of yanking or doing a post release this time... just bumped the version. "test" folder renamed to "tests" as per convention, so the build will automatically ignore it +03/12/2021 01:37:30 PM (PST) | 0.4.4 | no functional code changes. Revamped the setup.py and related files to release to PyPI. Added all necessary and recommended files into release +03/12/2021 02:03:38 PM (PST) | 0.4.5 | no functional code changes. re-release with new version because I accidentally included the "test" package with the PyPI 0.4.4 release. I deleted it instead of yanking or doing a post release this time... just bumped the version. "test" folder renamed to "tests" as per convention, so the build will automatically ignore it +04/08/2021 03:38:46 PM (PDT) | 0.4.6 | added support for config files in constructor -- Merged pull request #7 from @asielen and fixed a bug referenced in the discussion https://github.com/sylikc/pyexiftool/pull/7 +04/19/2021 02:37:02 PM (PDT) | 0.4.7 | added support for writing a list of values in set_tags_batch() which allows setting individual keywords (and other tags which are exiftool lists) -- contribution from @davidorme referenced in issue https://github.com/sylikc/pyexiftool/issues/12#issuecomment-821879234 + On version changes, update setup.py to reflect version diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index d7615b8..6b3b071 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -663,6 +663,9 @@ def set_tags_batch(self, tags, filenames): :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 @@ -676,7 +679,15 @@ def set_tags_batch(self, tags, filenames): params = [] params_utf8 = [] for tag, value in tags.items(): - params.append(u'-%s=%s' % (tag, value)) + # 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] diff --git a/setup.py b/setup.py index ebe0306..3337a7f 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ # overview name="PyExifTool", - version="0.4.5", + version="0.4.7", license="GPLv3+/BSD", url="http://github.com/sylikc/pyexiftool", python_requires=">=2.6", From d2f43ef4929b60da1a3b01727341339e48daf8b9 Mon Sep 17 00:00:00 2001 From: SylikC Date: Mon, 19 Apr 2021 15:13:27 -0700 Subject: [PATCH 086/251] pulled out the @staticmethod to the module-level since it's really not a method tied to ExifToolHelper... only used by it. it doesn't use the class/instance, so Pythonic way is to have it as a module function added a stub to eventually write a keywords list test, but I'm not ready to do testing on it, so do it later removed extraneous spacing --- exiftool/helper.py | 81 +++++++++++++++++++++++--------------------- tests/test_helper.py | 35 +++++++++++++++++++ 2 files changed, 77 insertions(+), 39 deletions(-) diff --git a/exiftool/helper.py b/exiftool/helper.py index 75752c7..7b0ef8c 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -50,6 +50,28 @@ +# ====================================================================================================================== + + +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 + + + # ====================================================================================================================== @@ -99,7 +121,7 @@ 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 """ - + # ---------------------------------------------------------------------------------------------------------------------- def __init__(self, executable=None, common_args=None, win_shell=True, return_tuple=False): # call parent's constructor @@ -150,11 +172,11 @@ def get_metadata_batch_wrapper(self, filenames, params=None): # ---------------------------------------------------------------------------------------------------------------------- def get_metadata(self, in_files, params=None): """Return all meta-data for the given files. - + This will ALWAYS return a list - + in_files can be an iterable(strings) or a string. - + wildcard strings are accepted as it's passed straight to exiftool The return value will have the format described in the @@ -162,7 +184,7 @@ def get_metadata(self, in_files, params=None): """ if not params: params = [] - + return self.get_tags(None, in_files, params=params) # ---------------------------------------------------------------------------------------------------------------------- @@ -182,7 +204,7 @@ def get_tags(self, in_tags, in_files, params=None): The first argument is an iterable of tags. The tag names may include group names, as usual in the format :. - + If in_tags is None, or [], then returns all tags The second argument is an iterable of file names. @@ -190,41 +212,41 @@ def get_tags(self, in_tags, in_files, params=None): The format of the return value is the same as for :py:meth:`execute_json()`. """ - + tags = None files = None - + if in_tags is None: # all tags tags = [] elif isinstance(in_tags, basestring): tags = [in_tags] - elif ExifToolHelper._check_iterable(in_tags): + elif _is_iterable(in_tags): tags = in_tags else: raise TypeError("The argument 'in_tags' must be a str/bytes or a list") - - + + if isinstance(in_files, basestring): files = [in_files] - elif ExifToolHelper._check_iterable(in_files): + elif _is_iterable(in_files): files = in_files else: raise TypeError("The argument 'in_files' must be a str/bytes or a list") - - + + exec_params = [] - + if not params: pass else: exec_params.extend(params) - + # tags is always a list by this point. It will always be iterable... don't have to check for None exec_params.extend( ["-" + t for t in tags] ) - + exec_params.extend(files) - + return self.execute_json(*exec_params) @@ -297,7 +319,7 @@ def set_tags_batch(self, tags, filenames): It can be passed into `check_ok()` and `format_error()`. - tags items can be lists, in which case, the tag will be passed + 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 @@ -313,7 +335,7 @@ def set_tags_batch(self, tags, filenames): 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 + # 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): @@ -410,22 +432,3 @@ def _check_sanity_of_result(file_paths, result): raise IOError('exiftool returned data for file %s, but expected was %s' % (returned_source_file, requested_file)) - # ---------------------------------------------------------------------------------------------------------------------- - @staticmethod - def _check_iterable(in_param): - """ - 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 - \ No newline at end of file diff --git a/tests/test_helper.py b/tests/test_helper.py index 706cf3a..80c9e43 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -100,6 +100,7 @@ def test_set_keywords(self): "Keywords": ["nature", "red plant"]}] script_path = Path(__file__).parent source_files = [] + for d in expected_data: d["SourceFile"] = f = script_path / d["SourceFile"] self.assertTrue(f.exists()) @@ -123,6 +124,40 @@ def test_set_keywords(self): 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"]}] + script_path = Path(__file__).parent + source_files = [] + + for d in expected_data: + d["SourceFile"] = f = script_path / d["SourceFile"] + self.assertTrue(f.exists()) + f_mod = 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() From 7fc58572fa97854f111d7b420e495ca737163fa9 Mon Sep 17 00:00:00 2001 From: SylikC Date: Mon, 19 Apr 2021 19:13:00 -0700 Subject: [PATCH 087/251] fixed an indent bug (from the merge) and annotation bug in helper.py --- exiftool/helper.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/exiftool/helper.py b/exiftool/helper.py index 7b0ef8c..a6d7bae 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -33,6 +33,9 @@ basestring = (bytes, str) +from typing import Any + + # ====================================================================================================================== #def atexit_handler @@ -49,11 +52,10 @@ - # ====================================================================================================================== -def _is_iterable(in_param -> Any) -> bool: +def _is_iterable(in_param: Any) -> bool: """ Checks if this item is iterable, instead of using isinstance(list), anything iterable can be ok @@ -342,7 +344,7 @@ def set_tags_batch(self, tags, filenames): for item in value: params.append(u'-%s=%s' % (tag, item)) else: - params.append(u'-%s=%s' % (tag, value)) + params.append(u'-%s=%s' % (tag, value)) params.extend(filenames) params_utf8 = [x.encode('utf-8') for x in params] From ee4bbab65d77ea8856edea432a6d841a07573d05 Mon Sep 17 00:00:00 2001 From: SylikC Date: Mon, 19 Apr 2021 19:13:45 -0700 Subject: [PATCH 088/251] exiftool.py: * change the way common_args works. Before it was always -G -n, when it was None to get the default -G -n... now it's just in the constructor * common_args is now set as a property, allowing you to interface with it directly even after creating the object * stub was created to get and verify exiftool version, so that it can be retrieved in one go after launching the process. this will also help santiy check you're running the right thing, not just some random process called "exiftool" --- exiftool/exiftool.py | 81 +++++++++++++++++++++++++++++++++----------- 1 file changed, 61 insertions(+), 20 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 851d6e8..62480d1 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -232,7 +232,7 @@ class ExifTool(object): def __init__(self, executable: Optional[str] = None, - common_args=None, + common_args: Optional[List[str]] = ["-G", "-n"], win_shell: bool = True, return_tuple: bool = False, config_file: Optional[str] = None, @@ -250,9 +250,12 @@ def __init__(self, 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._no_output = None # TODO examine whether this is needed self._logger = None @@ -263,6 +266,7 @@ def __init__(self, # use the passed in parameter, or the default if not set # error checking is done in the property.setter self.executable = executable if executable is not None else constants.DEFAULT_EXECUTABLE + self.common_args = common_args # set the property, error checking happens in the property.setter self.config_file = config_file @@ -271,21 +275,6 @@ def __init__(self, - # TODO set this as a property, and may not use these defaults if they cause errors (I recall seeing an issue filed) - - # it can't be none, check if it's a list, if not, error - self._common_args: List[str] - - if common_args is None: - # 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 - else: - raise TypeError("common_args not a list of strings") - - self._no_output = '-w' in self._common_args # --- run any remaining initialization code --- @@ -364,6 +353,38 @@ def block_size(self, new_block_size: int) -> None: self._block_size = 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 + + so, if running==True, it will throw an error. Can only set common_args when exiftool is not running + """ + + if self.running: + raise RuntimeError("Cannot set new common_args while exiftool is running!") + + + # TODO may not use constructor defaults if they cause errors (I recall seeing an issue filed) + + # it can be none, the code accomodates for that now + + if new_args is None or isinstance(new_args, list): + # default parameters to exiftool + # -n = disable print conversion (speedup) + self._common_args = new_args + else: + raise TypeError("common_args not a list of strings") + + # TODO examine if this is still a needed thing + self._no_output = '-w' in self._common_args + # ---------------------------------------------------------------------------------------------------------------------- @property def config_file(self) -> Optional[str]: @@ -376,7 +397,7 @@ def config_file(self, new_config_file: Optional[str]) -> None: if running==True, it will throw an error. Can only set config_file when exiftool is not running """ if self.running: - raise RuntimeError("cannot set a new config_file while exiftool is running!") + raise RuntimeError("Cannot set a new config_file while exiftool is running!") if new_config_file is None: self._config_file = None @@ -478,16 +499,22 @@ def run(self) -> None: warnings.warn("ExifTool already running; doing nothing.", UserWarning) return - # TODO changing common args means it needs a restart, or error, have a restart=True for change common_args or error if running + # 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: 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 + # 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 + # TODO logging change logging.debug(proc_args) with open(os.devnull, "w") as devnull: # TODO can probably remove or make it a parameter @@ -526,6 +553,11 @@ def run(self) -> None: # the Popen launched, then process terminated raise RuntimeError("exiftool did not execute successfully") + + # TODO get ExifTool version here and any Exiftool metadata + # this can also verify that it is really ExifTool we ran, not some other random process + + self._running = True # ---------------------------------------------------------------------------------------------------------------------- @@ -700,3 +732,12 @@ def execute_json(self, *params): return json.loads(res_decoded) + ######################################################################################## + #################################### STATIC METHODS #################################### + ######################################################################################## + + # ---------------------------------------------------------------------------------------------------------------------- + @staticmethod + def _parse_version(): + # TODO stub for parsing exiftool -v -ver + pass From f85c2155a00e035fae4e81a755aedb276511ac4b Mon Sep 17 00:00:00 2001 From: "SylikC (admin)" Date: Mon, 19 Apr 2021 22:11:17 -0700 Subject: [PATCH 089/251] rename the 'doc' to 'docs' to follow more with the convention of the other folder names --- {doc => docs}/.gitignore | 0 {doc => docs}/Makefile | 0 {doc => docs}/conf.py | 0 {doc => docs}/index.rst | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename {doc => docs}/.gitignore (100%) rename {doc => docs}/Makefile (100%) rename {doc => docs}/conf.py (100%) rename {doc => docs}/index.rst (100%) 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 100% rename from doc/Makefile rename to docs/Makefile diff --git a/doc/conf.py b/docs/conf.py similarity index 100% rename from doc/conf.py rename to docs/conf.py diff --git a/doc/index.rst b/docs/index.rst similarity index 100% rename from doc/index.rst rename to docs/index.rst From 500f7585d3af3f9b9e8fff4be2c209002e324e02 Mon Sep 17 00:00:00 2001 From: SylikC Date: Mon, 19 Apr 2021 23:37:24 -0700 Subject: [PATCH 090/251] trying to get documentation working again, need to document as we move forward with 0.5.0 build, since the changes are drastically different than v0.4 and below --- docs/Makefile | 1 + docs/index.rst | 8 -------- docs/make.bat | 35 +++++++++++++++++++++++++++++++++++ docs/{ => source}/conf.py | 39 ++++++++++++++++++++++++--------------- docs/source/index.rst | 29 +++++++++++++++++++++++++++++ 5 files changed, 89 insertions(+), 23 deletions(-) delete mode 100644 docs/index.rst create mode 100644 docs/make.bat rename docs/{ => source}/conf.py (95%) create mode 100644 docs/source/index.rst diff --git a/docs/Makefile b/docs/Makefile index 87dda06..c29201a 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -5,6 +5,7 @@ SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = +SOURCEDIR = source BUILDDIR = _build # Internal variables. diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index e147b50..0000000 --- a/docs/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/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/docs/conf.py b/docs/source/conf.py similarity index 95% rename from docs/conf.py rename to docs/source/conf.py index 83cf656..257387f 100644 --- a/docs/conf.py +++ b/docs/source/conf.py @@ -11,12 +11,31 @@ # 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('..')) +sys.path.insert(1, Path(__file__).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 ----------------------------------------------------- @@ -39,18 +58,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 +98,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..7ad6592 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,29 @@ +.. 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.exiftool + :members: + :undoc-members: + :private-members: + :special-members: + :show-inheritance: + +.. + 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` From fb8b4f9ac2ef672d827d72bae0e158168f427d7a Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 21 Apr 2021 22:07:09 -0700 Subject: [PATCH 091/251] added the version and version_tuple properties which is the exiftool version... not the class version --- exiftool/exiftool.py | 71 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 62480d1..62ce14a 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# PyExifTool +# PyExifTool # Copyright 2012 Sven Marnach. # Copyright 2021 Kevin M (sylikc) @@ -94,6 +94,8 @@ # ====================================================================================================================== """ +.. + # This code has been adapted from Lib/os.py in the Python source tree # (sha1 265e36e277f3) def _fscodec(): @@ -181,7 +183,7 @@ def _read_fd_endswith(fd, b_endswith, block_size: int): # ====================================================================================================================== 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 @@ -218,7 +220,7 @@ class ExifTool(object): non-existent files to any of the methods, since this will lead to undefied behaviour. - .. py:attribute:: running + .. py:attribute:: _running A Boolean value indicating whether this instance is currently associated with a running subprocess. @@ -243,6 +245,7 @@ def __init__(self, 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 = None # this is set to be the exiftool -v -ver when running self._return_tuple: bool = return_tuple # are we returning a tuple in the execute? self._last_stdout: Optional[str] = None # previous output @@ -423,11 +426,44 @@ def running(self) -> bool: # process died warnings.warn("ExifTool process was previously running but died") self._process = None + self._ver = None self._running = False return self._running + # ---------------------------------------------------------------------------------------------------------------------- + @property + def version(self) -> str: + """ returns a string from -ver """ + + if not self.running: + raise RuntimeError("Can't get ExifTool version when it's not running!") + + return self._ver + + + # ---------------------------------------------------------------------------------------------------------------------- + @property + def version_tuple(self) -> tuple: + """ returns a parsed (major, minor) with integers """ + if not self.running: + raise RuntimeError("Can't get ExifTool version when it's not running!") + + # TODO 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) + + res: List = [] + try: + for v in arr: + res.append(int(v)) + except ValueError: + raise ValueError(f"Error parsing ExifTool version: '{self._ver}'") + + return tuple(res) + + + # ---------------------------------------------------------------------------------------------------------------------- @property def last_stdout(self) -> Optional[str]: @@ -554,11 +590,14 @@ def run(self) -> None: raise RuntimeError("exiftool did not execute successfully") + # have to set this before doing the checks below, or else execute() will fail + self._running = True + # TODO get ExifTool version here and any Exiftool metadata # this can also verify that it is really ExifTool we ran, not some other random process + self._ver = self._parse_ver() - self._running = True # ---------------------------------------------------------------------------------------------------------------------- def terminate(self, timeout: int = 30, _del: bool = False) -> None: @@ -597,6 +636,7 @@ def terminate(self, timeout: int = 30, _del: bool = False) -> None: # err handling code from https://docs.python.org/3/library/subprocess.html#subprocess.Popen.communicate self._process = None # don't delete, just leave as None + self._ver = None # unset the version self._running = False @@ -732,12 +772,21 @@ def execute_json(self, *params): return json.loads(res_decoded) - ######################################################################################## - #################################### STATIC METHODS #################################### - ######################################################################################## + ######################################################################################### + #################################### PRIVATE METHODS #################################### + ######################################################################################### # ---------------------------------------------------------------------------------------------------------------------- - @staticmethod - def _parse_version(): - # TODO stub for parsing exiftool -v -ver - pass + def _parse_ver(self): + """ private method to run exiftool -ver + and parse out the information + + """ + if not self.running: + raise RuntimeError("ExifTool instance not running.") + + + # -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(b"-ver").decode(ENCODING_UTF8).strip() From 4597b5498ee0ba75f13915eb2da9cbeb9ecf355d Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 21 Apr 2021 22:08:25 -0700 Subject: [PATCH 092/251] adjust copyright put a TODO regarding the version check here... I think there's a bug with the checking, but it's probably solved by the version_tuple behavior --- exiftool/helper.py | 2 +- tests/test_helper.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/exiftool/helper.py b/exiftool/helper.py index a6d7bae..ffe3cc6 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# PyExifTool +# PyExifTool # Copyright 2021 Kevin M (sylikc) # More contributors in the CHANGELOG for the pull requests diff --git a/tests/test_helper.py b/tests/test_helper.py index 80c9e43..3cd426a 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -56,7 +56,7 @@ def test_get_metadata(self): self.assertTrue(isinstance(et_version, float)) if isinstance(et_version, float): # avoid exception in Py3k self.assertTrue( - et_version >= 8.40, + et_version >= 8.40, # TODO there's probably a bug in this test, 8.40 == 8.4 which isn't the intended behavior "you should at least use ExifTool version 8.40") actual["SourceFile"] = Path(actual["SourceFile"]).resolve() for k, v in expected.items(): From 2f2872b5ebce3c5bc692b9816e4c27c7f1fc8ad8 Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 22 Apr 2021 05:24:44 -0700 Subject: [PATCH 093/251] add some rudimentary logging into ExifTool class you have to pass in a pre-configured logger, and ExifTool class will just use the passed in logger --- exiftool/exiftool.py | 56 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 62ce14a..cb8e3df 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -65,6 +65,7 @@ import shutil 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 @@ -76,8 +77,6 @@ import signal import ctypes -from . import constants - from pathlib import Path # requires Python 3.4+ import random @@ -87,6 +86,9 @@ from typing import Optional, List +from . import constants + + # constants to make typos obsolete! ENCODING_UTF8: str = "utf-8" ENCODING_LATIN1: str = "latin-1" @@ -157,10 +159,13 @@ def _read_fd_endswith(fd, b_endswith, block_size: int): if you're not careful, on windows, this will block """ output = b"" - endswith_count = len(b_endswith) + 4 # if we're only looking at the last few bytes, make it meaningful. 4 is max size of \r\n? (or 2) + + # 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 + # 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 @@ -266,6 +271,9 @@ def __init__(self, # --- 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 if executable is not None else constants.DEFAULT_EXECUTABLE @@ -274,7 +282,6 @@ def __init__(self, # set the property, error checking happens in the property.setter self.config_file = config_file - self.logger = logger @@ -325,19 +332,28 @@ def executable(self): def executable(self, new_executable) -> None: """ Set the executable. Does error checking. + + in testing, shutil.which() will work if a complete path is given, but this isn't clear, so we explicitly check and don't search if path exists """ # cannot set executable when process is running if self.running: raise RuntimeError( 'Cannot set new executable while Exiftool is running' ) - # Python 3.3+ required - abs_path: Optional[str] = shutil.which(new_executable) + abs_path: Optional[str] = None + + if Path(new_executable).exists(): + abs_path = new_executable + else: + # 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' ) + 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 = abs_path + + if self._logger: self._logger.info(f"Property 'executable': set to \"{abs_path}\"") # ---------------------------------------------------------------------------------------------------------------------- @@ -355,6 +371,8 @@ def block_size(self, new_block_size: int) -> None: self._block_size = new_block_size + if self._logger: self._logger.info(f"Property 'block_size': set to \"{new_block_size}\"") + # ---------------------------------------------------------------------------------------------------------------------- @property @@ -387,6 +405,9 @@ def common_args(self, new_args: Optional[List[str]]) -> None: # TODO examine if this is still a needed thing self._no_output = '-w' in self._common_args + + if self._logger: self._logger.info(f"Property 'common_args': set to \"{self._common_args}\"") + # ---------------------------------------------------------------------------------------------------------------------- @property @@ -409,6 +430,8 @@ def config_file(self, new_config_file: Optional[str]) -> None: else: self._config_file = new_config_file + if self._logger: self._logger.info(f"Property 'config_file': set to \"{self._config_file}\"") + ############################################################################################## @@ -428,6 +451,8 @@ def running(self) -> bool: self._process = None self._ver = None self._running = False + + if self._logger: self._logger.warning(f"Property 'running': ExifTool process was previously running but died") return self._running @@ -596,6 +621,8 @@ def run(self) -> None: # TODO get ExifTool version here and any Exiftool metadata # this can also verify that it is really ExifTool we ran, not some other random process self._ver = self._parse_ver() + + if self._logger: self._logger.info(f"Method 'run': Exiftool version '{self._ver}' (pid {self._process.pid}) launched with args '{proc_args}'") @@ -638,6 +665,8 @@ def terminate(self, timeout: int = 30, _del: bool = False) -> None: self._process = None # don't delete, just leave as None self._ver = None # unset the version self._running = False + + if self._logger: self._logger.info(f"Method 'terminate': Exiftool terminated successfully.") @@ -688,6 +717,8 @@ def execute(self, *params): # might look at something like this https://stackoverflow.com/questions/7585435/best-way-to-convert-string-to-bytes-in-python-3 self._process.stdin.write(cmd_text) self._process.stdin.flush() + + if self._logger: self._logger.info( "Method 'execute': Command sent = {}".format(cmd_text.split(b'\n')[:-1]) ) fdout = self._process.stdout.fileno() output = _read_fd_endswith(fdout, seq_ready, self._block_size) @@ -700,11 +731,18 @@ def execute(self, *params): self._last_stdout = output.strip()[:-len(seq_ready)] self._last_stderr = outerr.strip()[:-len(seq_err_post)] + + if self._logger: + self._logger.debug( "Method 'execute': Reply stdout = {}".format(self._last_stdout) ) + self._logger.debug( "Method 'execute': Reply stderr = {}".format(self._last_stderr) ) + + if self._return_tuple: return (self._last_stdout, self._last_stderr,) else: # this was the standard return before, just stdout return self._last_stdout + # ---------------------------------------------------------------------------------------------------------------------- From 590bfe246b162ae0aca2e7a056382ccd5c49c8be Mon Sep 17 00:00:00 2001 From: SylikC Date: Sat, 24 Apr 2021 15:07:45 -0700 Subject: [PATCH 094/251] put in some stub for Logger testing, but I encountered a freeze when zombie processes are around. apparently after executing the _parse_ver the exiftool process remains open and doesn't die with main process when main process is killed (on windows exiftool.exe unpacks and launches another exiftool.exe) investigating possible solutions --- tests/test_exiftool.py | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/tests/test_exiftool.py b/tests/test_exiftool.py index c2a5ed7..8d9f7f0 100644 --- a/tests/test_exiftool.py +++ b/tests/test_exiftool.py @@ -5,10 +5,18 @@ import unittest import exiftool import warnings + +import logging # to test logger #import os #import shutil #import sys +from pathlib import Path + + +TMP_DIR = Path(__file__).parent / 'tmp' + + class TestExifTool(unittest.TestCase): #--------------------------------------------------------------------------------------------------------- @@ -55,25 +63,25 @@ def test_blocksize_attribute(self): self.et.block_size = current #--------------------------------------------------------------------------------------------------------- - + def test_configfile_attribute(self): current = self.et.config_file - + with self.assertRaises(FileNotFoundError): self.et.config_file = "lkasjdflkjasfd" - + # TODO create a config file, and set it and test that it works - + self.assertFalse(self.et.running) self.et.run() self.assertTrue(self.et.running) - + with self.assertRaises(RuntimeError): self.et.config_file = None - + self.et.terminate() - - + + #--------------------------------------------------------------------------------------------------------- def test_termination_cm(self): # Test correct subprocess start and termination when using @@ -111,6 +119,7 @@ def test_termination_implicit(self): # Test implicit process termination on garbage collection 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) #--------------------------------------------------------------------------------------------------------- @@ -121,6 +130,7 @@ def test_process_died_running_status(self): 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: @@ -132,6 +142,22 @@ 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_logger(self): + log = logging.getLogger("log_test") + log.level = logging.WARNING + + logpath = TMP_DIR / 'exiftool_test.log' + fh = logging.FileHandler(logpath) + + log.addHandler(fh) + + self.et.run() + + """ + + #--------------------------------------------------------------------------------------------------------- #--------------------------------------------------------------------------------------------------------- From 8ecb707056849fb63ec545656e5bd468359048fe Mon Sep 17 00:00:00 2001 From: Benjamin Gelb Date: Tue, 27 Apr 2021 18:37:11 -0400 Subject: [PATCH 095/251] update url for documentation *.github.com has been replaced with *.github.io --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index b857f67..e84632f 100644 --- a/README.rst +++ b/README.rst @@ -71,7 +71,7 @@ Documentation ------------- The documentation is available at -http://smarnach.github.com/pyexiftool/. +http://smarnach.github.io/pyexiftool/. Licence ------- From ad852728e8f154f58d6a0493b3d0e6fdf3decd61 Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 5 May 2021 12:14:11 -0700 Subject: [PATCH 096/251] added some other extensions to sphinx to generate cooler documentation (hopefully) - testing add in a fix from @csparker247 regarding resolving the path reference https://github.com/sylikc/pyexiftool/pull/13#discussion_r625888195 --- docs/source/conf.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 257387f..e89e41a 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -17,7 +17,14 @@ # 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, Path(__file__).parent.parent) +# +# 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 ----------------------------------------------------- @@ -44,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'] From da8827d9f6d8ba6a0c34ccd1da44de7f24f0cc97 Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 5 May 2021 12:26:39 -0700 Subject: [PATCH 097/251] created a little private method that just unsets all the variables pertaining to running() this should help in the future if we decide to put more things in, like say stdout or stderr clearing --- exiftool/exiftool.py | 53 +++++++++++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index cb8e3df..3766eed 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -159,7 +159,7 @@ def _read_fd_endswith(fd, b_endswith, block_size: int): if you're not careful, on windows, this will block """ 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 @@ -332,7 +332,7 @@ def executable(self): def executable(self, new_executable) -> None: """ Set the executable. Does error checking. - + in testing, shutil.which() will work if a complete path is given, but this isn't clear, so we explicitly check and don't search if path exists """ # cannot set executable when process is running @@ -340,7 +340,7 @@ def executable(self, new_executable) -> None: raise RuntimeError( 'Cannot set new executable while Exiftool is running' ) abs_path: Optional[str] = None - + if Path(new_executable).exists(): abs_path = new_executable else: @@ -352,7 +352,7 @@ def executable(self, new_executable) -> None: # absolute path is returned self._executable = abs_path - + if self._logger: self._logger.info(f"Property 'executable': set to \"{abs_path}\"") @@ -405,7 +405,7 @@ def common_args(self, new_args: Optional[List[str]]) -> None: # TODO examine if this is still a needed thing self._no_output = '-w' in self._common_args - + if self._logger: self._logger.info(f"Property 'common_args': set to \"{self._common_args}\"") @@ -448,10 +448,8 @@ def running(self) -> bool: if self._process.poll() is not None: # process died warnings.warn("ExifTool process was previously running but died") - self._process = None - self._ver = None - self._running = False - + self._flag_running_false() + if self._logger: self._logger.warning(f"Property 'running': ExifTool process was previously running but died") return self._running @@ -492,13 +490,17 @@ def version_tuple(self) -> tuple: # ---------------------------------------------------------------------------------------------------------------------- @property def last_stdout(self) -> Optional[str]: - """last output stdout from execute()""" + """last output stdout from execute() + currently it is INTENTIONALLY _NOT_ CLEARED on exiftool termination and not dependent on running state + This allows for executing a command and termiting, but still haven't last* around.""" return self._last_stdout # ---------------------------------------------------------------------------------------------------------------------- @property def last_stderr(self) -> Optional[str]: - """last output stderr from execute()""" + """last output stderr from execute() + currently it is INTENTIONALLY _NOT_ CLEARED on exiftool termination and not dependent on running state + This allows for executing a command and termiting, but still haven't last* around.""" return self._last_stderr @@ -612,6 +614,7 @@ def run(self) -> None: # check error above before saying it's running 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") @@ -621,7 +624,7 @@ def run(self) -> None: # TODO get ExifTool version here and any Exiftool metadata # this can also verify that it is really ExifTool we ran, not some other random process self._ver = self._parse_ver() - + if self._logger: self._logger.info(f"Method 'run': Exiftool version '{self._ver}' (pid {self._process.pid}) launched with args '{proc_args}'") @@ -641,7 +644,8 @@ def terminate(self, timeout: int = 30, _del: bool = False) -> None: # don't cleanly exit on windows, during __del__ as it'll freeze at communicate() self._process.kill() #print("before comm", self._process.poll(), self._process) - self._process.kill() + 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 @@ -662,10 +666,8 @@ def terminate(self, timeout: int = 30, _del: bool = False) -> None: outs, errs = self._process.communicate() # err handling code from https://docs.python.org/3/library/subprocess.html#subprocess.Popen.communicate - self._process = None # don't delete, just leave as None - self._ver = None # unset the version - self._running = False - + self._flag_running_false() + if self._logger: self._logger.info(f"Method 'terminate': Exiftool terminated successfully.") @@ -717,7 +719,7 @@ def execute(self, *params): # might look at something like this https://stackoverflow.com/questions/7585435/best-way-to-convert-string-to-bytes-in-python-3 self._process.stdin.write(cmd_text) self._process.stdin.flush() - + if self._logger: self._logger.info( "Method 'execute': Command sent = {}".format(cmd_text.split(b'\n')[:-1]) ) fdout = self._process.stdout.fileno() @@ -742,7 +744,7 @@ def execute(self, *params): else: # this was the standard return before, just stdout return self._last_stdout - + # ---------------------------------------------------------------------------------------------------------------------- @@ -814,11 +816,22 @@ def execute_json(self, *params): #################################### PRIVATE METHODS #################################### ######################################################################################### + # ---------------------------------------------------------------------------------------------------------------------- + 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 + + This method makes it less likely someone will leave off a variable if one comes up in the future + """ + self._process = None # don't delete, just leave as None + self._ver = None # unset the version + self._running = False + + # ---------------------------------------------------------------------------------------------------------------------- def _parse_ver(self): """ private method to run exiftool -ver and parse out the information - """ if not self.running: raise RuntimeError("ExifTool instance not running.") From 98357b75c0387c3a6dc4adb119bd58db8878bc62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Philip=20G=C3=B6pfert?= Date: Wed, 5 May 2021 21:35:14 +0200 Subject: [PATCH 098/251] Encode parameters --- exiftool/exiftool.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 6b3b071..e10fa9e 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -648,7 +648,9 @@ def get_tag(self, tag, filename): def copy_tags(self, fromFilename, toFilename): """Copy all tags from one file to another.""" - self.execute("-overwrite_original", "-TagsFromFile", fromFilename, toFilename) + params = ["-overwrite_original", "-TagsFromFile", fromFilename, toFilename] + params_utf8 = [x.encode('utf-8') for x in params] + self.execute(*params_utf8) def set_tags_batch(self, tags, filenames): From d54af011a6b8812ca949c0eac3b4320a67fcfe9c Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 5 May 2021 12:45:24 -0700 Subject: [PATCH 099/251] removing now-defunct devnull code, since we now read from stderr and direct exiftool to output at least something to stderr (this code was first commented on in commit 56af0a94e2b6a5e90a4583166eb2e02ff57aa832 . Check the diff with the commit before it to see what it was like originally) --- exiftool/exiftool.py | 59 ++++++++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 3766eed..0154734 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -580,36 +580,35 @@ def run(self) -> None: # TODO logging change logging.debug(proc_args) - with open(os.devnull, "w") as devnull: # TODO can probably remove or make it a parameter - try: - if constants.PLATFORM_WINDOWS: - 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 - - self._process = subprocess.Popen( - proc_args, - stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, startupinfo=startup_info) #stderr=devnull - else: # pytest-cov:windows: no cover - # assume it's linux - self._process = subprocess.Popen( - proc_args, - stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, preexec_fn=set_pdeathsig(signal.SIGTERM)) #stderr=devnull - # 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 - # TODO print out more useful error messages to these different errors above + try: + if constants.PLATFORM_WINDOWS: + 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 + + self._process = subprocess.Popen( + proc_args, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, startupinfo=startup_info) + else: # pytest-cov:windows: no cover + # assume it's linux + self._process = subprocess.Popen( + proc_args, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, 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 + # TODO print out more useful error messages to these different errors above # check error above before saying it's running if self._process.poll() is not None: From f5d5ca308ce3c1a97e3f9797852f68ab1b58a206 Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 5 May 2021 12:46:58 -0700 Subject: [PATCH 100/251] remove the logging.debug() ... this is now handled directly by setting the logger. No extraneous logging --- exiftool/exiftool.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 0154734..d939ea5 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -577,9 +577,6 @@ def run(self) -> None: proc_args.append("-common_args") # add this param only if there are common_args proc_args.extend(self._common_args) # add the common arguments - # TODO logging change - logging.debug(proc_args) - try: if constants.PLATFORM_WINDOWS: startup_info = subprocess.STARTUPINFO() From 7aa14822dd7d7321067549a02f44e795af9b5b10 Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 5 May 2021 13:22:19 -0700 Subject: [PATCH 101/251] unified the call to subprocess.Popen so it's clearer in someways that there's no difference between the two platforms except the additiona kwargs remove the optional parameter from set_pdefathsig(), since it's probably better to be explicit here made the constant ALL CAPS, and for consistency, pulled it into constants.py beautified the constants.py with block comments --- exiftool/constants.py | 24 +++++++++++++++++++ exiftool/exiftool.py | 56 ++++++++++++++++++++++++------------------- 2 files changed, 55 insertions(+), 25 deletions(-) diff --git a/exiftool/constants.py b/exiftool/constants.py index 21a7b18..375f636 100644 --- a/exiftool/constants.py +++ b/exiftool/constants.py @@ -19,6 +19,11 @@ 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') @@ -28,6 +33,11 @@ +################################## +####### PLATFORM DEFAULTS ######## +################################## + + # specify the extension so exiftool doesn't default to running "exiftool.py" on windows (which could happen) DEFAULT_EXECUTABLE: str @@ -42,8 +52,22 @@ """ + +################################## +####### 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 diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index d939ea5..cf5e3f4 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -131,7 +131,7 @@ def fsencode(filename): # ====================================================================================================================== -def set_pdeathsig(sig=signal.SIGTERM) -> Optional[Callable]: +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. @@ -139,10 +139,8 @@ def set_pdeathsig(sig=signal.SIGTERM) -> Optional[Callable]: """ 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: @@ -556,7 +554,8 @@ def run(self) -> None: ``common_args`` parameter in the constructor. If it doesn't run successfully, an error will be raised, otherwise, the ``exiftool`` process has started - if you have another executable named exiftool which isn't exiftool, that's your fault + + (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.", UserWarning) @@ -577,26 +576,33 @@ def run(self) -> None: 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: + 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: - if constants.PLATFORM_WINDOWS: - 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 - - self._process = subprocess.Popen( - proc_args, - stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, startupinfo=startup_info) - else: # pytest-cov:windows: no cover - # assume it's linux - self._process = subprocess.Popen( - proc_args, - stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, 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 + # unify both platform calls into one subprocess.Popen call + self._process = subprocess.Popen( + proc_args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + **kwargs) except FileNotFoundError as fnfe: raise fnfe except OSError as oe: @@ -617,7 +623,7 @@ def run(self) -> None: # have to set this before doing the checks below, or else execute() will fail self._running = True - # TODO get ExifTool version here and any Exiftool metadata + # get ExifTool version here and any Exiftool metadata # this can also verify that it is really ExifTool we ran, not some other random process self._ver = self._parse_ver() From 57b72cd2c1bd880febe18fdfa1a32eb6a3ace824 Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 6 May 2021 12:40:44 -0700 Subject: [PATCH 102/251] super duper hacky fix to some Python interpreter vs exiftool interaction on windows only. because a command is ran, the .kill() doesn't terminate the whole tree... this is probably not the best fix --- tests/test_exiftool.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/tests/test_exiftool.py b/tests/test_exiftool.py index 8d9f7f0..76f147a 100644 --- a/tests/test_exiftool.py +++ b/tests/test_exiftool.py @@ -9,13 +9,14 @@ import logging # to test logger #import os #import shutil -#import sys +import sys from pathlib import Path TMP_DIR = Path(__file__).parent / 'tmp' +PLATFORM_WINDOWS: bool = (sys.platform == 'win32') class TestExifTool(unittest.TestCase): @@ -70,6 +71,9 @@ def test_configfile_attribute(self): 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:\\" + # TODO create a config file, and set it and test that it works self.assertFalse(self.et.running) @@ -117,6 +121,11 @@ def test_termination_explicit(self): #--------------------------------------------------------------------------------------------------------- def test_termination_implicit(self): # Test implicit process termination on garbage collection + + # 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 @@ -124,10 +133,23 @@ def test_termination_implicit(self): self.assertNotEqual(self.process.poll(), None) #--------------------------------------------------------------------------------------------------------- def test_process_died_running_status(self): - # Test correct .running status if process dies by itself + """ 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 @@ -137,6 +159,9 @@ def test_process_died_running_status(self): self.assertFalse(self.et.running) self.assertEquals(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 From cc91b7e0d0d18da79d942019f22f9a0e8d9e615f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Philip=20G=C3=B6pfert?= Date: Thu, 6 May 2021 23:40:49 +0200 Subject: [PATCH 103/251] Test copying of tags --- tests/test_exiftool.py | 45 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/test_exiftool.py b/tests/test_exiftool.py index a9de328..8761de9 100644 --- a/tests/test_exiftool.py +++ b/tests/test_exiftool.py @@ -8,6 +8,51 @@ import os import shutil import sys +import tempfile + + +class TestTagCopying(unittest.TestCase): + def setUp(self): + # Prepare exiftool. + self.exiftool = exiftool.ExifTool() + self.exiftool.start() + + # Prepare temporary directory for copy. + directory = tempfile.mkdtemp(prefix='exiftool-test-') + + # Find example image. + this_path = os.path.dirname(__file__) + self.tag_source = os.path.join(this_path, 'rose.jpg') + + # Prepare path of copy. + self.tag_target = os.path.join(directory, 'copy.jpg') + + # Copy image. + shutil.copyfile(self.tag_source, self.tag_target) + + # 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) + + def test_tag_copying(self): + tag = 'XMP:Subject' + expected_value = 'Röschen' + + # Ensure source image has correct tag. + original_value = self.exiftool.get_tag(tag, self.tag_source) + self.assertEqual(original_value, expected_value) + + # 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) + + # Copy tags. + self.exiftool.copy_tags(self.tag_source, self.tag_target) + + value_after_copying = self.exiftool.get_tag(tag, self.tag_target) + self.assertEqual(value_after_copying, expected_value) + class TestExifTool(unittest.TestCase): From b4ab5f96530a369f18759e81814c397fc7e3894b Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 6 May 2021 16:26:53 -0700 Subject: [PATCH 104/251] removed unnecessary check, as get_tags() has this parameter checking built-in --- exiftool/helper.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/exiftool/helper.py b/exiftool/helper.py index ffe3cc6..e0d1a7e 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -184,9 +184,6 @@ def get_metadata(self, in_files, params=None): The return value will have the format described in the documentation of :py:meth:`execute_json()`. """ - if not params: - params = [] - return self.get_tags(None, in_files, params=params) # ---------------------------------------------------------------------------------------------------------------------- From 93bfb155c1b4c0194c1480a8b9cc4818745659a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Philip=20G=C3=B6pfert?= Date: Wed, 12 May 2021 00:32:36 +0200 Subject: [PATCH 105/251] Test basic functionality All four implemented tests currently fail. --- tests/test_helper.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/test_helper.py b/tests/test_helper.py index 3cd426a..a7de0ac 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -13,6 +13,49 @@ TMP_DIR = Path(__file__).parent / 'tmp' + +class InitializationTest(unittest.TestCase): + @unittest.expectedFailure + 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() + + @unittest.expectedFailure + def test_read_all_from_no_file(self): + """ + Supposedly, `get_metadata` always returns a list. + """ + metadata = self.exif_tool_helper.get_metadata([]) + self.assertEqual(metadata, []) + + @unittest.expectedFailure + def test_read_all_from_nonexistent_file(self): + """ + Supposedly, `get_metadata` always returns a list. + """ + metadata = self.exif_tool_helper.get_metadata(['foo.bar']) + self.assertEqual(metadata, []) + + @unittest.expectedFailure + def test_read_tag_from_nonexistent_file(self): + """ + Confronted with a nonexistent file, `get_tag` should probably return None (as the tag is not found) or raise an + appropriate exception. + """ + result = self.exif_tool_helper.get_tag('DateTimeOriginal', 'foo.bar') + self.assertIsNone(result) + + class TestExifToolHelper(unittest.TestCase): #--------------------------------------------------------------------------------------------------------- From 8896d6b6cc55d5db4606207ee83678bff6b1fe4a Mon Sep 17 00:00:00 2001 From: "SylikC (admin)" Date: Wed, 19 May 2021 22:07:08 -0700 Subject: [PATCH 106/251] v0.4.9 update changelog and setup.py to reflect the previous two PR that got merged in, even if 0.4.8 was minor and didn't have any functional changes further changed the links in the README.rst as sylikc's github pages are now online --- CHANGELOG.md | 36 +++++++++++++++++------------------- README.rst | 4 ++-- setup.py | 2 +- 3 files changed, 20 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e5ea27..4ddb5b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,21 +1,19 @@ # PyExifTool Changelog -Current dates are in PST/PDT - Date (Timezone) | Version | Comment ---------------------------- | ------- | ------- -07/17/2019 12:26:16 AM (PDT) | 0.1 | 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.1 | Convert leading spaces to tabs -07/17/2019 12:52:33 AM (PDT) | 0.1.1 | 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
*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.1.2 | 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
*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".*
*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.1.3 | 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
*On Windows this will allow to run exiftool without showing the DOS shell.*
**This might break Linux but I don't know for sure**
Alternative source location with only this patch: https://github.com/blurstudio/pyexiftool/tree/shell-option -07/17/2019 01:24:32 AM (PDT) | 0.1.4 | Merge [Pull request #19 "Correct dependency for building an RPM."](https://github.com/smarnach/pyexiftool/pull/19) by [Achim Herwig (Achimh3011) Munich, Germany](https://github.com/Achimh3011) on Aug 25, 2016
**I'm not sure if this is entirely necessary, but merging it anyways** -07/17/2019 02:09:40 AM (PDT) | 0.1.5 | Merge [Pull request #15 "handling Errno:11 Resource temporarily unavailable"](https://github.com/smarnach/pyexiftool/pull/15) by [shoyebi](https://github.com/shoyebi) on Jun 12, 2015 -07/18/2019 03:40:39 AM (PDT) | 0.1.6 | set_tags and UTF-8 cmdline - Merge in the first set of changes by Leo Broska related to [Pull request #5 "add set_tags_batch, set_tags + constructor takes added options"](https://github.com/smarnach/pyexiftool/pull/5) by [halloleo](https://github.com/halloleo) on Aug 1, 2012
but this is sourced from [jmathai/elodie's 6114328 Jun 22,2016 commit](https://github.com/jmathai/elodie/blob/6114328f325660287d1998338a6d5e6ba4ccf069/elodie/external/pyexiftool.py) -07/18/2019 03:59:02 AM (PDT) | 0.1.7 | Merge another commit fromt he jmathai/elodie [zserg on Mar 12, 2016](https://github.com/jmathai/elodie/blob/af36de091e1746b490bed0adb839adccd4f6d2ef/elodie/external/pyexiftool.py)
seems to do UTF-8 encoding on set_tags -07/18/2019 04:01:18 AM (PDT) | 0.1.7 | minor change it looks like a rename to match PEP8 coding standards by [zserg on Aug 21, 2016](https://github.com/jmathai/elodie/blob/ad1cbefb15077844a6f64dca567ea5600477dd52/elodie/external/pyexiftool.py) -07/18/2019 04:05:36 AM (PDT) | 0.1.8 | [Fallback to latin if utf-8 decode fails in pyexiftool.py](https://github.com/jmathai/elodie/commit/fe70227c7170e01c8377de7f9770e761eab52036#diff-f9cf0f3eed27e85c9c9469d0e0d431d5) by [jmathai](https://github.com/jmathai/elodie/commits?author=jmathai) on Sep 7, 2016 -07/18/2019 04:14:32 AM (PDT) | 0.1.9 | Merge the test cases from the [Pull request #5 "add set_tags_batch, set_tags + constructor takes added options"](https://github.com/smarnach/pyexiftool/pull/5) by [halloleo](https://github.com/halloleo) on Aug 1, 2012 +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: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
*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
*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".*
*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
*On Windows this will allow to run exiftool without showing the DOS shell.*
**This might break Linux but I don't know for sure**
Alternative source location with only this patch: https://github.com/blurstudio/pyexiftool/tree/shell-option +07/17/2019 01:24:32 AM (PDT) | 0.2.5 | Merge [Pull request #19 "Correct dependency for building an RPM."](https://github.com/smarnach/pyexiftool/pull/19) by [Achim Herwig (Achimh3011) Munich, Germany](https://github.com/Achimh3011) on Aug 25, 2016
**I'm not sure if this is entirely necessary, but merging it anyways** +07/17/2019 02:09:40 AM (PDT) | 0.2.6 | Merge [Pull request #15 "handling Errno:11 Resource temporarily unavailable"](https://github.com/smarnach/pyexiftool/pull/15) by [shoyebi](https://github.com/shoyebi) on Jun 12, 2015 +07/18/2019 03:40:39 AM (PDT) | 0.2.7 | set_tags and UTF-8 cmdline - Merge in the first set of changes by Leo Broska related to [Pull request #5 "add set_tags_batch, set_tags + constructor takes added options"](https://github.com/smarnach/pyexiftool/pull/5) by [halloleo](https://github.com/halloleo) on Aug 1, 2012
but this is sourced from [jmathai/elodie's 6114328 Jun 22,2016 commit](https://github.com/jmathai/elodie/blob/6114328f325660287d1998338a6d5e6ba4ccf069/elodie/external/pyexiftool.py) +07/18/2019 03:59:02 AM (PDT) | 0.2.8 | Merge another commit fromt he jmathai/elodie [zserg on Mar 12, 2016](https://github.com/jmathai/elodie/blob/af36de091e1746b490bed0adb839adccd4f6d2ef/elodie/external/pyexiftool.py)
seems to do UTF-8 encoding on set_tags +07/18/2019 04:01:18 AM (PDT) | 0.2.9 | minor change it looks like a rename to match PEP8 coding standards by [zserg on Aug 21, 2016](https://github.com/jmathai/elodie/blob/ad1cbefb15077844a6f64dca567ea5600477dd52/elodie/external/pyexiftool.py) +07/18/2019 04:05:36 AM (PDT) | 0.2.10 | [Fallback to latin if utf-8 decode fails in pyexiftool.py](https://github.com/jmathai/elodie/commit/fe70227c7170e01c8377de7f9770e761eab52036#diff-f9cf0f3eed27e85c9c9469d0e0d431d5) by [jmathai](https://github.com/jmathai/elodie/commits?author=jmathai) on Sep 7, 2016 +07/18/2019 04:14:32 AM (PDT) | 0.2.11 | Merge the test cases from the [Pull request #5 "add set_tags_batch, set_tags + constructor takes added options"](https://github.com/smarnach/pyexiftool/pull/5) by [halloleo](https://github.com/halloleo) on Aug 1, 2012 07/18/2019 04:34:46 AM (PDT) | 0.3.0 | changed the setup.py licensing and updated the version numbering as in changelog
changed the version number scheme, as it appears the "official last release" was 0.2.0 tagged. There's going to be a lot of things broken in this current build, and I'll fix it as they come up. I'm going to start playing with the library and the included tests and such.
There's one more pull request #11 which would be pending, but it duplicates the extra arguments option.
I'm also likely to remove the print conversion as it's now covered by the extra args. I'll also rename some variable names with the addedargs patch
**for my changes (sylikc), I can only guarantee they will work on Python 3.7, because that's my environment... and while I'll try to maintain compatibility, there's no guarantees** 07/18/2019 05:06:19 AM (PDT) | 0.3.1 | make some minor tweaks to the naming of the extra args variable. The other pull request 11 names them params, and when I decide how to merge that pull request, I'll probably change the variable names again. 07/19/2019 12:01:22 AM (PDT) | 0.3.2 | fix the select() problem for windows, and fix all tests @@ -31,20 +29,20 @@ Date (Timezone) | Version | Comment 03/12/2021 02:03:38 PM (PST) | 0.4.5 | no functional code changes. re-release with new version because I accidentally included the "test" package with the PyPI 0.4.4 release. I deleted it instead of yanking or doing a post release this time... just bumped the version. "test" folder renamed to "tests" as per convention, so the build will automatically ignore it 04/08/2021 03:38:46 PM (PDT) | 0.4.6 | added support for config files in constructor -- Merged pull request #7 from @asielen and fixed a bug referenced in the discussion https://github.com/sylikc/pyexiftool/pull/7 04/19/2021 02:37:02 PM (PDT) | 0.4.7 | added support for writing a list of values in set_tags_batch() which allows setting individual keywords (and other tags which are exiftool lists) -- contribution from @davidorme referenced in issue https://github.com/sylikc/pyexiftool/issues/12#issuecomment-821879234 +04/28/2021 01:50:59 PM (PDT) | 0.4.8 | no functional changes, only a minor documentation link update -- Merged pull request #16 from @beng +05/19/2021 09:37:52 PM (PDT) | 0.4.9 | test_tags() parameter encoding bugfix and a new test case TestTagCopying -- Merged pull request #19 from @jangop
I also added further updates to README.rst to point to my repo and GH pages
I fixed the "previous versions" naming to match the v0.2.0 start. None of them were published, so I changed the version information here just to make it less confusing to a casual observer who might ask "why did you have 0.1 when you forked off on 0.2.0?" Sven Marnach's releases were all 0.1, but he tagged his last release v0.2.0, which is my starting point On version changes, update setup.py to reflect version # Changes around the web -Check for changes at the following resources to make sure we have the latest and greatest. After all, I'm "unofficially forked" here offline. I intend to publish the changes once I get it into a working state for my DV Suite +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! + +(last checked 5/19/2021 all) -(last checked 7/17/2019 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 https://github.com/blurstudio/pyexiftool/tree/shell-option (#27) -check for updates https://github.com/RootLUG/pyexiftool (#27) -check for updates https://pypi.org/project/PyExifTool/0.1.1/#files (#15) 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/README.rst b/README.rst index e84632f..1600d7a 100644 --- a/README.rst +++ b/README.rst @@ -31,7 +31,7 @@ Official releases are on PyPI https://pypi.org/project/PyExifTool/ -.. _tarball: https://github.com/smarnach/pyexiftool/tarball/master +.. _tarball: https://github.com/sylikc/pyexiftool/tarball/master Installation ------------ @@ -71,7 +71,7 @@ Documentation ------------- The documentation is available at -http://smarnach.github.io/pyexiftool/. +http://sylikc.github.io/pyexiftool/. Licence ------- diff --git a/setup.py b/setup.py index 3337a7f..042c181 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ # overview name="PyExifTool", - version="0.4.7", + version="0.4.9", license="GPLv3+/BSD", url="http://github.com/sylikc/pyexiftool", python_requires=">=2.6", From 1089e24ec737744cc511f056669f14abf9fed5fb Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 19 May 2021 23:30:32 -0700 Subject: [PATCH 107/251] move the TestTagCopying to ExifToolHelper test script fix TestTagCopying to use the TMP_DIR instead of creating (and leaving) an arbitrary test directory in the TEMP location of the system fix bug in ExifToolHelper where passing in common_args=None will cause a crash --- exiftool/helper.py | 7 +++++- tests/test_exiftool.py | 50 ++-------------------------------------- tests/test_helper.py | 52 +++++++++++++++++++++++++++++++++++++++--- 3 files changed, 57 insertions(+), 52 deletions(-) diff --git a/exiftool/helper.py b/exiftool/helper.py index 8a4f968..23820e8 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -127,7 +127,12 @@ class ExifToolHelper(ExifTool): # ---------------------------------------------------------------------------------------------------------------------- def __init__(self, executable=None, common_args=None, win_shell=True, return_tuple=False): # call parent's constructor - super().__init__(executable=executable, common_args=common_args, win_shell=win_shell, return_tuple=return_tuple) + kwargs = {"executable": executable, "win_shell": win_shell, "return_tuple": return_tuple} # TODO, need a better way of doing this, and not putting in common_args if not specified + if common_args: + kwargs["common_args"] = common_args + + super().__init__(**kwargs) + #super().__init__(executable=executable, common_args=common_args, win_shell=win_shell, return_tuple=return_tuple) # ---------------------------------------------------------------------------------------------------------------------- diff --git a/tests/test_exiftool.py b/tests/test_exiftool.py index a5406a1..028ee47 100644 --- a/tests/test_exiftool.py +++ b/tests/test_exiftool.py @@ -10,59 +10,13 @@ #import os #import shutil import sys -import tempfile - - -class TestTagCopying(unittest.TestCase): - def setUp(self): - # Prepare exiftool. - self.exiftool = exiftool.ExifTool() - self.exiftool.start() - - # Prepare temporary directory for copy. - directory = tempfile.mkdtemp(prefix='exiftool-test-') - - # Find example image. - this_path = os.path.dirname(__file__) - self.tag_source = os.path.join(this_path, 'rose.jpg') - - # Prepare path of copy. - self.tag_target = os.path.join(directory, 'copy.jpg') - - # Copy image. - shutil.copyfile(self.tag_source, self.tag_target) - - # 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) - - def test_tag_copying(self): - tag = 'XMP:Subject' - expected_value = 'Röschen' - - # Ensure source image has correct tag. - original_value = self.exiftool.get_tag(tag, self.tag_source) - self.assertEqual(original_value, expected_value) - - # 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) - - # Copy tags. - self.exiftool.copy_tags(self.tag_source, self.tag_target) - - value_after_copying = self.exiftool.get_tag(tag, self.tag_target) - self.assertEqual(value_after_copying, expected_value) - - from pathlib import Path - -TMP_DIR = Path(__file__).parent / 'tmp' +TMP_DIR = Path(__file__).resolve().parent / 'tmp' PLATFORM_WINDOWS: bool = (sys.platform == 'win32') + class TestExifTool(unittest.TestCase): #--------------------------------------------------------------------------------------------------------- diff --git a/tests/test_helper.py b/tests/test_helper.py index 3cd426a..ef5534a 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -8,10 +8,56 @@ #import os import shutil import sys - +#import tempfile from pathlib import Path -TMP_DIR = Path(__file__).parent / 'tmp' +TMP_DIR = Path(__file__).resolve().parent / 'tmp' + +class TestTagCopying(unittest.TestCase): + def setUp(self): + # Prepare exiftool. + self.exiftool = exiftool.ExifToolHelper() + self.exiftool.run() + + # Prepare temporary directory for copy. + #directory = tempfile.mkdtemp(prefix='exiftool-test-') # this requires cleanup or else it remains on the system in the $TEMP or %TEMP% directories + directory = TMP_DIR + + # Find example image. + this_path = Path(__file__).resolve().parent + self.tag_source = str(this_path / 'rose.jpg') + + # Prepare path of copy. + self.tag_target = str(directory / 'rose-tagcopy.jpg') + + # Copy image. + shutil.copyfile(self.tag_source, self.tag_target) + + # 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) + + def test_tag_copying(self): + tag = 'XMP:Subject' + expected_value = 'Röschen' + + # Ensure source image has correct tag. + original_value = self.exiftool.get_tag(tag, self.tag_source) + self.assertEqual(original_value, expected_value) + + # 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) + + # Copy tags. + self.exiftool.copy_tags(self.tag_source, self.tag_target) + + value_after_copying = self.exiftool.get_tag(tag, self.tag_target) + self.assertEqual(value_after_copying, expected_value) + + self.exiftool.terminate() # do it explictly for Windows, or else will hang on exit (CPython interpreter exit bug) + class TestExifToolHelper(unittest.TestCase): @@ -100,7 +146,7 @@ def test_set_keywords(self): "Keywords": ["nature", "red plant"]}] script_path = Path(__file__).parent source_files = [] - + for d in expected_data: d["SourceFile"] = f = script_path / d["SourceFile"] self.assertTrue(f.exists()) From 956f1c93c1b7afd691528ba3a86eba608a3049ef Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 26 May 2021 02:00:38 -0700 Subject: [PATCH 108/251] decided that execute_json() will return None if no output came back from execute() fix helper to make sure get_tags() will detect None and raise an error --- exiftool/exiftool.py | 17 ++++++++++------- exiftool/helper.py | 9 ++++++++- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index cf5e3f4..6a3ced4 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -788,13 +788,16 @@ def execute_json(self, *params): res_err = self._last_stderr if len(res) == 0: - # if the command has no files it's worked on, or some other type of error - # we can either return None, or [], or FileNotFoundError .. - - # but, since it's technically not an error to have no files, - # returning None is the best. - # Even [] could be ambugious if Exiftool changes the returned JSON structure in the future - # TODO haven't decided on [] or None yet + # 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 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) + + # Returning [] could be ambugious if Exiftool changes the returned JSON structure in the future + # Returning None is the safest as it clearly indicates that nothing came back from execute() return None diff --git a/exiftool/helper.py b/exiftool/helper.py index 23820e8..4d0b61c 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -251,7 +251,12 @@ def get_tags(self, in_tags, in_files, params=None): exec_params.extend(files) - return self.execute_json(*exec_params) + ret = self.execute_json(*exec_params) + + if ret is None: + raise RuntimeError("get_tags: exiftool returned no data") + + return ret # ---------------------------------------------------------------------------------------------------------------------- @@ -354,6 +359,8 @@ def set_tags_batch(self, tags, filenames): params_utf8 = [x.encode('utf-8') for x in params] return self.execute(*params_utf8) + #TODO if execute returns data, then error? + # ---------------------------------------------------------------------------------------------------------------------- def set_tags(self, tags, filename): """Writes the values of the specified tags for the given file. From 2e8f26b9d0e8a169242168f40becf07b6c67575c Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 26 May 2021 02:04:54 -0700 Subject: [PATCH 109/251] add in last_status. This is the exit status from the previous command executed via exiftool additional parameters and parsing was put in to read out the status code fixed a bug that caused _parse_ver() to fail when return_tuple = True --- exiftool/exiftool.py | 51 ++++++++++++++++++++++++++++++++++++++------ exiftool/helper.py | 2 ++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 6a3ced4..6fe31ae 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -253,6 +253,7 @@ def __init__(self, self._return_tuple: bool = return_tuple # are we returning a tuple in the execute? 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) self._block_size: int = constants.DEFAULT_BLOCK_SIZE # set to default block size @@ -490,7 +491,7 @@ def version_tuple(self) -> tuple: def last_stdout(self) -> Optional[str]: """last output stdout from execute() currently it is INTENTIONALLY _NOT_ CLEARED on exiftool termination and not dependent on running state - This allows for executing a command and termiting, but still haven't last* around.""" + This allows for executing a command and terminating, but still haven't last* around.""" return self._last_stdout # ---------------------------------------------------------------------------------------------------------------------- @@ -498,9 +499,16 @@ def last_stdout(self) -> Optional[str]: def last_stderr(self) -> Optional[str]: """last output stderr from execute() currently it is INTENTIONALLY _NOT_ CLEARED on exiftool termination and not dependent on running state - This allows for executing a command and termiting, but still haven't last* around.""" + This allows for executing a command and terminating, but still haven't last* around.""" return self._last_stderr + # ---------------------------------------------------------------------------------------------------------------------- + @property + def last_status(self) -> Optional[int]: + """last exit status from execute() + currently it is INTENTIONALLY _NOT_ CLEARED on exiftool termination and not dependent on running state + This allows for executing a command and terminating, but still haven't last* around.""" + return self._last_status @@ -715,8 +723,10 @@ def execute(self, *params): # 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}".encode(ENCODING_UTF8) # default there isn't any string + SEQ_ERR_STATUS_DELIM = b"=" # this can be configured to be one or more chacters... the code below will accomodate for longer sequences: len() >= 1 + seq_err_status = "${status}".encode(ENCODING_UTF8) # a special sequence, ${status} returns EXIT STATUS as per exiftool documentation - cmd_text = b"\n".join(params + (b"-echo4",seq_err_post, seq_execute,)) + cmd_text = b"\n".join(params + (b"-echo4", SEQ_ERR_STATUS_DELIM + seq_err_status + SEQ_ERR_STATUS_DELIM + seq_err_post, seq_execute, )) # 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 self._process.stdin.write(cmd_text) @@ -733,16 +743,37 @@ def execute(self, *params): # save the output to class vars for retrieval self._last_stdout = output.strip()[:-len(seq_ready)] - self._last_stderr = outerr.strip()[:-len(seq_err_post)] + self._last_stderr = outerr.strip()[:-len(seq_err_post)] # save it in case the RuntimeError happens and output can be checked easily + + out_stderr = self._last_stderr + + # sanity check the status code from the stderr output + delim_len = len(SEQ_ERR_STATUS_DELIM) + if out_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 RuntimeError("Exiftool expected to return status on stderr, but got unexpected charcter") + + # 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 = out_stderr.rfind(SEQ_ERR_STATUS_DELIM, 0, -delim_len) + out_status = out_stderr[err_delim_1 + delim_len : -delim_len ] + + # can check .isnumeric() here, but best just to duck-type cast it + self._last_status = int(out_status) + # lop off the actual status code from stderr + self._last_stderr = out_stderr[:err_delim_1] + if self._logger: self._logger.debug( "Method 'execute': Reply stdout = {}".format(self._last_stdout) ) self._logger.debug( "Method 'execute': Reply stderr = {}".format(self._last_stderr) ) + self._logger.debug( "Method 'execute': Reply status = {}".format(self._last_status) ) if self._return_tuple: - return (self._last_stdout, self._last_stderr,) + return (self._last_stdout, self._last_stderr, self._last_status, ) else: # this was the standard return before, just stdout return self._last_stdout @@ -783,9 +814,11 @@ def execute_json(self, *params): # get stdout only res = std[0] res_err = std[1] + res_status = std[2] else: res = std res_err = self._last_stderr + res_status = self._last_status if len(res) == 0: # the output from execute() can be empty under many relatively ambiguous situations @@ -816,6 +849,8 @@ def execute_json(self, *params): # TODO: if len(res_decoded) == 0, then there's obviously an error here return json.loads(res_decoded) + # TODO , return_tuple will also beautify stderr and output status as well + ######################################################################################### #################################### PRIVATE METHODS #################################### @@ -845,4 +880,8 @@ def _parse_ver(self): # -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(b"-ver").decode(ENCODING_UTF8).strip() + ret = self.execute(b"-ver") + if self._return_tuple: + ret = ret[0] # only take stdout if a tuple is returned + + return ret.decode(ENCODING_UTF8).strip() diff --git a/exiftool/helper.py b/exiftool/helper.py index 4d0b61c..fe54cad 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -256,6 +256,8 @@ def get_tags(self, in_tags, in_files, params=None): if ret is None: raise RuntimeError("get_tags: exiftool returned no data") + # TODO if last_status is <> 0, raise a warning that one or more files failed? + return ret From 578e899019cd0d646a3cacd02fd0cb7c4d1980c8 Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 26 May 2021 02:05:32 -0700 Subject: [PATCH 110/251] update copyright on LICENSE file to reflect years as per standard --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 4dff229..4c283ca 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ PyExifTool -Copyright 2012 Sven Marnach, Kevin M (sylikc) +Copyright 2012-2014 Sven Marnach, 2019-2021 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 From d4d57f3b58d7f3072449ad4ffaafc1a153e435df Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 27 May 2021 20:37:30 -0700 Subject: [PATCH 111/251] make the python.exe call explicit with .EXE extension in the batch files --- scripts/mypy.bat | 6 +++--- scripts/pytest-cov.bat | 6 +++--- scripts/unittest.bat | 4 +--- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/scripts/mypy.bat b/scripts/mypy.bat index 7014872..63a9c70 100644 --- a/scripts/mypy.bat +++ b/scripts/mypy.bat @@ -7,10 +7,10 @@ echo *** PyExifTool automation *** echo MyPy Static Analysis Script echo; echo pip's MyPy version -python -m pip show mypy | findstr /l /c:"Version:" +python.exe -m pip show mypy | findstr /l /c:"Version:" echo ______________________ -mypy --config-file mypy.ini --strict exiftool/ +python.exe -m mypy --config-file mypy.ini --strict exiftool/ -popd \ No newline at end of file +popd diff --git a/scripts/pytest-cov.bat b/scripts/pytest-cov.bat index b4ff667..6c46d1c 100644 --- a/scripts/pytest-cov.bat +++ b/scripts/pytest-cov.bat @@ -7,12 +7,12 @@ echo *** PyExifTool automation *** echo PyTest Coverage Script echo; echo pip's PyTest version -python -m pip show pytest | findstr /l /c:"Version:" +python.exe -m pip show pytest | findstr /l /c:"Version:" echo pip's PyTest-cov version -python -m pip show pytest-cov | findstr /l /c:"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 python.exe -m pytest -v --cov-config=%~dp0windows.coveragerc --cov=exiftool --cov-report term-missing tests/ -popd \ No newline at end of file +popd diff --git a/scripts/unittest.bat b/scripts/unittest.bat index 6f639cb..1fe7b86 100644 --- a/scripts/unittest.bat +++ b/scripts/unittest.bat @@ -7,8 +7,6 @@ echo *** PyExifTool automation *** echo Python Built-in Unittest Script echo ______________________ - -python -m unittest -v +python.exe -m unittest -v popd - From 0d7b3ff7efbbb61eba6981f39aeb83b53a7157db Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 27 May 2021 20:39:20 -0700 Subject: [PATCH 112/251] add in a README which shows the requirements file for existing ones rename pytest-cov.bat to pytest.bat as the requirements file will show the need for the -cov extension --- scripts/README.txt | 7 +++++++ scripts/mypy_reqiuirements.txt | 1 + scripts/{pytest-cov.bat => pytest.bat} | 0 scripts/pytest_requirements.txt | 4 ++++ 4 files changed, 12 insertions(+) create mode 100644 scripts/README.txt create mode 100644 scripts/mypy_reqiuirements.txt rename scripts/{pytest-cov.bat => pytest.bat} (100%) create mode 100644 scripts/pytest_requirements.txt 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 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 @@ +mypy diff --git a/scripts/pytest-cov.bat b/scripts/pytest.bat similarity index 100% rename from scripts/pytest-cov.bat rename to scripts/pytest.bat 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 From 854cc7f8c1a17ae427fedfdd1176ec284675745c Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 27 May 2021 20:55:07 -0700 Subject: [PATCH 113/251] exiftool.py fix all the: * E261 at least two spaces before inline comment * sys, codecs unused (that function was commented out) --- exiftool/exiftool.py | 66 ++++++++++++++++++++++---------------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 6fe31ae..035ef99 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -59,7 +59,6 @@ from __future__ import unicode_literals import select -import sys import subprocess import os import shutil @@ -68,16 +67,15 @@ # 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 json # type: ignore # comment related to https://github.com/python/mypy/issues/1153 import warnings import logging -import codecs # for the pdeathsig import signal import ctypes -from pathlib import Path # requires Python 3.4+ +from pathlib import Path # requires Python 3.4+ import random @@ -98,6 +96,8 @@ """ .. +import sys, codecs + # This code has been adapted from Lib/os.py in the Python source tree # (sha1 265e36e277f3) def _fscodec(): @@ -169,9 +169,9 @@ def _read_fd_endswith(fd, b_endswith, block_size: int): # 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 + 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], [], []) + inputready, outputready, exceptready = select.select([fd], [], []) for i in inputready: if i == fd: output += os.read(fd, block_size) @@ -247,22 +247,22 @@ def __init__(self, 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 = None # this is set to be the exiftool -v -ver when running + self._process = None # this is set to the process to interact with when _running=True + self._ver = None # this is set to be the exiftool -v -ver when running - self._return_tuple: bool = return_tuple # are we returning a tuple in the execute? - 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) + self._return_tuple: bool = return_tuple # are we returning a tuple in the execute? + 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) - self._block_size: int = constants.DEFAULT_BLOCK_SIZE # set to default block size + 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._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._no_output = None # TODO examine whether this is needed + self._no_output = None # TODO examine whether this is needed self._logger = None @@ -288,7 +288,7 @@ def __init__(self, # --- run any remaining initialization code --- - random.seed(None) # initialize random number generator + random.seed(None) # initialize random number generator @@ -473,7 +473,7 @@ def version_tuple(self) -> tuple: raise RuntimeError("Can't get ExifTool version when it's not running!") # TODO 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) + arr: List = self._ver.split(".", 1) # split to (major).(whatever) res: List = [] try: @@ -581,7 +581,7 @@ def run(self) -> None: # 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.append("-common_args") # add this param only if there are common_args proc_args.extend(self._common_args) # add the common arguments @@ -596,7 +596,7 @@ def run(self) -> None: startup_info.dwFlags |= constants.SW_FORCEMINIMIZE kwargs['startupinfo'] = startup_info - else: # pytest-cov:windows: no cover + 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. @@ -624,7 +624,7 @@ def run(self) -> None: # check error above before saying it's running if self._process.poll() is not None: # the Popen launched, then process terminated - self._process = None # unset it as it's now terminated + self._process = None # unset it as it's now terminated raise RuntimeError("exiftool did not execute successfully") @@ -656,7 +656,7 @@ def terminate(self, timeout: int = 30, _del: bool = False) -> None: #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 + 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 else: @@ -669,9 +669,9 @@ def terminate(self, timeout: int = 30, _del: bool = False) -> None: On Linux, this runs as is, and the process terminates properly """ - self._process.communicate(input=b"-stay_open\nFalse\n", timeout=timeout) # TODO these are constants which should be elsewhere defined + self._process.communicate(input=b"-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) + 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 @@ -714,17 +714,17 @@ def execute(self, *params): # 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) + 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".encode(ENCODING_UTF8) # the default string is b"-execute\n" - seq_ready = f"{{ready{signal_num}}}".encode(ENCODING_UTF8) # the default string is b"{ready}" + seq_execute = f"-execute{signal_num}\n".encode(ENCODING_UTF8) # the default string is b"-execute\n" + seq_ready = f"{{ready{signal_num}}}".encode(ENCODING_UTF8) # 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}".encode(ENCODING_UTF8) # default there isn't any string - SEQ_ERR_STATUS_DELIM = b"=" # this can be configured to be one or more chacters... the code below will accomodate for longer sequences: len() >= 1 - seq_err_status = "${status}".encode(ENCODING_UTF8) # a special sequence, ${status} returns EXIT STATUS as per exiftool documentation + seq_err_post = f"post{signal_num}".encode(ENCODING_UTF8) # default there isn't any string + SEQ_ERR_STATUS_DELIM = b"=" # this can be configured to be one or more chacters... the code below will accomodate for longer sequences: len() >= 1 + seq_err_status = "${status}".encode(ENCODING_UTF8) # a special sequence, ${status} returns EXIT STATUS as per exiftool documentation cmd_text = b"\n".join(params + (b"-echo4", SEQ_ERR_STATUS_DELIM + seq_err_status + SEQ_ERR_STATUS_DELIM + seq_err_post, seq_execute, )) # cmd_text.encode("utf-8") # a commit put this in the next line, but i can't get it to work TODO @@ -743,7 +743,7 @@ def execute(self, *params): # save the output to class vars for retrieval self._last_stdout = output.strip()[:-len(seq_ready)] - self._last_stderr = outerr.strip()[:-len(seq_err_post)] # save it in case the RuntimeError happens and output can be checked easily + self._last_stderr = outerr.strip()[:-len(seq_err_post)] # save it in case the RuntimeError happens and output can be checked easily out_stderr = self._last_stderr @@ -863,8 +863,8 @@ def _flag_running_false(self) -> None: This method makes it less likely someone will leave off a variable if one comes up in the future """ - self._process = None # don't delete, just leave as None - self._ver = None # unset the version + self._process = None # don't delete, just leave as None + self._ver = None # unset the version self._running = False @@ -882,6 +882,6 @@ def _parse_ver(self): # -v2 gives you even more, but it's less useful at that point ret = self.execute(b"-ver") if self._return_tuple: - ret = ret[0] # only take stdout if a tuple is returned + ret = ret[0] # only take stdout if a tuple is returned return ret.decode(ENCODING_UTF8).strip() From 2eefce0adddb1a08b6018ce0bbf6f1bea911cc35 Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 27 May 2021 21:08:16 -0700 Subject: [PATCH 114/251] fix the DeprecationWarning: Please use assertEqual instead. add a new test for common_args --- tests/test_exiftool.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/test_exiftool.py b/tests/test_exiftool.py index 028ee47..269900b 100644 --- a/tests/test_exiftool.py +++ b/tests/test_exiftool.py @@ -95,7 +95,7 @@ def test_termination_cm(self): self.assertTrue(self.et.running) with warnings.catch_warnings(record=True) as w: self.et.run() - self.assertEquals(len(w), 1) + self.assertEqual(len(w), 1) self.assertTrue(issubclass(w[0].category, UserWarning)) self.process = self.et._process self.assertEqual(self.process.poll(), None) @@ -114,7 +114,7 @@ def test_termination_explicit(self): # terminate when not running with warnings.catch_warnings(record=True) as w: self.et.terminate() - self.assertEquals(len(w), 1) + self.assertEqual(len(w), 1) self.assertTrue(issubclass(w[0].category, UserWarning)) #--------------------------------------------------------------------------------------------------------- @@ -156,7 +156,7 @@ def test_process_died_running_status(self): with warnings.catch_warnings(record=True) as w: self.assertFalse(self.et.running) - self.assertEquals(len(w), 1) + self.assertEqual(len(w), 1) self.assertTrue(issubclass(w[0].category, UserWarning)) # after removing that function, delete the object so it gets recreated cleanly @@ -167,6 +167,14 @@ def test_invalid_args_list(self): with self.assertRaises(TypeError): exiftool.ExifTool(common_args="not a list") #--------------------------------------------------------------------------------------------------------- + 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): log = logging.getLogger("log_test") From b3a279d00fd1d7ab69773d751bd05194d2818ceb Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 27 May 2021 21:13:42 -0700 Subject: [PATCH 115/251] fix all: E261 at least two spaces before inline comment --- exiftool/constants.py | 6 +++--- exiftool/helper.py | 2 +- tests/test_exiftool.py | 2 +- tests/test_helper.py | 8 ++++---- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/exiftool/constants.py b/exiftool/constants.py index 375f636..2edcf06 100644 --- a/exiftool/constants.py +++ b/exiftool/constants.py @@ -43,7 +43,7 @@ if PLATFORM_WINDOWS: DEFAULT_EXECUTABLE = "exiftool.exe" -else: # pytest-cov:windows: no cover +else: # pytest-cov:windows: no cover DEFAULT_EXECUTABLE = "exiftool" """The name of the executable to run. @@ -58,10 +58,10 @@ ################################## # for Windows STARTUPINFO -SW_FORCEMINIMIZE: int = 11 # from win32con +SW_FORCEMINIMIZE: int = 11 # from win32con # for Linux preexec_fn -PR_SET_PDEATHSIG: int = 1 # taken from linux/prctl.h +PR_SET_PDEATHSIG: int = 1 # taken from linux/prctl.h diff --git a/exiftool/helper.py b/exiftool/helper.py index fe54cad..99cd207 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -127,7 +127,7 @@ class ExifToolHelper(ExifTool): # ---------------------------------------------------------------------------------------------------------------------- def __init__(self, executable=None, common_args=None, win_shell=True, return_tuple=False): # call parent's constructor - kwargs = {"executable": executable, "win_shell": win_shell, "return_tuple": return_tuple} # TODO, need a better way of doing this, and not putting in common_args if not specified + kwargs = {"executable": executable, "win_shell": win_shell, "return_tuple": return_tuple} # TODO, need a better way of doing this, and not putting in common_args if not specified if common_args: kwargs["common_args"] = common_args diff --git a/tests/test_exiftool.py b/tests/test_exiftool.py index 269900b..ea5680d 100644 --- a/tests/test_exiftool.py +++ b/tests/test_exiftool.py @@ -6,7 +6,7 @@ import exiftool import warnings -import logging # to test logger +import logging # to test logger #import os #import shutil import sys diff --git a/tests/test_helper.py b/tests/test_helper.py index ef5534a..67cba4a 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -56,7 +56,7 @@ def test_tag_copying(self): value_after_copying = self.exiftool.get_tag(tag, self.tag_target) self.assertEqual(value_after_copying, expected_value) - self.exiftool.terminate() # do it explictly for Windows, or else will hang on exit (CPython interpreter exit bug) + self.exiftool.terminate() # do it explictly for Windows, or else will hang on exit (CPython interpreter exit bug) class TestExifToolHelper(unittest.TestCase): @@ -80,12 +80,12 @@ def test_get_metadata(self): "File:ImageWidth": 70, "File:ImageHeight": 46, "XMP:Subject": "Röschen", - "Composite:ImageSize": "70 46"}, # older versions of exiftool used to display 70x46 + "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 + "Composite:ImageSize": "64 64"}] # older versions of exiftool used to display 64x64 script_path = Path(__file__).parent source_files = [] @@ -102,7 +102,7 @@ def test_get_metadata(self): self.assertTrue(isinstance(et_version, float)) if isinstance(et_version, float): # avoid exception in Py3k self.assertTrue( - et_version >= 8.40, # TODO there's probably a bug in this test, 8.40 == 8.4 which isn't the intended behavior + et_version >= 8.40, # TODO there's probably a bug in this test, 8.40 == 8.4 which isn't the intended behavior "you should at least use ExifTool version 8.40") actual["SourceFile"] = Path(actual["SourceFile"]).resolve() for k, v in expected.items(): From 09b43f23a2cf09705f07803636ee6a3521a1e85d Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 27 May 2021 21:14:50 -0700 Subject: [PATCH 116/251] changed ExifTool common_args=None means that an empty list is set. A test verifies this behavior --- exiftool/exiftool.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 035ef99..31f7d9a 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -395,7 +395,9 @@ def common_args(self, new_args: Optional[List[str]]) -> None: # it can be none, the code accomodates for that now - if new_args is None or isinstance(new_args, list): + 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 = new_args From 452f50a75edfac30374edd8ffc01796d090deaf8 Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 27 May 2021 21:17:14 -0700 Subject: [PATCH 117/251] fix most of the: E265 block comment should start with '# ' --- tests/test_exiftool.py | 28 ++++++++++++++-------------- tests/test_helper.py | 12 ++++++------ 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/test_exiftool.py b/tests/test_exiftool.py index ea5680d..7154ef2 100644 --- a/tests/test_exiftool.py +++ b/tests/test_exiftool.py @@ -19,7 +19,7 @@ class TestExifTool(unittest.TestCase): - #--------------------------------------------------------------------------------------------------------- + # --------------------------------------------------------------------------------------------------------- def setUp(self): self.et = exiftool.ExifTool(common_args=["-G", "-n", "-overwrite_original"]) @@ -30,13 +30,13 @@ def tearDown(self): 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) @@ -48,7 +48,7 @@ def test_executable_attribute(self): with self.assertRaises(FileNotFoundError): self.et.executable = "lkajsdfoleiawjfasv" self.assertFalse(self.et.running) - #--------------------------------------------------------------------------------------------------------- + # --------------------------------------------------------------------------------------------------------- def test_blocksize_attribute(self): current = self.et.block_size @@ -62,7 +62,7 @@ def test_blocksize_attribute(self): # restore self.et.block_size = current - #--------------------------------------------------------------------------------------------------------- + # --------------------------------------------------------------------------------------------------------- def test_configfile_attribute(self): current = self.et.config_file @@ -85,7 +85,7 @@ def test_configfile_attribute(self): self.et.terminate() - #--------------------------------------------------------------------------------------------------------- + # --------------------------------------------------------------------------------------------------------- def test_termination_cm(self): # Test correct subprocess start and termination when using # self.et as a context manager @@ -101,7 +101,7 @@ def test_termination_cm(self): 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() @@ -117,7 +117,7 @@ def test_termination_explicit(self): self.assertEqual(len(w), 1) self.assertTrue(issubclass(w[0].category, UserWarning)) - #--------------------------------------------------------------------------------------------------------- + # --------------------------------------------------------------------------------------------------------- def test_termination_implicit(self): # Test implicit process termination on garbage collection @@ -130,7 +130,7 @@ def test_termination_implicit(self): # 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 """ @@ -161,12 +161,12 @@ def test_process_died_running_status(self): # 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_common_args(self): # test to make sure passing in an invalid args list will cause it to error out with self.assertRaises(TypeError): @@ -174,7 +174,7 @@ def test_common_args(self): # set to common_args=None == [] self.assertEqual(exiftool.ExifTool(common_args=None).common_args, []) - #--------------------------------------------------------------------------------------------------------- + # --------------------------------------------------------------------------------------------------------- """ def test_logger(self): log = logging.getLogger("log_test") @@ -189,9 +189,9 @@ def test_logger(self): """ - #--------------------------------------------------------------------------------------------------------- + # --------------------------------------------------------------------------------------------------------- -#--------------------------------------------------------------------------------------------------------- +# --------------------------------------------------------------------------------------------------------- if __name__ == '__main__': unittest.main() diff --git a/tests/test_helper.py b/tests/test_helper.py index 67cba4a..4b2f381 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -61,7 +61,7 @@ def test_tag_copying(self): class TestExifToolHelper(unittest.TestCase): - #--------------------------------------------------------------------------------------------------------- + # --------------------------------------------------------------------------------------------------------- def setUp(self): self.et = exiftool.ExifToolHelper(common_args=["-G", "-n", "-overwrite_original"]) @@ -73,7 +73,7 @@ def tearDown(self): if self.process.poll() is None: self.process.terminate() - #--------------------------------------------------------------------------------------------------------- + # --------------------------------------------------------------------------------------------------------- def test_get_metadata(self): expected_data = [{"SourceFile": "rose.jpg", "File:FileType": "JPEG", @@ -112,7 +112,7 @@ def test_get_metadata(self): for k in ["SourceFile", "XMP:Subject"])) self.assertEqual(tag0, "Röschen") - #--------------------------------------------------------------------------------------------------------- + # --------------------------------------------------------------------------------------------------------- def test_set_metadata(self): mod_prefix = "newcap_" expected_data = [{"SourceFile": "rose.jpg", @@ -138,7 +138,7 @@ def test_set_metadata(self): f_mod.unlink() self.assertEqual(tag0, d["Caption-Abstract"]) - #--------------------------------------------------------------------------------------------------------- + # --------------------------------------------------------------------------------------------------------- def test_set_keywords(self): kw_to_add = ["added"] mod_prefix = "newkw_" @@ -170,7 +170,7 @@ def test_set_keywords(self): 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): @@ -204,6 +204,6 @@ def test_set_list_keywords(self): self.assertEqual(kwtag2, [d["Keywords"][0]] + kw_to_add) """ -#--------------------------------------------------------------------------------------------------------- +# --------------------------------------------------------------------------------------------------------- if __name__ == '__main__': unittest.main() From bb3783e3893c4ab91453111bf5dfd555f38887ab Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 27 May 2021 21:31:44 -0700 Subject: [PATCH 118/251] fix some more PEP8 stuff and other things caught in static analysis by flake8 --- exiftool/exiftool.py | 15 ++++++++------- exiftool/helper.py | 24 ++++++++++++------------ tests/test_helper.py | 7 ++++--- 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 31f7d9a..47a0ec6 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -336,7 +336,7 @@ def executable(self, new_executable) -> None: """ # cannot set executable when process is running if self.running: - raise RuntimeError( 'Cannot set new executable while Exiftool is running' ) + raise RuntimeError("Cannot set new executable while Exiftool is running") abs_path: Optional[str] = None @@ -347,7 +347,7 @@ def executable(self, new_executable) -> None: 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' ) + raise FileNotFoundError(f'"{new_executable}" is not found, on path or as absolute path') # absolute path is returned self._executable = abs_path @@ -680,7 +680,8 @@ def terminate(self, timeout: int = 30, _del: bool = False) -> None: self._flag_running_false() - if self._logger: self._logger.info(f"Method 'terminate': Exiftool terminated successfully.") + # TODO log / return exit status from exiftool? + if self._logger: self._logger.info("Method 'terminate': Exiftool terminated successfully.") @@ -759,7 +760,7 @@ def execute(self, *params): # 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 = out_stderr.rfind(SEQ_ERR_STATUS_DELIM, 0, -delim_len) - out_status = out_stderr[err_delim_1 + delim_len : -delim_len ] + out_status = out_stderr[err_delim_1 + delim_len : -delim_len] # can check .isnumeric() here, but best just to duck-type cast it self._last_status = int(out_status) @@ -769,9 +770,9 @@ def execute(self, *params): if self._logger: - self._logger.debug( "Method 'execute': Reply stdout = {}".format(self._last_stdout) ) - self._logger.debug( "Method 'execute': Reply stderr = {}".format(self._last_stderr) ) - self._logger.debug( "Method 'execute': Reply status = {}".format(self._last_status) ) + self._logger.debug("Method 'execute': Reply stdout = {}".format(self._last_stdout)) + self._logger.debug("Method 'execute': Reply stderr = {}".format(self._last_stderr)) + self._logger.debug("Method 'execute': Reply status = {}".format(self._last_status)) if self._return_tuple: diff --git a/exiftool/helper.py b/exiftool/helper.py index 99cd207..80642c8 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -79,7 +79,7 @@ def _is_iterable(in_param: Any) -> bool: #string helper -def strip_nl (s): +def strip_nl(s): return ' '.join(s.splitlines()) # ====================================================================================================================== @@ -87,7 +87,7 @@ def strip_nl (s): # Error checking function # very rudimentary checking # Note: They are quite fragile, because this just parse the output text from exiftool -def check_ok (result): +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. @@ -98,14 +98,14 @@ def check_ok (result): # ====================================================================================================================== -def format_error (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): + if check_ok(result): return 'exiftool finished probably properly. ("%s")' % strip_nl(result) else: if result is None: @@ -247,7 +247,7 @@ def get_tags(self, in_tags, in_files, params=None): exec_params.extend(params) # tags is always a list by this point. It will always be iterable... don't have to check for None - exec_params.extend( ["-" + t for t in tags] ) + exec_params.extend(["-" + t for t in tags]) exec_params.extend(files) @@ -311,9 +311,9 @@ def get_tag(self, tag, filename): return self.get_tag_batch(tag, [filename])[0] # ---------------------------------------------------------------------------------------------------------------------- - def copy_tags(self, fromFilename, toFilename): + def copy_tags(self, from_filename, to_filename): """Copy all tags from one file to another.""" - params = ["-overwrite_original", "-TagsFromFile", fromFilename, toFilename] + params = ["-overwrite_original", "-TagsFromFile", from_filename, to_filename] params_utf8 = [x.encode('utf-8') for x in params] self.execute(*params_utf8) @@ -405,15 +405,15 @@ def set_keywords_batch(self, mode, keywords, filenames): params = [] params_utf8 = [] - kw_operation = {KW_REPLACE:"-%s=%s", - KW_ADD:"-%s+=%s", - KW_REMOVE:"-%s-=%s"}[mode] + 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 ] + kw_params = [kw_operation % (KW_TAGNAME, w) for w in keywords] params.extend(kw_params) params.extend(filenames) - logging.debug (params) + logging.debug(params) params_utf8 = [x.encode('utf-8') for x in params] return self.execute(*params_utf8) diff --git a/tests/test_helper.py b/tests/test_helper.py index 4b2f381..a250f61 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -4,10 +4,10 @@ import unittest import exiftool -import warnings +#import warnings #import os import shutil -import sys +#import sys #import tempfile from pathlib import Path @@ -133,7 +133,7 @@ def test_set_metadata(self): shutil.copyfile(f, f_mod) source_files.append(f_mod) with self.et: - self.et.set_tags({"Caption-Abstract":d["Caption-Abstract"]}, f_mod_str) + self.et.set_tags({"Caption-Abstract": d["Caption-Abstract"]}, f_mod_str) tag0 = self.et.get_tag("IPTC:Caption-Abstract", f_mod_str) f_mod.unlink() self.assertEqual(tag0, d["Caption-Abstract"]) @@ -204,6 +204,7 @@ def test_set_list_keywords(self): self.assertEqual(kwtag2, [d["Keywords"][0]] + kw_to_add) """ + # --------------------------------------------------------------------------------------------------------- if __name__ == '__main__': unittest.main() From dd31ca1a2a0fd6035ee7f3110dcb6794a3876c78 Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 27 May 2021 21:32:00 -0700 Subject: [PATCH 119/251] adding restore of config file before doing the create/test/set that will come later --- tests/test_exiftool.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_exiftool.py b/tests/test_exiftool.py index 7154ef2..a5aa43e 100644 --- a/tests/test_exiftool.py +++ b/tests/test_exiftool.py @@ -75,6 +75,10 @@ def test_configfile_attribute(self): # TODO create a config file, and set it and test that it works + + # then restore current config_file + self.et.config_file = current + self.assertFalse(self.et.running) self.et.run() self.assertTrue(self.et.running) From 0c7c01cf8524f288d9b99ddec27687a5f99db028 Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 27 May 2021 21:34:02 -0700 Subject: [PATCH 120/251] adding current flake8 configuration as per discussion on PEP8 coding style here: https://github.com/sylikc/pyexiftool/discussions/23#discussioncomment-795679 --- scripts/flake8.bat | 21 +++++++++++++++++++++ scripts/flake8.ini | 9 +++++++++ scripts/flake8_requirements.txt | 3 +++ 3 files changed, 33 insertions(+) create mode 100644 scripts/flake8.bat create mode 100644 scripts/flake8.ini create mode 100644 scripts/flake8_requirements.txt 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..7283b95 --- /dev/null +++ b/scripts/flake8_requirements.txt @@ -0,0 +1,3 @@ +flake8 +# check for PEP8 Naming Conventions +pep8-naming From 9c8622f58bf17cedf547f14955b014ca5bb54058 Mon Sep 17 00:00:00 2001 From: Nathaniel Young Date: Fri, 28 May 2021 03:00:44 -0700 Subject: [PATCH 121/251] doesn't use root logger --- exiftool/exiftool.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index e10fa9e..fa3c3f3 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -241,7 +241,11 @@ def find_executable(executable, path=None): return None +#------------------------------------------------------------------------------------------------ + +# sets logger with name rather than using the root logger +logger = logging.getLogger(__name__) @@ -350,7 +354,7 @@ def start(self): proc_args.extend(["-stay_open", "True", "-@", "-", "-common_args"]) proc_args.extend(self.common_args) # add the common arguments - logging.debug(proc_args) + logger.debug(proc_args) with open(os.devnull, "w") as devnull: try: @@ -743,7 +747,7 @@ def set_keywords_batch(self, mode, keywords, filenames): params.extend(kw_params) params.extend(filenames) - logging.debug (params) + logger.debug(params) params_utf8 = [x.encode('utf-8') for x in params] return self.execute(*params_utf8) From ee2b01ad58320792b5d5587b804c4d290a08d0d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Philip=20G=C3=B6pfert?= Date: Sat, 29 May 2021 22:36:07 +0200 Subject: [PATCH 122/251] Create lint-and-test.yml --- .github/workflows/lint-and-test.yml | 36 +++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/lint-and-test.yml diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml new file mode 100644 index 0000000..680cc36 --- /dev/null +++ b/.github/workflows/lint-and-test.yml @@ -0,0 +1,36 @@ +name: Lint and Test + +on: [push, pull_request] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: [3.7, 3.8, 3.9] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Install pyexiftool + run: | + python -m pip install . + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest From d7ceb0c605dd24788b3bbf752f9d6b06f5156bca Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 18 Aug 2021 19:43:47 -0700 Subject: [PATCH 123/251] made changes back in May, some extra comments and optimizing a few things * the proc_args not to trigger another function call * raise sends the errors up automatically --- docs/source/index.rst | 14 ++++++++++++-- exiftool/exiftool.py | 15 +++++++++------ scripts/flake8_requirements.txt | 3 +++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/docs/source/index.rst b/docs/source/index.rst index 7ad6592..690d103 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -4,17 +4,27 @@ PyExifTool -- A Python wrapper for Phil Harvey's ExifTool ========================================================== -.. automodule:: exiftool.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 +.. + automodule:: exiftool.helper :members: :undoc-members: diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 47a0ec6..cb8ec6e 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -242,6 +242,7 @@ def __init__(self, return_tuple: bool = False, config_file: Optional[str] = None, logger = None) -> None: + """ docstring stub """ # --- default settings / declare member variables --- self._running: bool = False # is it running? @@ -253,7 +254,7 @@ def __init__(self, self._return_tuple: bool = return_tuple # are we returning a tuple in the execute? 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) + 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 @@ -332,7 +333,6 @@ def executable(self, new_executable) -> None: """ Set the executable. Does error checking. - in testing, shutil.which() will work if a complete path is given, but this isn't clear, so we explicitly check and don't search if path exists """ # cannot set executable when process is running if self.running: @@ -340,6 +340,9 @@ def executable(self, new_executable) -> None: abs_path: Optional[str] = None + # 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: @@ -572,7 +575,7 @@ def run(self) -> None: return # first the executable ... - proc_args = [self.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: @@ -616,11 +619,11 @@ def run(self) -> None: except FileNotFoundError as fnfe: raise fnfe except OSError as oe: - raise oe + raise except ValueError as ve: - raise ve + raise except subprocess.CalledProcessError as cpe: - raise cpe + raise # TODO print out more useful error messages to these different errors above # check error above before saying it's running diff --git a/scripts/flake8_requirements.txt b/scripts/flake8_requirements.txt index 7283b95..ff10806 100644 --- a/scripts/flake8_requirements.txt +++ b/scripts/flake8_requirements.txt @@ -1,3 +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 From bec17655c69f01004167f0e1c50da5db1e4aee02 Mon Sep 17 00:00:00 2001 From: "SylikC (admin)" Date: Sun, 22 Aug 2021 20:17:34 -0700 Subject: [PATCH 124/251] as per https://github.com/sylikc/pyexiftool/pull/24 , loguru is a possibility as a logger, so check the logger set differently. Any drop-in replacement for logger will work with ExifTool --- exiftool/exiftool.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index cb8ec6e..974d4f3 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -531,8 +531,28 @@ def _set_logger(self, new_logger) -> None: """ if new_logger is None: self._logger = None - elif not isinstance(new_logger, logging.Logger): - raise TypeError("logger needs to be of type logging.Logger") + 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 = check and callable(new_logger.info) + check = check and callable(new_logger.warning) + check = check and callable(new_logger.error) + check = check and callable(new_logger.critical) + check = check 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 @@ -819,10 +839,12 @@ def execute_json(self, *params): if self._return_tuple: # get stdout only res = std[0] + # TODO these aren't used, if not important, comment them out res_err = std[1] res_status = std[2] else: res = std + # TODO these aren't used, if not important, comment them out res_err = self._last_stderr res_status = self._last_status From 5f4b9aab8f0a617410ee1db4904300551036dd13 Mon Sep 17 00:00:00 2001 From: SylikC Date: Sun, 22 Aug 2021 20:25:39 -0700 Subject: [PATCH 125/251] move the logger into the __init__ instead of global to exiftool.py --- exiftool/exiftool.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index fa3c3f3..1e6a0ab 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -241,13 +241,6 @@ def find_executable(executable, path=None): return None -#------------------------------------------------------------------------------------------------ - - -# sets logger with name rather than using the root logger -logger = logging.getLogger(__name__) - - #------------------------------------------------------------------------------------------------ class ExifTool(object): @@ -330,6 +323,9 @@ def __init__(self, executable_=None, common_args=None, win_shell=True, config_fi self.no_output = '-w' in self.common_args + # sets logger with name rather than using the root logger + self.logger = logging.getLogger(__name__) + def start(self): """Start an ``exiftool`` process in batch mode for this instance. @@ -354,7 +350,7 @@ def start(self): proc_args.extend(["-stay_open", "True", "-@", "-", "-common_args"]) proc_args.extend(self.common_args) # add the common arguments - logger.debug(proc_args) + self.logger.debug(proc_args) with open(os.devnull, "w") as devnull: try: @@ -747,7 +743,7 @@ def set_keywords_batch(self, mode, keywords, filenames): params.extend(kw_params) params.extend(filenames) - logger.debug(params) + self.logger.debug(params) params_utf8 = [x.encode('utf-8') for x in params] return self.execute(*params_utf8) From eaa480734840840693e5cbe41d434ff0f602bb0d Mon Sep 17 00:00:00 2001 From: SylikC Date: Sun, 22 Aug 2021 20:37:06 -0700 Subject: [PATCH 126/251] v0.4.11 changed documentation link to point to the right place in PyPI also added the changelog entry for the last commit --- CHANGELOG.md | 2 ++ setup.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ddb5b4..568e9d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ Date (Timezone) | Version | Comment 04/19/2021 02:37:02 PM (PDT) | 0.4.7 | added support for writing a list of values in set_tags_batch() which allows setting individual keywords (and other tags which are exiftool lists) -- contribution from @davidorme referenced in issue https://github.com/sylikc/pyexiftool/issues/12#issuecomment-821879234 04/28/2021 01:50:59 PM (PDT) | 0.4.8 | no functional changes, only a minor documentation link update -- Merged pull request #16 from @beng 05/19/2021 09:37:52 PM (PDT) | 0.4.9 | test_tags() parameter encoding bugfix and a new test case TestTagCopying -- Merged pull request #19 from @jangop
I also added further updates to README.rst to point to my repo and GH pages
I fixed the "previous versions" naming to match the v0.2.0 start. None of them were published, so I changed the version information here just to make it less confusing to a casual observer who might ask "why did you have 0.1 when you forked off on 0.2.0?" Sven Marnach's releases were all 0.1, but he tagged his last release v0.2.0, which is my starting point +08/22/2021 08:32:30 PM (PDT) | 0.4.10 | logger changed to use logging.getLogger(__name__) instead of the root logger -- Merged pull request #24 from @nyoungstudios +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 On version changes, update setup.py to reflect version diff --git a/setup.py b/setup.py index 042c181..f0ccf12 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ # overview name="PyExifTool", - version="0.4.9", + version="0.4.11", license="GPLv3+/BSD", url="http://github.com/sylikc/pyexiftool", python_requires=">=2.6", @@ -46,7 +46,7 @@ keywords="exiftool image exif metadata photo video photography", project_urls={ - "Documentation": "http://smarnach.github.io/pyexiftool/", + "Documentation": "https://sylikc.github.io/pyexiftool/", "Tracker": "https://github.com/sylikc/pyexiftool/issues", "Source": "https://github.com/sylikc/pyexiftool", }, From 96b47593760e429deeed8ec265925cbf7c2e5ef6 Mon Sep 17 00:00:00 2001 From: "SylikC (admin)" Date: Sun, 22 Aug 2021 21:05:50 -0700 Subject: [PATCH 127/251] v0.4.12 regarding pull request https://github.com/sylikc/pyexiftool/pull/26 didn't merge it directly as I would like to retain the outs, errs even if unused at this time --- CHANGELOG.md | 1 + exiftool/exiftool.py | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 568e9d8..e275e2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Date (Timezone) | Version | Comment 05/19/2021 09:37:52 PM (PDT) | 0.4.9 | test_tags() parameter encoding bugfix and a new test case TestTagCopying -- Merged pull request #19 from @jangop
I also added further updates to README.rst to point to my repo and GH pages
I fixed the "previous versions" naming to match the v0.2.0 start. None of them were published, so I changed the version information here just to make it less confusing to a casual observer who might ask "why did you have 0.1 when you forked off on 0.2.0?" Sven Marnach's releases were all 0.1, but he tagged his last release v0.2.0, which is my starting point 08/22/2021 08:32:30 PM (PDT) | 0.4.10 | logger changed to use logging.getLogger(__name__) instead of the root logger -- Merged pull request #24 from @nyoungstudios 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 On version changes, update setup.py to reflect version diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 1e6a0ab..8a28e05 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -400,7 +400,7 @@ def terminate(self, wait_timeout=30): 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) self._process.kill() - outs, errs = proc.communicate() + outs, errs = self._process.communicate() # err handling code from https://docs.python.org/3/library/subprocess.html#subprocess.Popen.communicate del self._process diff --git a/setup.py b/setup.py index f0ccf12..92bf331 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ # overview name="PyExifTool", - version="0.4.11", + version="0.4.12", license="GPLv3+/BSD", url="http://github.com/sylikc/pyexiftool", python_requires=">=2.6", From 4bf2ccf125d2cd9960b1198064c3d97a4496b520 Mon Sep 17 00:00:00 2001 From: SylikC Date: Mon, 23 Aug 2021 00:03:24 -0700 Subject: [PATCH 128/251] ExifToolHelper - add auto_start feature. Demonstrate overriding execute() to provide auto-start functionality added comment about why exec_params is initialized that way in get_tags() --- exiftool/helper.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/exiftool/helper.py b/exiftool/helper.py index 80642c8..00cf29c 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -125,7 +125,10 @@ class ExifToolHelper(ExifTool): """ # ---------------------------------------------------------------------------------------------------------------------- - def __init__(self, executable=None, common_args=None, win_shell=True, return_tuple=False): + def __init__(self, executable=None, common_args=None, win_shell=True, return_tuple=False, auto_start=True): + """ + auto_start = BOOLEAN. will autostart the exiftool process on first command run + """ # call parent's constructor kwargs = {"executable": executable, "win_shell": win_shell, "return_tuple": return_tuple} # TODO, need a better way of doing this, and not putting in common_args if not specified if common_args: @@ -135,6 +138,9 @@ def __init__(self, executable=None, common_args=None, win_shell=True, return_tup #super().__init__(executable=executable, common_args=common_args, win_shell=win_shell, return_tuple=return_tuple) + self._auto_start: bool = auto_start + + # ---------------------------------------------------------------------------------------------------------------------- # 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): @@ -241,9 +247,8 @@ def get_tags(self, in_tags, in_files, params=None): exec_params = [] - if not params: - pass - else: + if params: + # this is done to avoid accidentally modifying the reference object params exec_params.extend(params) # tags is always a list by this point. It will always be iterable... don't have to check for None @@ -430,6 +435,15 @@ def set_keywords(self, mode, keywords, filename): + # ---------------------------------------------------------------------------------------------------------------------- + def execute(self, *params): + """ overload 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) + + # ---------------------------------------------------------------------------------------------------------------------- @staticmethod def _check_sanity_of_result(file_paths, result): From e6f7b909809c628dfc1dc32f6461747b84749a64 Mon Sep 17 00:00:00 2001 From: SylikC Date: Mon, 23 Aug 2021 00:04:04 -0700 Subject: [PATCH 129/251] ExifToolHelper - fix bug that if you pass in common_args=[] it breaks because the check fails --- exiftool/helper.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/exiftool/helper.py b/exiftool/helper.py index 00cf29c..bb5a1e8 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -131,7 +131,8 @@ def __init__(self, executable=None, common_args=None, win_shell=True, return_tup """ # call parent's constructor kwargs = {"executable": executable, "win_shell": win_shell, "return_tuple": return_tuple} # TODO, need a better way of doing this, and not putting in common_args if not specified - if common_args: + # have to check None, because passing in an empty list is valid to pass on to kwargs + if common_args is not None: kwargs["common_args"] = common_args super().__init__(**kwargs) From 8325f64fff1cc948fb57e58bb2d1b491f9d305bb Mon Sep 17 00:00:00 2001 From: SylikC Date: Fri, 10 Sep 2021 18:38:01 -0700 Subject: [PATCH 130/251] ExifToolHelper - changed the way the constructor works The way I had it implemented before was sort of dumb, and using **kwargs can pass on any and all stuff to ExifTool() constructor regardless of how it changes (logger was added from last time this was updated) --- exiftool/helper.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/exiftool/helper.py b/exiftool/helper.py index bb5a1e8..e5239d5 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -125,18 +125,14 @@ class ExifToolHelper(ExifTool): """ # ---------------------------------------------------------------------------------------------------------------------- - def __init__(self, executable=None, common_args=None, win_shell=True, return_tuple=False, auto_start=True): + 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 - kwargs = {"executable": executable, "win_shell": win_shell, "return_tuple": return_tuple} # TODO, need a better way of doing this, and not putting in common_args if not specified - # have to check None, because passing in an empty list is valid to pass on to kwargs - if common_args is not None: - kwargs["common_args"] = common_args - super().__init__(**kwargs) - #super().__init__(executable=executable, common_args=common_args, win_shell=win_shell, return_tuple=return_tuple) self._auto_start: bool = auto_start From ec028a58c654f70b93cf7cf1ae66c03abc52edf8 Mon Sep 17 00:00:00 2001 From: SylikC Date: Fri, 10 Sep 2021 18:38:44 -0700 Subject: [PATCH 131/251] ExifToolHelper - support Path-like objects in get_tags() Will have to do it in other functions as we move forward, but gotta start somewhere. --- exiftool/helper.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/exiftool/helper.py b/exiftool/helper.py index e5239d5..7a18088 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -33,6 +33,8 @@ basestring = (bytes, str) +from pathlib import PurePath # Python 3.4 required + from typing import Any @@ -236,6 +238,9 @@ def get_tags(self, in_tags, in_files, params=None): if isinstance(in_files, basestring): files = [in_files] + elif isinstance(in_files, PurePath): + # support for Path-like objects + files = [str(in_files)] elif _is_iterable(in_files): files = in_files else: From 7b61701418653e7023d5daa0176f34ee99371efa Mon Sep 17 00:00:00 2001 From: SylikC Date: Sun, 3 Oct 2021 00:26:04 -0700 Subject: [PATCH 132/251] Exiftool main class code cleanup. * remove Python 2.x "unicode_literals" * clean up imports. Remove unused and sort them by standard vs internal library ones * remove the fscodec() commented out code * rename internal function to start with underscore * other misc comments --- exiftool/exiftool.py | 119 +++++++++++++++++-------------------------- 1 file changed, 47 insertions(+), 72 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 974d4f3..6a05ac2 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -56,12 +56,20 @@ d["EXIF:DateTimeOriginal"])) """ -from __future__ import unicode_literals - +# ---------- standard Python imports ---------- import select import subprocess import os import shutil +from pathlib import Path # requires Python 3.4+ +import random + +# for the pdeathsig +import signal +import ctypes + + +# ---------- UltraJSON overloaded import ---------- try: # Optional UltraJSON library - ultra-fast JSON encoder/decoder, drop-in replacement @@ -69,69 +77,32 @@ except ImportError: import json # type: ignore # comment related to https://github.com/python/mypy/issues/1153 import warnings -import logging - -# for the pdeathsig -import signal -import ctypes -from pathlib import Path # requires Python 3.4+ -import random +# ---------- Linting Imports ---------- # for static analysis / type checking - Python 3.5+ from collections.abc import Callable from typing import Optional, List -from . import constants - -# constants to make typos obsolete! -ENCODING_UTF8: str = "utf-8" -ENCODING_LATIN1: str = "latin-1" +# ---------- Library Package Imports ---------- -# ====================================================================================================================== +from . import constants -""" -.. -import sys, codecs +# ====================================================================================================================== -# 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: - # cannot assume that filename will be a str. In the off-chance we're using a filename which is a number, this will throw an error - return str(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) -> Optional[Callable]: +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. @@ -242,7 +213,10 @@ def __init__(self, return_tuple: bool = False, config_file: Optional[str] = None, logger = None) -> None: - """ docstring stub """ + """ 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? @@ -268,6 +242,11 @@ def __init__(self, + # --- run external library initialization code --- + random.seed(None) # initialize random number generator + + + # --- set variables via properties (which do the error checking) -- @@ -276,7 +255,7 @@ def __init__(self, # use the passed in parameter, or the default if not set # error checking is done in the property.setter - self.executable = executable if executable is not None else constants.DEFAULT_EXECUTABLE + self.executable = executable or constants.DEFAULT_EXECUTABLE self.common_args = common_args # set the property, error checking happens in the property.setter @@ -285,15 +264,6 @@ def __init__(self, - - - # --- run any remaining initialization code --- - - random.seed(None) # initialize random number generator - - - - ####################################################################################### #################################### MAGIC METHODS #################################### ####################################################################################### @@ -386,18 +356,16 @@ 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 RuntimeError("Cannot set new common_args while exiftool is running!") - - # TODO may not use constructor defaults if they cause errors (I recall seeing an issue filed) - - # it can be none, the code accomodates for that now - if new_args is None: self._common_args = [] elif isinstance(new_args, list): @@ -543,11 +511,11 @@ def _set_logger(self, new_logger) -> None: try: # ExifTool will probably use all of these logging method calls at some point # check all these are callable methods - check = check and callable(new_logger.info) - check = check and callable(new_logger.warning) - check = check and callable(new_logger.error) - check = check and callable(new_logger.critical) - check = check and callable(new_logger.exception) + 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 @@ -623,7 +591,7 @@ def run(self) -> None: kwargs['startupinfo'] = startup_info else: # pytest-cov:windows: no cover # assume it's linux - kwargs['preexec_fn'] = set_pdeathsig(signal.SIGTERM) + 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 @@ -637,7 +605,7 @@ def run(self) -> None: stderr=subprocess.PIPE, **kwargs) except FileNotFoundError as fnfe: - raise fnfe + raise except OSError as oe: raise except ValueError as ve: @@ -668,7 +636,7 @@ def run(self) -> None: 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) @@ -750,7 +718,7 @@ def execute(self, *params): #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}".encode(ENCODING_UTF8) # default there isn't any string SEQ_ERR_STATUS_DELIM = b"=" # this can be configured to be one or more chacters... the code below will accomodate for longer sequences: len() >= 1 - seq_err_status = "${status}".encode(ENCODING_UTF8) # a special sequence, ${status} returns EXIT STATUS as per exiftool documentation + seq_err_status = b"${status}" # a special sequence, ${status} returns EXIT STATUS as per exiftool documentation cmd_text = b"\n".join(params + (b"-echo4", SEQ_ERR_STATUS_DELIM + seq_err_status + SEQ_ERR_STATUS_DELIM + seq_err_post, seq_execute, )) # cmd_text.encode("utf-8") # a commit put this in the next line, but i can't get it to work TODO @@ -862,6 +830,8 @@ def execute_json(self, *params): return None + # TODO use fsdecode? + # os.fsdecode() instead of res.decode() try: res_decoded = res.decode(ENCODING_UTF8) except UnicodeDecodeError: @@ -870,11 +840,14 @@ def execute_json(self, *params): # which will return something like # image files read # output files created + + # res_decoded is also not valid if you do metadata manipulation without returning anything if self._no_output: print(res_decoded) # TODO: test why is this not returning anything from this function?? what if we are SETTING something and not GETTING? else: # TODO: if len(res_decoded) == 0, then there's obviously an error here + #print(res_decoded) return json.loads(res_decoded) # TODO , return_tuple will also beautify stderr and output status as well @@ -913,3 +886,5 @@ def _parse_ver(self): ret = ret[0] # only take stdout if a tuple is returned return ret.decode(ENCODING_UTF8).strip() + + # ---------------------------------------------------------------------------------------------------------------------- From 1beaaadb2893098fe2281e6ff3379753f2e69338 Mon Sep 17 00:00:00 2001 From: SylikC Date: Sun, 3 Oct 2021 00:29:03 -0700 Subject: [PATCH 133/251] ExifToolHelper() - added terminate() overload moved "not so well tested" functionality out to an ExifToolAlpha class, which will be an extension of ExifToolHelper This code has not been tested yet. The tests will be re-targeted to this multi layer class approach soon. --- exiftool/__init__.py | 4 + exiftool/experimental.py | 341 +++++++++++++++++++++++++++++++++++++++ exiftool/helper.py | 327 ++++--------------------------------- 3 files changed, 374 insertions(+), 298 deletions(-) create mode 100644 exiftool/experimental.py diff --git a/exiftool/__init__.py b/exiftool/__init__.py index 772b7e4..8079079 100644 --- a/exiftool/__init__.py +++ b/exiftool/__init__.py @@ -5,5 +5,9 @@ # make all of the original exiftool stuff available in this namespace 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/experimental.py b/exiftool/experimental.py new file mode 100644 index 0000000..f66d655 --- /dev/null +++ b/exiftool/experimental.py @@ -0,0 +1,341 @@ +# -*- coding: utf-8 -*- +# 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 + + +# ====================================================================================================================== + +#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 + """ + + # ---------------------------------------------------------------------------------------------------------------------- + # 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, 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 + + # ---------------------------------------------------------------------------------------------------------------------- + def get_tag_batch(self, tag, filenames): + """Extract a single tag from the given files. + + The first argument is a single tag name, as usual in the + format :. + + 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([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, from_filename, to_filename): + """Copy all tags from one file to another.""" + params = ["-overwrite_original", "-TagsFromFile", from_filename, to_filename] + params_utf8 = [x.encode('utf-8') for x in params] + self.execute(*params_utf8) + + + # ---------------------------------------------------------------------------------------------------------------------- + def set_tags_batch(self, tags, filenames): + """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 :. + + 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 = [] + 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) + + #TODO if execute returns data, then error? + + # ---------------------------------------------------------------------------------------------------------------------- + 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. + """ + 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. + + 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 = [] + params_utf8 = [] + + 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) + logging.debug(params) + + params_utf8 = [x.encode('utf-8') for x in params] + return self.execute(*params_utf8) + + # ---------------------------------------------------------------------------------------------------------------------- + def set_keywords(self, mode, keywords, filename): + """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(mode, keywords, [filename]) + + + # ---------------------------------------------------------------------------------------------------------------------- + @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 index 7a18088..81f7c2e 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -38,20 +38,6 @@ from typing import Any -# ====================================================================================================================== - -#def atexit_handler - -# constants related to keywords manipulations -KW_TAGNAME = "IPTC:Keywords" -KW_REPLACE, KW_ADD, KW_REMOVE = range(3) - - - - - - - # ====================================================================================================================== @@ -76,49 +62,6 @@ def _is_iterable(in_param: Any) -> bool: -# ====================================================================================================================== - - - -#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 ExifToolHelper(ExifTool): @@ -126,6 +69,10 @@ class ExifToolHelper(ExifTool): It keeps low-level functionality with the base class but adds helper functions on top of it """ + ############################################################################################ + #################################### OVERLOADED METHODS #################################### + ############################################################################################ + # ---------------------------------------------------------------------------------------------------------------------- def __init__(self, auto_start=True, **kwargs): """ @@ -136,50 +83,36 @@ def __init__(self, auto_start=True, **kwargs): # call parent's constructor super().__init__(**kwargs) - self._auto_start: bool = auto_start # ---------------------------------------------------------------------------------------------------------------------- - # 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 = [] + def execute(self, *params): + """ overload 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() - if params: - execute_params.extend(params) - execute_params.extend(filenames) - - result = self.execute_json(execute_params) - - if result: - try: - ExifToolHelper._check_sanity_of_result(filenames, result) - except (IOError, 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 super().execute(*params) - 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) + def terminate(self, **opts) -> None: + """ overload the terminate() method so that if it's not running, won't execute (no warning will be output) + + options are passed directly to the parent verbatim + """ + if not self.running: + return + + super().terminate(**opts) + + + + + + ##################################################################################### + #################################### NEW METHODS #################################### + ##################################################################################### # ---------------------------------------------------------------------------------------------------------------------- def get_metadata(self, in_files, params=None): @@ -192,20 +125,10 @@ def get_metadata(self, in_files, params=None): 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:`execute_json()`. + documentation of :py:meth:`get_tags()`. """ return self.get_tags(None, in_files, 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) # ---------------------------------------------------------------------------------------------------------------------- def get_tags(self, in_tags, in_files, params=None): @@ -216,7 +139,7 @@ def get_tags(self, in_tags, in_files, params=None): If in_tags is None, or [], then returns all tags - The second argument is an iterable of file names. + The second argument is an iterable of file names. or a single file name The format of the return value is the same as for :py:meth:`execute_json()`. @@ -236,6 +159,7 @@ def get_tags(self, in_tags, in_files, params=None): raise TypeError("The argument 'in_tags' must be a str/bytes or a list") + # TODO take Path-like objects in a list and single line convert with str() if isinstance(in_files, basestring): files = [in_files] elif isinstance(in_files, PurePath): @@ -268,198 +192,5 @@ def get_tags(self, in_tags, in_files, params=None): return ret - # ---------------------------------------------------------------------------------------------------------------------- - # (#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 - - - # ---------------------------------------------------------------------------------------------------------------------- - def get_tag_batch(self, tag, filenames): - """Extract a single tag from the given files. - - The first argument is a single tag name, as usual in the - format :. - - 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([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, from_filename, to_filename): - """Copy all tags from one file to another.""" - params = ["-overwrite_original", "-TagsFromFile", from_filename, to_filename] - params_utf8 = [x.encode('utf-8') for x in params] - self.execute(*params_utf8) - - - # ---------------------------------------------------------------------------------------------------------------------- - def set_tags_batch(self, tags, filenames): - """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 :. - - 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 = [] - 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) - - #TODO if execute returns data, then error? # ---------------------------------------------------------------------------------------------------------------------- - 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. - """ - 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. - - 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 = [] - params_utf8 = [] - - 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) - logging.debug(params) - - params_utf8 = [x.encode('utf-8') for x in params] - return self.execute(*params_utf8) - - # ---------------------------------------------------------------------------------------------------------------------- - def set_keywords(self, mode, keywords, filename): - """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(mode, keywords, [filename]) - - - - # ---------------------------------------------------------------------------------------------------------------------- - def execute(self, *params): - """ overload 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) - - - # ---------------------------------------------------------------------------------------------------------------------- - @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)) - From 53eb0a7a15220dbf1df2ffd658431579e43ac092 Mon Sep 17 00:00:00 2001 From: SylikC Date: Sun, 3 Oct 2021 01:04:01 -0700 Subject: [PATCH 134/251] ExifTool main class * remove "return_tuple" feature... I added it thinking it would be a useful feature, but in practice, it unnecessarily complicates the code paths * consistently used f-strings. removed all .format() uses * clean up the way execute() sets its class variables. Have all the local variables set in one spot, and class variables set much later, but together. Cleaned up the names to be consistent. * add in comments to sections of execute() --- exiftool/exiftool.py | 88 ++++++++++++++++++++++---------------------- 1 file changed, 45 insertions(+), 43 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 6a05ac2..ab15e92 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -210,7 +210,6 @@ def __init__(self, executable: Optional[str] = None, common_args: Optional[List[str]] = ["-G", "-n"], win_shell: bool = True, - return_tuple: bool = False, config_file: Optional[str] = None, logger = None) -> None: """ common_args defaults to -G -n as this is the most common use case. @@ -225,7 +224,6 @@ def __init__(self, self._process = None # this is set to the process to interact with when _running=True self._ver = None # this is set to be the exiftool -v -ver when running - self._return_tuple: bool = return_tuple # are we returning a tuple in the execute? 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) @@ -706,6 +704,8 @@ def execute(self, *params): raise RuntimeError("ExifTool instance not running.") + # ---------- 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) @@ -717,16 +717,24 @@ def execute(self, *params): # 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}".encode(ENCODING_UTF8) # default there isn't any string + SEQ_ERR_STATUS_DELIM = b"=" # this can be configured to be one or more chacters... the code below will accomodate for longer sequences: len() >= 1 seq_err_status = b"${status}" # a special sequence, ${status} returns EXIT STATUS as per exiftool documentation - cmd_text = b"\n".join(params + (b"-echo4", SEQ_ERR_STATUS_DELIM + seq_err_status + SEQ_ERR_STATUS_DELIM + seq_err_post, seq_execute, )) + cmd_text = b"\n".join(params + (b"-echo4", SEQ_ERR_STATUS_DELIM + seq_err_status + SEQ_ERR_STATUS_DELIM + seq_err_post, seq_execute)) # 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 + + + # ---------- write to the pipe connected with exiftool process ---------- + self._process.stdin.write(cmd_text) self._process.stdin.flush() - if self._logger: self._logger.info( "Method 'execute': Command sent = {}".format(cmd_text.split(b'\n')[:-1]) ) + if self._logger: self._logger.info(f"Method 'execute': Command sent = {cmd_text.split(b'\n')[:-1]}") + + + # ---------- read output from exiftool process until special sequences reached ---------- fdout = self._process.stdout.fileno() output = _read_fd_endswith(fdout, seq_ready, self._block_size) @@ -735,42 +743,45 @@ def execute(self, *params): fderr = self._process.stderr.fileno() outerr = _read_fd_endswith(fderr, seq_err_post, self._block_size) - # save the output to class vars for retrieval - self._last_stdout = output.strip()[:-len(seq_ready)] - self._last_stderr = outerr.strip()[:-len(seq_err_post)] # save it in case the RuntimeError happens and output can be checked easily - out_stderr = self._last_stderr + # ---------- parse output ---------- + + # save the outputs to some variables first + cmd_stdout = output.strip()[:-len(seq_ready)] + cmd_stderr = outerr.strip()[:-len(seq_err_post)] # save it in case the RuntimeError happens and output can be checked easily # sanity check the status code from the stderr output delim_len = len(SEQ_ERR_STATUS_DELIM) - if out_stderr[-delim_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 RuntimeError("Exiftool expected to return status on stderr, but got unexpected charcter") + raise RuntimeError(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 = out_stderr.rfind(SEQ_ERR_STATUS_DELIM, 0, -delim_len) - out_status = out_stderr[err_delim_1 + delim_len : -delim_len] + 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 ---------- - # can check .isnumeric() here, but best just to duck-type cast it - self._last_status = int(out_status) # lop off the actual status code from stderr - self._last_stderr = out_stderr[:err_delim_1] + 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("Method 'execute': Reply stdout = {}".format(self._last_stdout)) - self._logger.debug("Method 'execute': Reply stderr = {}".format(self._last_stderr)) - self._logger.debug("Method 'execute': Reply status = {}".format(self._last_status)) + 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}") - if self._return_tuple: - return (self._last_stdout, self._last_stderr, self._last_status, ) - else: - # this was the standard return before, just stdout - return self._last_stdout + # the standard return: just stdout + # if you need other output, retrieve from properties + return self._last_stdout @@ -802,21 +813,14 @@ def execute_json(self, *params): # Try utf-8 and fallback to latin. # http://stackoverflow.com/a/5552623/1318758 # https://github.com/jmathai/elodie/issues/127 - std = self.execute(b"-j", *params) - - if self._return_tuple: - # get stdout only - res = std[0] - # TODO these aren't used, if not important, comment them out - res_err = std[1] - res_status = std[2] - else: - res = std - # TODO these aren't used, if not important, comment them out - res_err = self._last_stderr - res_status = self._last_status - if len(res) == 0: + res_stdout = self.execute(b"-j", *params) + # TODO these aren't used, if not important, comment them out + res_err = self._last_stderr + res_status = self._last_status + + + if len(res_stdout) == 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 @@ -831,11 +835,11 @@ def execute_json(self, *params): # TODO use fsdecode? - # os.fsdecode() instead of res.decode() + # os.fsdecode() instead of res_stdout.decode() try: - res_decoded = res.decode(ENCODING_UTF8) + res_decoded = res_stdout.decode(ENCODING_UTF8) except UnicodeDecodeError: - res_decoded = res.decode(ENCODING_LATIN1) + res_decoded = res_stdout.decode(ENCODING_LATIN1) # TODO res_decoded can be invalid json (test this) if `-w` flag is specified in common_args # which will return something like # image files read @@ -862,7 +866,7 @@ 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 - This method makes it less likely someone will leave off a variable if one comes up in the future + This method makes it less likely a maintainer will leave off a variable if other ones are added in the future """ self._process = None # don't delete, just leave as None self._ver = None # unset the version @@ -882,8 +886,6 @@ def _parse_ver(self): # -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 ret = self.execute(b"-ver") - if self._return_tuple: - ret = ret[0] # only take stdout if a tuple is returned return ret.decode(ENCODING_UTF8).strip() From 7b3f38406ff94729d074ef786bf7e975974ce8d1 Mon Sep 17 00:00:00 2001 From: SylikC Date: Sun, 3 Oct 2021 01:16:59 -0700 Subject: [PATCH 135/251] ExifToolHelper - fix technically incorrect comment... overload -> override --- exiftool/helper.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/exiftool/helper.py b/exiftool/helper.py index 81f7c2e..ddb005d 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -69,9 +69,9 @@ class ExifToolHelper(ExifTool): It keeps low-level functionality with the base class but adds helper functions on top of it """ - ############################################################################################ - #################################### OVERLOADED METHODS #################################### - ############################################################################################ + ########################################################################################## + #################################### OVERRIDE METHODS #################################### + ########################################################################################## # ---------------------------------------------------------------------------------------------------------------------- def __init__(self, auto_start=True, **kwargs): @@ -88,7 +88,7 @@ def __init__(self, auto_start=True, **kwargs): # ---------------------------------------------------------------------------------------------------------------------- def execute(self, *params): - """ overload the execute() method so that it checks if it's running first, and if not, start it """ + """ 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() @@ -97,7 +97,7 @@ def execute(self, *params): # ---------------------------------------------------------------------------------------------------------------------- def terminate(self, **opts) -> None: - """ overload the terminate() method so that if it's not running, won't execute (no warning will be output) + """ override the terminate() method so that if it's not running, won't execute (no warning will be output) options are passed directly to the parent verbatim """ From 1a841955d5f6c72f6b2cbe4daa17352c8750b8d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Philip=20G=C3=B6pfert?= Date: Sun, 3 Oct 2021 11:31:46 +0200 Subject: [PATCH 136/251] Install exiftool --- .github/workflows/lint-and-test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 680cc36..2fb7096 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -22,6 +22,7 @@ jobs: python -m pip install --upgrade pip python -m pip install flake8 pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + apt install -qq libimage-exiftool-perl - name: Install pyexiftool run: | python -m pip install . From bc21c4c29c8620d5ef16cec38d321e99904b2908 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Philip=20G=C3=B6pfert?= Date: Sun, 3 Oct 2021 11:34:26 +0200 Subject: [PATCH 137/251] Use apt-get instead of apt --- .github/workflows/lint-and-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 2fb7096..bd54aae 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -22,7 +22,7 @@ jobs: python -m pip install --upgrade pip python -m pip install flake8 pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - apt install -qq libimage-exiftool-perl + apt-get install -qq libimage-exiftool-perl - name: Install pyexiftool run: | python -m pip install . From fe3500aa8eee5bffdd9ac6b556a69fa161c2772c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Philip=20G=C3=B6pfert?= Date: Sun, 3 Oct 2021 11:38:34 +0200 Subject: [PATCH 138/251] Install as root --- .github/workflows/lint-and-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index bd54aae..97d2698 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -22,7 +22,7 @@ jobs: python -m pip install --upgrade pip python -m pip install flake8 pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - apt-get install -qq libimage-exiftool-perl + sudo apt-get install -qq libimage-exiftool-perl - name: Install pyexiftool run: | python -m pip install . From ecbd420acf4c18acf27d52d9c0b2728c829d7431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Philip=20G=C3=B6pfert?= Date: Sun, 3 Oct 2021 11:44:58 +0200 Subject: [PATCH 139/251] Assign IOError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I **guess** that this is what was meant. Now, the variable `error` is redundant. 🤷 --- exiftool/exiftool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 8a28e05..3c238d5 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -477,7 +477,7 @@ def execute_json_wrapper(self, filenames, params=None, retry_on_error=True): if result: try: ExifTool._check_sanity_of_result(filenames, result) - except (IOError, error): + except IOError as error: # Restart the exiftool child process in these cases since something is going wrong self.terminate() self.start() From b03c373e2aee672a7f0c3a985e9094cf35e4bf2a Mon Sep 17 00:00:00 2001 From: SylikC Date: Sun, 3 Oct 2021 11:53:37 -0700 Subject: [PATCH 140/251] ExifTool - fix some bugs that caused actual use error... also, apparently "\" are not allowed anywhere in f-strings --- exiftool/exiftool.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index ab15e92..93dfd9a 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -509,11 +509,11 @@ def _set_logger(self, new_logger) -> None: 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) + 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 @@ -731,7 +731,7 @@ def execute(self, *params): self._process.stdin.write(cmd_text) self._process.stdin.flush() - if self._logger: self._logger.info(f"Method 'execute': Command sent = {cmd_text.split(b'\n')[:-1]}") + if self._logger: self._logger.info("Method 'execute': Command sent = {}".format(cmd_text.split(b'\n')[:-1])) # ---------- read output from exiftool process until special sequences reached ---------- @@ -872,6 +872,8 @@ def _flag_running_false(self) -> None: self._ver = None # unset the version self._running = False + # as an FYI, as per the last_* properties, they are intentionally not cleared when process closes + # ---------------------------------------------------------------------------------------------------------------------- def _parse_ver(self): From 15792bd6dbbc9ec435ab4446cc4bc95a6e12644a Mon Sep 17 00:00:00 2001 From: SylikC Date: Sun, 3 Oct 2021 11:55:57 -0700 Subject: [PATCH 141/251] IMPORTANT: API signature of get_tags() has been changed.. `files` and `tags` have been reversed API signature of all future methods will change to have `files` first, `params` last This should make it more consistent across the board as exiftool must work on "FILES" first and then options afterwards Also fixed get_tags() to be generic enough that it won't require specifically type-checking against PurePath --- exiftool/experimental.py | 16 +++++++- exiftool/helper.py | 89 ++++++++++++++++++++++++++-------------- 2 files changed, 74 insertions(+), 31 deletions(-) diff --git a/exiftool/experimental.py b/exiftool/experimental.py index f66d655..6e30147 100644 --- a/exiftool/experimental.py +++ b/exiftool/experimental.py @@ -100,6 +100,17 @@ 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! """ # ---------------------------------------------------------------------------------------------------------------------- @@ -170,6 +181,9 @@ def get_tag_batch_wrapper(self, tag, filenames, params=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, tag, filenames): """Extract a single tag from the given files. @@ -181,7 +195,7 @@ def get_tag_batch(self, tag, filenames): 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([tag], filenames) + data = self.get_tags(filenames, [tag]) result = [] for d in data: d.pop("SourceFile") diff --git a/exiftool/helper.py b/exiftool/helper.py index ddb005d..6341381 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -33,7 +33,7 @@ basestring = (bytes, str) -from pathlib import PurePath # Python 3.4 required +#from pathlib import PurePath # Python 3.4 required from typing import Any @@ -97,7 +97,7 @@ def execute(self, *params): # ---------------------------------------------------------------------------------------------------------------------- def terminate(self, **opts) -> None: - """ override the terminate() method so that if it's not running, won't execute (no warning will be output) + """ 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 """ @@ -114,61 +114,90 @@ def terminate(self, **opts) -> None: #################################### NEW METHODS #################################### ##################################################################################### + + # all generic helper functions will follow a convention of + # function(files to be worked on, ... , params=) + + # ---------------------------------------------------------------------------------------------------------------------- - def get_metadata(self, in_files, params=None): + def get_metadata(self, files, params=None): """Return all meta-data for the given files. - This will ALWAYS return a list + This will returns a list, or None - in_files can be an iterable(strings) or a string. + 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(None, in_files, params=params) + return self.get_tags(files, None, params=params) # ---------------------------------------------------------------------------------------------------------------------- - def get_tags(self, in_tags, in_files, params=None): + def get_tags(self, files, tags, params=None): """Return only specified tags for the given files. - The first argument is an iterable of tags. The tag names may + 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 :. - If in_tags is None, or [], then returns all tags + If tags is None, or [], then returns all tags - The second argument is an iterable of file names. or a single file name The format of the return value is the same as for :py:meth:`execute_json()`. """ - tags = None - files = None + final_tags = None + final_files = None - if in_tags is None: + if tags is None: # all tags - tags = [] - elif isinstance(in_tags, basestring): - tags = [in_tags] - elif _is_iterable(in_tags): - tags = in_tags + final_tags = [] + elif isinstance(tags, basestring): + final_tags = [tags] + elif _is_iterable(tags): + final_tags = tags else: - raise TypeError("The argument 'in_tags' must be a str/bytes or a list") + raise TypeError("The argument 'tags' must be a str/bytes or a list") + - # TODO take Path-like objects in a list and single line convert with str() - if isinstance(in_files, basestring): - files = [in_files] - elif isinstance(in_files, PurePath): - # support for Path-like objects - files = [str(in_files)] - elif _is_iterable(in_files): - files = in_files + if not files: + # Exiftool process would return None anyways + raise TypeError("The argument 'files' cannot be empty") + elif isinstance(files, basestring): + final_files = [files] + elif not _is_iterable(files): + final_files = [str(files)] else: - raise TypeError("The argument 'in_files' must be a str/bytes or a list") + # I'm sure there's a more pythonic way to do this, with a single line and expansion and stuff... but I can't figure it out . . . + + final_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? + + # duck-type whatever given that's an iterable + for x in files: + if isinstance(x, basestring): + final_files.append(x) + else: + # to support PurePath objects, for example, the output of Path.glob(), + # need to convert the whole list of anything not basestring to str() + # + # make it generic so that we can support anything that allows you to str() the object to something useful + final_files.append(str(x)) exec_params = [] @@ -178,9 +207,9 @@ def get_tags(self, in_tags, in_files, params=None): exec_params.extend(params) # tags is always a list by this point. It will always be iterable... don't have to check for None - exec_params.extend(["-" + t for t in tags]) + exec_params.extend([f"-{t}" for t in final_tags]) - exec_params.extend(files) + exec_params.extend(final_files) ret = self.execute_json(*exec_params) From ead8c4693e9a889248c8224df68498d959eb30cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Philip=20G=C3=B6pfert?= Date: Sun, 3 Oct 2021 21:31:05 +0200 Subject: [PATCH 142/251] =?UTF-8?q?Represent=20all=20=E2=80=9Cfiles?= =?UTF-8?q?=E2=80=9D=20as=20strings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I believe this list comprehension is what the original author was looking for. --- exiftool/helper.py | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/exiftool/helper.py b/exiftool/helper.py index 6341381..157ed9d 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -172,8 +172,6 @@ def get_tags(self, files, tags, params=None): else: raise TypeError("The argument 'tags' must be a str/bytes or a list") - - if not files: # Exiftool process would return None anyways raise TypeError("The argument 'files' cannot be empty") @@ -182,23 +180,7 @@ def get_tags(self, files, tags, params=None): elif not _is_iterable(files): final_files = [str(files)] else: - # I'm sure there's a more pythonic way to do this, with a single line and expansion and stuff... but I can't figure it out . . . - - final_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? - - # duck-type whatever given that's an iterable - for x in files: - if isinstance(x, basestring): - final_files.append(x) - else: - # to support PurePath objects, for example, the output of Path.glob(), - # need to convert the whole list of anything not basestring to str() - # - # make it generic so that we can support anything that allows you to str() the object to something useful - final_files.append(str(x)) - + final_files = [x if isinstance(x, basestring) else str(x) for x in files] exec_params = [] From 137c0e2b957dc499b3df41d7eee1dc5355957978 Mon Sep 17 00:00:00 2001 From: SylikC Date: Sun, 3 Oct 2021 15:08:41 -0700 Subject: [PATCH 143/251] ExifTool() - added `encoding` constructor parameter (requires Python 3.6+), and R/W property Modified all functionality to remove the use of bytes to communicate with the process, but instead use the encoding specified on Popen() This appears to address the issue noted in here https://github.com/sylikc/pyexiftool/issues/29 --- exiftool/exiftool.py | 78 +++++++++++++++++++++++++++++++------------- 1 file changed, 56 insertions(+), 22 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 93dfd9a..317b4e8 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -63,6 +63,7 @@ import shutil from pathlib import Path # requires Python 3.4+ import random +import locale # for the pdeathsig import signal @@ -211,6 +212,7 @@ def __init__(self, common_args: Optional[List[str]] = ["-G", "-n"], win_shell: bool = True, config_file: Optional[str] = None, + encoding = 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 @@ -237,6 +239,7 @@ def __init__(self, self._common_args: Optional[List[str]] = None self._no_output = None # TODO examine whether this is needed self._logger = None + self._encoding = None @@ -254,6 +257,7 @@ def __init__(self, # 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 @@ -291,7 +295,6 @@ def __del__(self) -> None: ######################################################################################## # ---------------------------------------------------------------------------------------------------------------------- - @property def executable(self): return self._executable @@ -326,6 +329,30 @@ def executable(self, new_executable) -> None: if self._logger: self._logger.info(f"Property 'executable': set to \"{abs_path}\"") + # ---------------------------------------------------------------------------------------------------------------------- + @property + def encoding(self): + return self._encoding + + @encoding.setter + def encoding(self, new_encoding) -> 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 executable when process is running + if self.running: + raise RuntimeError("Cannot set new executable while Exiftool is running") + + # auto-detect system specific + self._encoding = new_encoding or (locale.getpreferredencoding(do_setlocale=False) or ENCODING_UTF8) + + # ---------------------------------------------------------------------------------------------------------------------- @property def block_size(self) -> int: @@ -601,6 +628,7 @@ def run(self) -> None: stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + encoding=self._encoding, **kwargs) except FileNotFoundError as fnfe: raise @@ -650,6 +678,7 @@ def terminate(self, timeout: int = 30, _del: bool = False) -> None: 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: """ @@ -660,7 +689,7 @@ def terminate(self, timeout: int = 30, _del: bool = False) -> None: On Linux, this runs as is, and the process terminates properly """ - self._process.communicate(input=b"-stay_open\nFalse\n", timeout=timeout) # TODO these are constants which should be elsewhere defined + 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() @@ -693,7 +722,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. @@ -711,17 +740,17 @@ def execute(self, *params): 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".encode(ENCODING_UTF8) # the default string is b"-execute\n" - seq_ready = f"{{ready{signal_num}}}".encode(ENCODING_UTF8) # the default string is b"{ready}" + 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}".encode(ENCODING_UTF8) # default there isn't any string + seq_err_post = f"post{signal_num}" # default there isn't any string - SEQ_ERR_STATUS_DELIM = b"=" # this can be configured to be one or more chacters... the code below will accomodate for longer sequences: len() >= 1 - seq_err_status = b"${status}" # a special sequence, ${status} returns EXIT STATUS as per exiftool documentation + 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 - cmd_text = b"\n".join(params + (b"-echo4", SEQ_ERR_STATUS_DELIM + seq_err_status + SEQ_ERR_STATUS_DELIM + seq_err_post, seq_execute)) + cmd_text = "\n".join(params + ("-echo4", SEQ_ERR_STATUS_DELIM + seq_err_status + SEQ_ERR_STATUS_DELIM + seq_err_post, seq_execute)) # 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 @@ -731,24 +760,24 @@ def execute(self, *params): self._process.stdin.write(cmd_text) self._process.stdin.flush() - if self._logger: self._logger.info("Method 'execute': Command sent = {}".format(cmd_text.split(b'\n')[:-1])) + 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() - output = _read_fd_endswith(fdout, seq_ready, self._block_size) + 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() - outerr = _read_fd_endswith(fderr, seq_err_post, self._block_size) + 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 = output.strip()[:-len(seq_ready)] - cmd_stderr = outerr.strip()[:-len(seq_err_post)] # save it in case the RuntimeError happens and output can be checked easily + cmd_stdout = raw_stdout.strip()[:-len(seq_ready)] + cmd_stderr = raw_stderr.strip()[:-len(seq_err_post)] # save it in case the RuntimeError happens and output can be checked easily # sanity check the status code from the stderr output delim_len = len(SEQ_ERR_STATUS_DELIM) @@ -774,8 +803,8 @@ def execute(self, *params): 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 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}") @@ -808,13 +837,17 @@ 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(os.fsencode, params) + + + """ + params = map(os.fsencode, params) # don't fsencode all params, leave them alone for exiftool process to manage # 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_stdout = self.execute(b"-j", *params) + res_stdout = self.execute("-j", *params) # TODO these aren't used, if not important, comment them out res_err = self._last_stderr res_status = self._last_status @@ -834,12 +867,15 @@ def execute_json(self, *params): return None + res_decoded = res_stdout + """ # TODO use fsdecode? # os.fsdecode() instead of res_stdout.decode() try: - res_decoded = res_stdout.decode(ENCODING_UTF8) + res_decoded = res_stdout except UnicodeDecodeError: res_decoded = res_stdout.decode(ENCODING_LATIN1) + """ # TODO res_decoded can be invalid json (test this) if `-w` flag is specified in common_args # which will return something like # image files read @@ -887,8 +923,6 @@ def _parse_ver(self): # -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 - ret = self.execute(b"-ver") - - return ret.decode(ENCODING_UTF8).strip() + return self.execute("-ver").strip() # ---------------------------------------------------------------------------------------------------------------------- From 78ec074a68858a2148503e1b5e748993325bb5ef Mon Sep 17 00:00:00 2001 From: SylikC Date: Sun, 3 Oct 2021 16:34:55 -0700 Subject: [PATCH 144/251] Tests tests tests All tests fixed to pass with the new encoding parameter. I suspect because the encoding of the py files are UTF-8 the tests in this case need to use UTF-8 to get the same data back and forth Deleted the "tests/tmp" dir and now it uses a tempfile that just gets wiped when it's done. --- .gitignore | 4 +- exiftool/exiftool.py | 2 +- exiftool/experimental.py | 42 +++---- exiftool/helper.py | 6 + tests/test_alpha.py | 235 ++++++++++++++++++++++++++++++++++++++ tests/test_helper.py | 186 +----------------------------- tests/tmp/PLACEHOLDER.txt | 1 - 7 files changed, 268 insertions(+), 208 deletions(-) create mode 100644 tests/test_alpha.py delete mode 100644 tests/tmp/PLACEHOLDER.txt diff --git a/.gitignore b/.gitignore index ab8ab99..7ac4ae7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,5 @@ MANIFEST # pytest-cov db .coverage -# tests will be made to write to a tmp directory for modifications instead of putting into the tests directory -tests/tmp/ +# tests will be made to write to temp directories with this prefix +tests/exiftool-tmp-* diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 317b4e8..2760c36 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -98,7 +98,7 @@ # constants to make typos obsolete! ENCODING_UTF8: str = "utf-8" -ENCODING_LATIN1: str = "latin-1" +#ENCODING_LATIN1: str = "latin-1" # ====================================================================================================================== diff --git a/exiftool/experimental.py b/exiftool/experimental.py index 6e30147..14922bf 100644 --- a/exiftool/experimental.py +++ b/exiftool/experimental.py @@ -41,6 +41,11 @@ from .helper import ExifToolHelper +try: # Py3k compatibility + basestring +except NameError: + basestring = (bytes, str) + # ====================================================================================================================== #def atexit_handler @@ -184,7 +189,7 @@ def get_tag_batch_wrapper(self, tag, filenames, params=None): # 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, tag, filenames): + 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 @@ -208,24 +213,23 @@ 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): + 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(tag, [filename])[0] + 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", from_filename, to_filename] - params_utf8 = [x.encode('utf-8') for x in params] - self.execute(*params_utf8) + params = ["-overwrite_original", "-TagsFromFile", str(from_filename), str(to_filename)] + self.execute(*params) # ---------------------------------------------------------------------------------------------------------------------- - def set_tags_batch(self, tags, filenames): + 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 @@ -251,7 +255,6 @@ def set_tags_batch(self, tags, filenames): "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 @@ -259,28 +262,27 @@ def set_tags_batch(self, tags, filenames): # 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)) + params.append(f"-{tag}={item}") else: - params.append(u'-%s=%s' % (tag, value)) + params.append(f"-{tag}={value}") params.extend(filenames) - params_utf8 = [x.encode('utf-8') for x in params] - return self.execute(*params_utf8) + return self.execute(*params) #TODO if execute returns data, then error? # ---------------------------------------------------------------------------------------------------------------------- - def set_tags(self, tags, filename): + 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(tags, [filename]) + return self.set_tags_batch([filename], tags) # ---------------------------------------------------------------------------------------------------------------------- - def set_keywords_batch(self, mode, keywords, filenames): + def set_keywords_batch(self, filenames, mode, keywords): """Modifies the keywords tag for the given files. The first argument is the operation mode: @@ -309,7 +311,6 @@ def set_keywords_batch(self, mode, keywords, filenames): "an iterable of strings") params = [] - params_utf8 = [] kw_operation = {KW_REPLACE: "-%s=%s", KW_ADD: "-%s+=%s", @@ -319,20 +320,19 @@ def set_keywords_batch(self, mode, keywords, filenames): params.extend(kw_params) params.extend(filenames) - logging.debug(params) + if self._logger: self._logger.debug(params) - params_utf8 = [x.encode('utf-8') for x in params] - return self.execute(*params_utf8) + return self.execute(*params) # ---------------------------------------------------------------------------------------------------------------------- - def set_keywords(self, mode, keywords, filename): + 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(mode, keywords, [filename]) + return self.set_keywords_batch([filename], mode, keywords) # ---------------------------------------------------------------------------------------------------------------------- diff --git a/exiftool/helper.py b/exiftool/helper.py index 157ed9d..1c9a71e 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -180,8 +180,14 @@ def get_tags(self, files, tags, params=None): 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: diff --git a/tests/test_alpha.py b/tests/test_alpha.py new file mode 100644 index 0000000..6cefc9d --- /dev/null +++ b/tests/test_alpha.py @@ -0,0 +1,235 @@ +# -*- coding: utf-8 -*- + +import unittest +import exiftool +import tempfile +import shutil + +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) + + +# ========================================================================================================= + +class TestTagCopying(unittest.TestCase): + 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: + 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): + # # close the directory so it gets removed + # if self.temp_obj: + # self.temp_obj.close() + + 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) + + self.exiftool.terminate() # do it explictly for Windows, or else will hang on exit (CPython interpreter exit bug) + + + +# ========================================================================================================= + + + +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} + # 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: + 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": "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 + source_files = [] + + for d in expected_data: + d["SourceFile"] = f = SCRIPT_PATH / d["SourceFile"] + self.assertTrue(f.exists()) + source_files.append(f) + + 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") + + 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, # TODO there's probably a bug in this test, 8.40 == 8.4 which isn't the intended behavior + "you should at least use ExifTool version 8.40") + 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": "rose.jpg", + "Caption-Abstract": "Ein Röschen ganz allein"}, + {"SourceFile": "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": "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_helper.py b/tests/test_helper.py index a250f61..6a86c20 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -1,62 +1,13 @@ # -*- coding: utf-8 -*- -from __future__ import unicode_literals - import unittest import exiftool -#import warnings -#import os import shutil -#import sys -#import tempfile +import tempfile from pathlib import Path -TMP_DIR = Path(__file__).resolve().parent / 'tmp' - -class TestTagCopying(unittest.TestCase): - def setUp(self): - # Prepare exiftool. - self.exiftool = exiftool.ExifToolHelper() - self.exiftool.run() - - # Prepare temporary directory for copy. - #directory = tempfile.mkdtemp(prefix='exiftool-test-') # this requires cleanup or else it remains on the system in the $TEMP or %TEMP% directories - directory = TMP_DIR - - # Find example image. - this_path = Path(__file__).resolve().parent - self.tag_source = str(this_path / 'rose.jpg') - - # Prepare path of copy. - self.tag_target = str(directory / 'rose-tagcopy.jpg') - - # Copy image. - shutil.copyfile(self.tag_source, self.tag_target) - - # 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) - - def test_tag_copying(self): - tag = 'XMP:Subject' - expected_value = 'Röschen' - - # Ensure source image has correct tag. - original_value = self.exiftool.get_tag(tag, self.tag_source) - self.assertEqual(original_value, expected_value) - - # 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) - - # Copy tags. - self.exiftool.copy_tags(self.tag_source, self.tag_target) - - value_after_copying = self.exiftool.get_tag(tag, self.tag_target) - self.assertEqual(value_after_copying, expected_value) - - self.exiftool.terminate() # do it explictly for Windows, or else will hang on exit (CPython interpreter exit bug) +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 TestExifToolHelper(unittest.TestCase): @@ -73,137 +24,6 @@ def tearDown(self): if self.process.poll() is None: self.process.terminate() - # --------------------------------------------------------------------------------------------------------- - 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 = Path(__file__).parent - source_files = [] - - for d in expected_data: - d["SourceFile"] = f = script_path / d["SourceFile"] - self.assertTrue(f.exists()) - source_files.append(str(f)) - with self.et: - actual_data = self.et.get_metadata(source_files) - tags0 = self.et.get_tags(["XMP:Subject"], source_files[0])[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, # TODO there's probably a bug in this test, 8.40 == 8.4 which isn't the intended behavior - "you should at least use ExifTool version 8.40") - 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": "rose.jpg", - "Caption-Abstract": "Ein Röschen ganz allein"}, - {"SourceFile": "skyblue.png", - "Caption-Abstract": "Blauer Himmel"}] - script_path = Path(__file__).parent - source_files = [] - - for d in expected_data: - d["SourceFile"] = f = script_path / d["SourceFile"] - self.assertTrue(f.exists()) - - f_mod = 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_tags({"Caption-Abstract": d["Caption-Abstract"]}, f_mod_str) - tag0 = self.et.get_tag("IPTC:Caption-Abstract", f_mod_str) - f_mod.unlink() - 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 = Path(__file__).parent - source_files = [] - - for d in expected_data: - d["SourceFile"] = f = script_path / d["SourceFile"] - self.assertTrue(f.exists()) - f_mod = 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) - - - # --------------------------------------------------------------------------------------------------------- - """ - # 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"]}] - script_path = Path(__file__).parent - source_files = [] - - for d in expected_data: - d["SourceFile"] = f = script_path / d["SourceFile"] - self.assertTrue(f.exists()) - f_mod = 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__': diff --git a/tests/tmp/PLACEHOLDER.txt b/tests/tmp/PLACEHOLDER.txt deleted file mode 100644 index d2b4ca5..0000000 --- a/tests/tmp/PLACEHOLDER.txt +++ /dev/null @@ -1 +0,0 @@ -empty placeholder to make sure the directory exists on checkout \ No newline at end of file From 4420cb6d6ad3978f6f749bf6b0d242b4b9cadd81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Philip=20G=C3=B6pfert?= Date: Mon, 4 Oct 2021 14:30:18 +0200 Subject: [PATCH 145/251] Parse and compare versions of exiftool --- setup.py | 4 + tests/test_alpha.py | 389 ++++++++++++++++++++------------------------ 2 files changed, 184 insertions(+), 209 deletions(-) diff --git a/setup.py b/setup.py index 0d5eba9..56733b3 100644 --- a/setup.py +++ b/setup.py @@ -77,6 +77,10 @@ where=".", exclude = ['test*',] ), + + extras_require={ + "test": ["packaging"], + }, #package_dir={'exiftool': 'exiftool'}, diff --git a/tests/test_alpha.py b/tests/test_alpha.py index 6cefc9d..c10c8dd 100644 --- a/tests/test_alpha.py +++ b/tests/test_alpha.py @@ -1,235 +1,206 @@ # -*- coding: utf-8 -*- -import unittest -import exiftool -import tempfile 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): - 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: - self.temp_obj = tempfile.TemporaryDirectory(**kwargs) - self.tmp_dir = Path(self.temp_obj.name) - - # Find example image. - self.tag_source = SCRIPT_PATH / 'rose.jpg' + """ + We duplicate an image with metadata, erase all metadata in the copy, and then copy the tags. + """ - # Prepare path of copy. - self.tag_target = self.tmp_dir / 'rose-tagcopy.jpg' + def setUp(self): + # Prepare exiftool + self.exiftool = exiftool.ExifToolAlpha(encoding="UTF-8") + self.exiftool.run() - # Copy image. - shutil.copyfile(self.tag_source, self.tag_target) + # 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: + self.temp_obj = tempfile.TemporaryDirectory(**kwargs) + self.tmp_dir = Path(self.temp_obj.name) - # Clear tags in copy. - params = ['-overwrite_original', '-all=', str(self.tag_target)] - self.exiftool.execute(*params) + # Find example image. + self.tag_source = SCRIPT_PATH / "rose.jpg" - #def tearDown(self): - # # close the directory so it gets removed - # if self.temp_obj: - # self.temp_obj.close() + # Prepare path of copy. + self.tag_target = self.tmp_dir / "rose-tagcopy.jpg" - def test_tag_copying(self): - tag = 'XMP:Subject' - expected_value = 'Röschen' + # Copy image. + shutil.copyfile(self.tag_source, self.tag_target) - # Ensure source image has correct tag. - original_value = self.exiftool.get_tag(self.tag_source, tag) - self.assertEqual(original_value, expected_value) + # Clear tags in copy. + params = ["-overwrite_original", "-all=", str(self.tag_target)] + self.exiftool.execute(*params) - # 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) + def tearDown(self): + self.exiftool.terminate() # Apparently, Windows needs this. - # Copy tags. - self.exiftool.copy_tags(self.tag_source, self.tag_target) + def test_tag_copying(self): + tag = "XMP:Subject" + expected_value = "Röschen" - value_after_copying = self.exiftool.get_tag(self.tag_target, tag) - self.assertEqual(value_after_copying, expected_value) + # Ensure source image has correct tag. + original_value = self.exiftool.get_tag(self.tag_source, tag) + self.assertEqual(original_value, expected_value) - self.exiftool.terminate() # do it explictly for Windows, or else will hang on exit (CPython interpreter exit bug) + # 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} - # 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: - 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": "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 - source_files = [] - - for d in expected_data: - d["SourceFile"] = f = SCRIPT_PATH / d["SourceFile"] - self.assertTrue(f.exists()) - source_files.append(f) - - 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") - - 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, # TODO there's probably a bug in this test, 8.40 == 8.4 which isn't the intended behavior - "you should at least use ExifTool version 8.40") - 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": "rose.jpg", - "Caption-Abstract": "Ein Röschen ganz allein"}, - {"SourceFile": "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": "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() + 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) + + +if __name__ == "__main__": + unittest.main() From 1e33d764b355d10417faced2d5ad1ae675e3bad7 Mon Sep 17 00:00:00 2001 From: SylikC Date: Mon, 31 Jan 2022 10:07:21 -0800 Subject: [PATCH 146/251] * update documentation in ExifTool() class. ** removed some obsolete comment from the execute() method * update comments in test_alpha to remind myself why I had to specify the temp_obj --- exiftool/exiftool.py | 32 +++++++++++++++++++++++--------- tests/test_alpha.py | 2 ++ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 2760c36..1d44218 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -160,17 +160,33 @@ def _read_fd_endswith(fd, b_endswith, block_size: int): class ExifTool(object): """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 @@ -193,7 +209,7 @@ 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 @@ -303,7 +319,7 @@ def executable(self): def executable(self, new_executable) -> 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: @@ -751,8 +767,6 @@ def execute(self, *params): seq_err_status = "${status}" # a special sequence, ${status} returns EXIT STATUS as per exiftool documentation cmd_text = "\n".join(params + ("-echo4", SEQ_ERR_STATUS_DELIM + seq_err_status + SEQ_ERR_STATUS_DELIM + seq_err_post, seq_execute)) - # 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 # ---------- write to the pipe connected with exiftool process ---------- diff --git a/tests/test_alpha.py b/tests/test_alpha.py index 6cefc9d..6d10595 100644 --- a/tests/test_alpha.py +++ b/tests/test_alpha.py @@ -27,6 +27,8 @@ def setUp(self): 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) From 314427cdad529a4dea54ccd9a57c1f1431536c88 Mon Sep 17 00:00:00 2001 From: SylikC Date: Mon, 31 Jan 2022 10:37:41 -0800 Subject: [PATCH 147/251] ExifTool - add comment that ${status} only supported on exiftool v12.10+ ExifTool - change the cmd_text to use f-string which some users have documented to be way faster than concatenation --- exiftool/exiftool.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 1d44218..6b7d524 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -623,6 +623,9 @@ def run(self) -> None: 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 @@ -764,9 +767,10 @@ def execute(self, *params): 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 + seq_err_status = "${status}" # a special sequence, ${status} returns EXIT STATUS as per exiftool documentation - only supported on exiftool v12.10+ - cmd_text = "\n".join(params + ("-echo4", SEQ_ERR_STATUS_DELIM + seq_err_status + SEQ_ERR_STATUS_DELIM + seq_err_post, seq_execute)) + # 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 ---------- From d94ff05547b4fb05a019b306b64320d90ae397fc Mon Sep 17 00:00:00 2001 From: sylikc Date: Mon, 14 Feb 2022 10:52:34 -0800 Subject: [PATCH 148/251] Add versioning information __version__ to the package. removed version= in the setup.py and moved it to the setup.cfg --- exiftool/__init__.py | 4 +++- setup.cfg | 3 +++ setup.py | 35 ++++++++++++++++++----------------- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/exiftool/__init__.py b/exiftool/__init__.py index 8079079..7665655 100644 --- a/exiftool/__init__.py +++ b/exiftool/__init__.py @@ -1,6 +1,8 @@ # -*- 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-alpha.0" # make all of the original exiftool stuff available in this namespace from .exiftool import ExifTool 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 56733b3..c64326b 100644 --- a/setup.py +++ b/setup.py @@ -27,52 +27,53 @@ setup( # detailed list of options: # https://packaging.python.org/guides/distributing-packages-using-setuptools/ - + # overview name="PyExifTool", - version="0.5.0-alpha.0", + # 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=">=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 :: 3", "Programming Language :: Python :: 3.6", - + "Topic :: Multimedia", "Topic :: Utilities", ], - - + + packages=find_packages( where=".", exclude = ['test*',] @@ -81,8 +82,8 @@ extras_require={ "test": ["packaging"], }, - + #package_dir={'exiftool': 'exiftool'}, - + #py_modules=["exiftool"], - it is now the exiftool module ) From 8c5a3c2236783c0f95df1ba80530e07757e27a55 Mon Sep 17 00:00:00 2001 From: sylikc Date: Mon, 14 Feb 2022 10:57:10 -0800 Subject: [PATCH 149/251] invalid syntax fix this bugfix was fixed in ecbd420acf4c18acf27d52d9c0b2728c829d7431 but didn't propogate when I did the last merge from master --- exiftool/experimental.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exiftool/experimental.py b/exiftool/experimental.py index 14922bf..d922b0c 100644 --- a/exiftool/experimental.py +++ b/exiftool/experimental.py @@ -137,7 +137,7 @@ def execute_json_wrapper(self, filenames, params=None, retry_on_error=True): if result: try: ExifToolAlpha._check_sanity_of_result(filenames, result) - except (IOError, error): + except IOError as error: # Restart the exiftool child process in these cases since something is going wrong self.terminate() self.run() From 0bdbc81d496177b6c63490db0f9da7ae5edff229 Mon Sep 17 00:00:00 2001 From: SylikC Date: Mon, 14 Feb 2022 16:41:35 -0800 Subject: [PATCH 150/251] * was in the process of adding a version check, but realized it may not be necessary as the current required 12.15 will fail on GETTING the version already, no need to check it again at this time. * removed version_tuple() property as it was supposed to be for a future version check. It's rolled into a private method which also got commented out as it's not needed at this time This removes some brittle code (the version check is hacky), which can be added back later if a "version check" functionality is needed --- exiftool/constants.py | 5 +++ exiftool/exiftool.py | 88 ++++++++++++++++++++++++++++++++----------- 2 files changed, 70 insertions(+), 23 deletions(-) diff --git a/exiftool/constants.py b/exiftool/constants.py index 2edcf06..bd8d64f 100644 --- a/exiftool/constants.py +++ b/exiftool/constants.py @@ -73,3 +73,8 @@ # 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/exiftool.py b/exiftool/exiftool.py index 6b7d524..7e1e240 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -478,28 +478,6 @@ def version(self) -> str: return self._ver - - # ---------------------------------------------------------------------------------------------------------------------- - @property - def version_tuple(self) -> tuple: - """ returns a parsed (major, minor) with integers """ - if not self.running: - raise RuntimeError("Can't get ExifTool version when it's not running!") - - # TODO 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) - - res: List = [] - try: - for v in arr: - res.append(int(v)) - except ValueError: - raise ValueError(f"Error parsing ExifTool version: '{self._ver}'") - - return tuple(res) - - - # ---------------------------------------------------------------------------------------------------------------------- @property def last_stdout(self) -> Optional[str]: @@ -597,6 +575,8 @@ def run(self) -> None: 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: @@ -671,11 +651,28 @@ def run(self) -> None: # get ExifTool version here and any Exiftool metadata # this can also verify that it is really ExifTool we ran, not some other random process - self._ver = self._parse_ver() + 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 RuntimeError(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}'") + # currently not needed... if it passes -ver, the rest is OK + """ + # 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 RuntimeError(f"exiftool version '{self._ver}' < required '{constants.EXIFTOOL_MINIMUM_VERSION}'") + """ + # ---------------------------------------------------------------------------------------------------------------------- def terminate(self, timeout: int = 30, _del: bool = False) -> None: @@ -944,3 +941,48 @@ def _parse_ver(self): return self.execute("-ver").strip() # ---------------------------------------------------------------------------------------------------------------------- + """ + def _exiftool_version_check(self) -> bool: + "" " private method to check the minimum required version of ExifTool + + returns false if the version check fails + returns true if it's OK + + "" " + + # 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 + """ + + # ---------------------------------------------------------------------------------------------------------------------- From 5a5cf0b240193056e8f67520cfbc50afa5e25bc2 Mon Sep 17 00:00:00 2001 From: SylikC Date: Mon, 14 Feb 2022 16:55:42 -0800 Subject: [PATCH 151/251] update README.rst to prepare for initial Python3 release. Removed outdated information and talked about the refactor in an easy-to-understand way. --- README.rst | 88 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 65 insertions(+), 23 deletions(-) diff --git a/README.rst b/README.rst index 1600d7a..55497f7 100644 --- a/README.rst +++ b/README.rst @@ -11,7 +11,7 @@ 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/ Getting PyExifTool ------------------ @@ -22,41 +22,80 @@ 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_. -Official releases are on PyPI +Official releases are on the `PyExifTool PyPI`_ +.. _tarball: https://github.com/sylikc/pyexiftool/tarball/master +.. _PyExifTool PyPI: https://pypi.org/project/PyExifTool/ + +Installation +------------ + +PyExifTool runs on Python 3.6 and above. (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. + +Run :: - https://pypi.org/project/PyExifTool/ + python setup.py install [--user|--prefix= Date: Mon, 14 Feb 2022 16:57:34 -0800 Subject: [PATCH 152/251] test_helper.py * removed the expected fail on initialize * switched the parameters to match the new Helper() convention, * added a TypeError test for get_tags() --- tests/test_helper.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/test_helper.py b/tests/test_helper.py index cab0370..6e4cde7 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -12,7 +12,6 @@ class InitializationTest(unittest.TestCase): - @unittest.expectedFailure def test_initialization(self): """ Initialization with all arguments at their default values. @@ -49,7 +48,7 @@ def test_read_tag_from_nonexistent_file(self): Confronted with a nonexistent file, `get_tag` should probably return None (as the tag is not found) or raise an appropriate exception. """ - result = self.exif_tool_helper.get_tag('DateTimeOriginal', 'foo.bar') + result = self.exif_tool_helper.get_tags('foo.bar', 'DateTimeOriginal') self.assertIsNone(result) @@ -67,6 +66,26 @@ def tearDown(self): if self.process.poll() is None: self.process.terminate() + # --------------------------------------------------------------------------------------------------------- + + def test_terminate(self): + self.et.terminate() + # no warnings good + + # --------------------------------------------------------------------------------------------------------- + def test_get_tags(self): + + with self.assertRaises(TypeError): + # files can't be None + self.et.get_tags(None, None) + self.et.get_tags([], None) + + + + + # --------------------------------------------------------------------------------------------------------- + + # --------------------------------------------------------------------------------------------------------- if __name__ == '__main__': From b30f01ae999c8020c4c996bcb2793632b5547178 Mon Sep 17 00:00:00 2001 From: SylikC Date: Mon, 14 Feb 2022 16:59:06 -0800 Subject: [PATCH 153/251] Adding a GitHub workflow to make sure the minimum required version of exiftool is used rather than the default prints out the version after install so we are sure what's going on --- .github/workflows/lint-and-test.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 97d2698..d5902c0 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,24 @@ 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 - name: Install pyexiftool run: | python -m pip install . From 3889b77abd4ef5463a17befb37cbb8af0f058ea0 Mon Sep 17 00:00:00 2001 From: SylikC Date: Mon, 14 Feb 2022 17:12:57 -0800 Subject: [PATCH 154/251] GitHub action - try again to get the PATH variable to the test steps --- .github/workflows/lint-and-test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index d5902c0..1a8a9a6 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -40,6 +40,10 @@ jobs: 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 . From 165dac8af52bc1966c47180089b29cde000ba533 Mon Sep 17 00:00:00 2001 From: Kevin M Date: Mon, 14 Feb 2022 17:37:21 -0800 Subject: [PATCH 155/251] v0.4.13 - added a comment in CHANGELOG.md for the changes. This was a bugfix release and should be the last Python2 release. This will be published on PyPI --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e275e2c..81ee9bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Date (Timezone) | Version | Comment 08/22/2021 08:32:30 PM (PDT) | 0.4.10 | logger changed to use logging.getLogger(__name__) instead of the root logger -- Merged pull request #24 from @nyoungstudios 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 On version changes, update setup.py to reflect version From 10a457850212b08974297f25ef84a2cdaec9c831 Mon Sep 17 00:00:00 2001 From: SylikC Date: Mon, 14 Feb 2022 17:51:07 -0800 Subject: [PATCH 156/251] v0.4.13 - forgot to bump the version on setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 92bf331..d24c53d 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ # overview name="PyExifTool", - version="0.4.12", + version="0.4.13", license="GPLv3+/BSD", url="http://github.com/sylikc/pyexiftool", python_requires=">=2.6", From 608c1f344f631329e33bbcf2cc1a6c156bcafce0 Mon Sep 17 00:00:00 2001 From: SylikC Date: Tue, 15 Feb 2022 05:59:26 -0800 Subject: [PATCH 157/251] Update year on LICENSE --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 4c283ca..e114eb0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ PyExifTool -Copyright 2012-2014 Sven Marnach, 2019-2021 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 From 1c57f25f64b3d85297aeeea347176ea6ddfa64e6 Mon Sep 17 00:00:00 2001 From: SylikC Date: Tue, 15 Feb 2022 06:00:13 -0800 Subject: [PATCH 158/251] Add a "Brief History" section to README for some insight to how all of this came to be. Turned it into UTF-8 --- README.rst | 44 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 55497f7..ddf88de 100644 --- a/README.rst +++ b/README.rst @@ -58,7 +58,7 @@ Windows/Mac users can download the latest version of exiftool: 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`_ +will have to `build from source`_. * Ubuntu :: @@ -81,16 +81,16 @@ 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. +* ``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 +* ``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 +* ``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 @@ -115,6 +115,42 @@ The documentation is available at http://sylikc.github.io/pyexiftool/ +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 v0.5.x+ version is the result of all of that design and coding. + +.. _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 + Licence ------- From 429739936ee699d170dc0d114bbc9d1eae8a4da2 Mon Sep 17 00:00:00 2001 From: SylikC Date: Tue, 15 Feb 2022 06:01:27 -0800 Subject: [PATCH 159/251] ExifTool - changed the win_shell default (I actually am not sure this feature works right now) updated some of the comments and sample code to match what currently would be --- exiftool/exiftool.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 7e1e240..44f91ef 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -29,28 +29,28 @@ 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"])) @@ -226,7 +226,7 @@ class ExifTool(object): def __init__(self, executable: Optional[str] = None, common_args: Optional[List[str]] = ["-G", "-n"], - win_shell: bool = True, + win_shell: bool = False, config_file: Optional[str] = None, encoding = None, logger = None) -> None: From 6bc95c0590cf6a3757997b0008392e60a2a698c2 Mon Sep 17 00:00:00 2001 From: SylikC Date: Tue, 15 Feb 2022 13:54:50 -0800 Subject: [PATCH 160/251] updated COMPATABILITY.txt with some of the changed features. It's not pretty at the moment, but it should have the core changes which v0.5.0 provides --- COMPATIBILITY.txt | 49 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/COMPATIBILITY.txt b/COMPATIBILITY.txt index 2839352..6c53f3e 100644 --- a/COMPATIBILITY.txt +++ b/COMPATIBILITY.txt @@ -1,15 +1,50 @@ 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 +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.5 = code with all PRs, a superset of functionality on Exiftool class -v0.5.0 - = "should be" API compatible with v0.2.0, but moves functionality added in the versions before to other classes +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.2.0 and v0.5.0 - Exiftool constructor, "executable_" parameter renamed to "executable" +---- +API changes between v0.4.x and v0.5.0: + + Old: Python 2.6 supported. New: Python 3.6+ required + + Exiftool constructor: + "executable_" parameter renamed to "executable" + 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. + Old: win_shell defaults to True. New: win_shell defaults to False. + New encoding parameter + New logger parameter + + a lot of properties were added to do get/set validation, and parameters can be changed outside of the constructor. + + starting the process was renamed from "start" to "run" + + exiftool command line utility minimum requirements. Old: 8.60. New: 12.15 + + execute() and execute_json() no longer take bytes, but is guided by the encoding set in constructor/property + + execute_json() when no json was not returned (such as a set metadata operation) => Old: raised an error. New: returns None + + 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 +---- + From a65abbe3a3c84325639adbcc8e90a8fbe78f5875 Mon Sep 17 00:00:00 2001 From: SylikC Date: Tue, 15 Feb 2022 13:55:45 -0800 Subject: [PATCH 161/251] Update README.rst trying to format better, added in the special thanks, and reordered some sections/split into subsections --- README.rst | 80 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 51 insertions(+), 29 deletions(-) diff --git a/README.rst b/README.rst index ddf88de..b72ffff 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 @@ -14,7 +15,7 @@ process for every single query. .. _ExifTool: https://exiftool.org/ Getting PyExifTool ------------------- +================== The source code can be checked out from the github repository with @@ -29,20 +30,22 @@ Official releases are on the `PyExifTool PyPI`_ .. _tarball: https://github.com/sylikc/pyexiftool/tarball/master .. _PyExifTool PyPI: https://pypi.org/project/PyExifTool/ -Installation ------------- -PyExifTool runs on Python 3.6 and above. (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. +Installation +============ -Run -:: +Prerequisites +------------- - python setup.py install [--user|--prefix= Date: Tue, 15 Feb 2022 14:27:50 -0800 Subject: [PATCH 162/251] more refinements to README.rst --- README.rst | 99 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 57 insertions(+), 42 deletions(-) diff --git a/README.rst b/README.rst index b72ffff..b6b1485 100644 --- a/README.rst +++ b/README.rst @@ -14,9 +14,25 @@ process for every single query. .. _ExifTool: https://exiftool.org/ + 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 :: @@ -25,33 +41,47 @@ The source code can be checked out from the github repository with Alternatively, you can download a tarball_. -Official releases are on the `PyExifTool PyPI`_ - .. _tarball: https://github.com/sylikc/pyexiftool/tarball/master -.. _PyExifTool PyPI: https://pypi.org/project/PyExifTool/ +Run + +:: -Installation -============ + python setup.py install [--user|--prefix=)``. + +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). + +To check your ``exiftool`` version: + +:: + + exiftool -ver + -For PyExifTool to function, you need an installation of the ``exiftool`` -command-line tool. The code 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). +Windows/Mac +^^^^^^^^^^^ Windows/Mac users can download the latest version of exiftool: @@ -59,6 +89,9 @@ Windows/Mac users can download the latest version of exiftool: 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`_. @@ -76,19 +109,22 @@ will have to `build from source`_. .. _build from source: https://exiftool.org/install.html#Unix -Install PyExifTool -^^^^^^^^^^^^^^^^^^ +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 -Run :: - python setup.py install [--user|--prefix= Date: Tue, 15 Feb 2022 15:37:09 -0800 Subject: [PATCH 163/251] ExifToolHelper - changed files invalid files parameter to a ValueError while testing, realized that an invalid params would cause weird problems, so added error checking to params in Helper test_helper.py - added test and fixed failing tests --- exiftool/helper.py | 16 ++++++--- tests/test_helper.py | 83 ++++++++++++++++++++++++++++++-------------- 2 files changed, 68 insertions(+), 31 deletions(-) diff --git a/exiftool/helper.py b/exiftool/helper.py index 1c9a71e..9940130 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -170,11 +170,11 @@ def get_tags(self, files, tags, params=None): elif _is_iterable(tags): final_tags = tags else: - raise TypeError("The argument 'tags' must be a str/bytes or a list") + raise TypeError("ExifToolHelper.get_tags(): argument 'tags' must be a str/bytes or a list") if not files: # Exiftool process would return None anyways - raise TypeError("The argument 'files' cannot be empty") + raise ValueError("ExifToolHelper.get_tags(): argument 'files' cannot be empty") elif isinstance(files, basestring): final_files = [files] elif not _is_iterable(files): @@ -191,8 +191,14 @@ def get_tags(self, files, tags, params=None): exec_params = [] if params: - # this is done to avoid accidentally modifying the reference object params - exec_params.extend(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("ExifToolHelper.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]) @@ -202,7 +208,7 @@ def get_tags(self, files, tags, params=None): ret = self.execute_json(*exec_params) if ret is None: - raise RuntimeError("get_tags: exiftool returned no data") + raise RuntimeError("ExifToolHelper.get_tags(): exiftool returned no data") # TODO if last_status is <> 0, raise a warning that one or more files failed? diff --git a/tests/test_helper.py b/tests/test_helper.py index 6e4cde7..379460f 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -9,6 +9,9 @@ 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): @@ -26,30 +29,18 @@ def setUpClass(cls) -> None: cls.exif_tool_helper = exiftool.ExifToolHelper(common_args=['-G', '-n', '-overwrite_original']) cls.exif_tool_helper.run() - @unittest.expectedFailure - def test_read_all_from_no_file(self): - """ - Supposedly, `get_metadata` always returns a list. - """ - metadata = self.exif_tool_helper.get_metadata([]) - self.assertEqual(metadata, []) - - @unittest.expectedFailure def test_read_all_from_nonexistent_file(self): """ - Supposedly, `get_metadata` always returns a list. - """ - metadata = self.exif_tool_helper.get_metadata(['foo.bar']) - self.assertEqual(metadata, []) + `get_metadata`/`get_tags` raises an error if None comes back from execute_json() - @unittest.expectedFailure - def test_read_tag_from_nonexistent_file(self): - """ - Confronted with a nonexistent file, `get_tag` should probably return None (as the tag is not found) or raise an - appropriate exception. + ExifToolHelper DOES NOT check each individual file in the list for existence. If you pass invalid files to exiftool, undefined behavior can occur """ - result = self.exif_tool_helper.get_tags('foo.bar', 'DateTimeOriginal') - self.assertIsNone(result) + with self.assertRaises(RuntimeError): + self.exif_tool_helper.get_metadata(['foo.bar']) + + with self.assertRaises(RuntimeError): + self.exif_tool_helper.get_tags('foo.bar', 'DateTimeOriginal') + class TestExifToolHelper(unittest.TestCase): @@ -60,11 +51,7 @@ def setUp(self): 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() + self.et.terminate() # --------------------------------------------------------------------------------------------------------- @@ -74,13 +61,57 @@ def test_terminate(self): # --------------------------------------------------------------------------------------------------------- def test_get_tags(self): + """ + `get_metadata`/`get_tags` should return an error when no files specified. + """ - with self.assertRaises(TypeError): + 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(RuntimeError): + 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()) # --------------------------------------------------------------------------------------------------------- From dc27b770ebb51550d87135c16af083f376d05820 Mon Sep 17 00:00:00 2001 From: SylikC Date: Tue, 15 Feb 2022 15:40:36 -0800 Subject: [PATCH 164/251] test_exiftool.py - added a few tests to increase code coverage --- tests/test_exiftool.py | 78 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 71 insertions(+), 7 deletions(-) diff --git a/tests/test_exiftool.py b/tests/test_exiftool.py index a5aa43e..533d280 100644 --- a/tests/test_exiftool.py +++ b/tests/test_exiftool.py @@ -42,12 +42,24 @@ def test_executable_attribute(self): self.assertFalse(self.et.running) self.et.run() self.assertTrue(self.et.running) + + # if it's running, the executable has to exist (test coverage on reading the property) + e = self.et.executable + self.assertTrue(Path(e).exists()) + with self.assertRaises(RuntimeError): self.et.executable = "x" self.et.terminate() + with self.assertRaises(FileNotFoundError): self.et.executable = "lkajsdfoleiawjfasv" + + # 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 + self.assertFalse(self.et.running) + # --------------------------------------------------------------------------------------------------------- def test_blocksize_attribute(self): current = self.et.block_size @@ -59,9 +71,53 @@ def test_blocksize_attribute(self): with self.assertRaises(ValueError): self.et.block_size = -1 + with self.assertRaises(ValueError): + self.et.block_size = 0 + # restore self.et.block_size = current + # --------------------------------------------------------------------------------------------------------- + def test_encoding_attribute(self): + current = self.et.encoding + + self.et.run() + + # cannot set when running + with self.assertRaises(RuntimeError): + self.et.encoding = "x" + self.et.terminate() + + self.et.encoding = "x" + self.assertEqual(self.et.encoding, "x") + + # restore + self.et.encoding = current + + + + # --------------------------------------------------------------------------------------------------------- + def test_common_args_attribute(self): + + self.et.run() + with self.assertRaises(RuntimeError): + self.et.common_args = [] + + + # --------------------------------------------------------------------------------------------------------- + def test_version_attirubte(self): + self.et.run() + # no error + a = self.et.version + + self.et.terminate() + + # version is invalid when not running + with self.assertRaises(RuntimeError): + a = self.et.version + + + # --------------------------------------------------------------------------------------------------------- def test_configfile_attribute(self): @@ -179,19 +235,27 @@ def test_common_args(self): # 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 + #log.level = logging.WARNING - logpath = TMP_DIR / 'exiftool_test.log' - fh = logging.FileHandler(logpath) + #logpath = TMP_DIR / 'exiftool_test.log' + #fh = logging.FileHandler(logpath) - log.addHandler(fh) + #log.addHandler(fh) - self.et.run() + 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 + + # --------------------------------------------------------------------------------------------------------- - """ # --------------------------------------------------------------------------------------------------------- From 3bdf34610c9bffb33e708e4ea4f3573502f344a2 Mon Sep 17 00:00:00 2001 From: SylikC Date: Sun, 27 Feb 2022 15:41:55 -0800 Subject: [PATCH 165/251] update .gitignore as per https://github.com/sylikc/pyexiftool/pull/39 the output of tests changed and ignore some IDE stuff --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 7ac4ae7..f9f3293 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,8 @@ MANIFEST .coverage # tests will be made to write to temp directories with this prefix -tests/exiftool-tmp-* +#tests/exiftool-tmp-* +tests/tmp/ + +# IntelliJ +.idea From 4ff74af2450e42f926a75e1416ee122a703f05c2 Mon Sep 17 00:00:00 2001 From: SylikC Date: Sun, 27 Feb 2022 16:00:13 -0800 Subject: [PATCH 166/251] ExifTool * remove -w no output detection. If a user uses it, they shouldn't be calling execute_json() * remove some dead code and change code flow of execute_json() as per https://github.com/sylikc/pyexiftool/pull/39 --- COMPATIBILITY.txt | 4 ++++ exiftool/exiftool.py | 46 +++++++++----------------------------------- 2 files changed, 13 insertions(+), 37 deletions(-) diff --git a/COMPATIBILITY.txt b/COMPATIBILITY.txt index 6c53f3e..03ed3cc 100644 --- a/COMPATIBILITY.txt +++ b/COMPATIBILITY.txt @@ -33,6 +33,10 @@ API changes between v0.4.x and v0.5.0: execute_json() when no json was not returned (such as a set metadata operation) => Old: raised an error. New: returns None + 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: diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 44f91ef..42d6f83 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -253,7 +253,6 @@ def __init__(self, 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._no_output = None # TODO examine whether this is needed self._logger = None self._encoding = None @@ -416,9 +415,6 @@ def common_args(self, new_args: Optional[List[str]]) -> None: else: raise TypeError("common_args not a list of strings") - # TODO examine if this is still a needed thing - self._no_output = '-w' in self._common_args - if self._logger: self._logger.info(f"Property 'common_args': set to \"{self._common_args}\"") @@ -853,22 +849,12 @@ def execute_json(self, *params): as Unicode strings in Python 3.x. """ + result = self.execute("-j", *params) - """ - params = map(os.fsencode, params) # don't fsencode all params, leave them alone for exiftool process to manage - # 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_stdout = self.execute("-j", *params) - # TODO these aren't used, if not important, comment them out - res_err = self._last_stderr - res_status = self._last_status + # TODO check exitcode - if len(res_stdout) == 0: + 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 @@ -877,35 +863,21 @@ def execute_json(self, *params): # # 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) - # Returning [] could be ambugious if Exiftool changes the returned JSON structure in the future + # Returning [] could be ambiguous if Exiftool changes the returned JSON structure in the future # Returning None is the safest as it clearly indicates that nothing came back from execute() return None - res_decoded = res_stdout - """ - # TODO use fsdecode? - # os.fsdecode() instead of res_stdout.decode() - try: - res_decoded = res_stdout - except UnicodeDecodeError: - res_decoded = res_stdout.decode(ENCODING_LATIN1) - """ - # TODO res_decoded can be invalid json (test this) if `-w` flag is specified in common_args + parsed = json.loads(result) + # if `-w` flag is specified in common_args, nothing will return + # # which will return something like # image files read # output files created - # res_decoded is also not valid if you do metadata manipulation without returning anything - if self._no_output: - print(res_decoded) - # TODO: test why is this not returning anything from this function?? what if we are SETTING something and not GETTING? - else: - # TODO: if len(res_decoded) == 0, then there's obviously an error here - #print(res_decoded) - return json.loads(res_decoded) + # result is also not valid (empty) if you do metadata manipulation (setting tags) - # TODO , return_tuple will also beautify stderr and output status as well + return parsed ######################################################################################### From 74602964183d5e91b972640d30019e2e65930598 Mon Sep 17 00:00:00 2001 From: SylikC Date: Sun, 27 Feb 2022 17:46:41 -0800 Subject: [PATCH 167/251] .gitignore changed again... not sure why /tmp/ was added in the PR --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index f9f3293..5429489 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,7 @@ MANIFEST .coverage # tests will be made to write to temp directories with this prefix -#tests/exiftool-tmp-* -tests/tmp/ +tests/exiftool-tmp-* # IntelliJ .idea From fb23490d5ea6a0c957eda3498eccc5e32b085615 Mon Sep 17 00:00:00 2001 From: SylikC Date: Sun, 27 Feb 2022 17:55:56 -0800 Subject: [PATCH 168/251] ExifTool / ExifToolHelper * Add in exceptions classes and made all tests pass. Added a -w test. ExifToolHelper override run() to remove warning just like terminate() --- exiftool/__init__.py | 9 ++++-- exiftool/exceptions.py | 51 ++++++++++++++++++++++++++++++++++ exiftool/exiftool.py | 63 +++++++++++++++++++++++++----------------- exiftool/helper.py | 29 +++++++++++++------ scripts/pytest.bat | 1 + tests/test_exiftool.py | 25 ++++++++++++----- tests/test_helper.py | 39 +++++++++++++++++++++++--- 7 files changed, 169 insertions(+), 48 deletions(-) create mode 100644 exiftool/exceptions.py diff --git a/exiftool/__init__.py b/exiftool/__init__.py index 7665655..5e3cb89 100644 --- a/exiftool/__init__.py +++ b/exiftool/__init__.py @@ -2,14 +2,19 @@ # 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-alpha.0" +__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 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/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 42d6f83..79cd1ef 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -91,6 +91,7 @@ # ---------- Library Package Imports ---------- from . import constants +from .exceptions import ExifToolVersionError, ExifToolRunning, ExifToolNotRunning, OutputEmpty, OutputNotJSON # ====================================================================================================================== @@ -322,7 +323,7 @@ def executable(self, new_executable) -> None: """ # cannot set executable when process is running if self.running: - raise RuntimeError("Cannot set new executable while Exiftool is running") + raise ExifToolRunning("Cannot set new executable") abs_path: Optional[str] = None @@ -360,9 +361,9 @@ def encoding(self, new_encoding) -> None: this does NOT validate the encoding for validity. It is passed verbatim into subprocess.Popen() """ - # cannot set executable when process is running + # cannot set encoding when process is running if self.running: - raise RuntimeError("Cannot set new executable while Exiftool is running") + raise ExifToolRunning("Cannot set new encoding") # auto-detect system specific self._encoding = new_encoding or (locale.getpreferredencoding(do_setlocale=False) or ENCODING_UTF8) @@ -404,7 +405,7 @@ def common_args(self, new_args: Optional[List[str]]) -> None: """ if self.running: - raise RuntimeError("Cannot set new common_args while exiftool is running!") + raise ExifToolRunning("Cannot set new common_args") if new_args is None: self._common_args = [] @@ -430,7 +431,7 @@ def config_file(self, new_config_file: Optional[str]) -> None: if running==True, it will throw an error. Can only set config_file when exiftool is not running """ if self.running: - raise RuntimeError("Cannot set a new config_file while exiftool is running!") + raise ExifToolRunning("Cannot set a new config_file") if new_config_file is None: self._config_file = None @@ -470,7 +471,7 @@ def version(self) -> str: """ returns a string from -ver """ if not self.running: - raise RuntimeError("Can't get ExifTool version when it's not running!") + raise ExifToolNotRunning("Can't get ExifTool version") return self._ver @@ -654,19 +655,20 @@ def run(self) -> None: except ValueError: # trap the error and return it as a minimum version problem self.terminate() - raise RuntimeError(f"Error retrieving Exiftool info. Is your Exiftool version ('exiftool -ver') >= required version ('{constants.EXIFTOOL_MINIMUM_VERSION}')?") + 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}'") - # currently not needed... if it passes -ver, the rest is OK + # 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 RuntimeError(f"exiftool version '{self._ver}' < required '{constants.EXIFTOOL_MINIMUM_VERSION}'") + raise ExifToolVersionError(f"exiftool version '{self._ver}' < required '{constants.EXIFTOOL_MINIMUM_VERSION}'") """ @@ -678,7 +680,6 @@ def terminate(self, timeout: int = 30, _del: bool = False) -> None: """ if not self.running: warnings.warn("ExifTool not running; doing nothing.", UserWarning) - # TODO, maybe add an optional parameter that says ignore_running/check/force or something which will not warn return if _del and constants.PLATFORM_WINDOWS: @@ -742,7 +743,7 @@ def execute(self, *params): rarely be needed by application developers. """ if not self.running: - raise RuntimeError("ExifTool instance not running.") + raise ExifToolNotRunning("Cannot execute()") # ---------- build the special params to execute ---------- @@ -788,13 +789,13 @@ def execute(self, *params): # 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 RuntimeError happens and output can be checked easily + 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 RuntimeError(f"Exiftool expected to return status on stderr, but got unexpected character: {cmd_stderr[-delim_len:]} != {SEQ_ERR_STATUS_DELIM}") + 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 @@ -849,9 +850,14 @@ def execute_json(self, *params): as Unicode strings in Python 3.x. """ - result = self.execute("-j", *params) + result = self.execute("-j", *params) # stdout - # TODO check exitcode + # TODO check status code or have caller do it? + """ + status_code = self._last_status # status code + if status_code != 0: + warnings.warn(f"ExifTool returned a non-zero status code: {status_code}", UserWarning) + """ if len(result) == 0: @@ -859,23 +865,28 @@ def execute_json(self, *params): # * 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 setting tags) + # * 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) # Returning [] could be ambiguous if Exiftool changes the returned JSON structure in the future - # Returning None is the safest as it clearly indicates that nothing came back from execute() - return None + # 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") - parsed = json.loads(result) - # if `-w` flag is specified in common_args, nothing will return - # - # which will return something like - # image files read - # output files created + 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 - # result is also not valid (empty) if you do metadata manipulation (setting tags) + # explicit chaining https://www.python.org/dev/peps/pep-3134/ + raise OutputNotJSON() from e return parsed @@ -904,7 +915,7 @@ def _parse_ver(self): and parse out the information """ if not self.running: - raise RuntimeError("ExifTool instance not running.") + raise ExifToolNotRunning("Cannot get version") # -ver is just the version diff --git a/exiftool/helper.py b/exiftool/helper.py index 9940130..c7ca32a 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -95,6 +95,15 @@ def execute(self, *params): 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) @@ -170,11 +179,11 @@ def get_tags(self, files, tags, params=None): elif _is_iterable(tags): final_tags = tags else: - raise TypeError("ExifToolHelper.get_tags(): argument 'tags' must be a str/bytes or a list") + 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("ExifToolHelper.get_tags(): argument 'files' cannot be empty") + 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): @@ -198,19 +207,21 @@ def get_tags(self, files, tags, params=None): # this is done to avoid accidentally modifying the reference object params exec_params.extend(params) else: - raise TypeError("ExifToolHelper.get_tags(): argument 'params' must be a str or a list") + 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) - ret = self.execute_json(*exec_params) - - if ret is None: - raise RuntimeError("ExifToolHelper.get_tags(): exiftool returned no data") - - # TODO if last_status is <> 0, raise a warning that one or more files failed? + 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/scripts/pytest.bat b/scripts/pytest.bat index 6c46d1c..9c85ff9 100644 --- a/scripts/pytest.bat +++ b/scripts/pytest.bat @@ -13,6 +13,7 @@ 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/tests/test_exiftool.py b/tests/test_exiftool.py index 533d280..210832b 100644 --- a/tests/test_exiftool.py +++ b/tests/test_exiftool.py @@ -4,6 +4,7 @@ import unittest import exiftool +from exiftool.exceptions import ExifToolRunning, ExifToolNotRunning import warnings import logging # to test logger @@ -47,7 +48,7 @@ def test_executable_attribute(self): e = self.et.executable self.assertTrue(Path(e).exists()) - with self.assertRaises(RuntimeError): + with self.assertRaises(ExifToolRunning): self.et.executable = "x" self.et.terminate() @@ -84,7 +85,7 @@ def test_encoding_attribute(self): self.et.run() # cannot set when running - with self.assertRaises(RuntimeError): + with self.assertRaises(ExifToolRunning): self.et.encoding = "x" self.et.terminate() @@ -100,12 +101,12 @@ def test_encoding_attribute(self): def test_common_args_attribute(self): self.et.run() - with self.assertRaises(RuntimeError): + with self.assertRaises(ExifToolRunning): self.et.common_args = [] # --------------------------------------------------------------------------------------------------------- - def test_version_attirubte(self): + def test_version_attribute(self): self.et.run() # no error a = self.et.version @@ -113,7 +114,7 @@ def test_version_attirubte(self): self.et.terminate() # version is invalid when not running - with self.assertRaises(RuntimeError): + with self.assertRaises(ExifToolNotRunning): a = self.et.version @@ -139,7 +140,7 @@ def test_configfile_attribute(self): self.et.run() self.assertTrue(self.et.running) - with self.assertRaises(RuntimeError): + with self.assertRaises(ExifToolRunning): self.et.config_file = None self.et.terminate() @@ -150,7 +151,7 @@ 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(RuntimeError, 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: @@ -255,6 +256,16 @@ def test_logger(self): 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)) # --------------------------------------------------------------------------------------------------------- diff --git a/tests/test_helper.py b/tests/test_helper.py index 379460f..7088e5d 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -2,6 +2,7 @@ import unittest import exiftool +from exiftool.exceptions import ExifToolNotRunning, OutputEmpty, OutputNotJSON import shutil import tempfile from pathlib import Path @@ -29,18 +30,38 @@ 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(RuntimeError): + with self.assertRaises(OutputEmpty): self.exif_tool_helper.get_metadata(['foo.bar']) - with self.assertRaises(RuntimeError): + 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.name}/%f.txt"]) + class TestExifToolHelper(unittest.TestCase): @@ -55,9 +76,19 @@ def tearDown(self): # --------------------------------------------------------------------------------------------------------- + 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() - # no warnings good # --------------------------------------------------------------------------------------------------------- def test_get_tags(self): @@ -82,7 +113,7 @@ 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(RuntimeError): + with self.assertRaises(ExifToolNotRunning): self.et.get_metadata(EXAMPLE_FILE) # test that no errors returned if auto_start=True From 0d023ce6fdec5660f60d6ef435f12b78ff4c5d33 Mon Sep 17 00:00:00 2001 From: SylikC Date: Sun, 27 Feb 2022 17:58:36 -0800 Subject: [PATCH 169/251] forgot to import xceptions into helper --- exiftool/helper.py | 1 + 1 file changed, 1 insertion(+) diff --git a/exiftool/helper.py b/exiftool/helper.py index c7ca32a..8d42354 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -26,6 +26,7 @@ import logging from .exiftool import ExifTool +from .exceptions import OutputEmpty, OutputNotJSON try: # Py3k compatibility basestring From aa6e4f36cfe30d267134a1f1169fe25a307b3392 Mon Sep 17 00:00:00 2001 From: SylikC Date: Sun, 27 Feb 2022 21:30:44 -0800 Subject: [PATCH 170/251] ExifTool * added tests for config_file and setting it to a valid one * config_file can be "" as per documentation, so changed code to make "" valid * added additional typing indicators --- exiftool/exiftool.py | 32 ++++++----- tests/test_exiftool.py | 119 +++++++++++++++++++++++++++++------------ 2 files changed, 104 insertions(+), 47 deletions(-) diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index 79cd1ef..a869541 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -81,10 +81,10 @@ -# ---------- Linting Imports ---------- +# ---------- Typing Imports ---------- # for static analysis / type checking - Python 3.5+ from collections.abc import Callable -from typing import Optional, List +from typing import Optional, List, Union @@ -117,7 +117,7 @@ def callable_method(): return callable_method else: - return None + return None # pragma: no cover # ====================================================================================================================== @@ -229,7 +229,7 @@ def __init__(self, common_args: Optional[List[str]] = ["-G", "-n"], win_shell: bool = False, config_file: Optional[str] = None, - encoding = 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 @@ -241,7 +241,7 @@ def __init__(self, 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 = None # this is set to be the exiftool -v -ver when running + 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 @@ -255,7 +255,7 @@ def __init__(self, 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 = None + self._encoding: Optional[str] = None @@ -340,18 +340,18 @@ def executable(self, new_executable) -> None: raise FileNotFoundError(f'"{new_executable}" is not found, on path or as absolute path') # absolute path is returned - self._executable = abs_path + self._executable = str(abs_path) if self._logger: self._logger.info(f"Property 'executable': set to \"{abs_path}\"") # ---------------------------------------------------------------------------------------------------------------------- @property - def encoding(self): + def encoding(self) -> Optional[str]: return self._encoding @encoding.setter - def encoding(self, new_encoding) -> None: + def encoding(self, new_encoding: Optional[str]) -> None: """ Set the encoding of Popen() communication with exiftool process. Does error checking. @@ -425,9 +425,12 @@ def config_file(self) -> Optional[str]: return self._config_file @config_file.setter - def config_file(self, new_config_file: Optional[str]) -> None: + 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 running==True, it will throw an error. Can only set config_file when exiftool is not running """ if self.running: @@ -435,10 +438,14 @@ def config_file(self, new_config_file: Optional[str]) -> None: 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 = new_config_file + self._config_file = str(new_config_file) if self._logger: self._logger.info(f"Property 'config_file': set to \"{self._config_file}\"") @@ -584,7 +591,8 @@ def run(self) -> None: 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]) # this is the required stuff for the stay_open that makes pyexiftool so great! diff --git a/tests/test_exiftool.py b/tests/test_exiftool.py index 210832b..db736d5 100644 --- a/tests/test_exiftool.py +++ b/tests/test_exiftool.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals import unittest +import tempfile import exiftool from exiftool.exceptions import ExifToolRunning, ExifToolNotRunning import warnings @@ -13,11 +14,14 @@ import sys from pathlib import Path -TMP_DIR = Path(__file__).resolve().parent / 'tmp' 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 TestExifTool(unittest.TestCase): # --------------------------------------------------------------------------------------------------------- @@ -49,11 +53,11 @@ def test_executable_attribute(self): self.assertTrue(Path(e).exists()) with self.assertRaises(ExifToolRunning): - self.et.executable = "x" + self.et.executable = "foo.bar" self.et.terminate() with self.assertRaises(FileNotFoundError): - self.et.executable = "lkajsdfoleiawjfasv" + self.et.executable = "foo.bar" # specify the executable explicitly with the one known to exist (test coverage) self.et.executable = e @@ -86,11 +90,11 @@ def test_encoding_attribute(self): # cannot set when running with self.assertRaises(ExifToolRunning): - self.et.encoding = "x" + self.et.encoding = "foo.bar" self.et.terminate() - self.et.encoding = "x" - self.assertEqual(self.et.encoding, "x") + self.et.encoding = "foo" + self.assertEqual(self.et.encoding, "foo") # restore self.et.encoding = current @@ -105,6 +109,12 @@ def test_common_args_attribute(self): 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() @@ -117,35 +127,6 @@ def test_version_attribute(self): with self.assertRaises(ExifToolNotRunning): a = self.et.version - - - # --------------------------------------------------------------------------------------------------------- - - 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:\\" - - # TODO create a config file, and set it and test that it works - - - # 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_termination_cm(self): # Test correct subprocess start and termination when using @@ -271,6 +252,74 @@ def test_run_twice(self): # --------------------------------------------------------------------------------------------------------- + + +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: + # 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() From 48da066add940224709a2a1c899bccbda4f152bb Mon Sep 17 00:00:00 2001 From: SylikC Date: Sun, 27 Feb 2022 21:31:06 -0800 Subject: [PATCH 171/251] TestExifToolHelper * fixed bug which created random temp directories int he wrong place --- tests/test_helper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_helper.py b/tests/test_helper.py index 7088e5d..4183756 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -60,7 +60,7 @@ 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.name}/%f.txt"]) + self.exif_tool_helper.get_metadata(EXAMPLE_FILE, params=["-w", f"{self.tmp_dir}/%f.txt"]) From cbca475bc709e17f2b212978c75a83c3a3180e8a Mon Sep 17 00:00:00 2001 From: SylikC Date: Mon, 28 Feb 2022 12:46:52 -0800 Subject: [PATCH 172/251] * ExifTool - add some typing and documentation updates (better docstrings) * COMPATIBILITY.txt - beautify it to make it clearer... might still have more updates after deployment * CHANGELOG.md - add in the line to deploy which I've been waiting for for a year... --- CHANGELOG.md | 11 +++++--- COMPATIBILITY.txt | 27 +++++++++--------- README.rst | 20 ++++++++++++-- exiftool/exiftool.py | 66 ++++++++++++++++++++++++++++++++++---------- setup.py | 3 +- 5 files changed, 92 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9114fb5..d54a96e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,18 +36,21 @@ Date (Timezone) | Version | Comment 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 index 03ed3cc..824596e 100644 --- a/COMPATIBILITY.txt +++ b/COMPATIBILITY.txt @@ -14,29 +14,30 @@ v0.5.0 - = not API compatible with the v0.4.x series. See comments belo ---- API changes between v0.4.x and v0.5.0: - Old: Python 2.6 supported. New: Python 3.6+ required + PYTHON CHANGE: Old: Python 2.6 supported. New: Python 3.6+ required - Exiftool constructor: - "executable_" parameter renamed to "executable" - 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. - Old: win_shell defaults to True. New: win_shell defaults to False. - New encoding parameter - New logger parameter + 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 - a lot of properties were added to do get/set validation, and parameters can be changed outside of the constructor. + NEW PROPERTY GET/SET: a lot of properties were added to do get/set validation, and parameters can be changed outside of the constructor. - starting the process was renamed from "start" to "run" + METHOD RENAME: starting the process was renamed from "start" to "run" - exiftool command line utility minimum requirements. Old: 8.60. New: 12.15 + MINIMUM TOOL VERSION: exiftool command line utility minimum requirements. Old: 8.60. New: 12.15 - execute() and execute_json() no longer take bytes, but is guided by the encoding set in constructor/property + ENCODING CHANGE: execute() and execute_json() no longer take bytes, but is guided by the encoding set in constructor/property - execute_json() when no json was not returned (such as a set metadata operation) => Old: raised an error. New: returns None + ERROR CHANGE: execute_json() when no json was not returned (such as a set metadata operation) => Old: raised an error. New: returns custom ExifToolException - execute_json() no longer detects the '-w' flag being passed used in common_args. + 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: diff --git a/README.rst b/README.rst index b6b1485..a2d152e 100644 --- a/README.rst +++ b/README.rst @@ -14,6 +14,22 @@ process for every single query. .. _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 ================== @@ -69,8 +85,8 @@ 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=)``. -PyExifTool requires a **minimum version of 12.15** (which was the first -production version of exiftool featuring the options to allow exit status +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). To check your ``exiftool`` version: diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index a869541..a5ae6d2 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -312,11 +312,11 @@ def __del__(self) -> None: # ---------------------------------------------------------------------------------------------------------------------- @property - def executable(self): + def executable(self) -> str: return self._executable @executable.setter - def executable(self, new_executable) -> None: + 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 @@ -422,6 +422,7 @@ def common_args(self, new_args: Optional[List[str]]) -> None: # ---------------------------------------------------------------------------------------------------------------------- @property def config_file(self) -> Optional[str]: + """ Return currently set config file """ return self._config_file @config_file.setter @@ -431,7 +432,7 @@ def config_file(self, new_config_file: Optional[Union[str, Path]]) -> None: 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 running==True, it will throw an error. Can only set config_file when exiftool is not running + 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") @@ -458,8 +459,15 @@ def config_file(self, new_config_file: Optional[Union[str, Path]]) -> None: # ---------------------------------------------------------------------------------------------------------------------- @property def running(self) -> bool: - # read-only property + """ + 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: @@ -475,7 +483,13 @@ def running(self) -> bool: # ---------------------------------------------------------------------------------------------------------------------- @property def version(self) -> str: - """ returns a string from -ver """ + """ + 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") @@ -485,25 +499,43 @@ def version(self) -> str: # ---------------------------------------------------------------------------------------------------------------------- @property def last_stdout(self) -> Optional[str]: - """last output stdout from execute() - currently it is INTENTIONALLY _NOT_ CLEARED on exiftool termination and not dependent on running state - This allows for executing a command and terminating, but still haven't last* around.""" + """ + 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]: - """last output stderr from execute() - currently it is INTENTIONALLY _NOT_ CLEARED on exiftool termination and not dependent on running state - This allows for executing a command and terminating, but still haven't last* around.""" + """ + 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. + + 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]: - """last exit status from execute() - currently it is INTENTIONALLY _NOT_ CLEARED on exiftool termination and not dependent on running state - This allows for executing a command and terminating, but still haven't last* around.""" + """ + 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 @@ -551,7 +583,11 @@ def _set_logger(self, new_logger) -> None: # 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="'logger' property to set to the class logging.Logger") + 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. + """) diff --git a/setup.py b/setup.py index c64326b..a7692ed 100644 --- a/setup.py +++ b/setup.py @@ -80,7 +80,8 @@ ), extras_require={ - "test": ["packaging"], + "test": ["packaging"], # dependencies to do tests + "docs": ["sphinx", "sphinx-autoapi", "sphinx-rtd-theme", "sphinx-autodoc-typehints"], # dependencies to build docs }, #package_dir={'exiftool': 'exiftool'}, From 353872ed34876074a9c67a2143d0d3932a57575c Mon Sep 17 00:00:00 2001 From: SylikC Date: Mon, 28 Feb 2022 14:19:45 -0800 Subject: [PATCH 173/251] Fix README.rst to make it compatible with twine minor changes to documentation / dates. --- COMPATIBILITY.txt | 4 ++-- COPYING.BSD | 2 +- README.rst | 10 ++++------ setup.py | 2 +- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/COMPATIBILITY.txt b/COMPATIBILITY.txt index 824596e..0f10017 100644 --- a/COMPATIBILITY.txt +++ b/COMPATIBILITY.txt @@ -7,8 +7,8 @@ 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: +v0.2.1 - v0.4.13 = original v0.2 code with all PRs, a superset of functionality on Exiftool class +v0.5.0 - = not API compatible with the v0.4.x series. Broke down functionality stability by classes. See comments below: ---- diff --git a/COPYING.BSD b/COPYING.BSD index cc68df4..c6140ed 100644 --- a/COPYING.BSD +++ b/COPYING.BSD @@ -1,4 +1,4 @@ -Copyright 2012 Sven Marnach +Copyright 2012 Sven Marnach, 2019-2022 Kevin M (sylikc) All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/README.rst b/README.rst index a2d152e..72c96bf 100644 --- a/README.rst +++ b/README.rst @@ -17,18 +17,16 @@ process for every single query. Example Usage ============= -.. code-block:: python - :caption: Quick Sample - :linenos: +Simple example: :: import exiftool files = ["a.jpg", "b.png", "c.tif"] with exiftool.ExifToolHelper() as et: - metadata = et.get_metadata(files) + metadata = et.get_metadata(files) for d in metadata: - print("{:20.20} {:20.20}".format(d["SourceFile"], - d["EXIF:DateTimeOriginal"])) + print("{:20.20} {:20.20}".format(d["SourceFile"], + d["EXIF:DateTimeOriginal"])) Getting PyExifTool diff --git a/setup.py b/setup.py index a7692ed..b1200c8 100644 --- a/setup.py +++ b/setup.py @@ -76,7 +76,7 @@ packages=find_packages( where=".", - exclude = ['test*',] + exclude = ['test*','doc*'] ), extras_require={ From 1bcef4936afd1ac81cb4a15594319689f8a30362 Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 2 Mar 2022 07:18:25 -0800 Subject: [PATCH 174/251] Documentation update. Every single docstring was touched (except ExifToolAlpha). Sphinx documentation has been updated and will be updated on github.io with a version matching this commit --- CHANGELOG.md | 1 + README.rst | 76 +++-- docs/source/conf.py | 60 +++- docs/source/examples.rst | 6 + docs/source/index.rst | 38 +-- docs/source/installation.rst | 7 + docs/source/intro.rst | 49 +++ docs/source/maintenance/release-process.rst | 72 +++++ docs/source/package.rst | 32 ++ docs/source/reference/1-exiftool.rst | 12 + docs/source/reference/2-helper.rst | 9 + docs/source/reference/3-alpha.rst | 9 + exiftool/__init__.py | 2 +- exiftool/constants.py | 44 ++- exiftool/exiftool.py | 322 +++++++++++++------- exiftool/experimental.py | 7 +- exiftool/helper.py | 41 ++- setup.py | 3 +- 18 files changed, 594 insertions(+), 196 deletions(-) create mode 100644 docs/source/examples.rst create mode 100644 docs/source/installation.rst create mode 100644 docs/source/intro.rst create mode 100644 docs/source/maintenance/release-process.rst create mode 100644 docs/source/package.rst create mode 100644 docs/source/reference/1-exiftool.rst create mode 100644 docs/source/reference/2-helper.rst create mode 100644 docs/source/reference/3-alpha.rst diff --git a/CHANGELOG.md b/CHANGELOG.md index d54a96e..16a69e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Date (Timezone) | Version | Comment 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. +03/02/2022 07:07:26 AM (PST) | 0.5.1 | v0.5 Sphinx documentation generation finally working. Lots of reStructuredText written to make the documentation better!
There's no functional changes to PyExifTool, but after several days and hours of effort, every single docstring in ExifTool and ExifToolHelper was updated to reflect all v0.5.0 changes. ExifToolAlpha was largely untouched because the methods exposed haven't really been updated this time. On version changes, update __int__.py to reflect version diff --git a/README.rst b/README.rst index 72c96bf..5b01ffb 100644 --- a/README.rst +++ b/README.rst @@ -2,8 +2,10 @@ PyExifTool ********** -PyExifTool is a Python library to communicate with an instance of Phil -Harvey's excellent ExifTool_ command-line application. The library +.. DESCRIPTION_START + +PyExifTool is a Python library to communicate with an instance of +`Phil Harvey's ExifTool`_ command-line application. The library provides the class ``exiftool.ExifTool`` that runs the command-line tool in batch mode and features methods to send commands to that program, including methods to extract meta-information from one or @@ -12,7 +14,9 @@ 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: https://exiftool.org/ +.. _Phil Harvey's ExifTool: https://exiftool.org/ + +.. DESCRIPTION_END Example Usage ============= @@ -29,6 +33,8 @@ Simple example: :: d["EXIF:DateTimeOriginal"])) +.. INSTALLATION_START + Getting PyExifTool ================== @@ -123,6 +129,9 @@ will have to `build from source`_. .. _build from source: https://exiftool.org/install.html#Unix +.. INSTALLATION_END + + Documentation ============= @@ -140,30 +149,53 @@ project moves forward Package Structure ----------------- -PyExifTool consists of a few modules, each with increasingly more features. +.. DESIGN_INFO_START + +PyExifTool was designed with flexibility and extensibility in mind. The library consists of a few classes, each with increasingly more features. + +The base ``ExifTool`` class contains the core functionality exposed in the most rudimentary way, and each successive class inherits and adds functionality. + +.. DESIGN_INFO_END + +.. DESIGN_CLASS_START + +* ``exiftool.ExifTool`` is the base class with core logic to interface with PH's ExifTool process. + It contains only the core features with no extra fluff. + The main methods provided are ``execute()`` and ``execute_json()`` which allows direct interaction with the underlying exiftool process. + + * The API is considered stable and should not change much with future releases. -The base ``ExifTool`` class is the most rudimentary, and each successive class -inherits and adds functionality. +* ``exiftool.ExifToolHelper`` exposes some of the most commonly used functionality. It overloads + some inherited functions to turn common errors into warnings and adds logic to make + ``exiftool.ExifTool`` easier to use. + For example, ``ExifToolHelper`` provides wrapper functions to get metadata, and auto-starts the exiftool instance if it's not running (instead of raising an Exception). + ``ExifToolHelper`` demonstrates how to extend ``ExifTool`` to your liking if your project demands customizations not directly provided by ``ExifTool``. -* ``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. + * More methods may be added and/or slight API tweaks may occur with future releases. -* ``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. +* ``exiftool.ExifToolAlpha`` further extends the ``ExifToolHelper`` and includes some community-contributed not-very-well-tested methods. + These methods were formerly added ad-hoc by various community contributors, but no longer stand up to the rigor of the current design. + ``ExifToolAlpha`` is *not* up to the rigorous testing standard of both + ``ExifTool`` or ``ExifToolHelper``. There may be old, buggy, or defunct code. -* ``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. + * This is the least polished of the classes and functionality/API may be changed/added/removed on any release. + + * **NOTE: The methods exposed may be changed/removed at any time.** + + * If you are using any of these methods in your project, please `Submit an Issue`_ to start a discussion on making those functions more robust, and making their way into ``ExifToolHelper``. + (Think of ``ExifToolAlpha`` as ideas on how to extend ``ExifTool``, where new functionality which may one day make it into the ``ExifToolHelper`` class.) + +.. _Submit an Issue: https://github.com/sylikc/pyexiftool/issues + + +.. DESIGN_CLASS_END Brief History ============= +.. HISTORY_START + 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. @@ -202,9 +234,14 @@ and development. Special thanks to the community contributions, especially .. _Seth P: https://github.com/csparker247 .. _Kolen Cheung: https://github.com/ickc + +.. HISTORY_END + Licence ======= +.. LICENSE_START + 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 @@ -215,3 +252,6 @@ but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See ``LICENSE`` for more details. + + +.. LICENSE_END diff --git a/docs/source/conf.py b/docs/source/conf.py index e89e41a..887e563 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -30,18 +30,23 @@ # -- Project information ----------------------------------------------------- # General information about the project. -project = u'PyExifTool' -copyright = u'2012, Sven Marnach. 2021, Kevin M' -author = 'Sven Marnach, Kevin M' +project = 'PyExifTool' +copyright = '2022, Kevin M (sylikc)' +author = 'Kevin M (sylikc)' + +# read directly from exiftool's version instead of hard coding it here +import exiftool +from packaging import version as pv +et_ver = pv.parse(exiftool.__version__) # 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' +version = f'{et_ver.major}.{et_ver.minor}' # The full version, including alpha/beta/rc tags. -release = '0.5.0a1' +release = exiftool.__version__ # -- General configuration ----------------------------------------------------- @@ -52,15 +57,32 @@ # 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', # Core library for html generation from docstrings - 'sphinx.ext.autosummary', # Create neat summary tables - 'autoapi.extension', # pip install sphinx-autoapi + 'sphinx.ext.autodoc', # Core library for html generation from docstrings + 'sphinx.ext.autodoc.typehints', + #'sphinx.ext.autosummary', # Create neat summary tables + 'autoapi.extension', # pip install sphinx-autoapi + 'sphinx_autodoc_typehints', # pip install sphinx-autodoc-typehints + 'sphinx.ext.inheritance_diagram', ] -autosummary_generate = True # Turn on sphinx.ext.autosummary +#autosummary_generate = True # Turn on sphinx.ext.autosummary autoapi_type = 'python' -autoapi_dirs = [ str(Path(__file__).parent.parent.parent / 'exiftool') ] +autoapi_dirs = ['../../exiftool'] +autoapi_member_order = 'groupwise' +#autoapi_python_use_implicit_namespaces = True +#autoapi_options = [ 'members', 'undoc-members', 'show-inheritance', 'show-module-summary', 'special-members', ] +#autoapi_generate_api_docs = False + +autodoc_typehints = 'description' + +typehints_defaults = "comma" +# the common names of the classes rather than the absolute paths +inheritance_alias = { + 'exiftool.exiftool.ExifTool': 'exiftool.ExifTool', + 'exiftool.helper.ExifToolHelper': 'exiftool.ExifToolHelper', + 'exiftool.experimental.ExifToolAlpha': 'exiftool.ExifToolAlpha', +} @@ -116,15 +138,21 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -#html_theme = 'default' -# pip install sphinx_rtd_theme -html_theme = 'sphinx_rtd_theme' +html_theme = 'sphinx_rtd_theme' # pip install 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 # documentation. #html_theme_options = {} +# https://stackoverflow.com/questions/62904172/how-do-i-replace-view-page-source-with-edit-on-github-links-in-sphinx-rtd-th/62904217#62904217 +html_context = { + #'display_github': True, + 'github_user': 'sylikc', + 'github_repo': 'pyexiftool', + 'github_version': 'master/docs/source/', +} + # Add any paths that contain custom themes here, relative to this directory. #html_theme_path = [] @@ -209,10 +237,12 @@ # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). +""" latex_documents = [ ('index', 'PyExifTool.tex', u'PyExifTool Documentation', u'Sven Marnach', 'manual'), ] +""" # The name of an image file (relative to this directory) to place at the top of # the title page. @@ -239,10 +269,12 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). +""" man_pages = [ ('index', 'pyexiftool', u'PyExifTool Documentation', [u'Sven Marnach'], 1) ] +""" # If true, show URL addresses after external links. #man_show_urls = False @@ -253,11 +285,13 @@ # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) +""" texinfo_documents = [ ('index', 'PyExifTool', u'PyExifTool Documentation', u'Sven Marnach', 'PyExifTool', 'One line description of project.', 'Miscellaneous'), ] +""" # Documents to append as an appendix to all manuals. #texinfo_appendices = [] diff --git a/docs/source/examples.rst b/docs/source/examples.rst new file mode 100644 index 0000000..cc8bbd8 --- /dev/null +++ b/docs/source/examples.rst @@ -0,0 +1,6 @@ +******** +Examples +******** + +TODO show some ExifTool and ExifToolHelper use cases for common exiftool operations + diff --git a/docs/source/index.rst b/docs/source/index.rst index 690d103..108c53a 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,36 +1,30 @@ .. 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 -========================================================== +PyExifTool -- Python wrapper for Phil Harvey's ExifTool +======================================================= -.. automodule:: exiftool - :members: - :undoc-members: - :private-members: - :special-members: - :show-inheritance: +PyExifTool is a Python library to communicate with an instance of +`Phil Harvey's ExifTool`_ command-line application. - .. automethod:: __init__ +.. _Phil Harvey's ExifTool: https://exiftool.org/ -.. autosummary:: - :toctree: _autosummary - :recursive: +.. toctree:: + :maxdepth: 2 + :glob: + :caption: Contents: - exiftool + intro + package + installation + examples + reference/* -.. - look up info using this https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html -.. - automodule:: exiftool.helper - :members: - :undoc-members: +.. maintenance/* +.. not public at the moment, at least it doesn't have to be in the TOC (adds unnecessary clutter) -.. toctree:: - :maxdepth: 2 - :caption: Contents: Indices and tables ================== diff --git a/docs/source/installation.rst b/docs/source/installation.rst new file mode 100644 index 0000000..b987407 --- /dev/null +++ b/docs/source/installation.rst @@ -0,0 +1,7 @@ +************ +Installation +************ + +.. include:: ../../README.rst + :start-after: INSTALLATION_START + :end-before: INSTALLATION_END diff --git a/docs/source/intro.rst b/docs/source/intro.rst new file mode 100644 index 0000000..32190d1 --- /dev/null +++ b/docs/source/intro.rst @@ -0,0 +1,49 @@ +************ +Introduction +************ + +.. _introduction: + +.. include:: ../../README.rst + :start-after: DESCRIPTION_START + :end-before: DESCRIPTION_END + +Concepts +======== + +As noted in the :ref:`introduction `, PyExifTool is used to **communicate** with an instance of the external ExifTool process. + +.. note:: + + PyExifTool cannot do what ExifTool does not do. If you're not yet familiar with the capabilities of PH's ExifTool, please head over to `ExifTool by Phil Harvey`_ homepage and read up on how to use it, and what it's capable of. + +.. _ExifTool by Phil Harvey: https://exiftool.org/ + +What PyExifTool Is +------------------ + +* ... is a wrapper for PH's Exiftool, hence it can do everything PH's ExifTool can do. +* ... is a library which adds some helper functionality around ExifTool to make it easier to work with in Python. +* ... is extensible and you can add functionality on top of the base class for your use case. +* ... is supported on any platform which PH's ExifTool runs + +What PyExifTool Is NOT +---------------------- + +* ... is NOT a direct subtitute for Phil Harvey's ExifTool. The `exiftool` executable must still be installed and available for PyExifTool to use. +* ... is NOT a library which does direct image manipulation (ex. Python Pillow_). + +.. _Pillow: https://pillow.readthedocs.io/en/stable/ + +Nomenclature +============ + +PyExifTool's namespace is *exiftool*. While naming the library the same name of the tool it's meant to interface with, it can cause some ambiguity when describing it in docs. +Hence, here's some common nomenclature used. + +Because the term `exiftool` is overloaded (lowercase, CapWords case, ...) and can mean several things: + +* `PH's ExifTool` = Phil Harvey's ExifTool +* ``ExifTool`` in context usually implies ``exiftool.ExifTool`` +* `exiftool` when used alone almost always refers to `PH's ExifTool`'s command line executable. (While Windows is supported with `exiftool.exe` the Linux nomenclature is used throughout the docs) + diff --git a/docs/source/maintenance/release-process.rst b/docs/source/maintenance/release-process.rst new file mode 100644 index 0000000..d6bc5de --- /dev/null +++ b/docs/source/maintenance/release-process.rst @@ -0,0 +1,72 @@ +*************** +Release Process +*************** + +This page documents the steps to be taken to release a new version of PyExifTool. + + +Source Preparation +================== + +1. Update the version number in ``exiftool/__init__.py`` +2. Add any changelog entries to ``CHANGELOG.md`` +3. Run Tests +4. Generate docs +5. Commit and push the changes. +6. Check that the tests passed on GitHub. + + +Pre-Requisites +============== + +Make sure the latest packages are installed. + +1. pip: ``python -m pip install --upgrade pip`` +2. build tools: ``python -m pip install --upgrade setuptools build`` +3. for uploading to PyPI: ``python -m pip install --upgrade twine`` + +Run Tests +========= + +1. Run in standard unittest: ``python -m unittest -v`` +2. Run in PyTest: ``scripts\pytest.bat`` + +Build and Check +=============== + +1. Build package: ``python -m build`` +2. `Validating reStructuredText markup`_: ``python -m twine check dist/*`` + +.. _Validating reStructuredText markup: https://packaging.python.org/guides/making-a-pypi-friendly-readme/#validating-restructuredtext-markup + +Upload to Test PyPI +=================== + +Set up the ``$HOME/.pypirc`` (Linux) or ``%UserProfile%\.pypirc`` (Windows) + +1. ``python -m twine upload --repository testpypi dist/*`` +2. Check package uploaded properly: `TestPyPI PyExifTool`_ +3. Test in a new temporary venv: ``python -m pip install -U -i https://test.pypi.org/simple/ PyExifTool==`` + + * If there is an error with SSL verification, just trust it: ``python -m pip install --trusted-host test-files.pythonhosted.org -U -i https://test.pypi.org/simple/ PyExifTool==`` + +4. Run tests and examine files installed + +5. Cleanup: ``python -m pip uninstall PyExifTool``, then delete temp venv + +.. _TestPyPI PyExifTool: https://test.pypi.org/project/PyExifTool/#history + +Release +======= + +1. Be very sure all the tests pass and the package is good, because `PyPI does not allow for a filename to be reused`_ +2. Release to production PyPI: ``python -m twine upload dist/*`` +3. If needed, create a tag, and a GitHub release with the *whl* file + + .. code-block:: bash + + git tag -a vX.X.X + git push --tags + +.. _PyPI does not allow for a filename to be reused: https://pypi.org/help/#file-name-reuse + diff --git a/docs/source/package.rst b/docs/source/package.rst new file mode 100644 index 0000000..2b94825 --- /dev/null +++ b/docs/source/package.rst @@ -0,0 +1,32 @@ +**************** +Package Overview +**************** + +Design +====== + +.. include:: ../../README.rst + :start-after: DESIGN_INFO_START + :end-before: DESIGN_INFO_END + +.. inheritance-diagram:: exiftool.ExifToolAlpha + +.. include:: ../../README.rst + :start-after: DESIGN_CLASS_START + :end-before: DESIGN_CLASS_END + + +Fork Origins / Brief History +============================ + +.. include:: ../../README.rst + :start-after: HISTORY_START + :end-before: HISTORY_END + + +License +======= + +.. include:: ../../README.rst + :start-after: LICENSE_START + :end-before: LICENSE_END diff --git a/docs/source/reference/1-exiftool.rst b/docs/source/reference/1-exiftool.rst new file mode 100644 index 0000000..1a73ddb --- /dev/null +++ b/docs/source/reference/1-exiftool.rst @@ -0,0 +1,12 @@ +*********************** +Class exiftool.ExifTool +*********************** + +.. autoapimodule:: exiftool.ExifTool + :members: + :undoc-members: + :special-members: __init__ + :show-inheritance: + +.. :private-members: +.. currently excluding private members diff --git a/docs/source/reference/2-helper.rst b/docs/source/reference/2-helper.rst new file mode 100644 index 0000000..8e368ad --- /dev/null +++ b/docs/source/reference/2-helper.rst @@ -0,0 +1,9 @@ +***************************** +Class exiftool.ExifToolHelper +***************************** + +.. autoapimodule:: exiftool.ExifToolHelper + :members: + :undoc-members: + :special-members: __init__ + :show-inheritance: diff --git a/docs/source/reference/3-alpha.rst b/docs/source/reference/3-alpha.rst new file mode 100644 index 0000000..c90083b --- /dev/null +++ b/docs/source/reference/3-alpha.rst @@ -0,0 +1,9 @@ +**************************** +Class exiftool.ExifToolAlpha +**************************** + +.. autoapimodule:: exiftool.ExifToolAlpha + :members: + :undoc-members: + :special-members: __init__ + :show-inheritance: diff --git a/exiftool/__init__.py b/exiftool/__init__.py index 5e3cb89..2bff352 100644 --- a/exiftool/__init__.py +++ b/exiftool/__init__.py @@ -2,7 +2,7 @@ # 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" +__version__ = "0.5.1" # while we COULD import all the exceptions into the base library namespace, diff --git a/exiftool/constants.py b/exiftool/constants.py index bd8d64f..991c535 100644 --- a/exiftool/constants.py +++ b/exiftool/constants.py @@ -13,10 +13,9 @@ # See COPYING.GPL or COPYING.BSD for more details. """ -This file defines constants which are used by others in the package +This file defines constants which are used by other modules in the package """ - import sys @@ -27,9 +26,12 @@ # 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') +"""sys.platform check, set to True if Windows""" + # 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') +"""sys.platform check, set to True if Linux""" @@ -40,16 +42,18 @@ # specify the extension so exiftool doesn't default to running "exiftool.py" on windows (which could happen) DEFAULT_EXECUTABLE: str +"""The name of the default executable to run. + +``exiftool`` (Linux) or ``exiftool.exe`` (Windows) + +By default, the executable is searched for on one of the paths listed in the +``PATH`` environment variable. If it's not on the ``PATH``, a full path should be given to the ExifTool constructor. +""" 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. -""" @@ -58,10 +62,18 @@ ################################## # for Windows STARTUPINFO -SW_FORCEMINIMIZE: int = 11 # from win32con +SW_FORCEMINIMIZE: int = 11 +"""Windows ShowWindow constant from win32con + +Indicates the launched process window should start minimized +""" # for Linux preexec_fn -PR_SET_PDEATHSIG: int = 1 # taken from linux/prctl.h +PR_SET_PDEATHSIG: int = 1 +"""taken from linux/prctl.h + +Allows a kill signal to be sent to child processes when the parent unexpectedly dies +""" @@ -69,12 +81,14 @@ ######## 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 +"""The default block size when reading from exiftool. The standard value +should be fine, though other values might give better performance in +some cases.""" -# 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" +"""this is the minimum *exiftool* version required for current version of PyExifTool + +* 8.40 / 8.60 (production): implemented the -stay_open flag +* 12.10 / 12.15 (production): implemented exit status on -echo4 +""" diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index a5ae6d2..a93b923 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -161,36 +161,42 @@ def _read_fd_endswith(fd, b_endswith, block_size: int): class ExifTool(object): """Run the `exiftool` command-line tool and communicate with it. - There argument ``print_conversion`` no longer exists. Use ``common_args`` - to enable/disable print conversion by specifying or not ``-n``. + Use ``common_args`` to enable/disable print conversion by specifying/omitting ``-n``, respectively. 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 + See `Exiftool print conversion FAQ`_ for more details. + + .. _Exiftool print conversion FAQ: https://exiftool.org/faq.html#Q6 + 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`` + + * ``executable`` (string): file name of the *exiftool* executable. + The default value :py:attr:`exiftool.constants.DEFAULT_EXECUTABLE` 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 + * ``common_args`` (list of strings): contains additional parameters 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 + See :py:attr:`common_args` property for more details. + * ``win_shell`` + + .. note:: + This parameter may be deprecated in the future + + * ``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 + * ``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. + * ``logger`` (object): Set a custom logger to log status and debug messages to. + See :py:attr:`logger` for more details. - Most methods of this class are only available after calling - :py:meth:`start()`, which will actually launch the subprocess. To + Some methods of this class are only available after calling + :py:meth:`run()`, which will actually launch the *exiftool* subprocess. To avoid leaving the subprocess running, make sure to call :py:meth:`terminate()` method when finished using the instance. This method will also be implicitly called when the instance is @@ -206,16 +212,13 @@ class ExifTool(object): with ExifTool() as et: ... - .. warning:: Note that there is no error handling. Nonsensical - 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 undefined behaviour. - - .. py:attribute:: _running + .. warning:: + Note that options and parameters are not checked. There is no error handling or validation of options passed to *exiftool*. + Nonsensical options are mostly 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 undefined behaviour. - A Boolean value indicating whether this instance is currently - associated with a running subprocess. """ ############################################################################## @@ -231,13 +234,16 @@ def __init__(self, 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 + """ ``common_args`` defaults to *-G -n* as this is the most common use case. + + * -G separates the grouping + * -n (print conversion disabled) improves the speed and consistency of output, and is more machine-parsable """ # --- default settings / declare member variables --- self._running: bool = False # is it running? + """A Boolean value indicating whether this instance is currently + associated with a running subprocess.""" 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 @@ -313,14 +319,23 @@ def __del__(self) -> None: # ---------------------------------------------------------------------------------------------------------------------- @property def executable(self) -> str: + """ + Path to *exiftool* executable. + + :getter: Returns current exiftool path + :setter: Specify just the executable name, or an absolute path to the executable. + If path given is not absolute, searches environment ``PATH``. + + .. note:: + Setting is only available when exiftool process is not running. + + :raises ExifToolRunning: If attempting to set while running (:py:attr:`running` == True) + :type: str, Path + """ 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") @@ -348,19 +363,29 @@ def executable(self, new_executable: Union[str, Path]) -> None: # ---------------------------------------------------------------------------------------------------------------------- @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. + Encoding of Popen() communication with *exiftool* process. + + :getter: Returns current encoding setting + + :setter: Set a new encoding. + + * If *new_encoding* is None, will detect it from ``locale.getpreferredencoding(do_setlocale=False)`` (do_setlocale is set to False as not to affect the caller). + * Default to ``UTF-8`` if nothing is returned by ``getpreferredencoding`` + + .. warning:: + Property setter does NOT validate the encoding for validity. It is passed verbatim into subprocess.Popen() - 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 + .. note:: + Setting is only available when exiftool process is not running. + + :raises ExifToolRunning: If attempting to set while running (:py:attr:`running` == True) - this does NOT validate the encoding for validity. It is passed verbatim into subprocess.Popen() """ + return self._encoding + @encoding.setter + def encoding(self, new_encoding: Optional[str]) -> None: # cannot set encoding when process is running if self.running: raise ExifToolRunning("Cannot set new encoding") @@ -372,13 +397,21 @@ def encoding(self, new_encoding: Optional[str]) -> None: # ---------------------------------------------------------------------------------------------------------------------- @property def block_size(self) -> int: + """ + Block size for communicating with *exiftool* subprocess. Used when reading from the I/O pipe. + + :getter: Returns current block size + + :setter: Set a new block_size. Does basic error checking to make sure > 0. + + :raises ValueError: If new block size is invalid + + :type: int + """ return self._block_size @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") @@ -390,19 +423,35 @@ def block_size(self, new_block_size: int) -> None: # ---------------------------------------------------------------------------------------------------------------------- @property def common_args(self) -> Optional[List[str]]: - return self._common_args + """ + Common Arguments executed with every command passed to *exiftool* subprocess - @common_args.setter - def common_args(self, new_args: Optional[List[str]]) -> None: - """ set the common_args parameter + This is the parameter `-common_args`_ that is passed when the *exiftool* process is STARTED + + Read `Phil Harvey's ExifTool documentation`_ to get further information on what options are available / how to use them. - 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 + .. _-common_args: https://exiftool.org/exiftool_pod.html#Advanced-options + .. _Phil Harvey's 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 + :getter: Returns current common_args list - If new_args is None, will set to [] + :setter: If ``None`` is passed in, sets common_args to ``[]``. Otherwise, sets the given list without any validation. + + .. warning:: + No validation is done on the arguments list. It is passed verbatim to *exiftool*. Invalid options or combinations may result in undefined behavior. + + .. note:: + Setting is only available when exiftool process is not running. + + :raises ExifToolRunning: If attempting to set while running (:py:attr:`running` == True) + :raises TypeError: If setting is not a list + + :type: list[str], None """ + return self._common_args + + @common_args.setter + def common_args(self, new_args: Optional[List[str]]) -> None: if self.running: raise ExifToolRunning("Cannot set new common_args") @@ -422,18 +471,33 @@ def common_args(self, new_args: Optional[List[str]]) -> None: # ---------------------------------------------------------------------------------------------------------------------- @property def config_file(self) -> Optional[str]: - """ Return currently set config file """ - return self._config_file + """ + Path to config file. - @config_file.setter - def config_file(self, new_config_file: Optional[Union[str, Path]]) -> None: - """ set the config_file parameter + See `ExifTool documentation for -config`_ for more details. + + + + :getter: Returns current config file path, or None if not set + + :setter: File existence is checked when setting 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 + * Set to ``None`` to disable the ``-config`` parameter when starting *exiftool* + * Set to ``""`` has special meaning and disables loading of the default config file. See `ExifTool documentation for -config`_ for more info. - if :py:attr:`running` == True, it will throw an error. Can only set config_file when exiftool is not running + .. note:: + Currently file is checked for existence when setting. It is not checked when starting process. + + :raises ExifToolRunning: If attempting to set while running (:py:attr:`running` == True) + + :type: str, Path, None + + .. _ExifTool documentation for -config: https://exiftool.org/exiftool_pod.html#Advanced-options """ + return self._config_file + + @config_file.setter + def config_file(self, new_config_file: Optional[Union[str, Path]]) -> None: if self.running: raise ExifToolRunning("Cannot set a new config_file") @@ -460,13 +524,15 @@ def config_file(self, new_config_file: Optional[Union[str, Path]]) -> None: @property def running(self) -> bool: """ - Read-only property which indicates whether the ExifTool instance is running or not + Read-only property which indicates whether the *exiftool* subprocess is running or not. - .. note:: - This checks to make sure the process is still alive. + :getter: Returns current running state + + .. 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 the process has died since last `running` detection, this property + will detect the state change and reset the status accordingly. """ if self._running: # check if the process is actually alive @@ -484,11 +550,13 @@ def running(self) -> bool: @property def version(self) -> str: """ - Read-only property which is the string returned by `exiftool -ver` + Read-only property which is the string returned by ``exiftool -ver`` - The `-ver` command is ran once at process startup and cached. + The *-ver* command is ran once at process startup and cached. - This property is only valid when :py:attr:`running` == True + :getter: Returns cached output of ``exiftool -ver`` + + :raises ExifToolNotRunning: If attempting to read while not running (:py:attr:`running` == False) """ if not self.running: @@ -500,7 +568,7 @@ def version(self) -> str: @property def last_stdout(self) -> Optional[str]: """ - STDOUT for most recent result from execute() + ``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. @@ -514,7 +582,7 @@ def last_stdout(self) -> Optional[str]: @property def last_stderr(self) -> Optional[str]: """ - STDERR for most recent result from execute() + ``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. @@ -528,7 +596,7 @@ def last_stderr(self) -> Optional[str]: @property def last_status(self) -> Optional[int]: """ - Exit Status Code for most recent result from execute() + ``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. @@ -583,12 +651,19 @@ def _set_logger(self, new_logger) -> None: # 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 + logger = property(fset=_set_logger, doc="""Write-only property to set the class of logging.Logger""") + """ + Write-only property to set the class of logging.Logger - If this is set, then status messages will log out to the given class. - """) + If this is set, then status messages will log out to the given class. + :setter: Specify an object to log to. The class is not checked, but validation is done to ensure the object has callable methods ``info``, ``warning``, ``error``, ``critical``, ``exception``. + + :raises AttributeError: If object does not contain one or more of the required methods. + :raises TypeError: If object has one or more non-callable methods. + + :type: Object + """ @@ -602,22 +677,24 @@ def _set_logger(self, new_logger) -> None: # ---------------------------------------------------------------------------------------------------------------------- def run(self) -> None: - """Start an ``exiftool`` process in batch mode for this instance. + """Start an *exiftool* subprocess in batch mode. This method will issue a ``UserWarning`` if the subprocess is - 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()`. + already running (:py:attr:`running` == True). The process is started with :py:attr:`common_args` 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 - ``common_args`` parameter in the constructor. + You can override these default arguments with the + ``common_args`` parameter in the constructor or setting :py:attr:`common_args` before caaling :py:meth:`run()`. - 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) + .. note:: + If you have another executable named *exiftool* which isn't Phil Harvey's ExifTool, then you're shooting yourself in the foot as there's no error checking for that + + :raises FileNotFoundError: If *exiftool* is no longer found. Re-raised from subprocess.Popen() + :raises OSError: Re-raised from subprocess.Popen() + :raises ValueError: Re-raised from subprocess.Popen() + :raises subproccess.CalledProcessError: Re-raised from subprocess.Popen() + :raises RuntimeError: Popen() launched process but it died right away + :raises ExifToolVersionError: :py:attr:`exiftool.constants.EXIFTOOL_MINIMUM_VERSION` not met. ExifTool process will be automatically terminated. """ if self.running: warnings.warn("ExifTool already running; doing nothing.", UserWarning) @@ -629,6 +706,7 @@ def run(self) -> None: # 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 is not None: # must check explicitly for None, as "" is valid + # TODO check that the config file exists here? proc_args.extend(["-config", self._config_file]) # this is the required stuff for the stay_open that makes pyexiftool so great! @@ -718,9 +796,15 @@ def run(self) -> None: # ---------------------------------------------------------------------------------------------------------------------- def terminate(self, timeout: int = 30, _del: bool = False) -> None: - """Terminate the ``exiftool`` process of this instance. + """Terminate the *exiftool* subprocess. If the subprocess isn't running, this method will throw a warning, and do nothing. + + .. note:: + There is a bug in CPython 3.8+ on Windows where terminate() does not work during __del__() + See CPython issue `starting a thread in __del__ hangs at interpreter shutdown`_ for more info. + + .. _starting a thread in __del__ hangs at interpreter shutdown: https://bugs.python.org/issue43784 """ if not self.running: warnings.warn("ExifTool not running; doing nothing.", UserWarning) @@ -768,23 +852,34 @@ def terminate(self, timeout: int = 30, _del: bool = False) -> None: # ---------------------------------------------------------------------------------------------------------------------- def execute(self, *params): - """Execute the given batch of parameters with ``exiftool``. + """Execute the given batch of parameters with *exiftool*. This method accepts any number of parameters and sends them to - the attached ``exiftool`` process. The process must be - running, otherwise ``ValueError`` is raised. The final + the attached ``exiftool`` subprocess. The process must be + running, otherwise ``ExifToolNotRunning`` is raised. The final ``-execute`` necessary to actually run the batch is appended - automatically; see the documentation of :py:meth:`start()` for + automatically; see the documentation of :py:meth:`run()` for the common options. The ``exiftool`` output is read up to the - end-of-output sentinel and returned as a raw ``bytes`` object, + end-of-output sentinel and returned as a ``str`` decoded + based on the currently set :py:attr:`encoding`, excluding the sentinel. - The parameters must be in ``str``, use the `encoding` property to change to - encoding exiftool accepts. For filenames, this should be the + The parameters must be of type ``str``. Use the :py:attr:`encoding` property to change the + encoding ``exiftool`` accepts. For filenames, this should be the system's filesystem encoding. - .. note:: This is considered a low-level method, and should - rarely be needed by application developers. + .. note:: + This is the core method to interface with the ``exiftool`` subprocess. + + No processing is done on the input or output. + + :return: + * STDOUT is returned by the method call, and is also set in :py:attr:`last_stdout` + * STDERR is set in :py:attr:`last_stderr` + * Exit Status of the command is set in :py:attr:`last_status` + + :raises ExifToolNotRunning: If attempting to execute when not running (:py:attr:`running` == False) + :raises ExifToolVersionError: If unexpected text was returned from the command while parsing out the sentinels """ if not self.running: raise ExifToolNotRunning("Cannot execute()") @@ -876,22 +971,31 @@ def execute_json(self, *params): This method is similar to :py:meth:`execute()`. It automatically adds the parameter ``-j`` to request JSON output - from ``exiftool`` and parses the output. The return value is + from ``exiftool`` and parses the output. + + The return value is a list of dictionaries, mapping tag names to the corresponding - values. All keys are Unicode strings with the tag names - including the ExifTool group name in the format :. - The values can have multiple types. All strings occurring as - values will be Unicode strings. Each dictionary contains the + values. All keys are strings. + The values can have multiple types. Each dictionary contains the name of the file it corresponds to in the key ``"SourceFile"``. - The parameters to this function must be either raw strings - (type ``str`` in Python 2.x, type ``bytes`` in Python 3.x) or - Unicode strings (type ``unicode`` in Python 2.x, type ``str`` - in Python 3.x). Unicode strings will be encoded using - system's filesystem encoding. This behaviour means you can - pass in filenames according to the convention of the - respective Python version – as raw strings in Python 2.x and - as Unicode strings in Python 3.x. + .. note:: + By default, the tag names include the group name in the format : (if using the ``-G`` option). + + You can adjust the output structure with various *exiftool* options. + + The parameters to this function must be type ``str`` + + :return: valid JSON parsed into a Python list of dicts + :raises OutputEmpty: If *exiftool* did not return any STDOUT + + .. note:: + This is not necessarily an error, setting tags can cause this behavior. Use :py:meth:`execute()` to set tags. + + :raises OutputNotJSON: If *exiftool* returned STDOUT which is invalid JSON. + + .. note:: + This is not necessarily an error, ``-w`` can cause this behavior. Use :py:meth:`execute()` if using the ``-w`` flag. """ result = self.execute("-j", *params) # stdout diff --git a/exiftool/experimental.py b/exiftool/experimental.py index d922b0c..7b3f26a 100644 --- a/exiftool/experimental.py +++ b/exiftool/experimental.py @@ -286,10 +286,11 @@ 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. + + * 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. + * 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. diff --git a/exiftool/helper.py b/exiftool/helper.py index 8d42354..4332a04 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -66,8 +66,10 @@ def _is_iterable(in_param: Any) -> bool: # ====================================================================================================================== 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 + """ + This class extends the low-level :py:class:`exiftool.ExifTool` class with 'wrapper'/'helper' functionality + + It keeps low-level core functionality with the base class but extends helper functions in a separate class """ ########################################################################################## @@ -79,7 +81,7 @@ 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(**) + all other parameters are passed directly to super-class' constructor: :py:meth:`exiftool.ExifTool.__init__()` """ # call parent's constructor super().__init__(**kwargs) @@ -89,7 +91,11 @@ def __init__(self, auto_start=True, **kwargs): # ---------------------------------------------------------------------------------------------------------------------- def execute(self, *params): - """ override the execute() method so that it checks if it's running first, and if not, start it """ + """ + override the execute() method + + Adds logic to auto-start if not running, if auto_start == True + """ if self._auto_start and not self.running: self.run() @@ -98,7 +104,10 @@ def execute(self, *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) """ + """ + override the run() method + + Adds logic to check if already running. Will not attempt to run if already running (so no warning about 'ExifTool already running' will trigger) """ if self.running: return @@ -107,9 +116,12 @@ def run(self) -> None: # ---------------------------------------------------------------------------------------------------------------------- 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) + """ + override the terminate() method + + Adds logic to check if not running (so no warning about 'ExifTool not running' will trigger) - options are passed directly to the parent verbatim + opts are passed directly to the parent verbatim """ if not self.running: return @@ -131,13 +143,12 @@ def terminate(self, **opts) -> None: # ---------------------------------------------------------------------------------------------------------------------- def get_metadata(self, files, params=None): - """Return all meta-data for the given files. - - This will returns a list, or None + """ + Return all meta-data for the given files. - files parameter matches :py:meth:`get_tags()` + Files parameter matches :py:meth:`get_tags()` - wildcard strings are accepted as it's passed straight to exiftool + 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()`. @@ -147,9 +158,11 @@ def get_metadata(self, files, params=None): # ---------------------------------------------------------------------------------------------------------------------- def get_tags(self, files, tags, params=None): - """Return only specified tags for the given files. + """ + 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 @@ -166,7 +179,7 @@ def get_tags(self, files, tags, params=None): The format of the return value is the same as for - :py:meth:`execute_json()`. + :py:meth:`exiftool.ExifTool.execute_json()`. """ final_tags = None diff --git a/setup.py b/setup.py index b1200c8..e79cdb5 100644 --- a/setup.py +++ b/setup.py @@ -80,8 +80,9 @@ ), extras_require={ + "json": ["ujson"], # supported option for ExifTool, but not currently advertised "test": ["packaging"], # dependencies to do tests - "docs": ["sphinx", "sphinx-autoapi", "sphinx-rtd-theme", "sphinx-autodoc-typehints"], # dependencies to build docs + "docs": ["packaging", "sphinx", "sphinx-autoapi", "sphinx-rtd-theme", "sphinx-autodoc-typehints"], # dependencies to build docs }, #package_dir={'exiftool': 'exiftool'}, From ff9020f1ea5b9e0cb125e1e8a59e6a79ff4eebb3 Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 2 Mar 2022 08:50:41 -0800 Subject: [PATCH 175/251] add the inheritance diagram to the Class pages, and tweaked the output to be a bit cleaner --- docs/source/conf.py | 7 +++++++ docs/source/reference/1-exiftool.rst | 2 ++ docs/source/reference/2-helper.rst | 2 ++ docs/source/reference/3-alpha.rst | 2 ++ 4 files changed, 13 insertions(+) diff --git a/docs/source/conf.py b/docs/source/conf.py index 887e563..b1c49a0 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -84,6 +84,13 @@ 'exiftool.experimental.ExifToolAlpha': 'exiftool.ExifToolAlpha', } +# help on attributes and Graphviz params: +# https://www.sphinx-doc.org/en/master/usage/extensions/inheritance.html +# https://graphs.grevian.org/reference +# https://graphviz.org/doc/info/attrs.html +#inheritance_graph_attrs = dict(rankdir="LR", size='"6.0, 8.0"', fontsize=14, ratio='compress') +inheritance_graph_attrs = dict(pad="0.2", center=True) +inheritance_node_attrs = dict(shape='box', fontsize=14, height=0.75, color='dodgerblue1', style='rounded') # Add any paths that contain templates here, relative to this directory. diff --git a/docs/source/reference/1-exiftool.rst b/docs/source/reference/1-exiftool.rst index 1a73ddb..2e1cbed 100644 --- a/docs/source/reference/1-exiftool.rst +++ b/docs/source/reference/1-exiftool.rst @@ -2,6 +2,8 @@ Class exiftool.ExifTool *********************** +.. inheritance-diagram:: exiftool.ExifTool + .. autoapimodule:: exiftool.ExifTool :members: :undoc-members: diff --git a/docs/source/reference/2-helper.rst b/docs/source/reference/2-helper.rst index 8e368ad..4acf455 100644 --- a/docs/source/reference/2-helper.rst +++ b/docs/source/reference/2-helper.rst @@ -2,6 +2,8 @@ Class exiftool.ExifToolHelper ***************************** +.. inheritance-diagram:: exiftool.ExifToolHelper + .. autoapimodule:: exiftool.ExifToolHelper :members: :undoc-members: diff --git a/docs/source/reference/3-alpha.rst b/docs/source/reference/3-alpha.rst index c90083b..00da1a9 100644 --- a/docs/source/reference/3-alpha.rst +++ b/docs/source/reference/3-alpha.rst @@ -2,6 +2,8 @@ Class exiftool.ExifToolAlpha **************************** +.. inheritance-diagram:: exiftool.ExifToolAlpha + .. autoapimodule:: exiftool.ExifToolAlpha :members: :undoc-members: From 51f77c67dc2254d713f9ae8951bd55767f1e2cd3 Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 2 Mar 2022 17:55:36 -0800 Subject: [PATCH 176/251] Tweaked the README a bit and now there's one less duplicate blurb in the documentation --- README.rst | 32 ++++++++++++++++++++------------ docs/source/examples.rst | 2 ++ docs/source/index.rst | 26 ++++++++++++-------------- docs/source/package.rst | 2 ++ 4 files changed, 36 insertions(+), 26 deletions(-) diff --git a/README.rst b/README.rst index 5b01ffb..79f4997 100644 --- a/README.rst +++ b/README.rst @@ -4,9 +4,17 @@ PyExifTool .. DESCRIPTION_START +.. BLURB_START + PyExifTool is a Python library to communicate with an instance of -`Phil Harvey's ExifTool`_ command-line application. The library -provides the class ``exiftool.ExifTool`` that runs the command-line +`Phil Harvey's ExifTool`_ command-line application. + +.. _Phil Harvey's ExifTool: https://exiftool.org/ + + +.. BLURB_END + +The library provides the class ``exiftool.ExifTool`` that runs the command-line tool in batch mode and features methods to send commands to that program, including methods to extract meta-information from one or more image files. Since ``exiftool`` is run in batch mode, only a @@ -14,7 +22,6 @@ 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. -.. _Phil Harvey's ExifTool: https://exiftool.org/ .. DESCRIPTION_END @@ -135,9 +142,9 @@ will have to `build from source`_. 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 +The current documentation is available at `sylikc.github.io`_. +It may slightly lag behind the most updated version will be improved as the +project moves forward. :: @@ -202,18 +209,19 @@ 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. +Over the years, numerous issues were filed and several PRs were opened on the +stagnant repository. In early 2019, `Martin Čarnogurský`_ created a +`PyPI release`_ from the 2014 code with some minor updates. Coincidentally in +mid 2019, `Kevin M (sylikc)`_ forked the original repository and started merging +the 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 +Kevin M (sylikc) made the first release to the PyPI repository in early 2021. +At the same time, discussions were started, 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, diff --git a/docs/source/examples.rst b/docs/source/examples.rst index cc8bbd8..199ec0b 100644 --- a/docs/source/examples.rst +++ b/docs/source/examples.rst @@ -2,5 +2,7 @@ Examples ******** + TODO show some ExifTool and ExifToolHelper use cases for common exiftool operations +TODO show some Advanced use cases, and maybe even some don't-do-this-even-though-you-can cases diff --git a/docs/source/index.rst b/docs/source/index.rst index 108c53a..3b22fd0 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -4,22 +4,20 @@ PyExifTool -- Python wrapper for Phil Harvey's ExifTool ======================================================= -PyExifTool is a Python library to communicate with an instance of -`Phil Harvey's ExifTool`_ command-line application. - -.. _Phil Harvey's ExifTool: https://exiftool.org/ - +.. include:: ../../README.rst + :start-after: BLURB_START + :end-before: BLURB_END .. toctree:: - :maxdepth: 2 - :glob: - :caption: Contents: - - intro - package - installation - examples - reference/* + :maxdepth: 2 + :glob: + :caption: Contents: + + intro + package + installation + examples + reference/* .. maintenance/* diff --git a/docs/source/package.rst b/docs/source/package.rst index 2b94825..feadb0b 100644 --- a/docs/source/package.rst +++ b/docs/source/package.rst @@ -2,6 +2,8 @@ Package Overview **************** +All classes live under the PyExifTool library namespace: ``exiftool`` + Design ====== From bd83b579f9b36da6f2922934aec0b8e1baaa3f1b Mon Sep 17 00:00:00 2001 From: SylikC Date: Wed, 2 Mar 2022 18:00:56 -0800 Subject: [PATCH 177/251] added a new exception ExifToolExecuteError() which will be returned on a non-zero exit status ExifToolHelper() added a check parameter to execute/execute_json/get_tags. (not yet decided, but currently defaulting the lower methods to False, and the higher level methods to True --- exiftool/exceptions.py | 16 ++++++++++++++ exiftool/helper.py | 50 +++++++++++++++++++++++++++++++++++------- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/exiftool/exceptions.py b/exiftool/exceptions.py index ab8c92a..2ab158b 100644 --- a/exiftool/exceptions.py +++ b/exiftool/exceptions.py @@ -49,3 +49,19 @@ class OutputNotJSON(ExifToolException): """ ExifTool did not return valid JSON, only thrown by execute_json() """ + + +class ExifToolExecuteError(ExifToolException): + """ + ExifTool executed the command but returned a non-zero exit status + + mimics the signature of :py:class:`subprocess.CalledProcessError` + """ + def __init__(self, exit_status, cmd_stdout, cmd_stderr, params): + super().__init__(f"Exiftool execute returned a non-zero exit status: {exit_status}") + + self.returncode: int = exit_status + self.cmd: list = params + self.stdout: str = cmd_stdout + self.stderr: str = cmd_stderr + diff --git a/exiftool/helper.py b/exiftool/helper.py index 4332a04..0195049 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -26,7 +26,7 @@ import logging from .exiftool import ExifTool -from .exceptions import OutputEmpty, OutputNotJSON +from .exceptions import OutputEmpty, OutputNotJSON, ExifToolExecuteError try: # Py3k compatibility basestring @@ -90,17 +90,46 @@ def __init__(self, auto_start=True, **kwargs): # ---------------------------------------------------------------------------------------------------------------------- - def execute(self, *params): + def execute(self, *params, check=False): """ override the execute() method Adds logic to auto-start if not running, if auto_start == True + + :raises ExifToolExecuteError: If check=True, and exit status was non-zero """ if self._auto_start and not self.running: self.run() - return super().execute(*params) + result = super().execute(*params) + + # imitate the subprocess.run() signature. check=True will check non-zero exit status + if check and self._last_status: + raise ExifToolExecuteError(self._last_status, self._last_stdout, self._last_stderr, params) + return result + + # ---------------------------------------------------------------------------------------------------------------------- + def execute_json(self, *params, check=False): + """ + override the execute_json() method + + Add logic to auto-start + + Add the optional check flag + + :raises ExifToolExecuteError: If check=True, and exit status was non-zero + """ + if self._auto_start and not self.running: + self.run() + + result = super().execute_json(*params) + + # imitate the subprocess.run() signature. check=True will check non-zero exit status + if check and self._last_status: + raise ExifToolExecuteError(self._last_status, self._last_stdout, self._last_stderr, params) + + return result # ---------------------------------------------------------------------------------------------------------------------- def run(self) -> None: @@ -142,7 +171,7 @@ def terminate(self, **opts) -> None: # ---------------------------------------------------------------------------------------------------------------------- - def get_metadata(self, files, params=None): + def get_metadata(self, files, params=None, check=True): """ Return all meta-data for the given files. @@ -153,11 +182,11 @@ def get_metadata(self, files, params=None): The return value will have the format described in the documentation of :py:meth:`get_tags()`. """ - return self.get_tags(files, None, params=params) + return self.get_tags(files, None, params=params, check=check) # ---------------------------------------------------------------------------------------------------------------------- - def get_tags(self, files, tags, params=None): + def get_tags(self, files, tags, params=None, check=True): """ Return only specified tags for the given files. @@ -180,6 +209,9 @@ def get_tags(self, files, tags, params=None): The format of the return value is the same as for :py:meth:`exiftool.ExifTool.execute_json()`. + + + :raises ExifToolExecuteError: If check=True, and exit status was non-zero """ final_tags = None @@ -229,12 +261,14 @@ def get_tags(self, files, tags, params=None): exec_params.extend(final_files) try: - ret = self.execute_json(*exec_params) + ret = self.execute_json(*exec_params, check=check) 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 + except ExifToolExecuteError: + # if last_status is <> 0, raise an error that one or more files failed? raise return ret From 6a256809bf2fa9159fac5b32384544af8da79699 Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 3 Mar 2022 07:09:11 -0800 Subject: [PATCH 178/251] ExifTool - no functional changes * added some more type hints * Updated documentation for execute() and execute_json() --- docs/source/conf.py | 1 + exiftool/exiftool.py | 35 +++++++++++++++++++++++++++++------ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index b1c49a0..8bdcfea 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -73,6 +73,7 @@ #autoapi_options = [ 'members', 'undoc-members', 'show-inheritance', 'show-module-summary', 'special-members', ] #autoapi_generate_api_docs = False +# comment out when all documentation has documented parameters ... sometimes causes duplicates, but that may be a RST problem... always put links at the END of the docstring instead of in the middle autodoc_typehints = 'description' typehints_defaults = "comma" diff --git a/exiftool/exiftool.py b/exiftool/exiftool.py index a93b923..c791008 100644 --- a/exiftool/exiftool.py +++ b/exiftool/exiftool.py @@ -477,7 +477,6 @@ def config_file(self) -> Optional[str]: See `ExifTool documentation for -config`_ for more details. - :getter: Returns current config file path, or None if not set :setter: File existence is checked when setting parameter @@ -660,7 +659,7 @@ def _set_logger(self, new_logger) -> None: :setter: Specify an object to log to. The class is not checked, but validation is done to ensure the object has callable methods ``info``, ``warning``, ``error``, ``critical``, ``exception``. :raises AttributeError: If object does not contain one or more of the required methods. - :raises TypeError: If object has one or more non-callable methods. + :raises TypeError: If object contains those attributes, but one or more are non-callable methods. :type: Object """ @@ -701,6 +700,7 @@ def run(self) -> None: return # first the executable ... + # TODO should we check the executable for existence here? 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 @@ -851,7 +851,7 @@ def terminate(self, timeout: int = 30, _del: bool = False) -> None: ################################################################################## # ---------------------------------------------------------------------------------------------------------------------- - def execute(self, *params): + def execute(self, *params: str) -> str: """Execute the given batch of parameters with *exiftool*. This method accepts any number of parameters and sends them to @@ -873,6 +873,16 @@ def execute(self, *params): No processing is done on the input or output. + :param params: One or more parameters to send to the ``exiftool`` subprocess. + + Typically passed in via `Unpacking Argument Lists`_ + + .. note:: + The parameters to this function must be type ``str`` + + :type params: one or more string parameters + + :return: * STDOUT is returned by the method call, and is also set in :py:attr:`last_stdout` * STDERR is set in :py:attr:`last_stderr` @@ -880,6 +890,9 @@ def execute(self, *params): :raises ExifToolNotRunning: If attempting to execute when not running (:py:attr:`running` == False) :raises ExifToolVersionError: If unexpected text was returned from the command while parsing out the sentinels + + + .. _Unpacking Argument Lists: https://docs.python.org/3/tutorial/controlflow.html#unpacking-argument-lists """ if not self.running: raise ExifToolNotRunning("Cannot execute()") @@ -966,7 +979,7 @@ def execute(self, *params): # ---------------------------------------------------------------------------------------------------------------------- - def execute_json(self, *params): + def execute_json(self, *params: str) -> List: """Execute the given batch of parameters and parse the JSON output. This method is similar to :py:meth:`execute()`. It @@ -984,9 +997,16 @@ def execute_json(self, *params): You can adjust the output structure with various *exiftool* options. - The parameters to this function must be type ``str`` + :param params: One or more parameters to send to the ``exiftool`` subprocess. + + Typically passed in via `Unpacking Argument Lists`_ + + .. note:: + The parameters to this function must be type ``str`` - :return: valid JSON parsed into a Python list of dicts + :type params: one or more string parameters + + :return: Valid JSON parsed into a Python list of dicts :raises OutputEmpty: If *exiftool* did not return any STDOUT .. note:: @@ -996,6 +1016,9 @@ def execute_json(self, *params): .. note:: This is not necessarily an error, ``-w`` can cause this behavior. Use :py:meth:`execute()` if using the ``-w`` flag. + + + .. _Unpacking Argument Lists: https://docs.python.org/3/tutorial/controlflow.html#unpacking-argument-lists """ result = self.execute("-j", *params) # stdout From 0ba07f7a209e842228691f4d97c193917f17b5f2 Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 3 Mar 2022 07:53:02 -0800 Subject: [PATCH 179/251] ExifToolAlpha * moved set_tags_batch() and set_tags() out to Helper, with different method signature ExifToolHelper * add more type hints and better documentation * partial undo the check= parameter from last commit... making it a check_execute flag at the constructor level * added a property to get/set check_execute property * removed the execute_json() override. Tested the code paths and it's an unnecessary override (without it, the same behavior happens) * added set_tags method which combines the methods from ExifToolAlpha. Similar signature to get_tags() - I figured this should be the most requested functionality missing in v0.5.0 --- exiftool/experimental.py | 54 --------- exiftool/helper.py | 246 ++++++++++++++++++++++++++++++--------- 2 files changed, 190 insertions(+), 110 deletions(-) diff --git a/exiftool/experimental.py b/exiftool/experimental.py index 7b3f26a..d592369 100644 --- a/exiftool/experimental.py +++ b/exiftool/experimental.py @@ -227,60 +227,6 @@ def copy_tags(self, from_filename, to_filename): 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 :. - - 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. diff --git a/exiftool/helper.py b/exiftool/helper.py index 0195049..c17e892 100644 --- a/exiftool/helper.py +++ b/exiftool/helper.py @@ -36,7 +36,7 @@ #from pathlib import PurePath # Python 3.4 required -from typing import Any +from typing import Any, Union, Optional, List, Dict @@ -77,9 +77,10 @@ class ExifToolHelper(ExifTool): ########################################################################################## # ---------------------------------------------------------------------------------------------------------------------- - def __init__(self, auto_start=True, **kwargs): + def __init__(self, auto_start: bool = True, check_execute: bool = True, **kwargs) -> None: """ - auto_start = BOOLEAN. will autostart the exiftool process on first command run + :param bool auto_start: Will automatically start the exiftool process on first command run, defaults to True + :param bool check_execute: Will check the exit status (return code) of all commands. This catches some invalid commands passed to exiftool subprocess, defaults to True all other parameters are passed directly to super-class' constructor: :py:meth:`exiftool.ExifTool.__init__()` """ @@ -87,46 +88,26 @@ def __init__(self, auto_start=True, **kwargs): super().__init__(**kwargs) self._auto_start: bool = auto_start + self._check_execute: bool = check_execute # ---------------------------------------------------------------------------------------------------------------------- - def execute(self, *params, check=False): + def execute(self, *params) -> str: """ - override the execute() method + Override the :py:meth:`exiftool.ExifTool.execute()` method Adds logic to auto-start if not running, if auto_start == True - :raises ExifToolExecuteError: If check=True, and exit status was non-zero + :raises ExifToolExecuteError: If :py:attr:`check_execute` == True, and exit status was non-zero """ if self._auto_start and not self.running: + print('hey') self.run() - result = super().execute(*params) + result: str = super().execute(*params) # imitate the subprocess.run() signature. check=True will check non-zero exit status - if check and self._last_status: - raise ExifToolExecuteError(self._last_status, self._last_stdout, self._last_stderr, params) - - return result - - # ---------------------------------------------------------------------------------------------------------------------- - def execute_json(self, *params, check=False): - """ - override the execute_json() method - - Add logic to auto-start - - Add the optional check flag - - :raises ExifToolExecuteError: If check=True, and exit status was non-zero - """ - if self._auto_start and not self.running: - self.run() - - result = super().execute_json(*params) - - # imitate the subprocess.run() signature. check=True will check non-zero exit status - if check and self._last_status: + if self._check_execute and self._last_status: raise ExifToolExecuteError(self._last_status, self._last_stdout, self._last_stderr, params) return result @@ -134,7 +115,7 @@ def execute_json(self, *params, check=False): # ---------------------------------------------------------------------------------------------------------------------- def run(self) -> None: """ - override the run() method + override the :py:meth:`exiftool.ExifTool.run()` method Adds logic to check if already running. Will not attempt to run if already running (so no warning about 'ExifTool already running' will trigger) """ if self.running: @@ -146,7 +127,7 @@ def run(self) -> None: # ---------------------------------------------------------------------------------------------------------------------- def terminate(self, **opts) -> None: """ - override the terminate() method + override the :py:meth:`exiftool.ExifTool.terminate()` method Adds logic to check if not running (so no warning about 'ExifTool not running' will trigger) @@ -158,6 +139,35 @@ def terminate(self, **opts) -> None: super().terminate(**opts) + ######################################################################################## + #################################### NEW PROPERTIES #################################### + ######################################################################################## + + # ---------------------------------------------------------------------------------------------------------------------- + @property + def check_execute(self) -> bool: + """ + Flag to enable/disable checking exit status (return code) on execute + + If enabled, will raise :py:exc:`exiftool.exceptions.ExifToolExecuteError` if a non-zero exit status is returned during :py:meth:`execute()` + + :getter: Returns current setting + :setter: Enable or Disable the check + + .. note:: + This settings can be changed any time and will only affect subsequent calls + + :type: bool + """ + return self._check_execute + + @check_execute.setter + def check_execute(self, new_setting: bool) -> None: + self._check_execute = new_setting + + + + # ---------------------------------------------------------------------------------------------------------------------- @@ -171,51 +181,67 @@ def terminate(self, **opts) -> None: # ---------------------------------------------------------------------------------------------------------------------- - def get_metadata(self, files, params=None, check=True): + def get_metadata(self, files: Union[str, List], params: Optional[Union[str, List]] = None) -> List: """ - Return all meta-data for the given files. + Return all metadata for the given files. - Files parameter matches :py:meth:`get_tags()` + :param files: Files parameter matches :py:meth:`get_tags()` - wildcard strings are accepted as it's passed straight to exiftool + :param params: Optional parameters to send to *exiftool* + :type params: list or None - The return value will have the format described in the - documentation of :py:meth:`get_tags()`. + :return: The return value will have the format described in the documentation of :py:meth:`get_tags()`. """ - return self.get_tags(files, None, params=params, check=check) + return self.get_tags(files, None, params=params) # ---------------------------------------------------------------------------------------------------------------------- - def get_tags(self, files, tags, params=None, check=True): + def get_tags(self, files: Union[str, List], tags: Optional[Union[str, List]], params: Optional[Union[str, List]] = None) -> List: """ Return only specified tags for the given files. - The first argument is the files to be worked on. It can be: + :param files: File(s) to be worked on. + + If a ``str`` is provided, it will get tags for a single file + + If an interable is provided, the list is copied and any non-basestring elements are converted to str (to support ``PurePath`` and other similar objects) + + .. warning:: + Currently, filenames are NOT checked for existence, that is left up to the caller. + + Wildcard strings are valid and passed verbatim to exiftool. - * an iterable of strings/bytes - * string/bytes + However, exiftool's globbing is different than Python's globbing. Read `ExifTool Common Mistakes - Over-use of Wildcards in File Names`_ for more info - The list is copied and any non-basestring elements are converted to str (to support PurePath and other similar objects) + :type files: str or list - 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 + :param tags: Tag(s) to read. If tags is None, or [], method will returns all tags - The second argument is an iterable of tags. The tag names may - include group names, as usual in the format :. + .. note:: + The tag names may include group names, as usual in the format ``:``. - If tags is None, or [], then returns all tags + :type tags: str, list, or None - The format of the return value is the same as for - :py:meth:`exiftool.ExifTool.execute_json()`. + :param params: Optional parameter(s) to send to *exiftool* + :type params: str, list, or None - :raises ExifToolExecuteError: If check=True, and exit status was non-zero + :return: The format of the return value is the same as for :py:meth:`exiftool.ExifTool.execute_json()`. + + + :raises ValueError: Invalid Parameter + :raises TypeError: Invalid Parameter + :raises ExifToolExecuteError: If :py:attr:`check_execute` == True, and exit status was non-zero + + + .. _ExifTool Common Mistakes - Over-use of Wildcards in File Names: https://exiftool.org/mistakes.html#M2 + """ - final_tags = None - final_files = None + final_tags: Optional[List] = None + final_files: Optional[List] = None if tags is None: # all tags @@ -243,7 +269,7 @@ def get_tags(self, files, tags, params=None, check=True): # 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 = [] + exec_params: List = [] if params: if isinstance(params, basestring): @@ -261,7 +287,7 @@ def get_tags(self, files, tags, params=None, check=True): exec_params.extend(final_files) try: - ret = self.execute_json(*exec_params, check=check) + ret = self.execute_json(*exec_params) except OutputEmpty: raise #raise RuntimeError(f"{self.__class__.__name__}.get_tags: exiftool returned no data") @@ -274,5 +300,113 @@ def get_tags(self, files, tags, params=None, check=True): return ret + # ---------------------------------------------------------------------------------------------------------------------- + def set_tags(self, files: Union[str, List], tags: Dict, params: Optional[Union[str, List]] = None): + """ + Writes the values of the specified tags for the given file(s). + + :param files: File(s) to be worked on. + + If a ``str`` is provided, it will set tags for a single file + + If an interable is provided, the list is copied and any non-basestring elements are converted to str (to support ``PurePath`` and other similar objects) + + .. warning:: + Currently, filenames are NOT checked for existence, that is left up to the caller. + + Wildcard strings are valid and passed verbatim to exiftool. + + However, exiftool's globbing is different than Python's globbing. Read `ExifTool Common Mistakes - Over-use of Wildcards in File Names`_ for more info + + :type files: str or list + + + :param tags: Tag(s) to write. + + Dictionary keys = tags, values = str or list + + If a value is a str, will set key=value + If a value is a list, will iterate over list and set each individual value to the same tag ( + + .. note:: + The tag names may include group names, as usual in the format ``:``. + + .. note:: + Value of the dict can be a list, in which case, the tag will be passed with each item in the list, in the order given + + This allows setting things like ``-Keywords=a -Keywords=b -Keywords=c`` by passing in ``tags={"Keywords": ['a', 'b', 'c']}`` + + :type tags: dict + + + :param params: Optional parameter(s) to send to *exiftool* + :type params: str, list, or None + + + :return: The format of the return value is the same as for :py:meth:`execute()`. + + + :raises ValueError: Invalid Parameter + :raises TypeError: Invalid Parameter + :raises ExifToolExecuteError: If :py:attr:`check_execute` == True, and exit status was non-zero + + + .. _ExifTool Common Mistakes - Over-use of Wildcards in File Names: https://exiftool.org/mistakes.html#M2 + + """ + final_files: Optional[List] = None + + if not files: + # Exiftool process would return None anyways + raise ValueError(f"{self.__class__.__name__}.set_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 + 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? + + if not tags: + raise ValueError(f"{self.__class__.__name__}.set_tags: argument 'tags' cannot be empty") + elif not isinstance(tags, dict): + raise TypeError(f"{self.__class__.__name__}.set_tags: argument 'tags' must be a dict") + + + exec_params: List = [] + + 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") + + + 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: + exec_params.append(f"-{tag}={item}") + else: + exec_params.append(f"-{tag}={value}") + + exec_params.extend(final_files) + + try: + return self.execute(*exec_params) + #TODO if execute returns data, then error? + except ExifToolExecuteError: + # last status non-zero + raise + # ---------------------------------------------------------------------------------------------------------------------- From f85b0c2634a0a4de72ae84277f05aa196170b4ea Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 3 Mar 2022 07:53:51 -0800 Subject: [PATCH 180/251] updated tests for ExifToolHelper and ExifToolAlpha for coverage of the new methods and properties --- tests/test_alpha.py | 30 -------------- tests/test_helper.py | 98 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 32 deletions(-) diff --git a/tests/test_alpha.py b/tests/test_alpha.py index 5bd3022..2ceaa30 100644 --- a/tests/test_alpha.py +++ b/tests/test_alpha.py @@ -138,36 +138,6 @@ def test_get_metadata(self): ) 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_" diff --git a/tests/test_helper.py b/tests/test_helper.py index 4183756..74c4686 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -2,7 +2,7 @@ import unittest import exiftool -from exiftool.exceptions import ExifToolNotRunning, OutputEmpty, OutputNotJSON +from exiftool.exceptions import ExifToolNotRunning, OutputEmpty, OutputNotJSON, ExifToolExecuteError import shutil import tempfile from pathlib import Path @@ -43,18 +43,40 @@ def setUpClass(cls) -> None: cls.tmp_dir = Path(cls.temp_obj.name) - def test_read_all_from_nonexistent_file(self): + def test_read_all_from_nonexistent_file_no_checkexecute(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 """ + check_value = self.exif_tool_helper.check_execute # save off current setting + + self.exif_tool_helper.check_execute = False + 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') + self.exif_tool_helper.check_execute = check_value + + def test_read_all_from_nonexistent_file_yes_checkexecute(self): + # run above test again and check_execute = True + + check_value = self.exif_tool_helper.check_execute # save off current setting + + self.exif_tool_helper.check_execute = True + + with self.assertRaises(ExifToolExecuteError): + self.exif_tool_helper.get_metadata(['foo.bar']) + + with self.assertRaises(ExifToolExecuteError): + self.exif_tool_helper.get_tags('foo.bar', 'DateTimeOriginal') + + self.exif_tool_helper.check_execute = check_value + + def test_w_flag(self): """ test passing a -w flag to write some output @@ -148,6 +170,78 @@ def test_get_tags_params(self): # --------------------------------------------------------------------------------------------------------- + # --------------------------------------------------------------------------------------------------------- + +class WritingTest(unittest.TestCase): + @classmethod + def setUp(self): + self.et = exiftool.ExifToolHelper( + 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 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"}, + ] + 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"]}) + result = self.et.get_tags(f_mod_str, "IPTC:Caption-Abstract")[0] + tag0 = list(result.values())[1] + f_mod.unlink() + self.assertEqual(tag0, d["Caption-Abstract"]) + + # --------------------------------------------------------------------------------------------------------- + def test_set_tags_files_invalid(self): + """ test to cover the files == None """ + + with self.assertRaises(ValueError): + self.et.set_tags(None, []) + + # --------------------------------------------------------------------------------------------------------- + def test_set_tags_tags_invalid(self): + """ test to cover the files == None """ + + with self.assertRaises(ValueError): + self.et.set_tags("rose.jpg", None) + + + with self.assertRaises(TypeError): + self.et.set_tags("rose.jpg", object()) + + # --------------------------------------------------------------------------------------------------------- + + # --------------------------------------------------------------------------------------------------------- if __name__ == '__main__': From fd4e9eaeaec23ef5da3137b14272dd3a8753ccd3 Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 3 Mar 2022 17:46:45 -0800 Subject: [PATCH 181/251] README.rst - adding some badges to make it look cooler setup.py - ignore the badges when using README.rst as PyPI description --- README.rst | 15 ++++++++++++++- setup.py | 16 ++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 79f4997..14896c9 100644 --- a/README.rst +++ b/README.rst @@ -2,6 +2,19 @@ PyExifTool ********** +.. HIDE_FROM_PYPI_START + +.. image:: https://github.com/sylikc/pyexiftool/actions/workflows/lint-and-test.yml/badge.svg + :alt: GitHub Actions + :target: https://github.com/sylikc/pyexiftool/actions + +.. image:: https://img.shields.io/pypi/v/pyexiftool.svg + :target: https://pypi.org/project/PyExifTool/ + + +.. HIDE_FROM_PYPI_END + + .. DESCRIPTION_START .. BLURB_START @@ -143,7 +156,7 @@ Documentation ============= The current documentation is available at `sylikc.github.io`_. -It may slightly lag behind the most updated version will be improved as the +It may slightly lag behind the most updated version but will be improved as the project moves forward. :: diff --git a/setup.py b/setup.py index e79cdb5..9c4519d 100644 --- a/setup.py +++ b/setup.py @@ -21,8 +21,20 @@ # https://packaging.python.org/tutorials/packaging-projects/#configuring-metadata from setuptools import setup, find_packages -with open("README.rst", "r", encoding="utf-8") as fh: - long_desc = fh.read() +import re + +def get_long_desc(): + """ read README.rst without the badges (don't need those showing up on PyPI) """ + + with open("README.rst", "r", encoding="utf-8") as fh: + long_desc = fh.read() + + # crop out the portion between HIDE_FROM_PYPI_START and HIDE_FROM_PYPI_END + sub_pattern = r"^\.\. HIDE_FROM_PYPI_START.+\.\. HIDE_FROM_PYPI_END$" + long_desc = re.sub(sub_pattern, "", long_desc, flags=re.MULTILINE | re.DOTALL) + + return long_desc + setup( # detailed list of options: From 7c9493dac22e2588c5723d763751fbe3a540c86b Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 3 Mar 2022 17:48:40 -0800 Subject: [PATCH 182/251] add a Windows batch file to the scripts directory so I can get consistent docs generation results --- scripts/sphinx_docs.bat | 77 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 scripts/sphinx_docs.bat diff --git a/scripts/sphinx_docs.bat b/scripts/sphinx_docs.bat new file mode 100644 index 0000000..81cb4c3 --- /dev/null +++ b/scripts/sphinx_docs.bat @@ -0,0 +1,77 @@ +@echo off + +REM script takes ONE optional parameter, which is the path to the Graphviz dot.exe +REM it is only used if dot.exe does not exist on your PATH + +setlocal + +pushd %~dp0..\docs + +echo ______________________ +echo *** PyExifTool automation *** +echo Generate Sphinx Docs +echo; +call :PIP_SHOW_VER packaging +call :PIP_SHOW_VER sphinx +call :PIP_SHOW_VER sphinx-autoapi +call :PIP_SHOW_VER sphinx-rtd-theme +call :PIP_SHOW_VER sphinx-autodoc-typehints +echo ______________________ + +REM add some opts, -v = more verbose +SET SPHINXOPTS=-v + +echo; +echo ** Searching for Graphviz's dot ** +SET INPUT_DOT=dot.exe +where dot.exe 2>nul +IF NOT ERRORLEVEL 1 ( + REM no need to set more opts + goto FOUND_DOT +) + + +REM check %1 to see if a path to dot.exe was provided +SET INPUT_DOT=%~1 +IF EXIST "%INPUT_DOT%" ( + SET SPHINXOPTS=%SPHINXOPTS% -D graphviz_dot="%INPUT_DOT%" + goto FOUND_DOT +) + +echo Graphviz's dot.exe was not found. Either have it on your PATH, or specify with %%1 + +REM docs still generate without it, but all the graphics are missing, and so it sort of fails silently! + +exit /b 1 + +:FOUND_DOT + +echo; +echo ** Graphviz dot.exe version ** +%INPUT_DOT% -V + + +echo; +echo ** Clean build ** +call make.bat clean + +echo; +echo ** Build HTML ** +echo ___________________________________________________________________ +call make.bat html + +echo ___________________________________________________________________ +echo MAKE SURE TO CHECK FOR ANY ERRORS ABOVE!!! Sphinx fails silently! + +popd + +exit /b %errorlevel% + + +REM ------------------------------------------- +:PIP_SHOW_VER + +echo pip's %1 version +python.exe -m pip show %1 | findstr /l /c:"Version:" + +exit /b 0 From 431a57084a831f540e3d9bb9bff2807254056382 Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 3 Mar 2022 17:49:14 -0800 Subject: [PATCH 183/251] ExifToolExecuteError - add a better docstring to indicate that there are attributes which can be read from this error object --- exiftool/exceptions.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/exiftool/exceptions.py b/exiftool/exceptions.py index 2ab158b..db65f6a 100644 --- a/exiftool/exceptions.py +++ b/exiftool/exceptions.py @@ -55,7 +55,12 @@ class ExifToolExecuteError(ExifToolException): """ ExifTool executed the command but returned a non-zero exit status - mimics the signature of :py:class:`subprocess.CalledProcessError` + (mimics the signature of :py:class:`subprocess.CalledProcessError`) + + :attribute returncode: Exit Status (Return code) of the ``execute()`` command which raised the error + :attribute cmd: Parameters sent to *exiftool* which raised the error + :attribute stdout: STDOUT stream returned by the command which raised the error + :attribute stderr: STDERR stream returned by the command which raised the error """ def __init__(self, exit_status, cmd_stdout, cmd_stderr, params): super().__init__(f"Exiftool execute returned a non-zero exit status: {exit_status}") From 34bf1689ed3346e0fcb28de33d828d6e441df880 Mon Sep 17 00:00:00 2001 From: SylikC Date: Thu, 3 Mar 2022 17:50:12 -0800 Subject: [PATCH 184/251] update the scripts README.txt file so it better reflects the current usage of scripts, now that I'm also doing the docs building from there --- scripts/README.txt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/README.txt b/scripts/README.txt index 6b4204a..280443d 100644 --- a/scripts/README.txt +++ b/scripts/README.txt @@ -1,7 +1,10 @@ -These are standardized scripts/batch files which run tests or code reviews in the same way +These are standardized scripts/batch files which run tests, code reviews, or other maintenance tasks in a repeatable way. -_requirements.txt files are what extra pip requirements are required to run these + +While scripts could automatically install requirements, it is left up to the caller: + +