Skip to content

Commit

Permalink
tests passed!
Browse files Browse the repository at this point in the history
  • Loading branch information
RhetTbull committed Feb 21, 2020
1 parent 2b86f1a commit 6466adf
Show file tree
Hide file tree
Showing 8 changed files with 1,000 additions and 792 deletions.
571 changes: 269 additions & 302 deletions osxmetadata/__init__.py

Large diffs are not rendered by default.

493 changes: 86 additions & 407 deletions osxmetadata/__main__.py

Large diffs are not rendered by default.

110 changes: 110 additions & 0 deletions osxmetadata/attributes.py
Original file line number Diff line number Diff line change
@@ -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()])
]
]
)
270 changes: 270 additions & 0 deletions osxmetadata/classes.py
Original file line number Diff line number Diff line change
@@ -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()

0 comments on commit 6466adf

Please sign in to comment.