Spring Cleaning 2026 Day 5: exifmodern (converting exiftool to python)

Converting exiftool to Python exifmodern

We did it. I’ve been trying to do this on and off for five or six years now.

exiftool is one of the secret foundational parts of internet media infrastructure. In the same way every video hosting site is just infrastructure laundering for ffmpeg, every photo hosting or image processing site probably uses exiftool somewhere in the background to get everything working consistently with as much high quality data extraction as possible.

But there’s one shark in the room: exiftool is over 300,000 lines of perl and it’s not getting any younger.

I think I first used exiftool around 2004 or 2005 when I was creating personal photo album galleries online and I’ve used it on and off probably a couple of times every year since then.

My previous pattern for using exiftool from reasonbly modern projects is to wrap context managers around the exiftool stdin mode. Exiftool knows it’s a bit weird and incompatible with modern systems, so it has a runmode where it just starts, listens for commands (cli flags) on stdin, then replies to stdout, so you can “use” it from any system1.

exiftool is a marvel of software engineering because not only is exifwritten in perl, exiftool itself is defined by perl. exiftool can be thought of as almost a database more at times than a metadata processing library, because to figure out where and how to read and edit metadata, exiftool has decades of built-up collective knowledge as database-in-code structures for how to read and write binary metdata across hundreds of custom file formats and embeded nested file formats based on various dynamic runtime-dispatched conditions, all defined in-perl, not just “as structure,” but also defined with perl, as having meta-perl operators all in-line in well-defined database definitions.

