diff --git a/osxmetadata/__init__.py b/osxmetadata/__init__.py index bf6b07b..694278d 100644 --- a/osxmetadata/__init__.py +++ b/osxmetadata/__init__.py @@ -3,34 +3,32 @@ import datetime import logging -import os -import os.path import pathlib import plistlib -import pprint -import subprocess import sys -import tempfile # plistlib creates constants at runtime which causes pylint to complain from plistlib import FMT_BINARY # pylint: disable=E0611 import xattr -from .constants import ( +from .attributes import ATTRIBUTES, Attribute +from .classes import _AttributeList, _AttributeTagsSet +from .constants import ( # _DOWNLOAD_DATE,; _FINDER_COMMENT,; _TAGS,; _WHERE_FROM, _COLORIDS, _COLORNAMES, - _DOWNLOAD_DATE, - _FINDER_COMMENT, _FINDER_COMMENT_NAMES, _MAX_FINDERCOMMENT, _MAX_WHEREFROM, - _TAGS, _VALID_COLORIDS, - _WHERE_FROM, - Attribute, ) -from .utils import set_finder_comment +from .utils import ( + _debug, + _get_logger, + _set_debug, + set_finder_comment, + validate_attribute_value, +) # this was inspired by osx-tags by "Ben S / scooby" and is published under # the same MIT license. See: https://github.com/scooby/osx-tags @@ -52,260 +50,118 @@ def _onError(e): sys.stderr.write(str(e) + "\n") -class _Tags: - """ represents a tag/keyword """ - - def __init__(self, xa: xattr.xattr): - self._attrs = xa - - # used for __iter__ - self._tag_list = None - self._tag_count = None - self._tag_counter = None - - # initialize - self._load_tags() - - def add(self, tag): - """ add a tag """ - if not isinstance(tag, str): - raise TypeError("Tags must be strings") - self._load_tags() - tags = set(map(self._tag_normalize, self._tag_set)) - tags.add(self._tag_normalize(tag)) - self._write_tags(*tags) - - def update(self, *tags): - """ update tag list adding any new tags in *tags """ - if not all(isinstance(tag, str) for tag in tags): - raise TypeError("Tags must be strings") - self._load_tags() - old_tags = set(map(self._tag_normalize, self._tag_set)) - new_tags = old_tags.union(set(map(self._tag_normalize, tags))) - self._write_tags(*new_tags) - - def clear(self): - """ clear tags (remove all tags) """ - try: - self._attrs.remove(_TAGS) - except (IOError, OSError): - pass - - def remove(self, tag): - """ remove a tag, raise exception if tag does not exist """ - self._load_tags() - if not isinstance(tag, str): - raise TypeError("Tags must be strings") - tags = set(map(self._tag_normalize, self._tag_set)) - tags.remove(self._tag_normalize(tag)) - self._write_tags(*tags) - - def discard(self, tag): - """ remove a tag, does not raise exception if tag does not exist """ - self._load_tags() - if not isinstance(tag, str): - raise TypeError("Tags must be strings") - tags = set(map(self._tag_normalize, self._tag_set)) - tags.discard(self._tag_normalize(tag)) - self._write_tags(*tags) - - def _tag_split(self, tag): - # Extracts the color information from a Finder tag. - - parts = tag.rsplit("\n", 1) - if len(parts) == 1: - return parts[0], 0 - elif ( - len(parts[1]) != 1 or parts[1] not in _VALID_COLORIDS - ): # Not a color number - return tag, 0 - else: - return parts[0], int(parts[1]) - - def _load_tags(self): - self._tags = {} - try: - self._tagvalues = self._attrs[_TAGS] - # load the binary plist value - self._tagvalues = plistlib.loads(self._tagvalues) - for x in self._tagvalues: - (tag, color) = self._tag_split(x) - self._tags[tag] = color - # self._tags = [self._tag_strip_color(x) for x in self._tagvalues] - except KeyError: - self._tags = None - if self._tags: - self._tag_set = set(self._tags.keys()) - else: - self._tag_set = set([]) - - def _write_tags(self, *tags): - # Overwrites the existing tags with the iterable of tags provided. - - if not all(isinstance(tag, str) for tag in tags): - raise TypeError("Tags must be strings") - tag_plist = plistlib.dumps(list(map(self._tag_normalize, tags)), fmt=FMT_BINARY) - self._attrs.set(_TAGS, tag_plist) - - def _tag_colored(self, tag, color): - """ - Sets the color of a tag. - - Parameters: - tag(str): a tag name - color(int): an integer from 1 through 7 - - Return: - (str) the tag with encoded color. - """ - return "{}\n{}".format(self._tag_nocolor(tag), color) - - def _tag_normalize(self, tag): - """ - Ensures a color is set if not none. - :param tag: a possibly non-normal tag. - :return: A colorized tag. - """ - tag, color = self._tag_split(tag) - if tag.title() in _COLORNAMES: - # ignore the color passed and set proper color name - return self._tag_colored(tag.title(), _COLORNAMES[tag.title()]) - else: - return self._tag_colored(tag, color) - - def _tag_nocolor(self, tag): - """ - Removes the color information from a Finder tag. - """ - return tag.rsplit("\n", 1)[0] - - def __iter__(self): - self._load_tags() - self._tag_list = list(self._tag_set) - self._tag_count = len(self._tag_list) - self._tag_counter = 0 - return self - - def __next__(self): - if self._tag_counter < self._tag_count: - tag = self._tag_list[self._tag_counter] - self._tag_counter += 1 - return tag - else: - raise StopIteration - - def __len__(self): - self._load_tags() - return len(self._tag_set) - - def __repr__(self): - self._load_tags() - return repr(self._tag_set) - - def __str__(self): - self._load_tags() - return ", ".join(self._tag_set) - - def __iadd__(self, tag): - self.add(tag) - return self - - class OSXMetaData: """Create an OSXMetaData object to access file metadata""" + __slots__ = [ + "_fname", + "_posix_name", + "_attrs", + "__init", + "authors", + "creator", + "description", + "downloadeddate", + "findercomment", + "headline", + "keywords", + "tags", + "wherefroms", + "test", + ] + def __init__(self, fname): """Create an OSXMetaData object to access file metadata""" self._fname = pathlib.Path(fname) self._posix_name = self._fname.resolve().as_posix() if not self._fname.exists(): - raise ValueError("file does not exist: ", fname) - - try: - self._attrs = xattr.xattr(self._fname) - except (IOError, OSError) as e: - quit(_onError(e)) + raise FileNotFoundError("file does not exist: ", fname) - self._data = {} - self.tags = _Tags(self._attrs) + self._attrs = xattr.xattr(self._fname) - # TODO: Lot's of repetitive code here - # need to read these dynamically - self._load_findercomment() - self._load_download_wherefrom() - self._load_download_date() + # create property classes for the multi-valued attributes + # tags get special handling due to color labels + # self.tags = _AttributeTagsSet(ATTRIBUTES["tags"], self._attrs) + # ATTRIBUTES contains both long and short names, want only the short names (attribute.name) + for name in set([attribute.name for attribute in ATTRIBUTES.values()]): + attribute = ATTRIBUTES[name] + if attribute.class_ not in [str, float]: + super().__setattr__(name, attribute.class_(attribute, self._attrs)) - @property - def finder_comment(self): - """ Get/set the Finder comment (or None) associated with the file. - Functions as a string: e.g. finder_comment += 'my comment'. """ - self._load_findercomment() - return self._data[_FINDER_COMMENT] - - @finder_comment.setter - def finder_comment(self, fc): - """ Get/set the Finder comment (or None) associated with the file. - Functions as a string: e.g. finder_comment += 'my comment'. """ - # TODO: this creates a temporary script file which gets runs by osascript every time - # not very efficient. Perhaps use py-applescript in the future but that increases - # dependencies + PyObjC - - if fc is None: - fc = "" - elif not isinstance(fc, str): - raise TypeError("Finder comment must be strings") - - if len(fc) > _MAX_FINDERCOMMENT: - raise ValueError( - "Finder comment limited to %d characters" % _MAX_FINDERCOMMENT - ) - - fname = self._posix_name - set_finder_comment(fname, fc) - self._load_findercomment() + # Done with initialization + self.__init = True - @property - def where_from(self): - """ Get/set list of URL(s) where file was downloaded from. """ - self._load_download_wherefrom() - return self._data[_WHERE_FROM] - - @where_from.setter - def where_from(self, wf): - """ Get/set list of URL(s) where file was downloaded from. """ - if wf is None: - wf = [] - elif not isinstance(wf, list): - raise TypeError("Where from must be a list of one or more URL strings") - - for w in wf: - if len(w) > _MAX_WHEREFROM: - raise ValueError( - "Where from URL limited to %d characters" % _MAX_WHEREFROM - ) + # @property + # def finder_comment(self): + # """ Get/set the Finder comment (or None) associated with the file. + # Functions as a string: e.g. finder_comment += 'my comment'. """ + # self._load_findercomment() + # return self._data[_FINDER_COMMENT] + + # @finder_comment.setter + # def finder_comment(self, fc): + # """ Get/set the Finder comment (or None) associated with the file. + # Functions as a string: e.g. finder_comment += 'my comment'. """ + # # TODO: this creates a temporary script file which gets runs by osascript every time + # # not very efficient. Perhaps use py-applescript in the future but that increases + # # dependencies + PyObjC + + # if fc is None: + # fc = "" + # elif not isinstance(fc, str): + # raise TypeError("Finder comment must be strings") + + # if len(fc) > _MAX_FINDERCOMMENT: + # raise ValueError( + # "Finder comment limited to %d characters" % _MAX_FINDERCOMMENT + # ) + + # fname = self._posix_name + # set_finder_comment(fname, fc) + # self._load_findercomment() - wf_plist = plistlib.dumps(wf, fmt=FMT_BINARY) - self._attrs.set(_WHERE_FROM, wf_plist) - self._load_download_wherefrom() + # @property + # def where_from(self): + # """ Get/set list of URL(s) where file was downloaded from. """ + # self._load_download_wherefrom() + # return self._data[_WHERE_FROM] + + # @where_from.setter + # def where_from(self, wf): + # """ Get/set list of URL(s) where file was downloaded from. """ + # if wf is None: + # wf = [] + # elif not isinstance(wf, list): + # raise TypeError("Where from must be a list of one or more URL strings") + + # for w in wf: + # if len(w) > _MAX_WHEREFROM: + # raise ValueError( + # "Where from URL limited to %d characters" % _MAX_WHEREFROM + # ) + + # wf_plist = plistlib.dumps(wf, fmt=FMT_BINARY) + # self._attrs.set(_WHERE_FROM, wf_plist) + # self._load_download_wherefrom() - @property - def download_date(self): - """ Get/set date file was downloaded, as a datetime.datetime object. """ - self._load_download_date() - return self._data[_DOWNLOAD_DATE] - - @download_date.setter - def download_date(self, dt): - """ Get/set date file was downloaded, as a datetime.datetime object. """ - if dt is None: - dt = [] - elif not isinstance(dt, datetime.datetime): - raise TypeError("Download date must be a datetime object") - - dt_plist = plistlib.dumps([dt], fmt=FMT_BINARY) - self._attrs.set(_DOWNLOAD_DATE, dt_plist) - self._load_download_date() + # @property + # def download_date(self): + # """ Get/set date file was downloaded, as a datetime.datetime object. """ + # self._load_download_date() + # return self._data[_DOWNLOAD_DATE] + + # @download_date.setter + # def download_date(self, dt): + # """ Get/set date file was downloaded, as a datetime.datetime object. """ + # if dt is None: + # dt = [] + # elif not isinstance(dt, datetime.datetime): + # raise TypeError("Download date must be a datetime object") + + # dt_plist = plistlib.dumps([dt], fmt=FMT_BINARY) + # self._attrs.set(_DOWNLOAD_DATE, dt_plist) + # self._load_download_date() @property def name(self): @@ -328,16 +184,33 @@ def get_attribute(self, attribute): except KeyError: plist = None - # TODO: should I check Attribute.type is correct? + # TODO: should I check Attribute.type_ is correct? if attribute.as_list and isinstance(plist, list): return plist[0] else: return plist + def get_attribute_str(self, attribute): + """ returns a string representation of attribute value + e.g. if attribute is a datedate.datetime object, will + format using datetime.isoformat() """ + value = self.get_attribute(attribute) + try: + iter(value) + # must be an interable + if type(value[0]) == datetime.datetime: + new_value = [v.isoformat() for v in value] + return str(new_value) + return str(value) + except TypeError: + # not an iterable + if type(value) == datetime.datetime: + return value.isoformat() + return value + def set_attribute(self, attribute, value): """ write attribute to file attribute: an osxmetadata Attribute namedtuple """ - logging.debug(f"set: {attribute} {value}") if not isinstance(attribute, Attribute): raise TypeError( "attribute must be osxmetada.constants.Attribute namedtuple" @@ -346,16 +219,16 @@ def set_attribute(self, attribute, value): # verify type is correct if attribute.list and type(value) == list: for val in value: - if attribute.type != type(val): + if attribute.type_ != type(val): raise ValueError( - f"Expected type {attribute.type} but value is type {type(val)}" + f"Expected type {attribute.type_} but value is type {type(val)}" ) elif not attribute.list and type(value) == list: - raise TypeError(f"Expected single value but got list for {attribute.type}") + raise TypeError(f"Expected single value but got list for {attribute.type_}") else: - if attribute.type != type(value): + if attribute.type_ != type(value): raise ValueError( - f"Expected type {attribute.type} but value is type {type(value)}" + f"Expected type {attribute.type_} but value is type {type(value)}" ) if attribute.as_list: @@ -363,52 +236,82 @@ def set_attribute(self, attribute, value): # even though they only have only a single value value = [value] - try: - if attribute.name in _FINDER_COMMENT_NAMES: - # Finder Comment needs special handling - # code following will also set the attribute for Finder Comment - set_finder_comment(self._posix_name, value) - + if attribute.name in _FINDER_COMMENT_NAMES: + # Finder Comment needs special handling + # code following will also set the attribute for Finder Comment + set_finder_comment(self._posix_name, value) + elif attribute.class_ in [_AttributeList, _AttributeTagsSet]: + getattr(self, attribute.name).set_value(value) + else: + # must be a normal scalar (e.g. str, float) plist = plistlib.dumps(value, fmt=FMT_BINARY) self._attrs.set(attribute.constant, plist) - except Exception as e: - raise return value - def append_attribute(self, attribute, value): - """ append attribute to file - attribute: an osxmetadata Attribute namedtuple """ + def update_attribute(self, attribute, value): + """ Update attribute with union of itself and value + (this avoids adding duplicate values to attribute) + attribute: an osxmetadata Attribute namedtuple + value: value to append to attribute """ + return self.append_attribute(attribute, value, update=True) + + def append_attribute(self, attribute, value, update=False): + """ append value to attribute + attribute: an osxmetadata Attribute namedtuple + value: value to append to attribute + update: (bool) if True, update instead of append (e.g. avoid adding duplicates) + (default is False) """ + logging.debug(f"append_attribute: {attribute} {value}") if not isinstance(attribute, Attribute): raise TypeError( "attribute must be osxmetada.constants.Attribute namedtuple" ) + # start with existing values new_value = self.get_attribute(attribute) # verify type is correct if attribute.list and type(value) == list: + # expected a list, got a list for val in value: - if attribute.type != type(val): + # check type of each element in list + if attribute.type_ != type(val): raise ValueError( - f"Expected type {attribute.type} but value is type {type(val)}" + f"Expected type {attribute.type_} but value is type {type(val)}" ) else: if new_value: - new_value.extend(value) + if update: + # if update, only add values not already in the list + # behaves like set.update + for v in value: + if v not in new_value: + new_value.append(v) + else: + # not update, add all values + new_value.extend(value) else: - new_value = value - # convert to set & back to list to avoid duplicates - new_value = list(set(new_value)) + if update: + # no previous values but still need to make sure we don't have + # dupblicate values: convert to set & back to list + new_value = list(set(value)) + else: + # no previous values, set new_value to whatever value is + new_value = value elif not attribute.list and type(value) == list: - raise TypeError(f"Expected single value but got list for {attribute.type}") + raise TypeError(f"Expected single value but got list for {attribute.type_}") else: - if attribute.type != type(value): + # expected scalar, got a scalar, check type is correct + if attribute.type_ != type(value): raise ValueError( - f"Expected type {attribute.type} but value is type {type(value)}" + f"Expected type {attribute.type_} but value is type {type(value)}" ) else: + # not a list, could be str, float, datetime.datetime + if update: + raise AttributeError(f"Cannot use update on {attribute.type_}") if new_value: new_value += value else: @@ -428,10 +331,47 @@ def append_attribute(self, attribute, value): plist = plistlib.dumps(new_value, fmt=FMT_BINARY) self._attrs.set(attribute.constant, plist) except Exception as e: - raise + # todo: should catch this or not? + raise e return new_value + def remove_attribute(self, attribute, value): + """ remove a value from attribute, raise exception if attribute does not contain value + only applies to multi-valued attributes, otherwise raises TypeError """ + + if not isinstance(attribute, Attribute): + raise TypeError( + "attribute must be osxmetada.constants.Attribute namedtuple" + ) + + if not attribute.list: + raise TypeError("remove only applies to multi-valued attributes") + + values = self.get_attribute(attribute) + values.remove(value) + self.set_attribute(attribute, values) + + def discard_attribute(self, attribute, value): + """ remove a value from attribute, unlike remove, does not raise exception + if attribute does not contain value + only applies to multi-valued attributes, otherwise raises TypeError """ + + if not isinstance(attribute, Attribute): + raise TypeError( + "attribute must be osxmetada.constants.Attribute namedtuple" + ) + + if not attribute.list: + raise TypeError("discard only applies to multi-valued attributes") + + values = self.get_attribute(attribute) + try: + values.remove(value) + self.set_attribute(attribute, values) + except: + pass + def clear_attribute(self, attribute): """ clear attribute (remove) attribute: an osxmetadata Attribute namedtuple """ @@ -457,10 +397,37 @@ def _list_attributes(self): def list_metadata(self): """ list the Apple metadata attributes: e.g. those in com.apple.metadata namespace """ + # also lists com.osxmetadata.test used for debugging mdlist = self._attrs.list() - mdlist = [md for md in mdlist if md.startswith("com.apple.metadata")] + mdlist = [ + md + for md in mdlist + if md.startswith("com.apple.metadata") + or md.startswith("com.osxmetadata.test") + ] return mdlist + def __getattr__(self, name): + """ if attribute name is in ATTRIBUTE dict, return the value + otherwise raise AttributeError """ + value = self.get_attribute(ATTRIBUTES[name]) + return value + + def __setattr__(self, name, value): + """ if attribute name is in ATTRIBUTE dict, set the value + otherwise raise AttributeError """ + try: + if self.__init: + # already initialized + attribute = ATTRIBUTES[name] + value = validate_attribute_value(attribute, value) + if value is None: + self.clear_attribute(attribute) + else: + self.set_attribute(attribute, value) + except (KeyError, AttributeError): + super().__setattr__(name, value) + # @property # def colors(self): # """ return list of color labels from tags @@ -476,25 +443,25 @@ def list_metadata(self): # else: # return None - def _load_findercomment(self): - try: - # load the binary plist value - self._data[_FINDER_COMMENT] = plistlib.loads(self._attrs[_FINDER_COMMENT]) - except KeyError: - self._data[_FINDER_COMMENT] = None - - def _load_download_wherefrom(self): - try: - # load the binary plist value - self._data[_WHERE_FROM] = plistlib.loads(self._attrs[_WHERE_FROM]) - except KeyError: - self._data[_WHERE_FROM] = None - - def _load_download_date(self): - try: - # load the binary plist value - # returns an array with a single datetime.datetime object - self._data[_DOWNLOAD_DATE] = plistlib.loads(self._attrs[_DOWNLOAD_DATE])[0] - # logger.debug(self._downloaddate) - except KeyError: - self._data[_DOWNLOAD_DATE] = None + # def _load_findercomment(self): + # try: + # # load the binary plist value + # self._data[_FINDER_COMMENT] = plistlib.loads(self._attrs[_FINDER_COMMENT]) + # except KeyError: + # self._data[_FINDER_COMMENT] = None + + # def _load_download_wherefrom(self): + # try: + # # load the binary plist value + # self._data[_WHERE_FROM] = plistlib.loads(self._attrs[_WHERE_FROM]) + # except KeyError: + # self._data[_WHERE_FROM] = None + + # def _load_download_date(self): + # try: + # # load the binary plist value + # # returns an array with a single datetime.datetime object + # self._data[_DOWNLOAD_DATE] = plistlib.loads(self._attrs[_DOWNLOAD_DATE])[0] + # # logger.debug(self._downloaddate) + # except KeyError: + # self._data[_DOWNLOAD_DATE] = None diff --git a/osxmetadata/__main__.py b/osxmetadata/__main__.py index b7d036f..162397c 100644 --- a/osxmetadata/__main__.py +++ b/osxmetadata/__main__.py @@ -1,6 +1,5 @@ -# /usr/bin/env python +# /usr/bin/env python3 -import argparse import datetime import json import logging @@ -14,13 +13,9 @@ import osxmetadata from ._version import __version__ -from .constants import ( - _LONG_NAME_WIDTH, - _SHORT_NAME_WIDTH, - _TAGS_NAMES, - ATTRIBUTES, - ATTRIBUTES_LIST, -) +from .attributes import _LONG_NAME_WIDTH, _SHORT_NAME_WIDTH, ATTRIBUTES, ATTRIBUTES_LIST +from .constants import _TAGS_NAMES +from .utils import validate_attribute_value # TODO: add md5 option # TODO: how is metadata on symlink handled? @@ -34,17 +29,6 @@ # TODO: need special handling for --set color GREEN, etc. -# setup debugging and logging -_DEBUG = False - -logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s - %(levelname)s - %(filename)s - %(lineno)d - %(message)s", -) - -if not _DEBUG: - logging.disable(logging.DEBUG) - # custom error handler def onError(e): @@ -52,148 +36,12 @@ def onError(e): return e -# # custom argparse class to show help if error triggered -# class MyParser(argparse.ArgumentParser): -# def error(self, message): -# sys.stderr.write("error: %s\n" % message) -# self.print_help() -# sys.exit(2) - - -# def process_arguments(): -# parser = MyParser( -# description="Import and export metadata from files", add_help=False -# ) - -# # parser.add_argument( -# # "--test", -# # action="store_true", -# # default=False, -# # help="Test mode: do not actually modify any files or metadata" -# # + "most useful with --verbose", -# # ) - -# parser.add_argument( -# "-h", -# "--help", -# action="store_true", -# default=False, -# help="Show this help message", -# ) - -# parser.add_argument( -# "-v", -# "--version", -# action="store_true", -# default=False, -# help="Print version number", -# ) - -# parser.add_argument( -# "-V", -# "--verbose", -# action="store_true", -# default=False, -# help="Print verbose output during processing", -# ) - -# parser.add_argument( -# "-j", -# "--json", -# action="store_true", -# default=False, -# help="Output to JSON, optionally provide output file name: --outfile=file.json " -# + "NOTE: if processing multiple files each JSON object is written to a new line as a separate object (ie. not a list of objects)", -# ) - -# parser.add_argument( -# "-q", -# "--quiet", -# action="store_true", -# default=False, -# help="Be extra quiet when running.", -# ) - -# # parser.add_argument( -# # "--noprogress", -# # action="store_true", -# # default=False, -# # help="Disable the progress bar while running", -# # ) - -# parser.add_argument( -# "--force", -# action="store_true", -# default=False, -# help="Force new metadata to be written even if unchanged", -# ) - -# parser.add_argument( -# "-o", -# "--outfile", -# help="Name of output file. If not specified, output goes to STDOUT", -# ) - -# parser.add_argument( -# "-r", -# "--restore", -# help="Restore all metadata by reading from JSON file RESTORE (previously created with --json --outfile=RESTORE). " -# + "Will overwrite all existing metadata with the metadata specified in the restore file. " -# + "NOTE: JSON file expected to have one object per line as written by --json", -# ) - -# parser.add_argument( -# "--addtag", -# action="append", -# help="add tag/keyword for file. To add multiple tags, use multiple --addtag otions. e.g. --addtag foo --addtag bar", -# ) - -# parser.add_argument( -# "--cleartags", -# action="store_true", -# default=False, -# help="remove all tags from file", -# ) - -# parser.add_argument("--rmtag", action="append", help="remove tag from file") - -# parser.add_argument("--setfc", help="set Finder comment") - -# parser.add_argument( -# "--clearfc", action="store_true", default=False, help="clear Finder comment" -# ) - -# parser.add_argument( -# "--addfc", -# action="append", -# help="append a Finder comment, preserving existing comment", -# ) - -# # parser.add_argument( -# # "--list", -# # action="store_true", -# # default=False, -# # help="List all tags found in Yep; does not update any files", -# # ) - -# parser.add_argument("files", nargs="*") - -# args = parser.parse_args() -# # if no args, show help and exit -# if len(sys.argv) == 1 or args.help: -# parser.print_help(sys.stderr) -# sys.exit(1) - -# return args - - # Click CLI object & context settings class CLI_Obj: def __init__(self, debug=False, files=None): - global _DEBUG - _DEBUG = self.debug = debug + self.debug = debug if debug: - logging.disable(logging.NOTSET) + osxmetadata._set_debug(True) self.files = files @@ -203,7 +51,8 @@ class MyClickCommand(click.Command): def get_help(self, ctx): help_text = super().get_help(ctx) - help_text += "\n\nValid attributes for ATTRIBUTE:\n(either Short or Long Name may be passsed to option expecting an attribute)\n" + help_text += "\n\nValid attributes for ATTRIBUTE:\n" + help_text += "(Short Name, Constant, or Long Name may be passsed to option expecting an attribute)\n" help_text += "\n".join(ATTRIBUTES_LIST) return help_text @@ -256,7 +105,6 @@ def get_help(self, ctx): ) CLEAROPTION = click.option( "--clear", - "clear", help="Remove attribute from FILE", metavar="ATTRIBUTE", nargs=1, @@ -265,13 +113,28 @@ def get_help(self, ctx): ) APPEND_OPTION = click.option( "--append", - "append", metavar="ATTRIBUTE VALUE", help="Append VALUE to ATTRIBUTE", nargs=2, multiple=True, required=False, ) +UPDATE_OPTION = click.option( + "--update", + metavar="ATTRIBUTE VALUE", + help="Update ATTRIBUTE with VALUE", + nargs=2, + multiple=True, + required=False, +) +REMOVE_OPTION = click.option( + "--remove", + metavar="ATTRIBUTE VALUE", + help="Remove VALUE from ATTRIBUTE; only applies to multi-valued attributes", + nargs=2, + multiple=True, + required=False, +) # @click.group(context_settings=CTX_SETTINGS) # @click.version_option(__version__, "--version", "-v") @@ -304,24 +167,28 @@ def get_help(self, ctx): @CLEAROPTION @APPEND_OPTION @GET_OPTION +@REMOVE_OPTION +@UPDATE_OPTION @click.pass_context -def cli(ctx, debug, files, walk, json_, set_, list_, clear, append, get): - """ Read metadata from file(s). """ +def cli( + ctx, debug, files, walk, json_, set_, list_, clear, append, get, remove, update +): + """ Read/write metadata from file(s). """ if debug: logging.disable(logging.NOTSET) logging.debug( f"ctx={ctx} debug={debug} files={files} walk={walk} json={json_} " - f"set={set_}, list={list_},clear={clear},append={append},get={get}" + f"set={set_}, list={list_},clear={clear},append={append},get={get}, remove={remove}" ) if not files: click.echo(ctx.get_help()) ctx.exit() - # validate values for --set, --clear, append, get - if any([set_, clear, append, get]): + # validate values for --set, --clear, append, get, remove + if any([set_, append, remove, clear, get]): attributes = ( [a[0] for a in set_] + [a[0] for a in append] + list(clear) + list(get) ) @@ -337,105 +204,35 @@ def cli(ctx, debug, files, walk, json_, set_, list_, clear, append, get): click.echo(ctx.get_help()) ctx.exit() - process_files( - files=files, - walk=walk, - json_=json_, - set_=set_, - list_=list_, - clear=clear, - append=append, - get=get, - ) - - -def process_files( - files, - verbose=False, - quiet=False, - walk=False, - json_=False, - set_=None, - list_=None, - clear=None, - append=None, - get=None, -): - # if walk, use os.walk to walk through files and collect metadata - # on each file - # symlinks can resolve to missing files (e.g. unmounted volume) - # so catch those errors and set data to None - # osxmetadata raises ValueError if specified file is missing - for f in files: if walk and os.path.isdir(f): for root, _, filenames in os.walk(f): - if verbose: - print(f"Processing {root}") + # if verbose: + # print(f"Processing {root}") for fname in filenames: fpath = pathlib.Path(f"{root}/{fname}").resolve() - process_file(fpath, json_, set_, list_, clear, append, get) + process_file( + fpath, json_, set_, append, update, remove, clear, get, list_ + ) elif os.path.isdir(f): # skip directory - if _DEBUG: - logging.debug(f"skipping directory: {f}") + logging.debug(f"skipping directory: {f}") continue else: fpath = pathlib.Path(f).resolve() - process_file(fpath, json_, set_, list_, clear, append, get) - + process_file(fpath, json_, set_, append, update, remove, clear, get, list_) -def validate_attribute_value(attribute, value): - """ validate that value is compatible with attribute.type and convert value to correct type - value is list of one or more items - returns value as type attribute.type """ - - if not attribute.list and len(value) > 1: - raise ValueError( - f"{attribute.name} expects only one value but {len(value)} provided" - ) - new_value = [] - for val in value: - new_val = None - if attribute.type == str: - new_val = str(val) - elif attribute.type == float: - try: - new_val = float(val) - except: - raise TypeError( - f"{val} cannot be convereted to expected type {attribute.type}" - ) - elif attribute.type == datetime.datetime: - try: - new_val = datetime.datetime.fromisoformat(val) - except: - raise TypeError( - f"{val} cannot be convereted to expected type {attribute.type}" - ) - else: - raise TypeError(f"Unknown type: {type(val)}") - new_value.append(new_val) - - logging.debug(f"new_value = {new_value}") - if attribute.list: - return new_value - else: - return new_value[0] - - -def process_file(fpath, json_, set_, list_, clear, append, get): +def process_file(fpath, json_, set_, append, update, remove, clear, get, list_): """ process a single file to apply the options - options processed in this order: set, append, clear, get, list + options processed in this order: set, append, remove, clear, get, list Note: expects all attributes passed in parameters to be validated """ - if _DEBUG: - logging.debug(f"process_file: {fpath}") + logging.debug(f"process_file: {fpath}") md = osxmetadata.OSXMetaData(fpath) - if set_ is not None: + if set_: # set data # check attribute is valid attr_dict = {} @@ -449,6 +246,7 @@ def process_file(fpath, json_, set_, list_, clear, append, get): attr_dict[attribute] = [val] for attribute, value in attr_dict.items(): + logging.debug(f"value: {value}") value = validate_attribute_value(attribute, value) # tags get special handling if attribute.name in _TAGS_NAMES: @@ -458,8 +256,8 @@ def process_file(fpath, json_, set_, list_, clear, append, get): else: md.set_attribute(attribute, value) - if append is not None: - # set data + if append: + # append data # check attribute is valid attr_dict = {} for item in append: @@ -480,13 +278,46 @@ def process_file(fpath, json_, set_, list_, clear, append, get): else: md.append_attribute(attribute, value) - if clear is not None: + if update: + # update data + # check attribute is valid + attr_dict = {} + for item in update: + attr, val = item + attribute = ATTRIBUTES[attr] + logging.debug(f"appending {attr}={val}") + try: + attr_dict[attribute].append(val) + except KeyError: + attr_dict[attribute] = [val] + + for attribute, value in attr_dict.items(): + value = validate_attribute_value(attribute, value) + # tags get special handling + if attribute.name in _TAGS_NAMES: + tags = md.tags + tags.update(*value) + else: + md.update_attribute(attribute, value) + + if remove: + # remove value from attribute + # actually implemented with discard so no error raised if not present + # todo: catch errors and display help + for attr, val in remove: + try: + attribute = ATTRIBUTES[attr] + md.discard_attribute(attribute, val) + except KeyError as e: + raise e + + if clear: for attr in clear: attribute = ATTRIBUTES[attr] logging.debug(f"clearing {attr}") md.clear_attribute(attribute) - if get is not None: + if get: logging.debug(f"get: {get}") for attr in get: attribute = ATTRIBUTES[attr] @@ -503,144 +334,22 @@ def process_file(fpath, json_, set_, list_, clear, append, get): if list_: attribute_list = md.list_metadata() for attr in attribute_list: - if attr in ATTRIBUTES: + try: attribute = ATTRIBUTES[attr] # tags get special handling if attribute.name in _TAGS_NAMES: + # TODO: need to fix it so tags can be returned with proper formatting by get_attribute value = md.tags else: - value = md.get_attribute(attribute) + value = md.get_attribute_str(attribute) click.echo( f"{attribute.name:{_SHORT_NAME_WIDTH}}{attribute.constant:{_LONG_NAME_WIDTH}} = {value}" ) - else: + except KeyError: click.echo( f"{'UNKNOWN':{_SHORT_NAME_WIDTH}}{attr:{_LONG_NAME_WIDTH}} = THIS ATTRIBUTE NOT HANDLED" ) - # try: - # data = read_metadata(fpath) - # except (IOError, OSError, ValueError): - # logging.warning(f"warning: error processing metadata for {fpath}") - - # fp = sys.stdout - # if json_: - # write_json_data(fp, data) - # else: - # write_text_data(fp, data) - - -# def read_metadata(fname): -# try: -# md = osxmetadata.OSXMetaData(fname) -# tags = list(md.tags) -# fc = md.finder_comment -# dldate = md.download_date -# dldate = str(dldate) if dldate is not None else None -# where_from = md.where_from -# descr = md.get_attribute(ATTRIBUTES["description"]) -# data = { -# "file": str(fname), -# "description": descr, -# "tags": tags, -# "fc": fc, -# "dldate": dldate, -# "where_from": where_from, -# } -# except (IOError, OSError) as e: -# return onError(e) -# return data - - -# sets metadata based on args then returns dict with all metadata on the file -# def get_set_metadata(fname, args={}): -# try: -# md = osxmetadata.OSXMetaData(fname) - -# # clear tags -# if args.cleartags: -# md.tags.clear() - -# # remove tags -# if args.rmtag: -# tags = md.tags -# for t in args.rmtag: -# if t in tags: -# md.tags.remove(t) - -# # update tags -# if args.addtag: -# new_tags = [] -# new_tags += args.addtag -# old_tags = md.tags -# tags_different = (sorted(new_tags) != sorted(old_tags)) or args.force -# if tags_different: -# md.tags.update(*new_tags) - -# # finder comments -# if args.clearfc: -# md.finder_comment = "" - -# if args.addfc: -# for fc in args.addfc: -# md.finder_comment += fc - -# if args.setfc: -# old_comment = md.finder_comment -# if (old_comment != args.setfc) or args.force: -# md.finder_comment = args.setfc - -# tags = list(md.tags) -# fc = md.finder_comment -# dldate = md.download_date -# dldate = str(dldate) if dldate is not None else None -# where_from = md.where_from -# data = { -# "file": str(fname), -# "tags": tags, -# "fc": fc, -# "dldate": dldate, -# "where_from": where_from, -# } -# except (IOError, OSError) as e: -# return onError(e) -# return data - - -# sets metadata based on data dict as returned by get_set_metadata or restore_from_json -# clears any existing metadata -# def set_metadata(data, quiet=False): -# try: -# md = osxmetadata.OSXMetaData(data["file"]) - -# md.tags.clear() -# if data["tags"]: -# if not quiet: -# print(f"Tags: {data['tags']}") -# md.tags.update(*data["tags"]) - -# md.finder_comment = "" -# if data["fc"]: -# if not quiet: -# print(f"Finder comment: {data['fc']}") -# md.finder_comment = data["fc"] - -# # tags = list(md.tags) -# # fc = md.finder_comment -# # dldate = md.download_date -# # dldate = str(dldate) if dldate is not None else None -# # where_from = md.where_from -# # data = { -# # "file": str(fname), -# # "tags": tags, -# # "fc": fc, -# # "dldate": dldate, -# # "where_from": where_from, -# # } -# except (IOError, OSError) as e: -# return onError(e) -# return data - def write_json_data(fp, data): json.dump(data, fp) @@ -689,35 +398,5 @@ def write_json_data(fp, data): # set_metadata(data, quiet) -# def main(): -# args = process_arguments() - -# if args.version: -# print(f"Version {__version__}") -# exit() - -# if args.restore: -# if not args.quiet: -# print(f"Restoring metadata from file {args.restore}") -# restore_from_json(args.restore, args.quiet) - -# elif args.files: -# output_file = args.outfile if args.outfile is not None else None -# fp = sys.stdout -# if output_file is not None: -# try: -# fp = open(output_file, mode="w+") -# except: -# print(f"Error opening file {output_file} for writing") -# sys.exit(2) - -# process_files( -# files=args.files, quiet=args.quiet, verbose=args.verbose, args=args, fp=fp -# ) - -# if output_file is not None: -# fp.close() - - if __name__ == "__main__": cli() # pylint: disable=no-value-for-parameter diff --git a/osxmetadata/attributes.py b/osxmetadata/attributes.py new file mode 100644 index 0000000..940498c --- /dev/null +++ b/osxmetadata/attributes.py @@ -0,0 +1,110 @@ +from collections import namedtuple +import datetime + +from .constants import * +from .classes import _AttributeList, _AttributeTagsSet + +# Information about metadata attributes that can be set +# Each attribute type needs an Attribute namedtuple entry in ATTRIBUTES dict +# To add new entries, create an Attribute namedtuple and create an entry in +# ATTRIBUTES dict where key is short name for the attribute +# Fields in the Attribute namedtuple are: +# name: short name of the attribute -- will also be used as attribute/property +# in the OSXMetaData class +# constant: the name of the constant for the attribute +# (e.g. com.apple.metadata:kMDItemCreator) +# See https://developer.apple.com/documentation/coreservices/file_metadata/mditem?language=objc +# for reference on common metadata attributes +# type_: expected type for the attribute, e.g. if Apple says it's a CFString, it'll be python str +# CFNumber = python float, etc. +# (called type_ so pylint doesn't complain about misplaced type identifier) +# list: (boolean) True if attribute is a list (e.g. a CFArray) +# as_list: (boolean) True if attribute is a single value but stored in a list +# Note: the only attribute I've seen this on is com.apple.metadata:kMDItemDownloadedDate +# class: the attribute class to use, e.g. _AttributeList or str +# Note: also add short name to __slots__ in OSXMetaData so pylint doesn't protest + + +Attribute = namedtuple( + "Attribute", ["name", "constant", "type_", "list", "as_list", "class_"] +) + +ATTRIBUTES = { + "authors": Attribute("authors", kMDItemAuthors, str, True, False, _AttributeList), + "creator": Attribute("creator", kMDItemCreator, str, False, False, str), + "description": Attribute("description", kMDItemDescription, str, False, False, str), + "downloadeddate": Attribute( + "downloadeddate", + kMDItemDownloadedDate, + datetime.datetime, + # False, + True, + # True, + False, + _AttributeList, + ), + "findercomment": Attribute( + "findercomment", kMDItemFinderComment, str, False, False, str + ), + "headline": Attribute("headline", kMDItemHeadline, str, False, False, str), + "keywords": Attribute( + "keywords", kMDItemKeywords, str, True, False, _AttributeList + ), + "tags": Attribute("tags", _kMDItemUserTags, str, True, False, _AttributeTagsSet), + "wherefroms": Attribute( + "wherefroms", kMDItemWhereFroms, str, True, False, _AttributeList + ), + "test": Attribute( + "test", + "com.osxmetadata.test:DontTryThisAtHomeKids", + datetime.datetime, + True, + False, + _AttributeList, + ), + "test_float": Attribute( + "test_float", + "com.osxmetadata.test:DontTryThisAtHomeKids", + float, + False, + False, + float, + ), + "test_str": Attribute( + "test_str", "com.osxmetadata.test:String", str, False, False, str + ), +} + +# used for formatting output of --list +_SHORT_NAME_WIDTH = max([len(x) for x in ATTRIBUTES.keys()]) + 5 +_LONG_NAME_WIDTH = max([len(x.constant) for x in ATTRIBUTES.values()]) + 10 +_CONSTANT_WIDTH = 21 + 5 # currently is kMDItemDownloadedDate + +# also add entries for attributes by constant and short constant +# do this after computing widths above +_temp_attributes = {} +for attribute in ATTRIBUTES.values(): + if attribute.constant not in ATTRIBUTES: + _temp_attributes[attribute.constant] = attribute + constant_name = attribute.constant.split(":", 2)[1] + _temp_attributes[constant_name] = attribute + else: + raise ValueError(f"Duplicate attribute in ATTRIBUTES: {attribute}") +if _temp_attributes: + ATTRIBUTES.update(_temp_attributes) + +# list of all attributes for help text +ATTRIBUTES_LIST = [ + f"{'Short Name':{_SHORT_NAME_WIDTH}} {'Constant':{_CONSTANT_WIDTH}} Long Name" +] +ATTRIBUTES_LIST.extend( + [ + f"{a.name:{_SHORT_NAME_WIDTH}} " + f"{a.constant.split(':',2)[1]:{_CONSTANT_WIDTH}} " + f"{a.constant}" + for a in [ + ATTRIBUTES[a] + for a in set([attribute.name for attribute in ATTRIBUTES.values()]) + ] + ] +) diff --git a/osxmetadata/classes.py b/osxmetadata/classes.py new file mode 100644 index 0000000..74bb8de --- /dev/null +++ b/osxmetadata/classes.py @@ -0,0 +1,270 @@ +""" Classes for metadata attribute types """ + +import collections.abc +import datetime +import plistlib +from plistlib import FMT_BINARY # pylint: disable=E0611 + +from .constants import _COLORNAMES, _VALID_COLORIDS + + +class _AttributeList(collections.abc.MutableSequence): + """ represents a multi-valued OSXMetaData attribute list """ + + def __init__(self, attribute, xattr_): + self._attribute = attribute + self._attrs = xattr_ + self._constant = attribute.constant + + self.data = [] + self._values = [] + self._load_data() + + def set_value(self, value): + self.data = value + self._write_data() + + def _load_data(self): + self._values = [] + try: + # self._tagvalues = self._attrs[self._constant] + # load the binary plist value + self._values = plistlib.loads(self._attrs[self._constant]) + if self._values: + try: + self.data = list(self._values) + except TypeError: + self.data = set([self._values]) + else: + self.data = [] + # for x in self._tagvalues: + # (tag, color) = self._tag_split(x) + # self._tags[tag] = color + # # self._tags = [self._tag_strip_color(x) for x in self._tagvalues] + except KeyError: + self.data = [] + + def __delitem__(self, index): + self.data.__delitem__(index) + self._write_data() + + def __getitem__(self, index): + self._load_data() + return self.data.__getitem__(index) + + def __len__(self): + return self.data.__len__() + + def __setitem__(self, index, value): + self.data.__setitem__(index, value) + self._write_data() + self._load_data() + + def insert(self, index, value): + self.data.insert(index, value) + self._write_data() + + def _write_data(self): + # Overwrites the existing attribute values with the iterable of values provided. + plist = plistlib.dumps(self.data, fmt=FMT_BINARY) + self._attrs.set(self._constant, plist) + + def __repr__(self): + # return f"{type(self).__name__}({super().__repr__()})" + return repr(self.data) + + def __eq__(self, other): + return self.data == other + + +class _AttributeSet: + """ represents a multi-valued OSXMetaData attribute set """ + + def __init__(self, attribute, xattr_): + self._attribute = attribute + self._attrs = xattr_ + self._constant = attribute.constant + + self.data = set() + + # # used for __iter__ + # self._list = None + # self._count = None + # self._counter = None + + # initialize + self._load_data() + + def set_value(self, values): + """ set value to values """ + self.data = set(map(self._normalize, values)) + self._write_data() + + def add(self, value): + """ add a value""" + # if not isinstance(tag, list): + # raise TypeError("Tags must be strings") + self._load_data() + values = set(map(self._normalize, self.data)) + self.data.add(self._normalize(value)) + self._write_data() + + def update(self, *values): + """ update data adding any new values in *values """ + if not all(isinstance(value, str) for value in values): + raise TypeError("Value must be string") + self._load_data() + old_values = set(map(self._normalize, self.data)) + new_values = old_values.union(set(map(self._normalize, values))) + self.data = new_values + self._write_data() + + def clear(self): + """ clear attribute (removes all values) """ + try: + self._attrs.remove(self._constant) + except (IOError, OSError): + pass + + def remove(self, value): + """ remove a value, raise exception if value does not exist in data set """ + self._load_data() + if not isinstance(value, str): + raise TypeError("Value must be string") + values = set(map(self._normalize, self.data)) + values.remove(self._normalize(value)) + self.data = values + self._write_data() + + def discard(self, value): + """ remove a value, does not raise exception if value does not exist """ + self._load_data() + if not isinstance(value, str): + raise TypeError("Value must be string") + values = set(map(self._normalize, self.data)) + values.discard(self._normalize(value)) + self.data = values + self._write_data() + + def _load_data(self): + self._values = [] + try: + # self._tagvalues = self._attrs[self._constant] + # load the binary plist value + self._values = plistlib.loads(self._attrs[self._constant]) + if self._values: + try: + self.data = set(self._values) + except TypeError: + self.data = set([self._values]) + else: + self.data = set() + # for x in self._tagvalues: + # (tag, color) = self._tag_split(x) + # self._tags[tag] = color + # # self._tags = [self._tag_strip_color(x) for x in self._tagvalues] + except KeyError: + self.data = set() + + def _write_data(self): + # TODO: change this to write_data and to read from self.data + # Overwrites the existing tags with the iterable of tags provided. + plist = plistlib.dumps(list(map(self._normalize, self.data)), fmt=FMT_BINARY) + self._attrs.set(self._constant, plist) + + def _normalize(self, value): + """ processes a value to normalize/transform the value if needed + override in sublcass if desired (e.g. used _TagsSet) """ + return value + + def __iter__(self): + self._load_data() + for value in self.data: + yield value + + def __len__(self): + self._load_data() + return len(self.data) + + def __repr__(self): + self._load_data() + return repr(self.data) + + def __str__(self): + self._load_data() + if self._attribute.type_ == datetime.datetime: + values = [d.isoformat() for d in self.data] + else: + values = self.data + return str(list(values)) + + def __iadd__(self, value): + for v in value: + self.add(v) + return self + + +class _AttributeTagsSet(_AttributeSet): + """ represents a _kMDItemUserTag attribute set """ + + def _tag_split(self, tag): + # Extracts the color information from a Finder tag. + + parts = tag.rsplit("\n", 1) + if len(parts) == 1: + return parts[0], 0 + elif ( + len(parts[1]) != 1 or parts[1] not in _VALID_COLORIDS + ): # Not a color number + return tag, 0 + else: + return parts[0], int(parts[1]) + + def _normalize(self, tag): + """ + Ensures a color is set if not none. + :param tag: a possibly non-normal tag. + :return: A colorized tag. + """ + tag, color = self._tag_split(tag) + if tag.title() in _COLORNAMES: + # ignore the color passed and set proper color name + return self._tag_colored(tag.title(), _COLORNAMES[tag.title()]) + else: + return self._tag_colored(tag, color) + + def _tag_nocolor(self, tag): + """ + Removes the color information from a Finder tag. + """ + return tag.rsplit("\n", 1)[0] + + def _tag_colored(self, tag, color): + """ + Sets the color of a tag. + + Parameters: + tag(str): a tag name + color(int): an integer from 1 through 7 + + Return: + (str) the tag with encoded color. + """ + return "{}\n{}".format(self._tag_nocolor(tag), color) + + def _load_data(self): + self._tags = {} + try: + self._tagvalues = self._attrs[self._constant] + # load the binary plist value + self._tagvalues = plistlib.loads(self._tagvalues) + for x in self._tagvalues: + (tag, color) = self._tag_split(x) + self._tags[tag] = color + # self._tags = [self._tag_strip_color(x) for x in self._tagvalues] + except KeyError: + self._tags = None + if self._tags: + self.data = set(self._tags.keys()) + else: + self.data = set() diff --git a/osxmetadata/constants.py b/osxmetadata/constants.py index 3a8626b..7428030 100644 --- a/osxmetadata/constants.py +++ b/osxmetadata/constants.py @@ -1,6 +1,4 @@ -import datetime -from collections import namedtuple - +""" constants and definitions used by osxmetadata """ # color labels _COLORNAMES = { @@ -31,65 +29,42 @@ 1024 ) # just picked something....todo: need to figure out what max length is -_TAGS = "com.apple.metadata:_kMDItemUserTags" -_FINDER_COMMENT = "com.apple.metadata:kMDItemFinderComment" -_WHERE_FROM = "com.apple.metadata:kMDItemWhereFroms" -_DOWNLOAD_DATE = "com.apple.metadata:kMDItemDownloadedDate" - +# _TAGS = "com.apple.metadata:_kMDItemUserTags" +# _FINDER_COMMENT = "com.apple.metadata:kMDItemFinderComment" +# _WHERE_FROM = "com.apple.metadata:kMDItemWhereFroms" +# _DOWNLOAD_DATE = "com.apple.metadata:kMDItemDownloadedDate" -### Experimenting with generic method of reading / writing attributes -Attribute = namedtuple("Attribute", ["name", "constant", "type", "list", "as_list"]) - -ATTRIBUTES = { - "authors": Attribute( - "authors", "com.apple.metadata:kMDItemAuthors", str, True, False - ), - "creator": Attribute( - "creator", "com.apple.metadata:kMDItemCreator", str, False, False - ), - "description": Attribute( - "description", "com.apple.metadata:kMDItemDescription", str, False, False - ), - "downloadeddate": Attribute( - "downloadeddate", - "com.apple.metadata:kMDItemDownloadedDate", - datetime.datetime, - False, - True, - ), - "findercomment": Attribute( - "findercomment", "com.apple.metadata:kMDItemFinderComment", str, False, False - ), - "headline": Attribute( - "headline", "com.apple.metadata:kMDItemHeadline", str, False, False - ), - "keywords": Attribute( - "keywords", "com.apple.metadata:kMDItemKeywords", str, True, False - ), - "tags": Attribute("tags", "com.apple.metadata:_kMDItemUserTags", str, True, False), - "wherefroms": Attribute( - "wherefroms", "com.apple.metadata:kMDItemWhereFroms", str, True, False - ), -} -# used for formatting output of --list -_SHORT_NAME_WIDTH = max([len(x) for x in ATTRIBUTES.keys()]) + 5 -_LONG_NAME_WIDTH = max([len(x.constant) for x in ATTRIBUTES.values()]) + 10 +kMDItemAuthors = "com.apple.metadata:kMDItemAuthors" +kMDItemCreator = "com.apple.metadata:kMDItemCreator" +kMDItemDescription = "com.apple.metadata:kMDItemDescription" +kMDItemDownloadedDate = "com.apple.metadata:kMDItemDownloadedDate" +kMDItemFinderComment = "com.apple.metadata:kMDItemFinderComment" +kMDItemHeadline = "com.apple.metadata:kMDItemHeadline" +kMDItemKeywords = "com.apple.metadata:kMDItemKeywords" +_kMDItemUserTags = "com.apple.metadata:_kMDItemUserTags" +kMDItemUserTags = "com.apple.metadata:_kMDItemUserTags" +kMDItemWhereFroms = "com.apple.metadata:kMDItemWhereFroms" -# also add entries for attributes by constant, do this after computing widths above -_temp_attributes = {} -for attribute in ATTRIBUTES.values(): - if attribute.constant not in ATTRIBUTES: - _temp_attributes[attribute.constant] = attribute - else: - raise ValueError(f"Duplicate attribute in ATTRIBUTES: {attribute}") -if _temp_attributes: - ATTRIBUTES.update(_temp_attributes) # Special handling for Finder comments -_FINDER_COMMENT_NAMES = ["findercomment", "com.apple.metadata:kMDItemFinderComment"] +_FINDER_COMMENT_NAMES = [ + "findercomment", + "kMDItemFinderComment", + "com.apple.metadata:kMDItemFinderComment", +] _TAGS_NAMES = ["tags", "com.apple.metadata:_kMDItemUserTags"] -# list of all attributes for help text -ATTRIBUTES_LIST = [f"{'Short Name':16} Long Name"] -ATTRIBUTES_LIST.extend([f"{a.name:16} {a.constant}" for a in ATTRIBUTES.values()]) +__all__ = [ + "kMDItemAuthors", + "kMDItemCreator", + "kMDItemDescription", + "kMDItemDownloadedDate", + "kMDItemFinderComment", + "kMDItemFinderComment", + "kMDItemHeadline", + "kMDItemKeywords", + "kMDItemUserTags", + "_kMDItemUserTags", + "kMDItemWhereFroms", +] diff --git a/osxmetadata/utils.py b/osxmetadata/utils.py index d9e6090..8a11f9d 100644 --- a/osxmetadata/utils.py +++ b/osxmetadata/utils.py @@ -1,6 +1,44 @@ +import datetime +import logging import os + from . import _applescript +_DEBUG = False + +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s - %(levelname)s - %(filename)s - %(lineno)d - %(message)s", +) + +if not _DEBUG: + logging.disable(logging.DEBUG) + + +def _get_logger(): + """Used only for testing + + Returns: + logging.Logger object -- logging.Logger object for osxmetadata + """ + return logging.Logger(__name__) + + +def _set_debug(debug): + """ Enable or disable debug logging """ + global _DEBUG + _DEBUG = debug + if debug: + logging.disable(logging.NOTSET) + else: + logging.disable(logging.DEBUG) + + +def _debug(): + """ returns True if debugging turned on (via _set_debug), otherwise, false """ + return _DEBUG + + _scpt_set_finder_comment = _applescript.AppleScript( """ on run {fname, fc} @@ -21,3 +59,79 @@ def set_finder_comment(filename, comment): _scpt_set_finder_comment.run(filename, comment) + +def validate_attribute_value(attribute, value): + """ validate that value is compatible with attribute.type_ + and convert value to correct type + returns value as type attribute.type_ + value may be a single value or a list depending on what attribute expects + if value contains None, returns None """ + + logging.debug( + f"validate_attribute_value: attribute: {attribute}, value: {value}, type: {type(value)}" + ) + + # check to see if we got None + try: + if None in value: + return None + except TypeError: + if value is None: + return None + + try: + if isinstance(value, str): + value = [value] + else: + iter(value) + except TypeError: + # ZZZ handle passing in values as _AttributeSet, et + # ZZZ also handle string which can iterate but we don't want it to + value = [value] + + # # check for None and convert to list if needed + # if not isinstance(value, list): + # if value is None: + # return None + # value = [value] + # elif None in value: + # return None + + if not attribute.list and len(value) > 1: + # got a list but didn't expect one + raise ValueError( + f"{attribute.name} expects only one value but list of {len(value)} provided" + ) + + new_values = [] + for val in value: + new_val = None + if attribute.type_ == str: + new_val = str(val) + elif attribute.type_ == float: + try: + new_val = float(val) + except: + raise TypeError( + f"{val} cannot be converted to expected type {attribute.type_}" + ) + elif attribute.type_ == datetime.datetime: + if not isinstance(val, datetime.datetime): + # if not already a datetime.datetime, try to convert it + try: + new_val = datetime.datetime.fromisoformat(val) + except: + raise TypeError( + f"{val} cannot be converted to expected type {attribute.type_}" + ) + else: + new_val = val + else: + raise TypeError(f"Unknown type: {type(val)}") + new_values.append(new_val) + + logging.debug(f"new_value = {new_values}") + if attribute.list: + return new_values + else: + return new_values[0] diff --git a/tests/test_osxmetadata.py b/tests/test_osxmetadata.py index d49dc14..6d8adc3 100644 --- a/tests/test_osxmetadata.py +++ b/tests/test_osxmetadata.py @@ -3,6 +3,7 @@ from tempfile import NamedTemporaryFile import pytest +import platform @pytest.fixture @@ -45,16 +46,20 @@ def test_tags(temp_file): assert set(meta.tags) == set(["Test", "Green", "Foo"]) # __iadd__ - meta.tags += "Bar" + meta.tags += ["Bar"] assert set(meta.tags) == set(["Test", "Green", "Foo", "Bar"]) + # __iadd__ set + meta.tags += set(["Baz"]) + assert set(meta.tags) == set(["Test", "Green", "Foo", "Bar", "Baz"]) + # __repr__ tags = set(meta.tags) - assert tags == set(["Test", "Green", "Foo", "Bar"]) + assert tags == set(["Test", "Green", "Foo", "Bar", "Baz"]) # remove tags meta.tags.remove("Test") - assert set(meta.tags) == set(["Green", "Foo", "Bar"]) + assert set(meta.tags) == set(["Green", "Foo", "Bar", "Baz"]) # remove tag that doesn't exist, raises KeyError with pytest.raises(KeyError): @@ -62,40 +67,99 @@ def test_tags(temp_file): # discard tags meta.tags.discard("Green") - assert set(meta.tags) == set(["Foo", "Bar"]) + assert set(meta.tags) == set(["Foo", "Bar", "Baz"]) # discard tag that doesn't exist, no error meta.tags.discard("FooBar") - assert set(meta.tags) == set(["Foo", "Bar"]) + assert set(meta.tags) == set(["Foo", "Bar", "Baz"]) # len - assert len(meta.tags) == 2 + assert len(meta.tags) == 3 # clear tags meta.tags.clear() assert set(meta.tags) == set([]) -def test_finder_comments(temp_file): +def test_tags_2(temp_file): + # # Not using setlocale(LC_ALL, ..) to set locale because + # # sys.getfilesystemencoding() implementation falls back + # # to user's preferred locale by calling setlocale(LC_ALL, ''). + # xattr.compat.fs_encoding = 'UTF-8' + from osxmetadata import OSXMetaData, _MAX_FINDERCOMMENT + # update tags + meta = OSXMetaData(temp_file) + tagset = ["Test", "Green"] + meta.tags.update(*tagset) + assert set(meta.tags) == set(tagset) + + # __iadd__ string + meta.tags += "Bar" + assert set(meta.tags) == set(["Test", "Green", "B", "a", "r"]) + + # len + assert len(meta.tags) == 5 + + # clear tags + meta.tags.clear() + assert set(meta.tags) == set([]) + + +def test_finder_comments(temp_file): + from osxmetadata import OSXMetaData + meta = OSXMetaData(temp_file) fc = "This is my new comment" - meta.finder_comment = fc - assert meta.finder_comment == fc - meta.finder_comment += ", foo" + meta.findercomment = fc + assert meta.findercomment == fc + meta.findercomment += ", foo" fc += ", foo" - assert meta.finder_comment == fc + assert meta.findercomment == fc - with pytest.raises(ValueError): - meta.finder_comment = "x" * _MAX_FINDERCOMMENT + "x" + # set finder comment to "" results in null string but not deleted + meta.findercomment = "" + assert meta.findercomment == "" + + meta.findercomment = "foo" + assert meta.findercomment == "foo" + + # set finder comment to None deletes it + meta.findercomment = None + assert meta.findercomment == None + + # can we set findercomment after is was set to None? + meta.findercomment = "bar" + assert meta.findercomment == "bar" - # set finder comment to None same as '' - meta.finder_comment = None - assert meta.finder_comment == "" - meta.finder_comment = "" - assert meta.finder_comment == "" +def test_creator(temp_file): + from osxmetadata import OSXMetaData + + meta = OSXMetaData(temp_file) + creator = "Rhet Turnbull" + meta.creator = creator + assert meta.creator == creator + + creator += " and you!" + meta.creator = creator + assert meta.creator == creator + + meta.creator = None + assert meta.creator is None + + +@pytest.mark.skipif( + int(platform.mac_ver()[0].split(".")[1]) >= 15, + reason="limit on finder comment length seems to be gone on 10.15+", +) +def test_finder_comments_max(temp_file): + from osxmetadata import OSXMetaData, _MAX_FINDERCOMMENT + + meta = OSXMetaData(temp_file) + with pytest.raises(ValueError): + meta.findercomment = "x" * _MAX_FINDERCOMMENT + "x" def test_name(temp_file): @@ -107,23 +171,33 @@ def test_name(temp_file): assert meta.name == fname.resolve().as_posix() -def test_download_date(temp_file): +def test_downloaded_date(temp_file): from osxmetadata import OSXMetaData import datetime meta = OSXMetaData(temp_file) dt = datetime.datetime.now() - meta.download_date = dt - assert meta.download_date == dt + meta.downloadeddate = dt + assert meta.downloadeddate == [dt] + + +def test_downloaded_date_isoformat(temp_file): + from osxmetadata import OSXMetaData + import datetime + + meta = OSXMetaData(temp_file) + dt = "2020-02-17 00:25:00" + meta.downloadeddate = dt + assert meta.downloadeddate == [datetime.datetime.fromisoformat(dt)] -def test_download_where_from(temp_file): +def test_download_where_froms(temp_file): from osxmetadata import OSXMetaData meta = OSXMetaData(temp_file) - meta.where_from = ["http://google.com", "https://apple.com"] - wf = meta.where_from - assert wf == ["http://google.com", "https://apple.com"] + meta.wherefroms = ["http://google.com", "https://apple.com"] + wf = meta.wherefroms + assert sorted(wf) == sorted(["http://google.com", "https://apple.com"]) def test_file_not_exists(temp_file): diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..cd5bd6b --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,19 @@ +import pytest + + +def test_debug_enable(): + import osxmetadata + import logging + + osxmetadata._set_debug(True) + logger = osxmetadata.utils._get_logger() + assert logger.isEnabledFor(logging.DEBUG) + + +def test_debug_disable(): + import osxmetadata + import logging + + osxmetadata._set_debug(False) + logger = osxmetadata.utils._get_logger() + assert not logger.isEnabledFor(logging.DEBUG)