Skip to content

Commit

Permalink
Fixed handling of datetime attributes, closes #17
Browse files Browse the repository at this point in the history
  • Loading branch information
RhetTbull committed Apr 16, 2020
1 parent 609947c commit 10e17d5
Show file tree
Hide file tree
Showing 12 changed files with 617 additions and 328 deletions.
85 changes: 65 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,12 @@ description kMDItemDescription, com.apple.metadata:kMDItemDescription; A
of the content. A string.
downloadeddate kMDItemDownloadedDate,
com.apple.metadata:kMDItemDownloadedDate; The date the item
was downloaded. A date in ISO 8601 format: e.g.
2000-01-12T12:00:00 or 2000-12-31 (ISO 8601 w/o time zone)
was downloaded. A date in ISO 8601 format, time and
timezone offset are optional: e.g. 2020-04-14T12:00:00 (ISO
8601 w/o timezone), 2020-04-14 (ISO 8601 w/o time and time
zone), or 2020-04-14T12:00:00-07:00 (ISO 8601 with timezone
offset). Times without timezone offset are assumed to be in
local timezone.
findercomment kMDItemFinderComment,
com.apple.metadata:kMDItemFinderComment; Finder comments for
this file. A string.
Expand Down Expand Up @@ -138,17 +142,17 @@ Information about commonly used MacOS metadata attributes is available from [App

| Constant | Short Name | Long Constant | Description |
|---------------|----------|---------|-----------|
|kMDItemAuthors|authors|com.apple.metadata:kMDItemAuthors|The author, or authors, of the contents of the file. A list of strings.|
|kMDItemComment|comment|com.apple.metadata:kMDItemComment|A comment related to the file. This differs from the Finder comment, kMDItemFinderComment. A string.|
|kMDItemCopyright|copyright|com.apple.metadata:kMDItemCopyright|The copyright owner of the file contents. A string.|
|kMDItemCreator|creator|com.apple.metadata:kMDItemCreator|Application used to create the document content (for example “Word”, “Pages”, and so on). A string.|
|kMDItemDescription|description|com.apple.metadata:kMDItemDescription|A description of the content of the resource. The description may include an abstract, table of contents, reference to a graphical representation of content or a free-text account of the content. A string.|
|kMDItemDownloadedDate|downloadeddate|com.apple.metadata:kMDItemDownloadedDate|The date the item was downloaded. A date in ISO 8601 format: e.g. 2000-01-12T12:00:00 or 2000-12-31 (ISO 8601 w/o time zone)|
|kMDItemFinderComment|findercomment|com.apple.metadata:kMDItemFinderComment|Finder comments for this file. A string.|
|kMDItemHeadline|headline|com.apple.metadata:kMDItemHeadline|A publishable entry providing a synopsis of the contents of the file. A string.|
|kMDItemKeywords|keywords|com.apple.metadata:kMDItemKeywords|Keywords associated with this file. For example, “Birthday”, “Important”, etc. This differs from Finder tags (_kMDItemUserTags) which are keywords/tags shown in the Finder and searchable in Spotlight using "tag:tag_name"A list of strings.|
|_kMDItemUserTags|tags|com.apple.metadata:_kMDItemUserTags|Finder tags; searchable in Spotlight using "tag:tag_name". If you want tags/keywords visible in the Finder, use this instead of kMDItemKeywords. A list of strings.|
|kMDItemWhereFroms|wherefroms|com.apple.metadata:kMDItemWhereFroms|Describes where the file was obtained from (e.g. URL downloaded from). A list of strings.|
|kMDItemAuthors|authors|com.apple.metadata:kMDItemAuthors|The author, or authors, of the contents of the file. A list of strings.|
|kMDItemComment|comment|com.apple.metadata:kMDItemComment|A comment related to the file. This differs from the Finder comment, kMDItemFinderComment. A string.|
|kMDItemCopyright|copyright|com.apple.metadata:kMDItemCopyright|The copyright owner of the file contents. A string.|
|kMDItemCreator|creator|com.apple.metadata:kMDItemCreator|Application used to create the document content (for example “Word”, “Pages”, and so on). A string.|
|kMDItemDescription|description|com.apple.metadata:kMDItemDescription|A description of the content of the resource. The description may include an abstract, table of contents, reference to a graphical representation of content or a free-text account of the content. A string.|
|kMDItemDownloadedDate|downloadeddate|com.apple.metadata:kMDItemDownloadedDate|The date the item was downloaded. A datetime.datetime object. If datetime.datetime object lacks tzinfo (i.e. it is timezone naive), it will be assumed to be in local timezone.|
|kMDItemFinderComment|findercomment|com.apple.metadata:kMDItemFinderComment|Finder comments for this file. A string.|
|kMDItemHeadline|headline|com.apple.metadata:kMDItemHeadline|A publishable entry providing a synopsis of the contents of the file. A string.|
|kMDItemKeywords|keywords|com.apple.metadata:kMDItemKeywords|Keywords associated with this file. For example, “Birthday”, “Important”, etc. This differs from Finder tags (_kMDItemUserTags) which are keywords/tags shown in the Finder and searchable in Spotlight using "tag:tag_name". A list of strings.|
|_kMDItemUserTags|tags|com.apple.metadata:_kMDItemUserTags|Finder tags; searchable in Spotlight using "tag:tag_name". If you want tags/keywords visible in the Finder, use this instead of kMDItemKeywords. A list of strings.|
|kMDItemWhereFroms|wherefroms|com.apple.metadata:kMDItemWhereFroms|Describes where the file was obtained from (e.g. URL downloaded from). A list of strings.|


## Example uses of the package
Expand Down Expand Up @@ -229,17 +233,19 @@ ValueError: list.remove(x): x not in list
2
```

If attribute is a date/time stamp (e.g. kMDItemDownloadedDate), value should be a `datetime.datetime` object (or a list of `datetime.datetime` objects depending on the attribute type):
If attribute is a date/time stamp (e.g. kMDItemDownloadedDate), value should be a `datetime.datetime` object (or a list of `datetime.datetime` objects depending on the attribute type).

**Note**: `datetime.datetime` objects may be naive (lack timezone info, e.g. `tzinfo=None`) or timezone aware (have an associated timezone). If `datetime.datetime` object lacks timezone info, it will be assumed to be local time. MacOS stores date values in extended attributes as UTC timestamps so all `datetime.datetime` objects will undergo appropriate conversion prior to writing to the extended attribute. See also [tz_aware](#tz_aware).

```python
>>> import osxmetadata
>>> import datetime
>>> md = osxmetadata.OSXMetaData("/Users/rhet/Downloads/test.jpg")
>>> md = osxmetadata.OSXMetaData("/Users/rhet/Downloads/test.zip")
>>> md.downloadeddate
[datetime.datetime(2012, 2, 13, 0, 0)]
>>> md.downloadeddate = [datetime.datetime.now()]
[datetime.datetime(2020, 4, 14, 17, 51, 59, 40504)]
>>> now = datetime.datetime.now()
>>> md.downloadeddate = now
>>> md.downloadeddate
[datetime.datetime(2020, 2, 29, 8, 36, 10, 332350)]
[datetime.datetime(2020, 4, 15, 22, 17, 0, 558471)]
```

If attribute is string, it can be treated as a standard python `str`:
Expand Down Expand Up @@ -283,7 +289,10 @@ meta.clear_attribute("tags")
## OSXMetaData methods and attributes

### Create an OSXMetaData object
`md = osxmetadata.OSXMetaData(filename)`
`md = osxmetadata.OSXMetaData(filename, tz_aware = False)`

- filename: filename to operate on
- tz_aware: (boolean, optional); if True, attributes which return datetime.datetime objects such as kMDItemDownloadedDate will return timezone aware datetime.datetime objects with timezone set to UTC; if False (default), will return timezone naive objects in user's local timezone. See also [tz_aware](#tz_aware).

Once created, the following methods and attributes may be used to get/set metadata attribute data

Expand Down Expand Up @@ -364,6 +373,42 @@ List the Apple metadata attributes set on the file. e.g. those in com.apple.met

Return dict in JSON format with all attributes for this file. Format is the same as used by the command line --backup/--restore functions.

### tz_aware
`tz_aware`

Property (boolean, default = False). If True, any attribute that returns a datetime.datetime object will return a timezone aware object. If False, datetime.datetime attributes will return timezone naive objects.

For example:


```python
>>> import osxmetadata
>>> import datetime
>>> md = osxmetadata.OSXMetaData("/Users/rhet/Downloads/test.zip")
>>> md.downloadeddate
[datetime.datetime(2020, 4, 14, 17, 51, 59, 40504)]
>>> now = datetime.datetime.now()
>>> md.downloadeddate = now
>>> md.downloadeddate
[datetime.datetime(2020, 4, 15, 22, 17, 0, 558471)]
>>> md.tz_aware = True
>>> md.downloadeddate
[datetime.datetime(2020, 4, 16, 5, 17, 0, 558471, tzinfo=datetime.timezone.utc)]
>>> utc = datetime.datetime.utcnow()
>>> utc
datetime.datetime(2020, 4, 16, 5, 25, 10, 635417)
>>> utc = utc.replace(tzinfo=datetime.timezone.utc)
>>> utc
datetime.datetime(2020, 4, 16, 5, 25, 10, 635417, tzinfo=datetime.timezone.utc)
>>> md.downloadeddate = utc
>>> md.downloadeddate
[datetime.datetime(2020, 4, 16, 5, 25, 10, 635417, tzinfo=datetime.timezone.utc)]
>>> md.tz_aware = False
>>> md.downloadeddate
[datetime.datetime(2020, 4, 15, 22, 25, 10, 635417)]
```


## Usage Notes

Changes are immediately written to the file. For example, OSXMetaData.tags.append("Foo") immediately writes the tag 'Foo' to the file.
Expand Down
130 changes: 49 additions & 81 deletions osxmetadata/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
""" Python module to read and write various Mac OS X metadata
""" Python package to read and write various Mac OS X metadata
such as tags/keywords and Finder comments from files """


Expand All @@ -17,15 +17,17 @@

import xattr

from .attributes import ATTRIBUTES, Attribute
from ._version import __version__
from .attributes import ATTRIBUTES, Attribute, validate_attribute_value
from .classes import _AttributeList, _AttributeTagsList
from .constants import ( # _DOWNLOAD_DATE,; _FINDER_COMMENT,; _TAGS,; _WHERE_FROM,
from .constants import (
_COLORIDS,
_COLORNAMES,
_FINDER_COMMENT_NAMES,
_MAX_FINDERCOMMENT,
_MAX_WHEREFROM,
_VALID_COLORIDS,
_kMDItemUserTags,
kMDItemAuthors,
kMDItemComment,
kMDItemCopyright,
Expand All @@ -36,18 +38,18 @@
kMDItemHeadline,
kMDItemKeywords,
kMDItemUserTags,
_kMDItemUserTags,
kMDItemWhereFroms,
)
from .utils import (
_debug,
_get_logger,
_set_debug,
set_finder_comment,
clear_finder_comment,
validate_attribute_value,
datetime_naive_to_utc,
datetime_remove_tz,
datetime_utc_to_local,
set_finder_comment,
)
from ._version import __version__

__all__ = [
"OSXMetaData",
Expand Down Expand Up @@ -80,6 +82,7 @@ class OSXMetaData:
"_fname",
"_posix_name",
"_attrs",
"_tz_aware",
"__init",
"authors",
"comment",
Expand All @@ -94,10 +97,15 @@ class OSXMetaData:
"wherefroms",
]

def __init__(self, fname):
"""Create an OSXMetaData object to access file metadata"""
def __init__(self, fname, tz_aware=False):
"""Create an OSXMetaData object to access file metadata
fname: filename to operate on
timezone_aware: bool; if True, date/time attributes will return
timezone aware datetime.dateime attributes; if False (default)
date/time attributes will return timezone naive objects """
self._fname = pathlib.Path(fname)
self._posix_name = self._fname.resolve().as_posix()
self._tz_aware = tz_aware

if not self._fname.exists():
raise FileNotFoundError("file does not exist: ", fname)
Expand All @@ -110,7 +118,9 @@ def __init__(self, fname):
for name in set([attribute.name for attribute in ATTRIBUTES.values()]):
attribute = ATTRIBUTES[name]
if attribute.class_ not in [str, float, datetime.datetime]:
super().__setattr__(name, attribute.class_(attribute, self._attrs))
super().__setattr__(
name, attribute.class_(attribute, self._attrs, self)
)

# Done with initialization
self.__init = True
Expand All @@ -120,6 +130,17 @@ def name(self):
""" POSIX path of the file OSXMetaData is operating on """
return self._fname.resolve().as_posix()

@property
def tz_aware(self):
""" returns the timezone aware flag """
return self._tz_aware

@tz_aware.setter
def tz_aware(self, tz_flag):
""" sets the timezone aware flag
tz_flag: bool """
self._tz_aware = tz_flag

def _to_dict(self):
""" Return dict with all attributes for this file
key of dict is filename and value is another dict with attributes """
Expand Down Expand Up @@ -187,6 +208,24 @@ def get_attribute(self, attribute_name):
except KeyError:
plist = None

# add UTC to any datetime.datetime objects because that's how MacOS stores them
# In the plist associated with extended metadata attributes, times are stored as:
# <date>2020-04-14T14:49:22Z</date>
if plist and isinstance(plist, list):
if isinstance(plist[0], datetime.datetime):
plist = [datetime_naive_to_utc(d) for d in plist]
if not self._tz_aware:
# want datetimes in naive format
plist = [
datetime_remove_tz(d_local)
for d_local in [datetime_utc_to_local(d_utc) for d_utc in plist]
]
elif isinstance(plist, datetime.datetime):
plist = datetime_naive_to_utc(plist)
if not self._tz_aware:
# want datetimes in naive format
plist = datetime_remove_tz(datetime_utc_to_local(plist))

if attribute.as_list and isinstance(plist, list):
return plist[0]
else:
Expand Down Expand Up @@ -221,24 +260,6 @@ def set_attribute(self, attribute_name, value):
# verify type is correct
value = validate_attribute_value(attribute, value)

# if attribute.list and (type(value) == list or type(value) == set):
# for val in value:
# if attribute.type_ != type(val):
# raise ValueError(
# f"Expected type {attribute.type_} but value is type {type(val)}"
# )
# elif not attribute.list and (type(value) == list or type(value) == set):
# raise TypeError(f"Expected single value but got list for {attribute.type_}")
# elif attribute.type_ != type(value):
# raise ValueError(
# f"Expected type {attribute.type_} but value is type {type(value)}"
# )

# if attribute.as_list and (type(value) != list and type(value) != set):
# # some attributes like kMDItemDownloadedDate are stored in a list
# # even though they only have only a single value
# value = [value]

if attribute.name in _FINDER_COMMENT_NAMES:
# Finder Comment needs special handling
# code following will also set the attribute for Finder Comment
Expand Down Expand Up @@ -295,58 +316,6 @@ def append_attribute(self, attribute_name, value, update=False):
else:
new_value = value

# # verify type is correct
# if attribute.list and (type(value) == list or type(value) == set):
# # expected a list, got a list
# for val in value:
# # 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)}"
# )
# else:
# if new_value:
# # ZZZ TODO: this will fail if new_value is False
# new_value = list(new_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:
# 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 or type(value) == set):
# raise TypeError(f"Expected single value but got list for {attribute.type_}")
# else:
# # 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)}"
# )
# 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:
# new_value = value

# if attribute.as_list:
# # some attributes like kMDItemDownloadedDate are stored in a list
# # even though they only have only a single value
# new_value = [new_value]

try:
if attribute.name in _FINDER_COMMENT_NAMES:
# Finder Comment needs special handling
Expand Down Expand Up @@ -452,7 +421,6 @@ def __setattr__(self, name, value):
if self.__init:
# already initialized
attribute = ATTRIBUTES[name]
value = validate_attribute_value(attribute, value)
if value is None:
self.clear_attribute(attribute.name)
else:
Expand Down
7 changes: 2 additions & 5 deletions osxmetadata/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from .attributes import _LONG_NAME_WIDTH, _SHORT_NAME_WIDTH, ATTRIBUTES
from .classes import _AttributeList, _AttributeTagsList
from .constants import _BACKUP_FILENAME, _TAGS_NAMES
from .utils import load_backup_file, validate_attribute_value, write_backup_file
from .utils import load_backup_file, write_backup_file

# TODO: how is metadata on symlink handled?
# should symlink be resolved before gathering metadata?
Expand Down Expand Up @@ -502,7 +502,7 @@ def process_file(
):
""" process a single file to apply the options
options processed in this order: wipe, copyfrom, clear, set, append, remove, mirror, get, list
Note: expects all attributes passed in parameters to be validated """
Note: expects all attributes passed in parameters to be validated as valid attributes """

logging.debug(f"process_file: {fpath}")

Expand Down Expand Up @@ -557,7 +557,6 @@ def process_file(
attr_dict[attribute] = [val]

for attribute, value in attr_dict.items():
value = validate_attribute_value(attribute, value)
md.set_attribute(attribute.name, value)

if append:
Expand All @@ -575,7 +574,6 @@ def process_file(
attr_dict[attribute] = [val]

for attribute, value in attr_dict.items():
value = validate_attribute_value(attribute, value)
md.append_attribute(attribute.name, value)

if update:
Expand All @@ -593,7 +591,6 @@ def process_file(
attr_dict[attribute] = [val]

for attribute, value in attr_dict.items():
value = validate_attribute_value(attribute, value)
md.update_attribute(attribute.name, value)

if remove:
Expand Down
2 changes: 1 addition & 1 deletion osxmetadata/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.98.12"
__version__ = "0.98.14"
Loading

0 comments on commit 10e17d5

Please sign in to comment.