mee/mee.py

242 lines
7.9 KiB
Python
Raw Permalink Normal View History

2023-04-21 21:59:06 +03:00
"""Minimalistic EXIF Editor.
Commands overview:
ls, get, set, del operate with EXIF tags.
cget, cset, cdel operate with JSON in UserComment EXIF tag.
Usage:
2023-04-22 04:42:41 +03:00
mee ls [options] <file>
mee get [options] [-k <key>] <file>
mee set [options] -k <key> -v <value> <file> [-o <file>]
mee del [options] (--all | -k <key>) <file> [-o <file>]
mee cget [options] [-k <key>] <file>
mee cset [options] -k <key> -v <value> <file> [-o <file>]
mee cdel [options] -k <key> <file> [-o <file>]
2023-04-21 21:59:06 +03:00
mee (--help | --version)
Options:
-o, --output <file> output file. Same as input file by default.
-k, --key <key> set EXIF/JSON key.
-v, --value <value> EXIF/JSON key value.
2023-04-22 04:42:41 +03:00
-c, --custom make 'get -c' alias for 'cget', etc.
--json print JSON output if available.
--all perform action with all EXIF tags.
--debug enable debug messages.
2023-04-21 21:59:06 +03:00
--help print this message and exit.
--version print version and exit.
2023-04-22 04:42:41 +03:00
Environment:
DEBUG enable debug messages.
2023-04-21 21:59:06 +03:00
"""
2023-04-22 04:56:37 +03:00
__version__ = "0.2.1"
2023-04-21 21:59:06 +03:00
2023-04-22 04:42:41 +03:00
import os
2023-04-21 21:59:06 +03:00
import sys
import json
2023-04-22 04:42:41 +03:00
import logging
2023-04-21 21:59:06 +03:00
from tempfile import NamedTemporaryFile
2023-04-22 04:42:41 +03:00
from typing import Any, Optional
2023-04-21 21:59:06 +03:00
from pathlib import Path
from exif import Image
2023-04-22 04:42:41 +03:00
from docopt import docopt
2023-04-21 21:59:06 +03:00
2023-04-22 04:42:41 +03:00
class Mee:
"""
Minimalistic EXIF Editor class. Custom tags implemented as JSON string
written in Exif.Photo.UserComment tag.
2023-04-21 21:59:06 +03:00
2023-04-22 04:42:41 +03:00
Properties:
infile: `Path` Path to input file.
storage: `str` EXIF tag name used as custom tags storage.
image: `exif.Image` exif.Image object.
tags: `dict` Custom tags.
2023-04-21 21:59:06 +03:00
2023-04-22 04:42:41 +03:00
Methods:
get() Get custom tag value or all tags.
set() Set new custom tag or modify existing.
delete() Remove custom tag.
commit() Write changs into file on disk.
2023-04-21 21:59:06 +03:00
2023-04-22 04:42:41 +03:00
You can directly use `exif.Image` methods::
2023-04-21 21:59:06 +03:00
2023-04-22 04:42:41 +03:00
mee = Mee("image.jpg")
mee.image.get_all() # get all EXIF tags
2023-04-21 21:59:06 +03:00
2023-04-22 04:42:41 +03:00
See also:
* https://gitlab.com/TNThieding/exif/-/blob/master/src/exif/_image.py
* https://exif.readthedocs.io/en/latest/usage.html
* https://exiv2.org/doc/exifcomment_8cpp-example.html
"""
2023-04-21 21:59:06 +03:00
2023-04-22 04:42:41 +03:00
def __init__(self, infile: Path):
self.infile = infile
self.storage = "user_comment"
self.logger = logging.getLogger("Mee")
2023-04-21 21:59:06 +03:00
2023-04-22 04:42:41 +03:00
with open(infile, "rb") as in_img:
self.image = Image(in_img)
2023-04-21 21:59:06 +03:00
try:
2023-04-22 04:42:41 +03:00
tags = self.exif_get(self.storage) or "{}"
self.logger.debug("Raw custom tags: %s", tags)
if tags:
self.tags = json.loads(tags)
try:
# See WARNING in commit() method comments.
del self.tags["_"]
except KeyError:
pass
self.logger.debug("Got self.tags from JSON: %s", self.tags)
except (KeyError, json.JSONDecodeError) as error:
self.tags = {}
self.logger.debug(
"Set empty dict self.tags on error: %s", self.tags, error
)
def exif_get(self, key: str) -> str:
"""Return EXIF tag value."""
self.logger.debug("Call exif_get(): key=%s", key)
try:
return self.image.get(key, None)
except KeyError:
return None
def exif_set(self, key: str, value: Any) -> None:
"""Set EXIF tag. Wraps `exif.Image.set()`."""
self.logger.debug("Call exif_set(): key=%s, value=%s", key, value)
self.image.set(key, value)
def exif_del(self, key: str) -> None:
"""Delete EXIF tag. Wraps `exif.Image.delete()`."""
self.logger.debug("Call exif_del(): key=%s", key)
try:
self.image.delete(key)
except (KeyError, AttributeError) as error:
self.logger.debug("Pass on error in exif_del(): %s", error)
def get(self, key: str = None) -> Any:
"""Return custom tag value or all custom tags."""
if key:
try:
return self.tags[key]
except KeyError:
return None
return self.tags
def set(self, key: str, value: Any) -> None:
"""Set new or edit existing custom tag key:value pair."""
self.logger.debug("Call set(): key=%s, value=%s", key, value)
if not self.tags:
self.tags = {}
self.tags[key] = value
def delete(self, key: str) -> None:
"""Remove custom tag."""
self.logger.debug("Call delete(): key=%s", key)
if self.tags:
try:
self.logger.debug("Call delete(): delete key")
del self.tags[key]
except KeyError:
self.logger.debug("Pass on KeyError in delete()")
def write_out(self, outfile: Optional[Path] = None) -> None:
"""Just write data to `outfile` or overwrite `infile`."""
if not outfile:
with NamedTemporaryFile() as out_tmp, open(
self.infile, "wb"
) as out_img:
out_tmp.write(self.image.get_file())
out_tmp.seek(0)
out_img.write(out_tmp.read())
else:
with open(outfile, "wb") as out_img:
out_img.write(self.image.get_file())
def commit(self, outfile: Optional[Path] = None) -> None:
"""Safely write changes to file."""
self.logger.debug("Call commit(): outfile=%s", outfile)
# Remove old tag from EXIF.
# You cannot replace the data if it is longer than it was originally.
# Therefore, old data from the tag is always preliminarily removed.
self.exif_del(self.storage)
if not self.tags:
# !!! WARNING !!!
# If you write empty JSON to EXIF, then EXIF will be considered
# corrupted! You'll get a warning in exiftool, and you won't be
# able to do anything with the metadata. Here I add a placeholder
# key so that valid JSON is always written to UserComment tag.
self.tags = {"_": None}
string = json.dumps(self.tags, separators=(",", ":"))
self.logger.debug("Commit: self.tags: %s", self.tags)
self.logger.debug("Commit: Data to write: %s", string)
self.exif_set(self.storage, string)
# Write data to file
self.write_out(outfile)
2023-04-21 21:59:06 +03:00
def cli() -> None:
args = docopt(__doc__, version=__version__)
key = args["--key"]
value = args["--value"]
2023-04-22 04:42:41 +03:00
infile = args["<file>"]
outfile = args["--output"]
2023-04-21 21:59:06 +03:00
2023-04-22 04:42:41 +03:00
if args["--debug"] or os.getenv("DEBUG"):
logging.basicConfig(level=logging.DEBUG)
2023-04-21 21:59:06 +03:00
2023-04-22 04:42:41 +03:00
mee = Mee(infile)
2023-04-21 21:59:06 +03:00
2023-04-22 04:42:41 +03:00
if args["ls"]:
print(mee.image.list_all())
2023-04-21 21:59:06 +03:00
2023-04-22 04:42:41 +03:00
if args["get"] and not args["--custom"]:
if not key:
tags = mee.image.get_all()
if args['--json']:
print(json.dumps(tags, indent=4))
else:
print(tags)
else:
tag = mee.exif_get(key)
if args['--json']:
print(json.dumps(tag, indent=4))
else:
print(tag)
if args["set"] and not args["--custom"]:
2023-04-22 05:01:34 +03:00
mee.exif_del(mee.storage)
2023-05-04 21:53:10 +03:00
mee.exif_set(key, value)
2023-04-22 04:55:53 +03:00
mee.write_out(outfile)
2023-04-22 04:42:41 +03:00
if args["del"] and not args["--custom"]:
if args["--all"]:
mee.image.delete_all()
else:
mee.image.delete(key)
mee.write_out(outfile)
2023-04-21 21:59:06 +03:00
2023-04-22 04:42:41 +03:00
if args["cget"] or (args["get"] and args["--custom"]):
if not key:
print(json.dumps(mee.tags, indent=4))
else:
try:
print(mee.tags[key])
except KeyError:
sys.exit(1)
if args["cset"] or (args["set"] and args["--custom"]):
mee.set(key, value)
mee.commit(outfile)
if args["cdel"] or args["del"] and args["--custom"]:
mee.delete(key)
mee.commit(outfile)
2023-04-21 21:59:06 +03:00
if __name__ == "__main__":
cli()