that’s a long way of saying, exiftool often defines editing operations in terms of dynamically applying perl operations to data itself like:

    0x0097 => [ #4
        # (NOTE: these are byte-swapped by NX when byte order changes)
        {
            Condition => '$$valPt =~ /^0100/', # (D100 and Coolpix models)
            Name => 'ColorBalance0100',
            SubDirectory => {
                Start => '$valuePtr + 72',
                TagTable => 'Image::ExifTool::Nikon::ColorBalance1',
            },
        },
        {
            Condition => '$$valPt =~ /^0102/', # (D2H)
            Name => 'ColorBalance0102',
            SubDirectory => {
                Start => '$valuePtr + 10',
                TagTable => 'Image::ExifTool::Nikon::ColorBalance2',
            },
        },
        {
            Condition => '$$valPt =~ /^0103/', # (D70/D70s)
            Name => 'ColorBalance0103',
            # D70:  at file offset 'tag-value + base + 20', 4 16 bits numbers,
            # v[0]/v[1] , v[2]/v[3] are the red/blue multipliers.
            SubDirectory => {
                Start => '$valuePtr + 20',
                TagTable => 'Image::ExifTool::Nikon::ColorBalance3',
            },
        },
        {
            Condition => '$$valPt =~ /^0205/', # (D50)
            Name => 'ColorBalance0205',
            SubDirectory => {
                TagTable => 'Image::ExifTool::Nikon::ColorBalance2',
                ProcessProc => \&ProcessNikonEncrypted,
                WriteProc => \&ProcessNikonEncrypted,
                DecryptStart => 4,
                DirOffset => 14, # (start of directory relative to DecryptStart)
            },
        },
        {   # (D3/D3X/D300/D700=0209,D300S=0212,D3S=0214)
            Condition => '$$valPt =~ /^02(09|12|14)/',
            Name => 'ColorBalance0209',
            SubDirectory => {
                TagTable => 'Image::ExifTool::Nikon::ColorBalance4',
                ProcessProc => \&ProcessNikonEncrypted,
                WriteProc => \&ProcessNikonEncrypted,
                DecryptStart => 284,
                DirOffset => 10,
            },
        },
        {   # (D2X/D2Xs=0204,D2Hs=0206,D200=0207,D40/D40X/D80=0208,D60=0210)
            Condition => '$$valPt =~ /^02(\d{2})/ and $1 < 11',
            Name => 'ColorBalance02',
            SubDirectory => {
                TagTable => 'Image::ExifTool::Nikon::ColorBalance2',
                ProcessProc => \&ProcessNikonEncrypted,
                WriteProc => \&ProcessNikonEncrypted,
                DecryptStart => 284,
                DirOffset => 6,
            },
        },
   0xb0 => {
        Name => 'GPSVersionID',
        Format => 'int8u',
        Count => 4,
        Groups => { 1 => 'GPS', 2 => 'Location' },
        PrintConv => '$val =~ tr/ /./; $val',
    },
    0xb1 => {
        Name => 'GPSLatitudeRef',
        Format => 'string',
        Groups => { 1 => 'GPS', 2 => 'Location' },
        PrintConv => {
            N => 'North',
            S => 'South',
        },
    },
    0xb2 => {
        Name => 'GPSLatitude',
        Format => 'rational32u',
        Groups => { 1 => 'GPS', 2 => 'Location' },
        Notes => 'combined with tags 0xb3 and 0xb4',
        Combine => 2,   # combine the next 2 tags (0xb2=deg, 0xb3=min, 0xb4=sec)
        ValueConv => 'Image::ExifTool::GPS::ToDegrees($val)',
        PrintConv => 'Image::ExifTool::GPS::ToDMS($self, $val, 1)',
    },
    0xb5 => {
        Name => 'GPSLongitudeRef',
        Format => 'string',
        Groups => { 1 => 'GPS', 2 => 'Location' },
        PrintConv => {
            E => 'East',
            W => 'West',
        },
    },
    0xb6 => {
        Name => 'GPSLongitude',
        Format => 'rational32u',
        Groups => { 1 => 'GPS', 2 => 'Location' },
        Combine => 2,   # combine the next 2 tags (0xb6=deg, 0xb7=min, 0xb8=sec)
        Notes => 'combined with tags 0xb7 and 0xb8',
        ValueConv => 'Image::ExifTool::GPS::ToDegrees($val)',
        PrintConv => 'Image::ExifTool::GPS::ToDMS($self, $val, 1)',
    },

exiftool has over 200,000 similar as above lines of “database-in-code” containing fully combined “mini-perl-programs-and-references-in-database-in-code” structures.

(spoiler: one way i managed this conversion was by writing a tiny perl interperter in python to avoid rewriting all those custom perl closures and dynamic eval conditions)

You could imagine how all the nested-perl-eats-perl would be difficult to safely translate out into any other system.

except, I did.

I wanted more. I wanted a native exiftool I didn’t have to run through subprocesses. I wanted something where I didn’t have to time travel my brain back to living in camel land to play with extracting new file formats.

After five (or six) years, I finally found a workflow where I could iterate through all 300,000 lines of exiftool source and convert it to modern fully typed reusable python patterns.

Say hello to exifmodern: https://github.com/mattsta/exifmodern

exifmodern

exifmodern is output-compatible with exiftool (reasonably) and has a native python API so you can use the metadata reading and writing surfaces without calling out to any subprocesses, but exifmodern still supports “stdin command mode” as well (or even running as a local tcp server or unix socket server).

exifmodern is at the same time both faster and slower than exiftool.

exifmodern is faster if the python process remains running over multiple requests but exifmodern is slower for per-cli startup commands ONLY due to the slow python package import system.

Benchmarks

Cold CLI Subprocesses

These benchmarks run the CLI app once to process a file then exit.

For exifmodern these times are dominated by python transitive package import problems in various places. I spent a week manually moving imports from tops of files down into deeper and deeper method definitions so things often only get imported as needed on-demand but I didn’t clear all of them up. Hopefully the Python 3.15 lazy import system will make playing hide-the-import obsolete.

But the next set of benchmarks shows how a persistently running or re-processing-looping-API of exifmodern is faster than exiftool in almost all operations.

Representative Cold Examples By File Type

This section is selected from the measured cold-read rows, grouped by file extension, and capped at two highest-impact rows per file type. Impact is ranked by ExifModern-vs-ExifTool median delta.

Long-Running Transports

Here are the magic tests showing exifmodern is faster at data processing than exiftool in long-running repeated processes (where we don’t pay the “import the universe” python startup overhead problem so much).

Python API

ExifModern exposes a Python-native API so applications can read metadata without spawning a subprocess or parsing ExifTool-style stdout.

The ergonomic top-level API is file-oriented and lazy:

from pathlib import Path

import exifmodern

image = exifmodern.open_file(Path("image.jpg"))

print(image.status)
print(image.value("FileType"))
print(image.require("ImageWidth"))
print(image.text("CreateDate"))
print(image.diagnostics)

Batch reads return structured public API results:

import exifmodern

result = exifmodern.read_files(("first.jpg", "second.jpg"),
                                tags=("ImageWidth", "ImageHeight"))

for record in result.records:
    print(record.path, record.values)

In-memory image bytes can be read without creating a user-managed file:

import exifmodern

with open("image.jpg", "rb") as file:
    image = exifmodern.from_bytes(file.read(), suffix=".jpg")

print(image.value("FileType"))
print(image.value("ImageWidth"))

ExifTool-style read arguments can also be parsed into structured Python results:

import exifmodern

result = exifmodern.read_args(("-G", "-s", "-ImageWidth", "image.jpg"))
print(result.records[0].values)

Immediate file-backed writes use the same structured native writer as the CLI. They execute when called, so pass an explicit output_path when you want a new file:

import exifmodern

result = exifmodern.set_tags(
    "image.jpg",
    {
        "EXIF:Artist": "Example Artist",
        "XMP-dc:Title": "Example title",
    },
    output_path="edited.jpg",
)

if result.status != "ok":
    for diagnostic in result.diagnostics:
        print(diagnostic.code, diagnostic.message)

Delete helpers expose ExifTool-style tag/group delete requests:

import exifmodern

exifmodern.delete_tags("image.jpg", "GPS:All", output_path="without-gps.jpg")
exifmodern.open_file("image.jpg").remove_gps(output_path="without-gps.jpg")

For staged edits, use image.edit(). Edit methods accumulate operations only; no bytes are written until save() or save_in_place():

import exifmodern

image = exifmodern.open_file("image.jpg")

edit = (
    image.edit()
    .remove_gps()
    .set("EXIF:Artist", "Example Artist")
    .set("XMP-dc:Title", "Example title")
)

# Safe explicit-output save.
result = edit.save(output_path="edited.jpg")

# Destructive source-file overwrite is explicit.
in_place_result = edit.save_in_place()

Unsupported write routes return structured diagnostics rather than pretending to succeed.

The lower-level typed request/result API is available from exifmodern.public_api:

from pathlib import Path

from exifmodern.public_api import MetadataReadRequest, OutputRenderRequest, read_metadata

result = read_metadata(
    MetadataReadRequest(
        paths=(Path("image.jpg"),),
        tags=("File:ImageWidth", "Composite:ImageSize"),
        render=OutputRenderRequest(include_group_names=True, format="json"),
    )
)
print(result.rendered_text)

Command Line

Read metadata:

exifmodern read image.jpg
exifmodern read --format json image.jpg
exifmodern read -G -a -s image.jpg

Inspect container structure:

exifmodern inspect video.mp4

Plan or execute supported metadata writes:

exifmodern write --set XMP:Title="Example title" image.jpg
exifmodern write --delete XMP:Title image.jpg
exifmodern write --set XMP:Title="Example title" -o edited.jpg image.jpg

Query bundled metadata catalogs:

exifmodern capabilities
exifmodern tag-lookup --tag Make
exifmodern listgeo --json

For repeated command-style calls from non-Python programs, run an explicit local ExifModern backend once and send each command through the lightweight exifc client. This preserves normal exifmodern CLI semantics while avoiding Python startup for every request:

# Terminal 1: start the Python backend on loopback.
exifmodern server --host 127.0.0.1 --port 8765

# Terminal 2: send CLI arguments through the already-running backend.
exifc --host 127.0.0.1 --port 8765 -- read --format json image.jpg
exifc --host 127.0.0.1 --port 8765 -- -ver

The server speaks bounded newline-delimited JSON over localhost and returns the same stdout, stderr, and exit-code shape as the public CLI. The Rust client is intentionally thin: it connects, sends one request, prints the response streams, and exits with the backend’s exit code.

For full option details:

exifmodern read --help
exifmodern write --help

Access

Due to the effort, understanding, and just creativity involved in this translation, I’ve split the source into two parts: public and private.

The public source is what you can see and use for personal use at https://github.com/mattsta/exifmodern.

exifmodern public repo code looks like:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Language              Files        Lines         Code     Comments       Blanks
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 JSON                     24           24           24            0            0
 Python                  853       417255       376862          242        40151
 Rust                      1          206          187            0           19
 TOML                      2           40           37            0            3
─────────────────────────────────────────────────────────────────────────────────
 Markdown                  9         3118            0         2249          869
 |- JSON                   2           29           29            0            0
 |- Python                 5          746          595            6          145
 (Total)                             3893          624         2255         1014
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Total                   889       421418       377734         2497        41187
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

The private source is the entire development workflow, architecture docs, conversion tools from exiftool, validation hooks, development project planning documentation, code quality infrastructure, and testing interfaces.

exifmodern private repo code looks like:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Language              Files        Lines         Code     Comments       Blanks
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 D                        33          566          480            0           86
 JSON                    433      4383373      4383373            0            0
 Perl                     10         1484         1331           10          143
 Python                 2178       716087       637015         1793        77279
 Rust                      1          206          187            0           19
 Shell                    61         5181         4437           61          683
 Plain Text                1            1            0            1            0
 TOML                      3          144          132            0           12
─────────────────────────────────────────────────────────────────────────────────
 Markdown                 79        61049            0        51578         9471
 |- BASH                   1            3            3            0            0
 |- JSON                   5           64           64            0            0
 |- Perl                   2           22           22            0            0
 |- Python                13          998          816           13          169
 (Total)                            62136          905        51591         9640
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Total                  2799      5169178      5027860        53456        87862
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

If you’d like the private repo or you’d like to pay to release the private repo publicly, you know where to find me and half of all procedes will be returned to Phil Harvey the creator and nigh-on 25-year maintainer of exiftool itself.

Also, this is not a replacement for exiftool because this current implementation of exifmodern doesn’t maintain the decades of detailed code comments about structure and reason and purpose and theory of all the thousands of custom file formats and fields handled piece by piece. Those could be migrated back into exifmodern for posterity, but we currently still rely on exiftool as the oracle source of truth for all correctness and details about edge cases encountered in the wild. But who knows what the future holds?

This is the first version of exifmodern but it is not the last version. I’m not entirely happy with the current code organization or structure as it lacks the elegance and usability-for-purpose structure of original exiftool as a whole. I can think of ways to refactor exifmodern so it supports the more elegant exiftool data format descriptions while maintianing cross-programming-language feature parity and performance advantages (it turns out, while json-slopifying all interfaces between systems for quick translation is fun, it doesn’t really make for good usability or stable ongoing development practices). Maybe later in the year again I can revisit for another rearchitecture towards global consistency between platforms for better understandability over time too (if anybody cares).

Conclusion

be good. release software. pay for software. stay tuned for more.


  1. #!/usr/bin/env python3
    
    """Small wrapper around exiftool's ``-stay_open`` pipe/server mode.
    
    exiftool can be run as a long-lived process that reads batched argument lists
    on stdin and prints each batch's output followed by a ``{ready}`` sentinel
    line. That avoids paying Perl startup per file, so it is the recommended way
    to drive exiftool from another program.
    
    This file is a single self-contained reference implementation of that
    protocol with Python-native ergonomics:
    
    * :class:`ExifTool` is a dataclass — pass an explicit ``executable`` path or
      let ``__post_init__`` discover one. Used as a context manager to spawn
      the underlying ``exiftool -stay_open True -@ -`` process.
    * The original camelCase methods (``exifNumeric``, ``stripGPS``, ...) are
      preserved unchanged for existing callers.
    * :meth:`read` and :meth:`strip` are the modern entry points: ``read``
      returns parsed JSON records (``list[dict]``), ``strip`` unifies the two
      strip helpers.
    """
    
    from __future__ import annotations
    
    import json
    import os
    import subprocess
    from dataclasses import dataclass, field
    from pathlib import Path
    from typing import Any, ClassVar, Iterable, Sequence
    
    # Usage:
    # with ExifTool() as e:
    #    metadata = e.exifNumeric(*filenames)
    
    @dataclass
    class ExifTool:
        """Driver for an ``exiftool -stay_open`` subprocess.
    
        The instance carries both configuration (``executable``, ``timeout``) and,
        while inside a ``with`` block, the running :class:`subprocess.Popen`
        handle. Outside a ``with`` block ``process`` is ``None`` and the high-
        level methods will raise.
        """
    
        executable: str | None = "/usr/bin/exiftool"
        timeout: float = 5.0
        process: subprocess.Popen[bytes] | None = field(
            default=None, init=False, repr=False, compare=False
        )
    
        # Bytes, not str: stdout is read in binary mode so the sentinel scan
        # cannot be defeated by TextIOWrapper buffering between us and the pipe.
        sentinel: bytes = field(default=b"{ready}\n", repr=False)
    
        def __post_init__(self) -> None:
            """Init with executable, but if executable not found at default
            location, attempt to use exiftool from source package.
    
            Also note: our included exiftool version is patched for improved
            string output, so using system exiftool will show some numbers
            somewhat differently."""
            if not self.executable or not Path(self.executable).exists():
                executable: str | None = None
                current_dir = Path(__file__).resolve().parent
                others = [
                    current_dir / "exiftool" / "exiftool",
                    Path("/usr/local/bin/exiftool"),
                    Path("/opt/homebrew/bin/exiftool"),
                ]
                for other in others:
                    if other.exists():
                        executable = str(other)
                        break
    
                if not executable:
                    raise FileNotFoundError("exiftool executable not found")
    
                self.executable = executable
    
        # ---- context manager --------------------------------------------------
    
        def __enter__(self) -> ExifTool:
            """Launch exiftool in pipe reader command accepting mode"""
            # Binary pipes throughout. The original wrapper opened the pipes in
            # text mode and then read stdout via ``os.read`` on the raw fd, which
            # is a layering bug: the TextIOWrapper can buffer bytes the raw read
            # never sees. Staying in bytes is simpler and correct.
            self.process = subprocess.Popen(
                [self.executable, "-stay_open", "True", "-@", "-"],
                stdin=subprocess.PIPE,
                stdout=subprocess.PIPE,
            )
            return self
    
        def __exit__(self, exc_type, exc_value, traceback) -> None:  # type: ignore[no-untyped-def]
            """Send exiftool close pipe (thus, exit) command"""
            proc = self.process
            self.process = None
            if proc is None or proc.stdin is None:
                return
            try:
                proc.stdin.write(b"-stay_open\nFalse\n")
                proc.stdin.flush()
                proc.stdin.close()
            except (BrokenPipeError, ValueError):
                pass
            # Reap the child so we don't leak a zombie. The original __exit__
            # only flushed stdin and returned, which sometimes left exiftool
            # alive past the end of the with-block.
            try:
                proc.wait(timeout=self.timeout)
            except subprocess.TimeoutExpired:
                proc.terminate()
                try:
                    proc.wait(timeout=2.0)
                except subprocess.TimeoutExpired:
                    proc.kill()
                    proc.wait()
    
        # ---- introspection ----------------------------------------------------
    
        @property
        def version(self) -> str:
            """Return ``exiftool -ver`` output. One-shot subprocess; does not
            require an active session."""
            result = subprocess.run(
                [str(self.executable), "-ver"],
                check=False,
                capture_output=True,
                text=True,
            )
            return result.stdout.strip() or "unknown"
    
        # ---- pipe protocol ----------------------------------------------------
    
        def execute(self, *args: str) -> bytes:
            if not self.process or not self.process.stdin or not self.process.stdout:
                raise RuntimeError(
                    "ExifTool process not started. Use as a context manager."
                )
            payload = "\n".join(str(a) for a in args) + "\n-execute\n"
            self.process.stdin.write(payload.encode("utf-8"))
            self.process.stdin.flush()
    
            buf = bytearray()
            fd: int = self.process.stdout.fileno()
            while True:
                idx = buf.find(self.sentinel)
                if idx >= 0:
                    return bytes(buf[:idx])
                chunk = os.read(fd, 4096)
                if not chunk:
                    raise RuntimeError(
                        "exiftool pipe closed before sentinel was seen"
                    )
                buf.extend(chunk)
    
        # ---- flag reference ---------------------------------------------------
        # -r means recurse into any directories given as input
        # -g means nest each tag in an owner's group
        # (-G means prefix each tag as GROUP:Tag instead of nesting)
        # e.g: instead of GPSPosition, the key is Composite:GPSPosition because
        #      the value is synthensized by exiftool itself
        # -a means allow repeated tags in multiple groups
        # -j means json output, please
        # -n means numeric output, don't convert to values/strings
        # -c "%+.9f" means always convert GPS coords to float degrees of:
        #    - -121.898169444
        #    instead of the default exiftool string desc format:
        #    - 121 deg 53' 53.41" W
        # -fast1 means only read first metadata and don't scan through to end of file
    
        DEFAULT_READ_FLAGS: ClassVar[tuple[str, ...]] = (
            "-g",
            "-a",
            "-c",
            "%+.9f",
            "-j",
            "-fast1",
        )
    
        # ---- original API (preserved) -----------------------------------------
    
        def exif(self, extraArgs: Iterable[str] | None = None, *paths: str) -> bytes:
            safeExtraArgs = list(extraArgs) if extraArgs else []
            args = [*self.DEFAULT_READ_FLAGS, *safeExtraArgs, *paths]
            return self.execute(*args)
    
        def exifHuman(self, *paths: str) -> bytes:
            """Return EXIF output in Human Readable mode from one or more paths"""
            return self.exif(["-r"], *paths)
    
        def exifNumeric(self, *paths: str) -> bytes:
            """Return EXIF output in Numeric mode from one or more paths"""
            return self.exif(["-r", "-n"], *paths)
    
        def exifHumanNoRecurse(self, *paths: str) -> bytes:
            """Return EXIF output in Human Readable mode from one or more paths"""
            return self.exif([], *paths)
    
        def exifNumericNoRecurse(self, *paths: str) -> bytes:
            """Return EXIF output in Numeric mode from one or more paths"""
            return self.exif(["-n"], *paths)
    
        def stripGPS(self, *filenames: str) -> bool:
            self.execute("-gps:all=", "-j", *filenames)
            return True
    
        def stripTags(self, tags: list[str], *filenames: str) -> bool:
            genTags = []
            for tag in tags:
                # Specifying an argument of -[TAG]= deletes the tag
                genTags.append(f"-{tag}=")
    
            self.execute(*genTags, *filenames)
            return True
    
        # ---- modern API -------------------------------------------------------
    
        def read(
            self,
            *paths: str | Path,
            recurse: bool = True,
            numeric: bool = False,
            extra: Sequence[str] = (),
        ) -> list[dict[str, Any]]:
            """Read metadata from ``paths`` and return parsed JSON records.
    
            Each record is the dict exiftool emits in ``-j -g`` form: a
            ``SourceFile`` key plus per-group dicts (``EXIF``, ``Composite``,
            ``QuickTime``, ...). Returns ``[]`` for empty input or empty result.
            """
            if not paths:
                return []
            flags: list[str] = []
            if recurse:
                flags.append("-r")
            if numeric:
                flags.append("-n")
            flags.extend(extra)
            raw = self.exif(flags, *(str(p) for p in paths))
            text = raw.decode("utf-8")
            return json.loads(text) if text.strip() else []
    
        def strip(
            self,
            *paths: str | Path,
            tags: Sequence[str] | str = "gps:all",
        ) -> None:
            """Strip ``tags`` from ``paths``. Default removes all GPS tags."""
            if not paths:
                return
            tag_list = [tags] if isinstance(tags, str) else list(tags)
            # Specifying an argument of -[TAG]= deletes the tag
            args = [f"-{t}=" for t in tag_list]
            args.extend(str(p) for p in paths)
            self.execute(*args)
    
    if __name__ == "__main__":
        import sys
    
        if len(sys.argv) < 2:
            print(f"{sys.argv[0]}: file1 file2 ... fileN")
            sys.exit(1)
    
        inPaths = sys.argv[1:]
    
        with ExifTool() as e:
            # ALSO check for UTC timestamp if exists and use instead of non-tz timestamp
            # -> Composite.GPSDateTime
            print(json.dumps(e.read(*inPaths), indent=2))
    ↩︎