diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 348c5e45..e19496fc 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: "3.10" + python-version: "3.11" - name: Cache dependencies uses: actions/cache@v3 @@ -30,7 +30,7 @@ jobs: - name: Install dependencies run: | pip install virtualenv - make venv reqs-install + make venv install-dev - name: Static analysis of the code run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fe796287..11f06a83 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,13 +40,16 @@ jobs: - name: Install run: | - pip install -e . + pip install virtualenv + make venv install - name: Run in debug and color mode run: | + source .venv/bin/activate make test - name: Compare image processing output run: | + source .venv/bin/activate make test-diff diff --git a/.pylintrc b/.pylintrc index 2155d14c..55ab01f1 100644 --- a/.pylintrc +++ b/.pylintrc @@ -59,10 +59,11 @@ ignore-paths= # Emacs file locks ignore-patterns=^\.# -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis). It -# supports qualified module names, as well as Unix pattern matching. +# List of module names for which member attributes should not be checked and +# will not be imported (useful for modules/projects where namespaces are +# manipulated during runtime and thus existing member attributes cannot be +# deduced by static analysis). It supports qualified module names, as well as +# Unix pattern matching. ignored-modules= # Python code to execute, usually for sys.path manipulation such as @@ -86,6 +87,10 @@ load-plugins= # Pickle collected data for later comparisons. persistent=yes +# Resolve imports to .pyi stubs if available. May reduce no-member messages and +# increase not-an-iterable messages. +prefer-stubs=no + # Minimum Python version to use for version dependent checks. Will default to # the version used to run pylint. py-version=3.7 @@ -190,10 +195,7 @@ good-names=i, k, ex, Run, - _, - s, - fh, - EXIF + _ # Good variable names regexes, separated by a comma. If names match any regex, # they will always be accepted @@ -305,6 +307,9 @@ max-locals=25 # Maximum number of parents for a class (see R0901). max-parents=7 +# Maximum number of positional arguments for function / method. +max-positional-arguments=10 + # Maximum number of public methods for a class (see R0904). max-public-methods=20 @@ -429,13 +434,12 @@ disable=raw-checker-failed, locally-disabled, file-ignored, suppressed-message, - useless-suppression, deprecated-pragma, use-symbolic-message-instead, use-implicit-booleaness-not-comparison-to-string, use-implicit-booleaness-not-comparison-to-zero, - consider-using-f-string, missing-function-docstring, + consider-using-f-string, fixme, # Enable the message, report, category or checker with the given id(s). You can @@ -474,6 +478,11 @@ max-nested-blocks=5 # printed. never-returning-functions=sys.exit,argparse.parse_error +# Let 'consider-using-join' be raised when the separator to join on would be +# non-empty (resulting in expected fixes of the type: ``"- " + " - +# ".join(items)``) +suggest-join-with-non-empty-separator=yes + [REPORTS] @@ -488,10 +497,10 @@ evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor # used to format the message information. See doc for all details. msg-template= -# Set the output format. Available formats are: text, parseable, colorized, -# json2 (improved json format), json (old json format) and msvs (visual -# studio). You can also give a reporter class, e.g. -# mypackage.mymodule.MyReporterClass. +# Set the output format. Available formats are: 'text', 'parseable', +# 'colorized', 'json2' (improved json format), 'json' (old json format), msvs +# (visual studio) and 'github' (GitHub actions). You can also give a reporter +# class, e.g. mypackage.mymodule.MyReporterClass. #output-format= # Tells whether to display a full report or only the messages. diff --git a/ChangeLog.rst b/ChangeLog.rst index ea95058d..d515986e 100644 --- a/ChangeLog.rst +++ b/ChangeLog.rst @@ -1,7 +1,14 @@ EXIF.py Change Log ################## -3.1.0 — 2023-05-?? +3.1.0 — 2025-04-25 + * Add more typing definitions (#181) + * Put all test files in the repo (#208) + * use pre-commit to run black, isort, pylint, mypy (#209) + * Canon MakerNote: Allow callable to process tag value (#189) by Daan van Gorkum + * Added missing HEIC box names and handling of a TIFF header inside HEIC (#173) by Antti Ketola + * don't let debug logging trigger an exception (#196) by David Bonner + * fix for certain box names not handled, but skipping would generate valid output by Anand Mahesh * Add DJI makernotes, extract_thumbnail parameter (#168) by Piero Toffanin * Fix endianess bug while reading DJI makernotes, add Make tag (#169) by Piero Toffanin * Make CI pass (#178) by Nick Dimitroff diff --git a/LICENSE.txt b/LICENSE.txt deleted file mode 100644 index 7eb9d177..00000000 --- a/LICENSE.txt +++ /dev/null @@ -1,31 +0,0 @@ - -Copyright (c) 2002-2007 Gene Cash -Copyright (c) 2007-2023 Ianaré Sévi and contributors - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions -are met: - - 1. Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - - 2. Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following - disclaimer in the documentation and/or other materials provided - with the distribution. - - 3. Neither the name of the authors nor the names of its contributors - may be used to endorse or promote products derived from this - software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile index f4301bb2..15f5537b 100644 --- a/Makefile +++ b/Makefile @@ -37,8 +37,11 @@ test-diff: ## Run and compare exif dump analyze: ## Run all static analysis tools $(PRE_COMMIT_BIN) run --all -reqs-install: ## Install with all requirements - $(PIP_INSTALL) .[dev] +install-dev: ## Install with all development requirements + $(PIP_INSTALL) -U -e .[dev] + +install: ## Install with basic requirements + $(PIP_INSTALL) -U -e . build: ## build distribution rm -fr ./dist diff --git a/README.rst b/README.rst index 0761f267..d62a3816 100644 --- a/README.rst +++ b/README.rst @@ -4,6 +4,8 @@ EXIF.py Easy to use Python module to extract Exif metadata from digital image files. +Pure Python, lightweight, no dependencies. + Supported formats: TIFF, JPEG, PNG, Webp, HEIC @@ -12,10 +14,6 @@ Compatibility EXIF.py is tested and officially supported on Python 3.7 to 3.13 -Starting with version ``3.0.0``, Python2 compatibility is dropped *completely* (syntax errors due to type hinting). - -https://pythonclock.org/ - Installation ************ @@ -36,9 +34,9 @@ Development Version After cloning the repo, use the provided Makefile:: - make venv reqs-install + make venv install-dev -Which will install a virtual environment and install development dependencies. +Which will create a virtual environment and install development dependencies. Usage ***** @@ -163,7 +161,8 @@ Pass the ``-s`` or ``--strict`` argument, or as: Built-in Types ============== -For easier serialization and programmatic use, this option returns a dictionary with values in built-in Python types (int, float, str, bytes, list, None) instead of `IfdTag` objects. +For easier serialization and programmatic use, this option returns a dictionary with values in built-in Python types +(int, float, str, bytes, list, None) instead of `IfdTag` objects. Pass the ``-b`` or ``--builtin`` argument, or as: @@ -226,7 +225,7 @@ License Copyright © 2002-2007 Gene Cash -Copyright © 2007-2023 Ianaré Sévi and contributors +Copyright © 2007-2025 Ianaré Sévi and contributors A **huge** thanks to all the contributors over the years! diff --git a/exifread/__init__.py b/exifread/__init__.py index c0adc162..8a192ce6 100644 --- a/exifread/__init__.py +++ b/exifread/__init__.py @@ -4,7 +4,7 @@ """ import struct -from typing import BinaryIO, Dict, Tuple +from typing import Any, BinaryIO, Dict, Tuple from exifread.classes import ExifHeader from exifread.exceptions import ExifNotFound, InvalidExif @@ -37,8 +37,13 @@ def _find_tiff_exif(fh: BinaryIO) -> Tuple[int, bytes]: return offset, endian -def _find_heic_tiff(fh: BinaryIO) -> tuple: - """In some HEIC files, the Exif offset is 0 and there is a plain TIFF header near end of the file.""" +def _find_heic_tiff(fh: BinaryIO) -> Tuple[int, bytes]: + """ + Look for TIFF header in HEIC files. + + In some HEIC files, the Exif offset is 0, + and yet there is a plain TIFF header near end of the file. + """ data = fh.read(4) if data[0:2] in [b"II", b"MM"] and data[2] == 42 and data[3] == 0: @@ -126,7 +131,7 @@ def _get_xmp(fh: BinaryIO) -> bytes: return xmp_bytes -def _determine_type(fh: BinaryIO) -> tuple: +def _determine_type(fh: BinaryIO) -> Tuple[int, bytes, int]: # by default do not fake an EXIF beginning fake_exif = 0 @@ -163,7 +168,7 @@ def process_file( auto_seek=True, extract_thumbnail=True, builtin_types=False, -) -> dict: +) -> Dict[str, Any]: """ Process an image file (expects an open file object). @@ -175,7 +180,7 @@ def process_file( fh.seek(0) try: - offset, endian, fake_exif = _determine_type(fh) + offset, endian_bytes, fake_exif = _determine_type(fh) except ExifNotFound as err: logger.warning(err) return {} @@ -183,14 +188,14 @@ def process_file( logger.debug(err) return {} - endian = chr(ord_(endian[0])) + endian_str = chr(ord_(endian_bytes[0])) # deal with the EXIF info we found logger.debug( - "Endian format is %s (%s)", endian, ENDIAN_TYPES.get(endian, "Unknown") + "Endian format is %s (%s)", endian_str, ENDIAN_TYPES.get(endian_str, "Unknown") ) hdr = ExifHeader( - fh, endian, offset, fake_exif, strict, debug, details, truncate_tags + fh, endian_str, offset, fake_exif, strict, debug, details, truncate_tags ) ifd_list = hdr.list_ifd() thumb_ifd = 0 diff --git a/exifread/__main__.py b/exifread/__main__.py new file mode 100644 index 00000000..9c21ec33 --- /dev/null +++ b/exifread/__main__.py @@ -0,0 +1,6 @@ +"""Main entrypoint for the module.""" + +from exifread.cli import main + +if __name__ == "__main__": + main() diff --git a/exifread/classes.py b/exifread/classes.py index 82d66438..ab61059f 100644 --- a/exifread/classes.py +++ b/exifread/classes.py @@ -83,7 +83,7 @@ def __init__( file_handle: BinaryIO, endian: str, offset: int, - fake_exif, + fake_exif: int, strict: bool, debug=False, detailed=True, @@ -431,7 +431,9 @@ def extract_tiff_thumbnail(self, thumb_ifd: int) -> None: old_offset = self.s2n(entry + 8, 4) # start of the 4-byte pointer area in entry ptr = i * 12 + 18 - # remember strip offsets location + + # remember strip offsets + strip_len = 0 if tag == 0x0111: strip_off = ptr strip_len = count * type_length diff --git a/EXIF.py b/exifread/cli.py similarity index 96% rename from EXIF.py rename to exifread/cli.py index f232b23d..1f9ea5e7 100755 --- a/EXIF.py +++ b/exifread/cli.py @@ -6,7 +6,7 @@ # # # Copyright (c) 2002-2007 Gene Cash -# Copyright (c) 2007-2023 Ianaré Sévi and contributors +# Copyright (c) 2007-2025 Ianaré Sévi and contributors # # See LICENSE.txt file for licensing information # See ChangeLog.rst file for all contributors and changes @@ -91,7 +91,7 @@ def get_args() -> argparse.Namespace: return args -def main(args) -> None: +def run_cli(args: argparse.Namespace) -> None: """Extract tags based on options (args).""" exif_log.setup_logger(args.debug, args.color) @@ -159,5 +159,9 @@ def main(args) -> None: print() +def main() -> None: + run_cli(get_args()) + + if __name__ == "__main__": - main(get_args()) + main() diff --git a/exifread/jpeg.py b/exifread/jpeg.py index f53d464c..c4c538b4 100644 --- a/exifread/jpeg.py +++ b/exifread/jpeg.py @@ -1,6 +1,6 @@ """Extract EXIF from JPEG files.""" -from typing import BinaryIO +from typing import BinaryIO, Tuple from exifread.exceptions import InvalidExif from exifread.exif_log import get_logger @@ -13,7 +13,7 @@ def _increment_base(data, base) -> int: return ord_(data[base + 2]) * 256 + ord_(data[base + 3]) + 2 -def _get_initial_base(fh: BinaryIO, data, fake_exif) -> tuple: +def _get_initial_base(fh: BinaryIO, data: bytes, fake_exif: int) -> Tuple[int, int]: base = 2 logger.debug( "data[2]=0x%X data[3]=0x%X data[6:10]=%s", @@ -151,7 +151,7 @@ def _get_base(base: int, data: bytes) -> int: return base -def find_jpeg_exif(fh: BinaryIO, data: bytes, fake_exif) -> tuple: +def find_jpeg_exif(fh: BinaryIO, data: bytes, fake_exif: int) -> Tuple[int, bytes, int]: logger.debug( "JPEG format recognized data[0:2]=0x%X%X", ord_(data[0]), ord_(data[1]) ) diff --git a/exifread/py.typed b/exifread/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..a30af720 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,71 @@ +[build-system] +requires = ["setuptools >= 68.0.0", "wheel >= 0.41.0"] +build-backend = "setuptools.build_meta" + + +[project] +name = "ExifRead" +description = "Library to extract Exif information from digital camera image files." +readme = "README.rst" +license = {file = "LICENSE"} +requires-python = ">= 3.7" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: End Users/Desktop", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Utilities", +] +keywords = ["exif", "image", "metadata", "photo"] +authors = [ + {name = "Ianaré Sévi"}, +] +dependencies = [] +dynamic = ["version"] + + +[project.optional-dependencies] +dev = [ + "pre-commit~=2.21", + "pylint~=3.0", + "build~=1.0", +] + + +[project.urls] +Repository = "https://github.com/ianare/exif-py" +Changelog = "https://github.com/ianare/exif-py/blob/master/ChangeLog.rst" + + +[project.scripts] +"EXIF.py" = "exifread.cli:main" + + +[tool.setuptools.packages] +find = {} + + +[tool.setuptools.dynamic] +version = {attr = "exifread.__version__"} + + +[tool.setuptools.package-data] +"exifread" = ["py.typed"] + + +[tool.isort] +profile = "black" + + +[tool.mypy] +disallow_any_unimported = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +no_implicit_optional = true +strict_equality = true +warn_unused_ignores = true +warn_unreachable = true diff --git a/setup.py b/setup.py deleted file mode 100644 index f1ff1eb1..00000000 --- a/setup.py +++ /dev/null @@ -1,45 +0,0 @@ -"""EXIF.py setup""" - -from setuptools import find_packages, setup # pylint: disable=import-error - -import exifread - -with open("README.rst", "rt", encoding="utf-8") as fh: - readme_file = fh.read() - -dev_requirements = [ - "pre-commit==2.21.0", - "pylint==3.0.4", -] - -setup( - name="ExifRead", - version=exifread.__version__, - author="Ianaré Sévi", - author_email="ianare@gmail.com", - packages=find_packages(), - scripts=["EXIF.py"], - url="https://github.com/ianare/exif-py", - license="BSD", - keywords="exif image metadata photo", - description=" ".join(exifread.__doc__.splitlines()).strip(), - long_description=readme_file, - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Environment :: Console", - "Intended Audience :: Developers", - "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: BSD License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Topic :: Utilities", - ], - extras_require={ - "dev": dev_requirements, - }, -)