diff --git a/README.md b/README.md
index 5c042df..24cf457 100644
--- a/README.md
+++ b/README.md
@@ -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.
@@ -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
@@ -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`:
@@ -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
@@ -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.
diff --git a/osxmetadata/__init__.py b/osxmetadata/__init__.py
index 07d1b80..5c765de 100644
--- a/osxmetadata/__init__.py
+++ b/osxmetadata/__init__.py
@@ -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 """
@@ -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,
@@ -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",
@@ -80,6 +82,7 @@ class OSXMetaData:
"_fname",
"_posix_name",
"_attrs",
+ "_tz_aware",
"__init",
"authors",
"comment",
@@ -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)
@@ -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
@@ -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 """
@@ -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:
+ # 2020-04-14T14:49:22Z
+ 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:
@@ -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
@@ -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
@@ -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:
diff --git a/osxmetadata/__main__.py b/osxmetadata/__main__.py
index aaa56e3..8a2a75b 100644
--- a/osxmetadata/__main__.py
+++ b/osxmetadata/__main__.py
@@ -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?
@@ -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}")
@@ -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:
@@ -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:
@@ -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:
diff --git a/osxmetadata/_version.py b/osxmetadata/_version.py
index 8c8d90f..9b3e043 100644
--- a/osxmetadata/_version.py
+++ b/osxmetadata/_version.py
@@ -1 +1 @@
-__version__ = "0.98.12"
+__version__ = "0.98.14"
diff --git a/osxmetadata/attributes.py b/osxmetadata/attributes.py
index 83fddf4..9aa00a2 100644
--- a/osxmetadata/attributes.py
+++ b/osxmetadata/attributes.py
@@ -1,8 +1,17 @@
-from collections import namedtuple
+""" Constants and validation for metadata attributes that can be set """
+
import datetime
+import logging
+from collections import namedtuple
-from .constants import *
from .classes import _AttributeList, _AttributeTagsList
+from .constants import *
+from .utils import (
+ datetime_has_tz,
+ datetime_naive_to_local,
+ datetime_remove_tz,
+ datetime_tz_to_utc,
+)
# Information about metadata attributes that can be set
# Each attribute type needs an Attribute namedtuple entry in ATTRIBUTES dict
@@ -25,6 +34,8 @@
# 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
# help: help text for the attribute (for use in command line interface)
+# api_help: help text for use in documentation for the programmer who will use the library
+# if None, will use same text as help
# Note: also add short name to __slots__ in __init__.py OSXMetaData
# Note: also add the constant name (e.g. kMDItemDateAdded) to constants.py
@@ -40,6 +51,7 @@
"as_list",
"class_",
"help",
+ "api_help",
],
)
@@ -53,6 +65,7 @@
False,
_AttributeList,
"The author, or authors, of the contents of the file. A list of strings.",
+ None,
),
"comment": Attribute(
"comment",
@@ -64,6 +77,7 @@
str,
"A comment related to the file. This differs from the Finder comment, "
+ "kMDItemFinderComment. A string.",
+ None,
),
"copyright": Attribute(
"copyright",
@@ -74,6 +88,7 @@
False,
str,
"The copyright owner of the file contents. A string.",
+ None,
),
"creator": Attribute(
"creator",
@@ -85,6 +100,7 @@
str,
"Application used to create the document content (for example “Word”, “Pages”, "
+ "and so on). A string.",
+ None,
),
"description": Attribute(
"description",
@@ -97,6 +113,7 @@
"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.",
+ None,
),
"downloadeddate": Attribute(
"downloadeddate",
@@ -108,8 +125,15 @@
# True,
False,
_AttributeList,
- "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)",
+ "The date the item 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.",
+ "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.",
),
"findercomment": Attribute(
"findercomment",
@@ -120,6 +144,7 @@
False,
str,
"Finder comments for this file. A string.",
+ None,
),
"headline": Attribute(
"headline",
@@ -130,6 +155,7 @@
False,
str,
"A publishable entry providing a synopsis of the contents of the file. A string.",
+ None,
),
"keywords": Attribute(
"keywords",
@@ -143,6 +169,7 @@
+ "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.",
+ None,
),
"tags": Attribute(
"tags",
@@ -155,6 +182,7 @@
'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.",
+ None,
),
"wherefroms": Attribute(
"wherefroms",
@@ -166,14 +194,17 @@
_AttributeList,
"Describes where the file was obtained from (e.g. URL downloaded from). "
+ "A list of strings.",
+ None,
),
# "test": Attribute(
# "test",
# "com.osxmetadata.test:DontTryThisAtHomeKids",
+ # "com.osxmetadata.test:DontTryThisAtHomeKids",
# datetime.datetime,
- # True,
# False,
- # _AttributeList,
+ # False,
+ # datetime.datetime,
+ # "Don't try this at home",
# ),
# "test_float": Attribute(
# "test_float",
@@ -205,20 +236,89 @@
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(
-# sorted(
-# [
-# 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()])
-# ]
-# ]
-# )
-# )
+
+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:
+ 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
+ # convert datetime to UTC
+ if datetime_has_tz(new_val):
+ # convert to UTC and remove timezone
+ new_val = datetime_tz_to_utc(new_val)
+ new_val = datetime_remove_tz(new_val)
+ else:
+ # assume it's in local time, so add local timezone,
+ # convert to UTC, then drop timezone
+ new_val = datetime_naive_to_local(new_val)
+ new_val = datetime_tz_to_utc(new_val)
+ new_val = datetime_remove_tz(new_val)
+ else:
+ raise TypeError(f"Unknown type: {type(val)}")
+ new_values.append(new_val)
+
+ logging.debug(f"new_values = {new_values}")
+
+ if attribute.list:
+ return new_values
+ else:
+ return new_values[0]
diff --git a/osxmetadata/classes.py b/osxmetadata/classes.py
index cc510df..61b9d4a 100644
--- a/osxmetadata/classes.py
+++ b/osxmetadata/classes.py
@@ -7,17 +7,21 @@
import sys
from .constants import _COLORNAMES, _VALID_COLORIDS
+from .utils import datetime_naive_to_utc, datetime_utc_to_local, datetime_remove_tz
class _AttributeList(collections.abc.MutableSequence):
""" represents a multi-valued OSXMetaData attribute list """
- def __init__(self, attribute, xattr_):
+ def __init__(self, attribute, xattr_, osxmetadata_obj):
""" initialize object
attribute: an OSXMetaData Attributes namedtuple
- xattr_: an instance of xattr.xattr """
+ xattr_: an instance of xattr.xattr
+ osxmetadata_obj: instance of OSXMetaData that created this class instance """
self._attribute = attribute
self._attrs = xattr_
+ self._md = osxmetadata_obj
+
self._constant = attribute.constant
self.data = []
@@ -36,8 +40,33 @@ def _load_data(self):
if self._values:
try:
self.data = list(self._values)
+ if self._attribute.type_ == datetime.datetime:
+ # add UTC timezone to datetime values
+ # because that's how MacOS stores them
+ # In the plist associated with xattr, times are stored as:
+ # 2020-04-14T14:49:22Z
+ self.data = [datetime_naive_to_utc(dt) for dt in self.data]
+ if not self._md._tz_aware:
+ # want datetimes in naive format
+ self.data = [
+ datetime_remove_tz(d_local)
+ for d_local in [
+ datetime_utc_to_local(d_utc) for d_utc in self.data
+ ]
+ ]
except TypeError:
self.data = set([self._values])
+ if self._attribute.type_ == datetime.datetime:
+ # add UTC timezone to datetime values
+ self.data = {datetime_naive_to_utc(dt) for dt in self.data}
+ if not self._tz_aware:
+ # want datetimes in naive format
+ self.data = {
+ datetime_remove_tz(d_local)
+ for d_local in {
+ datetime_utc_to_local(d_utc) for d_utc in self.data
+ }
+ }
else:
self.data = []
except KeyError:
@@ -161,120 +190,122 @@ def _write_data(self):
self._attrs.set(self._constant, plist)
-class _AttributeSet:
- """ represents a multi-valued OSXMetaData attribute set """
-
- def __init__(self, attribute, xattr_):
- """ initialize object
- attribute: an OSXMetaData Attributes namedtuple
- xattr_: an instance of xattr.xattr """
- self._attribute = attribute
- self._attrs = xattr_
- self._constant = attribute.constant
-
- # initialize
- self.data = set()
- 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"""
- # TODO: should check to see if value is a non-list, set, etc. (single value)
- self._load_data()
- self.data.add(self._normalize(value))
- self._write_data()
-
- def update(self, *others):
- """ update data adding any new values in *others
- each item passed in *others must be an iterable """
- self._load_data()
- old_values = set(map(self._normalize, self.data))
- new_values = old_values
- for item in others:
- new_values = new_values.union(set(map(self._normalize, item)))
- 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 ValueError exception if value does not exist in data set """
- self._load_data()
- if value not in self.data:
- raise ValueError("list.remove(x): x not in list")
- 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()
- 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:
- # 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()
- except KeyError:
- self.data = set()
-
- def _write_data(self):
- # 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)
+# class _AttributeSet:
+# """ represents a multi-valued OSXMetaData attribute set """
+
+# def __init__(self, attribute, xattr_, osxmetadata_obj):
+# """ initialize object
+# attribute: an OSXMetaData Attributes namedtuple
+# xattr_: an instance of xattr.xattr """
+# osxmetadata_obj: instance of OSXMetaData that created this class instance """
+# self._attribute = attribute
+# self._attrs = xattr_
+# self._md = osxmetadata_obj
+# self._constant = attribute.constant
+
+# # initialize
+# self.data = set()
+# 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"""
+# # TODO: should check to see if value is a non-list, set, etc. (single value)
+# self._load_data()
+# self.data.add(self._normalize(value))
+# self._write_data()
+
+# def update(self, *others):
+# """ update data adding any new values in *others
+# each item passed in *others must be an iterable """
+# self._load_data()
+# old_values = set(map(self._normalize, self.data))
+# new_values = old_values
+# for item in others:
+# new_values = new_values.union(set(map(self._normalize, item)))
+# 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 ValueError exception if value does not exist in data set """
+# self._load_data()
+# if value not in self.data:
+# raise ValueError("list.remove(x): x not in list")
+# 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()
+# values = set(map(self._normalize, self.data))
+# values.discard(self._normalize(value))
+# self.data = values
+# self._write_data()
- def __repr__(self):
- self._load_data()
- return repr(self.data)
+# def _load_data(self):
+# self._values = []
+# try:
+# # 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()
+# except KeyError:
+# self.data = set()
- 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 __ior__(self, values):
- if type(values) != set:
- raise TypeError
- self.update(values)
- return self
+# def _write_data(self):
+# # 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 __ior__(self, values):
+# if type(values) != set:
+# raise TypeError
+# self.update(values)
+# return self
# deprecated
diff --git a/osxmetadata/constants.py b/osxmetadata/constants.py
index 7153e5f..4ca7bd9 100644
--- a/osxmetadata/constants.py
+++ b/osxmetadata/constants.py
@@ -1,4 +1,4 @@
-""" constants and definitions used by osxmetadata """
+""" Constants and definitions used by osxmetadata """
# color labels
_COLORNAMES = {
diff --git a/osxmetadata/utils.py b/osxmetadata/utils.py
index 80cc277..e42ba33 100644
--- a/osxmetadata/utils.py
+++ b/osxmetadata/utils.py
@@ -4,7 +4,6 @@
import os
from . import _applescript
-from .attributes import ATTRIBUTES
_DEBUG = False
@@ -83,82 +82,6 @@ def clear_finder_comment(filename):
_scpt_clear_finder_comment.run(filename)
-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:
- 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]
-
-
def load_backup_file(backup_file):
""" Load attribute data from JSON in backup_file
Returns: backup_data dict """
@@ -197,3 +120,111 @@ def write_backup_file(backup_file, backup_data):
fp.close()
+
+# datetime.datetime helper functions for converting to/from UTC
+
+
+def get_local_tz():
+ """ return local timezone as datetime.timezone tzinfo """
+ local_tz = (
+ datetime.datetime.now(datetime.timezone(datetime.timedelta(0)))
+ .astimezone()
+ .tzinfo
+ )
+ return local_tz
+
+
+def datetime_has_tz(dt):
+ """ return True if datetime dt has tzinfo else False
+ dt: datetime.datetime
+ returns True if dt is timezone aware, else False """
+
+ if type(dt) != datetime.datetime:
+ raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
+
+ if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None:
+ return True
+ return False
+
+
+def datetime_tz_to_utc(dt):
+ """ convert datetime.datetime object with timezone to UTC timezone
+ dt: datetime.datetime object
+ returns: datetime.datetime in UTC timezone """
+
+ if type(dt) != datetime.datetime:
+ raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
+
+ local_tz = get_local_tz()
+ if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None:
+ dt_utc = dt.replace(tzinfo=dt.tzinfo).astimezone(tz=datetime.timezone.utc)
+ return dt_utc
+ else:
+ raise ValueError(f"dt does not have timezone info")
+
+
+def datetime_remove_tz(dt):
+ """ remove timezone from a datetime.datetime object
+ dt: datetime.datetime object with tzinfo
+ returns: dt without any timezone info (naive datetime object) """
+
+ if type(dt) != datetime.datetime:
+ raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
+
+ dt_new = dt.replace(tzinfo=None)
+ return dt_new
+
+
+def datetime_naive_to_utc(dt):
+ """ convert naive (timezone unaware) datetime.datetime
+ to aware timezone in UTC timezone
+ dt: datetime.datetime without timezone
+ returns: datetime.datetime with UTC timezone """
+
+ if type(dt) != datetime.datetime:
+ raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
+
+ if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None:
+ # has timezone info
+ raise ValueError(
+ "dt must be naive/timezone unaware: "
+ f"{dt} has tzinfo {dt.tzinfo} and offset {dt.tzinfo.utcoffset(dt)}"
+ )
+
+ dt_utc = dt.replace(tzinfo=datetime.timezone.utc)
+ return dt_utc
+
+
+def datetime_naive_to_local(dt):
+ """ convert naive (timezone unaware) datetime.datetime
+ to aware timezone in local timezone
+ dt: datetime.datetime without timezone
+ returns: datetime.datetime with local timezone """
+
+ if type(dt) != datetime.datetime:
+ raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
+
+ if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None:
+ # has timezone info
+ raise ValueError(
+ "dt must be naive/timezone unaware: "
+ f"{dt} has tzinfo {dt.tzinfo} and offset {dt.tizinfo.utcoffset(dt)}"
+ )
+
+ dt_local = dt.replace(tzinfo=get_local_tz())
+ return dt_local
+
+
+def datetime_utc_to_local(dt):
+ """ convert datetime.datetime object in UTC timezone to local timezone
+ dt: datetime.datetime object
+ returns: datetime.datetime in local timezone """
+
+ if type(dt) != datetime.datetime:
+ raise TypeError(f"dt must be type datetime.datetime, not {type(dt)}")
+
+ if dt.tzinfo is not datetime.timezone.utc:
+ raise ValueError(f"{dt} must be in UTC timezone: timezone = {dt.tzinfo}")
+
+ dt_local = dt.replace(tzinfo=datetime.timezone.utc).astimezone(tz=get_local_tz())
+ return dt_local
diff --git a/setup.py b/setup.py
index 71741a7..0b5facb 100755
--- a/setup.py
+++ b/setup.py
@@ -4,9 +4,9 @@
import sys
import os.path
-# uses f-strings so check for python >= 3.6
-if sys.version_info < (3, 6, 0):
- sys.stderr.write("ERROR: You need Python 3.6 or later to use osxmetadata.\n")
+# uses datetime.datetime.fromisoformat so requires >= 3.7
+if sys.version_info < (3, 7, 0):
+ sys.stderr.write("ERROR: You need Python 3.7 or later to use osxmetadata.\n")
exit(1)
# we'll import stuff from the source tree, let's ensure is on the sys path
@@ -44,10 +44,10 @@
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: MacOS :: MacOS X",
- "Programming Language :: Python :: 3.6",
+ "Programming Language :: Python :: 3.7",
"Topic :: Software Development :: Libraries :: Python Modules",
],
install_requires=["xattr", "pyobjc>=6.0.1", "click>=7.0"],
- python_requires=">=3.6",
+ python_requires=">=3.7",
entry_points={"console_scripts": ["osxmetadata=osxmetadata.__main__:cli"]},
)
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 4672f70..0f70d1a 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -732,3 +732,31 @@ def test_cli_version():
result = runner.invoke(cli, ["--version"])
assert result.exit_code == 0
assert f"version {__version__}" in result.output
+
+
+def test_cli_downloadeddate(temp_file):
+ # pass ISO 8601 format with timezone, get back naive local time
+ import datetime
+ from osxmetadata import OSXMetaData, kMDItemDownloadedDate
+ from osxmetadata.utils import (
+ datetime_naive_to_utc,
+ datetime_utc_to_local,
+ datetime_remove_tz,
+ )
+ from osxmetadata.__main__ import cli
+
+ runner = CliRunner()
+ dt = "2020-02-23:00:00:00+00:00" # UTC time
+ utc_time = datetime.datetime.fromisoformat(dt)
+ local_time = datetime_remove_tz(datetime_utc_to_local(utc_time))
+
+ result = runner.invoke(cli, ["--set", "downloadeddate", dt, "--list", temp_file])
+ assert result.exit_code == 0
+
+ output = parse_cli_output(result.stdout)
+ assert output["downloadeddate"] == f"['{local_time.isoformat()}']"
+
+ meta = OSXMetaData(temp_file)
+ meta.tz_aware = True
+ assert meta.get_attribute(kMDItemDownloadedDate) == [utc_time]
+ assert meta.downloadeddate == [utc_time]
diff --git a/tests/test_download_date.py b/tests/test_download_date.py
index 71fd9c4..3213e78 100644
--- a/tests/test_download_date.py
+++ b/tests/test_download_date.py
@@ -28,3 +28,91 @@ def test_download_date(temp_file):
meta.downloadeddate = dt
assert meta.downloadeddate == [dt]
assert meta.get_attribute("downloadeddate") == [dt]
+
+
+def test_download_date_tz_1A(temp_file):
+ """ set naive time but return tz_aware """
+ from osxmetadata import OSXMetaData
+ from osxmetadata.utils import datetime_naive_to_local
+ import datetime
+
+ meta = OSXMetaData(temp_file, tz_aware=True)
+ dt = datetime.datetime.now()
+ meta.set_attribute("downloadeddate", dt)
+ dt_tz = datetime_naive_to_local(dt)
+ assert meta.downloadeddate == [dt_tz]
+ assert meta.get_attribute("downloadeddate") == [dt_tz]
+
+
+def test_download_date_tz_1B(temp_file):
+ """ set naive time but return tz_aware """
+ from osxmetadata import OSXMetaData
+ from osxmetadata.utils import datetime_naive_to_local
+ import datetime
+
+ meta = OSXMetaData(temp_file, tz_aware=True)
+ dt = datetime.datetime.now()
+ meta.downloadeddate = dt
+ dt_tz = datetime_naive_to_local(dt)
+ assert meta.downloadeddate == [dt_tz]
+ assert meta.get_attribute("downloadeddate") == [dt_tz]
+
+
+def test_download_date_tz_2(temp_file):
+ """ set tz_aware and return tz_aware """
+ from osxmetadata import OSXMetaData
+ from osxmetadata.utils import datetime_naive_to_local
+ import datetime
+
+ meta = OSXMetaData(temp_file, tz_aware=True)
+ dt = datetime.datetime.now()
+ dt_tz = datetime_naive_to_local(dt)
+ meta.downloadeddate = dt_tz
+ assert meta.downloadeddate == [dt_tz]
+ assert meta.get_attribute("downloadeddate") == [dt_tz]
+
+
+def test_download_date_tz_3(temp_file):
+ """ set tz_aware and return naive """
+ from osxmetadata import OSXMetaData
+ from osxmetadata.utils import datetime_naive_to_local
+ import datetime
+
+ meta = OSXMetaData(temp_file, tz_aware=False)
+ dt = datetime.datetime.now()
+ dt_tz = datetime_naive_to_local(dt)
+ meta.downloadeddate = dt_tz
+ assert meta.downloadeddate == [dt]
+ assert meta.get_attribute("downloadeddate") == [dt]
+
+
+def test_download_date_tz_4(temp_file):
+ """ test tz_aware property """
+ from osxmetadata import OSXMetaData
+ from osxmetadata.utils import datetime_naive_to_local
+ import datetime
+
+ meta = OSXMetaData(temp_file)
+ dt = datetime.datetime.now()
+
+ # test tz_aware
+ assert not meta.tz_aware
+ meta.tz_aware = True
+ assert meta.tz_aware
+
+ # test tz_aware
+ dt_tz = datetime_naive_to_local(dt)
+ meta.downloadeddate = dt_tz
+ assert meta.downloadeddate == [dt_tz]
+ assert meta.get_attribute("downloadeddate") == [dt_tz]
+
+ # test timezone == UTC
+ dld = meta.downloadeddate[0]
+ tz = dld.tzinfo.tzname(dld)
+ assert tz == "UTC"
+
+ # turn tz_aware off
+ meta.tz_aware = False
+ assert meta.downloadeddate == [dt]
+ assert meta.get_attribute("downloadeddate") == [dt]
+ assert meta.downloadeddate[0].tzinfo == None
diff --git a/utils/build_help_table.py b/utils/build_help_table.py
index 9c0db4f..8d02254 100644
--- a/utils/build_help_table.py
+++ b/utils/build_help_table.py
@@ -13,4 +13,5 @@
]
):
attr = osxmetadata.ATTRIBUTES[attribute]
- print(f"|{attr.short_constant}|{attr.name}|{attr.constant}|{attr.help}|")
+ help_ = attr.api_help if attr.api_help is not None else attr.help
+ print(f"|{attr.short_constant}|{attr.name}|{attr.constant}|{help_}|")