Refactor update osxmetadata (#804)

* Updated osxmetadata to use v1.0.0

* Added README_DEV

* fix for missing detected_text xattr

* fix for missing detected_text xattr
This commit is contained in:
Rhet Turnbull 2022-10-15 22:12:11 -07:00 committed by GitHub
parent 0ba8bc3eb9
commit 5665cf1804
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 142 additions and 69 deletions

View File

@ -2204,9 +2204,9 @@ Attributes:
### <a name="textdetection">Text Detection</a> ### <a name="textdetection">Text Detection</a>
The [PhotoInfo.detected_text()](#detected_text_method) and the `{detected_text}` template will perform text detection on the photos in your library. Text detection is a slow process so to avoid unnecessary re-processing of photos, osxphotos will cache the results of the text detection process as an extended attribute on the photo image file. Extended attributes do not modify the actual file. The extended attribute is named `osxphotos.metadata:detected_text` and can be viewed using the built-in [xattr](https://ss64.com/osx/xattr.html) command or my [osxmetadata](https://github.com/RhetTbull/osxmetadata) tool. If you want to remove the cached attribute, you can do so with osxmetadata as follows: The [PhotoInfo.detected_text()](#detected_text_method) and the `{detected_text}` template will perform text detection on the photos in your library. Text detection is a slow process so to avoid unnecessary re-processing of photos, osxphotos will cache the results of the text detection process as an extended attribute on the photo image file. Extended attributes do not modify the actual file. The extended attribute is named `osxphotos.metadata:detected_text` and can be viewed using the built-in [xattr](https://ss64.com/osx/xattr.html) command or my [osxmetadata](https://github.com/RhetTbull/osxmetadata) tool. If you want to remove the cached attribute, you can do so with `xattr` as follows:
`osxmetadata --clear osxphotos.metadata:detected_text --walk ~/Pictures/Photos\ Library.photoslibrary/` `find ~/Pictures/Photos\ Library.photoslibrary | xargs -I{} xattr -c osxphotos.metadata:detected_text '{}'`
### Utility Functions ### Utility Functions

25
README_DEV.md Normal file
View File

@ -0,0 +1,25 @@
# Developer Notes for osxphotos
These are notes for developers working on osxphotos. They're mostly to help me remember how to do things in this repo but will be useful to anyone who wants to contribute to osxphotos.
## Installing osxphotos
- Clone the repo: `git clone git@github.com:RhetTbull/osxphotos.git`
- Create a virtual environment and activate it: `python3 -m venv venv` then `source venv/bin/activate`. I use [pyenv](https://github.com/pyenv/pyenv) with [pyenv-virtualenv](https://github.com/pyenv/pyenv-virtualenv) to manage my virtual environments
- Install the requirements: `pip install -r requirements.txt`
- Install the development requirements: `pip install -r requirements-dev.txt`
- Install osxphotos: `pip install -e .`
## Running tests
- Run all tests: `pytest`
See the [test README.md](tests/README.md) for more information on running tests.
## Building the package
- Run `./build.sh` to run the build script.
## Other Notes
[cogapp](https://nedbatchelder.com/code/cog/index.html) is used to update the README.md and other files. cog will be called from the build script as needed.

View File

@ -262,10 +262,9 @@ EXTENDED_ATTRIBUTE_NAMES = [
"description", "description",
"findercomment", "findercomment",
"headline", "headline",
"keywords",
"participants", "participants",
"projects", "projects",
"rating", "starrating",
"subject", "subject",
"title", "title",
"version", "version",

View File

@ -11,10 +11,16 @@ import shlex
import subprocess import subprocess
import sys import sys
import time import time
from typing import Iterable, List, Tuple from typing import Iterable, List, Optional, Tuple
import click import click
import osxmetadata from osxmetadata import (
MDITEM_ATTRIBUTE_DATA,
MDITEM_ATTRIBUTE_SHORT_NAMES,
OSXMetaData,
Tag,
)
from osxmetadata.constants import _TAGS_NAMES
import osxphotos import osxphotos
from osxphotos._constants import ( from osxphotos._constants import (
@ -86,7 +92,7 @@ from .common import (
from .help import ExportCommand, get_help_msg from .help import ExportCommand, get_help_msg
from .list import _list_libraries from .list import _list_libraries
from .param_types import ExportDBType, FunctionCall, TemplateString from .param_types import ExportDBType, FunctionCall, TemplateString
from .report_writer import report_writer_factory, ReportWriterNoOp from .report_writer import ReportWriterNoOp, report_writer_factory
from .rich_progress import rich_progress from .rich_progress import rich_progress
from .verbose import get_verbose_console, time_stamp, verbose_print from .verbose import get_verbose_console, time_stamp, verbose_print
@ -2683,9 +2689,9 @@ def write_finder_tags(
] ]
tags.extend(rendered_tags) tags.extend(rendered_tags)
tags = [osxmetadata.Tag(tag) for tag in set(tags)] tags = [Tag(tag, 0) for tag in set(tags)]
for f in files: for f in files:
md = osxmetadata.OSXMetaData(f) md = OSXMetaData(f)
if sorted(md.tags) != sorted(tags): if sorted(md.tags) != sorted(tags):
verbose_(f"Writing Finder tags to {f}") verbose_(f"Writing Finder tags to {f}")
md.tags = tags md.tags = tags
@ -2747,24 +2753,24 @@ def write_extended_attributes(
written = set() written = set()
skipped = set() skipped = set()
for f in files: for f in files:
md = osxmetadata.OSXMetaData(f) md = OSXMetaData(f)
for attr, value in attributes.items(): for attr, value in attributes.items():
islist = osxmetadata.ATTRIBUTES[attr].list attr_type = get_metadata_attribute_type(attr) or "str"
if value: if value:
value = ", ".join(value) if not islist else sorted(value) value = sorted(list(value)) if attr_type == "list" else ", ".join(value)
file_value = md.get_attribute(attr) file_value = md.get(attr)
if file_value and islist: if file_value and attr_type == "lists":
file_value = sorted(file_value) file_value = sorted(file_value)
if (not file_value and not value) or file_value == value: if (not file_value and not value) or file_value == value:
# if both not set or both equal, nothing to do # if both not set or both equal, nothing to do
# get_attribute returns None if not set and value will be [] if not set so can't directly compare # get returns None if not set and value will be [] if not set so can't directly compare
verbose_(f"Skipping extended attribute {attr} for {f}: nothing to do") verbose_(f"Skipping extended attribute {attr} for {f}: nothing to do")
skipped.add(f) skipped.add(f)
else: else:
verbose_(f"Writing extended attribute {attr} to {f}") verbose_(f"Writing extended attribute {attr} to {f}")
md.set_attribute(attr, value) md.set(attr, value)
written.add(f) written.add(f)
return list(written), [f for f in skipped if f not in written] return list(written), [f for f in skipped if f not in written]
@ -2841,3 +2847,23 @@ def render_and_validate_report(report: str, exiftool_path: str, export_dir: str)
sys.exit(1) sys.exit(1)
return report return report
def get_metadata_attribute_type(attr: str) -> Optional[str]:
"""Get the type of a metadata attribute
Args:
attr: attribute name
Returns:
type of attribute as string or None if type is not known
"""
if attr in MDITEM_ATTRIBUTE_SHORT_NAMES:
attr = MDITEM_ATTRIBUTE_SHORT_NAMES[attr]
return (
"list"
if attr in _TAGS_NAMES
else MDITEM_ATTRIBUTE_DATA[attr]["python_type"]
if attr in MDITEM_ATTRIBUTE_DATA
else None
)

View File

@ -5,7 +5,7 @@ import re
import typing as t import typing as t
import click import click
import osxmetadata from osxmetadata import MDITEM_ATTRIBUTE_DATA, MDITEM_ATTRIBUTE_SHORT_NAMES
from rich.console import Console from rich.console import Console
from rich.markdown import Markdown from rich.markdown import Markdown
@ -256,34 +256,46 @@ class ExportCommand(click.Command):
formatter.write_text( formatter.write_text(
""" """
Some options (currently '--finder-tag-template', '--finder-tag-keywords', '-xattr-template') write Some options (currently '--finder-tag-template', '--finder-tag-keywords', '-xattr-template') write
additional metadata to extended attributes in the file. These options will only work additional metadata accessible by Spotlight to facilitate searching.
if the destination filesystem supports extended attributes (most do).
For example, --finder-tag-keyword writes all keywords (including any specified by '--keyword-template' For example, --finder-tag-keyword writes all keywords (including any specified by '--keyword-template'
or other options) to Finder tags that are searchable in Spotlight using the syntax: 'tag:tagname'. or other options) to Finder tags that are searchable in Spotlight using the syntax: 'tag:tagname'.
For example, if you have images with keyword "Travel" then using '--finder-tag-keywords' you could quickly For example, if you have images with keyword "Travel" then using '--finder-tag-keywords' you could quickly
find those images in the Finder by typing 'tag:Travel' in the Spotlight search bar. find those images in the Finder by typing 'tag:Travel' in the Spotlight search bar.
Finder tags are written to the 'com.apple.metadata:_kMDItemUserTags' extended attribute. Finder tags are written to the 'com.apple.metadata:_kMDItemUserTags' extended attribute.
Unlike EXIF metadata, extended attributes do not modify the actual file. Most cloud storage services Unlike EXIF metadata, extended attributes do not modify the actual file;
do not synch extended attributes. Dropbox does sync them and any changes to a file's extended attributes the metadata is written to extended attributes associated with the file and the Spotlight metadata database.
Most cloud storage services do not synch extended attributes.
Dropbox does sync them and any changes to a file's extended attributes
will cause Dropbox to re-sync the files. will cause Dropbox to re-sync the files.
The following attributes may be used with '--xattr-template': The following attributes may be used with '--xattr-template':
""" """
) )
# build help text from all the attribute names
# passed to click.HelpFormatter.write_dl for formatting
attr_tuples = [ attr_tuples = [
( (
rich_text("[bold]Attribute[/bold]", width=formatter.width), rich_text("[bold]Attribute[/bold]", width=formatter.width),
rich_text("[bold]Description[/bold]", width=formatter.width), rich_text("[bold]Description[/bold]", width=formatter.width),
), )
*[
(
attr,
f"{osxmetadata.ATTRIBUTES[attr].help} ({osxmetadata.ATTRIBUTES[attr].constant})",
)
for attr in EXTENDED_ATTRIBUTE_NAMES
],
] ]
for attr_key in sorted(EXTENDED_ATTRIBUTE_NAMES):
# get short and long name
attr = MDITEM_ATTRIBUTE_SHORT_NAMES[attr_key]
short_name = MDITEM_ATTRIBUTE_DATA[attr]["short_name"]
long_name = MDITEM_ATTRIBUTE_DATA[attr]["name"]
constant = MDITEM_ATTRIBUTE_DATA[attr]["xattr_constant"]
# get help text
description = MDITEM_ATTRIBUTE_DATA[attr]["description"]
type_ = MDITEM_ATTRIBUTE_DATA[attr]["help_type"]
attr_help = f"{long_name}; {constant}; {description}; {type_}"
# add to list
attr_tuples.append((short_name, attr_help))
formatter.write_dl(attr_tuples) formatter.write_dl(attr_tuples)
formatter.write("\n") formatter.write("\n")
formatter.write_text( formatter.write_text(

View File

@ -1440,11 +1440,29 @@ class PhotoInfo:
return [] return []
md = OSXMetaData(path) md = OSXMetaData(path)
detected_text = md.get_attribute("osxphotos_detected_text") try:
def decoder(val):
"""Decode value from JSON"""
return json.loads(val.decode("utf-8"))
detected_text = md.get_xattr(
"osxphotos.metadata:detected_text", decode=decoder
)
except KeyError:
detected_text = None
if detected_text is None: if detected_text is None:
orientation = self.orientation or None orientation = self.orientation or None
detected_text = detect_text(path, orientation) detected_text = detect_text(path, orientation)
md.set_attribute("osxphotos_detected_text", detected_text)
def encoder(obj):
"""Encode value as JSON"""
val = json.dumps(obj)
return val.encode("utf-8")
md.set_xattr(
"osxphotos.metadata:detected_text", detected_text, encode=encoder
)
return detected_text return detected_text
@property @property

View File

@ -5,10 +5,10 @@ bitmath>=1.3.3.1,<1.4.0.0
bpylist2==4.0.1 bpylist2==4.0.1
more-itertools>=8.8.0,<9.0.0 more-itertools>=8.8.0,<9.0.0
objexplore>=1.6.3,<2.0.0 objexplore>=1.6.3,<2.0.0
osxmetadata>=0.99.34,<1.0.0 osxmetadata>=<1.0.0,<2.0.0
packaging>=21.3 packaging>=21.3
pathvalidate>=2.4.1,<2.5.0 pathvalidate>=2.4.1,<2.5.0
photoscript>=0.1.4,<0.2.0 photoscript>=0.1.6,<0.2.0
ptpython>=3.0.20,<3.1.0 ptpython>=3.0.20,<3.1.0
pyobjc-core>=7.3,<9.0 pyobjc-core>=7.3,<9.0
pyobjc-framework-AVFoundation>=7.3,<9.0 pyobjc-framework-AVFoundation>=7.3,<9.0

View File

@ -80,10 +80,10 @@ setup(
"bpylist2==4.0.1", "bpylist2==4.0.1",
"more-itertools>=8.8.0,<9.0.0", "more-itertools>=8.8.0,<9.0.0",
"objexplore>=1.6.3,<2.0.0", "objexplore>=1.6.3,<2.0.0",
"osxmetadata>=0.99.34,<1.0.0", "osxmetadata>=1.0.0,<2.0.0",
"packaging>=21.3", "packaging>=21.3",
"pathvalidate>=2.4.1,<3.0.0", "pathvalidate>=2.4.1,<3.0.0",
"photoscript>=0.1.4,<0.2.0", "photoscript>=0.1.6,<0.2.0",
"ptpython>=3.0.20,<4.0.0", "ptpython>=3.0.20,<4.0.0",
"pyobjc-core>=7.3,<9.0", "pyobjc-core>=7.3,<9.0",
"pyobjc-framework-AVFoundation>=7.3,<9.0", "pyobjc-framework-AVFoundation>=7.3,<9.0",

View File

@ -6514,7 +6514,7 @@ def test_export_finder_tag_keywords():
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"]) md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
keywords = CLI_FINDER_TAGS[uuid]["IPTC:Keywords"] keywords = CLI_FINDER_TAGS[uuid]["IPTC:Keywords"]
keywords = [keywords] if type(keywords) != list else keywords keywords = [keywords] if type(keywords) != list else keywords
expected = [Tag(x) for x in keywords] expected = [Tag(x, 0) for x in keywords]
assert sorted(md.tags) == sorted(expected) assert sorted(md.tags) == sorted(expected)
# run again with --update, should skip writing extended attributes # run again with --update, should skip writing extended attributes
@ -6536,7 +6536,7 @@ def test_export_finder_tag_keywords():
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"]) md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
keywords = CLI_FINDER_TAGS[uuid]["IPTC:Keywords"] keywords = CLI_FINDER_TAGS[uuid]["IPTC:Keywords"]
keywords = [keywords] if type(keywords) != list else keywords keywords = [keywords] if type(keywords) != list else keywords
expected = [Tag(x) for x in keywords] expected = [Tag(x, 0) for x in keywords]
assert sorted(md.tags) == sorted(expected) assert sorted(md.tags) == sorted(expected)
# clear tags and run again, should update extended attributes # clear tags and run again, should update extended attributes
@ -6560,7 +6560,7 @@ def test_export_finder_tag_keywords():
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"]) md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
keywords = CLI_FINDER_TAGS[uuid]["IPTC:Keywords"] keywords = CLI_FINDER_TAGS[uuid]["IPTC:Keywords"]
keywords = [keywords] if type(keywords) != list else keywords keywords = [keywords] if type(keywords) != list else keywords
expected = [Tag(x) for x in keywords] expected = [Tag(x, 0) for x in keywords]
assert sorted(md.tags) == sorted(expected) assert sorted(md.tags) == sorted(expected)
@ -6589,7 +6589,7 @@ def test_export_finder_tag_template():
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"]) md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
keywords = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"] keywords = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
keywords = [keywords] if type(keywords) != list else keywords keywords = [keywords] if type(keywords) != list else keywords
expected = [Tag(x) for x in keywords] expected = [Tag(x, 0) for x in keywords]
assert sorted(md.tags) == sorted(expected) assert sorted(md.tags) == sorted(expected)
# run again with --update, should skip writing extended attributes # run again with --update, should skip writing extended attributes
@ -6612,7 +6612,7 @@ def test_export_finder_tag_template():
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"]) md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
keywords = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"] keywords = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
keywords = [keywords] if type(keywords) != list else keywords keywords = [keywords] if type(keywords) != list else keywords
expected = [Tag(x) for x in keywords] expected = [Tag(x, 0) for x in keywords]
assert sorted(md.tags) == sorted(expected) assert sorted(md.tags) == sorted(expected)
# clear tags and run again, should update extended attributes # clear tags and run again, should update extended attributes
@ -6637,7 +6637,7 @@ def test_export_finder_tag_template():
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"]) md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
keywords = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"] keywords = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
keywords = [keywords] if type(keywords) != list else keywords keywords = [keywords] if type(keywords) != list else keywords
expected = [Tag(x) for x in keywords] expected = [Tag(x, 0) for x in keywords]
assert sorted(md.tags) == sorted(expected) assert sorted(md.tags) == sorted(expected)
@ -6670,7 +6670,7 @@ def test_export_finder_tag_template_multiple():
keywords = [keywords] if type(keywords) != list else keywords keywords = [keywords] if type(keywords) != list else keywords
persons = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"] persons = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
persons = [persons] if type(persons) != list else persons persons = [persons] if type(persons) != list else persons
expected = [Tag(x) for x in set(keywords + persons)] expected = [Tag(x, 0) for x in set(keywords + persons)]
assert sorted(md.tags) == sorted(expected) assert sorted(md.tags) == sorted(expected)
@ -6702,7 +6702,7 @@ def test_export_finder_tag_template_keywords():
keywords = [keywords] if type(keywords) != list else keywords keywords = [keywords] if type(keywords) != list else keywords
persons = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"] persons = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
persons = [persons] if type(persons) != list else persons persons = [persons] if type(persons) != list else persons
expected = [Tag(x) for x in set(keywords + persons)] expected = [Tag(x, 0) for x in set(keywords + persons)]
assert sorted(md.tags) == sorted(expected) assert sorted(md.tags) == sorted(expected)
@ -6731,17 +6731,23 @@ def test_export_finder_tag_template_multi_field():
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"]) md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
title = CLI_FINDER_TAGS[uuid]["XMP:Title"] or "" title = CLI_FINDER_TAGS[uuid]["XMP:Title"] or ""
descr = CLI_FINDER_TAGS[uuid]["XMP:Description"] or "" descr = CLI_FINDER_TAGS[uuid]["XMP:Description"] or ""
expected = [Tag(f"{title};{descr}")] expected = [Tag(f"{title};{descr}", 0)]
assert sorted(md.tags) == sorted(expected) assert sorted(md.tags) == sorted(expected)
def test_export_xattr_template(): def test_export_xattr_template():
"""test --xattr template""" """test --xattr template"""
# Note: this test does not actually test that the metadata attributes get correctly
# written by osxmetadata as osxmetadata doesn't work reliably when run by pytest
# (but does appear to work correctly in practice)
# Reference: https://github.com/RhetTbull/osxmetadata/issues/68
runner = CliRunner() runner = CliRunner()
cwd = os.getcwd() cwd = os.getcwd()
# pylint: disable=not-context-manager # pylint: disable=not-context-manager
with runner.isolated_filesystem(): with runner.isolated_filesystem():
test_dir = os.getcwd()
for uuid in CLI_FINDER_TAGS: for uuid in CLI_FINDER_TAGS:
result = runner.invoke( result = runner.invoke(
export, export,
@ -6750,8 +6756,8 @@ def test_export_xattr_template():
".", ".",
"-V", "-V",
"--xattr-template", "--xattr-template",
"keywords", "copyright",
"{person}", "osxphotos 2022",
"--xattr-template", "--xattr-template",
"comment", "comment",
"{title};{descr}", "{title};{descr}",
@ -6760,14 +6766,8 @@ def test_export_xattr_template():
], ],
) )
assert result.exit_code == 0 assert result.exit_code == 0
assert "Writing extended attribute copyright" in result.output
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"]) assert "Writing extended attribute comment" in result.output
expected = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
expected = [expected] if type(expected) != list else expected
assert sorted(md.keywords) == sorted(expected)
title = CLI_FINDER_TAGS[uuid]["XMP:Title"] or ""
descr = CLI_FINDER_TAGS[uuid]["XMP:Description"] or ""
assert md.comment == f"{title};{descr}"
# run again with --update, should skip writing extended attributes # run again with --update, should skip writing extended attributes
result = runner.invoke( result = runner.invoke(
@ -6777,8 +6777,8 @@ def test_export_xattr_template():
".", ".",
"-V", "-V",
"--xattr-template", "--xattr-template",
"keywords", "copyright",
"{person}", "osxphotos 2022",
"--xattr-template", "--xattr-template",
"comment", "comment",
"{title};{descr}", "{title};{descr}",
@ -6788,11 +6788,12 @@ def test_export_xattr_template():
], ],
) )
assert result.exit_code == 0 assert result.exit_code == 0
assert "Skipping extended attribute keywords" in result.output
assert "Skipping extended attribute comment" in result.output
# clear tags and run again, should update extended attributes # clear tags and run again, should update extended attributes
md.keywords = None md = OSXMetaData(
os.path.join(test_dir, CLI_FINDER_TAGS[uuid]["File:FileName"])
)
md.copyright = None
md.comment = None md.comment = None
result = runner.invoke( result = runner.invoke(
@ -6802,8 +6803,8 @@ def test_export_xattr_template():
".", ".",
"-V", "-V",
"--xattr-template", "--xattr-template",
"keywords", "copyright",
"{person}", "osxphotos 2022",
"--xattr-template", "--xattr-template",
"comment", "comment",
"{title}", "{title}",
@ -6813,14 +6814,6 @@ def test_export_xattr_template():
], ],
) )
assert result.exit_code == 0 assert result.exit_code == 0
assert "Writing extended attribute keyword" in result.output
assert "Writing extended attribute comment" in result.output
md = OSXMetaData(CLI_FINDER_TAGS[uuid]["File:FileName"])
expected = CLI_FINDER_TAGS[uuid]["XMP:PersonInImage"]
expected = [expected] if type(expected) != list else expected
assert sorted(md.keywords) == sorted(expected)
assert md.comment == CLI_FINDER_TAGS[uuid]["XMP:Title"]
def test_export_jpeg_ext(): def test_export_jpeg_ext():