Repository: RhetTbull/osxmetadata (language: python)

Issue:
bug: tags added via osxmetadata don't show up in mdls
If file had no tags and tags writtten with osxmetadata, kMDItemUserTags doesn't show up in mdls
If tag added in Finder then updated with osxmetadata, they do show up in mdls

But, not always!  I have seen this behavior once but not always repeatable.


Discussion / hints:
The tags do show up in `xattr -l` though so I suspect mdls is caching data


Fix patch:
diff --git a/.bumpversion.cfg b/.bumpversion.cfg
new file mode 100644
index 0000000..021d344
--- /dev/null
+++ b/.bumpversion.cfg
@@ -0,0 +1,12 @@
+[bumpversion]
+current_version = 0.99.38
+parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
+serialize = {major}.{minor}.{patch}
+
+[bumpversion:file:osxmetadata/_version.py]
+parse = __version__\s=\s\"(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\"
+serialize = {major}.{minor}.{patch}
+
+[bumpversion:file:pyproject.toml]
+parse = version\s=\s\"(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\"
+serialize = {major}.{minor}.{patch}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8e9b036..6a26c2f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -80,8 +80,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
 
 > 15 July 2021
 
-- Added stationarypad, closes #47 [`#47`](https://github.com/RhetTbull/osxmetadata/issues/47)
-- Added stationarypad to finderinfo [`f4db1bd`](https://github.com/RhetTbull/osxmetadata/commit/f4db1bd0eec063412552da2a0a0ebfcd8aa538d7)
+- Added stationerypad, closes #47 [`#47`](https://github.com/RhetTbull/osxmetadata/issues/47)
+- Added stationerypad to finderinfo [`f4db1bd`](https://github.com/RhetTbull/osxmetadata/commit/f4db1bd0eec063412552da2a0a0ebfcd8aa538d7)
 
 #### [v0.99.21](https://github.com/RhetTbull/osxmetadata/compare/v0.99.19...v0.99.21)
 
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..a993f93
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,9 @@
+# Contributing
+
+Contributions of all kinds are welcome! Please open an issue or submit a pull request. Please read [README_DEV.md](README_DEV.md) for more information on the development process and tooling.
+
+## Code of Conduct
+
+Be nice to each other.  Treat everyone with dignity and respect.
+
+Abusive behavior of any kind will not be tolerated here.
\ No newline at end of file
diff --git a/README.md b/README.md
index 1d324c7..e0fef88 100644
--- a/README.md
+++ b/README.md
@@ -1,24 +1,29 @@
-# osxmetadata 
+# osxmetadata
+
 [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/python/black)
 [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
-[![All Contributors](https://img.shields.io/badge/all_contributors-1-orange.svg?style=flat)](#contributors-)
-
+[![All Contributors](https://img.shields.io/badge/all_contributors-2-orange.svg?style=flat)](#contributors-)
 
 ## What is osxmetadata?
 
-osxmetadata provides a simple interface to access various metadata about MacOS / OS X files.  Currently supported metadata attributes include tags/keywords, Finder comments, authors, etc.  
+osxmetadata provides a simple interface to access various metadata about MacOS / OS X files.  Currently supported metadata attributes include tags/keywords, Finder comments, authors, etc.
+
+## Why osxmetadata?
+
+Apple provides rich support for file metadata through the [MDItem](https://developer.apple.com/documentation/coreservices/file_metadata/mditem) class and the [NSURL getResourceValue:forKey:error:](https://developer.apple.com/documentation/foundation/nsurl/1408874-getresourcevalue?language=objc) method. However, Apple does not provide a way to easily set much of the metadata. For example, while there is a documented [MDItem MDItemCopyAttribute](https://developer.apple.com/documentation/coreservices/1427080-mditemcopyattribute?language=objc) to copy metadata attributes such as [kMDItemAuthors](https://developer.apple.com/documentation/coreservices/kmditemauthors?language=objc), Apple does not provide a public interface to set this data.  Other data, such as Finder comments, can only be set through sending AppleScript commands to the Finder and others, like Finder tags can be retrieved but cannot be set through the public API.
 
-## Motivation
+osxmetadata provides a unified interface to get and set most of the metadata available on your Mac from python.  It uses a combination of documented and undocumented APIs to access the metadata.  It also provides a simple interface to set Finder tags and Finder comments.
 
-Apple provides rich support for file metadata through various metadata extended attributes.  MacOS provides tools to view and set these various metadata attributes.  For example, `mdls` lists metadata associated with a file but doesn't let you edit the data while `xattr` allows the user to set extended attributes but requires the values be in the form of a MacOS plist which is impractical.   `osxmetadata` makes it easy to to both view and manipulate the MacOS metadata attributes, either programmatically or through a command line tool.
+MacOS provides some tools to view these various metadata attributes.  For example, `mdls` lists the MDItem Spotlight metadata associated with a file but doesn't let you edit the data. osxmetadata makes it easy to to both view and manipulate the macOS metadata attributes, either programmatically or through an included `osxmetadata` command line tool.
 
 ## Supported operating systems
 
-Only works on MacOS.  Requires Python 3.7+. 
+Only works on MacOS.  Requires Python 3.8+. Tested on macOS 10.15.7 (Catalina); should work on all versions of macOS 10.15 and later.
 
 ## Installation instructions
 
 ### Installation using pipx
+
 If you aren't familiar with installing python applications, I recommend you install `osxmetadata` with [pipx](https://github.com/pipxproject/pipx). If you use `pipx`, you will not need to create a virtual environment as `pipx` takes care of this. The easiest way to do this on a Mac is to use [homebrew](https://brew.sh/):
 
 - Open `Terminal` (search for `Terminal` in Spotlight or look in `Applications/Utilities`)
@@ -32,6 +37,7 @@ Once you've installed osxmetadata with pipx, to upgrade to the latest version:
     pipx upgrade osxmetadata
 
 ### Installation using pip
+
 You can also install directly from [pypi](https://pypi.org/project/osxmetadata/):
 
     pip install osxmetadata
@@ -41,6 +47,7 @@ Once you've installed osxmetadata with pip, to upgrade to the latest version:
     pip install --upgrade osxmetadata
 
 ### Installation from git repository
+
 OSXMetaData uses setuptools, thus simply run:
 
     git clone https://github.com/RhetTbull/osxmetadata.git
@@ -49,6 +56,210 @@ OSXMetaData uses setuptools, thus simply run:
 
 I recommend you create a [virtual environment](https://docs.python.org/3/tutorial/venv.html) before installing osxmetadata.
 
+## Using the API
+
+```pycon
+>>> from osxmetadata import *
+>>> import datetime
+>>> md = OSXMetaData("test_file.txt")
+>>> md.set(kMDItemAuthors, ["Jane Smith", "John Doe"])
+>>> md.get(kMDItemAuthors)
+['Jane Smith', 'John Doe']
+>>> md.kMDItemFinderComment = "This is my comment"
+>>> md.kMDItemFinderComment
+'This is my comment'
+>>> md.tags = [Tag("Test", FINDER_COLOR_NONE), Tag("ToDo", FINDER_COLOR_RED)]
+>>> md.tags
+[Tag(name='Test', color=0), Tag(name='ToDo', color=6)]
+>>> md[kMDItemDueDate] = datetime.datetime(2022,10,1)
+>>> md[kMDItemDueDate]
+datetime.datetime(2022, 10, 1, 0, 0)
+>>>
+```
+
+Somewhat contrary to the [Zen of Python](https://peps.python.org/pep-0020/), osxmetadata provides more than one way to access metadata attributes.  You can get and set metadata attributes using the `get()`/`set()` getter/setter methods, using the attribute name as a dictionary key on the OSXMetaData object, or using the attribute name as an attribute on the `OSXMetaData()` object.  For example, the following are all equivalent:
+
+- `OSXMetaData.get(attribute)` - get the value of the metadata attribute `attribute`
+- `OSXMetaData[attribute]` - get the value of the metadata attribute `attribute`
+- `OSXMetaData.attribute` - get the value of the metadata attribute `attribute`
+
+As are the following:
+
+- `OSXMetaData.set(attribute, value)` - set the value of the metadata attribute `attribute` to `value`
+- `OSXMetaData[attribute] = value` - set the value of the metadata attribute `attribute` to `value`
+- `OSXMetaData.attribute = value` - set the value of the metadata attribute `attribute` to `value`
+
+This allows you to use osxmetadata in accordance with your own code style preferences.
+
+Supported attribute names include all attributes defined for [MDItem](https://developer.apple.com/documentation/coreservices/file_metadata/mditem) and all resource keys defined for [NSURL](https://developer.apple.com/documentation/foundation/nsurl?language=objc). Additionally, the metadata constants defined in the [MDImporter](https://developer.apple.com/documentation/coreservices/file_metadata/mdimporter?language=objc) are supported as well as the following additional attributes:
+
+- `_kMDItemUserTags` - list of Finder tags
+- `kMDItemDownloadedDate` - list of datetime objects for when the file was downloaded
+
+Additionally, osxmetadata defines a "shortcut name" attribute for each MDItem attribute that can be used as a shortcut `OSXMetaData` class attribute.  The shortcut name is the lowercase value of text following `kMDItem` for each attribute. For example, `kMDItemAuthors` has a short name of `authors` so you can set the authors like this:
+
+```pycon
+>>> from osxmetadata import *
+>>> md = OSXMetaData("test_file.txt")
+>>> md.authors = ["Jane Smith", "John Doe"]
+>>> md.authors
+['Jane Smith', 'John Doe']
+>>>
+```
+
+and `kMDItemDueDate` would have a short name of `duedate`:
+
+```pycon
+>>> from osxmetadata import *
+>>> import datetime
+>>> md = OSXMetaData("test_file.txt")
+>>> md.duedate = datetime.datetime(2022, 10, 1)
+>>> md.duedate
+datetime.datetime(2022, 10, 1, 0, 0)
+>>>
+```
+
+The names of all supported attributes are available in the `osxmetadata.ALL_ATTRIBUTES` set:
+
+```pycon
+>>> from osxmetadata import ALL_ATTRIBUTES
+>>> "kMDItemDueDate" in ALL_ATTRIBUTES
+True
+>>> "NSURLTagNamesKey" in ALL_ATTRIBUTES
+True
+>>> "findercomment" in ALL_ATTRIBUTES
+True
+>>>
+```
+
+The class attributes are handled dynamically which, unfortunately, means that IDEs like PyCharm and Visual Studio Code cannot provide tab-completion for them.
+
+## Finder Tags
+
+Unlike other attributes, which are mapped to native Python types appropriate for the source Objective C type, Finder tags (`_kMDItemUserTags` or `tags`) have two components: a name (str) and a color ID (unsigned int in range 0 to 7) representing a color tag in the Finder.  Reading tags returns a list of `Tag` namedtuples and setting tags requires a list of `Tag` namedtuples.  
+
+```pycon
+>>> from osxmetadata import *
+>>> md = OSXMetaData("test_file.txt")
+>>> md.tags = [Tag("Test", FINDER_COLOR_NONE), Tag("ToDo", FINDER_COLOR_RED)]
+>>> md.tags
+[Tag(name='Test', color=0), Tag(name='ToDo', color=6)]
+>>> md.get("_kMDItemUserTags")
+[Tag(name='Test', color=0), Tag(name='ToDo', color=6)]
+>>> 
+```
+
+Tag names (but not colors) can also be accessed through the [NSURLTagNamesKey](https://developer.apple.com/documentation/foundation/nsurltagnameskey) resource key and the label color ID is accessible through `NSURLLabelNumberKey`; the localized label color name is accessible through `NSURLLocalizedLabelKey` though these latter two resource keys only return a single color whereas a file may have more than one color tag. For most purposes, I recommend using the `tags` attribute as it is more convenient and provides access to both the name and color ID of the tag.
+
+```pycon
+>>> from osxmetadata import *
+>>> md = OSXMetaData("test_file.txt")
+>>> md.tags = [Tag("Test", FINDER_COLOR_NONE), Tag("ToDo", FINDER_COLOR_RED)]
+>>> md.tags
+[Tag(name='Test', color=0), Tag(name='ToDo', color=6)]
+>>> md.NSURLTagNamesKey
+(
+    Test,
+    ToDo
+)
+>>> md.NSURLLabelNumberKey
+6
+>>> md.NSURLLocalizedLabelKey
+'Red'
+>>> md.NSURLTagNamesKey = ["NewTag"]
+>>> md.NSURLTagNamesKey
+(
+    NewTag
+)
+>>> md.tags
+[Tag(name='NewTag', color=0)]
+>>>
+```
+
+### Create a Tag namedtuple
+
+`Tag(name, color)`
+
+- `name`: tag name (str)
+- `color`: color ID for Finder color label associated with tag (int)
+
+Valid color constants (exported by osxmetadata):
+
+- `FINDER_COLOR_NONE` = 0
+- `FINDER_COLOR_GRAY` = 1
+- `FINDER_COLOR_GREEN` = 2
+- `FINDER_COLOR_PURPLE` = 3
+- `FINDER_COLOR_BLUE` = 4
+- `FINDER_COLOR_YELLOW` = 5
+- `FINDER_COLOR_RED` = 6
+- `FINDER_COLOR_ORANGE` = 7
+
+## Finder Comments
+
+Finder comments can be access via the `kMDItemFinderComment` attribute or the `findercomment` shortcut attribute. Apple provides a public API for getting Finder comments but does not provide a programmatic method for setting Finder comments and I have not been able to find a private API for doing so. osxmetadata works around this by send AppleScript events to the Finder to set the Finder comment. This means that setting Finder comments is slower than setting other attributes and may not work in all circumstances. The first time you set a Finder comment, your terminal app may need to prompt you to allow AppleScript to control the Finder. If you include osxmetadata in a standalone app, for example, one created with [py2app](https://py2app.readthedocs.io/en/latest/), you will need to include the `com.apple.security.automation.apple-events` entitlement and the `NSAppleEventsUsageDescription` key in your app's `Info.plist` file. See the [Apple Developer Documentation](https://developer.apple.com/documentation/bundleresources/information_property_list/nsappleeventsusagedescription?language=objc) for more information.
+
+```pycon
+>>> from osxmetadata import *
+>>> md = OSXMetaData("test_file.txt")
+>>> md.kMDItemFinderComment = "Hello World!"
+>>> md.kMDItemFinderComment
+'Hello World!'
+>>> md.findercomment
+'Hello World!'
+>>>
+```
+
+## Dates/Times
+
+Metadata attributes which return date/times such as `kMDItemDueDate` or `kMDItemDownloadedDate` return a `datetime.datetime` object.  The `datetime.datetime` object is timezone-naive (does not contain timezone) and returns the time in the local timezone.  Internally, Apple appears to store these as [CFDate](https://developer.apple.com/documentation/corefoundation/cfdate?language=objc) objects in the UTC timezone but when retrieved, they are returned in the local time.  You may pass a timezone-aware datetime object to set these attributes and it will be converted appropriately.
+
+```pycon
+>>> from osxmetadata import *
+>>> md = OSXMetaData("test_file.txt")
+>>> import datetime
+>>> md.kMDItemDueDate = datetime.datetime(2022, 10, 1)
+>>> md.kMDItemDueDate
+datetime.datetime(2022, 10, 1, 0, 0)
+>>> md.kMDItemDownloadedDate = datetime.datetime(2022, 10, 1, tzinfo=datetime.timezone.utc)
+>>> 
+```
+
+## Extended Attributes
+
+In addition to `MDItem` and `NSURL` metadata attributes, osxmetadata can also read & write metadata saved in extended attributes. For many MDItem attributes, Apple stores the same data in both the MDItem and extended attribute (with name `com.apple.metadata:AttributeName`).  For example, the `kMDItemWhereFroms` attribute can be accessed both via MDItemCopyAttribute (exposed via osxmetadata's `get()` method) and via the `com.apple.metadata:kMDItemWhereFroms` extended attribute.  The extended attribute is a binary plist (BPLIST) and can be read using the `xattr` command line tool.  The `get_xattr()` method will return the value of the extended attribute and the `set_xattr()` method will set it.  Extended attributes can be removed with the `remove_xattr()` method.  `get_xattr()` provides for an optional callable argument, `decode`, which will be called on the returned value.  `set_xattr()` provides an optional callable argument `encode`. This is useful for encoding/decoding binary plist data.  For example, to decode the `com.apple.metadata:kMDItemWhereFroms` extended attribute, you can use the `plistlib.loads()` function:
+
+```pycon
+>>> from osxmetadata import *
+>>> import plistlib
+>>> from plistlib import FMT_BINARY
+>>> from functools import partial
+>>> md = OSXMetaData("test_file.txt")
+>>> md.kMDItemWhereFroms = ["apple.com"]
+>>> md.kMDItemWhereFroms
+['apple.com']
+>>> decode = partial(plistlib.loads, fmt=FMT_BINARY)
+>>> encode = partial(plistlib.dumps, fmt=FMT_BINARY)
+>>> md.get_xattr("com.apple.metadata:kMDItemWhereFroms")
+b'bplist00\xa1\x01Yapple.com\x08\n\x00\x00\x00\x00\x00\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x14'
+>>> md.get_xattr("com.apple.metadata:kMDItemWhereFroms", decode=decode)
+['apple.com']
+>>> md.set_xattr("com.apple.metadata:kMDItemWhereFroms", ["google.com"], encode=encode)
+>>> md.get_xattr("com.apple.metadata:kMDItemWhereFroms", decode=decode)
+['google.com']
+>>> md.remove_xattr("com.apple.metadata:kMDItemWhereFroms")
+>>>
+```
+
+For most use cases, it is recommended you do not directly access the Apple metadata related extended attributes and instead use the getter/setter methods provided by osxmetadata.
+
+## Finder Info
+
+The Finder keeps some legacy Finder info data about files in a bitstring stored in the `com.apple.FinderInfo` extended attribute. osxmetadata provides some attributes for working with this data.
+
+- `stationerypad`: True if the file is a stationery pad (file template) otherwise False; setting this attribute has the same effect as setting the `Stationery pad` checkbox in the Finder's `Get Info` window.
+- `findercolor`: The color of the file as an integer; setting this attribute has the same effect as applying a color label in the Finder's `Get Info` window. osxmetadata will set this attribute automatically when setting user tags; it is recommended you do not set this attribute directly.
+- `finderinfo`: The raw Finder info data as a bytes object; you should only manipulate this attribute if you know what you are doing.
+
 ## Command Line Usage
 
 Installs command line tool called `osxmetadata` which provides a simple interface to view/edit metadata supported by osxmetadata.
@@ -57,6 +268,17 @@ If you only care about the command line tool, I recommend installing with [pipx]
 
 The command line tool can also be run via `python -m osxmetadata`.  Running it with no arguments or with --help option will print a help message:
 
+<!-- [[[cog
+import cog
+from osxmetadata.__main__ import cli
+from click.testing import CliRunner
+runner = CliRunner()
+result = runner.invoke(cli, ["--help"])
+help = result.output.replace("Usage: cli", "Usage: osxmetadata")
+cog.out(
+    "```\n{}\n```".format(help)
+)
+]]] -->
 ```
 Usage: osxmetadata [OPTIONS] FILE
 
@@ -64,7 +286,6 @@ Usage: osxmetadata [OPTIONS] FILE
 
 Options:
   -v, --version                   Show the version and exit.
-  -h, --help                      Show this message and exit.
   -w, --walk                      Walk directory tree, processing each file in
                                   the tree.
   -j, --json                      Print output in JSON format, for use with
@@ -73,13 +294,12 @@ Options:
   -s, --set ATTRIBUTE VALUE       Set ATTRIBUTE to VALUE.
   -l, --list                      List all metadata attributes for FILE.
   -c, --clear ATTRIBUTE           Remove attribute from FILE.
-  -a, --append ATTRIBUTE VALUE    Append VALUE to ATTRIBUTE.
+  -a, --append ATTRIBUTE VALUE    Append VALUE to ATTRIBUTE; for multi-valued
+                                  attributes, appends only if VALUE is not
+                                  already present.
   -g, --get ATTRIBUTE             Get value of ATTRIBUTE.
   -r, --remove ATTRIBUTE VALUE    Remove VALUE from ATTRIBUTE; only applies to
                                   multi-valued attributes.
-  -u, --update ATTRIBUTE VALUE    Update ATTRIBUTE with VALUE; for multi-
-                                  valued attributes, this adds VALUE to the
-                                  attribute if not already in the list.
   -m, --mirror ATTRIBUTE1 ATTRIBUTE2
                                   Mirror values between ATTRIBUTE1 and
                                   ATTRIBUTE2 so that ATTRIBUTE1 = ATTRIBUTE2;
@@ -90,31 +310,28 @@ Options:
                                   tags' sets tags and keywords to same values.
   -B, --backup                    Backup FILE attributes.  Backup file
                                   '.osxmetadata.json' will be created in same
-                                  folder as FILE. Only backs up attributes
-                                  known to osxmetadata unless used with --all.
+                                  folder as FILE. Only backs up attributes known
+                                  to osxmetadata unless used with --all.
   -R, --restore                   Restore FILE attributes from backup file.
                                   Restore will look for backup file
                                   '.osxmetadata.json' in same folder as FILE.
-                                  Only restores attributes known to
-                                  osxmetadata unless used with --all.
-  -A, --all                       Process all extended attributes including
-                                  those not known to osxmetadata. Use with
-                                  --backup/--restore to backup/restore all
-                                  extended attributes.
+                                  Only restores attributes known to osxmetadata
+                                  unless used with --all.
   -V, --verbose                   Print verbose output.
-  -f, --copyfrom SOURCE_FILE      Copy attributes from file SOURCE_FILE.
-  --files-only                    Do not apply metadata commands to
-                                  directories themselves, only files in a
-                                  directory.
+  -f, --copyfrom SOURCE_FILE      Copy attributes from file SOURCE_FILE (only
+                                  updates destination attributes that are not
+                                  null in SOURCE_FILE).
+  --files-only                    Do not apply metadata commands to directories
+                                  themselves, only files in a directory.
   -p, --pattern PATTERN           Only process files matching PATTERN; only
                                   applies to --walk. If specified, only files
                                   matching PATTERN will be processed as each
-                                  directory is walked. May be used for than
-                                  once to specify multiple patterns. For
-                                  example, tag all *.pdf files in projectdir
-                                  and subfolders with tag 'project':
-                                  osxmetadata --append tags 'project' --walk
-                                  projectdir/ --pattern '*.pdf'
+                                  directory is walked. May be used for than once
+                                  to specify multiple patterns. For example, tag
+                                  all *.pdf files in projectdir and subfolders
+                                  with tag 'project': osxmetadata --append tags
+                                  'project' --walk projectdir/ --pattern '*.pdf'
+  --help                          Show this message and exit.
 
 Valid attributes for ATTRIBUTE: Each attribute has a short name, a constant
 name, and a long constant name. Any of these may be used for ATTRIBUTE
@@ -129,168 +346,555 @@ times to add to the array: e.g. --set keywords 'foo' --set keywords 'bar' will
 set keywords to ['foo', 'bar']
 
 Options are executed in the following order regardless of order passed on the
-command line: restore, wipe, copyfrom, clear, set, append, update, remove,
-mirror, get, list, backup.  --backup and --restore are mutually exclusive.
-Other options may be combined or chained together.
+command line: restore, wipe, copyfrom, clear, set, append, remove, mirror, get,
+list, backup.  --backup and --restore are mutually exclusive.  Other options may
+be combined or chained together.
 
 Finder tags (tags attribute) contain both a name and an optional color. To
 specify the color, append comma + color name (e.g. 'red') after the tag name.
 For example --set tags Foo,red. Valid color names are: gray, green, purple,
-blue, yellow, red, orange. If color is not specified but a tag of the same
-name has already been assigned a color in the Finder, the same color will
+blue, yellow, red, orange. If color is not specified but a tag of the same name
+has already been assigned a color in the Finder, the same color will
 automatically be assigned.
 
 com.apple.FinderInfo (finderinfo) value is a key:value dictionary. To set
 finderinfo, pass value in format key1:value1,key2:value2,etc. For example:
 'osxmetadata --set finderinfo color:2 file.ext'.
 
-Short Name               Description
-authors                  kMDItemAuthors, com.apple.metadata:kMDItemAuthors;
-                         The author, or authors, of the contents of the
-                         file.  A list of strings.
-comment                  kMDItemComment, com.apple.metadata:kMDItemComment;
-                         A comment related to the file.  This differs from
-                         the Finder comment, kMDItemFinderComment.  A
-                         string.
-copyright                kMDItemCopyright,
-                         com.apple.metadata:kMDItemCopyright; The copyright
-                         owner of the file contents.  A string.
-creator                  kMDItemCreator, com.apple.metadata:kMDItemCreator;
-                         Application used to create the document content
-                         (for example “Word”, “Pages”, and so on).  A
-                         string.
-description              kMDItemDescription,
-                         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.
-downloadeddate           kMDItemDownloadedDate,
-                         com.apple.metadata:kMDItemDownloadedDate; 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.
-duedate                  kMDItemDueDate, com.apple.metadata:kMDItemDueDate;
-                         The date the item is due.  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.
-findercolor              findercolor, com.apple.FinderInfo; Color tag set by
-                         the Finder.  Colors can also be set by
-                         _kMDItemUserTags.  This is controlled by the Finder
-                         and it's recommended you don't directly access this
-                         attribute.  If you set or remove a color tag via
-                         _kMDItemUserTag, osxmetadata will automatically
-                         handle processing of FinderInfo color tag.
-findercomment            kMDItemFinderComment,
-                         com.apple.metadata:kMDItemFinderComment; Finder
-                         comments for this file.  A string.
-finderinfo               finderinfo, com.apple.FinderInfo; Info set by the
-                         Finder, for example tag color.  Colors can also be
-                         set by _kMDItemUserTags.  com.apple.FinderInfo is
-                         controlled by the Finder and it's recommended you
-                         don't directly access this attribute.  If you set
-                         or remove a color tag via _kMDItemUserTag,
-                         osxmetadata will automatically handle processing of
-                         FinderInfo color tag.
-headline                 kMDItemHeadline,
-                         com.apple.metadata:kMDItemHeadline; A publishable
-                         entry providing a synopsis of the contents of the
-                         file.  A string.
-keywords                 kMDItemKeywords,
-                         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.
-osxphotos_detected_text  OSXPhotosDetectedText,
-                         osxphotos.metadata:detected_text; Text detected in
-                         a photo; used by osxphotos
-                         (https://github.com/RhetTbull/osxphotos).
-participants             kMDItemParticipants,
-                         com.apple.metadata:kMDItemParticipants; The list of
-                         people who are visible in an image or movie or
-                         written about in a document. A list of strings.
-projects                 kMDItemProjects,
-                         com.apple.metadata:kMDItemProjects; The list of
-                         projects that this file is part of. For example, if
-                         you were working on a movie all of the files could
-                         be marked as belonging to the project “My Movie”. A
-                         list of strings.
-rating                   kMDItemStarRating,
-                         com.apple.metadata:kMDItemStarRating; User rating
-                         of this item. For example, the stars rating of an
-                         iTunes track. An integer.
-stationary               kMDItemFSIsStationery,
-                         com.apple.metadata:kMDItemFSIsStationery; Boolean
-                         indicating if this file is stationery. Note: this
-                         is not what the Finder uses for Stationary Pad
-                         flag.  See also 'stationarypad'.
-stationarypad            stationarypad, com.apple.FinderInfo; Marks the file
-                         as stationary (a template that can be re-used).
-                         Setting this to True is the same as checking the
-                         'Stationary Pad' box in Finder Info.
-subject                  kMDItemSubject, com.apple.metadata:kMDItemSubject;
-                         Subject of the this item. A string.
-tags                     _kMDItemUserTags,
-                         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 Tag
-                         objects.
-title                    kMDItemTitle, com.apple.metadata:kMDItemTitle; The
-                         title of the file. For example, this could be the
-                         title of a document, the name of a song, or the
-                         subject of an email message. A string.
-version                  kMDItemVersion, com.apple.metadata:kMDItemVersion;
-                         The version number of this file. A string.
-wherefroms               kMDItemWhereFroms,
-                         com.apple.metadata:kMDItemWhereFroms; Describes
-                         where the file was obtained from (e.g. URL
-                         downloaded from).  A list of strings.
-```
+Short Name                 Description
+acquisitionmake            kMDItemAcquisitionMake;
+                           com.apple.metadata:kMDItemAcquisitionMake; The
+                           manufacturer of the device used to aquire the
+                           document contents.; string
+acquisitionmodel           kMDItemAcquisitionModel;
+                           com.apple.metadata:kMDItemAcquisitionModel; The
+                           model of the device used to aquire the document
+                           contents. For example, 100, 200, 400, etc.; string
+album                      kMDItemAlbum; com.apple.metadata:kMDItemAlbum; The
+                           title for a collection of media. This is analagous
+                           to a record album, or photo album.; string
+altitude                   kMDItemAltitude;
+                           com.apple.metadata:kMDItemAltitude; The altitude of
+                           the item in meters above sea level, expressed using
+                           the WGS84 datum. Negative values lie below sea
+                           level.; string
+aperture                   kMDItemAperture;
+                           com.apple.metadata:kMDItemAperture; The aperture
+                           setting used to acquire the document contents. This
+                           unit is the APEX value.; number
+appleloopdescriptors       kMDItemAppleLoopDescriptors;
+                           com.apple.metadata:kMDItemAppleLoopDescriptors;
+                           Specifies multiple pieces of descriptive
+                           information about a loop.; list of strings
+appleloopskeyfiltertype    kMDItemAppleLoopsKeyFilterType;
+                           com.apple.metadata:kMDItemAppleLoopsKeyFilterType;
+                           Specifies key filtering information about a loop.
+                           Loops are matched against projects that often in a
+                           major or minor key.; string
+appleloopsloopmode         kMDItemAppleLoopsLoopMode;
+                           com.apple.metadata:kMDItemAppleLoopsLoopMode;
+                           Specifies how a file should be played.; string
+appleloopsrootkey          kMDItemAppleLoopsRootKey;
+                           com.apple.metadata:kMDItemAppleLoopsRootKey;
+                           Specifies the loop's original key. The key is the
+                           root note or tonic for the loop, and does not
+                           include the scale type.; string
+attributechangedate        kMDItemAttributeChangeDate;
+                           com.apple.metadata:kMDItemAttributeChangeDate; The
+                           date and time of the last change made to a metadata
+                           attribute.; date/time
+audiences                  kMDItemAudiences;
+                           com.apple.metadata:kMDItemAudiences; The audience
+                           for which the file is intended. The audience may be
+                           determined by the creator or the publisher or by a
+                           third party.; list of strings
+audiobitrate               kMDItemAudioBitRate;
+                           com.apple.metadata:kMDItemAudioBitRate; The audio
+                           bit rate.; number
+audiochannelcount          kMDItemAudioChannelCount;
+                           com.apple.metadata:kMDItemAudioChannelCount; Number
+                           of channels in the audio data contained in the
+                           file.; number
+audioencodingapplication   kMDItemAudioEncodingApplication;
+                           com.apple.metadata:kMDItemAudioEncodingApplication;
+                           The name of the application that encoded the data
+                           contained in the audio file.; string
+audiosamplerate            kMDItemAudioSampleRate;
+                           com.apple.metadata:kMDItemAudioSampleRate; Sample
+                           rate of the audio data contained in the file. The
+                           sample rate is a float value representing hz
+                           (audio_frames/second). For example: 44100. 0,
+                           22254. 54.; number
+audiotracknumber           kMDItemAudioTrackNumber;
+                           com.apple.metadata:kMDItemAudioTrackNumber; The
+                           track number of a song or composition when it is
+                           part of an album.; number
+authoraddresses            kMDItemAuthorAddresses;
+                           com.apple.metadata:kMDItemAuthorAddresses; This
+                           attribute indicates the author addresses of the
+                           document.; list of strings
+authoremailaddresses       kMDItemAuthorEmailAddresses;
+                           com.apple.metadata:kMDItemAuthorEmailAddresses;
+                           This attribute indicates the author of the emails
+                           message addresses. (This is always the email
+                           address, and not the human readable version).; list
+                           of strings
+authors                    kMDItemAuthors; com.apple.metadata:kMDItemAuthors;
+                           The author, or authors, of the contents of the
+                           file.; list of strings
+bitspersample              kMDItemBitsPerSample;
+                           com.apple.metadata:kMDItemBitsPerSample; The number
+                           of bits per sample. For example, the bit depth of
+                           an image (8-bit, 16-bit etc. . . ) or the bit depth
+                           per audio sample of uncompressed audio data (8, 16,
+                           24, 32, 64, etc. . ).; number
+cfbundleidentifier         kMDItemCFBundleIdentifier;
+                           com.apple.metadata:kMDItemCFBundleIdentifier; If
+                           this item is a bundle, then this is the
+                           CFBundleIdentifier.; string
+city                       kMDItemCity; com.apple.metadata:kMDItemCity;
+                           Identifies city of origin according to guidelines
+                           established by the provider.; string
+codecs                     kMDItemCodecs; com.apple.metadata:kMDItemCodecs;
+                           The codecs used to encode/decode the media.; list
+                           of strings
+colorspace                 kMDItemColorSpace;
+                           com.apple.metadata:kMDItemColorSpace; The color
+                           space model used by the document contents. For
+                           example, "RGB", "CMYK", "YUV", or "YCbCr".; string
+comment                    kMDItemComment; com.apple.metadata:kMDItemComment;
+                           A comment related to the file. This differs from
+                           the Finder comment, kMDItemFinderComment.; string
+composer                   kMDItemComposer;
+                           com.apple.metadata:kMDItemComposer; The composer of
+                           the music contained in the audio file.; string
+contactkeywords            kMDItemContactKeywords;
+                           com.apple.metadata:kMDItemContactKeywords; A list
+                           of contacts that are associated with this document,
+                           not including the authors.; list of strings
+contentcreationdate        kMDItemContentCreationDate;
+                           com.apple.metadata:kMDItemContentCreationDate; The
+                           creation date of an edited or optimized version of
+                           the song or composition.; date/time
+contentmodificationdate    kMDItemContentModificationDate;
+                           com.apple.metadata:kMDItemContentModificationDate;
+                           The date and time that the contents of the file
+                           were last modified.; date/time
+contenttype                kMDItemContentType;
+                           com.apple.metadata:kMDItemContentType; The UTI
+                           pedigree of a file.; string
+contributors               kMDItemContributors;
+                           com.apple.metadata:kMDItemContributors; The
+                           entities responsible for making contributions to
+                           the content of the resource.; list of strings
+copyright                  kMDItemCopyright;
+                           com.apple.metadata:kMDItemCopyright; The copyright
+                           owner of the file contents.; string
+country                    kMDItemCountry; com.apple.metadata:kMDItemCountry;
+                           The full, publishable name of the country or region
+                           where the intellectual property of the item was
+                           created, according to guidelines of the provider.;
+                           string
+coverage                   kMDItemCoverage;
+                           com.apple.metadata:kMDItemCoverage; The extent or
+                           scope of the content of the resource.; string
+creator                    kMDItemCreator; com.apple.metadata:kMDItemCreator;
+                           Application used to create the document content
+                           (for example "Word", "Pages", and so on).; string
+deliverytype               kMDItemDeliveryType;
+                           com.apple.metadata:kMDItemDeliveryType; The
+                           delivery type. Values are "Fast start" or "RTSP".;
+                           string
+description                kMDItemDescription;
+                           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.;
+                           string
+director                   kMDItemDirector;
+                           com.apple.metadata:kMDItemDirector; Directory of
+                           the movie.; string
+displayname                kMDItemDisplayName;
+                           com.apple.metadata:kMDItemDisplayName; The
+                           localized version of the file name.; string
+downloadeddate             kMDItemDownloadedDate;
+                           com.apple.metadata:kMDItemDownloadedDate; Date the
+                           item was downloaded.; list of date/time
+duedate                    kMDItemDueDate; com.apple.metadata:kMDItemDueDate;
+                           Date this item is due.; date/time
+durationseconds            kMDItemDurationSeconds;
+                           com.apple.metadata:kMDItemDurationSeconds; The
+                           duration, in seconds, of the content of file. A
+                           value of 10. 5 represents media that is 10 and 1/2
+                           seconds long.; number
+exifgpsversion             kMDItemEXIFGPSVersion;
+                           com.apple.metadata:kMDItemEXIFGPSVersion; The
+                           version of GPSInfoIFD in EXIF used to generate the
+                           metadata.; string
+exifversion                kMDItemEXIFVersion;
+                           com.apple.metadata:kMDItemEXIFVersion; The version
+                           of the EXIF header used to generate the metadata.;
+                           string
+emailaddresses             kMDItemEmailAddresses;
+                           com.apple.metadata:kMDItemEmailAddresses; Email
+                           addresses related to this item.; list of strings
+encodingapplications       kMDItemEncodingApplications;
+                           com.apple.metadata:kMDItemEncodingApplications;
+                           Application used to convert the original content
+                           into it's current form. For example, a PDF file
+                           might have an encoding application set to
+                           "Distiller".; list of strings
+exposuremode               kMDItemExposureMode;
+                           com.apple.metadata:kMDItemExposureMode; The
+                           exposure mode used to acquire the document
+                           contents.; number
+exposureprogram            kMDItemExposureProgram;
+                           com.apple.metadata:kMDItemExposureProgram; The
+                           class of the exposure program used by the camera to
+                           set exposure when the image is taken. Possible
+                           values include: Manual, Normal, and Aperture
+                           priority.; string
+exposuretimeseconds        kMDItemExposureTimeSeconds;
+                           com.apple.metadata:kMDItemExposureTimeSeconds; The
+                           exposure time, in seconds, used to acquire the
+                           document contents.; number
+exposuretimestring         kMDItemExposureTimeString;
+                           com.apple.metadata:kMDItemExposureTimeString; The
+                           time of the exposure.; string
+fnumber                    kMDItemFNumber; com.apple.metadata:kMDItemFNumber;
+                           The diameter of the diaphragm aperture in terms of
+                           the effective focal length of the lens.; number
+fscontentchangedate        kMDItemFSContentChangeDate;
+                           com.apple.metadata:kMDItemFSContentChangeDate; The
+                           date the file contents last changed.; date/time
+fscreationdate             kMDItemFSCreationDate;
+                           com.apple.metadata:kMDItemFSCreationDate; The date
+                           and time that the file was created.; date/time
+fshascustomicon            kMDItemFSHasCustomIcon;
+                           com.apple.metadata:kMDItemFSHasCustomIcon; Boolean
+                           indicating if this file has a custom icon.; boolean
+fsinvisible                kMDItemFSInvisible;
+                           com.apple.metadata:kMDItemFSInvisible; Indicates
+                           whether the file is invisible.; boolean
+fsisextensionhidden        kMDItemFSIsExtensionHidden;
+                           com.apple.metadata:kMDItemFSIsExtensionHidden;
+                           Indicates whether the file extension of the file is
+                           hidden.; boolean
+fsisstationery             kMDItemFSIsStationery;
+                           com.apple.metadata:kMDItemFSIsStationery; Boolean
+                           indicating if this file is stationery.; boolean
+fslabel                    kMDItemFSLabel; com.apple.metadata:kMDItemFSLabel;
+                           Index of the Finder label of the file. Possible
+                           values are 0 through 7.; number
+fsname                     kMDItemFSName; com.apple.metadata:kMDItemFSName;
+                           The file name of the item.; string
+fsnodecount                kMDItemFSNodeCount;
+                           com.apple.metadata:kMDItemFSNodeCount; Number of
+                           files in a directory.; number
+fsownergroupid             kMDItemFSOwnerGroupID;
+                           com.apple.metadata:kMDItemFSOwnerGroupID; The group
+                           ID of the owner of the file.; number
+fsowneruserid              kMDItemFSOwnerUserID;
+                           com.apple.metadata:kMDItemFSOwnerUserID; The user
+                           ID of the owner of the file.; number
+fssize                     kMDItemFSSize; com.apple.metadata:kMDItemFSSize;
+                           The size, in bytes, of the file on disk.; number
+findercomment              kMDItemFinderComment;
+                           com.apple.metadata:kMDItemFinderComment; Finder
+                           comments for this file.; string
+flashonoff                 kMDItemFlashOnOff;
+                           com.apple.metadata:kMDItemFlashOnOff; Indicates if
+                           a camera flash was used.; number
+focallength                kMDItemFocalLength;
+                           com.apple.metadata:kMDItemFocalLength; The actual
+                           focal length of the lens, in millimeters.; number
+fonts                      kMDItemFonts; com.apple.metadata:kMDItemFonts;
+                           Fonts used in this item. You should store the
+                           font's full name, the postscript name, or the font
+                           family name, based on the available information.;
+                           list of strings
+gpstrack                   kMDItemGPSTrack;
+                           com.apple.metadata:kMDItemGPSTrack; The direction
+                           of travel of the item, in degrees from true north.;
+                           string
+genre                      kMDItemGenre; com.apple.metadata:kMDItemGenre;
+                           Genre of the movie.; string
+hasalphachannel            kMDItemHasAlphaChannel;
+                           com.apple.metadata:kMDItemHasAlphaChannel;
+                           Indicates if this image file has an alpha channel.;
+                           boolean
+headline                   kMDItemHeadline;
+                           com.apple.metadata:kMDItemHeadline; A publishable
+                           entry providing a synopsis of the contents of the
+                           file. For example, "Apple Introduces the iPod
+                           Photo".; string
+isospeed                   kMDItemISOSpeed;
+                           com.apple.metadata:kMDItemISOSpeed; The ISO speed
+                           used to acquire the document contents.; number
+identifier                 kMDItemIdentifier;
+                           com.apple.metadata:kMDItemIdentifier; A formal
+                           identifier used to reference the resource within a
+                           given context.; string
+imagedirection             kMDItemImageDirection;
+                           com.apple.metadata:kMDItemImageDirection; The
+                           direction of the item's image, in degrees from true
+                           north.; string
+information                kMDItemInformation;
+                           com.apple.metadata:kMDItemInformation; Information
+                           about the item.; string
+instantmessageaddresses    kMDItemInstantMessageAddresses;
+                           com.apple.metadata:kMDItemInstantMessageAddresses;
+                           Instant message addresses related to this item.;
+                           list of strings
+instructions               kMDItemInstructions;
+                           com.apple.metadata:kMDItemInstructions; Editorial
+                           instructions concerning the use of the item, such
+                           as embargoes and warnings. For example, "Second of
+                           four stories".; string
+isgeneralmidisequence      kMDItemIsGeneralMIDISequence;
+                           com.apple.metadata:kMDItemIsGeneralMIDISequence;
+                           Indicates whether the MIDI sequence contained in
+                           the file is setup for use with a General MIDI
+                           device.; boolean
+keysignature               kMDItemKeySignature;
+                           com.apple.metadata:kMDItemKeySignature; The key of
+                           the music contained in the audio file. For example:
+                           C, Dm, F#m, Bb.; string
+keywords                   kMDItemKeywords;
+                           com.apple.metadata:kMDItemKeywords; Keywords
+                           associated with this file. For example, "Birthday",
+                           "Important", etc.; list of strings
+kind                       kMDItemKind; com.apple.metadata:kMDItemKind; A
+                           description of the kind of item this file
+                           represents.; string
+languages                  kMDItemLanguages;
+                           com.apple.metadata:kMDItemLanguages; Indicates the
+                           languages of the intellectual content of the
+                           resource. Recommended best practice for the values
+                           of the Language element is defined by RFC 3066.;
+                           list of strings
+lastuseddate               kMDItemLastUsedDate;
+                           com.apple.metadata:kMDItemLastUsedDate; The date
+                           and time that the file was last used. This value is
+                           updated automatically by LaunchServices everytime a
+                           file is opened by double clicking, or by asking
+                           LaunchServices to open a file.; date/time
+latitude                   kMDItemLatitude;
+                           com.apple.metadata:kMDItemLatitude; The latitude of
+                           the item in degrees north of the equator, expressed
+                           using the WGS84 datum. Negative values lie south of
+                           the equator.; string
+layernames                 kMDItemLayerNames;
+                           com.apple.metadata:kMDItemLayerNames; The names of
+                           the layers in the file.; list of strings
+longitude                  kMDItemLongitude;
+                           com.apple.metadata:kMDItemLongitude; The longitude
+                           of the item in degrees east of the prime meridian,
+                           expressed using the WGS84 datum. Negative values
+                           lie west of the prime meridian.; string
+lyricist                   kMDItemLyricist;
+                           com.apple.metadata:kMDItemLyricist; The lyricist,
+                           or text writer, of the music contained in the audio
+                           file.; string
+maxaperture                kMDItemMaxAperture;
+                           com.apple.metadata:kMDItemMaxAperture; The smallest
+                           f-number of the lens. Ordinarily it is given in the
+                           range of 00. 00 to 99. 99.; number
+mediatypes                 kMDItemMediaTypes;
+                           com.apple.metadata:kMDItemMediaTypes; The media
+                           types present in the content.; list of strings
+meteringmode               kMDItemMeteringMode;
+                           com.apple.metadata:kMDItemMeteringMode; The
+                           metering mode used to take the image.; string
+musicalgenre               kMDItemMusicalGenre;
+                           com.apple.metadata:kMDItemMusicalGenre; The musical
+                           genre of the song or composition contained in the
+                           audio file. For example: Jazz, Pop, Rock,
+                           Classical.; string
+musicalinstrumentcategory  kMDItemMusicalInstrumentCategory; com.apple.metadat
+                           a:kMDItemMusicalInstrumentCategory; Specifies the
+                           category of an instrument.; string
+musicalinstrumentname      kMDItemMusicalInstrumentName;
+                           com.apple.metadata:kMDItemMusicalInstrumentName;
+                           Specifies the name of instrument relative to the
+                           instrument category.; string
+namedlocation              kMDItemNamedLocation;
+                           com.apple.metadata:kMDItemNamedLocation; The name
+                           of the location or point of interest associated
+                           with the item. The name may be user provided.;
+                           string
+numberofpages              kMDItemNumberOfPages;
+                           com.apple.metadata:kMDItemNumberOfPages; Number of
+                           pages in the document.; number
+organizations              kMDItemOrganizations;
+                           com.apple.metadata:kMDItemOrganizations; The
+                           company or organization that created the document.;
+                           list of strings
+orientation                kMDItemOrientation;
+                           com.apple.metadata:kMDItemOrientation; The
+                           orientation of the document contents. Possible
+                           values are 0 (landscape) and 1 (portrait).; number
+originalformat             kMDItemOriginalFormat;
+                           com.apple.metadata:kMDItemOriginalFormat; Original
+                           format of the movie.; string
+originalsource             kMDItemOriginalSource;
+                           com.apple.metadata:kMDItemOriginalSource; Original
+                           source of the movie.; string
+pageheight                 kMDItemPageHeight;
+                           com.apple.metadata:kMDItemPageHeight; Height of the
+                           document page, in points (72 points per inch). For
+                           PDF files this indicates the height of the first
+                           page only.; number
+pagewidth                  kMDItemPageWidth;
+                           com.apple.metadata:kMDItemPageWidth; Width of the
+                           document page, in points (72 points per inch). For
+                           PDF files this indicates the width of the first
+                           page only.; number
+participants               kMDItemParticipants;
+                           com.apple.metadata:kMDItemParticipants; The list of
+                           people who are visible in an image or movie or
+                           written about in a document.; list of strings
+path                       kMDItemPath; com.apple.metadata:kMDItemPath; The
+                           complete path to the file.; string
+performers                 kMDItemPerformers;
+                           com.apple.metadata:kMDItemPerformers; Performers in
+                           the movie.; list of strings
+phonenumbers               kMDItemPhoneNumbers;
+                           com.apple.metadata:kMDItemPhoneNumbers; Phone
+                           numbers related to this item.; list of strings
+pixelcount                 kMDItemPixelCount;
+                           com.apple.metadata:kMDItemPixelCount; The total
+                           number of pixels in the contents. Same as
+                           kMDItemPixelWidth x kMDItemPixelHeight.; number
+pixelheight                kMDItemPixelHeight;
+                           com.apple.metadata:kMDItemPixelHeight; The height,
+                           in pixels, of the contents. For example, the image
+                           height or the video frame height.; number
+pixelwidth                 kMDItemPixelWidth;
+                           com.apple.metadata:kMDItemPixelWidth; The width, in
+                           pixels, of the contents. For example, the image
+                           width or the video frame width.; number
+producer                   kMDItemProducer;
+                           com.apple.metadata:kMDItemProducer; Producer of the
+                           content.; string
+profilename                kMDItemProfileName;
+                           com.apple.metadata:kMDItemProfileName; The name of
+                           the color profile used by the document contents.;
+                           string
+projects                   kMDItemProjects;
+                           com.apple.metadata:kMDItemProjects; The list of
+                           projects that this file is part of. For example, if
+                           you were working on a movie all of the files could
+                           be marked as belonging to the project "My Movie".;
+                           list of strings
+publishers                 kMDItemPublishers;
+                           com.apple.metadata:kMDItemPublishers; The entity
+                           responsible for making the resource available. For
+                           example, a person, an organization, or a service.
+                           Typically, the name of a publisher should be used
+                           to indicate the entity.; list of strings
+recipientaddresses         kMDItemRecipientAddresses;
+                           com.apple.metadata:kMDItemRecipientAddresses; This
+                           attribute indicates the recipient addresses of the
+                           document.; list of strings
+recipientemailaddresses    kMDItemRecipientEmailAddresses;
+                           com.apple.metadata:kMDItemRecipientEmailAddresses;
+                           This attribute indicates the recipients email
+                           addresses. (This is always the email address, and
+                           not the human readable version).; list of strings
+recipients                 kMDItemRecipients;
+                           com.apple.metadata:kMDItemRecipients; Recipients of
+                           this item.; list of strings
+recordingdate              kMDItemRecordingDate;
+                           com.apple.metadata:kMDItemRecordingDate; The
+                           recording date of the song or composition.;
+                           date/time
+recordingyear              kMDItemRecordingYear;
+                           com.apple.metadata:kMDItemRecordingYear; Indicates
+                           the year the item was recorded. For example, 1964,
+                           2003, etc.; number
+redeyeonoff                kMDItemRedEyeOnOff;
+                           com.apple.metadata:kMDItemRedEyeOnOff; Indicates if
+                           red-eye reduction was used to take the picture.;
+                           boolean
+resolutionheightdpi        kMDItemResolutionHeightDPI;
+                           com.apple.metadata:kMDItemResolutionHeightDPI;
+                           Resolution height, in DPI, of this image.; number
+resolutionwidthdpi         kMDItemResolutionWidthDPI;
+                           com.apple.metadata:kMDItemResolutionWidthDPI;
+                           Resolution width, in DPI, of this image.; number
+rights                     kMDItemRights; com.apple.metadata:kMDItemRights;
+                           Provides a link to information about rights held in
+                           and over the resource.; string
+securitymethod             kMDItemSecurityMethod;
+                           com.apple.metadata:kMDItemSecurityMethod; The
+                           security or encryption method used for the file.;
+                           number
+speed                      kMDItemSpeed; com.apple.metadata:kMDItemSpeed; The
+                           speed of the item, in kilometers per hour.; string
+starrating                 kMDItemStarRating;
+                           com.apple.metadata:kMDItemStarRating; User rating
+                           of this item. For example, the stars rating of an
+                           iTunes track.; number
+stateorprovince            kMDItemStateOrProvince;
+                           com.apple.metadata:kMDItemStateOrProvince;
+                           Identifies the province or state of origin
+                           according to guidelines established by the
+                           provider. For example, "CA", "Ontario", or
+                           "Sussex".; string
+streamable                 kMDItemStreamable;
+                           com.apple.metadata:kMDItemStreamable; Whether the
+                           content is prepared for streaming.; boolean
+subject                    kMDItemSubject; com.apple.metadata:kMDItemSubject;
+                           Subject of the this item.; string
+tempo                      kMDItemTempo; com.apple.metadata:kMDItemTempo; A
+                           float value that specifies the beats per minute of
+                           the music contained in the audio file.; number
+textcontent                kMDItemTextContent;
+                           com.apple.metadata:kMDItemTextContent; Contains a
+                           text representation of the content of the document.
+                           Data in multiple fields should be combined using a
+                           whitespace character as a separator.; string
+theme                      kMDItemTheme; com.apple.metadata:kMDItemTheme;
+                           Theme of the this item.; string
+timesignature              kMDItemTimeSignature;
+                           com.apple.metadata:kMDItemTimeSignature; The time
+                           signature of the musical composition contained in
+                           the audio/MIDI file. For example: "4/4", "7/8".;
+                           string
+timestamp                  kMDItemTimestamp;
+                           com.apple.metadata:kMDItemTimestamp; The timestamp
+                           on the item. This generally is used to indicate the
+                           time at which the event captured by the item took
+                           place.; string
+title                      kMDItemTitle; com.apple.metadata:kMDItemTitle; The
+                           title of the file. For example, this could be the
+                           title of a document, the name of a song, or the
+                           subject of an email message.; string
+totalbitrate               kMDItemTotalBitRate;
+                           com.apple.metadata:kMDItemTotalBitRate; The total
+                           bit rate, audio and video combined, of the media.;
+                           number
+url                        kMDItemURL; com.apple.metadata:kMDItemURL; Url of
+                           the item.; string
+version                    kMDItemVersion; com.apple.metadata:kMDItemVersion;
+                           The version number of this file.; string
+videobitrate               kMDItemVideoBitRate;
+                           com.apple.metadata:kMDItemVideoBitRate; The video
+                           bit rate.; number
+wherefroms                 kMDItemWhereFroms;
+                           com.apple.metadata:kMDItemWhereFroms; Describes
+                           where the file was obtained from.; list of strings
+whitebalance               kMDItemWhiteBalance;
+                           com.apple.metadata:kMDItemWhiteBalance; The white
+                           balance setting used to acquire the document
+                           contents. Possible values are 0 (auto white
+                           balance) and 1 (manual).; number
 
 
-## Supported Attributes
-
-Information about commonly used MacOS metadata attributes is available from [Apple](https://developer.apple.com/documentation/coreservices/file_metadata/mditem/common_metadata_attribute_keys?language=objc).  
-
-`osxmetadata` currently supports the following metadata attributes:
-
-| 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 datetime.datetime object.  If datetime.datetime object lacks tzinfo (i.e. it is timezone naive), it will be assumed to be in local timezone.|
-|kMDItemDueDate|duedate|com.apple.metadata:kMDItemDueDate|The date the item is due.  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.|
-|findercolor|findercolor|com.apple.FinderInfo|Color tag set by the Finder.  Colors can also be set by _kMDItemUserTags.  This is controlled by the Finder and it's recommended you don't directly access this attribute.  If you set or remove a color tag via _kMDItemUserTag, osxmetadata will automatically handle processing of FinderInfo color tag.|
-|kMDItemFinderComment|findercomment|com.apple.metadata:kMDItemFinderComment|Finder comments for this file.  A string.|
-|finderinfo|finderinfo|com.apple.FinderInfo|Info set by the Finder, for example tag color.  Colors can also be set by _kMDItemUserTags.  com.apple.FinderInfo is controlled by the Finder and it's recommended you don't directly access this attribute.  If you set or remove a color tag via _kMDItemUserTag, osxmetadata will automatically handle processing of FinderInfo color tag.|
-|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.|
-|OSXPhotosDetectedText|osxphotos_detected_text|osxphotos.metadata:detected_text|Text detected in a photo; used by osxphotos (https://github.com/RhetTbull/osxphotos).|
-|kMDItemParticipants|participants|com.apple.metadata:kMDItemParticipants|The list of people who are visible in an image or movie or written about in a document. A list of strings.|
-|kMDItemProjects|projects|com.apple.metadata:kMDItemProjects|The list of projects that this file is part of. For example, if you were working on a movie all of the files could be marked as belonging to the project “My Movie”. A list of strings.|
-|kMDItemStarRating|rating|com.apple.metadata:kMDItemStarRating|User rating of this item. For example, the stars rating of an iTunes track. An integer.|
-|kMDItemFSIsStationery|stationary|com.apple.metadata:kMDItemFSIsStationery|Boolean indicating if this file is stationery. Note: this is not what the Finder uses for Stationary Pad flag.  See also 'stationarypad'.|
-|stationarypad|stationarypad|com.apple.FinderInfo|Marks the file as stationary (a template that can be re-used). Setting this to True is the same as checking the 'Stationary Pad' box in Finder Info.|
-|kMDItemSubject|subject|com.apple.metadata:kMDItemSubject|Subject of the this item. A string.|
-|_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 Tag objects.|
-|kMDItemTitle|title|com.apple.metadata:kMDItemTitle|The title of the file. For example, this could be the title of a document, the name of a song, or the subject of an email message. A string.|
-|kMDItemVersion|version|com.apple.metadata:kMDItemVersion|The version number of this file. A string.|
-|kMDItemWhereFroms|wherefroms|com.apple.metadata:kMDItemWhereFroms|Describes where the file was obtained from (e.g. URL downloaded from).  A list of strings.|
+```
+<!-- [[[end]]] -->
 
 ## Notes on backup/restore
 
@@ -298,331 +902,29 @@ When run with `--backup`, osxmetadata backs up the metadata of each file in a fi
 
 **Note**: Prior to version 0.99.38, the backup file was not well-formed JSON which meant that some apps/viewers could not process the JSON file.  Version 0.99.38 fixes this and will silently update any `.osxmetadata.json` file encountered during `--backup` to be well-formed JSON but this breaks backwards compatibility with older versions of osxmetadata. If you use osxmetadata to sync data across multiple Macs, you must ensure all Macs are running the updated version.  For additional details, see [issue #57](https://github.com/RhetTbull/osxmetadata/issues/57).
 
-## Example uses of the package
-
-### Using the command line tool to set metadata:
-
-Set Finder tags to Test, append "John Doe" to list of authors, clear (delete) description, set finder comment to "Hello World":
-
-`osxmetadata --set tags Test --append authors "John Doe" --clear description --set findercomment "Hello World" ~/Downloads/test.jpg`
-
-Set Finder tag Foo with color green:
-
-`osxmetadata --set tags Foo,green test.txt`
-
-Walk a directory tree and add the Finder tag "test" to every file:
-
-`osxmetadata --append tags "Test" --walk ~/Downloads`
-
-Walk a directory tree and add the Finder tag "project" to all .jpg and .pdf files:
-
-`osxmetadata --append tags "project" --walk projectdir --pattern "*.pdf" --pattern "*.jpg"`
-
-### Using the programmatic interface
-
-There are two ways to access metadata using the programmatic interface.  First, an OSXMetaData object will create properties for each supported attribute using the "Short name" in table above.  For example:
-
-```python
-from osxmetadata import OSXMetaData, Tag
-
-filename = 'foo.txt'
-meta = OSXMetaData(filename)
-
-# set description
-meta.description = "This is my document."
-
-# add "Foo" to tags
-meta.tags += [Tag("Foo")]
-
-# set authors to "John Doe" and "Jane Smith"
-meta.authors = ["John Doe","Jane Smith"]
-
-# clear copyright
-meta.copyright = None
-
-```
-
-For additional details on using Finder tags, see [Tag object](#tag-object).
-
-If attribute is a list, most `list` methods can be used. For example:
-
-```python
->>> from osxmetadata import OSXMetaData, Tag
->>> md = OSXMetaData("test.txt")
->>> md.tags
-[Tag('Blue', 4), Tag('Green', 2), Tag('Foo', 0)]
->>> md.tags.pop(1)
-Tag('Green', 2)
->>> md.tags
-[Tag('Blue', 4), Tag('Foo', 0)]
->>> md.tags.sort()
->>> md.tags
-[Tag('Blue', 4), Tag('Foo', 0)]
->>> md.tags.append(Tag("Test"))
->>> md.tags
-[Tag('Blue', 4), Tag('Foo', 0), Tag('Test', 0)]
->>> md.tags.extend([Tag("Test1"),Tag("Test2")])
->>> md.tags
-[Tag('Blue', 4), Tag('Foo', 0), Tag('Test', 0), Tag('Test1', 0), Tag('Test2', 0)]
->>> md.tags.remove(Tag("Blue", 4))
->>> md.tags
-[Tag('Foo', 0), Tag('Test', 0), Tag('Test1', 0), Tag('Test2', 0)]
->>> md.tags.remove(Tag("Blue", 4))
-# ValueError if attempt to remove element not in list
-Traceback (most recent call last):
-  File "<stdin>", line 1, in <module>
-  File "/Users/rhet/anaconda3/envs/osxmeta/lib/python3.8/_collections_abc.py", line 997, in remove
-    del self[self.index(value)]
-  File "/Users/rhet/anaconda3/envs/osxmeta/lib/python3.8/_collections_abc.py", line 911, in index
-    raise ValueError
-ValueError
->>> md.tags.count(Tag("Test"))
-1
->>> md.tags.index(Tag("Test"))
-1
-```
-
-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
->>> 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)]
-```
-
-If attribute is string, it can be treated as a standard python `str`:
-
-```python
->>> import osxmetadata
->>> md = osxmetadata.OSXMetaData("/Users/rhet/Downloads/test.jpg")
->>> md.findercomment = "Hello world"
->>> md.findercomment
-'Hello world'
->>> md.findercomment += ". Goodbye"
->>> md.findercomment
-'Hello world. Goodbye'
->>> "world" in md.findercomment
-True
-```
-
-
-The second way to access metadata is using methods from OSXMetaData to get/set/update etc. the various attributes.  The various methods take the name of the attribute to be operated on which can be specified using either the short name, constant, or long constant from the table above. `osxmetadata` also exports constants with the same name as specified in the Apple documentation and the table above, for example, `kMDItemDescription`.
-
-```
-from osxmetadata import *
-
-fname = 'foo.txt'
-meta = OSXMetaData(fname)
-
-description = meta.get_attribute(kMDItemDescription)
-
-meta.set_attribute(kMDItemCreator,"OSXMetaData")
-
-meta.append_attribute("tags", [Tag("Blue")])
-
-meta.update_attribute("com.apple.metadata:kMDItemKeywords",["Foo"])
-
-meta.append_attribute("findercomment","Goodbye")
-
-meta.clear_attribute("tags")
-```
-
-
-## OSXMetaData methods and attributes
-
-### Create an OSXMetaData object
-`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
-
-### name
-`name()`
-
-Returns POSIX path of the file OSXMetaData is operating on.
-
-### get_attribute
-`get_attribute(attribute_name)`
-
-Load attribute and return value or None if attribute was not set (for list attributes, returns empty list if not set).
-
-- attribute_name: an osxmetadata Attribute name
-
-### get_attribute_str
-`get_attribute_str(attribute_name)`
-
-Returns a string representation of attribute value.  e.g. if attribute is a datedate.datetime object, will format using datetime.isoformat()
-
-- attribute_name: an osxmetadata Attribute name
-
-### set_attribute
-`set_attribute(attribute_name, value)`
-
-Write attribute to file with value
-
-- attribute_name: an osxmetadata Attribute name
-- value: value to store in attribute
-
-### update_attribute
-`update_attribute(attribute_name, value)`
-
-Update attribute with union of itself and value.  This avoids adding duplicate values to attribute. 
-
-- attribute: an osxmetadata Attribute name
-- value: value to append to attribute
-
-Note: implementation simply calls `append_attribute` with `update=True`; provided for convenience.
-
-### append_attribute
-`append_attribute(attribute_name, value, update=False)`
-
-Append value to attribute.
-
-- attribute_name: an osxmetadata Attribute name
-- value: value to append to attribute
-- update: (bool) if True, update instead of append (e.g. avoid adding duplicates, default is False)
-
-### remove_attribute
-`remove_attribute(attribute_name, value)`
-
-Remove a value from attribute, raise ValueError if attribute does not contain value.  Only applies to multi-valued attributes, otherwise raises TypeError.
-
-- attribute_name: name of OSXMetaData attribute
-
-### discard_attribute
-`discard_attribute(attribute_name, 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.
-
-- attribute_name: name of OSXMetaData attribute
-
-### clear_attribute
-`clear_attribute(attribute_name)`
-
-Clear anttribute (remove it from the file).
-
-- attribute_name: name of OSXMetaData attribute
-
-### list_metadata
-`list_metadata()`
-
-List the Apple metadata attributes set on the file.  e.g. those in com.apple.metadata namespace.
-
-### to_json
-`to_json()`
-
-Return dict in JSON format with all attributes for this file.  Format is the same as used by the command line --backup/--restore functions.
-
-### asdict
-`asdict()`
-
-Returns a dictionary of attribute values for the dictionary object in form:
-
-```python
-{'_version': '0.99.6', '_filepath': '/Users/rhet/Desktop/t.txt', '_filename': 't.txt', 'com.apple.metadata:_kMDItemUserTags': [['Hello', 0]], 'com.apple.metadata:kMDItemComment': 'test', 'com.apple.metadata:kMDItemFinderComment': 'Foo'}
-```
-
-### 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)]
-```
-
-## Tag object
-
-Unlike other attributes, Finder tags (`_kMDItemUserTags`) have two components: a name (str) and a color ID (unsigned int in range 0 to 7) represting a color tag in the Finder.  Reading tags returns a list of `Tag` objects and setting tags requires a list of `Tag` objects.  
-
-### Create a Tag object
-
-`Tag(name,color)`
-- `name`: tag name (str)
-- `color`: optional; color ID for Finder color label associated with tag (int) 
-
-If color is not provided, `Tag` will see if the user has already assigned a color to a tag of the same name via the Finder (Finder | Preferences | Tags) and if so, assign the same color.  If a match is not found, the tag will be created with no color (`osxmetadata.FINDER_COLOR_NONE`)
-
-Valid color constants (exported by osxmetadata):
-
-- `FINDER_COLOR_GRAY` = 1
-- `FINDER_COLOR_GREEN` = 2
-- `FINDER_COLOR_PURPLE` = 3
-- `FINDER_COLOR_BLUE` = 4
-- `FINDER_COLOR_YELLOW` = 5
-- `FINDER_COLOR_RED` = 6
-- `FINDER_COLOR_ORANGE` = 7
-
-
-```python
-from osxmetadata import OSXMetaData, Tag, FINDER_COLOR_GREEN
-md = OSXMetaData("test.txt")
-md.tags = [Tag("Foo")]
-md.tags += [Tag("Test",FINDER_COLOR_GREEN)]
-```
-
 ## Usage Notes
 
-Changes are immediately written to the file.  For example, OSXMetaData.tags.append("Foo") immediately writes the tag 'Foo' to the file.
-
-Metadata is refreshed from disk every time a class property is accessed.
-
 This will only work on file systems that support Mac OS X extended attributes.
 
-## Dependencies
-[bitstring](https://pypi.org/project/bitstring/)
-
-[Click](https://palletsprojects.com/p/click/)
-
-[PyObjC](https://pythonhosted.org/pyobjc/)
-
-[xattr](https://github.com/xattr/xattr)
-
-[py-applescript](https://github.com/rdhyee/py-applescript)
-
 ## Related Projects
 
 - [tag](https://github.com/jdberry/tag) A command line tool to manipulate tags on Mac OS X files, and to query for files with those tags.
 - [osx-tags](https://github.com/scooby/osx-tags) Python module to manipulate Finder tags in OS X.
 
 ## Acknowledgements
+
 This module was inspired by [osx-tags](https://github.com/scooby/osx-tags) by "Ben S / scooby".  I leveraged osx-tags to bootstrap the design of this module.  I wanted a more general OS X metadata library so I rolled my own.  This module is published under the same MIT license as osx-tags.
 
+## License
+
+MIT License
+
+Copyright (c) 2020 Rhet Turnbull
+
+## Contributing
+
+Contributions of all kinds are welcome.  Please submit a pull request or open an issue.
+
 ## Contributors ✨
 
 Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
diff --git a/README_DEV.md b/README_DEV.md
new file mode 100644
index 0000000..8e7f064
--- /dev/null
+++ b/README_DEV.md
@@ -0,0 +1,22 @@
+# Developer Notes For osxmetadata
+
+These are notes to help me remember how to build and release osxmetadata. They may be useful for contributors.
+
+## Tooling
+
+The build is managed by [doit](https://pydoit.org/) and [poetry](https://python-poetry.org/).  Version management uses [bump2version](https://github.com/c4urself/bump2version).  Documentation is updated with [cog](https://nedbatchelder.com/code/cog/) and [mkdocs](https://www.mkdocs.org/). [pytest](https://docs.pytest.org/en/stable/) is used for testing.
+
+To bump the minor version, run `bumpversion minor --verbose --dry-run` to see what will happen then again without the `--dru-run` to implement the change. This updates the `__version__` constant in the package as well as the poetry version.
+
+## Setting up the environment
+
+- `pip install poetry`
+- `poetry install`
+
+## Testing
+
+The doit `test` task (`doit test`) will run pytest to test the package. The README.md file contains some example code that can be tested with `poetry run pytest --doctest-glob=README.md`.  The `test` task will do this automatically.
+
+## Building
+
+To test and build the package, run `doit`. This will build the package and run the tests.  Run `doit list` to see the available tasks.  doit tasks are defined in `dodo.py`.
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..3ee27fa
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,9 @@
+# Welcome to osxmetadata
+
+osxmetadata provides a simple interface to access various metadata about MacOS / OS X files.  Currently supported metadata attributes include tags/keywords, Finder comments, authors, etc.
+
+## Reference
+
+::: osxmetadata.osxmetadata.OSXMetaData
+    :members:
+    :undoc-members:
diff --git a/dodo.py b/dodo.py
index 0eb32b5..0b44aed 100644
--- a/dodo.py
+++ b/dodo.py
@@ -3,6 +3,21 @@
 import osxmetadata
 
 
+def task_update_readme():
+    """Update README with CLI output"""
+    return {"actions": ["poetry run cog -r README.md"]}
+
+
+def task_test():
+    """Run tests"""
+    return {"actions": ["poetry run pytest --doctest-glob=README.md tests/"]}
+
+
+def task_docs():
+    """Build docs"""
+    return {"actions": ["poetry run mkdocs build"]}
+
+
 def task_clean_build_files():
     """Clean out old build files"""
     return {
diff --git a/mkdocs.yml b/mkdocs.yml
new file mode 100644
index 0000000..38e8ba0
--- /dev/null
+++ b/mkdocs.yml
@@ -0,0 +1,8 @@
+site_name: osxmetadata Documenation 
+
+theme:
+  name: "material"
+
+plugins:
+- search
+- mkdocstrings
\ No newline at end of file
diff --git a/osxmetadata/__init__.py b/osxmetadata/__init__.py
index f08e985..b72bf55 100644
--- a/osxmetadata/__init__.py
+++ b/osxmetadata/__init__.py
@@ -2,7 +2,13 @@
     such as tags/keywords and Finder comments from files """
 
 from ._version import __version__
-from .attributes import ATTRIBUTES
+from .attribute_data import (
+    MDIMPORTER_ATTRIBUTE_DATA,
+    MDITEM_ATTRIBUTE_DATA,
+    MDITEM_ATTRIBUTE_READ_ONLY,
+    MDITEM_ATTRIBUTE_SHORT_NAMES,
+    NSURL_RESOURCE_KEY_DATA,
+)
 from .constants import (
     FINDER_COLOR_BLUE,
     FINDER_COLOR_GRAY,
@@ -12,32 +18,46 @@
     FINDER_COLOR_PURPLE,
     FINDER_COLOR_RED,
     FINDER_COLOR_YELLOW,
-    FinderInfo,
-    _COLORIDS,
-    _COLORNAMES,
-    _FINDER_COMMENT_NAMES,
-    _MAX_FINDERCOMMENT,
-    _MAX_WHEREFROM,
-    _VALID_COLORIDS,
-    _kMDItemUserTags,
-    kMDItemAuthors,
-    kMDItemComment,
-    kMDItemCopyright,
-    kMDItemCreator,
-    kMDItemDescription,
-    kMDItemDownloadedDate,
-    kMDItemFinderComment,
-    kMDItemHeadline,
-    kMDItemKeywords,
-    kMDItemParticipants,
-    kMDItemProjects,
-    kMDItemStarRating,
-    kMDItemSubject,
-    kMDItemTitle,
-    kMDItemUserTags,
-    kMDItemVersion,
-    kMDItemWhereFroms,
 )
-from .debug import _debug, _set_debug
-from .findertags import Tag, get_tag_color_name
-from .osxmetadata import OSXMetaData
+from .finder_info import _kFinderColor, _kFinderInfo, _kFinderStationeryPad
+from .finder_tags import Tag, _kMDItemUserTags
+from .mditem import MDItemValueType
+from .osxmetadata import ALL_ATTRIBUTES, ASDICT_ATTRIBUTES, OSXMetaData
+
+# add metadata attribute constants such as kMDItemFinderComment and NSURLTagNamesKey to module namespace
+for constant in MDITEM_ATTRIBUTE_DATA.keys():
+    globals()[constant] = constant
+for constant in MDIMPORTER_ATTRIBUTE_DATA.keys():
+    globals()[constant] = constant
+for constant in NSURL_RESOURCE_KEY_DATA.keys():
+    globals()[constant] = constant
+
+
+__all__ = [
+    "ALL_ATTRIBUTES",
+    "ASDICT_ATTRIBUTES",
+    "FINDER_COLOR_BLUE",
+    "FINDER_COLOR_GRAY",
+    "FINDER_COLOR_GREEN",
+    "FINDER_COLOR_NONE",
+    "FINDER_COLOR_ORANGE",
+    "FINDER_COLOR_PURPLE",
+    "FINDER_COLOR_RED",
+    "FINDER_COLOR_YELLOW",
+    "MDIMPORTER_ATTRIBUTE_DATA",
+    "MDITEM_ATTRIBUTE_DATA",
+    "MDITEM_ATTRIBUTE_READ_ONLY",
+    "MDITEM_ATTRIBUTE_SHORT_NAMES",
+    "MDItemValueType",
+    "NSURL_RESOURCE_KEY_DATA",
+    "OSXMetaData",
+    "Tag",
+    "__version__",
+    "_kFinderColor",
+    "_kFinderInfo",
+    "_kFinderStationeryPad",
+    "_kMDItemUserTags",
+    *MDIMPORTER_ATTRIBUTE_DATA.keys(),
+    *MDITEM_ATTRIBUTE_DATA.keys(),
+    *NSURL_RESOURCE_KEY_DATA.keys(),
+]
diff --git a/osxmetadata/__main__.py b/osxmetadata/__main__.py
index f2e2356..545c6fd 100644
--- a/osxmetadata/__main__.py
+++ b/osxmetadata/__main__.py
@@ -1,36 +1,35 @@
-# /usr/bin/env python3
+"""CLI for osxmetadata"""
 
 import datetime
 import glob
-import itertools
 import json
 import logging
 import os
 import os.path
 import pathlib
-import sys
+import typing as t
 
 import click
 
-import osxmetadata
-
-from ._version import __version__
-from .attributes import (
-    _LONG_NAME_WIDTH,
-    _SHORT_NAME_WIDTH,
-    ATTRIBUTE_DISPATCH,
-    ATTRIBUTES,
-)
-from .backup import load_backup_file, write_backup_file
-from .classes import _AttributeFinderInfo, _AttributeList, _AttributeTagsList
-from .constants import (
-    _BACKUP_FILENAME,
-    _COLORNAMES_LOWER,
-    _FINDERINFO_NAMES,
-    _TAGS_NAMES,
-    FINDER_COLOR_NONE,
+from osxmetadata import (
+    ALL_ATTRIBUTES,
+    MDIMPORTER_ATTRIBUTE_DATA,
+    MDITEM_ATTRIBUTE_DATA,
+    MDITEM_ATTRIBUTE_READ_ONLY,
+    MDITEM_ATTRIBUTE_SHORT_NAMES,
+    OSXMetaData,
+    Tag,
+    __version__,
+    _kFinderColor,
+    _kFinderInfo,
+    _kFinderStationeryPad,
+    _kMDItemUserTags,
 )
-from .findertags import Tag, tag_factory
+from osxmetadata.backup import get_backup_dict, load_backup_file, write_backup_file
+from osxmetadata.constants import _COLORNAMES_LOWER, _TAGS_NAMES, FINDER_COLOR_NONE
+from osxmetadata.finder_info import str_to_finder_color
+from osxmetadata.finder_tags import tag_factory
+from osxmetadata.mditem import str_to_mditem_type
 
 # TODO: how is metadata on symlink handled?
 # should symlink be resolved before gathering metadata?
@@ -41,14 +40,606 @@
 # TODO: add selective restore (e.g only restore files matching command line path)
 #   e.g osxmetadata -r meta.json *.pdf
 
+# TODO: fix output of str_to_mditem_type to be more helpful: ValueError: Invalid isoformat string: '2022-10-6'
+# also wrap in try/except and print error message
+
+_SHORT_NAME_WIDTH = (
+    max(len(x["short_name"]) for x in MDITEM_ATTRIBUTE_DATA.values()) + 1
+)
+_LONG_NAME_WIDTH = max(len(x["name"]) for x in MDITEM_ATTRIBUTE_DATA.values()) + 1
+
+BACKUP_FILENAME = ".osxmetadata.json"
+
+
+def get_writeable_attributes() -> t.List[str]:
+    """Return a list of writeable attributes"""
+    no_write = ["kMDItemContentCreationDate", "kMDItemContentModificationDate"]
+    write = [
+        *MDITEM_ATTRIBUTE_DATA.keys(),
+        _kFinderColor,
+        _kFinderStationeryPad,
+        _kMDItemUserTags,
+    ]
+    return [
+        attr
+        for attr in write
+        if attr not in MDITEM_ATTRIBUTE_READ_ONLY and attr not in no_write
+    ]
+
+
+WRITABLE_ATTRIBUTES = get_writeable_attributes()
+
+
+def get_attribute_type(attr: str) -> t.Optional[str]:
+    """Get the type of an attribute
+
+    Args:
+        attr: attribute name
+
+    Returns:
+        type of attribute as string or None if type is not known
+    """
+    if attr in MDITEM_ATTRIBUTE_SHORT_NAMES:
+        attr = MDITEM_ATTRIBUTE_SHORT_NAMES[attr]
+    return (
+        "list"
+        if attr in _TAGS_NAMES
+        else MDITEM_ATTRIBUTE_DATA[attr]["python_type"]
+        if attr in MDITEM_ATTRIBUTE_DATA
+        else "int"
+        if attr == _kFinderColor
+        else "bool"
+        if attr == _kFinderStationeryPad
+        else None
+    )
+
+
+def get_attribute_name(attr: str) -> str:
+    """Get the long name of an attribute
+
+    Args:
+        attr: attribute name
+
+    Returns:
+        long name of attribute or attr if long name is not known
+    """
+    if attr in MDITEM_ATTRIBUTE_SHORT_NAMES:
+        return MDITEM_ATTRIBUTE_SHORT_NAMES[attr]
+    elif attr in MDITEM_ATTRIBUTE_DATA:
+        return attr
+    elif attr in MDIMPORTER_ATTRIBUTE_DATA:
+        return attr
+    elif attr in _TAGS_NAMES:
+        return _kMDItemUserTags
+    elif attr in [_kFinderColor, _kFinderStationeryPad]:
+        return attr
+    else:
+        raise ValueError(f"Unknown attribute: {attr}")
+
+
+def value_to_str(value) -> str:
+    """Convert a metadata value to str suitable for printing to terminal"""
+    if isinstance(value, str):
+        return value
+    elif value is None:
+        return "(null)"
+    if isinstance(value, datetime.datetime):
+        return value.isoformat()
+    elif isinstance(value, (list, tuple)):
+        if not value:
+            return "(empty list)"
+        if isinstance(value[0], str):
+            return ", ".join(value)
+        elif isinstance(value[0], Tag):
+            return ", ".join(f"{x.name}: {x.color}" for x in value)
+        elif isinstance(value[0], datetime.datetime):
+            return ", ".join(x.isoformat() for x in value)
+        else:
+            return ", ".join(str(x) for x in value)
+    else:
+        return str(value)
+
+
+def str_to_bool(value: str) -> bool:
+    """Convert str value to bool:
+
+    Args:
+        value: str value to convert to bool
+
+    Returns: True if str value is "True" or "true", or non-zero int; False otherwise
+    """
+    try:
+        return bool(int(value))
+    except ValueError:
+        return value.lower() == "true"
+
+
+def get_attributes_to_wipe(md: OSXMetaData) -> t.List[str]:
+    """Get list of non-null metadata attributes on a file that can be wiped"""
+
+    attribute_list = []
+    for attr in WRITABLE_ATTRIBUTES:
+        if value := md.get(attr):
+            attribute_list.append(attr)
+    return attribute_list
+
+
+def md_wipe_metadata(md: OSXMetaData, verbose: bool = False):
+    """Wipe metadata attributes on a file
+
+    Args:
+        md: OSXMetaData object for file
+        attributes: list of attributes to wipe
+        verbose: if True, print verbose output
+    """
+    attr_list = get_attributes_to_wipe(md)
+    filepath = md.path
+    if verbose:
+        if attr_list:
+            click.echo(f"Wiping metadata from {filepath}")
+        else:
+            click.echo(f"No metadata to wipe from {filepath}")
+    for attr in attr_list:
+        try:
+            if verbose:
+                click.echo(f"  Wiping {attr} from {filepath}")
+            md.set(attr, None)
+        except AttributeError:
+            if verbose:
+                click.echo(
+                    f"  Unknown attribute {attr} on {filepath}, skipping", err=True
+                )
+
+
+def validate_attribute_names(attributes: t.Union[t.Tuple[str], t.Tuple[str, str]]):
+    """Validate attribute names as returned by click option parsing:
+
+    Args:
+        attributes: tuple of attribute names or tuple of attribute name and value
+
+    Returns:
+        True if valid, raises click.BadParameter if not
+    """
+
+    for attr in attributes:
+        if isinstance(attr, tuple):
+            attr = attr[0]
+        if attr not in ALL_ATTRIBUTES:
+            raise click.BadParameter(f"Invalid attribute name: {attr}")
+
+
+def get_attribute_names(attribute: str) -> t.Tuple[str, str]:
+    """Get the name and short name for a metadata attribute
+
+    Args:
+        attribute: attribute name or short name
+
+    Returns:
+        tuple of attribute name and short name
+    """
+    if attribute in MDITEM_ATTRIBUTE_DATA:
+        attribute = MDITEM_ATTRIBUTE_DATA[attribute]
+        short_name = attribute["short_name"]
+        name = attribute["name"]
+    elif attribute in MDITEM_ATTRIBUTE_SHORT_NAMES:
+        short_name = attribute
+        name = MDITEM_ATTRIBUTE_SHORT_NAMES[attribute]
+    elif attribute in MDIMPORTER_ATTRIBUTE_DATA:
+        attribute = MDIMPORTER_ATTRIBUTE_DATA[attribute]
+        short_name = attribute["name"]
+        name = attribute["name"]
+    elif attribute in [_kFinderInfo, _kFinderColor, _kFinderStationeryPad]:
+        short_name = attribute
+        name = attribute
+    elif attribute in _TAGS_NAMES:
+        short_name = "tags"
+        name = _kMDItemUserTags
+    else:
+        raise ValueError(f"Unknown attribute: {attribute}")
+
+    return name, short_name
+
+
+def md_copyfrom_metadata(md: OSXMetaData, copyfrom: str, verbose: bool = False):
+    """Copy metadata attributes to a file from another file
+
+    Args:
+        md: OSXMetaData object for destination file
+        copyfrom: path to source file
+        verbose: if True, print verbose output
+    """
+    if verbose:
+        click.echo(f"Copying attributes from {copyfrom}")
+    src_md = OSXMetaData(copyfrom)
+    for attr in WRITABLE_ATTRIBUTES:
+        if value := src_md.get(attr):
+            if verbose:
+                click.echo(f"  Copying {attr}")
+            md.set(attr, value)
+
+
+def md_clear_metadata(
+    md: OSXMetaData, tr, attributes: t.List[str], verbose: bool = False
+):
+    """Clear metadata attributes on a file
+
+    Args:
+        md: OSXMetaData object for file
+        attributes: list of attributes to clear
+        verbose: if True, print verbose output
+    """
+    for attr in attributes:
+        if verbose:
+            click.echo(f"Clearing {attr}")
+        if not md.get(attr):
+            if verbose:
+                click.echo(f"  {attr} is already empty on {md.path}")
+            continue
+        md.set(attr, None)
+
+
+def md_set_metadata_with_error(
+    md: OSXMetaData, metadata: t.Tuple[t.Tuple[str, str]], verbose: bool
+) -> t.Optional[str]:
+    """Set metadata for OSXMetaData object, return error message if any
+
+    Args:
+        md: OSXMetaData object
+        metadata: tuple of tuples of (attribute, value) as returned by click parser
+        verbose: if True, print metadata being set
+
+    Returns:
+        None if successful, else error message
+    """
+
+    attr_dict = {}
+    for item in metadata:
+        attr, val = item
+        val = val or None
+
+        # Convert attribute shortcut name to long name if necessary
+        attr = get_attribute_name(attr)
+
+        if attr in _TAGS_NAMES:
+            val = tag_factory(val) if val else None
+        elif attr == _kFinderColor:
+            val = str_to_finder_color(val)
+        elif attr == _kFinderStationeryPad:
+            val = str_to_bool(val)
+        elif attr in MDITEM_ATTRIBUTE_DATA:
+            val = str_to_mditem_type(attr, val)
+        else:
+            return f"Invalid attribute: {attr}"
+
+        if attr in attr_dict:
+            attr_dict[attr].append(val)
+        else:
+            attr_dict[attr] = [val]
+
+    for attribute, value in attr_dict.items():
+        # if we got a list of values and attribute takes a list, set the list
+        # otherwise, set the last value in the list
+        if verbose:
+            click.echo(f"Setting {attribute}={value}")
+        if get_attribute_type(attribute) in ["list", "list[datetime.datetime]"]:
+            # filter out any None values ([None] should be [])
+            value = [v for v in value if v is not None]
+            md.set(attribute, value)
+        else:
+            md.set(attribute, value[-1])
+
+    return None
+
+
+def md_append_metadata_with_error(
+    md: OSXMetaData, metadata: t.List[t.Tuple[str, str]], verbose: bool
+) -> t.Optional[str]:
+    """Append metadata attributes on a file
+
+    Args:
+        md: OSXMetaData object for file
+        metadata: list of tuples of (attribute, value) as returned by click parser
+        verbose: if True, print verbose output
+
+    Returns:
+        None if successful, else error message
+    """
+    for attr, val in metadata:
+        if verbose:
+            click.echo(f"Appending {attr}={val}")
+
+        # Convert attribute shortcut name to long name if necessary
+        attr = get_attribute_name(attr)
+
+        if attr in _TAGS_NAMES:
+            value = tag_factory(val)
+        elif attr in MDITEM_ATTRIBUTE_DATA:
+            value = str_to_mditem_type(attr, val)
+        else:
+            # other types like _kFinderColor cannot be appended to
+            return f"Invalid attribute: {attr}"
+
+        attr_type = get_attribute_type(attr)
+
+        if attr_type in ["list", "list[datetime.datetime]"]:
+            new_value = md.get(attr) or []
+            if value not in new_value:
+                new_value.append(value)
+                md.set(attr, new_value)
+            elif verbose:
+                click.echo(f"  {attr} already contains {val}")
+        elif attr_type == "str":
+            new_value = md.get(attr) or ""
+            md.set(attr, new_value + value)
+        else:
+            return f"Attribute {attr} does not support appending"
+
+    return None
+
+
+def md_remove_metadata_with_error(
+    md: OSXMetaData, metadata: t.List[t.Tuple[str, str]], verbose: bool
+) -> t.Optional[str]:
+    """ "Remove metadata attributes on a file
+
+    Args:
+        md: OSXMetaData object for file
+        metadata: list of tuples of (attribute, value) as returned by click parser
+        verbose: if True, print verbose output
+
+    Returns:
+        None if successful, else error message
+    """
+    for attr, val in metadata:
+        attr_type = get_attribute_type(attr)
+        if attr_type not in ["list", "list[datetime.datetime]"]:
+            return f"remove is not a valid operation for single-value attribute {attr}"
+
+        if attr in _TAGS_NAMES:
+            val = tag_factory(val)
+        elif attr in MDITEM_ATTRIBUTE_DATA or attr in MDITEM_ATTRIBUTE_SHORT_NAMES:
+            val = str_to_mditem_type(attr, val)
+        else:
+            return f"Invalid attribute: {attr}"
+
+        new_value = md.get(attr) or []
+        new_value = [v for v in new_value if v != val]
+
+        if verbose:
+            click.echo(f"Removing {val} from {attr}")
+        try:
+            md.set(attr, new_value)
+        except KeyError as e:
+            raise e
+
+
+def md_mirror_metadata_with_error(
+    md: OSXMetaData, attributes: t.Tuple[t.Tuple[str, str]], verbose: bool
+) -> t.Optional[str]:
+    """Mirror metadata attributes on a file
+
+    Args:
+        md: OSXMetaData object for file
+        attributes: tuple of attribute tuples to mirror as returned by click parser for --mirror
+        verbose: if True, print verbose output
+
+    Returns:
+        None if successful, else error message
+    """
+    for item in attributes:
+        attr1, attr2 = item
+        if verbose:
+            click.echo(f"Mirroring {attr1} {attr2}")
+
+        attr_type1 = get_attribute_type(attr1)
+        attr_type2 = get_attribute_type(attr2)
+
+        if attr_type1 != attr_type2:
+            return f"Attributes {attr1} and {attr2} are not compatible"
+
+        if attr_type1 in ["list", "list[datetime.datetime]"]:
+            value1 = md.get(attr1) or []
+            value2 = md.get(attr2) or []
+            if attr1 in _TAGS_NAMES and attr2 not in _TAGS_NAMES:
+                # might be mirroring a keyword to a tag
+                # convert non-tags to tags
+                value2_tags = [tag_factory(v) for v in value2]
+                value1 = value1 + value2_tags
+                value2 = value2 + [v.name for v in value1 if v.name not in value2]
+                md.set(attr1, value1)
+                md.set(attr2, value2)
+            elif attr2 in _TAGS_NAMES and attr1 not in _TAGS_NAMES:
+                # might be mirroring a tag to a keyword
+                # convert tags to non-tags
+                value1_tags = [tag_factory(v) for v in value1]
+                value2 = value2 + value1_tags
+                value1 = value1 + [v.name for v in value2 if v.name not in value1]
+                md.set(attr1, value1)
+                md.set(attr2, value2)
+            elif value1 != value2:
+                new_value = value1 + [v for v in value2 if v not in value1]
+                md.set(attr1, new_value)
+                md.set(attr2, new_value)
+        else:
+            md.set(attr1, md.get(attr2))
+
+        return None
+
+
+def md_list_metadata_with_error(md: OSXMetaData, json_: bool) -> t.Optional[str]:
+    """List metadata attributes on a file
+
+    Args:
+        md: OSXMetaData object for file
+        json_: if True, print output as JSON
+        verbose: if True, print verbose output
+
+    Returns:
+        None if successful, else error message
+    """
+    if json_:
+        json_str = md.to_json()
+        click.echo(json_str)
+        return
+
+    # print in readable format, not json
+    click.echo(f"{md.path}:")
+    for attr in sorted(md.asdict()):
+        try:
+            value = md.get(attr)
+            if value is None or value == "" or value == []:
+                continue
+            value = value_to_str(value)
+        except Exception as e:
+            click.echo(
+                f"{'Error loading attribute':{_SHORT_NAME_WIDTH}}{attr:{_LONG_NAME_WIDTH}}: {e}",
+                err=True,
+            )
+        else:
+            try:
+                name, short_name = get_attribute_names(attr)
+            except ValueError:
+                click.echo(
+                    f"{'UNKNOWN ATTRIBUTE':{_SHORT_NAME_WIDTH}}{attr:{_LONG_NAME_WIDTH}} = THIS ATTRIBUTE NOT HANDLED",
+                    err=True,
+                )
+            else:
+                click.echo(
+                    f"{short_name:{_SHORT_NAME_WIDTH}}{name:{_LONG_NAME_WIDTH}} = {value}"
+                )
+
+
+def md_get_metadata_with_error(
+    md: OSXMetaData, attributes: t.Tuple[str], json_: bool
+) -> t.Optional[str]:
+    """Get metadata attribute on a file
+
+    Args:
+        md: OSXMetaData object for file
+        attributes: tuple of attributes to get as returned by click parser for --get
+        json_: if True, print output as JSON
+
+    Returns:
+        None if successful, else error message
+    """
+    data = {}
+    if json_:
+        data["_version"] = __version__
+        data["_filepath"] = md.path
+        data["_filename"] = os.path.basename(md.path)
+    for attr in attributes:
+        try:
+            value = md.get(attr)
+            attr_type = get_attribute_type(attr)
+            if json_ and attr_type in ["list", "list[datetime.datetime]"]:
+                # preserve lists for json output and convert datetimes if needed
+                if attr_type == "list[datetime.datetime]":
+                    # value could be 'None' if attribute not set
+                    value = [v.isoformat() for v in value] if value else []
+            else:
+                value = value_to_str(value)
+        except Exception as e:
+            return f"Error loading attribute {attr}: {e}"
+        else:
+            try:
+                name, short_name = get_attribute_names(attr)
+            except ValueError:
+                return f"UNKNOWN ATTRIBUTE {attr}: THIS ATTRIBUTE NOT HANDLED"
+            else:
+                if json_:
+                    data[name] = value
+                else:
+                    click.echo(
+                        f"{short_name:{_SHORT_NAME_WIDTH}}{name:{_LONG_NAME_WIDTH}} = {value}"
+                    )
+    if json_:
+        json_str = json.dumps(data)
+        click.echo(json_str)
+
+
+def validate_mirror_attributes_with_error(mirror: t.Tuple[t.Tuple[str, str]]):
+    """Validate mirror attributes"""
+    for item in mirror:
+        attr1, attr2 = item
+
+        validate_attribute_names((attr1, attr2))
+        attr1 = get_attribute_name(attr1)
+        attr2 = get_attribute_name(attr2)
+
+        # avoid self mirroring
+        if attr1 == attr2:
+            return f"cannot mirror the same attribute: {attr1} {attr2}"
+
+        # check type compatibility
+        if get_attribute_type(attr1) != get_attribute_type(attr2):
+            # can only mirror compatible attributes
+            return f"Cannot mirror {attr1}, {attr2}: incompatible types"
+
+    return None
+
+
+def md_backup_metadata(filepath: str, backup_file: str, verbose: bool):
+    """Backup metadata from file
+
+    Args:
+        filepath: path to file
+        backup_file: path to backup file
+        verbose: if True, print verbose output
+    """
+    # TODO: this is ripe for refactoring with a sqlite database
+    # Currently, the code writes the entire backup database each time a file is processed
+    if verbose:
+        click.echo(f"  Backing up attribute data for {filepath}")
+    # load the file if it exists, merge new data, then write out the file again
+    backup_data = load_backup_file(backup_file) if backup_file.is_file() else {}
+    backup_dict = get_backup_dict(filepath)
+    backup_data[pathlib.Path(filepath).name] = backup_dict
+    write_backup_file(backup_file, backup_data)
+
+
+def md_restore_metadata(filepath: str, backup_file: str, verbose: bool):
+    """Restore metadata from backup file
+
+    Args:
+        filepath: path to file to restore metadata for
+        backup_file: path to backup file
+        verbose: if True, print verbose output
+    """
+
+    try:
+        backup_data = load_backup_file(backup_file)
+        attr_dict = backup_data[pathlib.Path(filepath).name]
+        if verbose:
+            click.echo(f"  Restoring attribute data for {filepath}")
+        md = OSXMetaData(filepath)
+        for attr, value in attr_dict.items():
+            if attr not in WRITABLE_ATTRIBUTES:
+                continue
+            if not value:
+                # TODO: should values be set to None on restore if they were None in the backup?
+                continue
+            attr_type = get_attribute_type(attr)
+            if attr in _TAGS_NAMES:
+                value = [Tag(v[0], v[1]) for v in value]
+            if attr_type == "datetime.datetime":
+                value = datetime.datetime.fromisoformat(value)
+            elif attr_type == "list[datetime.datetime]":
+                value = [datetime.datetime.fromisoformat(v) for v in value]
+            md.set(attr, value)
+    except FileNotFoundError:
+        click.echo(
+            f"Missing backup file {backup_file} for {filepath}, skipping restore",
+            err=True,
+        )
+    except KeyError:
+        if verbose:
+            click.echo(f"  Skipping restore for file {filepath}: not in backup file")
+
 
 # Click CLI object & context settings
 class CLI_Obj:
     def __init__(self, debug=False, files=None):
         self.debug = debug
-        if debug:
-            osxmetadata.debug._set_debug(True)
-
         self.files = files
 
 
@@ -60,21 +651,22 @@ def get_help(self, ctx):
         formatter = click.HelpFormatter()
 
         # build help text from all the attribute names
-        # get set of attribute names
-        # (to eliminate the duplicate entries for short_constant and long costant)
-        # then sort and get the short constant, long constant, and help text
         # passed to click.HelpFormatter.write_dl for formatting
         attr_tuples = [("Short Name", "Description")]
-        attr_tuples.extend(
-            (
-                ATTRIBUTES[attr].name,
-                f"{ATTRIBUTES[attr].short_constant}, "
-                + f"{ATTRIBUTES[attr].constant}; {ATTRIBUTES[attr].help}",
-            )
-            for attr in sorted(
-                [attr for attr in {attr.name for attr in ATTRIBUTES.values()}]
-            )
-        )
+        for attr in sorted(set(MDITEM_ATTRIBUTE_DATA.keys())):
+
+            # get short and long name
+            short_name = MDITEM_ATTRIBUTE_DATA[attr]["short_name"]
+            long_name = MDITEM_ATTRIBUTE_DATA[attr]["name"]
+            constant = MDITEM_ATTRIBUTE_DATA[attr]["xattr_constant"]
+
+            # get help text
+            description = MDITEM_ATTRIBUTE_DATA[attr]["description"]
+            type_ = MDITEM_ATTRIBUTE_DATA[attr]["help_type"]
+            attr_help = f"{long_name}; {constant}; {description}; {type_}"
+
+            # add to list
+            attr_tuples.append((short_name, attr_help))
 
         formatter.write("\n\n")
         formatter.write_text(
@@ -99,7 +691,7 @@ def get_help(self, ctx):
         formatter.write_text(
             "Options are executed in the following order regardless of order "
             + "passed on the command line: "
-            + "restore, wipe, copyfrom, clear, set, append, update, remove, mirror, get, list, backup.  "
+            + "restore, wipe, copyfrom, clear, set, append, remove, mirror, get, list, backup.  "
             + "--backup and --restore are mutually exclusive.  "
             + "Other options may be combined or chained together."
         )
@@ -128,17 +720,7 @@ def get_help(self, ctx):
 
 # All the command line options defined here
 FILES_ARGUMENT = click.argument(
-    "files", metavar="FILE", nargs=-1, type=click.Path(exists=True)
-)
-HELP_OPTION = click.option(
-    # add this only so I can show help text via echo_via_pager
-    "--help",
-    "-h",
-    "help_",
-    help="Show this message and exit.",
-    is_flag=True,
-    default=False,
-    required=False,
+    "files", metavar="FILE", nargs=-1, type=click.Path(exists=True), required=True
 )
 WALK_OPTION = click.option(
     "--walk",
@@ -206,17 +788,7 @@ def get_help(self, ctx):
     "--append",
     "-a",
     metavar="ATTRIBUTE VALUE",
-    help="Append VALUE to ATTRIBUTE.",
-    nargs=2,
-    multiple=True,
-    required=False,
-)
-UPDATE_OPTION = click.option(
-    "--update",
-    "-u",
-    metavar="ATTRIBUTE VALUE",
-    help="Update ATTRIBUTE with VALUE; for multi-valued attributes, "
-    "this adds VALUE to the attribute if not already in the list.",
+    help="Append VALUE to ATTRIBUTE; for multi-valued attributes, appends only if VALUE is not already present.",
     nargs=2,
     multiple=True,
     required=False,
@@ -262,14 +834,6 @@ def get_help(self, ctx):
     required=False,
     default=False,
 )
-ALL_OPTION = click.option(
-    "--all",
-    "-A",
-    "all_",
-    is_flag=True,
-    help="Process all extended attributes including those not known to osxmetadata. "
-    "Use with --backup/--restore to backup/restore all extended attributes. ",
-)
 VERBOSE_OPTION = click.option(
     "--verbose",
     "-V",
@@ -282,7 +846,7 @@ def get_help(self, ctx):
     "--copyfrom",
     "-f",
     metavar="SOURCE_FILE",
-    help="Copy attributes from file SOURCE_FILE.",
+    help="Copy attributes from file SOURCE_FILE (only updates destination attributes that are not null in SOURCE_FILE).",
     type=click.Path(exists=True),
     nargs=1,
     multiple=False,
@@ -311,7 +875,6 @@ def get_help(self, ctx):
 
 @click.command(cls=MyClickCommand)
 @click.version_option(__version__, "--version", "-v")
-@HELP_OPTION
 @DEBUG_OPTION
 @FILES_ARGUMENT
 @WALK_OPTION
@@ -323,11 +886,9 @@ def get_help(self, ctx):
 @APPEND_OPTION
 @GET_OPTION
 @REMOVE_OPTION
-@UPDATE_OPTION
 @MIRROR_OPTION
 @BACKUP_OPTION
 @RESTORE_OPTION
-@ALL_OPTION
 @VERBOSE_OPTION
 @COPY_FROM_OPTION
 @FILES_ONLY_OPTION
@@ -335,7 +896,6 @@ def get_help(self, ctx):
 @click.pass_context
 def cli(
     ctx,
-    help_,
     debug,
     files,
     walk,
@@ -347,11 +907,9 @@ def cli(
     append,
     get,
     remove,
-    update,
     mirror,
     backup,
     restore,
-    all_,
     verbose,
     copyfrom,
     files_only,
@@ -359,78 +917,36 @@ def cli(
 ):
     """Read/write metadata from file(s)."""
 
-    if help_:
-        click.echo_via_pager(ctx.get_help())
-        ctx.exit(0)
-
     if debug:
         logging.disable(logging.NOTSET)
 
-    if not files:
-        click.echo(ctx.get_help())
-        ctx.exit()
-
-    # validate values for --set, --clear, --append, --get, --remove, --mirror
-    if any([set_, append, remove, clear, get, mirror]):
-        attributes = (
-            [a[0] for a in set_]
-            + [a[0] for a in append]
-            + list(clear)
-            + list(get)
-            + list(itertools.chain(*mirror))
-        )
-        invalid_attr = False
-        for attr in attributes:
-            if attr not in ATTRIBUTES:
-                click.echo(f"Invalid attribute {attr}", err=True)
-                invalid_attr = True
-        if invalid_attr:
-            # click.echo("")  # add a new line before rest of help text
-            # click.echo(ctx.get_help())
-            ctx.exit(2)
+    # validate values for --set, --clear, --append, --get, --remove
+    for attributes in [set_, append, remove, clear, get]:
+        try:
+            validate_attribute_names(attributes)
+        except click.BadParameter as e:
+            click.echo(e)
+            ctx.exit(1)
+
+    # check compatible types for mirror
+    if mirror:
+        if error := validate_mirror_attributes_with_error(mirror):
+            click.echo(error, err=True)
+            ctx.exit(1)
 
     # check that json_ only used with get or list_
     if json_ and not any([get, list_]):
         click.echo("--json can only be used with --get or --list", err=True)
         # click.echo("")  # add a new line before rest of help text
         # click.echo(ctx.get_help())
-        ctx.exit(2)
+        ctx.exit(1)
 
     # can't backup and restore at once
     if backup and restore:
         click.echo("--backup and --restore cannot be used together", err=True)
         # click.echo("")  # add a new line before rest of help text
         # click.echo(ctx.get_help())
-        ctx.exit(2)
-
-    # check compatible types for mirror
-    if mirror:
-        for item in mirror:
-            attr1, attr2 = item
-            attribute1 = ATTRIBUTES[attr1]
-            attribute2 = ATTRIBUTES[attr2]
-
-            # avoid self mirroring
-            if attribute1 == attribute2:
-                click.echo(
-                    f"cannot mirror the same attribute: {attribute1.name} {attribute2.name}",
-                    err=True,
-                )
-                ctx.get_help()
-                ctx.exit(2)
-
-            # check type compatibility
-            if (
-                attribute1.list != attribute2.list
-                or attribute1.type_ != attribute2.type_
-            ):
-                # can only mirror compatible attributes
-                click.echo(
-                    f"Cannot mirror {attr1}, {attr2}: incompatible types", err=True
-                )
-                # click.echo("")  # add a new line before rest of help text
-                # click.echo(ctx.get_help())
-                ctx.exit(2)
+        ctx.exit(1)
 
     # loop through each file, process it, then do backup or restore if needed
     for filename in files:
@@ -441,7 +957,6 @@ def cli(
                 json_,
                 set_,
                 append,
-                update,
                 remove,
                 clear,
                 get,
@@ -452,9 +967,7 @@ def cli(
                 copyfrom,
                 backup,
                 restore,
-                walk,
                 files_only,
-                all_,
             )
 
         if walk and os.path.isdir(filename):
@@ -478,7 +991,6 @@ def cli(
                     json_,
                     set_,
                     append,
-                    update,
                     remove,
                     clear,
                     get,
@@ -489,9 +1001,7 @@ def cli(
                     copyfrom,
                     backup,
                     restore,
-                    walk,
                     files_only,
-                    all_,
                 )
 
 
@@ -501,7 +1011,6 @@ def process_files(
     json_,
     set_,
     append,
-    update,
     remove,
     clear,
     get,
@@ -512,9 +1021,7 @@ def process_files(
     copyfrom,
     backup,
     restore,
-    walk,
     files_only,
-    all_,
 ):
     """process list of files, calls process_single_file to process each file
     options processed in this order: wipe, copyfrom, clear, set, append, remove, mirror, get, list
@@ -522,7 +1029,7 @@ def process_files(
     """
     for filename in files:
         fpath = pathlib.Path(filename).resolve()
-        backup_file = pathlib.Path(pathlib.Path(filename).parent) / _BACKUP_FILENAME
+        backup_file = pathlib.Path(pathlib.Path(filename).parent) / BACKUP_FILENAME
 
         if files_only and fpath.is_dir():
             if verbose:
@@ -533,23 +1040,7 @@ def process_files(
             click.echo(f"Processing file: {fpath}")
 
         if restore:
-            try:
-                backup_data = load_backup_file(backup_file)
-                attr_dict = backup_data[pathlib.Path(fpath).name]
-                if verbose:
-                    click.echo(f"  Restoring attribute data for {fpath}")
-                md = osxmetadata.OSXMetaData(fpath)
-                md._restore_attributes(attr_dict, all_=all_)
-            except FileNotFoundError:
-                click.echo(
-                    f"Missing backup file {backup_file} for {fpath}, skipping restore",
-                    err=True,
-                )
-            except KeyError:
-                if verbose:
-                    click.echo(
-                        f"  Skipping restore for file {fpath}: not in backup file"
-                    )
+            md_restore_metadata(fpath, backup_file, verbose)
 
         process_single_file(
             ctx,
@@ -557,7 +1048,6 @@ def process_files(
             json_,
             set_,
             append,
-            update,
             remove,
             clear,
             get,
@@ -569,13 +1059,7 @@ def process_files(
         )
 
         if backup:
-            if verbose:
-                click.echo(f"  Backing up attribute data for {fpath}")
-            # load the file if it exists, merge new data, then write out the file again
-            backup_data = load_backup_file(backup_file) if backup_file.is_file() else {}
-            backup_dict = osxmetadata.OSXMetaData(fpath).asdict(all_=all_)
-            backup_data[pathlib.Path(fpath).name] = backup_dict
-            write_backup_file(backup_file, backup_data)
+            md_backup_metadata(fpath, backup_file, verbose)
 
 
 def process_single_file(
@@ -584,7 +1068,6 @@ def process_single_file(
     json_,
     set_,
     append,
-    update,
     remove,
     clear,
     get,
@@ -598,256 +1081,47 @@ def process_single_file(
     options processed in this order: wipe, copyfrom, clear, set, append, remove, mirror, get, list
     Note: expects all attributes passed in parameters to be validated as valid attributes"""
 
-    md = osxmetadata.OSXMetaData(fpath)
+    md = OSXMetaData(fpath)
 
     if wipe:
-        attr_list = md.list_metadata()
-        if verbose:
-            if attr_list:
-                click.echo(f"Wiping metadata from {fpath}")
-            else:
-                click.echo(f"No metadata to wipe from {fpath}")
-        for attr in attr_list:
-            try:
-                attribute = ATTRIBUTES[attr]
-                if verbose:
-                    click.echo(f"  Wiping {attr} from {fpath}")
-                md.clear_attribute(attribute.name)
-            except KeyError:
-                if verbose:
-                    click.echo(
-                        f"  Unknown attribute {attr} on {fpath}, skipping", err=True
-                    )
+        md_wipe_metadata(md, verbose)
 
     if copyfrom:
-        if verbose:
-            click.echo(f"Copying attributes from {copyfrom}")
-        src_md = osxmetadata.OSXMetaData(copyfrom)
-        for attr in src_md.list_metadata():
-            if verbose:
-                click.echo(f"  Copying {attr}")
-            md.set_attribute(attr, src_md.get_attribute(attr))
+        # TODO: add option to clear existing attributes if copyfrom does not have them
+        md_copyfrom_metadata(md, copyfrom, verbose)
 
     if clear:
-        for attr in clear:
-            attribute = ATTRIBUTES[attr]
-            if verbose:
-                click.echo(f"Clearing {attr}")
-            md.clear_attribute(attribute.name)
-    if set_:
-        # set data
-        # check attribute is valid
-        attr_dict = {}
-        for item in set_:
-            attr, val = item
-            attribute = ATTRIBUTES[attr]
-
-            if attr in _TAGS_NAMES:
-                val = tag_factory(val)
-            elif attr in _FINDERINFO_NAMES:
-                val = _AttributeFinderInfo._str_to_value_dict(val)
+        md_clear_metadata(md, fpath, clear, verbose)
 
-            if verbose:
-                click.echo(f"Setting {attr}={val}")
-            try:
-                attr_dict[attribute].append(val)
-            except KeyError:
-                attr_dict[attribute] = [val]
-
-        for attribute, value in attr_dict.items():
-            if attribute.list:
-                # attribute expects a list so pass value (which is a list)
-                md.set_attribute(attribute.name, value)
-            elif len(value) == 1:
-                # expected one and got one
-                md.set_attribute(attribute.name, value[0])
-            else:
-                click.echo(
-                    f"attribute {attribute.name} expects only a single value but {len(value)} provided",
-                    err=True,
-                )
-                ctx.get_help()
-                ctx.exit(2)
+    if set_:
+        if error := md_set_metadata_with_error(md, set_, verbose):
+            click.echo(error, err=True)
+            ctx.exit(1)
 
     if append:
-        # append data
-        # check attribute is valid
-        attr_dict = {}
-        for item in append:
-            attr, val = item
-            attribute = ATTRIBUTES[attr]
-
-            if not attribute.append:
-                click.echo(
-                    f"append is not a valid operation for attribute {attribute.name}",
-                    err=True,
-                )
-                ctx.get_help()
-                ctx.exit(2)
-
-            if attr in [*_TAGS_NAMES, *_FINDERINFO_NAMES]:
-                val = tag_factory(val)
-
-            if verbose:
-                click.echo(f"Appending {attr}={val}")
-            try:
-                attr_dict[attribute].append(val)
-            except KeyError:
-                attr_dict[attribute] = [val]
-
-        for attribute, value in attr_dict.items():
-            md.append_attribute(attribute.name, value)
-
-    if update:
-        # update data
-        # check attribute is valid
-        attr_dict = {}
-        for item in update:
-            attr, val = item
-            attribute = ATTRIBUTES[attr]
-
-            if not attribute.update:
-                click.echo(
-                    f"update is not a valid operation for attribute {attribute.name}",
-                    err=True,
-                )
-                ctx.get_help()
-                ctx.exit(2)
-
-            if attr in [*_TAGS_NAMES, *_FINDERINFO_NAMES]:
-                val = tag_factory(val)
-
-            if verbose:
-                click.echo(f"Updating {attr}={val}")
-            try:
-                attr_dict[attribute].append(val)
-            except KeyError:
-                attr_dict[attribute] = [val]
-
-        for attribute, value in attr_dict.items():
-            md.update_attribute(attribute.name, value)
+        if error := md_append_metadata_with_error(md, append, verbose):
+            click.echo(error, err=True)
+            ctx.exit(1)
 
     if remove:
-        # remove value from attribute
-        # actually implemented with discard so no error raised if not present
-        for attr, val in remove:
-            attribute = ATTRIBUTES[attr]
-            if not attribute.list:
-                click.echo(
-                    f"remove is not a valid operation for single-value attributes",
-                    err=True,
-                )
-                ctx.get_help()
-                ctx.exit(2)
-
-            if attr in [*_TAGS_NAMES, *_FINDERINFO_NAMES]:
-                val = tag_factory(val)
-            if verbose:
-                click.echo(f"Removing {attr}")
-            try:
-                md.discard_attribute(attribute.name, val)
-            except KeyError as e:
-                raise e
+        if error := md_remove_metadata_with_error(md, remove, verbose):
+            click.echo(error, err=True)
+            ctx.exit(1)
 
     if mirror:
-        for item in mirror:
-            # mirror value of each attribute
-            # validation that attributes are compatible
-            # will have occured prior to call to process_file
-            attr1, attr2 = item
-            if verbose:
-                click.echo(f"Mirroring {attr1} {attr2}")
-
-            attribute1 = ATTRIBUTES[attr1]
-            attribute2 = ATTRIBUTES[attr2]
-
-            if attribute1.name == "tags":
-                tags = md.get_attribute(attr1)
-                attr1_values = [tag.name for tag in tags]
-                md.update_attribute(attr2, attr1_values)
-                md.set_attribute(attr1, [Tag(val) for val in md.get_attribute(attr2)])
-            elif attribute2.name == "tags":
-                attr1_values = md.get_attribute(attr1)
-                md.update_attribute(attr2, [Tag(val) for val in attr1_values])
-                tags = md.get_attribute(attr2)
-                attr2_values = [tag.name for tag in tags]
-                md.set_attribute(attr1, attr2_values)
-            elif attribute1.list:
-                # merge the two lists, assume attribute2 also a list due to
-                # previous validation
-                # update attr2 with any new values from attr1
-                # then set attr1 = attr2
-                attr1_values = md.get_attribute(attr1)
-                md.update_attribute(attr2, attr1_values)
-                md.set_attribute(attr1, md.get_attribute(attr2))
-            else:
-                md.set_attribute(attr1, md.get_attribute(attr2))
+        if error := md_mirror_metadata_with_error(md, mirror, verbose):
+            click.echo(error, err=True)
+            ctx.exit(1)
 
     if get:
-        data = {}
-        if json_:
-            data["_version"] = __version__
-            data["_filepath"] = fpath.resolve().as_posix()
-            data["_filename"] = fpath.name
-        for attr in get:
-            attribute = ATTRIBUTES[attr]
-            if json_:
-                try:
-                    if attribute.name == "tags":
-                        tags = md.get_attribute(attribute.name)
-                        value = [[tag.name, tag.color] for tag in tags]
-                        data[attribute.constant] = value
-                    elif (
-                        attribute.name == "finderinfo"
-                        or attribute.type_ != datetime.datetime
-                    ):
-                        data[attribute.constant] = md.get_attribute(attribute.name)
-                    else:
-                        # need to convert datetime.datetime to string to serialize
-                        value = md.get_attribute(attribute.name)
-                        if type(value) == list:
-                            value = [v.isoformat() for v in value]
-                        else:
-                            value = value.isoformat()
-                        data[attribute.constant] = value
-                except KeyError:
-                    # unknown attribute, ignore it
-                    pass
-            else:
-                value = md.get_attribute_str(attribute.name)
-                click.echo(
-                    f"{attribute.name:{_SHORT_NAME_WIDTH}}{attribute.constant:{_LONG_NAME_WIDTH}} = {value}"
-                )
-        if json_:
-            json_str = json.dumps(data)
-            click.echo(json_str)
+        if error := md_get_metadata_with_error(md, get, json_):
+            click.echo(error, err=True)
+            ctx.exit(1)
 
     if list_:
-        if json_:
-            json_str = md.to_json()
-            click.echo(json_str)
-        else:
-            click.echo(f"{fpath}:")
-            attribute_list = md.list_metadata()
-            for attr in attribute_list:
-                try:
-                    attribute_names = ATTRIBUTE_DISPATCH[attr]
-                    for name in attribute_names:
-                        attribute = ATTRIBUTES[name]
-                        value = md.get_attribute_str(attribute.name)
-                        click.echo(
-                            f"{attribute.name:{_SHORT_NAME_WIDTH}}{attribute.constant:{_LONG_NAME_WIDTH}} = {value}"
-                        )
-                except KeyError:
-                    click.echo(
-                        f"{'UNKNOWN':{_SHORT_NAME_WIDTH}}{attr:{_LONG_NAME_WIDTH}} = THIS ATTRIBUTE NOT HANDLED",
-                        err=True,
-                    )
-                except Exception as e:
-                    click.echo(
-                        f"{'Error loading attribute':{_SHORT_NAME_WIDTH}}{attr:{_LONG_NAME_WIDTH}}: {e}",
-                        err=True,
-                    )
+        if error := md_list_metadata_with_error(md, json_):
+            click.echo(error, err=True)
+            ctx.exit(1)
 
 
 if __name__ == "__main__":
diff --git a/osxmetadata/attribute_data/README.md b/osxmetadata/attribute_data/README.md
new file mode 100644
index 0000000..a87fde3
--- /dev/null
+++ b/osxmetadata/attribute_data/README.md
@@ -0,0 +1,3 @@
+# Attributes Metadata
+
+The JSON files in this directory contain metadata about the various attributes that can be set with osxmetadata. The data is generated by [macos_mditem_metadata](https://github.com/RhetTbull/macos_mditem_metadata) and is loaded by osxmetadata when the module is importedsss
\ No newline at end of file
diff --git a/osxmetadata/attribute_data/__init__.py b/osxmetadata/attribute_data/__init__.py
new file mode 100644
index 0000000..bc349cb
--- /dev/null
+++ b/osxmetadata/attribute_data/__init__.py
@@ -0,0 +1,27 @@
+"""Load metadata about metadata attributes from JSON files"""
+
+from .load_attribute_data import (
+    MDIMPORTER_ATTRIBUTE_DATA,
+    MDITEM_ATTRIBUTE_AUDIO,
+    MDITEM_ATTRIBUTE_COMMON,
+    MDITEM_ATTRIBUTE_DATA,
+    MDITEM_ATTRIBUTE_FILESYSTEM,
+    MDITEM_ATTRIBUTE_IMAGE,
+    MDITEM_ATTRIBUTE_READ_ONLY,
+    MDITEM_ATTRIBUTE_SHORT_NAMES,
+    MDITEM_ATTRIBUTE_VIDEO,
+    NSURL_RESOURCE_KEY_DATA,
+)
+
+__all__ = [
+    "MDIMPORTER_ATTRIBUTE_DATA",
+    "MDITEM_ATTRIBUTE_AUDIO",
+    "MDITEM_ATTRIBUTE_COMMON",
+    "MDITEM_ATTRIBUTE_DATA",
+    "MDITEM_ATTRIBUTE_FILESYSTEM",
+    "MDITEM_ATTRIBUTE_IMAGE",
+    "MDITEM_ATTRIBUTE_READ_ONLY",
+    "MDITEM_ATTRIBUTE_SHORT_NAMES",
+    "MDITEM_ATTRIBUTE_VIDEO",
+    "NSURL_RESOURCE_KEY_DATA",
+]
diff --git a/osxmetadata/attribute_data/audio_attributes.json b/osxmetadata/attribute_data/audio_attributes.json
new file mode 100644
index 0000000..0a1c925
--- /dev/null
+++ b/osxmetadata/attribute_data/audio_attributes.json
@@ -0,0 +1,116 @@
+[
+    {
+        "name": "kMDItemAppleLoopDescriptors",
+        "description": "Specifies multiple pieces of descriptive information about a loop.",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemAppleLoopsKeyFilterType",
+        "description": "Specifies key filtering information about a loop. Loops are matched against projects that often in a major or minor key.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemAppleLoopsLoopMode",
+        "description": "Specifies how a file should be played.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemAppleLoopsRootKey",
+        "description": "Specifies the loop's original key. The key is the root note or tonic for the loop, and does not include the scale type.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemAudioChannelCount",
+        "description": "Number of channels in the audio data contained in the file.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemAudioEncodingApplication",
+        "description": "The name of the application that encoded the data contained in the audio file.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemAudioSampleRate",
+        "description": "Sample rate of the audio data contained in the file. The sample rate is a float value representing hz (audio_frames/second). For example: 44100. 0, 22254. 54.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemAudioTrackNumber",
+        "description": "The track number of a song or composition when it is part of an album.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemComposer",
+        "description": "The composer of the music contained in the audio file.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemIsGeneralMIDISequence",
+        "description": "Indicates whether the MIDI sequence contained in the file is setup for use with a General MIDI device.",
+        "type": "CFBoolean",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemKeySignature",
+        "description": "The key of the music contained in the audio file. For example: C, Dm, F#m, Bb.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemLyricist",
+        "description": "The lyricist, or text writer, of the music contained in the audio file.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemMusicalGenre",
+        "description": "The musical genre of the song or composition contained in the audio file. For example: Jazz, Pop, Rock, Classical.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemMusicalInstrumentCategory",
+        "description": "Specifies the category of an instrument.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemMusicalInstrumentName",
+        "description": "Specifies the name of instrument relative to the instrument category.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemRecordingDate",
+        "description": "The recording date of the song or composition.",
+        "type": "CFDate",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemRecordingYear",
+        "description": "Indicates the year the item was recorded. For example, 1964, 2003, etc.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemTempo",
+        "description": "A float value that specifies the beats per minute of the music contained in the audio file.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemTimeSignature",
+        "description": "The time signature of the musical composition contained in the audio/MIDI file. For example: \"4/4\", \"7/8\".",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    }
+]
\ No newline at end of file
diff --git a/osxmetadata/attribute_data/common_attributes.json b/osxmetadata/attribute_data/common_attributes.json
new file mode 100644
index 0000000..035df5e
--- /dev/null
+++ b/osxmetadata/attribute_data/common_attributes.json
@@ -0,0 +1,344 @@
+[
+    {
+        "name": "kMDItemAttributeChangeDate",
+        "description": "The date and time of the last change made to a metadata attribute.",
+        "type": "CFDate",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemAudiences",
+        "description": "The audience for which the file is intended. The audience may be determined by the creator or the publisher or by a third party.",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemAuthors",
+        "description": "The author, or authors, of the contents of the file.",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemAuthorAddresses",
+        "description": "This attribute indicates the author addresses of the document.",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.6+"
+    },
+    {
+        "name": "kMDItemCity",
+        "description": "Identifies city of origin according to guidelines established by the provider.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemComment",
+        "description": "A comment related to the file. This differs from the Finder comment, kMDItemFinderComment.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemContactKeywords",
+        "description": "A list of contacts that are associated with this document, not including the authors.",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemContentCreationDate",
+        "description": "The creation date of an edited or optimized version of the song or composition.",
+        "type": "CFDate",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemContentModificationDate",
+        "description": "The date and time that the contents of the file were last modified.",
+        "type": "CFDate",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemContentType",
+        "description": "The UTI pedigree of a file.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemContributors",
+        "description": "The entities responsible for making contributions to the content of the resource.",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemCopyright",
+        "description": "The copyright owner of the file contents.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemCountry",
+        "description": "The full, publishable name of the country or region where the intellectual property of the item was created, according to guidelines of the provider.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemCoverage",
+        "description": "The extent or scope of the content of the resource.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemCreator",
+        "description": "Application used to create the document content (for example \"Word\", \"Pages\", and so on).",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemDescription",
+        "description": "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.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemDueDate",
+        "description": "Date this item is due.",
+        "type": "CFDate",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemDurationSeconds",
+        "description": "The duration, in seconds, of the content of file. A value of 10. 5 represents media that is 10 and 1/2 seconds long.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemEmailAddresses",
+        "description": "Email addresses related to this item.",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemEncodingApplications",
+        "description": "Application used to convert the original content into it's current form. For example, a PDF file might have an encoding application set to \"Distiller\".",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemFinderComment",
+        "description": "Finder comments for this file.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemFonts",
+        "description": "Fonts used in this item. You should store the font's full name, the postscript name, or the font family name, based on the available information.",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemHeadline",
+        "description": "A publishable entry providing a synopsis of the contents of the file. For example, \"Apple Introduces the iPod Photo\".",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemIdentifier",
+        "description": "A formal identifier used to reference the resource within a given context.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemInstantMessageAddresses",
+        "description": "Instant message addresses related to this item.",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemInstructions",
+        "description": "Editorial instructions concerning the use of the item, such as embargoes and warnings. For example, \"Second of four stories\".",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemKeywords",
+        "description": "Keywords associated with this file. For example, \"Birthday\", \"Important\", etc.",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemKind",
+        "description": "A description of the kind of item this file represents.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemLanguages",
+        "description": "Indicates the languages of the intellectual content of the resource. Recommended best practice for the values of the Language element is defined by RFC 3066.",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemLastUsedDate",
+        "description": "The date and time that the file was last used. This value is updated automatically by LaunchServices everytime a file is opened by double clicking, or by asking LaunchServices to open a file.",
+        "type": "CFDate",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemNumberOfPages",
+        "description": "Number of pages in the document.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemOrganizations",
+        "description": "The company or organization that created the document.",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemPageHeight",
+        "description": "Height of the document page, in points (72 points per inch). For PDF files this indicates the height of the first page only.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemPageWidth",
+        "description": "Width of the document page, in points (72 points per inch). For PDF files this indicates the width of the first page only.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemParticipants",
+        "description": "The list of people who are visible in an image or movie or written about in a document.",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.6+"
+    },
+    {
+        "name": "kMDItemPhoneNumbers",
+        "description": "Phone numbers related to this item.",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemProjects",
+        "description": "The list of projects that this file is part of. For example, if you were working on a movie all of the files could be marked as belonging to the project \"My Movie\".",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemPublishers",
+        "description": "The entity responsible for making the resource available. For example, a person, an organization, or a service. Typically, the name of a publisher should be used to indicate the entity.",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemRecipients",
+        "description": "Recipients of this item.",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemRecipientAddresses",
+        "description": "This attribute indicates the recipient addresses of the document.",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.6+"
+    },
+    {
+        "name": "kMDItemRights",
+        "description": "Provides a link to information about rights held in and over the resource.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemSecurityMethod",
+        "description": "The security or encryption method used for the file.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemStarRating",
+        "description": "User rating of this item. For example, the stars rating of an iTunes track.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemStateOrProvince",
+        "description": "Identifies the province or state of origin according to guidelines established by the provider. For example, \"CA\", \"Ontario\", or \"Sussex\".",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemTextContent",
+        "description": "Contains a text representation of the content of the document. Data in multiple fields should be combined using a whitespace character as a separator.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemTitle",
+        "description": "The title of the file. For example, this could be the title of a document, the name of a song, or the subject of an email message.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemVersion",
+        "description": "The version number of this file.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemWhereFroms",
+        "description": "Describes where the file was obtained from.",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemAuthorEmailAddresses",
+        "description": "This attribute indicates the author of the emails message addresses. (This is always the email address, and not the human readable version).",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.5+"
+    },
+    {
+        "name": "kMDItemRecipientEmailAddresses",
+        "description": "This attribute indicates the recipients email addresses. (This is always the email address, and not the human readable version).",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.5+"
+    },
+    {
+        "name": "kMDItemTheme",
+        "description": "Theme of the this item.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemSubject",
+        "description": "Subject of the this item.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemCFBundleIdentifier",
+        "description": "If this item is a bundle, then this is the CFBundleIdentifier.",
+        "type": "CFString",
+        "version": "macOS 10.5+"
+    },
+    {
+        "name": "kMDItemFSHasCustomIcon",
+        "description": "Boolean indicating if this file has a custom icon.",
+        "type": "CFBoolean",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemFSIsStationery",
+        "description": "Boolean indicating if this file is stationery.",
+        "type": "CFBoolean",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemInformation",
+        "description": "Information about the item.",
+        "type": "CFString",
+        "version": "macOS 10.5+"
+    },
+    {
+        "name": "kMDItemURL",
+        "description": "Url of the item.",
+        "type": "CFString",
+        "version": "macOS 10.5+"
+    }
+]
\ No newline at end of file
diff --git a/osxmetadata/attribute_data/filesystem_attributes.json b/osxmetadata/attribute_data/filesystem_attributes.json
new file mode 100644
index 0000000..24e09b5
--- /dev/null
+++ b/osxmetadata/attribute_data/filesystem_attributes.json
@@ -0,0 +1,74 @@
+[
+    {
+        "name": "kMDItemDisplayName",
+        "description": "The localized version of the file name.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemFSContentChangeDate",
+        "description": "The date the file contents last changed.",
+        "type": "CFDate",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemFSCreationDate",
+        "description": "The date and time that the file was created.",
+        "type": "CFDate",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemFSInvisible",
+        "description": "Indicates whether the file is invisible.",
+        "type": "CFBoolean",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemFSIsExtensionHidden",
+        "description": "Indicates whether the file extension of the file is hidden.",
+        "type": "CFBoolean",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemFSLabel",
+        "description": "Index of the Finder label of the file. Possible values are 0 through 7.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemFSName",
+        "description": "The file name of the item.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemFSNodeCount",
+        "description": "Number of files in a directory.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemFSOwnerGroupID",
+        "description": "The group ID of the owner of the file.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemFSOwnerUserID",
+        "description": "The user ID of the owner of the file.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemFSSize",
+        "description": "The size, in bytes, of the file on disk.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemPath",
+        "description": "The complete path to the file.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    }
+]
\ No newline at end of file
diff --git a/osxmetadata/attribute_data/image_attributes.json b/osxmetadata/attribute_data/image_attributes.json
new file mode 100644
index 0000000..a6e39ae
--- /dev/null
+++ b/osxmetadata/attribute_data/image_attributes.json
@@ -0,0 +1,224 @@
+[
+    {
+        "name": "kMDItemPixelHeight",
+        "description": "The height, in pixels, of the contents. For example, the image height or the video frame height.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemPixelWidth",
+        "description": "The width, in pixels, of the contents. For example, the image width or the video frame width.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemPixelCount",
+        "description": "The total number of pixels in the contents. Same as kMDItemPixelWidth x kMDItemPixelHeight.",
+        "type": "CFNumber",
+        "version": "macOS 10.6+"
+    },
+    {
+        "name": "kMDItemColorSpace",
+        "description": "The color space model used by the document contents. For example, \"RGB\", \"CMYK\", \"YUV\", or \"YCbCr\".",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemBitsPerSample",
+        "description": "The number of bits per sample. For example, the bit depth of an image (8-bit, 16-bit etc. . . ) or the bit depth per audio sample of uncompressed audio data (8, 16, 24, 32, 64, etc. . ).",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemFlashOnOff",
+        "description": "Indicates if a camera flash was used.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemFocalLength",
+        "description": "The actual focal length of the lens, in millimeters.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemAcquisitionMake",
+        "description": "The manufacturer of the device used to aquire the document contents.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemAcquisitionModel",
+        "description": "The model of the device used to aquire the document contents. For example, 100, 200, 400, etc.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemISOSpeed",
+        "description": "The ISO speed used to acquire the document contents.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemOrientation",
+        "description": "The orientation of the document contents. Possible values are 0 (landscape) and 1 (portrait).",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemLayerNames",
+        "description": "The names of the layers in the file.",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemWhiteBalance",
+        "description": "The white balance setting used to acquire the document contents. Possible values are 0 (auto white balance) and 1 (manual).",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemAperture",
+        "description": "The aperture setting used to acquire the document contents. This unit is the APEX value.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemProfileName",
+        "description": "The name of the color profile used by the document contents.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemResolutionWidthDPI",
+        "description": "Resolution width, in DPI, of this image.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemResolutionHeightDPI",
+        "description": "Resolution height, in DPI, of this image.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemExposureMode",
+        "description": "The exposure mode used to acquire the document contents.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemExposureTimeSeconds",
+        "description": "The exposure time, in seconds, used to acquire the document contents.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemEXIFVersion",
+        "description": "The version of the EXIF header used to generate the metadata.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemAlbum",
+        "description": "The title for a collection of media. This is analagous to a record album, or photo album.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemHasAlphaChannel",
+        "description": "Indicates if this image file has an alpha channel.",
+        "type": "CFBoolean",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemRedEyeOnOff",
+        "description": "Indicates if red-eye reduction was used to take the picture.",
+        "type": "CFBoolean",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemMeteringMode",
+        "description": "The metering mode used to take the image.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemMaxAperture",
+        "description": "The smallest f-number of the lens. Ordinarily it is given in the range of 00. 00 to 99. 99.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemFNumber",
+        "description": "The diameter of the diaphragm aperture in terms of the effective focal length of the lens.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemExposureProgram",
+        "description": "The class of the exposure program used by the camera to set exposure when the image is taken. Possible values include: Manual, Normal, and Aperture priority.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemExposureTimeString",
+        "description": "The time of the exposure.",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemEXIFGPSVersion",
+        "description": "The version of GPSInfoIFD in EXIF used to generate the metadata.",
+        "type": "CFString",
+        "version": "macOS 10.5+"
+    },
+    {
+        "name": "kMDItemAltitude",
+        "description": "The altitude of the item in meters above sea level, expressed using the WGS84 datum. Negative values lie below sea level.",
+        "type": "CFString",
+        "version": "macOS 10.5+"
+    },
+    {
+        "name": "kMDItemLatitude",
+        "description": "The latitude of the item in degrees north of the equator, expressed using the WGS84 datum. Negative values lie south of the equator.",
+        "type": "CFString",
+        "version": "macOS 10.5+"
+    },
+    {
+        "name": "kMDItemLongitude",
+        "description": "The longitude of the item in degrees east of the prime meridian, expressed using the WGS84 datum. Negative values lie west of the prime meridian.",
+        "type": "CFString",
+        "version": "macOS 10.5+"
+    },
+    {
+        "name": "kMDItemTimestamp",
+        "description": "The timestamp on the item. This generally is used to indicate the time at which the event captured by the item took place.",
+        "type": "CFString",
+        "version": "macOS 10.5+"
+    },
+    {
+        "name": "kMDItemSpeed",
+        "description": "The speed of the item, in kilometers per hour.",
+        "type": "CFString",
+        "version": "macOS 10.5+"
+    },
+    {
+        "name": "kMDItemGPSTrack",
+        "description": "The direction of travel of the item, in degrees from true north.",
+        "type": "CFString",
+        "version": "macOS 10.5+"
+    },
+    {
+        "name": "kMDItemImageDirection",
+        "description": "The direction of the item's image, in degrees from true north.",
+        "type": "CFString",
+        "version": "macOS 10.5+"
+    },
+    {
+        "name": "kMDItemNamedLocation",
+        "description": "The name of the location or point of interest associated with the item. The name may be user provided.",
+        "type": "CFString",
+        "version": "macOS 10.6+"
+    }
+]
\ No newline at end of file
diff --git a/osxmetadata/attribute_data/load_attribute_data.py b/osxmetadata/attribute_data/load_attribute_data.py
new file mode 100644
index 0000000..c2d1eae
--- /dev/null
+++ b/osxmetadata/attribute_data/load_attribute_data.py
@@ -0,0 +1,171 @@
+"""Load attribute metadata from JSON files"""
+
+import datetime
+import json
+import pathlib
+import typing as t
+
+import Foundation
+
+MDITEM_METADATA_FILES = [
+    "audio_attributes.json",
+    "common_attributes.json",
+    "filesystem_attributes.json",
+    "image_attributes.json",
+    "video_attributes.json",
+]
+
+NSURL_RESOURCE_KEY_FILES = ["nsurl_resource_keys.json"]
+
+MDIMPORTER_CONSTANT_FILES = ["mdimporter_constants.json"]
+
+
+def load_mditem_attribute_data(files) -> t.Dict:
+    """Load attribute metadata from JSON files"""
+    # TODO: would be better to use importlib.abc.ResourceReader but I can't find a single example of how to do this
+    parent = pathlib.Path(__file__).parent
+    data = {}
+    for filename in files:
+        with open(parent / filename, "r") as f:
+            file_data = json.load(f)
+            for item in file_data:
+                data[item["name"]] = item
+
+    # add python types (e.g. CFString -> str) and help text types
+    # the types used in the JSON files are the CF types with these counts
+    # {'CFArray of CFStrings': 26, 'CFString': 61, 'CFNumber': 35, 'CFBoolean': 8, 'CFDate': 8}
+    for key, value in data.items():
+        if value["type"] == "CFString":
+            data[key]["python_type"] = "str"
+            data[key]["help_type"] = "string"
+        elif value["type"] == "CFNumber":
+            data[key]["python_type"] = "float"
+            data[key]["help_type"] = "number"
+        elif value["type"] == "CFBoolean":
+            data[key]["python_type"] = "bool"
+            data[key]["help_type"] = "boolean"
+        elif value["type"] == "CFDate":
+            data[key]["python_type"] = "datetime.datetime"
+            data[key]["help_type"] = "date/time"
+        elif value["type"] == "CFArray of CFStrings":
+            data[key]["python_type"] = "list"
+            data[key]["help_type"] = "list of strings"
+        else:
+            raise ValueError(f"Unknown type {value['type']}")
+
+    # add the attribute short_name which is lowercase of the part after kMDItem
+    for key, value in data.items():
+        if value["name"].startswith("kMDItem"):
+            data[key]["short_name"] = value["name"][7:].lower()
+        else:
+            data[key]["short_name"] = value["name"].lower()
+
+    # add the xattr_constant which is com.apple.metadata:MDItemName
+    for key, value in data.items():
+        data[key]["xattr_constant"] = f"com.apple.metadata:{value['name']}"
+
+    return data
+
+
+def load_nsurl_resource_key_data() -> t.Dict:
+    """Load NSRL resource key metadata from JSON files"""
+    # TODO: would be better to use importlib.abc.ResourceReader but I can't find a single example of how to do this
+    parent = pathlib.Path(__file__).parent
+    data = {}
+    for filename in NSURL_RESOURCE_KEY_FILES:
+        with open(parent / filename, "r") as f:
+            file_data = json.load(f)
+            for item in file_data:
+                # Some resource keys only available on certain versions of macOS
+                try:
+                    getattr(Foundation, item["name"])
+                except AttributeError:
+                    # Not available on this version of macOS
+                    continue
+                data[item["name"]] = item
+    return data
+
+
+def load_mdimporter_constant_data() -> t.Dict:
+    """Load MDImporter constants from JSON files"""
+    parent = pathlib.Path(__file__).parent
+    data = {}
+    for filename in MDIMPORTER_CONSTANT_FILES:
+        with open(parent / filename, "r") as f:
+            file_data = json.load(f)
+            for item in file_data:
+                data[item["name"]] = item
+    return data
+
+
+# all attribute data
+MDITEM_ATTRIBUTE_DATA = load_mditem_attribute_data(MDITEM_METADATA_FILES)
+
+
+# Add kMDItemDownloadedDate which isn't in the normal MDItem reference but is
+# referenced in the MDImporter reference here:
+# https://developer.apple.com/documentation/coreservices/kmditemdownloadeddate?language=objc
+MDITEM_ATTRIBUTE_DATA["kMDItemDownloadedDate"] = {
+    "name": "kMDItemDownloadedDate",
+    "description": "Date the item was downloaded.",
+    "version": "10.7",
+    "type": "CFArray of CFDates",
+    "python_type": "list[datetime.datetime]",
+    "help_type": "list of date/time",
+    "short_name": "downloadeddate",
+    "xattr_constant": "com.apple.metadata:kMDItemDownloadedDate",
+}
+
+# specific types of attribute data
+MDITEM_ATTRIBUTE_AUDIO = load_mditem_attribute_data(["audio_attributes.json"])
+MDITEM_ATTRIBUTE_COMMON = load_mditem_attribute_data(["common_attributes.json"])
+MDITEM_ATTRIBUTE_FILESYSTEM = load_mditem_attribute_data(["filesystem_attributes.json"])
+MDITEM_ATTRIBUTE_IMAGE = load_mditem_attribute_data(["image_attributes.json"])
+MDITEM_ATTRIBUTE_VIDEO = load_mditem_attribute_data(["video_attributes.json"])
+
+# short names for accessing via OSXMetaData().short_name
+MDITEM_ATTRIBUTE_SHORT_NAMES = {
+    item["short_name"]: item["name"] for item in MDITEM_ATTRIBUTE_DATA.values()
+}
+
+# Some MDItem attributes are read-only
+MDITEM_ATTRIBUTE_READ_ONLY = {
+    "kMDItemAttributeChangeDate",
+    "kMDItemContentType",
+    "kMDItemKind",
+    "kMDItemLastUsedDate",
+    "kMDItemSecurityMethod",
+    "kMDItemTextContent",
+    "kMDItemFSHasCustomIcon",
+    "kMDItemFSIsStationery",
+    "kMDItemDisplayName",
+    "kMDItemFSContentChangeDate",
+    "kMDItemFSCreationDate",
+    "kMDItemFSInvisible",
+    "kMDItemFSIsExtensionHidden",
+    "kMDItemFSLabel",
+    "kMDItemFSName",
+    "kMDItemFSNodeCount",
+    "kMDItemFSOwnerGroupID",
+    "kMDItemFSOwnerUserID",
+    "kMDItemFSSize",
+    "kMDItemPath",
+}
+
+NSURL_RESOURCE_KEY_DATA = load_nsurl_resource_key_data()
+
+MDIMPORTER_ATTRIBUTE_DATA = load_mdimporter_constant_data()
+
+
+__all__ = [
+    "MDITEM_ATTRIBUTE_DATA",
+    "MDITEM_ATTRIBUTE_READ_ONLY",
+    "MDITEM_ATTRIBUTE_SHORT_NAMES",
+    "MDITEM_ATTRIBUTE_AUDIO",
+    "MDITEM_ATTRIBUTE_COMMON",
+    "MDITEM_ATTRIBUTE_FILESYSTEM",
+    "MDITEM_ATTRIBUTE_IMAGE",
+    "MDITEM_ATTRIBUTE_VIDEO",
+    "NSURL_RESOURCE_KEY_DATA",
+    "MDIMPORTER_ATTRIBUTE_DATA",
+]
diff --git a/osxmetadata/attribute_data/mdimporter_constants.json b/osxmetadata/attribute_data/mdimporter_constants.json
new file mode 100644
index 0000000..31e01fb
--- /dev/null
+++ b/osxmetadata/attribute_data/mdimporter_constants.json
@@ -0,0 +1,178 @@
+[
+    {
+        "name": "kMDItemApplicationCategories",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDItemCameraOwner",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDItemContentTypeTree",
+        "version": "macOS 10.5+"
+    },
+    {
+        "name": "kMDItemDateAdded",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDItemDownloadedDate",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDItemEditors",
+        "version": "macOS 10.5+"
+    },
+    {
+        "name": "kMDItemExecutableArchitectures",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDItemExecutablePlatform",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDItemFocalLength35mm",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDItemGPSAreaInformation",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDItemGPSDOP",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDItemGPSDateStamp",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDItemGPSDestBearing",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDItemGPSDestDistance",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDItemGPSDestLatitude",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDItemGPSDestLongitude",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDItemGPSDifferental",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDItemGPSMapDatum",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDItemGPSMeasureMode",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDItemGPSProcessingMethod",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDItemGPSStatus",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDItemHTMLContent",
+        "version": "macOS 10.11+"
+    },
+    {
+        "name": "kMDItemIsApplicationManaged",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDItemIsLikelyJunk",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDItemLensModel",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDLabelAddedNotification",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDLabelBundleURL",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDLabelChangedNotification",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDLabelContentChangeDate",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDLabelDisplayName",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDLabelIconData",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDLabelIconUUID",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDLabelIsMutuallyExclusiveSetMember",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDLabelKind",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDLabelKindIsMutuallyExclusiveSetKey",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDLabelKindVisibilityKey",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDLabelLocalDomain",
+        "version": "macOS 10.7+ Mac Catalyst 13.0+"
+    },
+    {
+        "name": "kMDLabelRemovedNotification",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDLabelSetsFinderColor",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDLabelUUID",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDLabelUserDomain",
+        "version": "macOS 10.7+ Mac Catalyst 13.0+"
+    },
+    {
+        "name": "kMDLabelVisibility",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDPrivateVisibility",
+        "version": "macOS 10.7+"
+    },
+    {
+        "name": "kMDPublicVisibility",
+        "version": "macOS 10.7+"
+    }
+]
\ No newline at end of file
diff --git a/osxmetadata/attribute_data/nsurl_resource_keys.json b/osxmetadata/attribute_data/nsurl_resource_keys.json
new file mode 100644
index 0000000..2a676df
--- /dev/null
+++ b/osxmetadata/attribute_data/nsurl_resource_keys.json
@@ -0,0 +1,567 @@
+[
+    {
+        "name": "NSURLIsApplicationKey",
+        "description": "",
+        "version": "iOS 9.0+ iPadOS 9.0+ macOS 10.11+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLApplicationIsScriptableKey",
+        "description": "",
+        "version": "macOS 10.11+"
+    },
+    {
+        "name": "NSURLIsDirectoryKey",
+        "description": "Key for determining whether the resource is a directory, returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLParentDirectoryURLKey",
+        "description": "The parent directory of the resource, returned as an NSURL object, or nil if the resource is the root directory of its volume (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLFileAllocatedSizeKey",
+        "description": "The key for the total size allocated on-disk for the file.",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLFileProtectionKey",
+        "description": "The key for the protection level of the file.",
+        "version": "iOS 9.0+ iPadOS 9.0+ macOS 10.11+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLFileContentIdentifierKey",
+        "description": "The key for a value that APFS assigns to identify a file's content data stream.",
+        "version": "iOS 14.0+ iPadOS 14.0+ macOS 11.0+ Mac Catalyst 14.0+ tvOS 14.0+ watchOS 7.0+"
+    },
+    {
+        "name": "NSURLFileResourceIdentifierKey",
+        "description": "The key for the resource's unique identifier.",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLFileResourceTypeKey",
+        "description": "The key for the resource's object type.",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLFileSecurityKey",
+        "description": "The key for the resource's security information.",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLFileSizeKey",
+        "description": "The key for the file's size, in bytes.",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLIsAliasFileKey",
+        "description": "The key for determining whether the file is an alias.",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLIsPackageKey",
+        "description": "The key for determining whether the resource is a file package.",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLIsRegularFileKey",
+        "description": "The key for determining whether the resource is a regular file rather than a directory or a symbolic link.",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLIsPurgeableKey",
+        "description": "The key for a Boolean value that indicates whether the file system can delete a file when the system needs to free space.",
+        "version": "iOS 14.0+ iPadOS 14.0+ macOS 11.0+ Mac Catalyst 14.0+ tvOS 14.0+ watchOS 7.0+"
+    },
+    {
+        "name": "NSURLIsSparseKey",
+        "description": "The key for a Boolean value that indicates whether the file has sparse regions.",
+        "version": "iOS 14.0+ iPadOS 14.0+ macOS 11.0+ Mac Catalyst 14.0+ tvOS 14.0+ watchOS 7.0+"
+    },
+    {
+        "name": "NSURLMayHaveExtendedAttributesKey",
+        "description": "The key for a Boolean value that indicates whether the file has extended attributes.",
+        "version": "iOS 14.0+ iPadOS 14.0+ macOS 11.0+ Mac Catalyst 14.0+ tvOS 14.0+ watchOS 7.0+"
+    },
+    {
+        "name": "NSURLMayShareFileContentKey",
+        "description": "The key for a Boolean value that indicates whether cloned files and their original files may share data blocks.",
+        "version": "iOS 14.0+ iPadOS 14.0+ macOS 11.0+ Mac Catalyst 14.0+ tvOS 14.0+ watchOS 7.0+"
+    },
+    {
+        "name": "NSURLPreferredIOBlockSizeKey",
+        "description": "The key for the optimal block size to use when reading or writing the file's data.",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLTotalFileAllocatedSizeKey",
+        "description": "The key for the total allocated size of the file, in bytes.",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLTotalFileSizeKey",
+        "description": "The key for the total displayable size of the file, in bytes.",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeAvailableCapacityKey",
+        "description": "Key for the volume's available capacity in bytes (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeAvailableCapacityForImportantUsageKey",
+        "description": "Key for the volume's available capacity in bytes for storing important resources (read-only).",
+        "version": "iOS 11.0+ iPadOS 11.0+ macOS 10.13+ Mac Catalyst 13.1+"
+    },
+    {
+        "name": "NSURLVolumeAvailableCapacityForOpportunisticUsageKey",
+        "description": "Key for the volume's available capacity in bytes for storing nonessential resources (read-only).",
+        "version": "iOS 11.0+ iPadOS 11.0+ macOS 10.13+ Mac Catalyst 13.1+"
+    },
+    {
+        "name": "NSURLVolumeTotalCapacityKey",
+        "description": "Key for the volume's total capacity in bytes (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeIsAutomountedKey",
+        "description": "Key for determining whether the volume is automounted, returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeIsBrowsableKey",
+        "description": "Key for determining whether the volume is visible in GUI-based file-browsing environments, such as the Desktop or the Finder application, returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeIsEjectableKey",
+        "description": "Key for determining whether the volume is ejectable from the drive mechanism under software control, returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeIsEncryptedKey",
+        "description": "Whether the volume is encrypted, returned as NSNumber containing a Boolean value (read-only).",
+        "version": "iOS 10.0+ iPadOS 10.0+ macOS 10.12+ Mac Catalyst 13.1+ tvOS 10.0+ watchOS 3.0+"
+    },
+    {
+        "name": "NSURLVolumeIsInternalKey",
+        "description": "Key for determining whether the volume is connected to an internal bus, returned as a Boolean NSNumber object, or nil if it cannot be determined (read-only).",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeIsJournalingKey",
+        "description": "Key for determining whether the volume is currently journaling, returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeIsLocalKey",
+        "description": "Key for determining whether the volume is stored on a local device, returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeIsReadOnlyKey",
+        "description": "Key for determining whether the volume is read-only, returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeIsRemovableKey",
+        "description": "Key for determining whether the volume is removable from the drive mechanism, returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeIsRootFileSystemKey",
+        "description": "Whether the volume is the root filesystem, returned as NSNumber containing a Boolean value (read-only).",
+        "version": "iOS 10.0+ iPadOS 10.0+ macOS 10.12+ Mac Catalyst 13.1+ tvOS 10.0+ watchOS 3.0+"
+    },
+    {
+        "name": "NSURLVolumeSupportsFileProtectionKey",
+        "description": "A Boolean value that indicates the volume supports data protection for files.",
+        "version": "iOS 14.0+ iPadOS 14.0+ macOS 11.0+ Mac Catalyst 14.0+ tvOS 14.0+ watchOS 7.0+"
+    },
+    {
+        "name": "NSURLIsMountTriggerKey",
+        "description": "Key for determining whether the URL is a file system trigger directory, returned as a Boolean NSNumber object (read-only). Traversing or opening a file system trigger directory causes an attempt to mount a file system on the directory.",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLIsVolumeKey",
+        "description": "Key for determining whether the resource is the root directory of a volume, returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeCreationDateKey",
+        "description": "Key for the volume's creation date, returned as an NSDate object, or NULL if it cannot be determined (read-only).",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeIdentifierKey",
+        "description": "The unique identifier of the resource's volume, returned as an id (read-only).",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeLocalizedFormatDescriptionKey",
+        "description": "Key for the volume's descriptive format name, returned as an NSString object (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeLocalizedNameKey",
+        "description": "The name of the volume as it should be displayed in the user interface, returned as an NSString object (read-only).",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeMaximumFileSizeKey",
+        "description": "Key for the largest file size supported by the volume in bytes, returned as a Boolean NSNumber object, or nil if it cannot be determined (read-only).",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeNameKey",
+        "description": "The name of the volume, returned as an NSString object (read-write). Settable only if NSURLVolumeSupportsRenamingKey is YES.",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeResourceCountKey",
+        "description": "Key for the total number of resources on the volume, returned as an NSNumber object (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeSupportsAccessPermissionsKey",
+        "description": "",
+        "version": "iOS 11.0+ iPadOS 11.0+ macOS 10.13+ Mac Catalyst 13.1+ tvOS 11.0+ watchOS 4.0+"
+    },
+    {
+        "name": "NSURLVolumeSupportsAdvisoryFileLockingKey",
+        "description": "Key for determining whether the volume implements whole-file advisory locks in the style of flock, along with the O_EXLOCK and O_SHLOCK flags of the open function, returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeSupportsCasePreservedNamesKey",
+        "description": "Key for determining whether the volume supports case-preserved names, returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeSupportsCaseSensitiveNamesKey",
+        "description": "Key for determining whether the volume supports case-sensitive names, returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeSupportsCompressionKey",
+        "description": "Whether the volume supports transparent decompression of compressed files using decmpfs, returned as NSNumber containing a Boolean value (read-only).",
+        "version": "iOS 10.0+ iPadOS 10.0+ macOS 10.12+ Mac Catalyst 13.1+ tvOS 10.0+ watchOS 3.0+"
+    },
+    {
+        "name": "NSURLVolumeSupportsExclusiveRenamingKey",
+        "description": "Whether the volume supports exclusive renaming using renamex_np(2) with the RENAME_EXCL option, returned as NSNumber containing a Boolean value (read-only).",
+        "version": "iOS 10.0+ iPadOS 10.0+ macOS 10.12+ Mac Catalyst 13.1+ tvOS 10.0+ watchOS 3.0+"
+    },
+    {
+        "name": "NSURLVolumeSupportsExtendedSecurityKey",
+        "description": "Key for determining whether the volume supports extended security (access control lists), returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeSupportsFileCloningKey",
+        "description": "Whether the volume supports cloning using clonefile(2), returned as NSNumber containing a Boolean value (read-only).",
+        "version": "iOS 10.0+ iPadOS 10.0+ macOS 10.12+ Mac Catalyst 13.1+ tvOS 10.0+ watchOS 3.0+"
+    },
+    {
+        "name": "NSURLVolumeSupportsHardLinksKey",
+        "description": "Key for determining whether the volume supports hard links, returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeSupportsImmutableFilesKey",
+        "description": "",
+        "version": "iOS 11.0+ iPadOS 11.0+ macOS 10.13+ Mac Catalyst 13.1+ tvOS 11.0+ watchOS 4.0+"
+    },
+    {
+        "name": "NSURLVolumeSupportsJournalingKey",
+        "description": "Key for determining whether the volume supports journaling, returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeSupportsPersistentIDsKey",
+        "description": "Key for determining whether the volume supports persistent IDs, returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeSupportsRenamingKey",
+        "description": "Key for determining whether the volume can be renamed, returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeSupportsRootDirectoryDatesKey",
+        "description": "Key for determining whether the volume supports reliable storage of times for the root directory, returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeSupportsSparseFilesKey",
+        "description": "Key for determining whether the volume supports sparse files, returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeSupportsSwapRenamingKey",
+        "description": "Whether the volume supports renaming using renamex_np(2) with the RENAME_SWAP option, returned as NSNumber containing a Boolean value (read-only).",
+        "version": "iOS 10.0+ iPadOS 10.0+ macOS 10.12+ Mac Catalyst 13.1+ tvOS 10.0+ watchOS 3.0+"
+    },
+    {
+        "name": "NSURLVolumeSupportsSymbolicLinksKey",
+        "description": "Key for determining whether the volume supports symbolic links, returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeSupportsVolumeSizesKey",
+        "description": "Key for determining whether the volume supports returning volume size information, returned as a Boolean NSNumber object (read-only). If true, volume size information is available as values of the NSURLVolumeTotalCapacityKey andNSURLVolumeAvailableCapacityKey keys.",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeSupportsZeroRunsKey",
+        "description": "Key for determining whether the volume supports zero runs, returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeURLForRemountingKey",
+        "description": "Key for the URL needed to remount the network volume, returned as an NSURL object, or nil if a URL is not available (read-only).",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeURLKey",
+        "description": "The root directory of the resource's volume, returned as an NSURL object (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLVolumeUUIDStringKey",
+        "description": "Key for the volume's persistent UUID, returned as an NSString object, or nil if a persistent UUID is not available (read-only).",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLIsUbiquitousItemKey",
+        "description": "The key for a Boolean value that indicates whether the item is in iCloud storage.",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLUbiquitousSharedItemMostRecentEditorNameComponentsKey",
+        "description": "The key for the name components of the most recent editor.",
+        "version": "iOS 10.0+ iPadOS 10.0+ macOS 10.12+ Mac Catalyst 13.1+"
+    },
+    {
+        "name": "NSURLUbiquitousItemDownloadRequestedKey",
+        "description": "The key for a Boolean value that indicates whether the system has already made a call startDownloadingUbiquitousItemAtURL:error: to download the item.",
+        "version": "iOS 8.0+ iPadOS 8.0+ macOS 10.10+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLUbiquitousItemIsDownloadingKey",
+        "description": "The key for a Boolean value that indicates whether the system is downloading the item from iCloud.",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLUbiquitousItemDownloadingErrorKey",
+        "description": "The key for an error object that indicates why downloading the item from iCloud fails.",
+        "version": "iOS 7.0+ iPadOS 7.0+ macOS 10.9+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLUbiquitousItemDownloadingStatusKey",
+        "description": "The key for the current download state for the item.",
+        "version": "iOS 7.0+ iPadOS 7.0+ macOS 10.9+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLUbiquitousItemIsExcludedFromSyncKey",
+        "description": "The key of a Boolean value that indicates whether the system excludes the item from syncing.",
+        "version": "iOS 14.5+ iPadOS 14.5+ macOS 11.3+ Mac Catalyst 14.5+ tvOS 14.5+ watchOS 7.4+"
+    },
+    {
+        "name": "NSURLUbiquitousItemIsUploadedKey",
+        "description": "The key for a Boolean value that indicates whether the system uploads the item's data to iCloud storage.",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLUbiquitousItemIsUploadingKey",
+        "description": "The key for a Boolean value that indicates whether the system is uploading the item to iCloud.",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLUbiquitousItemUploadingErrorKey",
+        "description": "The key for an error object that indicates why uploading the item to iCloud fails.",
+        "version": "iOS 7.0+ iPadOS 7.0+ macOS 10.9+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLUbiquitousItemHasUnresolvedConflictsKey",
+        "description": "The key for a Boolean value that indicates whether this item has outstanding conflicts.",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLUbiquitousItemContainerDisplayNameKey",
+        "description": "The key for a string that contains the name of the item's container as it appears to the user.",
+        "version": "iOS 8.0+ iPadOS 8.0+ macOS 10.10+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLUbiquitousSharedItemOwnerNameComponentsKey",
+        "description": "The key for the name components of the item's owner.",
+        "version": "iOS 10.0+ iPadOS 10.0+ macOS 10.12+ Mac Catalyst 13.1+"
+    },
+    {
+        "name": "NSURLUbiquitousSharedItemCurrentUserPermissionsKey",
+        "description": "The key for the current user's permissions.",
+        "version": "iOS 10.0+ iPadOS 10.0+ macOS 10.12+ Mac Catalyst 13.1+"
+    },
+    {
+        "name": "NSURLUbiquitousSharedItemCurrentUserRoleKey",
+        "description": "The key for the role of the current user.",
+        "version": "iOS 10.0+ iPadOS 10.0+ macOS 10.12+ Mac Catalyst 13.1+"
+    },
+    {
+        "name": "NSURLUbiquitousItemIsSharedKey",
+        "description": "The key for a Boolean value that indicates a shared item.",
+        "version": "iOS 10.0+ iPadOS 10.0+ macOS 10.12+ Mac Catalyst 13.1+"
+    },
+    {
+        "name": "NSURLKeysOfUnsetValuesKey",
+        "description": "Key for the resource properties that have not been set after the setResourceValues:error: method returns an error, returned as an array of NSString objects.",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLQuarantinePropertiesKey",
+        "description": "",
+        "version": "macOS 10.10+"
+    },
+    {
+        "name": "NSURLAddedToDirectoryDateKey",
+        "description": "The time at which the resource's was created or renamed into or within its parent directory, returned as an NSDate. Inconsistent behavior may be observed when this attribute is requested on hard-linked items. This property is not supported by all volumes. (read-only)",
+        "version": "iOS 8.0+ iPadOS 8.0+ macOS 10.10+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLAttributeModificationDateKey",
+        "description": "The time at which the resource's attributes were most recently modified, returned as an NSDate object if the volume supports attribute modification dates, or nil if attribute modification dates are unsupported (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLContentAccessDateKey",
+        "description": "The time at which the resource was most recently accessed.",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLContentModificationDateKey",
+        "description": "The time at which the resource was most recently modified.",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLCreationDateKey",
+        "description": "The time at which the resource was created.",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLCustomIconKey",
+        "description": "The icon stored with the resource, returned as an NSImage object, or nil if the resource has no custom icon.",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLDocumentIdentifierKey",
+        "description": "The document identifier returned as an NSNumber (read-only).",
+        "version": "iOS 8.0+ iPadOS 8.0+ macOS 10.10+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLEffectiveIconKey",
+        "description": "The resource's normal icon, returned as an NSImage object (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLGenerationIdentifierKey",
+        "description": "An opaque generation identifier, returned as an id <NSCopying, NSCoding, NSObject> (read-only)",
+        "version": "iOS 8.0+ iPadOS 8.0+ macOS 10.10+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLHasHiddenExtensionKey",
+        "description": "Key for determining whether the resource's extension is normally removed from its localized name, returned as a Boolean NSNumber object (read-write).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLIsExcludedFromBackupKey",
+        "description": "A key for indicating whether the system excludes the resource from all backups of app data.",
+        "version": "iOS 5.1+ iPadOS 5.1+ macOS 10.8+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLIsExecutableKey",
+        "description": "Key for determining whether the current process (as determined by the EUID) can execute the resource (if it is a file) or search the resource (if it is a directory), returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLIsHiddenKey",
+        "description": "Key for determining whether the resource is normally not displayed to users, returned as a Boolean NSNumber object (read-write).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLIsReadableKey",
+        "description": "Key for determining whether the current process (as determined by the EUID) can read the resource, returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLIsSymbolicLinkKey",
+        "description": "Key for determining whether the resource is a symbolic link, returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLIsSystemImmutableKey",
+        "description": "Key for determining whether the resource's system immutable bit is set, returned as a Boolean NSNumber object (read-write).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLIsUserImmutableKey",
+        "description": "Key for determining whether the resource's user immutable bit is set, returned as a Boolean NSNumber object (read-write).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLIsWritableKey",
+        "description": "Key for determining whether the current process (as determined by the EUID) can write to the resource, returned as a Boolean NSNumber object (read-only).",
+        "version": "iOS 5.0+ iPadOS 5.0+ macOS 10.7+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLLabelColorKey",
+        "description": "The resource's label color, returned as an NSColor object, or nil if the resource has no label color (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLLabelNumberKey",
+        "description": "The resource's label number, returned as an NSNumber object (read-write).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLLinkCountKey",
+        "description": "The number of hard links to the resource, returned as an NSNumber object (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLLocalizedLabelKey",
+        "description": "The resource's localized label text, returned as an NSString object, or nil if the resource has no localized label text (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLLocalizedNameKey",
+        "description": "The resource's localized or extension-hidden name, returned as an NSString object (read-only).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLNameKey",
+        "description": "The resource's name in the file system, returned as an NSString object (read-write).",
+        "version": "iOS 4.0+ iPadOS 4.0+ macOS 10.6+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLPathKey",
+        "description": "The file system path for the URL, returned as an NSString object (read-only).",
+        "version": "iOS 6.0+ iPadOS 6.0+ macOS 10.8+ Mac Catalyst 13.1+ tvOS 9.0+ watchOS 2.0+"
+    },
+    {
+        "name": "NSURLCanonicalPathKey",
+        "description": "",
+        "version": "iOS 10.0+ iPadOS 10.0+ macOS 10.12+ Mac Catalyst 13.1+ tvOS 10.0+ watchOS 3.0+"
+    },
+    {
+        "name": "NSURLTagNamesKey",
+        "description": "The names of tags attached to the resource, returned as an array of NSString values (read-write).",
+        "version": "macOS 10.9+"
+    },
+    {
+        "name": "NSURLContentTypeKey",
+        "description": "The resource's type.",
+        "version": "iOS 14.0+ iPadOS 14.0+ macOS 11.0+ Mac Catalyst 14.0+ tvOS 14.0+ watchOS 7.0+"
+    }
+]
\ No newline at end of file
diff --git a/osxmetadata/attribute_data/video_attributes.json b/osxmetadata/attribute_data/video_attributes.json
new file mode 100644
index 0000000..bcc74de
--- /dev/null
+++ b/osxmetadata/attribute_data/video_attributes.json
@@ -0,0 +1,80 @@
+[
+    {
+        "name": "kMDItemAudioBitRate",
+        "description": "The audio bit rate.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemCodecs",
+        "description": "The codecs used to encode/decode the media.",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemDeliveryType",
+        "description": "The delivery type. Values are \"Fast start\" or \"RTSP\".",
+        "type": "CFString",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemMediaTypes",
+        "description": "The media types present in the content.",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemStreamable",
+        "description": "Whether the content is prepared for streaming.",
+        "type": "CFBoolean",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemTotalBitRate",
+        "description": "The total bit rate, audio and video combined, of the media.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemVideoBitRate",
+        "description": "The video bit rate.",
+        "type": "CFNumber",
+        "version": "macOS 10.4+"
+    },
+    {
+        "name": "kMDItemDirector",
+        "description": "Directory of the movie.",
+        "type": "CFString",
+        "version": "macOS 10.5+"
+    },
+    {
+        "name": "kMDItemProducer",
+        "description": "Producer of the content.",
+        "type": "CFString",
+        "version": "macOS 10.5+"
+    },
+    {
+        "name": "kMDItemGenre",
+        "description": "Genre of the movie.",
+        "type": "CFString",
+        "version": "macOS 10.5+"
+    },
+    {
+        "name": "kMDItemPerformers",
+        "description": "Performers in the movie.",
+        "type": "CFArray of CFStrings",
+        "version": "macOS 10.5+"
+    },
+    {
+        "name": "kMDItemOriginalFormat",
+        "description": "Original format of the movie.",
+        "type": "CFString",
+        "version": "macOS 10.5+"
+    },
+    {
+        "name": "kMDItemOriginalSource",
+        "description": "Original source of the movie.",
+        "type": "CFString",
+        "version": "macOS 10.5+"
+    }
+]
\ No newline at end of file
diff --git a/osxmetadata/attributes.py b/osxmetadata/attributes.py
index 846a7f0..2ecbaf5 100644
--- a/osxmetadata/attributes.py
+++ b/osxmetadata/attributes.py
@@ -7,7 +7,7 @@
 from .classes import (
     _AttributeFinderInfo,
     _AttributeFinderInfoColor,
-    _AttributeFinderInfoStationaryPad,
+    _AttributeFinderInfoStationeryPad,
     _AttributeList,
     _AttributeTagsList,
     _AttributeOSXPhotosDetectedText,
@@ -20,412 +20,10 @@
     datetime_tz_to_utc,
 )
 
-# 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
-# short_constant: the short constant name for the attribute
-#   (e.g. kMDItemFinderComment)
-# constant: the long 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.
-# 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
-# append: the attribute can be operated on with append
-# update: the attribute can be operated on with update
-# 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
-
-
-Attribute = namedtuple(
-    "Attribute",
-    [
-        "name",
-        "short_constant",
-        "constant",
-        "type_",
-        "list",
-        "as_list",
-        "class_",
-        "append",
-        "update",
-        "help",
-        "api_help",
-    ],
-)
-
-ATTRIBUTES = {
-    "authors": Attribute(
-        name="authors",
-        short_constant="kMDItemAuthors",
-        constant=kMDItemAuthors,
-        type_=str,
-        list=True,
-        as_list=False,
-        class_=_AttributeList,
-        append=True,
-        update=True,
-        help="The author, or authors, of the contents of the file.  A list of strings.",
-        api_help=None,
-    ),
-    "comment": Attribute(
-        name="comment",
-        short_constant="kMDItemComment",
-        constant=kMDItemComment,
-        type_=str,
-        list=False,
-        as_list=False,
-        class_=str,
-        append=True,
-        update=False,
-        help="A comment related to the file.  This differs from the Finder comment, "
-        + "kMDItemFinderComment.  A string.",
-        api_help=None,
-    ),
-    "copyright": Attribute(
-        name="copyright",
-        short_constant="kMDItemCopyright",
-        constant=kMDItemCopyright,
-        type_=str,
-        list=False,
-        as_list=False,
-        class_=str,
-        append=True,
-        update=False,
-        help="The copyright owner of the file contents.  A string.",
-        api_help=None,
-    ),
-    "creator": Attribute(
-        name="creator",
-        short_constant="kMDItemCreator",
-        constant=kMDItemCreator,
-        type_=str,
-        list=False,
-        as_list=False,
-        class_=str,
-        append=True,
-        update=False,
-        help="Application used to create the document content (for example “Word”, “Pages”, "
-        + "and so on).  A string.",
-        api_help=None,
-    ),
-    "description": Attribute(
-        name="description",
-        short_constant="kMDItemDescription",
-        constant=kMDItemDescription,
-        type_=str,
-        list=False,
-        as_list=False,
-        class_=str,
-        append=True,
-        update=False,
-        help="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.",
-        api_help=None,
-    ),
-    "downloadeddate": Attribute(
-        name="downloadeddate",
-        short_constant="kMDItemDownloadedDate",
-        constant=kMDItemDownloadedDate,
-        type_=datetime.datetime,
-        list=True,
-        as_list=False,
-        class_=_AttributeList,
-        append=False,
-        update=False,
-        help="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.",
-        api_help="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(
-        name="findercomment",
-        short_constant="kMDItemFinderComment",
-        constant=kMDItemFinderComment,
-        type_=str,
-        list=False,
-        as_list=False,
-        class_=str,
-        append=True,
-        update=False,
-        help="Finder comments for this file.  A string.",
-        api_help=None,
-    ),
-    "headline": Attribute(
-        name="headline",
-        short_constant="kMDItemHeadline",
-        constant=kMDItemHeadline,
-        type_=str,
-        list=False,
-        as_list=False,
-        class_=str,
-        append=True,
-        update=False,
-        help="A publishable entry providing a synopsis of the contents of the file.  A string.",
-        api_help=None,
-    ),
-    "keywords": Attribute(
-        name="keywords",
-        short_constant="kMDItemKeywords",
-        constant=kMDItemKeywords,
-        type_=str,
-        list=True,
-        as_list=False,
-        class_=_AttributeList,
-        append=True,
-        update=True,
-        help="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.",
-        api_help=None,
-    ),
-    "tags": Attribute(
-        name="tags",
-        short_constant="_kMDItemUserTags",
-        constant=_kMDItemUserTags,
-        type_=str,
-        list=True,
-        as_list=False,
-        class_=_AttributeTagsList,
-        append=True,
-        update=True,
-        help='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 Tag objects.",
-        api_help=None,
-    ),
-    "wherefroms": Attribute(
-        name="wherefroms",
-        short_constant="kMDItemWhereFroms",
-        constant=kMDItemWhereFroms,
-        type_=str,
-        list=True,
-        as_list=False,
-        class_=_AttributeList,
-        append=True,
-        update=True,
-        help="Describes where the file was obtained from (e.g. URL downloaded from).  "
-        + "A list of strings.",
-        api_help=None,
-    ),
-    "finderinfo": Attribute(
-        name="finderinfo",
-        short_constant="finderinfo",
-        constant=FinderInfo,
-        type_=str,
-        list=False,
-        as_list=False,
-        class_=_AttributeFinderInfo,
-        append=False,
-        update=False,
-        help="Info set by the Finder, for example tag color.  Colors can also be set by _kMDItemUserTags.  "
-        + f"{FinderInfo} is controlled by the Finder and it's recommended you don't directly access this attribute.  "
-        + "If you set or remove a color tag via _kMDItemUserTag, osxmetadata will automatically handle "
-        + "processing of FinderInfo color tag.",
-        api_help=None,
-    ),
-    "duedate": Attribute(
-        name="duedate",
-        short_constant="kMDItemDueDate",
-        constant=kMDItemDueDate,
-        type_=datetime.datetime,
-        list=False,
-        as_list=False,
-        class_=datetime.datetime,
-        append=False,
-        update=False,
-        help="The date the item is due.  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.",
-        api_help="The date the item is due.  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.",
-    ),
-    "rating": Attribute(
-        name="rating",
-        short_constant="kMDItemStarRating",
-        constant=kMDItemStarRating,
-        type_=int,
-        list=False,
-        as_list=False,
-        class_=int,
-        append=False,
-        update=False,
-        help="User rating of this item. "
-        + "For example, the stars rating of an iTunes track. An integer.",
-        api_help=None,
-    ),
-    "participants": Attribute(
-        name="participants",
-        short_constant="kMDItemParticipants",
-        constant=kMDItemParticipants,
-        type_=str,
-        list=True,
-        as_list=False,
-        class_=_AttributeList,
-        append=True,
-        update=True,
-        help="The list of people who are visible in an image or movie or written about in a document. "
-        + "A list of strings.",
-        api_help=None,
-    ),
-    "projects": Attribute(
-        name="projects",
-        short_constant="kMDItemProjects",
-        constant=kMDItemProjects,
-        type_=str,
-        list=True,
-        as_list=False,
-        class_=_AttributeList,
-        append=True,
-        update=True,
-        help="The list of projects that this file is part of. For example, if you were working on a movie all of the files could be marked as belonging to the project “My Movie”. "
-        + "A list of strings.",
-        api_help=None,
-    ),
-    "version": Attribute(
-        name="version",
-        short_constant="kMDItemVersion",
-        constant=kMDItemVersion,
-        type_=str,
-        list=False,
-        as_list=False,
-        class_=str,
-        append=True,
-        update=False,
-        help="The version number of this file. A string.",
-        api_help=None,
-    ),
-    "stationary": Attribute(
-        name="stationary",
-        short_constant="kMDItemFSIsStationery",
-        constant=kMDItemFSIsStationery,
-        type_=bool,
-        list=False,
-        as_list=False,
-        class_=bool,
-        append=False,
-        update=False,
-        help="Boolean indicating if this file is stationery. "
-        + "Note: this is not what the Finder uses for Stationary Pad flag.  See also 'stationarypad'.",
-        api_help=None,
-    ),
-    "findercolor": Attribute(
-        name="findercolor",
-        short_constant="findercolor",
-        constant=FinderInfo,
-        type_=str,
-        list=False,
-        as_list=False,
-        class_=_AttributeFinderInfoColor,
-        append=False,
-        update=False,
-        help="Color tag set by the Finder.  Colors can also be set by _kMDItemUserTags.  "
-        + "This is controlled by the Finder and it's recommended you don't directly access this attribute.  "
-        + "If you set or remove a color tag via _kMDItemUserTag, osxmetadata will automatically handle "
-        + "processing of FinderInfo color tag.",
-        api_help=None,
-    ),
-    "stationarypad": Attribute(
-        name="stationarypad",
-        short_constant="stationarypad",
-        constant=FinderInfo,
-        type_=str,
-        list=False,
-        as_list=False,
-        class_=_AttributeFinderInfoStationaryPad,
-        append=False,
-        update=False,
-        help="Marks the file as stationary (a template that can be re-used). "
-        + "Setting this to True is the same as checking the 'Stationary Pad' box in Finder Info.",
-        api_help=None,
-    ),
-    "title": Attribute(
-        name="title",
-        short_constant="kMDItemTitle",
-        constant=kMDItemTitle,
-        type_=str,
-        list=False,
-        as_list=False,
-        class_=str,
-        append=True,
-        update=False,
-        help="The title of the file. For example, this could be the title of a document, the name of a song, "
-        + "or the subject of an email message. A string.",
-        api_help=None,
-    ),
-    "subject": Attribute(
-        name="subject",
-        short_constant="kMDItemSubject",
-        constant=kMDItemSubject,
-        type_=str,
-        list=False,
-        as_list=False,
-        class_=str,
-        append=True,
-        update=False,
-        help="Subject of the this item. A string.",
-        api_help=None,
-    ),
-    "osxphotos_detected_text": Attribute(
-        name="osxphotos_detected_text",
-        short_constant="OSXPhotosDetectedText",
-        constant=OSXPhotosDetectedText,
-        type_=list,
-        list=True,
-        as_list=False,
-        class_=_AttributeOSXPhotosDetectedText,
-        append=True,
-        update=True,
-        help="Text detected in a photo; used by osxphotos (https://github.com/RhetTbull/osxphotos).",
-        api_help=None,
-    ),
-}
-
 # used for formatting output of --list
-_SHORT_NAME_WIDTH = max(len(x) for x in ATTRIBUTES) + 5
-_LONG_NAME_WIDTH = max(len(x.constant) for x in ATTRIBUTES.values()) + 10
-_CONSTANT_WIDTH = 21 + 5  # currently longest is kMDItemDownloadedDate
-
-
-ATTRIBUTE_DISPATCH = {}
-for attr in ATTRIBUTES.values():
-    try:
-        ATTRIBUTE_DISPATCH[attr.constant].append(attr.name)
-    except KeyError:
-        ATTRIBUTE_DISPATCH[attr.constant] = [attr.name]
-
-# 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 in ATTRIBUTES:
-        raise ValueError(f"Duplicate attribute in ATTRIBUTES: {attribute}")
-    _temp_attributes[attribute.constant] = attribute
-    _temp_attributes[attribute.short_constant] = attribute
-if _temp_attributes:
-    ATTRIBUTES.update(_temp_attributes)
+# _SHORT_NAME_WIDTH = max(len(x) for x in ATTRIBUTES) + 5
+# _LONG_NAME_WIDTH = max(len(x.constant) for x in ATTRIBUTES.values()) + 10
+# _CONSTANT_WIDTH = 21 + 5  # currently longest is kMDItemDownloadedDate
 
 
 def validate_attribute_value(attribute, value):
@@ -513,7 +111,7 @@ def validate_attribute_value(attribute, value):
         else:
             raise TypeError(f"Unknown type: {type(val)}")
         new_values.append(new_val)
-    
+
     if attribute.list:
         return new_values
     else:
diff --git a/osxmetadata/backup.py b/osxmetadata/backup.py
index a23d175..5768f62 100644
--- a/osxmetadata/backup.py
+++ b/osxmetadata/backup.py
@@ -1,16 +1,35 @@
 """ Functions for writing and loading backup files """
 
+import datetime
 import json
 import logging
 import os
 from enum import Enum
 
+from osxmetadata import OSXMetaData, __version__
+
+__all__ = ["get_backup_dict", "write_backup_file", "load_backup_file"]
+
 
 class BackupDatabaseType(Enum):
     SINGLE_RECORD_JSON = 1
     JSON = 2
 
 
+def get_backup_dict(filepath: str):
+    """get backup dict for a single file"""
+    md = OSXMetaData(filepath)
+    backup_dict = md.asdict()
+    backup_dict.update(
+        {
+            "_version": __version__,
+            "_filepath": md._posix_path,
+            "_filename": md._fname.name,
+        }
+    )
+    return backup_dict
+
+
 def backup_database_type(path: str) -> BackupDatabaseType:
     """Return BackupDatabaseType enum indicating type of backup file for path"""
     with open(path) as fp:
@@ -31,6 +50,15 @@ def write_backup_file(backup_file, backup_data):
     backup_data: dict where key is filename and value is dict of the attributes
     as returned by json.loads(OSXMetaData.to_json())"""
 
+    # Convert datetime objects to isoformat strings for serialization
+    for filename, data in backup_data.items():
+        for key, value in data.items():
+            if isinstance(value, datetime.datetime):
+                data[key] = value.isoformat()
+            elif isinstance(value, (list, tuple)):
+                if value and isinstance(value[0], datetime.datetime):
+                    data[key] = [v.isoformat() for v in value]
+
     with open(backup_file, mode="w") as fp:
         json.dump(list(backup_data.values()), fp, indent=2)
 
diff --git a/osxmetadata/classes.py b/osxmetadata/classes.py
index 835e3b5..48cd314 100644
--- a/osxmetadata/classes.py
+++ b/osxmetadata/classes.py
@@ -17,7 +17,7 @@
     _VALID_COLORIDS,
     FINDER_COLOR_NONE,
     _kCOLOR_OFFSET,
-    _kSTATIONARYPAD_OFFSET,
+    _kSTATIONERYPAD_OFFSET,
 )
 from .datetime_utils import (
     datetime_naive_to_utc,
@@ -190,7 +190,7 @@ def __init__(self, attribute, xattr_, osxmetadata_obj):
         self._md = osxmetadata_obj
 
         # valid keys for the finderinfo dict
-        self.keys = ["color", "stationarypad"]
+        self.keys = ["color", "stationerypad"]
 
         self._constant = attribute.constant
 
@@ -216,9 +216,9 @@ def _load_data(self):
 
         # check color tag stored in com.apple.FinderInfo
         color = self.get_finderinfo_color()
-        stationarypad = self.get_finderinfo_stationarypad()
+        stationerypad = self.get_finderinfo_stationerypad()
 
-        self.data = {"color": color, "stationarypad": stationarypad}
+        self.data = {"color": color, "stationerypad": stationerypad}
 
     def _write_data(self):
         # Overwrites the existing attribute values with the iterable of values provided.
@@ -232,11 +232,11 @@ def _write_data(self):
             return
 
         colorid = self.data.get("color", None)
-        stationarypad = self.data.get("stationarypad", None)
+        stationerypad = self.data.get("stationerypad", None)
         if colorid is not None:
             self.set_finderinfo_color(colorid)
-        if stationarypad is not None:
-            self.set_finderinfo_stationarypad(stationarypad)
+        if stationerypad is not None:
+            self.set_finderinfo_stationerypad(stationerypad)
 
     @property
     def color(self):
@@ -248,13 +248,13 @@ def color(self, value):
         self.set_finderinfo_color(value)
 
     @property
-    def stationarypad(self):
+    def stationerypad(self):
         self._load_data()
-        return self.data["stationarypad"]
+        return self.data["stationerypad"]
 
-    @stationarypad.setter
-    def stationarypad(self, value):
-        self.set_finderinfo_stationarypad(value)
+    @stationerypad.setter
+    def stationerypad(self, value):
+        self.set_finderinfo_stationerypad(value)
 
     def asdict(self):
         return self.data
@@ -290,28 +290,28 @@ def set_finderinfo_color(self, colorid):
         finderbits.overwrite(bits, _kCOLOR_OFFSET)
         self._set_findinfo_bits(finderbits)
 
-    def get_finderinfo_stationarypad(self):
-        """get the Stationary Pad bit from com.apple.FinderInfo
-        returns: True if Stationary Pad is set, False if not set"""
+    def get_finderinfo_stationerypad(self):
+        """get the Stationery Pad bit from com.apple.FinderInfo
+        returns: True if Stationery Pad is set, False if not set"""
 
         try:
             finderbits = self._get_finderinfo_bits()
-            bit = finderbits.bin[_kSTATIONARYPAD_OFFSET]
+            bit = finderbits.bin[_kSTATIONERYPAD_OFFSET]
             return bool(int(bit))
         except Exception as e:
             return False
 
-    def set_finderinfo_stationarypad(self, value):
-        """set the Stationary Pad flag of com.apple.FinderInfo"""
+    def set_finderinfo_stationerypad(self, value):
+        """set the Stationery Pad flag of com.apple.FinderInfo"""
 
         value = 1 if value_to_bool(value) else 0
 
-        # stationary pad is encoded as a single bit
+        # stationery pad is encoded as a single bit
         bit = bitstring.BitArray(uint=value, length=1)
 
         # set color bits
         finderbits = self._get_finderinfo_bits()
-        finderbits.overwrite(bit, _kSTATIONARYPAD_OFFSET)
+        finderbits.overwrite(bit, _kSTATIONERYPAD_OFFSET)
         self._set_findinfo_bits(finderbits)
 
     def _get_finderinfo_bits(self) -> bitstring.BitArray:
@@ -371,30 +371,30 @@ def __ne__(self, other):
         return not self.__eq__(other)
 
 
-class _AttributeFinderInfoStationaryPad(_AttributeFinderInfo):
+class _AttributeFinderInfoStationeryPad(_AttributeFinderInfo):
     def __init__(self, attribute, xattr_, osxmetadata_obj):
         super().__init__(attribute, xattr_, osxmetadata_obj)
 
     def set_value(self, value):
-        self.data = {"stationarypad": value_to_bool(value)}
+        self.data = {"stationerypad": value_to_bool(value)}
         self._write_data()
 
     def get_value(self):
         self._load_data()
-        return self.data.get("stationarypad", None)
+        return self.data.get("stationerypad", None)
 
     def __repr__(self):
         return repr(self.get_value())
 
     def __eq__(self, other):
         self._load_data()
-        return self.data.get("stationarypad") == other
+        return self.data.get("stationerypad") == other
 
     def __ne__(self, other):
         return not self.__eq__(other)
 
     def __bool__(self):
-        return bool(self.data.get("stationarypad", None))
+        return bool(self.data.get("stationerypad", None))
 
 
 class _AttributeTagsList(_AttributeList, _AttributeFinderInfo):
diff --git a/osxmetadata/constants.py b/osxmetadata/constants.py
index 4312c8f..053d7e8 100644
--- a/osxmetadata/constants.py
+++ b/osxmetadata/constants.py
@@ -1,5 +1,17 @@
 """ Constants and definitions used by osxmetadata """
 
+FINDER_COLOR_NONE = 0
+FINDER_COLOR_GRAY = 1
+FINDER_COLOR_GREEN = 2
+FINDER_COLOR_PURPLE = 3
+FINDER_COLOR_BLUE = 4
+FINDER_COLOR_YELLOW = 5
+FINDER_COLOR_RED = 6
+FINDER_COLOR_ORANGE = 7
+
+_MIN_FINDER_COLOR = 0
+_MAX_FINDER_COLOR = 7
+
 # color labels
 _COLORNAMES = {
     "None": 0,
@@ -34,59 +46,34 @@
     7: "Orange",
 }
 
-FINDER_COLOR_NONE = 0
-FINDER_COLOR_GRAY = 1
-FINDER_COLOR_GREEN = 2
-FINDER_COLOR_PURPLE = 3
-FINDER_COLOR_BLUE = 4
-FINDER_COLOR_YELLOW = 5
-FINDER_COLOR_RED = 6
-FINDER_COLOR_ORANGE = 7
-
-_MIN_FINDER_COLOR = 0
-_MAX_FINDER_COLOR = 7
-
-_VALID_COLORIDS = "01234567"
-_MAX_FINDERCOMMENT = 750  # determined through trial & error with Finder
-_MAX_WHEREFROM = (
-    1024  # just picked something....todo: need to figure out what max length is
-)
+__all__ = [
+    "FINDER_COLOR_NONE",
+    "FINDER_COLOR_GRAY",
+    "FINDER_COLOR_GREEN",
+    "FINDER_COLOR_PURPLE",
+    "FINDER_COLOR_BLUE",
+    "FINDER_COLOR_YELLOW",
+    "FINDER_COLOR_RED",
+    "FINDER_COLOR_ORANGE",
+    "_MIN_FINDER_COLOR",
+    "_MAX_FINDER_COLOR",
+    "_COLORNAMES",
+    "_COLORNAMES_LOWER",
+    "_COLORIDS",
+]
 
-# offset of tag color in com.apple.FinderInfo xattr
-# tag color is 3 bits
-_kCOLOR_OFFSET = 76
 
-# offset of stationary pad bit in com.apple.FinderInfo xattr
-_kSTATIONARYPAD_OFFSET = 68
 
-# _TAGS = "com.apple.metadata:_kMDItemUserTags"
-# _FINDER_COMMENT = "com.apple.metadata:kMDItemFinderComment"
-# _WHERE_FROM = "com.apple.metadata:kMDItemWhereFroms"
-# _DOWNLOAD_DATE = "com.apple.metadata:kMDItemDownloadedDate"
+# _VALID_COLORIDS = "01234567"
+# _MAX_FINDERCOMMENT = 750  # determined through trial & error with Finder
+# _MAX_WHEREFROM = (
+#     1024  # just picked something....todo: need to figure out what max length is
+# )
 
 
-FinderInfo = "com.apple.FinderInfo"
-_kMDItemUserTags = "com.apple.metadata:_kMDItemUserTags"
-kMDItemAuthors = "com.apple.metadata:kMDItemAuthors"
-kMDItemComment = "com.apple.metadata:kMDItemComment"
-kMDItemCopyright = "com.apple.metadata:kMDItemCopyright"
-kMDItemCreator = "com.apple.metadata:kMDItemCreator"
-kMDItemDescription = "com.apple.metadata:kMDItemDescription"
-kMDItemDownloadedDate = "com.apple.metadata:kMDItemDownloadedDate"
-kMDItemDueDate = "com.apple.metadata:kMDItemDueDate"
-kMDItemFSIsStationery = "com.apple.metadata:kMDItemFSIsStationery"
-kMDItemFinderComment = "com.apple.metadata:kMDItemFinderComment"
-kMDItemHeadline = "com.apple.metadata:kMDItemHeadline"
-kMDItemKeywords = "com.apple.metadata:kMDItemKeywords"
-kMDItemParticipants = "com.apple.metadata:kMDItemParticipants"
-kMDItemProjects = "com.apple.metadata:kMDItemProjects"
-kMDItemStarRating = "com.apple.metadata:kMDItemStarRating"
-kMDItemSubject = "com.apple.metadata:kMDItemSubject"
-kMDItemTitle = "com.apple.metadata:kMDItemTitle"
-kMDItemUserTags = "com.apple.metadata:_kMDItemUserTags"
-kMDItemVersion = "com.apple.metadata:kMDItemVersion"
-kMDItemWhereFroms = "com.apple.metadata:kMDItemWhereFroms"
-OSXPhotosDetectedText = "osxphotos.metadata:detected_text"
+# FinderInfo = "com.apple.FinderInfo"
+# _kMDItemUserTags = "com.apple.metadata:_kMDItemUserTags"
+# OSXPhotosDetectedText = "osxphotos.metadata:detected_text"
 
 # Special handling for Finder comments
 _FINDER_COMMENT_NAMES = [
@@ -95,31 +82,10 @@
     "com.apple.metadata:kMDItemFinderComment",
 ]
 _TAGS_NAMES = ["tags", "_kMDItemUserTags", "com.apple.metadata:_kMDItemUserTags"]
-_FINDERINFO_NAMES = ["finderinfo", "com.apple.FinderInfo"]
 
-_BACKUP_FILENAME = ".osxmetadata.json"
 
-__all__ = [
-    "FinderInfo",
-    "_kMDItemUserTags",
-    "kMDItemAuthors",
-    "kMDItemComment",
-    "kMDItemCopyright",
-    "kMDItemCreator",
-    "kMDItemDescription",
-    "kMDItemDownloadedDate",
-    "kMDItemDueDate",
-    "kMDItemFSIsStationery",
-    "kMDItemFinderComment",
-    "kMDItemHeadline",
-    "kMDItemKeywords",
-    "kMDItemParticipants",
-    "kMDItemProjects",
-    "kMDItemStarRating",
-    "kMDItemSubject",
-    "kMDItemTitle",
-    "kMDItemUserTags",
-    "kMDItemVersion",
-    "kMDItemWhereFroms",
-    "OSXPhotosDetectedText",
-]
+# __all__ = [
+#     "FinderInfo",
+#     "_kMDItemUserTags",
+#     "OSXPhotosDetectedText",
+# ]
diff --git a/osxmetadata/datetime_utils.py b/osxmetadata/datetime_utils.py
index e560847..e7b086d 100644
--- a/osxmetadata/datetime_utils.py
+++ b/osxmetadata/datetime_utils.py
@@ -1,14 +1,33 @@
-""" datetime.datetime helper functions for converting to/from UTC """
+""" datetime.datetime helper functions for converting to/from UTC and other datetime manipulations"""
+
+# source: https://github.com/RhetTbull/datetime-utils
+
+__version__ = "2022.04.30"
 
 import datetime
 
+# TODO: probably shouldn't use replace here, see this:
+# https://stackoverflow.com/questions/13994594/how-to-add-timezone-into-a-naive-datetime-instance-in-python/13994611#13994611
+
+__all__ = [
+    "datetime_has_tz",
+    "datetime_naive_to_local",
+    "datetime_naive_to_utc",
+    "datetime_remove_tz",
+    "datetime_to_new_tz",
+    "datetime_tz_to_utc",
+    "datetime_utc_to_local",
+    "get_local_tz",
+    "utc_offset_seconds",
+]
+
+# TODO: look at https://github.com/regebro/tzlocal for more robust implementation
+def get_local_tz(dt: datetime.datetime) -> datetime.tzinfo:
+    """Return local timezone as datetime.timezone tzinfo for dt
 
-def get_local_tz(dt):
-    """ Return local timezone as datetime.timezone tzinfo for dt
-    
     Args:
         dt: datetime.datetime
-    
+
     Returns:
         local timezone for dt as datetime.timezone
 
@@ -21,15 +40,15 @@ def get_local_tz(dt):
         raise ValueError("dt must be naive datetime.datetime object")
 
 
-def datetime_has_tz(dt):
-    """ Return True if datetime dt has tzinfo else False
+def datetime_has_tz(dt: datetime.datetime) -> bool:
+    """Return True if datetime dt has tzinfo else False
 
     Args:
         dt: datetime.datetime
-    
+
     Returns:
         True if dt is timezone aware, else False
-        
+
     Raises:
         TypeError if dt is not a datetime.datetime object
     """
@@ -40,16 +59,16 @@ def datetime_has_tz(dt):
     return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
 
 
-def datetime_tz_to_utc(dt):
-    """ Convert datetime.datetime object with timezone to UTC timezone 
+def datetime_tz_to_utc(dt: datetime.datetime) -> datetime.datetime:
+    """Convert datetime.datetime object with timezone to UTC timezone
 
     Args:
         dt: datetime.datetime object
 
     Returns:
         datetime.datetime in UTC timezone
-        
-    Raises: 
+
+    Raises:
         TypeError if dt is not datetime.datetime object
         ValueError if dt does not have timeone information
     """
@@ -60,18 +79,18 @@ def datetime_tz_to_utc(dt):
     if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None:
         return dt.replace(tzinfo=dt.tzinfo).astimezone(tz=datetime.timezone.utc)
     else:
-        raise ValueError(f"dt does not have timezone info")
+        raise ValueError("dt does not have timezone info")
 
 
-def datetime_remove_tz(dt):
-    """ Remove timezone from a datetime.datetime object
+def datetime_remove_tz(dt: datetime.datetime) -> datetime.datetime:
+    """Remove timezone from a datetime.datetime object
 
     Args:
         dt: datetime.datetime object with tzinfo
-    
+
     Returns:
-        dt without any timezone info (naive datetime object) 
-        
+        dt without any timezone info (naive datetime object)
+
     Raises:
         TypeError if dt is not a datetime.datetime object
     """
@@ -82,16 +101,16 @@ def datetime_remove_tz(dt):
     return dt.replace(tzinfo=None)
 
 
-def datetime_naive_to_utc(dt):
-    """ Convert naive (timezone unaware) datetime.datetime
+def datetime_naive_to_utc(dt: datetime.datetime) -> datetime.datetime:
+    """Convert naive (timezone unaware) datetime.datetime
         to aware timezone in UTC timezone
 
     Args:
         dt: datetime.datetime without timezone
-    
+
     Returns:
         datetime.datetime with UTC timezone
-        
+
     Raises:
         TypeError if dt is not a datetime.datetime object
         ValueError if dt is not a naive/timezone unaware object
@@ -110,16 +129,16 @@ def datetime_naive_to_utc(dt):
     return dt.replace(tzinfo=datetime.timezone.utc)
 
 
-def datetime_naive_to_local(dt):
-    """ Convert naive (timezone unaware) datetime.datetime
+def datetime_naive_to_local(dt: datetime.datetime) -> datetime.datetime:
+    """Convert naive (timezone unaware) datetime.datetime
         to aware timezone in local timezone
 
     Args:
         dt: datetime.datetime without timezone
-    
+
     Returns:
         datetime.datetime with local timezone
-        
+
     Raises:
         TypeError if dt is not a datetime.datetime object
         ValueError if dt is not a naive/timezone unaware object
@@ -132,14 +151,14 @@ def datetime_naive_to_local(dt):
         # has timezone info
         raise ValueError(
             "dt must be naive/timezone unaware: "
-            f"{dt} has tzinfo {dt.tzinfo} and offset {dt.tizinfo.utcoffset(dt)}"
+            f"{dt} has tzinfo {dt.tzinfo} and offset {dt.tzinfo.utcoffset(dt)}"
         )
 
     return dt.replace(tzinfo=get_local_tz(dt))
 
 
-def datetime_utc_to_local(dt):
-    """ Convert datetime.datetime object in UTC timezone to local timezone 
+def datetime_utc_to_local(dt: datetime.datetime) -> datetime.datetime:
+    """Convert datetime.datetime object in UTC timezone to local timezone
 
     Args:
         dt: datetime.datetime object
@@ -159,3 +178,32 @@ def datetime_utc_to_local(dt):
         raise ValueError(f"{dt} must be in UTC timezone: timezone = {dt.tzinfo}")
 
     return dt.replace(tzinfo=datetime.timezone.utc).astimezone(tz=None)
+
+
+def datetime_to_new_tz(dt: datetime.datetime, offset) -> datetime.datetime:
+    """Convert datetime.datetime object from current timezone to new timezone with offset of seconds from UTC"""
+    if not datetime_has_tz(dt):
+        raise ValueError("dt must be timezone aware")
+
+    time_delta = datetime.timedelta(seconds=offset)
+    tz = datetime.timezone(time_delta)
+    return dt.astimezone(tz=tz)
+
+
+def utc_offset_seconds(dt: datetime.datetime) -> int:
+    """Return offset in seconds from UTC for timezone aware datetime.datetime object
+
+    Args:
+        dt: datetime.datetime object
+
+    Returns:
+        offset in seconds from UTC
+
+    Raises:
+        ValueError if dt does not have timezone information
+    """
+
+    if dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None:
+        return dt.tzinfo.utcoffset(dt).total_seconds()
+    else:
+        raise ValueError("dt does not have timezone info")
diff --git a/osxmetadata/debug.py b/osxmetadata/debug.py
index de5f871..1f08f9f 100644
--- a/osxmetadata/debug.py
+++ b/osxmetadata/debug.py
@@ -13,7 +13,7 @@
 
 def _get_logger():
     """Used only for testing
-    
+
     Returns:
         logging.Logger object -- logging.Logger object for osxmetadata
     """
@@ -21,7 +21,7 @@ def _get_logger():
 
 
 def _set_debug(debug):
-    """ Enable or disable debug logging """
+    """Enable or disable debug logging"""
     global _DEBUG
     _DEBUG = debug
     if debug:
@@ -31,5 +31,5 @@ def _set_debug(debug):
 
 
 def _debug():
-    """ returns True if debugging turned on (via _set_debug), otherwise, false """
+    """returns True if debugging turned on (via _set_debug), otherwise, false"""
     return _DEBUG
diff --git a/osxmetadata/finder_comment.py b/osxmetadata/finder_comment.py
new file mode 100644
index 0000000..1b56ac2
--- /dev/null
+++ b/osxmetadata/finder_comment.py
@@ -0,0 +1,74 @@
+"""Set Finder comments on a file.
+
+Finder comments can be read by reading the kMDItemFinderComment attribute 
+with MDItemCopyAttribute() but they cannot be set with MDItemSetAttribute() so
+this script uses Scripting Bridge to get the Finder to set the comment.
+
+It's a bit of a hack but it works and will have to suffice until I can find whatever
+undocumented API Finder actually uses to set the comment.
+"""
+
+import objc
+import xattr
+from Foundation import NSURL
+from ScriptingBridge import SBApplication
+
+kMDItemFinderComment = "kMDItemFinderComment"
+
+__all__ = [
+    "clear_finder_comment",
+    "kMDItemFinderComment",
+    "set_finder_comment",
+    "set_or_remove_finder_comment",
+]
+
+# _scpt_clear_finder_comment = applescript.AppleScript(
+#     """
+#         on run {path}
+#             tell application "Finder" to set comment of (POSIX file path as alias) to missing value
+#         end run
+#     """
+# )
+
+
+def set_finder_comment(url: NSURL, comment: str):
+    """Set Finder comment for file at url"""
+
+    with objc.autorelease_pool():
+        finder_app = SBApplication.applicationWithBundleIdentifier_("com.apple.finder")
+        if not finder_app:
+            raise OSError("Could not instantiate scripting bridge to Finder")
+
+        item = finder_app.items().objectAtLocation_(url)
+        item.setComment_(comment)
+
+
+def set_or_remove_finder_comment(url: NSURL, xattr_: xattr.xattr, comment: str):
+    """Set Finder comment for file at url
+
+    If comment is None, remove the comment
+    """
+    if comment:
+        set_finder_comment(url, comment)
+    else:
+        # Note: this does not exactly match the behavior of the Finder
+        # When removing a comment in Finder, a subsequent read of kMDItemFinderComment
+        # returns None (null in objc) but with this code, reading kMDItemFinderComment returns
+        # an empty string; I've not figured out how to mirror the Finder behavior
+        # attempting to set or remove kMDItemFinderComment directly has no effect
+        # The Finder does remove the extended attribute com.apple.metadata:kMDItemFinderComment
+        # so that is what this code does
+        set_finder_comment(url, "")
+        xattr_.remove("com.apple.metadata:kMDItemFinderComment")
+
+
+# def clear_finder_comment(path: str, xattr_: xattr.xattr):
+#     """Clear Finder comment for file at path"""
+
+#     _scpt_clear_finder_comment.run(path)
+
+#     # remove attribute from xattr list
+#     # if Finder comment value was None or "", remove the attribute
+#     # this mirrors what Finder does when you clear the comment
+#     # but the AppleScript/ScriptingBridge interface does not do this
+#     xattr_.remove("com.apple.metadata:kMDItemFinderComment")
diff --git a/osxmetadata/finder_info.py b/osxmetadata/finder_info.py
new file mode 100644
index 0000000..9f67719
--- /dev/null
+++ b/osxmetadata/finder_info.py
@@ -0,0 +1,159 @@
+"""Read/write metadata in the com.apple.FinderInfo extended attribute such as Stationery Pad
+
+FinderInfo is a 256-bit bit field
+
+For more information see: 
+https://eclecticlight.co/2017/12/19/xattr-com-apple-finderinfo-information-for-the-finder/
+"""
+
+import bitstring
+import xattr
+
+from .constants import (
+    _COLORNAMES_LOWER,
+    _MAX_FINDER_COLOR,
+    _MIN_FINDER_COLOR,
+    FINDER_COLOR_NONE,
+)
+
+_kFinderInfo = "com.apple.FinderInfo"
+_kFinderStationeryPad = "stationerypad"
+_kFinderColor = "findercolor"
+
+# offset of tag color in com.apple.FinderInfo xattr
+# tag color is 3 bits
+_kCOLOR_OFFSET = 76
+
+# offset of stationery pad bit in com.apple.FinderInfo xattr
+_kSTATIONERYPAD_OFFSET = 68
+
+__all__ = [
+    "_kFinderColor",
+    "_kFinderInfo",
+    "_kFinderStationeryPad",
+    "get_finderinfo_bytes",
+    "get_finderinfo_color",
+    "get_finderinfo_stationerypad",
+    "set_finderinfo_bytes",
+    "set_finderinfo_color",
+    "set_finderinfo_stationerypad",
+    "str_to_finder_color",
+]
+
+
+def _get_finderinfo_bits(xattr_: xattr.xattr) -> bitstring.BitArray:
+    """Get FinderInfo bits"""
+
+    try:
+        finderinfo = xattr_.get(_kFinderInfo)
+        finderbits = bitstring.BitArray(finderinfo)
+    except OSError:
+        # if the extended attribute is missing, the error will be
+        # OSError: [Errno 93] Attribute not found
+        # in this case, create a new bitstring with all bits set to 0
+        finderbits = bitstring.BitArray(uint=0, length=256)
+    return finderbits
+
+
+def _set_finderinfo_bits(xattr_: xattr.xattr, bits: bitstring.BitArray):
+    """Set FinderInfo bits"""
+    xattr_.set("com.apple.FinderInfo", bits.bytes)
+
+
+def get_finderinfo_bytes(xattr_: xattr.xattr) -> bytes:
+    """Get FinderInfo bytes"""
+    finderbits = _get_finderinfo_bits(xattr_)
+    return finderbits.bytes
+
+
+def set_finderinfo_bytes(xattr_: xattr.xattr, value: bytes):
+    """Set FinderInfo bytes"""
+    finderbits = bitstring.BitArray(value)
+    _set_finderinfo_bits(xattr_, finderbits)
+
+
+def get_finderinfo_stationerypad(xattr_: xattr.xattr) -> bool:
+    """get the Stationery Pad bit from com.apple.FinderInfo
+    returns: True if Stationery Pad is set, False if not set"""
+
+    try:
+        finderbits = _get_finderinfo_bits(xattr_)
+        bit = finderbits.bin[_kSTATIONERYPAD_OFFSET]
+        return bool(int(bit))
+    except Exception as e:
+        return False
+
+
+def set_finderinfo_stationerypad(xattr_: xattr.xattr, value: bool):
+    """set the Stationery Pad flag of com.apple.FinderInfo"""
+
+    value = 1 if value else 0
+
+    # stationery pad is encoded as a single bit
+    bit = bitstring.BitArray(uint=value, length=1)
+
+    # set stationery bits
+    finderbits = _get_finderinfo_bits(xattr_)
+    finderbits.overwrite(bit, _kSTATIONERYPAD_OFFSET)
+    _set_finderinfo_bits(xattr_, finderbits)
+
+
+def get_finderinfo_color(xattr_: xattr.xattr) -> int:
+    """get the tag color of a file set via com.apple.FinderInfo
+    Returns: color id as int, 0 if no color or FinderInfo not set"""
+
+    try:
+        finderbits = _get_finderinfo_bits(xattr_)
+        bits = finderbits[_kCOLOR_OFFSET : _kCOLOR_OFFSET + 3]
+        return bits.uint
+    except OSError as e:
+        # if the extended attribute is missing, the error will be OSError: [Errno 93] Attribute not found
+        return 0
+
+
+def set_finderinfo_color(xattr_: xattr.xattr, colorid: int):
+    """set tag color of filename to colorid
+
+    Args:
+        colorid: ID of tag color in range 0 to 7
+    """
+
+    if colorid is None:
+        colorid = FINDER_COLOR_NONE
+
+    if not _MIN_FINDER_COLOR <= colorid <= _MAX_FINDER_COLOR:
+        raise ValueError(f"colorid out of range {colorid}")
+
+    # color is encoded as 3 binary bits
+    bits = bitstring.BitArray(uint=colorid, length=3)
+
+    # set color bits
+    finderbits = _get_finderinfo_bits(xattr_)
+    finderbits.overwrite(bits, _kCOLOR_OFFSET)
+    _set_finderinfo_bits(xattr_, finderbits)
+
+
+def str_to_finder_color(color: str) -> int:
+    """Convert a string to a Finder color ID
+
+    Args:
+        color: string name of color or integer color ID
+
+    Returns:
+        int Finder color ID
+
+    Raises:
+        ValueError: if color is not a valid color name
+    """
+
+    color = color.lower()
+    try:
+        colorid = int(color)
+        if not _MIN_FINDER_COLOR <= colorid <= _MAX_FINDER_COLOR:
+            raise ValueError(f"colorid out of range {colorid}")
+        return colorid
+    except ValueError as e:
+        try:
+            return _COLORNAMES_LOWER[color]
+        except KeyError as e:
+            raise ValueError(f"invalid color name {color}") from e
diff --git a/osxmetadata/finder_tags.py b/osxmetadata/finder_tags.py
new file mode 100644
index 0000000..7e11d44
--- /dev/null
+++ b/osxmetadata/finder_tags.py
@@ -0,0 +1,131 @@
+"""Read _kMDItemUserTags (Finder tags) from extended attributes on a file.
+
+Finder tags can be read/written with [NSURL getResourceValue:forKey:error:] and 
+[NSURL setResourceValue:forKey:error:] using key NSURLTagNamesKey for tags and
+NSURLLabelNumberKey for color (label) number. However, when a tag has a custom color,
+only the tag name is returned by NSURLTagNamesKey and NSURLLabelNumberKey does not 
+return the color of the custom tag. To get the color of a custom tag, you must read
+the extended attribute _kMDItemUserTags. This module reads the extended attribute.
+"""
+
+import plistlib
+import typing as t
+from collections import namedtuple
+
+import xattr
+from Foundation import NSURL
+
+from .constants import _COLORIDS, _COLORNAMES_LOWER, _MAX_FINDER_COLOR
+from .nsurl_metadata import set_nsurl_metadata
+
+NSURLTagNamesKey = "NSURLTagNamesKey"
+
+Tag = namedtuple("Tag", ["name", "color"])
+
+_kMDItemUserTags = "_kMDItemUserTags"
+_kMDItemUserTagsXattr = "com.apple.metadata:_kMDItemUserTags"
+
+__all__ = ["Tag", "_kMDItemUserTags", "get_finder_tags", "set_finder_tags"]
+
+
+def split_tag_names_colors(tag_values: t.List[str]) -> t.List[Tag]:
+    """Split tag name and color from tag value
+
+    Args:
+        tag_values: list of tag values
+
+    Returns:
+        list of Tag namedtuples
+    """
+    # tags are in format name\ncolor where color is an int 0-7
+    # for example "Blue\n4"
+    # I've seen custom tags that have more than one color in the color field though this shouldn't happen
+    # so we'll just take the first color
+    tags = []
+    for tag_value in tag_values:
+        tag_data = tag_value.split("\n")
+        tag_name = tag_data[0]
+        tag_color = int(tag_data[1]) if len(tag_data) > 1 else 0
+        tags.append(Tag(tag_name, tag_color))
+    return tags
+
+
+def get_finder_tags(xattr_obj: xattr.xattr) -> t.List[Tag]:
+    """Get Finder tags from extended attribute _kMDItemUserTags
+
+    Args:
+        xattr_obj: xattr.xattr object for file
+
+    Returns:
+        list of Finder tags
+    """
+    try:
+        tag_data = xattr_obj[_kMDItemUserTagsXattr]
+        # load the binary plist values
+        tag_values = plistlib.loads(tag_data)
+        tags = split_tag_names_colors(tag_values)
+    except KeyError:
+        tags = []
+    return tags
+
+
+def set_finder_tags(url: NSURL, tags: t.Optional[t.List[Tag]]):
+    """Set Finder tags in extended attribute _kMDItemUserTags
+
+    Args:
+        url: NSURL for file
+        tags: list of Finder tags
+    """
+    if tags is not None and not isinstance(tags, (list, tuple)):
+        raise TypeError("tags must be a list or tuple of Tag namedtuples or None")
+    if tags is not None and not all(isinstance(tag, Tag) for tag in tags):
+        raise TypeError("tags items must be Tag namedtuples")
+
+    # convert to list of strings in format name\ncolor
+    tag_values = [f"{tag.name}\n{tag.color}" for tag in tags] if tags else []
+    set_nsurl_metadata(url, NSURLTagNamesKey, tag_values)
+
+
+def tag_factory(tag_str: str) -> Tag:
+    """Creates a Tag namedtuple from a string in format 'name,color'
+
+    Args:
+        tag_str: (str) tag value in format: 'name,color' where name is the name of the tag and color specifies the color ID;
+
+    Returns:
+        Tag namedtuple
+
+    Notes:
+        The comma and color are optional; if not provided Tag will use color assigned in Finder or FINDER_COLOR_NONE if no color
+            e.g. tag_factory("foo") -> Tag("foo", 0)
+                 tag_factory("test,6") -> Tag("test", 6)
+    """
+
+    values = tag_str.split(",")
+    if len(values) > 2:
+        raise ValueError(f"More than one value found after comma: {tag_str}")
+    name = values[0]
+    name = name.lstrip().rstrip()
+
+    if len(values) == 1:
+        # got name only, check to see if name is also a color and if so assign it
+        # TODO: this might not be right/desired in different locale settings
+        # but I'm not sure yet how to get the non-English color names used by Finder
+        try:
+            colorid = _COLORNAMES_LOWER[name.lower()]
+            return Tag(_COLORIDS[colorid], colorid)
+        except KeyError:
+            return Tag(name, 0)
+    elif len(values) == 2:
+        # got a color, is it a name or number
+        color = values[1].lstrip().rstrip().lower()
+        try:
+            colorid = _COLORNAMES_LOWER[color]
+        except KeyError as e:
+            colorid = int(color)
+            if colorid not in range(_MAX_FINDER_COLOR + 1):
+                raise ValueError(
+                    f"color must be in range 0 to {_MAX_FINDER_COLOR} inclusive: {colorid}"
+                ) from e
+
+        return Tag(name, colorid)
diff --git a/osxmetadata/findertags.py b/osxmetadata/findertags.py
deleted file mode 100644
index 0e9145b..0000000
--- a/osxmetadata/findertags.py
+++ /dev/null
@@ -1,194 +0,0 @@
-""" Functions for handling Finder tags and colors """
-
-import logging
-import pathlib
-import plistlib
-
-from osxmetadata.constants import (
-    _COLORIDS,
-    _COLORNAMES,
-    _COLORNAMES_LOWER,
-    _MAX_FINDER_COLOR,
-    _MIN_FINDER_COLOR,
-    FINDER_COLOR_NONE,
-)
-
-
-def get_tag_color_name(colorid):
-    """Return name of the Finder color based on ID"""
-    # TODO: need to figure out how to do this in locale/language name
-    try:
-        colorname = _COLORIDS[colorid]
-    except KeyError:
-        raise ValueError(f"Invalid colorid: {colorid}")
-    return colorname
-
-
-def tag_factory(tag_str):
-    """creates a Tag object
-    tag_str: (str) tag value in format: 'name,color'
-    where name is the name of the tag and color specifies the color ID
-    the comma and color are optional;
-    if not provided Tag will use color assigned in Finder or FINDER_COLOR_NONE if no color
-    e.g. tag_factory("foo") -> Tag("foo")
-         tag_factory("test,6") -> Tag("test", 6)"""
-
-    if not isinstance(tag_str, str):
-        raise TypeError(f"tag_str must be str not {type(tag_str)}")
-
-    values = tag_str.split(",")
-    name = values[0]
-    name = name.lstrip().rstrip()
-    if len(values) > 2:
-        raise ValueError(f"More than one value found after comma: {tag_str}")
-    elif len(values) == 1:
-        # got name only, check to see if name is also a color and if so assign it
-        # TODO: this might not be right/desired in different locale settings
-        # but I'm not sure yet how to get the non-English color names used by Finder
-        try:
-            colorid = _COLORNAMES_LOWER[name.lower()]
-            return Tag(_COLORIDS[colorid], colorid)
-        except KeyError:
-            return Tag(name)
-    elif len(values) == 2:
-        # got a color, is it a name or number
-        color = values[1].lstrip().rstrip().lower()
-        try:
-            colorid = _COLORNAMES_LOWER[color]
-        except KeyError:
-            colorid = int(color)
-            if colorid not in range(_MAX_FINDER_COLOR + 1):
-                raise ValueError(
-                    f"color must be in range 0 to {_MAX_FINDER_COLOR} inclusive: {colorid}"
-                )
-        return Tag(name, colorid)
-
-
-def get_finder_tags():
-    """parses com.apple.finder.plist to get list of Finder tags and associated colors
-    returns: dict in form {tag_name: color_id}"""
-
-    # TODO: Is it possible to get a race condition where Finder is writing at same time
-    # this code is trying to open the plist file?  If so, what happens?
-    plist_files = [
-        pathlib.Path(
-            str(pathlib.Path.home())
-            + "/Library/SyncedPreferences/com.apple.finder.plist"
-        ),
-        # On Monterey the SyncedPreferences/com.apple.finder.plist does not exist
-        # it might be in this one but I can't verify until I can get access to Monterey
-        # pathlib.Path(
-        # str(pathlib.Path.home()) + "/Library/Preferences/com.apple.finder.plist"
-        # ),
-    ]
-    tags = {}
-
-    for plist_file in plist_files:
-        try:
-            with open(plist_file, "rb") as fp:
-                pl = plistlib.load(fp)
-                try:
-                    finder_tags = pl["values"]["FinderTagDict"]["value"]["FinderTags"]
-                    for tag in finder_tags:
-                        try:
-                            name = tag["n"]
-                            color = tag["l"]
-                        except KeyError:
-                            # color will not be present if no color
-                            color = 0
-                        tags[name] = color
-                    break
-                except Exception as e:
-                    pass
-        except Exception as e:
-            pass
-
-    return tags
-
-
-class Tag:
-    """Information about Finder tags/keywords and associated color labels"""
-
-    def __init__(self, name, *color):
-        """Create a new Tag object
-        name: (str) name of the tag
-        color: (int) color ID of the tag, optional
-        If color name is not provided, com.apple.finder.plist will be parsed to see if
-        a color is already associated with 'name' and if so, that color will be assigned
-        If a color is provided and the Finder's color is different, a warning will be logged
-        but the new color ID will be used"""
-
-        # input validation
-        # todo: add length validation on name? Need to determine what max tag length is for Finder
-        # and if there are any illegal characters
-        if type(name) is not str:
-            raise TypeError(f"name must be a str not {type(name)}")
-        if len(color):
-            if len(color) != 1:
-                raise ValueError(
-                    f"only 1 argument expected for color but {len(color)} provided"
-                )
-            if type(color[0]) is not int:
-                raise TypeError(f"color must be an int not {type(color[0])}")
-            if color[0] not in range(_MAX_FINDER_COLOR + 1):
-                raise ValueError(
-                    f"color must be in range 0 to {_MAX_FINDER_COLOR} inclusive: {color[0]}"
-                )
-
-        self._finder_tags = get_finder_tags()
-        self._name = name
-
-        # if no color provided use color from Finder if it exists
-        if len(color):
-            # color was provided
-            if name in self._finder_tags and color[0] != self._finder_tags[name]:
-                logging.warning(
-                    f"assigning color {color[0]} to tag {name} but "
-                    f"color {self._finder_tags[name]} already assigned in Finder"
-                )
-            self._colorid = color[0]
-        elif name in self._finder_tags:
-            # use color Finder already using
-            self._colorid = self._finder_tags[name]
-        else:
-            self._colorid = FINDER_COLOR_NONE  # set to no color
-
-    @property
-    def name(self):
-        return self._name
-
-    @property
-    def color(self):
-        return self._colorid
-
-    def _format(self):
-        """formats a tag for writing to _kMDItemUserTags"""
-        return f"{self.name}\n{self.color}"
-
-    def __str__(self):
-        return f"{self.name}: {_COLORIDS[self.color]}"
-
-    def __repr__(self):
-        return f"Tag('{self.name}', {self.color})"
-
-    def __eq__(self, other):
-        return (
-            isinstance(other, Tag)
-            and self.name == other.name
-            and self.color == other.color
-        )
-
-    def __lt__(self, other):
-        if not isinstance(other, Tag):
-            raise TypeError(f"< not supported between type Tag and type {type(other)}")
-
-        return self.name < other.name
-
-    def __gt__(self, other):
-        if not isinstance(other, Tag):
-            raise TypeError(f"< not supported between type Tag and type {type(other)}")
-
-        return self.name > other.name
-
-    def __len__(self):
-        return len(self.name)
diff --git a/osxmetadata/mditem.py b/osxmetadata/mditem.py
new file mode 100644
index 0000000..e196d55
--- /dev/null
+++ b/osxmetadata/mditem.py
@@ -0,0 +1,235 @@
+"""Get/Set macOS metadata using MDItem* functions from CoreServices
+
+Background: Apple provides MDItemCopyAttribute to get metadata from files:
+https://developer.apple.com/documentation/coreservices/1427080-mditemcopyattribute?language=objc
+
+but does not provide a documented way to set file metadata.
+
+This package uses the undocumented functions MDItemSetAttribute, MDItemRemoveAttribute to do so.
+"""
+
+import datetime
+import typing as t
+
+import CoreFoundation
+import CoreServices
+import Foundation
+import objc
+
+from .attribute_data import (
+    MDIMPORTER_ATTRIBUTE_DATA,
+    MDITEM_ATTRIBUTE_DATA,
+    MDITEM_ATTRIBUTE_SHORT_NAMES,
+)
+
+__all__ = [
+    "get_mditem_metadata",
+    "remove_mditem_metadata",
+    "set_mditem_metadata",
+    "set_or_remove_mditem_metadata",
+    "str_to_mditem_type",
+]
+
+# Absolute time in macOS is measured in seconds relative to the absolute reference date of Jan 1 2001 00:00:00 GMT.
+# Reference: https://developer.apple.com/documentation/corefoundation/1542812-cfdategetabsolutetime?language=objc
+MACOS_TIME_DELTA = (
+    datetime.datetime(2001, 1, 1, 0, 0) - datetime.datetime(1970, 1, 1, 0, 0)
+).total_seconds()
+
+MDItemValueType = t.Union[bool, str, float, t.List[str], datetime.datetime]
+
+# load undocumented function MDItemSetAttribute
+# signature: Boolean MDItemSetAttribute(MDItemRef, CFStringRef name, CFTypeRef attr);
+# references:
+# https://github.com/WebKit/WebKit/blob/5b8ad34f804c64c944ebe43c02aba88482c2afa8/Source/WTF/wtf/mac/FileSystemMac.MDItemSetAttribute
+# https://pyobjc.readthedocs.io/en/latest/metadata/manual.html#objc.loadBundleFunctions
+# signature of B@@@ translates to returns BOOL, takes 3 arguments, all objects
+# In reality, the function takes references (pointers) to the objects, but pyobjc barfs if
+# the function signature is specified using pointers.
+# Specifying generic objects allows the bridge to convert the Python objects to the
+# appropriate Objective C object pointers.
+
+
+def MDItemSetAttribute(mditem, name, attr):
+    """dummy function definition"""
+    ...
+
+
+def MDItemRemoveAttribute(mditem, name):
+    """ "dummy function definition"""
+    ...
+
+
+# This will load MDItemSetAttribute from the CoreServices framework into module globals
+objc.loadBundleFunctions(
+    CoreServices.__bundle__,
+    globals(),
+    [("MDItemSetAttribute", b"B@@@"), ("MDItemRemoveAttribute", b"B@@")],
+)
+
+
+def value_to_boolean(value: str) -> bool:
+    """Convert string to boolean"""
+    if value.lower() == "true":
+        return True
+    elif value.lower() == "false":
+        return False
+    elif value.isdigit():
+        return bool(int(value))
+    else:
+        raise ValueError(f"Invalid boolean value: {value}")
+
+
+def CFDate_to_datetime(cfdate: CoreFoundation.CFDateRef) -> datetime.datetime:
+    """Convert CFDate to datetime.datetime"""
+    return datetime.datetime.fromtimestamp(
+        CoreFoundation.CFDateGetAbsoluteTime(cfdate) + MACOS_TIME_DELTA
+    )
+
+
+def NSDate_to_datetime(nsdate: Foundation.NSDate) -> datetime.datetime:
+    """Convert NSDate to datetime.datetime"""
+    return datetime.datetime.fromtimestamp(nsdate.timeIntervalSince1970())
+
+
+def set_mditem_metadata(
+    mditem: CoreServices.MDItemRef,
+    attribute: str,
+    value: MDItemValueType,
+) -> bool:
+    """Set file metadata using undocumented function MDItemSetAttribute
+
+    file: path to file
+    attribute: metadata attribute to set
+    value: value to set attribute to; must match the type expected by the attribute (e.g. bool, str, List[str], float, datetime.datetime)
+
+    Returns True if successful, False otherwise.
+    """
+    if isinstance(value, list):
+        value = CoreFoundation.CFArrayCreate(
+            None, value, len(value), CoreFoundation.kCFTypeArrayCallBacks
+        )
+    elif isinstance(value, datetime.datetime):
+        value = CoreFoundation.CFDateCreate(None, value.timestamp() - MACOS_TIME_DELTA)
+    return MDItemSetAttribute(
+        mditem,
+        attribute,
+        value,
+    )
+
+
+def get_mditem_metadata(
+    mditem: CoreServices.MDItemRef, attribute: str
+) -> MDItemValueType:
+    """Get file metadata using MDItemCopyAttribute
+
+    mditem: MDItem object
+    attribute: metadata attribute to get
+
+    Returns value of attribute
+    """
+    value = CoreServices.MDItemCopyAttribute(mditem, attribute)
+    if value is None:
+        return None
+    if attribute in MDITEM_ATTRIBUTE_DATA:
+        attribute_data = MDITEM_ATTRIBUTE_DATA[attribute]
+    elif attribute in MDIMPORTER_ATTRIBUTE_DATA:
+        attribute_data = MDIMPORTER_ATTRIBUTE_DATA[attribute]
+    else:
+        raise ValueError(f"Unknown attribute: {attribute}")
+
+    # Some attributes don't have a documented type
+    attribute_type = attribute_data.get("python_type")
+    if attribute_type == "bool":
+        return bool(value)
+    elif attribute_type == "str":
+        return str(value)
+    elif attribute_type == "float":
+        return float(value)
+    elif attribute_type == "list":
+        return [str(x) for x in value]
+    elif attribute_type == "datetime.datetime":
+        return CFDate_to_datetime(value)
+    elif attribute_type == "list[datetime.datetime]":
+        return [CFDate_to_datetime(x) for x in value]
+    elif "__NSCFArray" in repr(type(value)):
+        # this is a hack but works for MDImporter attributes that don't have a documented type
+        return [str(x) for x in value]
+    elif "__NSTaggedDate" in repr(type(value)):
+        # this is a hack but works for MDImporter attributes that don't have a documented type
+        return NSDate_to_datetime(value)
+    else:
+        return value
+
+
+def remove_mditem_metadata(mditem: CoreServices.MDItemRef, attribute: str):
+    """Remove file metadata using undocumented function MDItemRemoveAttribute
+
+    mditem: MDItem object
+    attribute: metadata attribute to remove
+    """
+    MDItemRemoveAttribute(mditem, attribute)
+
+
+def set_or_remove_mditem_metadata(
+    mditem: CoreServices.MDItemRef,
+    attribute: str,
+    value: MDItemValueType,
+) -> bool:
+    """Set file metadata or remove metadata if value is None
+
+    file: path to file
+    attribute: metadata attribute to set
+    value: value to set attribute to; must match the type expected by the attribute (e.g. bool, str, List[str], float, datetime.datetime)
+
+    Returns True if successful, False otherwise.
+    """
+    if value is None:
+        remove_mditem_metadata(mditem, attribute)
+    else:
+        set_mditem_metadata(mditem, attribute, value)
+
+
+def str_to_mditem_type(attribute: str, value: str) -> MDItemValueType:
+    """Convert string to type expected by MDItem attribute;
+
+    Args:
+        attribute: name of attribute
+        value: string value to convert
+
+    Returns: value converted to type expected by set_mditem_metadata
+
+    Raises:
+        ValueError: if value cannot be converted to expected type
+
+    Notes:
+        For example, kMDItemDueDate expects a datetime.datetime so this function will convert
+        a string to a datetime.datetime using datetime.datetime.fromisoformat
+
+        Caller is responsible for knowing if attribute is a list-type attribute; this method returns
+        a single value, not a list for all attributes.
+    """
+    if attribute in MDITEM_ATTRIBUTE_DATA:
+        attribute_data = MDITEM_ATTRIBUTE_DATA[attribute]
+    elif attribute in MDITEM_ATTRIBUTE_SHORT_NAMES:
+        attribute_data = MDITEM_ATTRIBUTE_DATA[MDITEM_ATTRIBUTE_SHORT_NAMES[attribute]]
+    else:
+        raise ValueError(f"Unknown attribute: {value}")
+
+    attribute_type = attribute_data.get("python_type")
+    if attribute_type == "str":
+        return value
+    if attribute_type == "bool":
+        return bool(value)
+    elif attribute_type == "float":
+        return float(value)
+    elif attribute_type == "list":
+        # caller is responsible for knowing if attribute is a list-type attribute
+        return value
+    elif attribute_type == "datetime.datetime":
+        return datetime.datetime.fromisoformat(value)
+    elif attribute_type == "list[datetime.datetime]":
+        # caller is responsible for knowing if attribute is a list-type attribute
+        return datetime.datetime.fromisoformat(value)
+    else:
+        return value
diff --git a/osxmetadata/nsurl_metadata.py b/osxmetadata/nsurl_metadata.py
new file mode 100644
index 0000000..1290e20
--- /dev/null
+++ b/osxmetadata/nsurl_metadata.py
@@ -0,0 +1,41 @@
+"""Get and set macOS metadata keys for NSURL objects, for example, tags and labels"""
+
+import typing as t
+
+import CoreFoundation
+import objc
+from Foundation import NSURL
+
+
+__all__ = ["get_nsurl_metadata", "set_nsurl_metadata"]
+
+
+def get_nsurl_metadata(url: NSURL, key: str) -> t.Any:
+    """Get metadata attribute value
+
+    Args:
+        key: metadata attribute name, e.g. NSURLTagNamesKey
+    """
+    success, value, error = url.getResourceValue_forKey_error_(None, key, None)
+    if not success:
+        raise ValueError(f"Error getting metadata for {key}: {error}")
+    return value
+
+
+def set_nsurl_metadata(url: NSURL, key: str, value: t.Any):
+    """Set metadata attribute value
+
+    Args:
+        key: metadata attribute name, e.g. NSURLTagNamesKey
+        value: value to set attribute to; must match the type expected by the attribute (e.g. str or list)
+    """
+    with objc.autorelease_pool():
+        if isinstance(value, list):
+            value = CoreFoundation.CFArrayCreate(
+                None, value, len(value), CoreFoundation.kCFTypeArrayCallBacks
+            )
+        kv = CoreFoundation.NSDictionary.dictionaryWithObject_forKey_(value, key)
+        success, error = url.setResourceValues_error_(kv, None)
+        if not success:
+            raise OSError(f"Error setting tag {key} = {value} on {url}: {error}")
+        return True
diff --git a/osxmetadata/osxmetadata.py b/osxmetadata/osxmetadata.py
index 2b3b853..6ad16e2 100644
--- a/osxmetadata/osxmetadata.py
+++ b/osxmetadata/osxmetadata.py
@@ -1,577 +1,308 @@
 """ OSXMetaData class to read and write various Mac OS X metadata 
     such as tags/keywords and Finder comments from files """
 
-
 import base64
 import datetime
 import json
-import logging
-import os.path
 import pathlib
-import plistlib
-
-# plistlib creates constants at runtime which causes pylint to complain
-from plistlib import FMT_BINARY  # pylint: disable=E0611
+import typing as t
 
-import applescript
+import CoreServices
 import xattr
+from Foundation import NSURL
 
 from ._version import __version__
-from .attributes import ATTRIBUTES, Attribute, validate_attribute_value
-from .classes import _AttributeList, _AttributeOSXPhotosDetectedText, _AttributeTagsList
-from .constants import (
-    _FINDER_COMMENT_NAMES,
-    FINDER_COLOR_NONE,
-    FinderInfo,
-    _kMDItemUserTags,
+from .attribute_data import (
+    MDIMPORTER_ATTRIBUTE_DATA,
+    MDITEM_ATTRIBUTE_DATA,
+    MDITEM_ATTRIBUTE_SHORT_NAMES,
+    NSURL_RESOURCE_KEY_DATA,
 )
-from .datetime_utils import (
-    datetime_naive_to_utc,
-    datetime_remove_tz,
-    datetime_utc_to_local,
+from .finder_comment import kMDItemFinderComment, set_or_remove_finder_comment
+from .finder_info import (
+    _kFinderColor,
+    _kFinderInfo,
+    _kFinderStationeryPad,
+    get_finderinfo_bytes,
+    get_finderinfo_color,
+    get_finderinfo_stationerypad,
+    set_finderinfo_bytes,
+    set_finderinfo_color,
+    set_finderinfo_stationerypad,
 )
-from .debug import _debug, _get_logger, _set_debug
-from .findertags import Tag, get_tag_color_name
+from .finder_tags import _kMDItemUserTags, get_finder_tags, set_finder_tags
+from .mditem import MDItemValueType, get_mditem_metadata, set_or_remove_mditem_metadata
+from .nsurl_metadata import get_nsurl_metadata, set_nsurl_metadata
 
-# TODO: What to do about colors
-# TODO: check what happens if OSXMetaData.__init__ called with invalid file--should result in error but saw one case where it didn't
-# TODO: cleartags does not always clear colors--this is a new behavior, did Mac OS change something in implementation of colors?
+ALL_ATTRIBUTES = {
+    "finderinfo",
+    "tags",
+    *list(MDITEM_ATTRIBUTE_DATA.keys()),
+    *list(MDITEM_ATTRIBUTE_SHORT_NAMES.keys()),
+    *list(NSURL_RESOURCE_KEY_DATA.keys()),
+    *list(MDIMPORTER_ATTRIBUTE_DATA.keys()),
+    _kFinderColor,
+    _kFinderInfo,
+    _kFinderStationeryPad,
+    _kMDItemUserTags,
+}
+
+# Subset of attributes returned by asdict() and to_json() methods
+ASDICT_ATTRIBUTES = {
+    *list(MDITEM_ATTRIBUTE_DATA.keys()),
+    *list(MDIMPORTER_ATTRIBUTE_DATA.keys()),
+    _kFinderStationeryPad,
+    _kFinderColor,
+    _kMDItemUserTags,
+}
 
-# all attributes that are part of com.apple.FinderInfo
-_FINDERINFO_ATTRIBUTES = ["finderinfo", "findercolor", "stationarypad"]
+# TODO: Need a wipe() method to remove all metadata from a file
 
-# all attributes that use an Attribute class (defined in attributes.py)
-_ATTRIBUTE_CLASS_ATTRIBUTES = [
-    "tags",
-    "osxphotos_detected_text",
-    *_FINDERINFO_ATTRIBUTES,
-]
-
-# all attributes related to stationary pad
-_FINDERINFO_STATIONARYPAD_ATTRIBUTES = ["finderinfo", "stationarypad"]
-
-# all attributes related to Finder color
-_FINDERINFO_COLOR_ATTRIBUTES = ["finderinfo", "findercolor"]
-
-# all attributes that are included in finderinfo
-_FINDERINFO_SUB_ATTRIBUTES = ["findercolor", "stationarypad"]
-
-# AppleScript for manipulating Finder comments
-_scpt_set_finder_comment = applescript.AppleScript(
-    """
-            on run {path, fc}
-	            set thePath to path
-	            set theComment to fc
-	            tell application "Finder" to set comment of (POSIX file thePath as alias) to theComment
-            end run
-            """
-)
+class OSXMetaDataAttributeError(Exception):
+    """Raised when an attribute is not supported or attempting to set read-only attribute"""
 
-_scpt_clear_finder_comment = applescript.AppleScript(
-    """
-            on run {path}
-	            set thePath to path
-	            set theComment to missing value
-	            tell application "Finder" to set comment of (POSIX file thePath as alias) to theComment
-            end run
-            """
-)
+    pass
 
 
 class OSXMetaData:
     """Create an OSXMetaData object to access file metadata"""
 
-    __slots__ = [
-        "__init",
-        "_attrs",
-        "_fname",
-        "_posix_name",
-        "_tz_aware",
-        "authors",
-        "comment",
-        "copyright",
-        "creator",
-        "description",
-        "downloadeddate",
-        "duedate",
-        "findercolor",
-        "findercomment",
-        "finderinfo",
-        "headline",
-        "keywords",
-        "participants",
-        "projects",
-        "rating",
-        "stationary",
-        "stationarypad",
-        "tags",
-        "version",
-        "wherefroms",
-        "osxphotos_detected_text",
-    ]
-
-    def __init__(self, fname, tz_aware=False):
+    def __init__(self, fname: str):
         """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.datetime 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)
-
-        self._attrs = xattr.xattr(self._fname)
-
-        # create property classes for the multi-valued attributes
-        # tags get special handling due to color labels
-        # ATTRIBUTES contains both long and short names, want only the short names (attribute.name)
-        for name in {attribute.name for attribute in ATTRIBUTES.values()}:
-            attribute = ATTRIBUTES[name]
-            if attribute.class_ not in [str, float, int, bool, datetime.datetime]:
-                super().__setattr__(
-                    name, attribute.class_(attribute, self._attrs, self)
-                )
-
-        # Done with initialization
+            raise FileNotFoundError(f"file does not exist: {fname}")
+
+        self._posix_path = self._fname.resolve().as_posix()
+
+        # Create MDItemRef, NSURL, and xattr objects
+        # MDItemRef is used for most attributes
+        # NSURL and xattr are required for certain attributes like Finder tags
+        # Because many of the getter/setter functions require some combination of MDItemRef, NSURL, and xattr,
+        # they are created here and kept for the life of the object so that they don't have to be
+        # recreated for each attribute
+        # This does mean that if the file is moved or renamed, the object will still be pointing to the old file
+        # thus you should not rename or move a file while using an OSXMetaData object
+        self._mditem: CoreServices.MDItemRef = CoreServices.MDItemCreate(
+            None, self._posix_path
+        )
+        if not self._mditem:
+            raise OSError(f"Unable to create MDItem for file: {fname}")
+        self._url = NSURL.fileURLWithPath_(self._posix_path)
+        self._xattr = xattr.xattr(self._posix_path)
+
+        # Required so __setattr__ gets handled correctly during __init__
         self.__init = True
 
-    @property
-    def name(self):
-        """POSIX path of the file OSXMetaData is operating on"""
-        return self._fname.resolve().as_posix()
+    def get(self, attribute: str) -> MDItemValueType:
+        """Get metadata attribute value
+        attribute: metadata attribute name
+        """
+        return self.__getattr__(attribute)
 
-    @property
-    def tz_aware(self):
-        """returns the timezone aware flag"""
-        return self._tz_aware
+    def set(self, attribute: str, value: MDItemValueType):
+        """Set metadata attribute value
 
-    @tz_aware.setter
-    def tz_aware(self, tz_flag):
-        """sets the timezone aware flag
-        tz_flag: bool"""
-        self._tz_aware = tz_flag
+        Args:
+            attribute: metadata attribute name
+            value: value to set attribute to; must match the type expected by the attribute (e.g. str or list)
+        """
+        self.__setattr__(attribute, value)
 
-    def asdict(self, all_=False, encode=True):
-        """Return dict with all attributes for this file
+    def get_xattr(
+        self, key: str, decode: t.Callable[[t.ByteString], t.Any] = None
+    ) -> t.Any:
+        """Get xattr value
 
         Args:
-            all_: bool, if True, returns all attributes including those that osxmetadata knows nothing about
-            encode: bool, if True, encodes values for unknown attributes with base64, otherwise leaves the values as raw bytes
-
-        Returns:
-            dict with attributes for this file
+            key: xattr name
+            decode: optional Callable to decode value before returning
         """
+        xattr = self._xattr[key]
+        if decode:
+            xattr = decode(xattr)
+        return xattr
 
-        attribute_list = self._list_attributes() if all_ else self.list_metadata()
-        dict_data = {
-            "_version": __version__,
-            "_filepath": self._posix_name,
-            "_filename": self._fname.name,
-        }
-
-        for attr in attribute_list:
-            try:
-                attribute = ATTRIBUTES[attr]
-                if attribute.name == "tags":
-                    tags = self.get_attribute(attribute.name)
-                    value = [[tag.name, tag.color] for tag in tags]
-                    dict_data[attribute.constant] = value
-                elif attribute.constant == FinderInfo:
-                    value = self.finderinfo.asdict()
-                    dict_data[attribute.constant] = value
-                elif attribute.type_ == datetime.datetime:
-                    # need to convert datetime.datetime to string to serialize
-                    value = self.get_attribute(attribute.name)
-                    if type(value) == list:
-                        value = [v.isoformat() for v in value]
-                    else:
-                        value = value.isoformat()
-                    dict_data[attribute.constant] = value
-                else:
-                    # get raw value
-                    dict_data[attribute.constant] = self.get_attribute(attribute.name)
-            except KeyError:
-                # an attribute osxmetadata doesn't know about
-                if all_:
-                    try:
-                        value = self._attrs[attr]
-                        # convert value to base64 encoded ascii
-                        if encode:
-                            value = base64.b64encode(value).decode("ascii")
-                        dict_data[attr] = value
-                    except KeyError as e:
-                        # value disappeared between call to _list_attributes and now
-                        pass
-        return dict_data
-
-    def to_json(self, all_=False):
-        """Returns a string in JSON format for all attributes in this file
+    def set_xattr(
+        self, key: str, value: t.Any, encode: t.Callable[[t.ByteString], t.Any] = None
+    ):
+        """Set xattr value
 
         Args:
-            all_: bool; if True, also restores attributes not known to osxmetadata (generated with asdict(all_=True, encode=True) )
+            key: xattr name
+            encode: optional Callable to encode value before setting
         """
-        dict_data = self.asdict(all_=all_)
-        return json.dumps(dict_data)
+        if encode:
+            value = encode(value)
+        self._xattr[key] = value
 
-    def _restore_attributes(self, attr_dict, all_=False):
-        """restore attributes from attr_dict
-        for each attribute in attr_dict, will set the attribute
-        will not clear/erase any attributes on file that are not in attr_dict
+    def remove_xattr(self, key: str):
+        """Remove xattr
 
         Args:
-            attr_dict: an attribute dict as produced by OSXMetaData.asdict()
-            all_: bool; if True, also restores attributes not known to osxmetadata (generated with asdict(all_=True, encode=True) )
+            key: xattr name
         """
+        self._xattr.remove(key)
 
-        for key, val in attr_dict.items():
-            if key.startswith("_"):
-                # skip private keys like _version and _filepath
-                continue
-            try:
-                if key == _kMDItemUserTags:
-                    if not isinstance(val, list):
-                        raise TypeError(
-                            f"expected list for attribute {key} but got {type(val)}"
-                        )
-                    self.set_attribute(key, [Tag(*tag_val) for tag_val in val])
-                elif key == FinderInfo:
-                    if not isinstance(val, dict):
-                        raise TypeError(
-                            f"expected dict for attribute {key} but got {type(val)}"
-                        )
-                    self.set_attribute(key, val)
-                elif key in ATTRIBUTES:
-                    self.set_attribute(key, val)
-                elif all_:
-                    self._attrs.set(key, base64.b64decode(val))
-            except Exception as e:
-                logging.warning(
-                    f"Unable to restore attribute {key} for {self._fname}: {e}"
-                )
-
-    def get_attribute(self, attribute_name):
-        """load attribute and return value or None if attribute was not set
-        attribute_name: name of attribute
+    def asdict(self, attributes: t.Set[str] = ASDICT_ATTRIBUTES) -> t.Dict[str, t.Any]:
+        """Return all MDItem metadata (or a subset defined by attributes) as a dict
+
+        Args:
+            attributes: set of attributes to include in dict
+
+        Returns:
+            dict of metadata
         """
+        return {key: getattr(self, key) for key in attributes}
 
-        attribute = ATTRIBUTES[attribute_name]
+    def to_json(
+        self, attributes: t.Set[str] = ASDICT_ATTRIBUTES, indent: int = 4
+    ) -> str:
+        """Return all MDItem metadata (or a subset defined by attributes) as a JSON string
 
-        # user tags and finderinfo need special processing
-        if attribute.name in _ATTRIBUTE_CLASS_ATTRIBUTES:
-            return getattr(self, attribute.name).get_value()
+        Args:
+            attributes: set of attributes to include in JSON
+            indent: indent level for JSON output
 
-        # must be a "normal" metadata attribute
-        try:
-            plist = plistlib.loads(self._attrs[attribute.constant])
-        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:
-            return plist
-
-    def get_attribute_str(self, attribute_name):
-        """returns a string representation of attribute value
-        e.g. if attribute is a datedate.datetime object, will
-        format using datetime.isoformat()
-        attribute_name: name of attribute"""
-
-        attribute = ATTRIBUTES[attribute_name]
-        value = self.get_attribute(attribute_name)
-        if attribute.class_ == _AttributeOSXPhotosDetectedText:
-            return json.dumps(value)
-
-        if type(value) in [list, set]:
-            if value:
-                if type(value[0]) == datetime.datetime:
-                    new_value = [v.isoformat() for v in value]
-                    return str(new_value)
-                elif isinstance(value[0], Tag):
-                    new_value = [
-                        f"{tag.name},{get_tag_color_name(tag.color)}"
-                        if tag.color
-                        else f"{tag.name}"
-                        for tag in value
-                    ]
-                    return str(new_value)
-            return [str(val) for val in value]
-        else:
-            if type(value) == datetime.datetime:
-                return value.isoformat()
-            elif isinstance(value, Tag):
-                return (
-                    f"{value.name},{get_tag_color_name(value.color)}"
-                    if value.color
-                    else f"{value.name}"
-                )
-            return str(value)
-
-    def set_attribute(self, attribute_name, value):
-        """write attribute to file with value
-        attribute_name: an osxmetadata Attribute name
-        value: value to store in attribute"""
-        attribute = ATTRIBUTES[attribute_name]
-
-        # user tags and Finder info need special processing
-        if attribute.name in _ATTRIBUTE_CLASS_ATTRIBUTES:
-            return getattr(self, attribute.name).set_value(value)
-
-        # verify type is correct
-        value = validate_attribute_value(attribute, value)
-
-        if attribute.name in _FINDER_COMMENT_NAMES:
-            # Finder Comment needs special handling
-            # code following will also set the attribute for Finder Comment
-            self.set_finder_comment(self._posix_name, value)
-        elif attribute.class_ in [
-            _AttributeList,
-            _AttributeTagsList,
-        ]:
-            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)
-
-    def update_attribute(self, attribute_name, value):
-        """Update attribute with union of itself and value
-        (this avoids adding duplicate values to attribute)
-        attribute: an osxmetadata Attribute name
-        value: value to append to attribute"""
-        return self.append_attribute(attribute_name, value, update=True)
-
-    def append_attribute(self, attribute_name, value, update=False):
-        """append value to attribute
-        attribute_name: an osxmetadata Attribute name
-        value: value to append to attribute
-        update: (bool) if True, update instead of append (e.g. avoid adding duplicates)
-                (default is False)"""
-
-        attribute = ATTRIBUTES[attribute_name]
-
-        # start with existing values
-        new_value = self.get_attribute(attribute.name)
-
-        # user tags need special processing to normalize names
-        if attribute.name == "tags":
-            if not isinstance(new_value, list) or not isinstance(value, list):
-                raise TypeError(
-                    f"tags expects values in list: {type(new_value)}, {type(value)}"
-                )
-
-            if update:
-                # verify not already in values
-                for val in value:
-                    if val not in new_value:
-                        new_value.append(val)
-            else:
-                new_value.extend(value)
-            return self.tags.set_value(new_value)
-        if attribute.name in _FINDERINFO_ATTRIBUTES:
-            raise ValueError(f"cannot append or update {attribute.name}")
-
-        value = validate_attribute_value(attribute, value)
-
-        if (
-            attribute.class_ == _AttributeOSXPhotosDetectedText
-            and new_value is None
-            or attribute.class_ != _AttributeOSXPhotosDetectedText
-            and attribute.list
-            and new_value is None
-        ):
-            new_value = value
-        elif attribute.list:
-            new_value = list(new_value)
-            if update:
-                for val in value:
-                    if val not in new_value:
-                        new_value.append(val)
-            elif attribute.class_ == _AttributeOSXPhotosDetectedText:
-                for val in value:
-                    new_value.append(val)
-            else:
-                new_value.extend(value)
-        else:
-            # scalar value
-            if update:
-                raise AttributeError(f"Cannot use update on {attribute.type_}")
-            if new_value is None:
-                new_value = value
-            else:
-                new_value += value
+        Returns:
+            JSON string
 
-        try:
-            if attribute.name in _FINDER_COMMENT_NAMES:
-                # Finder Comment needs special handling
-                self.set_finder_comment(self._posix_name, new_value)
-            elif attribute.class_ in [
-                _AttributeList,
-                _AttributeTagsList,
-                _AttributeOSXPhotosDetectedText,
-            ]:
-                # if tags, set_value will normalize
-                getattr(self, attribute.name).set_value(new_value)
-            else:
-                plist = plistlib.dumps(new_value, fmt=FMT_BINARY)
-                self._attrs.set(attribute.constant, plist)
-        except Exception as e:
-            # todo: should catch this or not?
-            raise e
+        Notes:
+            datetime objects are converted to ISO 8601 format
+            binary objects are converted to base64 encoded strings
+            the resulting JSON will include 3 additional keys: _version, _filepath, and _filename;
+            these are expected by the CLI backup/restore commands
+        """
 
-        return new_value
+        dict_data = self.asdict(attributes)
 
-    def remove_attribute(self, attribute_name, value):
-        """remove a value from attribute, raise ValueError if attribute does not contain value
-        only applies to multi-valued attributes, otherwise raises TypeError
-        attribute_name: name of OSXMetaData attribute"""
+        # add fields that backup/restore expects
+        dict_data.update(
+            {
+                "_version": __version__,
+                "_filepath": self._posix_path,
+                "_filename": self._fname.name,
+            }
+        )
 
-        attribute = ATTRIBUTES[attribute_name]
+        for key, value in dict_data.items():
+            if isinstance(value, datetime.datetime):
+                dict_data[key] = value.isoformat()
+            elif isinstance(value, (list, tuple)):
+                if value and isinstance(value[0], datetime.datetime):
+                    dict_data[key] = [v.isoformat() for v in value]
+            elif isinstance(value, bytes):
+                dict_data[key] = base64.b64encode(value).decode("ascii")
 
-        if not attribute.list:
-            raise TypeError("remove only applies to multi-valued attributes")
+        return json.dumps(dict_data, indent=indent)
 
-        if attribute.name == "tags":
-            # tags need special processing
-            self.tags.remove(value)
-        else:
-            values = self.get_attribute(attribute.name)
-            values = list(values)
-            values.remove(value)
-            self.set_attribute(attribute.name, values)
+    @property
+    def path(self) -> str:
+        """Return path to file"""
+        return self._posix_path
 
-    def discard_attribute(self, attribute_name, 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
-        attribute_name: name of OSXMetaData attribute"""
+    def __getattr__(self, attribute: str) -> MDItemValueType:
+        """Get metadata attribute value
 
-        attribute = ATTRIBUTES[attribute_name]
+        Args:
+            attribute: metadata attribute name
+        """
+        if attribute in ["tags", _kMDItemUserTags]:
+            return get_finder_tags(self._xattr)
+        elif attribute in MDITEM_ATTRIBUTE_SHORT_NAMES:
+            # handle dynamic properties like self.keywords and self.comments
+            return get_mditem_metadata(
+                self._mditem, MDITEM_ATTRIBUTE_SHORT_NAMES[attribute]
+            )
+        elif (
+            attribute in MDITEM_ATTRIBUTE_DATA or attribute in MDIMPORTER_ATTRIBUTE_DATA
+        ):
+            return get_mditem_metadata(self._mditem, attribute)
+        elif attribute in NSURL_RESOURCE_KEY_DATA:
+            return get_nsurl_metadata(self._url, attribute)
+        elif attribute in ["finderinfo", _kFinderInfo]:
+            return get_finderinfo_bytes(self._xattr)
+        elif attribute == _kFinderStationeryPad:
+            return get_finderinfo_stationerypad(self._xattr)
+        elif attribute == _kFinderColor:
+            return get_finderinfo_color(self._xattr)
+        else:
+            raise AttributeError(f"Invalid attribute: {attribute}")
 
-        if not attribute.list:
-            raise TypeError("discard only applies to multi-valued attributes")
+    def __setattr__(self, attribute: str, value: t.Any):
+        """set metadata attribute value
 
-        values = self.get_attribute(attribute.name)
+        Args:
+            attribute: metadata attribute name
+            value: value to set
+        """
         try:
-            values.remove(value)
-            self.set_attribute(attribute.name, values)
-        except Exception:
-            pass
-
-    def clear_attribute(self, attribute_name):
-        """clear anttribute (remove it from the file)
-        attribute_name: name of OSXMetaData attribute"""
+            if not self.__init:
+                # during __init__ we don't want to call __setattr__ as it will
+                # cause an infinite loop
+                return super().__setattr__(attribute, value)
+            if attribute in ["findercomment", kMDItemFinderComment]:
+                # finder comment cannot be set using MDItemSetAttribute
+                set_or_remove_finder_comment(self._url, self._xattr, value)
+            elif attribute in ["tags", _kMDItemUserTags]:
+                # handle Finder tags
+                set_finder_tags(self._url, value)
+            elif attribute in MDITEM_ATTRIBUTE_SHORT_NAMES:
+                # handle dynamic properties like self.keywords and self.comments
+                attribute_name = MDITEM_ATTRIBUTE_SHORT_NAMES[attribute]
+                set_or_remove_mditem_metadata(self._mditem, attribute_name, value)
+            elif attribute in MDITEM_ATTRIBUTE_DATA:
+                set_or_remove_mditem_metadata(self._mditem, attribute, value)
+            elif attribute in NSURL_RESOURCE_KEY_DATA:
+                set_nsurl_metadata(self._url, attribute, value)
+            elif attribute in ["finderinfo", _kFinderInfo]:
+                set_finderinfo_bytes(self._xattr, value)
+            elif attribute == _kFinderStationeryPad:
+                set_finderinfo_stationerypad(self._xattr, bool(value))
+            elif attribute == _kFinderColor:
+                set_finderinfo_color(self._xattr, value)
+            elif attribute in ALL_ATTRIBUTES:
+                raise OSXMetaDataAttributeError(f"Attribute {attribute} is read-only")
+            else:
+                raise OSXMetaDataAttributeError(f"Invalid attribute: {attribute}")
+        except (KeyError, AttributeError):
+            super().__setattr__(attribute, value)
+        except OSXMetaDataAttributeError as e:
+            raise AttributeError(e) from e
 
-        attribute = ATTRIBUTES[attribute_name]
+    def __getitem__(self, key: str) -> MDItemValueType:
+        """Get metadata attribute value
 
-        try:
-            if attribute.name in _FINDER_COMMENT_NAMES:
-                # Finder Comment needs special handling
-                # code following will also clear the attribute for Finder Comment
-                self.clear_finder_comment(self._posix_name)
-
-            if attribute.name in ["tags", *_FINDERINFO_COLOR_ATTRIBUTES]:
-                # don't clear the entire FinderInfo attribute, just delete the bits we know about
-                self.finderinfo.set_finderinfo_color(FINDER_COLOR_NONE)
-
-            if attribute.name in _FINDERINFO_STATIONARYPAD_ATTRIBUTES:
-                self.finderinfo.set_finderinfo_stationarypad(False)
-
-            if attribute.name not in _FINDERINFO_ATTRIBUTES:
-                # remove the entire attribute
-                self._attrs.remove(attribute.constant)
-        except (IOError, OSError):
-            # TODO: fix this try/except handling
-            pass
-
-    def _list_attributes(self):
-        """list all the attributes set on the file"""
-        return self._attrs.list()
-
-    def list_metadata(self):
-        """list the Apple metadata attributes set on the file:
-        e.g. those in com.apple.metadata namespace"""
-        # also lists com.osxmetadata.test used for debugging
-        mdlist = self._list_attributes()
-        mdlist = [
-            md
-            for md in mdlist
-            if md.startswith("com.apple.metadata")
-            or md.startswith("com.apple.FinderInfo")
-            or md.startswith("com.osxmetadata.test")
-            or md.startswith("osxphotos.metadata")
-        ]
-        return mdlist
-
-    def set_finder_comment(self, path, comment):
-        """set finder comment for object path (file or directory)
-        path: path to file or directory in posix format
-        comment: comment string
+        Args:
+            key: metadata attribute name
         """
-        if not os.path.exists(path):
-            raise FileNotFoundError(f"Could not find {path}")
-
-        if comment:
-            _scpt_set_finder_comment.run(path, comment)
-            plist = plistlib.dumps(comment, fmt=FMT_BINARY)
-            self._attrs.set(ATTRIBUTES["findercomment"].constant, plist)
+        if key == _kMDItemUserTags:
+            return get_finder_tags(self._xattr)
+        elif key in MDITEM_ATTRIBUTE_DATA:
+            return get_mditem_metadata(self._mditem, key)
+        elif key in NSURL_RESOURCE_KEY_DATA:
+            return get_nsurl_metadata(self._url, key)
         else:
-            self.clear_finder_comment(path)
+            raise KeyError(f"Invalid key: {key}")
 
-    def clear_finder_comment(self, path):
-        """clear finder comment for object path (file or directory)
-        path: path to file or directory in posix format
-        """
-        if not os.path.exists(path):
-            raise FileNotFoundError(f"Could not find {path}")
+    def __setitem__(self, key: str, value: t.Any):
+        """set metadata attribute value
 
-        _scpt_clear_finder_comment.run(path)
-        try:
-            self._attrs.remove(ATTRIBUTES["findercomment"].constant)
-        except (IOError, OSError):
-            # exception raised if attribute not found and attempt to remove it
-            pass
-
-    def __getattr__(self, name):
-        """if attribute name is in ATTRIBUTE dict, return the value"""
-        if name in ATTRIBUTES:
-            return self.get_attribute(name)
-        raise AttributeError(f"{name} is not an attribute")
-
-    def __setattr__(self, name, value):
-        """if object is initialized and name is an attribute in ATTRIBUTES,
-        set the attribute to value
-        if value value is None, will clear (delete) the attribute and all associated values
-        if name is not a metadata attribute, assume it's a normal class attribute and pass to
-        super() to handle"""
-        try:
-            if self.__init:
-                # already initialized
-                attribute = ATTRIBUTES[name]
-                if value is None:
-                    self.clear_attribute(attribute.name)
-                else:
-                    self.set_attribute(attribute.name, value)
-        except (KeyError, AttributeError):
-            super().__setattr__(name, value)
+        Args:
+            key: metadata attribute name
+            value: value to set
+        """
+        if key == _kMDItemUserTags:
+            set_finder_tags(self._xattr, value)
+        elif key == kMDItemFinderComment:
+            set_or_remove_finder_comment(self._url, self._xattr, value)
+        elif key in MDITEM_ATTRIBUTE_DATA:
+            set_or_remove_mditem_metadata(self._mditem, key, value)
+        elif key in NSURL_RESOURCE_KEY_DATA:
+            set_nsurl_metadata(self._url, key, value)
+        elif key in ALL_ATTRIBUTES:
+            raise KeyError(f"Attribute {key} is read-only")
+        else:
+            raise KeyError(f"Invalid key: {key}")
diff --git a/poetry.lock b/poetry.lock
new file mode 100644
index 0000000..ca5aa6d
--- /dev/null
+++ b/poetry.lock
@@ -0,0 +1,1626 @@
+[[package]]
+name = "altgraph"
+version = "0.17.3"
+description = "Python graph (network) package"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "attrs"
+version = "22.1.0"
+description = "Classes Without Boilerplate"
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+
+[package.extras]
+dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"]
+docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"]
+tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"]
+tests_no_zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"]
+
+[[package]]
+name = "bitstring"
+version = "3.1.9"
+description = "Simple construction, analysis and modification of binary data."
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "bleach"
+version = "5.0.1"
+description = "An easy safelist-based HTML-sanitizing tool."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+six = ">=1.9.0"
+webencodings = "*"
+
+[package.extras]
+css = ["tinycss2 (>=1.1.0,<1.2)"]
+dev = ["Sphinx (==4.3.2)", "black (==22.3.0)", "build (==0.8.0)", "flake8 (==4.0.1)", "hashin (==0.17.0)", "mypy (==0.961)", "pip-tools (==6.6.2)", "pytest (==7.1.2)", "tox (==3.25.0)", "twine (==4.0.1)", "wheel (==0.37.1)"]
+
+[[package]]
+name = "build"
+version = "0.8.0"
+description = "A simple, correct PEP 517 build frontend"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+colorama = {version = "*", markers = "os_name == \"nt\""}
+packaging = ">=19.0"
+pep517 = ">=0.9.1"
+tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
+
+[package.extras]
+docs = ["furo (>=2021.08.31)", "sphinx (>=4.0,<5.0)", "sphinx-argparse-cli (>=1.5)", "sphinx-autodoc-typehints (>=1.10)"]
+test = ["filelock (>=3)", "pytest (>=6.2.4)", "pytest-cov (>=2.12)", "pytest-mock (>=2)", "pytest-rerunfailures (>=9.1)", "pytest-xdist (>=1.34)", "setuptools (>=42.0.0)", "setuptools (>=56.0.0)", "toml (>=0.10.0)", "wheel (>=0.36.0)"]
+typing = ["importlib-metadata (>=4.6.4)", "mypy (==0.950)", "typing-extensions (>=3.7.4.3)"]
+virtualenv = ["virtualenv (>=20.0.35)"]
+
+[[package]]
+name = "bump2version"
+version = "1.0.1"
+description = "Version-bump your software with a single command!"
+category = "dev"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
+name = "certifi"
+version = "2022.9.24"
+description = "Python package for providing Mozilla's CA Bundle."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "cffi"
+version = "1.15.1"
+description = "Foreign Function Interface for Python calling C code."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+pycparser = "*"
+
+[[package]]
+name = "charset-normalizer"
+version = "2.1.1"
+description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+category = "main"
+optional = false
+python-versions = ">=3.6.0"
+
+[package.extras]
+unicode_backport = ["unicodedata2"]
+
+[[package]]
+name = "click"
+version = "8.1.3"
+description = "Composable command line interface toolkit"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+
+[[package]]
+name = "cloudpickle"
+version = "2.2.0"
+description = "Extended pickling support for Python objects"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "cogapp"
+version = "3.3.0"
+description = "Cog: A content generator for executing Python snippets in source files."
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[[package]]
+name = "colorama"
+version = "0.4.5"
+description = "Cross-platform colored terminal text."
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[[package]]
+name = "commonmark"
+version = "0.9.1"
+description = "Python parser for the CommonMark Markdown spec"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.extras]
+test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"]
+
+[[package]]
+name = "cryptography"
+version = "38.0.1"
+description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+cffi = ">=1.12"
+
+[package.extras]
+docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"]
+docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"]
+pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"]
+sdist = ["setuptools-rust (>=0.11.4)"]
+ssh = ["bcrypt (>=3.1.5)"]
+test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pytz"]
+
+[[package]]
+name = "docutils"
+version = "0.19"
+description = "Docutils -- Python Documentation Utilities"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[[package]]
+name = "doit"
+version = "0.36.0"
+description = "doit - Automation Tool"
+category = "dev"
+optional = false
+python-versions = ">=3.8"
+
+[package.dependencies]
+cloudpickle = "*"
+importlib-metadata = ">=4.4"
+
+[package.extras]
+toml = ["tomli"]
+
+[[package]]
+name = "future"
+version = "0.18.2"
+description = "Clean single-source support for Python 3 and 2"
+category = "dev"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+
+[[package]]
+name = "ghp-import"
+version = "2.1.0"
+description = "Copy your docs directly to the gh-pages branch."
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+python-dateutil = ">=2.8.1"
+
+[package.extras]
+dev = ["flake8", "markdown", "twine", "wheel"]
+
+[[package]]
+name = "griffe"
+version = "0.22.2"
+description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.extras]
+async = ["aiofiles (>=0.7,<1.0)"]
+
+[[package]]
+name = "idna"
+version = "3.4"
+description = "Internationalized Domain Names in Applications (IDNA)"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
+name = "importlib-metadata"
+version = "4.12.0"
+description = "Read metadata from Python packages"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+zipp = ">=0.5"
+
+[package.extras]
+docs = ["jaraco.packaging (>=9)", "rst.linker (>=1.9)", "sphinx"]
+perf = ["ipython"]
+testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"]
+
+[[package]]
+name = "iniconfig"
+version = "1.1.1"
+description = "iniconfig: brain-dead simple config-ini parsing"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "jaraco.classes"
+version = "3.2.3"
+description = "Utility functions for Python class constructs"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+more-itertools = "*"
+
+[package.extras]
+docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"]
+testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
+
+[[package]]
+name = "jeepney"
+version = "0.8.0"
+description = "Low-level, pure Python DBus protocol wrapper."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.extras]
+test = ["async-timeout", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"]
+trio = ["async_generator", "trio"]
+
+[[package]]
+name = "Jinja2"
+version = "3.1.2"
+description = "A very fast and expressive template engine."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+MarkupSafe = ">=2.0"
+
+[package.extras]
+i18n = ["Babel (>=2.7)"]
+
+[[package]]
+name = "keyring"
+version = "23.9.3"
+description = "Store and access your passwords safely."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+importlib-metadata = {version = ">=3.6", markers = "python_version < \"3.10\""}
+"jaraco.classes" = "*"
+jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""}
+pywin32-ctypes = {version = "<0.1.0 || >0.1.0,<0.1.1 || >0.1.1", markers = "sys_platform == \"win32\""}
+SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""}
+
+[package.extras]
+docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx"]
+testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
+
+[[package]]
+name = "macholib"
+version = "1.16.2"
+description = "Mach-O header analysis and editing"
+category = "dev"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+altgraph = ">=0.17"
+
+[[package]]
+name = "Markdown"
+version = "3.3.7"
+description = "Python implementation of Markdown."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""}
+
+[package.extras]
+testing = ["coverage", "pyyaml"]
+
+[[package]]
+name = "MarkupSafe"
+version = "2.1.1"
+description = "Safely add untrusted strings to HTML/XML markup."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[[package]]
+name = "mergedeep"
+version = "1.3.4"
+description = "A deep merge function for 🐍."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "mkdocs"
+version = "1.4.0"
+description = "Project documentation with Markdown."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+click = ">=7.0"
+ghp-import = ">=1.0"
+importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""}
+Jinja2 = ">=2.11.1"
+Markdown = ">=3.2.1,<3.4"
+mergedeep = ">=1.3.4"
+packaging = ">=20.5"
+PyYAML = ">=5.1"
+pyyaml-env-tag = ">=0.1"
+watchdog = ">=2.0"
+
+[package.extras]
+i18n = ["babel (>=2.9.0)"]
+
+[[package]]
+name = "mkdocs-autorefs"
+version = "0.4.1"
+description = "Automatically link across pages in MkDocs."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+Markdown = ">=3.3"
+mkdocs = ">=1.1"
+
+[[package]]
+name = "mkdocs-material"
+version = "8.5.4"
+description = "Documentation that simply works"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+jinja2 = ">=3.0.2"
+markdown = ">=3.2"
+mkdocs = ">=1.3.0"
+mkdocs-material-extensions = ">=1.0.3"
+pygments = ">=2.12"
+pymdown-extensions = ">=9.4"
+requests = ">=2.26"
+
+[[package]]
+name = "mkdocs-material-extensions"
+version = "1.0.3"
+description = "Extension pack for Python Markdown."
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "mkdocstrings"
+version = "0.19.0"
+description = "Automatic documentation from sources, for MkDocs."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+Jinja2 = ">=2.11.1"
+Markdown = ">=3.3"
+MarkupSafe = ">=1.1"
+mkdocs = ">=1.2"
+mkdocs-autorefs = ">=0.3.1"
+mkdocstrings-python = {version = ">=0.5.2", optional = true, markers = "extra == \"python\""}
+pymdown-extensions = ">=6.3"
+
+[package.extras]
+crystal = ["mkdocstrings-crystal (>=0.3.4)"]
+python = ["mkdocstrings-python (>=0.5.2)"]
+python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"]
+
+[[package]]
+name = "mkdocstrings-python"
+version = "0.7.1"
+description = "A Python handler for mkdocstrings."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+griffe = ">=0.11.1"
+mkdocstrings = ">=0.19"
+
+[[package]]
+name = "more-itertools"
+version = "8.14.0"
+description = "More routines for operating on iterables, beyond itertools"
+category = "main"
+optional = false
+python-versions = ">=3.5"
+
+[[package]]
+name = "packaging"
+version = "21.3"
+description = "Core utilities for Python packages"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
+
+[[package]]
+name = "pefile"
+version = "2022.5.30"
+description = "Python PE parsing module"
+category = "dev"
+optional = false
+python-versions = ">=3.6.0"
+
+[package.dependencies]
+future = "*"
+
+[[package]]
+name = "pep517"
+version = "0.13.0"
+description = "Wrappers to build Python packages using PEP 517 hooks"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
+
+[[package]]
+name = "pkginfo"
+version = "1.8.3"
+description = "Query metadatdata from sdists / bdists / installed packages."
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
+
+[package.extras]
+testing = ["coverage", "nose"]
+
+[[package]]
+name = "pluggy"
+version = "1.0.0"
+description = "plugin and hook calling mechanisms for python"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
+
+[[package]]
+name = "py"
+version = "1.11.0"
+description = "library with cross-python path, ini-parsing, io, code, log facilities"
+category = "dev"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
+[[package]]
+name = "py-applescript"
+version = "1.0.3"
+description = "An easy-to-use Python wrapper for NSAppleScript, allowing Python scripts to communicate with AppleScripts and AppleScriptable applications."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+pyobjc-core = "*"
+pyobjc-framework-AppleScriptKit = "*"
+pyobjc-framework-AppleScriptObjC = "*"
+
+[[package]]
+name = "pycparser"
+version = "2.21"
+description = "C parser in Python"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "Pygments"
+version = "2.13.0"
+description = "Pygments is a syntax highlighting package written in Python."
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+plugins = ["importlib-metadata"]
+
+[[package]]
+name = "pyinstaller"
+version = "5.4.1"
+description = "PyInstaller bundles a Python application and all its dependencies into a single package."
+category = "dev"
+optional = false
+python-versions = "<3.11,>=3.7"
+
+[package.dependencies]
+altgraph = "*"
+macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""}
+pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""}
+pyinstaller-hooks-contrib = ">=2021.4"
+pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""}
+setuptools = "*"
+
+[package.extras]
+encryption = ["tinyaes (>=1.0.0)"]
+hook_testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"]
+
+[[package]]
+name = "pyinstaller-hooks-contrib"
+version = "2022.10"
+description = "Community maintained hooks for PyInstaller"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[[package]]
+name = "pymdown-extensions"
+version = "9.6"
+description = "Extension pack for Python Markdown."
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+markdown = ">=3.2"
+
+[[package]]
+name = "pyobjc-core"
+version = "8.5.1"
+description = "Python<->ObjC Interoperability Module"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "pyobjc-framework-AppleScriptKit"
+version = "8.5.1"
+description = "Wrappers for the framework AppleScriptKit on macOS"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+pyobjc-core = ">=8.5.1"
+pyobjc-framework-Cocoa = ">=8.5.1"
+
+[[package]]
+name = "pyobjc-framework-AppleScriptObjC"
+version = "8.5.1"
+description = "Wrappers for the framework AppleScriptObjC on macOS"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+pyobjc-core = ">=8.5.1"
+pyobjc-framework-Cocoa = ">=8.5.1"
+
+[[package]]
+name = "pyobjc-framework-AVFoundation"
+version = "8.5.1"
+description = "Wrappers for the framework AVFoundation on macOS"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+pyobjc-core = ">=8.5.1"
+pyobjc-framework-Cocoa = ">=8.5.1"
+pyobjc-framework-CoreAudio = ">=8.5.1"
+pyobjc-framework-CoreMedia = ">=8.5.1"
+pyobjc-framework-Quartz = ">=8.5.1"
+
+[[package]]
+name = "pyobjc-framework-Cocoa"
+version = "8.5.1"
+description = "Wrappers for the Cocoa frameworks on macOS"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+pyobjc-core = ">=8.5.1"
+
+[[package]]
+name = "pyobjc-framework-CoreAudio"
+version = "8.5.1"
+description = "Wrappers for the framework CoreAudio on macOS"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+pyobjc-core = ">=8.5.1"
+pyobjc-framework-Cocoa = ">=8.5.1"
+
+[[package]]
+name = "pyobjc-framework-CoreMedia"
+version = "8.5.1"
+description = "Wrappers for the framework CoreMedia on macOS"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+pyobjc-core = ">=8.5.1"
+pyobjc-framework-Cocoa = ">=8.5.1"
+
+[[package]]
+name = "pyobjc-framework-CoreServices"
+version = "8.5.1"
+description = "Wrappers for the framework CoreServices on macOS"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+pyobjc-core = ">=8.5.1"
+pyobjc-framework-FSEvents = ">=8.5.1"
+
+[[package]]
+name = "pyobjc-framework-FSEvents"
+version = "8.5.1"
+description = "Wrappers for the framework FSEvents on macOS"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+pyobjc-core = ">=8.5.1"
+pyobjc-framework-Cocoa = ">=8.5.1"
+
+[[package]]
+name = "pyobjc-framework-Quartz"
+version = "8.5.1"
+description = "Wrappers for the Quartz frameworks on macOS"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+pyobjc-core = ">=8.5.1"
+pyobjc-framework-Cocoa = ">=8.5.1"
+
+[[package]]
+name = "pyobjc-framework-ScriptingBridge"
+version = "8.5.1"
+description = "Wrappers for the framework ScriptingBridge on macOS"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+pyobjc-core = ">=8.5.1"
+pyobjc-framework-Cocoa = ">=8.5.1"
+
+[[package]]
+name = "pyparsing"
+version = "3.0.9"
+description = "pyparsing module - Classes and methods to define and execute parsing grammars"
+category = "dev"
+optional = false
+python-versions = ">=3.6.8"
+
+[package.extras]
+diagrams = ["jinja2", "railroad-diagrams"]
+
+[[package]]
+name = "pytest"
+version = "7.1.3"
+description = "pytest: simple powerful testing with Python"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+attrs = ">=19.2.0"
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=0.12,<2.0"
+py = ">=1.8.2"
+tomli = ">=1.0.0"
+
+[package.extras]
+testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
+
+[[package]]
+name = "python-dateutil"
+version = "2.8.2"
+description = "Extensions to the standard Python datetime module"
+category = "dev"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+
+[package.dependencies]
+six = ">=1.5"
+
+[[package]]
+name = "pywin32-ctypes"
+version = "0.2.0"
+description = ""
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "PyYAML"
+version = "6.0"
+description = "YAML parser and emitter for Python"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[[package]]
+name = "pyyaml_env_tag"
+version = "0.1"
+description = "A custom YAML tag for referencing environment variables in YAML files. "
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+pyyaml = "*"
+
+[[package]]
+name = "readme-renderer"
+version = "37.2"
+description = "readme_renderer is a library for rendering \"readme\" descriptions for Warehouse"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+bleach = ">=2.1.0"
+docutils = ">=0.13.1"
+Pygments = ">=2.5.1"
+
+[package.extras]
+md = ["cmarkgfm (>=0.8.0)"]
+
+[[package]]
+name = "requests"
+version = "2.28.1"
+description = "Python HTTP for Humans."
+category = "main"
+optional = false
+python-versions = ">=3.7, <4"
+
+[package.dependencies]
+certifi = ">=2017.4.17"
+charset-normalizer = ">=2,<3"
+idna = ">=2.5,<4"
+urllib3 = ">=1.21.1,<1.27"
+
+[package.extras]
+socks = ["PySocks (>=1.5.6,!=1.5.7)"]
+use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"]
+
+[[package]]
+name = "requests-toolbelt"
+version = "0.9.1"
+description = "A utility belt for advanced users of python-requests"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+requests = ">=2.0.1,<3.0.0"
+
+[[package]]
+name = "rfc3986"
+version = "2.0.0"
+description = "Validating URI References per RFC 3986"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.extras]
+idna2008 = ["idna"]
+
+[[package]]
+name = "rich"
+version = "12.5.1"
+description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
+category = "main"
+optional = false
+python-versions = ">=3.6.3,<4.0.0"
+
+[package.dependencies]
+commonmark = ">=0.9.0,<0.10.0"
+pygments = ">=2.6.0,<3.0.0"
+typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""}
+
+[package.extras]
+jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"]
+
+[[package]]
+name = "SecretStorage"
+version = "3.3.3"
+description = "Python bindings to FreeDesktop.org Secret Service API"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+cryptography = ">=2.0"
+jeepney = ">=0.6"
+
+[[package]]
+name = "setuptools"
+version = "65.4.1"
+description = "Easily download, build, install, upgrade, and uninstall Python packages"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
+testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mock", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
+testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
+
+[[package]]
+name = "six"
+version = "1.16.0"
+description = "Python 2 and 3 compatibility utilities"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+
+[[package]]
+name = "tomli"
+version = "2.0.1"
+description = "A lil' TOML parser"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+
+[[package]]
+name = "twine"
+version = "4.0.1"
+description = "Collection of utilities for publishing packages on PyPI"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+importlib-metadata = ">=3.6"
+keyring = ">=15.1"
+pkginfo = ">=1.8.1"
+readme-renderer = ">=35.0"
+requests = ">=2.20"
+requests-toolbelt = ">=0.8.0,<0.9.0 || >0.9.0"
+rfc3986 = ">=1.4.0"
+rich = ">=12.0.0"
+urllib3 = ">=1.26.0"
+
+[[package]]
+name = "typing-extensions"
+version = "4.3.0"
+description = "Backported and Experimental Type Hints for Python 3.7+"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[[package]]
+name = "urllib3"
+version = "1.26.12"
+description = "HTTP library with thread-safe connection pooling, file post, and more."
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4"
+
+[package.extras]
+brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"]
+secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"]
+socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
+
+[[package]]
+name = "watchdog"
+version = "2.1.9"
+description = "Filesystem events monitoring"
+category = "dev"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+watchmedo = ["PyYAML (>=3.10)"]
+
+[[package]]
+name = "webencodings"
+version = "0.5.1"
+description = "Character encoding aliases for legacy web content"
+category = "main"
+optional = false
+python-versions = "*"
+
+[[package]]
+name = "wheel"
+version = "0.37.1"
+description = "A built-package format for Python"
+category = "main"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
+
+[package.extras]
+test = ["pytest (>=3.0.0)", "pytest-cov"]
+
+[[package]]
+name = "xattr"
+version = "0.9.9"
+description = "Python wrapper for extended filesystem attributes"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+cffi = ">=1.0"
+
+[[package]]
+name = "zipp"
+version = "3.8.1"
+description = "Backport of pathlib-compatible object wrapper for zip files"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.extras]
+docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx"]
+testing = ["func-timeout", "jaraco.itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
+
+[metadata]
+lock-version = "1.1"
+python-versions = ">=3.8,<3.11"
+content-hash = "10b0f270d1725c64cd5eef3903198b74ef7ca0942cb456b54c1c0c8c20108b0b"
+
+[metadata.files]
+altgraph = [
+    {file = "altgraph-0.17.3-py2.py3-none-any.whl", hash = "sha256:c8ac1ca6772207179ed8003ce7687757c04b0b71536f81e2ac5755c6226458fe"},
+    {file = "altgraph-0.17.3.tar.gz", hash = "sha256:ad33358114df7c9416cdb8fa1eaa5852166c505118717021c6a8c7c7abbd03dd"},
+]
+attrs = [
+    {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"},
+    {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"},
+]
+bitstring = [
+    {file = "bitstring-3.1.9-py2-none-any.whl", hash = "sha256:e3e340e58900a948787a05e8c08772f1ccbe133f6f41fe3f0fa19a18a22bbf4f"},
+    {file = "bitstring-3.1.9-py3-none-any.whl", hash = "sha256:0de167daa6a00c9386255a7cac931b45e6e24e0ad7ea64f1f92a64ac23ad4578"},
+    {file = "bitstring-3.1.9.tar.gz", hash = "sha256:a5848a3f63111785224dca8bb4c0a75b62ecdef56a042c8d6be74b16f7e860e7"},
+]
+bleach = [
+    {file = "bleach-5.0.1-py3-none-any.whl", hash = "sha256:085f7f33c15bd408dd9b17a4ad77c577db66d76203e5984b1bd59baeee948b2a"},
+    {file = "bleach-5.0.1.tar.gz", hash = "sha256:0d03255c47eb9bd2f26aa9bb7f2107732e7e8fe195ca2f64709fcf3b0a4a085c"},
+]
+build = [
+    {file = "build-0.8.0-py3-none-any.whl", hash = "sha256:19b0ed489f92ace6947698c3ca8436cb0556a66e2aa2d34cd70e2a5d27cd0437"},
+    {file = "build-0.8.0.tar.gz", hash = "sha256:887a6d471c901b1a6e6574ebaeeebb45e5269a79d095fe9a8f88d6614ed2e5f0"},
+]
+bump2version = [
+    {file = "bump2version-1.0.1-py2.py3-none-any.whl", hash = "sha256:37f927ea17cde7ae2d7baf832f8e80ce3777624554a653006c9144f8017fe410"},
+    {file = "bump2version-1.0.1.tar.gz", hash = "sha256:762cb2bfad61f4ec8e2bdf452c7c267416f8c70dd9ecb1653fd0bbb01fa936e6"},
+]
+certifi = [
+    {file = "certifi-2022.9.24-py3-none-any.whl", hash = "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"},
+    {file = "certifi-2022.9.24.tar.gz", hash = "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14"},
+]
+cffi = [
+    {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"},
+    {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"},
+    {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"},
+    {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"},
+    {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"},
+    {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"},
+    {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"},
+    {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"},
+    {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"},
+    {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"},
+    {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"},
+    {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"},
+    {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"},
+    {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"},
+    {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"},
+    {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"},
+    {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"},
+    {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"},
+    {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"},
+    {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"},
+    {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"},
+    {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"},
+    {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"},
+    {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"},
+    {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"},
+    {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"},
+    {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"},
+    {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"},
+    {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"},
+    {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"},
+    {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"},
+    {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"},
+    {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"},
+    {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"},
+    {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"},
+    {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"},
+    {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"},
+    {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"},
+    {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"},
+    {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"},
+    {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"},
+    {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"},
+    {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"},
+    {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"},
+    {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"},
+    {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"},
+    {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"},
+    {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"},
+    {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"},
+    {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"},
+    {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"},
+    {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"},
+    {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"},
+    {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"},
+    {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"},
+    {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"},
+    {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"},
+    {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"},
+    {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"},
+    {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"},
+    {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"},
+    {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"},
+    {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"},
+    {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"},
+]
+charset-normalizer = [
+    {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"},
+    {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"},
+]
+click = [
+    {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
+    {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
+]
+cloudpickle = [
+    {file = "cloudpickle-2.2.0-py3-none-any.whl", hash = "sha256:7428798d5926d8fcbfd092d18d01a2a03daf8237d8fcdc8095d256b8490796f0"},
+    {file = "cloudpickle-2.2.0.tar.gz", hash = "sha256:3f4219469c55453cfe4737e564b67c2a149109dabf7f242478948b895f61106f"},
+]
+cogapp = [
+    {file = "cogapp-3.3.0-py2.py3-none-any.whl", hash = "sha256:8b5b5f6063d8ee231961c05da010cb27c30876b2279e23ad0eae5f8f09460d50"},
+    {file = "cogapp-3.3.0.tar.gz", hash = "sha256:1be95183f70282422d594fa42426be6923070a4bd8335621f6347f3aeee81db0"},
+]
+colorama = [
+    {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"},
+    {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"},
+]
+commonmark = [
+    {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"},
+    {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"},
+]
+cryptography = [
+    {file = "cryptography-38.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:10d1f29d6292fc95acb597bacefd5b9e812099d75a6469004fd38ba5471a977f"},
+    {file = "cryptography-38.0.1-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:3fc26e22840b77326a764ceb5f02ca2d342305fba08f002a8c1f139540cdfaad"},
+    {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:3b72c360427889b40f36dc214630e688c2fe03e16c162ef0aa41da7ab1455153"},
+    {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:194044c6b89a2f9f169df475cc167f6157eb9151cc69af8a2a163481d45cc407"},
+    {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca9f6784ea96b55ff41708b92c3f6aeaebde4c560308e5fbbd3173fbc466e94e"},
+    {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:16fa61e7481f4b77ef53991075de29fc5bacb582a1244046d2e8b4bb72ef66d0"},
+    {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d4ef6cc305394ed669d4d9eebf10d3a101059bdcf2669c366ec1d14e4fb227bd"},
+    {file = "cryptography-38.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3261725c0ef84e7592597606f6583385fed2a5ec3909f43bc475ade9729a41d6"},
+    {file = "cryptography-38.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:0297ffc478bdd237f5ca3a7dc96fc0d315670bfa099c04dc3a4a2172008a405a"},
+    {file = "cryptography-38.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:89ed49784ba88c221756ff4d4755dbc03b3c8d2c5103f6d6b4f83a0fb1e85294"},
+    {file = "cryptography-38.0.1-cp36-abi3-win32.whl", hash = "sha256:ac7e48f7e7261207d750fa7e55eac2d45f720027d5703cd9007e9b37bbb59ac0"},
+    {file = "cryptography-38.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:ad7353f6ddf285aeadfaf79e5a6829110106ff8189391704c1d8801aa0bae45a"},
+    {file = "cryptography-38.0.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:896dd3a66959d3a5ddcfc140a53391f69ff1e8f25d93f0e2e7830c6de90ceb9d"},
+    {file = "cryptography-38.0.1-pp37-pypy37_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:d3971e2749a723e9084dd507584e2a2761f78ad2c638aa31e80bc7a15c9db4f9"},
+    {file = "cryptography-38.0.1-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:79473cf8a5cbc471979bd9378c9f425384980fcf2ab6534b18ed7d0d9843987d"},
+    {file = "cryptography-38.0.1-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:d9e69ae01f99abe6ad646947bba8941e896cb3aa805be2597a0400e0764b5818"},
+    {file = "cryptography-38.0.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5067ee7f2bce36b11d0e334abcd1ccf8c541fc0bbdaf57cdd511fdee53e879b6"},
+    {file = "cryptography-38.0.1-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:3e3a2599e640927089f932295a9a247fc40a5bdf69b0484532f530471a382750"},
+    {file = "cryptography-38.0.1-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c2e5856248a416767322c8668ef1845ad46ee62629266f84a8f007a317141013"},
+    {file = "cryptography-38.0.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:64760ba5331e3f1794d0bcaabc0d0c39e8c60bf67d09c93dc0e54189dfd7cfe5"},
+    {file = "cryptography-38.0.1-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b6c9b706316d7b5a137c35e14f4103e2115b088c412140fdbd5f87c73284df61"},
+    {file = "cryptography-38.0.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0163a849b6f315bf52815e238bc2b2346604413fa7c1601eea84bcddb5fb9ac"},
+    {file = "cryptography-38.0.1-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:d1a5bd52d684e49a36582193e0b89ff267704cd4025abefb9e26803adeb3e5fb"},
+    {file = "cryptography-38.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:765fa194a0f3372d83005ab83ab35d7c5526c4e22951e46059b8ac678b44fa5a"},
+    {file = "cryptography-38.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:52e7bee800ec869b4031093875279f1ff2ed12c1e2f74923e8f49c916afd1d3b"},
+    {file = "cryptography-38.0.1.tar.gz", hash = "sha256:1db3d807a14931fa317f96435695d9ec386be7b84b618cc61cfa5d08b0ae33d7"},
+]
+docutils = [
+    {file = "docutils-0.19-py3-none-any.whl", hash = "sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc"},
+    {file = "docutils-0.19.tar.gz", hash = "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6"},
+]
+doit = [
+    {file = "doit-0.36.0-py3-none-any.whl", hash = "sha256:ebc285f6666871b5300091c26eafdff3de968a6bd60ea35dd1e3fc6f2e32479a"},
+    {file = "doit-0.36.0.tar.gz", hash = "sha256:71d07ccc9514cb22fe59d98999577665eaab57e16f644d04336ae0b4bae234bc"},
+]
+future = [
+    {file = "future-0.18.2.tar.gz", hash = "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"},
+]
+ghp-import = [
+    {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"},
+    {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"},
+]
+griffe = [
+    {file = "griffe-0.22.2-py3-none-any.whl", hash = "sha256:cea5415ac6a92f4a22638e3f1f2e661402bac09fb8e8266936d67185a7e0d0fb"},
+    {file = "griffe-0.22.2.tar.gz", hash = "sha256:1408e336a4155392bbd81eed9f2f44bf144e71b9c664e905630affe83bbc088e"},
+]
+idna = [
+    {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"},
+    {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"},
+]
+importlib-metadata = [
+    {file = "importlib_metadata-4.12.0-py3-none-any.whl", hash = "sha256:7401a975809ea1fdc658c3aa4f78cc2195a0e019c5cbc4c06122884e9ae80c23"},
+    {file = "importlib_metadata-4.12.0.tar.gz", hash = "sha256:637245b8bab2b6502fcbc752cc4b7a6f6243bb02b31c5c26156ad103d3d45670"},
+]
+iniconfig = [
+    {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
+    {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
+]
+"jaraco.classes" = [
+    {file = "jaraco.classes-3.2.3-py3-none-any.whl", hash = "sha256:2353de3288bc6b82120752201c6b1c1a14b058267fa424ed5ce5984e3b922158"},
+    {file = "jaraco.classes-3.2.3.tar.gz", hash = "sha256:89559fa5c1d3c34eff6f631ad80bb21f378dbcbb35dd161fd2c6b93f5be2f98a"},
+]
+jeepney = [
+    {file = "jeepney-0.8.0-py3-none-any.whl", hash = "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755"},
+    {file = "jeepney-0.8.0.tar.gz", hash = "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806"},
+]
+Jinja2 = [
+    {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"},
+    {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"},
+]
+keyring = [
+    {file = "keyring-23.9.3-py3-none-any.whl", hash = "sha256:69732a15cb1433bdfbc3b980a8a36a04878a6cfd7cb99f497b573f31618001c0"},
+    {file = "keyring-23.9.3.tar.gz", hash = "sha256:69b01dd83c42f590250fe7a1f503fc229b14de83857314b1933a3ddbf595c4a5"},
+]
+macholib = [
+    {file = "macholib-1.16.2-py2.py3-none-any.whl", hash = "sha256:44c40f2cd7d6726af8fa6fe22549178d3a4dfecc35a9cd15ea916d9c83a688e0"},
+    {file = "macholib-1.16.2.tar.gz", hash = "sha256:557bbfa1bb255c20e9abafe7ed6cd8046b48d9525db2f9b77d3122a63a2a8bf8"},
+]
+Markdown = [
+    {file = "Markdown-3.3.7-py3-none-any.whl", hash = "sha256:f5da449a6e1c989a4cea2631aa8ee67caa5a2ef855d551c88f9e309f4634c621"},
+    {file = "Markdown-3.3.7.tar.gz", hash = "sha256:cbb516f16218e643d8e0a95b309f77eb118cb138d39a4f27851e6a63581db874"},
+]
+MarkupSafe = [
+    {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"},
+    {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"},
+    {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"},
+    {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"},
+    {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"},
+    {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"},
+    {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"},
+    {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"},
+    {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"},
+    {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"},
+    {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"},
+    {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"},
+    {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"},
+    {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"},
+    {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"},
+    {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"},
+    {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"},
+    {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"},
+    {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"},
+    {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"},
+    {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"},
+    {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"},
+    {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"},
+    {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"},
+    {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"},
+    {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"},
+    {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"},
+    {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"},
+    {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"},
+    {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"},
+    {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"},
+    {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"},
+    {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"},
+    {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"},
+    {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"},
+    {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"},
+    {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"},
+    {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"},
+    {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"},
+    {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"},
+]
+mergedeep = [
+    {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"},
+    {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"},
+]
+mkdocs = [
+    {file = "mkdocs-1.4.0-py3-none-any.whl", hash = "sha256:ce057e9992f017b8e1496b591b6c242cbd34c2d406e2f9af6a19b97dd6248faa"},
+    {file = "mkdocs-1.4.0.tar.gz", hash = "sha256:e5549a22d59e7cb230d6a791edd2c3d06690908454c0af82edc31b35d57e3069"},
+]
+mkdocs-autorefs = [
+    {file = "mkdocs-autorefs-0.4.1.tar.gz", hash = "sha256:70748a7bd025f9ecd6d6feeba8ba63f8e891a1af55f48e366d6d6e78493aba84"},
+    {file = "mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"},
+]
+mkdocs-material = [
+    {file = "mkdocs_material-8.5.4-py3-none-any.whl", hash = "sha256:aec2f0f2143109f8388aadf76e6fff749a2b74ebe730d0f674c65b53da89d19d"},
+    {file = "mkdocs_material-8.5.4.tar.gz", hash = "sha256:70dc47820d4765b77968b9119f2957d09b4d8d328d950bee4544ff224d5c7b36"},
+]
+mkdocs-material-extensions = [
+    {file = "mkdocs-material-extensions-1.0.3.tar.gz", hash = "sha256:bfd24dfdef7b41c312ede42648f9eb83476ea168ec163b613f9abd12bbfddba2"},
+    {file = "mkdocs_material_extensions-1.0.3-py3-none-any.whl", hash = "sha256:a82b70e533ce060b2a5d9eb2bc2e1be201cf61f901f93704b4acf6e3d5983a44"},
+]
+mkdocstrings = [
+    {file = "mkdocstrings-0.19.0-py3-none-any.whl", hash = "sha256:3217d510d385c961f69385a670b2677e68e07b5fea4a504d86bf54c006c87c7d"},
+    {file = "mkdocstrings-0.19.0.tar.gz", hash = "sha256:efa34a67bad11229d532d89f6836a8a215937548623b64f3698a1df62e01cc3e"},
+]
+mkdocstrings-python = [
+    {file = "mkdocstrings-python-0.7.1.tar.gz", hash = "sha256:c334b382dca202dfa37071c182418a6df5818356a95d54362a2b24822ca3af71"},
+    {file = "mkdocstrings_python-0.7.1-py3-none-any.whl", hash = "sha256:a22060bfa374697678e9af4e62b020d990dad2711c98f7a9fac5c0345bef93c7"},
+]
+more-itertools = [
+    {file = "more-itertools-8.14.0.tar.gz", hash = "sha256:c09443cd3d5438b8dafccd867a6bc1cb0894389e90cb53d227456b0b0bccb750"},
+    {file = "more_itertools-8.14.0-py3-none-any.whl", hash = "sha256:1bc4f91ee5b1b31ac7ceacc17c09befe6a40a503907baf9c839c229b5095cfd2"},
+]
+packaging = [
+    {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
+    {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
+]
+pefile = [
+    {file = "pefile-2022.5.30.tar.gz", hash = "sha256:a5488a3dd1fd021ce33f969780b88fe0f7eebb76eb20996d7318f307612a045b"},
+]
+pep517 = [
+    {file = "pep517-0.13.0-py3-none-any.whl", hash = "sha256:4ba4446d80aed5b5eac6509ade100bff3e7943a8489de249654a5ae9b33ee35b"},
+    {file = "pep517-0.13.0.tar.gz", hash = "sha256:ae69927c5c172be1add9203726d4b84cf3ebad1edcd5f71fcdc746e66e829f59"},
+]
+pkginfo = [
+    {file = "pkginfo-1.8.3-py2.py3-none-any.whl", hash = "sha256:848865108ec99d4901b2f7e84058b6e7660aae8ae10164e015a6dcf5b242a594"},
+    {file = "pkginfo-1.8.3.tar.gz", hash = "sha256:a84da4318dd86f870a9447a8c98340aa06216bfc6f2b7bdc4b8766984ae1867c"},
+]
+pluggy = [
+    {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
+    {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
+]
+py = [
+    {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
+    {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
+]
+py-applescript = [
+    {file = "py-applescript-1.0.3.tar.gz", hash = "sha256:fa22c955fc25b3d24e03e66825b36a721897ec0d9b6ce185a4d177e2d1ecfa6b"},
+    {file = "py_applescript-1.0.3-py3-none-any.whl", hash = "sha256:eb21dfc6e845a40be602372a5c3d6317f7ad3c999c2c355a00d1a19bdb1d57c0"},
+]
+pycparser = [
+    {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
+    {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
+]
+Pygments = [
+    {file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"},
+    {file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"},
+]
+pyinstaller = [
+    {file = "pyinstaller-5.4.1-py3-none-macosx_10_13_universal2.whl", hash = "sha256:0ac78f1145be34adda8afb5fe4c8d659172140092c055994dab57ee2190bec71"},
+    {file = "pyinstaller-5.4.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:806df33e2b93a69f45e4c8be72d3b51449e9e0f557a3e89e0b06f5cb9a111022"},
+    {file = "pyinstaller-5.4.1-py3-none-manylinux2014_i686.whl", hash = "sha256:9a2b99f191e28dec6cb8eb5544fa436f2109073e634f8e602225ada0239aed46"},
+    {file = "pyinstaller-5.4.1-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4b4d867fbc6c35bb30b09f681d469f824c69fe32f2358b8af75247bdefdf694a"},
+    {file = "pyinstaller-5.4.1-py3-none-manylinux2014_s390x.whl", hash = "sha256:994b76d10892f1d3f9a4241109a3f7b1e9b74dea3198bf3f502d2f4ae744fa6e"},
+    {file = "pyinstaller-5.4.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:b57e8f9ec23a48b740c5dbb19cd5499150e35a2997dfd0b8f96e2deced512ecc"},
+    {file = "pyinstaller-5.4.1-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:bec1d94cba89f64a61606a1eaf33202f3a52ff2ac65e25555053fd5fe55b760d"},
+    {file = "pyinstaller-5.4.1-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:f7f0ad1bdea828c163e2032c7ce941b2d0b68a83b3b572e6ce50c77ca7987574"},
+    {file = "pyinstaller-5.4.1-py3-none-win32.whl", hash = "sha256:7da11fdee216ae0b887f9d1fe0eca7bce2b8f5d039eeac2561282feff5970d24"},
+    {file = "pyinstaller-5.4.1-py3-none-win_amd64.whl", hash = "sha256:0c8626c2db6e2d426be3e44ae08039d638481de7b580769668dd777116d911b9"},
+    {file = "pyinstaller-5.4.1.tar.gz", hash = "sha256:2a09e6bd6e121eb1a71fadb223797dc502e4fd4168931c31a5f87faa10eb5b4c"},
+]
+pyinstaller-hooks-contrib = [
+    {file = "pyinstaller-hooks-contrib-2022.10.tar.gz", hash = "sha256:e5edd4094175e78c178ef987b61be19efff6caa23d266ade456fc753e847f62e"},
+    {file = "pyinstaller_hooks_contrib-2022.10-py2.py3-none-any.whl", hash = "sha256:d1dd6ea059dc30e77813cc12a5efa8b1d228e7da8f5b884fe11775f946db1784"},
+]
+pymdown-extensions = [
+    {file = "pymdown_extensions-9.6-py3-none-any.whl", hash = "sha256:1e36490adc7bfcef1fdb21bb0306e93af99cff8ec2db199bd17e3bf009768c11"},
+    {file = "pymdown_extensions-9.6.tar.gz", hash = "sha256:b956b806439bbff10f726103a941266beb03fbe99f897c7d5e774d7170339ad9"},
+]
+pyobjc-core = [
+    {file = "pyobjc-core-8.5.1.tar.gz", hash = "sha256:f8592a12de076c27006700c4a46164478564fa33d7da41e7cbdd0a3bf9ddbccf"},
+    {file = "pyobjc_core-8.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b62dcf987cc511188fc2aa5b4d3b9fd895361ea4984380463497ce4b0752ddf4"},
+    {file = "pyobjc_core-8.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0accc653501a655f66c13f149a1d3d30e6cb65824edf852f7960a00c4f930d5b"},
+    {file = "pyobjc_core-8.5.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f82b32affc898e9e5af041c1cecde2c99f2ce160b87df77f678c99f1550a4655"},
+    {file = "pyobjc_core-8.5.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f7b2f6b6f3caeb882c658fe0c7098be2e8b79893d84daa8e636cb3e58a07df00"},
+    {file = "pyobjc_core-8.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:872c0202c911a5a2f1269261c168e36569f6ddac17e5d854ac19e581726570cc"},
+    {file = "pyobjc_core-8.5.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:21f92e231a4bae7f2d160d065f5afbf5e859a1e37f29d34ac12592205fc8c108"},
+    {file = "pyobjc_core-8.5.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:315334dd09781129af6a39641248891c4caa57043901750b0139c6614ce84ec0"},
+]
+pyobjc-framework-AppleScriptKit = [
+    {file = "pyobjc-framework-AppleScriptKit-8.5.1.tar.gz", hash = "sha256:48b2574025c8d25fb7e98559cecfa7d58d8519b26d81f317623e28515d6765b5"},
+    {file = "pyobjc_framework_AppleScriptKit-8.5.1-py2.py3-none-any.whl", hash = "sha256:13ce8f0c4f9fa45335c3b60494eb513b9fcf7271d89c02c58ecddd0e52a75a26"},
+]
+pyobjc-framework-AppleScriptObjC = [
+    {file = "pyobjc-framework-AppleScriptObjC-8.5.1.tar.gz", hash = "sha256:9d9ae445c16cb193eadca79916e5cdb1c89306d1962a349270bd06a0a762e0d0"},
+    {file = "pyobjc_framework_AppleScriptObjC-8.5.1-py2.py3-none-any.whl", hash = "sha256:51ac4a445096663e9665fb33fd0d517a32d50a6277c4403bf96349632c23d9d8"},
+]
+pyobjc-framework-AVFoundation = [
+    {file = "pyobjc-framework-AVFoundation-8.5.1.tar.gz", hash = "sha256:99ccd94c5e31a599310b2cee2eb9c0d77c61d1eab4b799157b8982956ca30a24"},
+    {file = "pyobjc_framework_AVFoundation-8.5.1-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:206fd060c704c0c97e5f282b6a362cfb4007a522e18bc9ab6a80d450edd6fb08"},
+    {file = "pyobjc_framework_AVFoundation-8.5.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ca2887b77fb5c1ec443abb0328fbf1e6bbdf950af0ca82b9574a09969c4205e1"},
+    {file = "pyobjc_framework_AVFoundation-8.5.1-cp36-abi3-macosx_11_0_universal2.whl", hash = "sha256:ffab98255b8d4e56270cd30185f3b518e86dbb0d95e904b95679e311db3d80b3"},
+]
+pyobjc-framework-Cocoa = [
+    {file = "pyobjc-framework-Cocoa-8.5.1.tar.gz", hash = "sha256:9a3de5cdb4644e85daf53f2ed912ef6c16ea5804a9e65552eafe62c2e139eb8c"},
+    {file = "pyobjc_framework_Cocoa-8.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:aa572acc2628488a47be8d19f4701fc96fce7377cc4da18316e1e08c3918521a"},
+    {file = "pyobjc_framework_Cocoa-8.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cb3ae21c8d81b7f02a891088c623cef61bca89bd671eff58c632d2f926b649f3"},
+    {file = "pyobjc_framework_Cocoa-8.5.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:88f08f5bd94c66d373d8413c1d08218aff4cff0b586e0cc4249b2284023e7577"},
+    {file = "pyobjc_framework_Cocoa-8.5.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:063683b57e4bd88cb0f9631ae65d25ec4eecf427d2fe8d0c578f88da9c896f3f"},
+    {file = "pyobjc_framework_Cocoa-8.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8f8806ddfac40620fb27f185d0f8937e69e330617319ecc2eccf6b9c8451bdd1"},
+    {file = "pyobjc_framework_Cocoa-8.5.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:7733a9a201df9e0cc2a0cf7bf54d76bd7981cba9b599353b243e3e0c9eefec10"},
+    {file = "pyobjc_framework_Cocoa-8.5.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f0ab227f99d3e25dd3db73f8cde0999914a5f0dd6a08600349d25f95eaa0da63"},
+]
+pyobjc-framework-CoreAudio = [
+    {file = "pyobjc-framework-CoreAudio-8.5.1.tar.gz", hash = "sha256:6dd76697c8ae018a0bbf7eadf26b05ec6da3f2865107a83bfd46d2e7fc557da1"},
+    {file = "pyobjc_framework_CoreAudio-8.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec4caca2087c86d8dd6033d4f7cc71b14c713cb739bb50b9da67446ef43fb91e"},
+    {file = "pyobjc_framework_CoreAudio-8.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d3bf64a2cc0e6ff9c3913b5dcac52b2d99bd5d72a17c6d8a27ff8f9a11e7bb56"},
+    {file = "pyobjc_framework_CoreAudio-8.5.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:30ccec3945ecb34e3a7d811d4fc3927404b84c47cb7c3942a2c71ab9e2766dbe"},
+    {file = "pyobjc_framework_CoreAudio-8.5.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:30a4a97b307b401188aacdef8653a8bc6fd6c4e0abd4de1c6b8b425dc5ef6064"},
+    {file = "pyobjc_framework_CoreAudio-8.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b6121d22161152431c9764194061254b07ed264594f91b80df1dff3bc301b548"},
+    {file = "pyobjc_framework_CoreAudio-8.5.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:a7b41f3c14ec14e15a2b8deb8d4771df97ec2c1739b7b1fc6d470b6b331d0999"},
+    {file = "pyobjc_framework_CoreAudio-8.5.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c042e08e191a7ec7c62f8395e3a1a024b978b8007e2cc782a63433a9a918a6c8"},
+]
+pyobjc-framework-CoreMedia = [
+    {file = "pyobjc-framework-CoreMedia-8.5.1.tar.gz", hash = "sha256:01f378b9e4697d0a986800c4c5290aa70453e35890acda07c785154fe3d849df"},
+    {file = "pyobjc_framework_CoreMedia-8.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:54e3d4945d425c70e992d5ad27d34f016ebab61572b12a3d0039ab4be1a278dd"},
+    {file = "pyobjc_framework_CoreMedia-8.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:89c87f25ce6f47addfaaeaec4dc661781444670a93ec969d2c641a5f5469d246"},
+    {file = "pyobjc_framework_CoreMedia-8.5.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:71c1eb6e1352aca0e11e4c91f25aee3e118aa9efa46bd7996522c59f66ccb0a1"},
+    {file = "pyobjc_framework_CoreMedia-8.5.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:241c0584a90003852b653e80ac434c4ef9028700eb5b810e64ebfc6c162ae633"},
+    {file = "pyobjc_framework_CoreMedia-8.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:482793232aa8c1b6e1103785ada8952149dee65d6b45e315a7176d80c9cb5784"},
+    {file = "pyobjc_framework_CoreMedia-8.5.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:1e0104a70950972796e6e88a6ad74fd31e8be08985274e6738b479953f2711de"},
+    {file = "pyobjc_framework_CoreMedia-8.5.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9fe88bb0e8d7573cc9d996401de329dbb68d07e3c26f5345c19de71dbcf037d6"},
+]
+pyobjc-framework-CoreServices = [
+    {file = "pyobjc-framework-CoreServices-8.5.1.tar.gz", hash = "sha256:97e8b27b32a32ca7d7d0c780b4e5812a09b109f9867fc3890031f2bb42fd37ee"},
+    {file = "pyobjc_framework_CoreServices-8.5.1-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:3b1c66778184f34c68ea266990cc4d3dd85c085b88ce7d6d222d21f57652d64a"},
+    {file = "pyobjc_framework_CoreServices-8.5.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:f6789f3a571893eb6ed298d81749d9726a9a33050f686d9b491eacd1aea81aed"},
+    {file = "pyobjc_framework_CoreServices-8.5.1-cp36-abi3-macosx_11_0_universal2.whl", hash = "sha256:09edfb4e0b2673addb91b8064f418df3d868fd1064758762e14c9d9ddaeda547"},
+]
+pyobjc-framework-FSEvents = [
+    {file = "pyobjc-framework-FSEvents-8.5.1.tar.gz", hash = "sha256:c25ec45f6154ee2a42437a33bad6d1ada24f6791742f22d7ab632a524f4dc99f"},
+    {file = "pyobjc_framework_FSEvents-8.5.1-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:e0d88a03e83087c94cc1421e88a21f31fdfbd45777080a56d18e720bb40b8bd6"},
+    {file = "pyobjc_framework_FSEvents-8.5.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:578e0d021db87289faca431dedc4a2b433f1d902a5dff5ae322d83562929b8c2"},
+    {file = "pyobjc_framework_FSEvents-8.5.1-cp36-abi3-macosx_11_0_universal2.whl", hash = "sha256:4d54949c8559f927456db9c51b2197fe0c8455347b481ca92bcb9cfd872461a1"},
+]
+pyobjc-framework-Quartz = [
+    {file = "pyobjc-framework-Quartz-8.5.1.tar.gz", hash = "sha256:37ba625b6c240d4f5aea02f1672b16e53e39de07dbaf33751a61b5a0be7dc9fa"},
+    {file = "pyobjc_framework_Quartz-8.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fe4433be49dafe7efb378bd701583c6ffc93a899e23f1dec743298bc6da5aed6"},
+    {file = "pyobjc_framework_Quartz-8.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:54f30895cd97a08b67dc3cf2cb032a9ebfe8b80458cddab0b32eb903da8fe447"},
+    {file = "pyobjc_framework_Quartz-8.5.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:5ee24f34d35da7360ca6c790a93a2b61dd6f74a14efef42344555ad0db4e2f8a"},
+    {file = "pyobjc_framework_Quartz-8.5.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:577c912c8d59519a0c5c70b8984406d82f771bca4e399a1f29f224b885012f17"},
+    {file = "pyobjc_framework_Quartz-8.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4599337079050512179229625d97c1494ee4821fcbb82b1a209ed498d7e476af"},
+    {file = "pyobjc_framework_Quartz-8.5.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:9b7c1030fda93695f8c0bbbe03fce3251a351ca392dd6b03f94805d3afaadd16"},
+    {file = "pyobjc_framework_Quartz-8.5.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8790fd2499fb1a80d49d8991739e15c5604dba4c8ece87e4afa2f3f679fa0f12"},
+]
+pyobjc-framework-ScriptingBridge = [
+    {file = "pyobjc-framework-ScriptingBridge-8.5.1.tar.gz", hash = "sha256:51b2da03b4c7d12ac814e953bfa897d1763683d4f9293f76a0ecc54745cc64d6"},
+    {file = "pyobjc_framework_ScriptingBridge-8.5.1-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:ad3d5452a0ca8769ab5cfb47cb8ba87b9002c0be518162937d507e778cf3a634"},
+    {file = "pyobjc_framework_ScriptingBridge-8.5.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:25fd56eaddecd09ad75620e5f80a7befa02bb175b5c696a54d2742829f7984b2"},
+    {file = "pyobjc_framework_ScriptingBridge-8.5.1-cp36-abi3-macosx_11_0_universal2.whl", hash = "sha256:4ec4feef84502a882e55baddf11b55d5a2c1d8de8d3da908c5ef8be9c1460c9f"},
+]
+pyparsing = [
+    {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
+    {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
+]
+pytest = [
+    {file = "pytest-7.1.3-py3-none-any.whl", hash = "sha256:1377bda3466d70b55e3f5cecfa55bb7cfcf219c7964629b967c37cf0bda818b7"},
+    {file = "pytest-7.1.3.tar.gz", hash = "sha256:4f365fec2dff9c1162f834d9f18af1ba13062db0c708bf7b946f8a5c76180c39"},
+]
+python-dateutil = [
+    {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
+    {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
+]
+pywin32-ctypes = [
+    {file = "pywin32-ctypes-0.2.0.tar.gz", hash = "sha256:24ffc3b341d457d48e8922352130cf2644024a4ff09762a2261fd34c36ee5942"},
+    {file = "pywin32_ctypes-0.2.0-py2.py3-none-any.whl", hash = "sha256:9dc2d991b3479cc2df15930958b674a48a227d5361d413827a4cfd0b5876fc98"},
+]
+PyYAML = [
+    {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"},
+    {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"},
+    {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"},
+    {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"},
+    {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"},
+    {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"},
+    {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"},
+    {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"},
+    {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"},
+    {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"},
+    {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"},
+    {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"},
+    {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"},
+    {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"},
+    {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"},
+    {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"},
+    {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"},
+    {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"},
+    {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"},
+    {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"},
+    {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"},
+    {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"},
+    {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"},
+    {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"},
+    {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"},
+    {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"},
+    {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"},
+    {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"},
+    {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"},
+    {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"},
+    {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"},
+    {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"},
+    {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"},
+    {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"},
+    {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"},
+    {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"},
+    {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"},
+    {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"},
+    {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"},
+    {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
+]
+pyyaml_env_tag = [
+    {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"},
+    {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"},
+]
+readme-renderer = [
+    {file = "readme_renderer-37.2-py3-none-any.whl", hash = "sha256:d3f06a69e8c40fca9ab3174eca48f96d9771eddb43517b17d96583418427b106"},
+    {file = "readme_renderer-37.2.tar.gz", hash = "sha256:e8ad25293c98f781dbc2c5a36a309929390009f902f99e1798c761aaf04a7923"},
+]
+requests = [
+    {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"},
+    {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"},
+]
+requests-toolbelt = [
+    {file = "requests-toolbelt-0.9.1.tar.gz", hash = "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"},
+    {file = "requests_toolbelt-0.9.1-py2.py3-none-any.whl", hash = "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f"},
+]
+rfc3986 = [
+    {file = "rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd"},
+    {file = "rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c"},
+]
+rich = [
+    {file = "rich-12.5.1-py3-none-any.whl", hash = "sha256:2eb4e6894cde1e017976d2975ac210ef515d7548bc595ba20e195fb9628acdeb"},
+    {file = "rich-12.5.1.tar.gz", hash = "sha256:63a5c5ce3673d3d5fbbf23cd87e11ab84b6b451436f1b7f19ec54b6bc36ed7ca"},
+]
+SecretStorage = [
+    {file = "SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99"},
+    {file = "SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77"},
+]
+setuptools = [
+    {file = "setuptools-65.4.1-py3-none-any.whl", hash = "sha256:1b6bdc6161661409c5f21508763dc63ab20a9ac2f8ba20029aaaa7fdb9118012"},
+    {file = "setuptools-65.4.1.tar.gz", hash = "sha256:3050e338e5871e70c72983072fe34f6032ae1cdeeeb67338199c2f74e083a80e"},
+]
+six = [
+    {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
+    {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
+]
+tomli = [
+    {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
+    {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
+]
+twine = [
+    {file = "twine-4.0.1-py3-none-any.whl", hash = "sha256:42026c18e394eac3e06693ee52010baa5313e4811d5a11050e7d48436cf41b9e"},
+    {file = "twine-4.0.1.tar.gz", hash = "sha256:96b1cf12f7ae611a4a40b6ae8e9570215daff0611828f5fe1f37a16255ab24a0"},
+]
+typing-extensions = [
+    {file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"},
+    {file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"},
+]
+urllib3 = [
+    {file = "urllib3-1.26.12-py2.py3-none-any.whl", hash = "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"},
+    {file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"},
+]
+watchdog = [
+    {file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a735a990a1095f75ca4f36ea2ef2752c99e6ee997c46b0de507ba40a09bf7330"},
+    {file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b17d302850c8d412784d9246cfe8d7e3af6bcd45f958abb2d08a6f8bedf695d"},
+    {file = "watchdog-2.1.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ee3e38a6cc050a8830089f79cbec8a3878ec2fe5160cdb2dc8ccb6def8552658"},
+    {file = "watchdog-2.1.9-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:64a27aed691408a6abd83394b38503e8176f69031ca25d64131d8d640a307591"},
+    {file = "watchdog-2.1.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:195fc70c6e41237362ba720e9aaf394f8178bfc7fa68207f112d108edef1af33"},
+    {file = "watchdog-2.1.9-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:bfc4d351e6348d6ec51df007432e6fe80adb53fd41183716017026af03427846"},
+    {file = "watchdog-2.1.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8250546a98388cbc00c3ee3cc5cf96799b5a595270dfcfa855491a64b86ef8c3"},
+    {file = "watchdog-2.1.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:117ffc6ec261639a0209a3252546b12800670d4bf5f84fbd355957a0595fe654"},
+    {file = "watchdog-2.1.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:97f9752208f5154e9e7b76acc8c4f5a58801b338de2af14e7e181ee3b28a5d39"},
+    {file = "watchdog-2.1.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:247dcf1df956daa24828bfea5a138d0e7a7c98b1a47cf1fa5b0c3c16241fcbb7"},
+    {file = "watchdog-2.1.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:226b3c6c468ce72051a4c15a4cc2ef317c32590d82ba0b330403cafd98a62cfd"},
+    {file = "watchdog-2.1.9-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d9820fe47c20c13e3c9dd544d3706a2a26c02b2b43c993b62fcd8011bcc0adb3"},
+    {file = "watchdog-2.1.9-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:70af927aa1613ded6a68089a9262a009fbdf819f46d09c1a908d4b36e1ba2b2d"},
+    {file = "watchdog-2.1.9-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ed80a1628cee19f5cfc6bb74e173f1b4189eb532e705e2a13e3250312a62e0c9"},
+    {file = "watchdog-2.1.9-py3-none-manylinux2014_aarch64.whl", hash = "sha256:9f05a5f7c12452f6a27203f76779ae3f46fa30f1dd833037ea8cbc2887c60213"},
+    {file = "watchdog-2.1.9-py3-none-manylinux2014_armv7l.whl", hash = "sha256:255bb5758f7e89b1a13c05a5bceccec2219f8995a3a4c4d6968fe1de6a3b2892"},
+    {file = "watchdog-2.1.9-py3-none-manylinux2014_i686.whl", hash = "sha256:d3dda00aca282b26194bdd0adec21e4c21e916956d972369359ba63ade616153"},
+    {file = "watchdog-2.1.9-py3-none-manylinux2014_ppc64.whl", hash = "sha256:186f6c55abc5e03872ae14c2f294a153ec7292f807af99f57611acc8caa75306"},
+    {file = "watchdog-2.1.9-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:083171652584e1b8829581f965b9b7723ca5f9a2cd7e20271edf264cfd7c1412"},
+    {file = "watchdog-2.1.9-py3-none-manylinux2014_s390x.whl", hash = "sha256:b530ae007a5f5d50b7fbba96634c7ee21abec70dc3e7f0233339c81943848dc1"},
+    {file = "watchdog-2.1.9-py3-none-manylinux2014_x86_64.whl", hash = "sha256:4f4e1c4aa54fb86316a62a87b3378c025e228178d55481d30d857c6c438897d6"},
+    {file = "watchdog-2.1.9-py3-none-win32.whl", hash = "sha256:5952135968519e2447a01875a6f5fc8c03190b24d14ee52b0f4b1682259520b1"},
+    {file = "watchdog-2.1.9-py3-none-win_amd64.whl", hash = "sha256:7a833211f49143c3d336729b0020ffd1274078e94b0ae42e22f596999f50279c"},
+    {file = "watchdog-2.1.9-py3-none-win_ia64.whl", hash = "sha256:ad576a565260d8f99d97f2e64b0f97a48228317095908568a9d5c786c829d428"},
+    {file = "watchdog-2.1.9.tar.gz", hash = "sha256:43ce20ebb36a51f21fa376f76d1d4692452b2527ccd601950d69ed36b9e21609"},
+]
+webencodings = [
+    {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"},
+    {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"},
+]
+wheel = [
+    {file = "wheel-0.37.1-py2.py3-none-any.whl", hash = "sha256:4bdcd7d840138086126cd09254dc6195fb4fc6f01c050a1d7236f2630db1d22a"},
+    {file = "wheel-0.37.1.tar.gz", hash = "sha256:e9a504e793efbca1b8e0e9cb979a249cf4a0a7b5b8c9e8b65a5e39d49529c1c4"},
+]
+xattr = [
+    {file = "xattr-0.9.9-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:58a9fb4fd19b467e88f4b75b5243706caa57e312d3aee757b53b57c7fd0f4ba9"},
+    {file = "xattr-0.9.9-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e71efca59705c7abde5b7f76323ebe00ed2977f10cba4204b9421dada036b5ca"},
+    {file = "xattr-0.9.9-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:1aad96b6603961c3d1ca1aaa8369b1a8d684a7b37357b2428087c286bf0e561c"},
+    {file = "xattr-0.9.9-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:46cb74f98d31d9d70f975ec3e6554360a9bdcbb4b9fb50a69fabe54f9f928c97"},
+    {file = "xattr-0.9.9-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:80c2db56058a687d7439be041f916cbeb2943fbe2623e53d5da721a4552d8991"},
+    {file = "xattr-0.9.9-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:c360d1cc42e885b64d84f64de3c501dd7bce576248327ef583b4625ee63aa023"},
+    {file = "xattr-0.9.9-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:debd87afe6bdf88c3689bde52eecf2b166388b13ef7388259d23223374db417d"},
+    {file = "xattr-0.9.9-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:4280c9f33a8678828f1bbc3d3dc8b823b5e4a113ee5ecb0fb98bff60cc2b9ad1"},
+    {file = "xattr-0.9.9-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:e0916ec1656d2071cd3139d1f52426825985d8ed076f981ef7f0bc13dfa8e96c"},
+    {file = "xattr-0.9.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a517916fbf2f58a3222bb2048fe1eeff4e23e07a4ce6228a27de004c80bf53ab"},
+    {file = "xattr-0.9.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e886c882b3b28c7a684c3e3daf46347da5428a46b88bc6d62c4867d574b90c54"},
+    {file = "xattr-0.9.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:373e3d1fd9258438fc38d1438142d3659f36743f374a20457346ef26741ed441"},
+    {file = "xattr-0.9.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a7beeb54ca140273b2f6320bb98b701ec30628af2ebe4eb30f7051419eb4ef3"},
+    {file = "xattr-0.9.9-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef3ca29cdaae9c47c625d84bb6c9046f7275cccde0ea805caa23ca58d3671f3f"},
+    {file = "xattr-0.9.9-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c381d890931cd18b137ce3fb5c5f08b672c3c61e2e47b1a7442ee46e827abfe"},
+    {file = "xattr-0.9.9-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:59c5783ccf57cf2700ce57d51a92134900ed26f6ab20d209f383fb898903fea6"},
+    {file = "xattr-0.9.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:966b885b69d95362e2a12d39f84889cf857090e57263b5ac33409498aa00c160"},
+    {file = "xattr-0.9.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efaaf0cb1ea8e9febb7baad301ae8cc9ad7a96fdfc5c6399d165e7a19e3e61ce"},
+    {file = "xattr-0.9.9-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f19fa75ed1e9db86354efab29869cb2be6976d456bd7c89e67b118d5384a1d98"},
+    {file = "xattr-0.9.9-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ca28ad06828244b315214ee35388f57e81e90aac2ceac3f32e42ae394e31b9c"},
+    {file = "xattr-0.9.9-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:532c7f1656dd2fe937116b9e210229f716d7fc7ac142f9cdace7da92266d32e8"},
+    {file = "xattr-0.9.9-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11c28033c17e98c67e0def9d6ebd415ad3c006a7bc3fee6bad79c5e52d0dff49"},
+    {file = "xattr-0.9.9-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:473cabb30e544ea08c8c01c1ef18053147cdc8552d443ac97815e46fbb13c7d4"},
+    {file = "xattr-0.9.9-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:c4a308522b444d090fbd66a385c9519b6b977818226921b0d2fc403667c93564"},
+    {file = "xattr-0.9.9-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:82493434488aca72d88b5129dac8f212e7b8bdca7ceffe7bb977c850f2452e4e"},
+    {file = "xattr-0.9.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e41d289706c7e8940f4d08e865da6a8ae988123e40a44f9a97ddc09e67795d7d"},
+    {file = "xattr-0.9.9-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef08698e360cf43688dca3db3421b156b29948a714d5d089348073f463c11646"},
+    {file = "xattr-0.9.9-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4eb10ac16ca8d534c0395425d52121e0c1981f808e1b3f577f6a5ec33d3853e4"},
+    {file = "xattr-0.9.9-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5605fec07b0e964bd980cc70ec335b9eb1b7ac7c6f314c7c2d8f54b09104fe4c"},
+    {file = "xattr-0.9.9-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:974e7d577ddb15e4552fb0ec10a4cfe09bdf6267365aa2b8394bb04637785aad"},
+    {file = "xattr-0.9.9-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:ad6777de922c638bfa87a0d7faebc5722ddef04a1210b2a8909289b58b769af0"},
+    {file = "xattr-0.9.9-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3887e70873ebf0efbde32f9929ec1c7e45ec0013561743e2cc0406a91e51113b"},
+    {file = "xattr-0.9.9-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:83caa8e93a45a0f25f91b92d9b45f490c87bff74f02555df6312efeba0dacc31"},
+    {file = "xattr-0.9.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e33ec0a1d913d946d1ab7509f37ee37306c45af735347f13b963df34ffe6e029"},
+    {file = "xattr-0.9.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:263c58dca83372260c5c195e0b59959e38e1f107f0b7350de82e3db38479036c"},
+    {file = "xattr-0.9.9-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:125dfb9905428162349d3b8b825d9a18280893f0cb0db2a2467d5ef253fa6ce2"},
+    {file = "xattr-0.9.9-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e243524e0dde16d7a2e1b52512ad2c6964df2143dd1c79b820dcb4c6c0822c20"},
+    {file = "xattr-0.9.9-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01ec07d24a14406bdc6a123041c63a88e1c4a3f820e4a7d30f7609d57311b499"},
+    {file = "xattr-0.9.9-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:85c1df5f1d209345ea96de137419e886a27bb55076b3ae01faacf35aafcf3a61"},
+    {file = "xattr-0.9.9-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ca74d3eff92d6dc16e271fbad9cbab547fb9a0c983189c4031c3ff3d150dd871"},
+    {file = "xattr-0.9.9-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7d17505e49ac70c0e71939c5aac96417a863583fb30a2d6304d5ac881230548f"},
+    {file = "xattr-0.9.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1ae47a6398d3c04623fa386a4aa2f66e5cd3cdb1a7e69d1bfaeb8c73983bf271"},
+    {file = "xattr-0.9.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:809e2537d0aff9fca97dacf3245cbbaf711bbced5d1b0235a8d1906b04e26114"},
+    {file = "xattr-0.9.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de3af84364f06d67b3662ccf7c1a73e1d389d8d274394e952651e7bf1bbd2718"},
+    {file = "xattr-0.9.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b62cdad232d2d2dedd39b543701db8e3883444ec0d57ce3fab8f75e5f8b0301"},
+    {file = "xattr-0.9.9-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6b11d2eda397d47f7075743409683c233519ca52aa1dac109b413a4d8c15b740"},
+    {file = "xattr-0.9.9-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:661c0a939aefdf071887121f534bb10588d69c7b2dfca5c486af2fc81a0786e8"},
+    {file = "xattr-0.9.9-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5db7c2db320a8d5264d437d71f1eb7270a7e4a6545296e7766161d17752590b7"},
+    {file = "xattr-0.9.9-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:83203e60cbaca9536d297e5039b285a600ff84e6e9e8536fe2d521825eeeb437"},
+    {file = "xattr-0.9.9-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:42bfb4e4da06477e739770ac6942edbdc71e9fc3b497b67db5fba712fa8109c2"},
+    {file = "xattr-0.9.9-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:67047d04d1c56ad4f0f5886085e91b0077238ab3faaec6492c3c21920c6566eb"},
+    {file = "xattr-0.9.9-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:885782bc82ded1a3f684d54a1af259ae9fcc347fa54b5a05b8aad82b8a42044c"},
+    {file = "xattr-0.9.9-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bc84ccec618b5aa089e7cee8b07fcc92d4069aac4053da604c8143a0d6b1381"},
+    {file = "xattr-0.9.9-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baeff3e5dda8ea7e9424cfaee51829f46afe3836c30d02f343f9049c685681ca"},
+    {file = "xattr-0.9.9.tar.gz", hash = "sha256:09cb7e1efb3aa1b4991d6be4eb25b73dc518b4fe894f0915f5b0dcede972f346"},
+]
+zipp = [
+    {file = "zipp-3.8.1-py3-none-any.whl", hash = "sha256:47c40d7fe183a6f21403a199b3e4192cca5774656965b0a4988ad2f8feb5f009"},
+    {file = "zipp-3.8.1.tar.gz", hash = "sha256:05b45f1ee8f807d0cc928485ca40a07cb491cf092ff587c0df9cb1fd154848d2"},
+]
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..d8b48a7
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,55 @@
+[tool.poetry]
+name = "osxmetadata"
+version = "0.99.38"
+description = "Read and write meta data, such as tags/keywords, Finder comments, etc. on MacOS files"
+authors = ["Rhet Turnbull <rturnbull+git@gmail.com>"]
+license = "MIT License"
+readme = "README.md"
+homepage = "https://github.com/RhetTbull/osxmetadata"
+repository = "https://github.com/RhetTbull/osxmetadata"
+keywords = [
+    "cli",
+    "mac",
+    "macos",
+    "metadata",
+    "tags",
+    "osx",
+    "spotlight",
+    "extended_attributes",
+]
+
+[tool.poetry.dependencies]
+python = ">=3.8,<3.11"
+bitstring = "^3.1.9"
+click = "^8.0"
+py-applescript = "^1.0.3"
+pyobjc-core = "^8.5.1"
+pyobjc-framework-AppleScriptKit = "^8.5.1"
+pyobjc-framework-AppleScriptObjC = "^8.5.1"
+pyobjc-framework-AVFoundation = "^8.5.1"
+pyobjc-framework-CoreMedia = "^8.5.1"
+pyobjc-framework-Quartz = "^8.5.1"
+twine = "^4.0.1"
+wheel = "^0.37.1"
+xattr = "^0.9.9"
+pyobjc-framework-CoreServices = "^8.5.1"
+pyobjc-framework-ScriptingBridge = "^8.5.1"
+
+[tool.poetry.group.dev.dependencies]
+pytest = "^7.1.3"
+build = "^0.8.0"
+doit = "^0.36.0"
+pyinstaller = "^5.4.1"
+bump2version = "^1.0.1"
+mkdocs = "^1.4.0"
+mkdocstrings = {extras = ["python"], version = "^0.19.0"}
+mkdocs-material = "^8.5.4"
+wheel = "^0.37.1"
+cogapp = "^3.3.0"
+
+[tool.poetry.scripts]
+osxmetadata = "osxmetadata.__main__:cli"
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
diff --git a/setup.py b/setup.py
deleted file mode 100755
index 216ffab..0000000
--- a/setup.py
+++ /dev/null
@@ -1,65 +0,0 @@
-#!/usr/bin/env python
-
-from setuptools import setup, find_packages
-import sys
-import os.path
-
-# 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
-sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
-
-# read the contents of README file
-this_directory = os.path.abspath(os.path.dirname(__file__))
-with open(os.path.join(this_directory, "README.md"), encoding="utf-8") as f:
-    long_description = f.read()
-
-about = {}
-with open(
-    os.path.join(this_directory, "osxmetadata", "_version.py"),
-    mode="r",
-    encoding="utf-8",
-) as f:
-    exec(f.read(), about)
-
-setup(
-    name="osxmetadata",
-    version=about["__version__"],
-    description="Read and write meta data, such as tags/keywords, Finder comments, etc. on MacOS files",
-    long_description=long_description,
-    long_description_content_type="text/markdown",
-    author="Rhet Turnbull",
-    author_email="rturnbull+git@gmail.com",
-    url="https://github.com/RhetTbull/osxmetadata",
-    project_urls={"GitHub": "https://github.com/RhetTbull/osxmetadata"},
-    download_url="https://github.com/RhetTbull/osxmetadata",
-    packages=find_packages(exclude=["tests", "utils"]),
-    license="License :: OSI Approved :: MIT License",
-    classifiers=[
-        "Development Status :: 4 - Beta",
-        "Environment :: MacOS X",
-        "Intended Audience :: Developers",
-        "License :: OSI Approved :: MIT License",
-        "Operating System :: MacOS :: MacOS X",
-        "Programming Language :: Python :: 3.7",
-        "Topic :: Software Development :: Libraries :: Python Modules",
-    ],
-    install_requires=[
-        "xattr>=0.9.7",
-        "click>=7.0",
-        "bitstring>=3.1.9",
-        "py-applescript==1.0.2",
-        "pyobjc-core>=7.3,<9.0",
-        "pyobjc-framework-AppleScriptKit>=7.3,<9.0",
-        "pyobjc-framework-AppleScriptObjC>=7.3,<9.0",
-        "pyobjc-framework-Cocoa>=7.3,<9.0",
-        "pyobjc-framework-AVFoundation>=7.3,<9.0",
-        "pyobjc-framework-Quartz>=7.3,<9.0",
-        "pyobjc-framework-CoreMedia>=7.3,<9.0",
-    ],
-    python_requires=">=3.7",
-    entry_points={"console_scripts": ["osxmetadata=osxmetadata.__main__:cli"]},
-)


Test patch:
diff --git a/tests/README.md b/tests/README.md
index da07b82..a63e4a5 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -1,10 +1,13 @@
 # Tests for osxphotos
 
-## Running Tests ##
-Tests require pytest:
-`pip install pytest`
+## Running Tests
 
-To run the tests, do the following from the main source folder:
-`python -m pytest tests/`
+Tests require pytest which will be installed by poetry.  To run tests, use:
 
-Running the tests this way allows the library to be tested without installing it.
+`poetry run pytest --doctest-glob=README.md tests/ README.md -v`
+
+The `--doctest-glob=README.md` option is required to run the doctests in the main README.md file.
+
+## Test Data
+
+The test suite includes some image, audio, and video files in the `tests/` folder.  These files were produced by the author and are licensed under the [Creative Commons Attribution-ShareAlike 4.0 International License](https://creativecommons.org/licenses/by-sa/4.0/). The files are used for testing purposes only and are not included in the osxphotos package.
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..6107830
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,82 @@
+"""Config for pytest"""
+
+import datetime
+import os
+import time
+import typing as t
+from tempfile import NamedTemporaryFile, TemporaryDirectory
+
+import pytest
+
+TEST_IMAGE = "tests/test_image.jpg"
+TEST_VIDEO = "tests/test_video.mov"
+TEST_AUDIO = "tests/test_audio.m4a"
+
+
+@pytest.fixture(scope="session")
+def test_image():
+    return TEST_IMAGE
+
+
+@pytest.fixture(scope="session")
+def test_video():
+    return TEST_VIDEO
+
+
+@pytest.fixture(scope="session")
+def test_audio():
+    return TEST_AUDIO
+
+
+@pytest.fixture(scope="module")
+def test_file():
+    """Create a temporary test file"""
+    # can't use tmp_path fixture because the tmpfs filesystem doesn't support xattrs
+    with NamedTemporaryFile(dir=os.getcwd(), prefix="tmp_") as test_file:
+        yield test_file
+
+
+@pytest.fixture(scope="module")
+def test_file2():
+    """Create a temporary test file"""
+    # can't use tmp_path fixture because the tmpfs filesystem doesn't support xattrs
+    with NamedTemporaryFile(dir=os.getcwd(), prefix="tmp_") as test_file:
+        yield test_file
+
+
+@pytest.fixture(scope="function")
+def test_dir():
+    """Create a temporary directory"""
+    # can't use tmp_path fixture because the tmpfs filesystem doesn't support xattrs
+    with TemporaryDirectory(dir=os.getcwd(), prefix="tmp_") as test_dir:
+        yield test_dir
+
+
+@pytest.fixture(scope="session")
+def snooze():
+    """Sleep for a bit to allow Finder to update metadata"""
+
+    def _sleep():
+        time.sleep(0.5)
+
+    return _sleep
+
+
+def value_for_type(
+    type_: type,
+) -> t.Union[str, float, bool, datetime.datetime, t.List[str]]:
+    """Get test value for a given metadata attribute type"""
+    if type_ == "str":
+        return "Hello World"
+    elif type_ == "float":
+        return 42.0
+    elif type_ == "bool":
+        return True
+    elif type_ == "datetime.datetime":
+        return datetime.datetime(1995, 5, 31, 0, 0, 0)
+    elif type_ == "list":
+        return ["a", "b"]
+    elif type_ == "list[datetime.datetime]":
+        return [datetime.datetime(1995, 5, 31, 0, 0, 0)]
+    else:
+        raise ValueError(f"Unknown type: {type_}")
diff --git a/tests/test_audio.m4a b/tests/test_audio.m4a
new file mode 100644
index 0000000..c46bcd9
Binary files /dev/null and b/tests/test_audio.m4a differ
diff --git a/tests/test_bool_attributes.py b/tests/test_bool_attributes.py
deleted file mode 100644
index c3a810f..0000000
--- a/tests/test_bool_attributes.py
+++ /dev/null
@@ -1,122 +0,0 @@
-""" Test bool attributes """
-
-import platform
-from tempfile import NamedTemporaryFile, TemporaryDirectory
-
-import pytest
-from osxmetadata.attributes import ATTRIBUTES
-
-
-@pytest.fixture(params=["file", "dir"])
-def temp_file(request):
-
-    # TESTDIR for temporary files usually defaults to "/tmp",
-    # which may not have XATTR support (e.g. tmpfs);
-    # manual override here.
-    TESTDIR = None
-    if request.param == "file":
-        tempfile = NamedTemporaryFile(dir=TESTDIR)
-        tempfilename = tempfile.name
-        yield tempfilename
-        tempfile.close()
-    else:
-        tempdir = TemporaryDirectory(dir=TESTDIR)
-        tempdirname = tempdir.name
-        yield tempdirname
-        tempdir.cleanup()
-
-
-test_data = [
-    (attr)
-    for attr in sorted(list(ATTRIBUTES.keys()))
-    if ATTRIBUTES[attr].class_ == bool
-]
-ids = [
-    attr for attr in sorted(list(ATTRIBUTES.keys())) if ATTRIBUTES[attr].class_ == bool
-]
-
-test_data2 = [
-    (attribute_name)
-    for attribute_name in {
-        ATTRIBUTES[attr].name
-        for attr in sorted(ATTRIBUTES)
-        if ATTRIBUTES[attr].class_ == bool
-    }
-]
-ids2 = [
-    attribute_name
-    for attribute_name in {
-        ATTRIBUTES[attr].name
-        for attr in sorted(ATTRIBUTES)
-        if ATTRIBUTES[attr].class_ == bool
-    }
-]
-
-
-@pytest.mark.parametrize("attribute", test_data, ids=ids)
-def test_str_attribute(temp_file, attribute):
-    """test set_attribute, get_attribute, etc"""
-    from osxmetadata import OSXMetaData
-    from osxmetadata.constants import _FINDER_COMMENT_NAMES
-
-    meta = OSXMetaData(temp_file)
-    expected = True
-    meta.set_attribute(attribute, expected)
-    got = meta.get_attribute(attribute)
-    assert expected == got
-
-    meta.set_attribute(attribute, not expected)
-    assert not meta.get_attribute(attribute)
-
-    meta.clear_attribute(attribute)
-    assert meta.get_attribute(attribute) is None
-
-    # test setting empty string
-    # setting finder comment to empty string clears it
-    # but other fields get set to empty string
-    # this mirrors the way finder comments work in mdls
-    meta.set_attribute(attribute, "")
-    assert not meta.get_attribute(attribute)
-
-    with pytest.raises(AttributeError):
-        meta.update_attribute(attribute, "foo")
-
-    with pytest.raises(TypeError):
-        meta.discard_attribute(attribute, "foo")
-
-    with pytest.raises(TypeError):
-        meta.remove_attribute(attribute, "foo")
-
-
-@pytest.mark.parametrize("attribute", test_data2, ids=ids2)
-def test_str_attribute_2(temp_file, attribute):
-    """test getting and setting attribute by name"""
-    from osxmetadata import OSXMetaData
-
-    meta = OSXMetaData(temp_file)
-    setattr(meta, attribute, True)
-    test_attr = getattr(meta, attribute)
-    assert test_attr
-    assert meta.get_attribute(attribute)
-
-
-def test_stationary(temp_file):
-    """test string functions on one of the bool attributes"""
-    from osxmetadata import OSXMetaData
-
-    meta = OSXMetaData(temp_file)
-    meta.stationary = True
-    assert meta.stationary
-    assert meta.get_attribute("stationary")
-
-    meta.stationary = not meta.stationary
-    assert not meta.stationary
-    assert not meta.get_attribute("stationary")
-
-    meta.stationary = None
-    assert meta.stationary is None
-    assert meta.get_attribute("stationary") is None
-
-    meta.stationary = True
-    assert meta.stationary
-    assert meta.get_attribute("stationary")
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 5a7a1ff..61b2885 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,959 +1,345 @@
 """ Test osxmetadata command line interface """
 
 import datetime
-from tempfile import NamedTemporaryFile, TemporaryDirectory
+import glob
+import os
+import pathlib
 
 import pytest
 from click.testing import CliRunner
 
-from osxmetadata.attributes import ATTRIBUTES
-from osxmetadata.classes import _AttributeList, _AttributeTagsList
-
-# get list attribute names for string attributes for parameterized testing
-test_names_str = [
-    (attr) for attr in sorted(list(ATTRIBUTES.keys())) if ATTRIBUTES[attr].class_ == str
-]
-ids_str = [
-    attr for attr in sorted(list(ATTRIBUTES.keys())) if ATTRIBUTES[attr].class_ == str
-]
-
-# list of list attributes of type str
-test_names_list = [
-    (attr)
-    for attr in sorted(list(ATTRIBUTES.keys()))
-    if ATTRIBUTES[attr].class_ in (_AttributeList, _AttributeTagsList)
-    and ATTRIBUTES[attr].type_ == str
-]
-ids_list = [
-    attr
-    for attr in sorted(list(ATTRIBUTES.keys()))
-    if ATTRIBUTES[attr].class_ in (_AttributeList, _AttributeTagsList)
-    and ATTRIBUTES[attr].type_ == str
-]
-
-
-# list of list attributes of type datetime.datetime
-test_names_dt = [
-    (attr)
-    for attr in sorted(list(ATTRIBUTES.keys()))
-    if ATTRIBUTES[attr].class_ == _AttributeList
-    and ATTRIBUTES[attr].type_ == datetime.datetime
-]
-ids_list_dt = [
-    attr
-    for attr in sorted(list(ATTRIBUTES.keys()))
-    if ATTRIBUTES[attr].class_ == _AttributeList
-    and ATTRIBUTES[attr].type_ == datetime.datetime
-]
-
-
-def create_file(filepath):
-    """create an empty file at filepath"""
-    fd = open(filepath, "w+")
-    fd.close()
-
-
-@pytest.fixture(params=["file", "dir"])
-def temp_file(request):
-
-    # TESTDIR for temporary files usually defaults to "/tmp",
-    # which may not have XATTR support (e.g. tmpfs);
-    # manual override here.
-    TESTDIR = None
-    if request.param == "file":
-        tempfile = NamedTemporaryFile(dir=TESTDIR)
-        tempfilename = tempfile.name
-        yield tempfilename
-        tempfile.close()
-    else:
-        tempdir = TemporaryDirectory(dir=TESTDIR)
-        tempdirname = tempdir.name
-        yield tempdirname
-        tempdir.cleanup()
-
-
-@pytest.fixture
-def temp_dir():
-    # TESTDIR for temporary files usually defaults to "/tmp",
-    # which may not have XATTR support (e.g. tmpfs);
-    # manual override here.
-    TESTDIR = None
-    tempdir = TemporaryDirectory(dir=TESTDIR)
-    tempdirname = tempdir.name
-    yield tempdirname
-    tempdir.cleanup()
+from osxmetadata import *
+from osxmetadata import __version__
+from osxmetadata.__main__ import BACKUP_FILENAME, cli
+from osxmetadata.backup import load_backup_file
+
+help = """
+  -v, --version                   Show the version and exit.
+  -w, --walk                      Walk directory tree, processing each file in
+                                  the tree.
+  -j, --json                      Print output in JSON format, for use with
+                                  --list and --get.
+  -X, --wipe                      Wipe all metadata attributes from FILE.
+  -s, --set ATTRIBUTE VALUE       Set ATTRIBUTE to VALUE.
+  -l, --list                      List all metadata attributes for FILE.
+  -c, --clear ATTRIBUTE           Remove attribute from FILE.
+  -a, --append ATTRIBUTE VALUE    Append VALUE to ATTRIBUTE; for multi-valued
+                                  attributes, appends only if VALUE is not
+                                  already present.
+  -g, --get ATTRIBUTE             Get value of ATTRIBUTE.
+  -r, --remove ATTRIBUTE VALUE    Remove VALUE from ATTRIBUTE; only applies to
+                                  multi-valued attributes.
+  -m, --mirror ATTRIBUTE1 ATTRIBUTE2
+                                  Mirror values between ATTRIBUTE1 and
+                                  ATTRIBUTE2 so that ATTRIBUTE1 = ATTRIBUTE2;
+                                  for multi-valued attributes, merges values;
+                                  for string attributes, sets ATTRIBUTE1 =
+                                  ATTRIBUTE2 overwriting any value in
+                                  ATTRIBUTE1.  For example: '--mirror keywords
+                                  tags' sets tags and keywords to same values.
+  -B, --backup                    Backup FILE attributes.  Backup file
+                                  '.osxmetadata.json' will be created in same
+                                  folder as FILE. Only backs up attributes
+                                  known to osxmetadata unless used with --all.
+  -R, --restore                   Restore FILE attributes from backup file.
+                                  Restore will look for backup file
+                                  '.osxmetadata.json' in same folder as FILE.
+                                  Only restores attributes known to
+                                  osxmetadata unless used with --all.
+  -V, --verbose                   Print verbose output.
+  -f, --copyfrom SOURCE_FILE      Copy attributes from file SOURCE_FILE (only
+                                  updates destination attributes that are not
+                                  null in SOURCE_FILE).
+  --files-only                    Do not apply metadata commands to
+                                  directories themselves, only files in a
+                                  directory.
+  -p, --pattern PATTERN           Only process files matching PATTERN; only
+                                  applies to --walk. If specified, only files
+                                  matching PATTERN will be processed as each
+                                  directory is walked. May be used for than
+                                  once to specify multiple patterns. For
+                                  example, tag all *.pdf files in projectdir
+                                  and subfolders with tag 'project':
+                                  osxmetadata --append tags 'project' --walk
+                                  projectdir/ --pattern '*.pdf'
+  --help                          Show this message and exit.
+"""
 
 
 def parse_cli_output(output):
-    """helper for testing
-    parse the CLI --list output and return value of all set attributes as dict"""
+    """Helper for testing
+
+    Parse the CLI --list output and return value of all set attributes as dict
+    """
     import re
 
-    results = {}
     matches = re.findall(r"^(\w+)\s+.*\=\s+(.*)$", output, re.MULTILINE)
-    for match in matches:
-        results[match[0]] = match[1]
-    return results
+    return {match[0]: match[1] for match in matches}
 
 
-def test_cli_1(temp_file):
-    import datetime
+def test_cli_list(test_file):
+    """Test --list"""
 
-    from osxmetadata import FINDER_COLOR_RED, OSXMetaData, kMDItemDownloadedDate
-    from osxmetadata.__main__ import cli
+    md = OSXMetaData(test_file.name)
+    md.authors = ["John Doe"]
+    md.findercomment = "Hello World"
+    md.tags = [Tag("test", 0)]
+    md.description = "This is a test file"
 
     runner = CliRunner()
     result = runner.invoke(
         cli,
-        [
-            "--set",
-            "description",
-            "Foo",
-            "--append",
-            "description",
-            "Bar",
-            "--list",
-            temp_file,
-        ],
+        ["--list", test_file.name],
     )
     assert result.exit_code == 0
     output = parse_cli_output(result.stdout)
-    assert output["description"] == "FooBar"
-    meta = OSXMetaData(temp_file)
-    assert meta.description == "FooBar"
+    assert output["description"] == "This is a test file"
+    assert output["findercomment"] == "Hello World"
+    assert output["authors"] == "John Doe"
+    assert output["tags"] == "test: 0"
 
-    result = runner.invoke(
-        cli,
-        [
-            "--update",
-            "keywords",
-            "foo",
-            "--update",
-            "keywords",
-            "bar",
-            "--remove",
-            "keywords",
-            "foo",
-            "--set",
-            "finderinfo",
-            "color:red",
-            "--list",
-            temp_file,
-        ],
-    )
-    assert result.exit_code == 0
-    output = parse_cli_output(result.stdout)
-    assert output["keywords"] == "['bar']"
-    meta = OSXMetaData(temp_file)
-    assert meta.keywords == ["bar"]
-    assert meta.finderinfo.color == FINDER_COLOR_RED
 
-    dt = "2020-02-23"
-    result = runner.invoke(cli, ["--set", "downloadeddate", dt, "--list", temp_file])
-    assert result.exit_code == 0
-    output = parse_cli_output(result.stdout)
-    assert output["downloadeddate"] == "['2020-02-23T00:00:00']"
-    expected_dt = datetime.datetime.fromisoformat(dt)
-    meta = OSXMetaData(temp_file)
-    assert meta.get_attribute(kMDItemDownloadedDate) == [expected_dt]
-    assert meta.downloadeddate == [expected_dt]
-
-    result = runner.invoke(cli, ["--clear", "description", temp_file])
-    assert result.exit_code == 0
-    meta = OSXMetaData(temp_file)
-    assert meta.description is None
-
-
-@pytest.mark.parametrize("attribute", test_names_str, ids=ids_str)
-def test_str_attributes(temp_file, attribute):
-    from osxmetadata import ATTRIBUTES, OSXMetaData
-    from osxmetadata.__main__ import cli
-
-    runner = CliRunner()
-    result = runner.invoke(
-        cli,
-        ["--set", attribute, "Foo", "--append", attribute, "Bar", "--list", temp_file],
-    )
-    assert result.exit_code == 0
-    output = parse_cli_output(result.stdout)
-    attr_short_name = ATTRIBUTES[attribute].name
-    assert output[attr_short_name] == "FooBar"
-    meta = OSXMetaData(temp_file)
-    assert meta.get_attribute(attribute) == "FooBar"
-
-
-@pytest.mark.parametrize("attribute", test_names_list, ids=ids_list)
-def test_list_attributes(temp_file, attribute):
-    from osxmetadata import ATTRIBUTES, OSXMetaData, Tag
-    from osxmetadata.__main__ import cli
-    from osxmetadata.constants import _TAGS_NAMES
-
-    runner = CliRunner()
-    result = runner.invoke(
-        cli, ["--set", attribute, "Foo", "--set", attribute, "Bar", "--list", temp_file]
-    )
-    assert result.exit_code == 0
-    output = parse_cli_output(result.stdout)
-    attr_short_name = ATTRIBUTES[attribute].name
-    assert output[attr_short_name] == "['Foo', 'Bar']"
-    meta = OSXMetaData(temp_file)
-    if attribute in [*_TAGS_NAMES]:
-        assert meta.get_attribute(attribute) == [Tag("Foo"), Tag("Bar")]
-    else:
-        assert meta.get_attribute(attribute) == ["Foo", "Bar"]
-
-    result = runner.invoke(
-        cli,
-        [
-            "--append",
-            attribute,
-            "Green",
-            "--remove",
-            attribute,
-            "Foo",
-            "--list",
-            temp_file,
-        ],
-    )
-    assert result.exit_code == 0
-    output = parse_cli_output(result.stdout)
-    attr_short_name = ATTRIBUTES[attribute].name
-    if attr_short_name == "tags":
-        assert output[attr_short_name] == "['Bar', 'Green,Green']"
-    else:
-        assert output[attr_short_name] == "['Bar', 'Green']"
-
-
-@pytest.mark.parametrize("attribute", test_names_dt, ids=ids_list_dt)
-def test_datetime_list_attributes(temp_file, attribute):
-    from osxmetadata import ATTRIBUTES, OSXMetaData
-    from osxmetadata.__main__ import cli
-
-    dt = datetime.datetime.now()
-    dt_str = dt.isoformat()
-
-    runner = CliRunner()
-    result = runner.invoke(cli, ["--set", attribute, dt_str, "--list", temp_file])
-    assert result.exit_code == 0
-    output = parse_cli_output(result.stdout)
-    attr_short_name = ATTRIBUTES[attribute].name
-    assert output[attr_short_name] == f"['{dt_str}']"
-    meta = OSXMetaData(temp_file)
-    assert meta.get_attribute(attribute) == [dt]
-
-
-def test_get_json(temp_file):
-    import json
-    import pathlib
-
-    from osxmetadata import ATTRIBUTES, OSXMetaData, __version__
-    from osxmetadata.__main__ import cli
-
-    runner = CliRunner()
-    result = runner.invoke(
-        cli, ["--set", "tags", "foo", "--set", "tags", "bar", temp_file]
-    )
-    result = runner.invoke(cli, ["--get", "tags", "--json", temp_file])
-    assert result.exit_code == 0
-    json_ = json.loads(result.stdout)
-    assert json_["com.apple.metadata:_kMDItemUserTags"] == [["foo", 0], ["bar", 0]]
-    assert json_["_version"] == __version__
-    assert json_["_filename"] == pathlib.Path(temp_file).name
-
-
-def test_list_json(temp_file):
-    import json
-    import pathlib
-
-    from osxmetadata import ATTRIBUTES, OSXMetaData, __version__
-    from osxmetadata.__main__ import cli
+def test_cli_version():
+    """Test --version"""
 
     runner = CliRunner()
-    result = runner.invoke(
-        cli, ["--set", "tags", "foo", "--set", "tags", "bar", temp_file]
-    )
-    result = runner.invoke(cli, ["--list", "--json", temp_file])
+    result = runner.invoke(cli, ["--version"])
     assert result.exit_code == 0
-    json_ = json.loads(result.stdout)
-    assert json_["com.apple.metadata:_kMDItemUserTags"] == [["foo", 0], ["bar", 0]]
-    assert json_["_version"] == __version__
-    assert json_["_filename"] == pathlib.Path(temp_file).name
-
-
-def test_cli_error_json(temp_file):
-    from osxmetadata import ATTRIBUTES, OSXMetaData
-    from osxmetadata.__main__ import cli
-
-    runner = CliRunner()
-    result = runner.invoke(cli, ["--set", "tags", "foo", "--json", temp_file])
-    assert result.exit_code == 2
-    assert "--json can only be used with --get or --list" in result.stdout
-
-
-def test_cli_error_bad_attribute(temp_file):
-    from osxmetadata import ATTRIBUTES, OSXMetaData
-    from osxmetadata.__main__ import cli
+    assert __version__ in result.stdout
 
-    runner = CliRunner()
-    result = runner.invoke(cli, ["--set", "foo", "bar", temp_file])
-    assert result.exit_code == 2
-    assert "Invalid attribute foo" in result.stdout
-
-
-def test_cli_error(temp_file):
-    from osxmetadata.__main__ import cli
-
-    runner = CliRunner()
-    result = runner.invoke(cli, ["--set", temp_file])
-    assert result.exit_code == 2
-    assert "Error:" in result.stdout
 
+def test_cli_wipe(test_file):
+    """Test --wipe"""
 
-def test_cli_backup_restore(temp_file):
-    import pathlib
-
-    from osxmetadata import ATTRIBUTES, OSXMetaData, Tag
-    from osxmetadata.__main__ import cli
-    from osxmetadata.constants import (
-        _BACKUP_FILENAME,
-        FINDER_COLOR_GREEN,
-        FINDER_COLOR_NONE,
-    )
+    md = OSXMetaData(test_file.name)
+    md.authors = ["John Doe"]
+    md.description = "This is a test file"
 
     runner = CliRunner()
     result = runner.invoke(
         cli,
-        [
-            "--set",
-            "tags",
-            "Foo",
-            "--set",
-            "tags",
-            "Bar",
-            "--set",
-            "comment",
-            "Hello World!",
-            "--list",
-            "--set",
-            "finderinfo",
-            "color:green",
-            temp_file,
-        ],
+        ["--wipe", test_file.name],
     )
     assert result.exit_code == 0
-    output = parse_cli_output(result.stdout)
-    assert output["tags"] == "['Foo', 'Bar', 'Green,Green']"
-    meta = OSXMetaData(temp_file)
-    assert meta.get_attribute("tags") == [
-        Tag("Foo"),
-        Tag("Bar"),
-        Tag("Green", FINDER_COLOR_GREEN),
-    ]
-    assert meta.get_attribute("comment") == "Hello World!"
-    assert meta.finderinfo.color == FINDER_COLOR_GREEN
-
-    # test creation of backup file
-    result = runner.invoke(cli, ["--backup", temp_file])
-    assert result.exit_code == 0
-    backup_file = pathlib.Path(pathlib.Path(temp_file).parent) / _BACKUP_FILENAME
-    assert backup_file.exists()
-
-    # clear the attributes to see if they can be restored
-    meta.clear_attribute("tags")
-    meta.clear_attribute("comment")
-    meta.clear_attribute("finderinfo")
-    assert meta.tags == []
-    assert meta.comment is None
-    assert meta.finderinfo.color == FINDER_COLOR_NONE
-
-    result = runner.invoke(cli, ["--restore", temp_file])
-    assert result.exit_code == 0
-    assert meta.tags == [Tag("Foo"), Tag("Bar"), Tag("Green", FINDER_COLOR_GREEN)]
-    assert meta.comment == "Hello World!"
-    assert meta.finderinfo.color == FINDER_COLOR_GREEN
-
+    assert not md.authors
+    assert not md.description
 
-def test_cli_backup_restore_2(temp_file):
-    # test set during restore
-    import pathlib
 
-    from osxmetadata import ATTRIBUTES, OSXMetaData, Tag
-    from osxmetadata.__main__ import cli
-    from osxmetadata.constants import _BACKUP_FILENAME
+def test_cli_set(test_file):
+    """Test --set"""
 
     runner = CliRunner()
     result = runner.invoke(
         cli,
         [
             "--set",
-            "tags",
-            "Foo",
+            "authors",
+            "John Doe",
             "--set",
             "tags",
-            "Bar",
+            "test,0",
             "--set",
-            "comment",
-            "Hello World!",
-            "--list",
-            temp_file,
-        ],
-    )
-    assert result.exit_code == 0
-    output = parse_cli_output(result.stdout)
-    assert output["tags"] == "['Foo', 'Bar']"
-    meta = OSXMetaData(temp_file)
-    assert meta.get_attribute("tags") == [Tag("Foo"), Tag("Bar")]
-    assert meta.get_attribute("comment") == "Hello World!"
-
-    # test creation of backup file
-    result = runner.invoke(cli, ["--backup", temp_file])
-    assert result.exit_code == 0
-    backup_file = pathlib.Path(pathlib.Path(temp_file).parent) / _BACKUP_FILENAME
-    assert backup_file.exists()
-
-    # clear the attributes to see if they can be restored
-    meta.clear_attribute("tags")
-    meta.clear_attribute("comment")
-    assert meta.tags == []
-    assert meta.comment is None
-
-    result = runner.invoke(
-        cli,
-        [
-            "--restore",
-            "--append",
-            "tags",
-            "Flooz",
+            "description",
+            "Hello World",
             "--set",
-            "keywords",
-            "FooBar",
-            temp_file,
+            "description",
+            "Goodbye World",  # this should overwrite the previous value
+            test_file.name,
         ],
     )
     assert result.exit_code == 0
-    assert meta.tags == [Tag("Foo"), Tag("Bar"), Tag("Flooz")]
-    assert meta.comment == "Hello World!"
-    assert meta.keywords == ["FooBar"]
+    md = OSXMetaData(test_file.name)
+    assert md.authors == ["John Doe"]
+    assert md.tags == [Tag("test", 0)]
+    assert md.description == "Goodbye World"
 
 
-def test_cli_backup_restore_all(temp_file):
-    """Test --backup/--restore with --all"""
-    import pathlib
+def test_cli_clear(test_file):
+    """Test --clear"""
 
-    from osxmetadata import ATTRIBUTES, OSXMetaData, Tag
-    from osxmetadata.__main__ import cli
-    from osxmetadata.constants import _BACKUP_FILENAME
+    md = OSXMetaData(test_file.name)
+    md.authors = ["John Doe"]
+    md.description = "This is a test file"
 
     runner = CliRunner()
     result = runner.invoke(
         cli,
-        [
-            "--set",
-            "tags",
-            "Foo",
-            "--set",
-            "tags",
-            "Bar",
-            "--set",
-            "comment",
-            "Hello World!",
-            "--list",
-            temp_file,
-        ],
+        ["--clear", "authors", test_file.name],
     )
     assert result.exit_code == 0
-    meta = OSXMetaData(temp_file)
-    # set a value osxmetadata doesn't know about
-    meta._attrs.set("com.foo.bar", b"FOOBAR")
+    assert not md.authors
+    assert md.description == "This is a test file"
 
-    # backup
-    result = runner.invoke(cli, ["--backup", "--all", temp_file])
-    assert result.exit_code == 0
 
-    # clear the attributes to see if they can be restored
-    meta.clear_attribute("tags")
-    meta.clear_attribute("comment")
-    meta._attrs.remove("com.foo.bar")
-    assert meta.tags == []
-    assert meta.comment is None
-    assert "com.foo.bar" not in meta._list_attributes()
-
-    # first run restore without --all
-    result = runner.invoke(cli, ["--restore", temp_file])
-    assert result.exit_code == 0
-    assert meta.tags == [Tag("Foo"), Tag("Bar")]
-    assert meta.comment == "Hello World!"
+def test_cli_append(test_file):
+    """Test --append"""
 
-    with pytest.raises(KeyError):
-        assert meta._attrs["com.foo.bar"] == b"FOOBAR"
-
-    # next run restore with --all
-    result = runner.invoke(cli, ["--restore", "--all", temp_file])
-    assert result.exit_code == 0
-    assert meta.tags == [Tag("Foo"), Tag("Bar")]
-    assert meta.comment == "Hello World!"
-    assert meta._attrs["com.foo.bar"] == b"FOOBAR"
-
-
-def test_cli_mirror(temp_file):
-    import datetime
-
-    from osxmetadata import OSXMetaData, Tag
-    from osxmetadata.__main__ import cli
+    md = OSXMetaData(test_file.name)
+    md.authors = ["John Doe"]
 
     runner = CliRunner()
     result = runner.invoke(
         cli,
         [
-            "--set",
-            "comment",
-            "Foo",
-            "--set",
-            "findercomment",
-            "Bar",
-            "--set",
-            "keywords",
-            "foo",
-            "--set",
-            "tags",
-            "bar",
-            "--list",
-            temp_file,
-        ],
-    )
-    assert result.exit_code == 0
-    meta = OSXMetaData(temp_file)
-    assert meta.tags == [Tag("bar")]
-    assert meta.keywords == ["foo"]
-    assert meta.findercomment == "Bar"
-    assert meta.comment == "Foo"
-
-    result = runner.invoke(
-        cli,
-        [
-            "--mirror",
-            "keywords",
+            "--append",
+            "authors",
+            "Jane Doe",
+            "--append",  # append to empty attribute
             "tags",
-            "--mirror",
-            "comment",
-            "findercomment",
-            temp_file,
+            "test,0",
+            test_file.name,
         ],
     )
-
     assert result.exit_code == 0
-    assert meta.keywords == ["bar", "foo"]
-    assert meta.tags == [Tag("bar"), Tag("foo")]
-    assert meta.findercomment == "Bar"
-    assert meta.comment == "Bar"
+    assert md.authors == ["John Doe", "Jane Doe"]
+    assert md.tags == [Tag("test", 0)]
 
 
-def test_cli_mirror_bad_args(temp_file):
-    import datetime
+def test_cli_get(test_file):
+    """Test --get"""
 
-    from osxmetadata import OSXMetaData
-    from osxmetadata.__main__ import cli
-
-    runner = CliRunner()
-    result = runner.invoke(cli, ["--mirror", "keywords", "comment", temp_file])
-    assert result.exit_code == 2
-    assert "incompatible types" in result.output
-
-    result = runner.invoke(cli, ["--mirror", "downloadeddate", "tags", temp_file])
-    assert result.exit_code == 2
-    assert "incompatible types" in result.output
-
-
-def test_cli_wipe(temp_file):
-    from osxmetadata import OSXMetaData, Tag
-    from osxmetadata.__main__ import cli
+    md = OSXMetaData(test_file.name)
+    md.authors = ["John Doe"]
+    md.description = "This is a test file"
 
     runner = CliRunner()
     result = runner.invoke(
         cli,
-        [
-            "--set",
-            "comment",
-            "Foo",
-            "--set",
-            "findercomment",
-            "Bar",
-            "--set",
-            "keywords",
-            "foo",
-            "--set",
-            "tags",
-            "bar",
-            "--list",
-            temp_file,
-        ],
+        ["--get", "authors", test_file.name],
     )
     assert result.exit_code == 0
-    meta = OSXMetaData(temp_file)
-    assert meta.tags == [Tag("bar")]
-    assert meta.keywords == ["foo"]
-    assert meta.findercomment == "Bar"
-    assert meta.comment == "Foo"
+    output = parse_cli_output(result.stdout)
+    assert output["authors"] == "John Doe"
 
-    result = runner.invoke(cli, ["--wipe", temp_file])
-    assert result.exit_code == 0
-    assert meta.tags == []
-    assert meta.keywords == []
-    assert meta.findercomment is None
-    assert meta.comment is None
 
+def test_cli_remove(test_file):
+    """Test --remove"""
 
-def test_cli_wipe_2(temp_file):
-    # test wipe then set
-    from osxmetadata import OSXMetaData, Tag
-    from osxmetadata.__main__ import cli
+    md = OSXMetaData(test_file.name)
+    md.authors = ["John Doe", "Jane Doe"]
+    md.tags = [Tag("test", 0)]
 
     runner = CliRunner()
     result = runner.invoke(
         cli,
         [
-            "--set",
-            "comment",
-            "Foo",
-            "--set",
-            "findercomment",
-            "Bar",
-            "--set",
-            "keywords",
-            "foo",
-            "--set",
+            "--remove",
+            "authors",
+            "John Doe",
+            "--remove",
             "tags",
-            "bar",
-            "--list",
-            temp_file,
+            "test,0",
+            test_file.name,
         ],
     )
     assert result.exit_code == 0
-    meta = OSXMetaData(temp_file)
-    assert meta.tags == [Tag("bar")]
-    assert meta.keywords == ["foo"]
-    assert meta.findercomment == "Bar"
-    assert meta.comment == "Foo"
-
-    result = runner.invoke(
-        cli, ["--wipe", "--set", "comment", "Hello World!", temp_file]
-    )
-    assert result.exit_code == 0
-    assert meta.tags == []
-    assert meta.keywords == []
-    assert meta.findercomment is None
-    assert meta.comment == "Hello World!"
-
-
-def test_cli_copy_from(temp_file):
-    # test copy from source file
-    from osxmetadata import OSXMetaData, Tag
-    from osxmetadata.__main__ import cli
-
-    TESTDIR = None
-    source_file = NamedTemporaryFile(dir=TESTDIR)
-    source_filename = source_file.name
-
-    meta_source = OSXMetaData(source_filename)
-    meta_source.tags = [Tag("bar")]
-    meta_source.keywords = ["foo"]
-    meta_source.findercomment = "Bar"
-    meta_source.comment = "Foo"
-
-    runner = CliRunner()
-    result = runner.invoke(cli, ["--copyfrom", source_filename, temp_file])
-    assert result.exit_code == 0
-    meta = OSXMetaData(temp_file)
-    assert meta.tags == [Tag("bar")]
-    assert meta.keywords == ["foo"]
-    assert meta.findercomment == "Bar"
-    assert meta.comment == "Foo"
-
-    source_file.close()
-
+    assert md.authors == ["Jane Doe"]
+    assert not md.tags
 
-def test_cli_copy_from_2(temp_file):
-    # test copy from source file with setting etc
-    from osxmetadata import OSXMetaData, Tag
-    from osxmetadata.__main__ import cli
 
-    TESTDIR = None
-    source_file = NamedTemporaryFile(dir=TESTDIR)
-    source_filename = source_file.name
+def test_cli_mirror(test_file):
+    """Test --mirror"""
 
-    meta_source = OSXMetaData(source_filename)
-    meta_source.tags = [Tag("bar")]
-    meta_source.keywords = ["foo"]
-    meta_source.findercomment = "Bar"
-    meta_source.comment = "Foo"
+    md = OSXMetaData(test_file.name)
+    md.description = "This is a test file"
 
     runner = CliRunner()
     result = runner.invoke(
         cli,
         [
-            "--copyfrom",
-            source_filename,
-            temp_file,
-            "--set",
-            "tags",
-            "FOOBAR",
-            "--append",
-            "findercomment",
-            "Foo",
+            "--mirror",
+            "comment",
+            "description",
+            test_file.name,
+            "--verbose",
         ],
     )
     assert result.exit_code == 0
-    meta = OSXMetaData(temp_file)
-    assert meta.tags == [Tag("FOOBAR")]
-    assert meta.keywords == ["foo"]
-    assert meta.findercomment == "BarFoo"
-    assert meta.comment == "Foo"
-
-    source_file.close()
-
+    assert "Mirroring" in result.stdout
+    assert md.description == "This is a test file"
 
-def test_cli_verbose(temp_file):
-    from osxmetadata import OSXMetaData, Tag
-    from osxmetadata.__main__ import cli
 
-    TESTDIR = None
-    source_file = NamedTemporaryFile(dir=TESTDIR)
-    source_filename = source_file.name
+def test_cli_copyfrom(test_file, test_file2):
+    """Test --copyfrom"""
 
-    meta_source = OSXMetaData(source_filename)
-    meta_source.tags = [Tag("bar")]
-    meta_source.keywords = ["foo"]
-    meta_source.findercomment = "Bar"
-    meta_source.comment = "Foo"
+    md = OSXMetaData(test_file.name)
+    md.description = "This is a test file"
 
     runner = CliRunner()
     result = runner.invoke(
         cli,
         [
-            "--wipe",
-            "--set",
-            "keywords",
-            "test",
-            "--list",
-            "--get",
-            "keywords",
-            "--clear",
-            "keywords",
-            "--remove",
-            "keywords",
-            "test",
-            "--update",
-            "keywords",
-            "foo",
-            "--mirror",
-            "keywords",
-            "tags",
-            "--backup",
-            "--verbose",
             "--copyfrom",
-            source_filename,
-            temp_file,
+            test_file.name,
+            test_file2.name,
         ],
     )
     assert result.exit_code == 0
-    output = result.output
-    assert "Processing file" in output
-    assert "No metadata to wipe from" in output
-    assert "Copying attributes from" in output
-    assert "Copying com.apple.metadata:_kMDItemUserTags" in output
-    assert "Copying com.apple.metadata:kMDItemComment" in output
-    assert "Copying com.apple.metadata:kMDItemKeywords" in output
-    assert "Copying com.apple.metadata:kMDItemFinderComment" in output
-    assert "Clearing keywords" in output
-    assert "Setting keywords=test" in output
-    assert "Updating keywords=foo" in output
-    assert "Removing keywords" in output
-    assert "Mirroring keywords tags" in output
-    assert "Backing up attribute data" in output
-
-    source_file.close()
-
-
-def test_cli_verbose_short_opts(temp_file):
-    from osxmetadata import OSXMetaData, Tag
-    from osxmetadata.__main__ import cli
-
-    TESTDIR = None
-    source_file = NamedTemporaryFile(dir=TESTDIR)
-    source_filename = source_file.name
-
-    meta_source = OSXMetaData(source_filename)
-    meta_source.tags = [Tag("bar")]
-    meta_source.keywords = ["foo"]
-    meta_source.findercomment = "Bar"
-    meta_source.comment = "Foo"
 
-    runner = CliRunner()
-    result = runner.invoke(
-        cli,
-        [
-            "-X",
-            "-s",
-            "keywords",
-            "test",
-            "-l",
-            "-g",
-            "keywords",
-            "-c",
-            "keywords",
-            "-r",
-            "keywords",
-            "test",
-            "-u",
-            "keywords",
-            "foo",
-            "-m",
-            "keywords",
-            "tags",
-            "-V",
-            "-B",
-            "-f",
-            source_filename,
-            temp_file,
-        ],
-    )
-    assert result.exit_code == 0
-    output = result.output
-    assert "Processing file" in output
-    assert "No metadata to wipe from" in output
-    assert "Copying attributes from" in output
-    assert "Copying com.apple.metadata:_kMDItemUserTags" in output
-    assert "Copying com.apple.metadata:kMDItemComment" in output
-    assert "Copying com.apple.metadata:kMDItemKeywords" in output
-    assert "Copying com.apple.metadata:kMDItemFinderComment" in output
-    assert "Clearing keywords" in output
-    assert "Setting keywords=test" in output
-    assert "Updating keywords=foo" in output
-    assert "Removing keywords" in output
-    assert "Mirroring keywords tags" in output
-    assert "Backing up attribute data" in output
-
-    source_file.close()
+    md2 = OSXMetaData(test_file2.name)
+    assert md2.description == "This is a test file"
 
 
-def test_cli_version():
-    from osxmetadata import OSXMetaData, __version__
-    from osxmetadata.__main__ import cli
-
-    runner = CliRunner()
-    result = runner.invoke(cli, ["-v"])
-    assert result.exit_code == 0
-    assert f"version {__version__}" in result.output
-
-    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.__main__ import cli
-    from osxmetadata.datetime_utils import (
-        datetime_naive_to_utc,
-        datetime_remove_tz,
-        datetime_utc_to_local,
-    )
-
-    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]
-
-
-def test_cli_walk(temp_dir):
+def test_cli_walk(test_dir):
     """test --walk"""
-    import os
-    import pathlib
 
-    from osxmetadata import OSXMetaData, Tag
-    from osxmetadata.__main__ import cli
-
-    dirname = pathlib.Path(temp_dir)
+    dirname = pathlib.Path(test_dir)
     os.makedirs(dirname / "temp" / "subfolder1")
     os.makedirs(dirname / "temp" / "subfolder2")
-    create_file(dirname / "temp" / "temp1.txt")
-    create_file(dirname / "temp" / "subfolder1" / "sub1.txt")
+    (dirname / "temp" / "temp1.txt").touch()
+    (dirname / "temp" / "subfolder1" / "sub1.txt").touch()
 
     runner = CliRunner()
-    result = runner.invoke(cli, ["--set", "tags", "FOO", "--walk", temp_dir])
+    result = runner.invoke(cli, ["--set", "tags", "test", "--walk", test_dir])
     assert result.exit_code == 0
 
     md = OSXMetaData(dirname / "temp" / "subfolder1" / "sub1.txt")
-    assert md.tags == [Tag("FOO")]
+    assert md.tags == [Tag("test", 0)]
 
     md = OSXMetaData(dirname / "temp" / "subfolder2")
-    assert md.tags == [Tag("FOO")]
+    assert md.tags == [Tag("test", 0)]
 
 
-def test_cli_walk_files_only(temp_dir):
+def test_cli_walk_files_only(test_dir):
     """test --walk with --files-only"""
-    import os
-    import pathlib
-
-    from osxmetadata import OSXMetaData, Tag
-    from osxmetadata.__main__ import cli
 
-    dirname = pathlib.Path(temp_dir)
+    dirname = pathlib.Path(test_dir)
     os.makedirs(dirname / "temp" / "subfolder1")
     os.makedirs(dirname / "temp" / "subfolder2")
-    create_file(dirname / "temp" / "temp1.txt")
-    create_file(dirname / "temp" / "subfolder1" / "sub1.txt")
+    (dirname / "temp" / "temp1.txt").touch()
+    (dirname / "temp" / "subfolder1" / "sub1.txt").touch()
 
     runner = CliRunner()
     result = runner.invoke(
-        cli, ["--set", "tags", "FOO", "--walk", "--files-only", temp_dir]
+        cli, ["--set", "tags", "test", "--walk", "--files-only", test_dir]
     )
     assert result.exit_code == 0
 
     md = OSXMetaData(dirname / "temp" / "subfolder1" / "sub1.txt")
-    assert md.tags == [Tag("FOO")]
+    assert md.tags == [Tag("test", 0)]
 
     md = OSXMetaData(dirname / "temp" / "subfolder2")
     assert not md.tags
 
 
-def test_cli_walk_pattern(temp_dir):
+def test_cli_walk_pattern(test_dir):
     """test --walk with --pattern"""
-    import os
-    import pathlib
 
-    from osxmetadata import OSXMetaData, Tag
-    from osxmetadata.__main__ import cli
-
-    dirname = pathlib.Path(temp_dir)
+    dirname = pathlib.Path(test_dir)
     os.makedirs(dirname / "temp" / "subfolder1")
     os.makedirs(dirname / "temp" / "subfolder2")
-    create_file(dirname / "temp" / "temp1.txt")
-    create_file(dirname / "temp" / "subfolder1" / "sub1.txt")
-    create_file(dirname / "temp" / "subfolder1" / "sub1.pdf")
+    (dirname / "temp" / "temp1.txt").touch()
+    (dirname / "temp" / "subfolder1" / "sub1.txt").touch()
+    (dirname / "temp" / "subfolder1" / "sub1.pdf").touch()
 
     runner = CliRunner()
     result = runner.invoke(
-        cli, ["--set", "tags", "FOO", "--walk", "--pattern", "*.pdf", temp_dir]
+        cli, ["--set", "tags", "test", "--walk", "--pattern", "*.pdf", test_dir]
     )
     assert result.exit_code == 0
 
     md = OSXMetaData(dirname / "temp" / "subfolder1" / "sub1.pdf")
-    assert md.tags == [Tag("FOO")]
+    assert md.tags == [Tag("test", 0)]
 
     md = OSXMetaData(dirname / "temp" / "subfolder1" / "sub1.txt")
     assert not md.tags
@@ -962,21 +348,16 @@ def test_cli_walk_pattern(temp_dir):
     assert not md.tags
 
 
-def test_cli_walk_pattern_2(temp_dir):
+def test_cli_walk_pattern_2(test_dir):
     """test --walk with more than one --pattern"""
-    import os
-    import pathlib
-
-    from osxmetadata import OSXMetaData, Tag
-    from osxmetadata.__main__ import cli
 
-    dirname = pathlib.Path(temp_dir)
+    dirname = pathlib.Path(test_dir)
     os.makedirs(dirname / "temp" / "subfolder1")
     os.makedirs(dirname / "temp" / "subfolder2")
-    create_file(dirname / "temp" / "temp1.txt")
-    create_file(dirname / "temp" / "subfolder1" / "sub1.txt")
-    create_file(dirname / "temp" / "subfolder1" / "sub1.pdf")
-    create_file(dirname / "temp" / "subfolder2" / "sub2.jpg")
+    (dirname / "temp" / "temp1.txt").touch()
+    (dirname / "temp" / "subfolder1" / "sub1.txt").touch()
+    (dirname / "temp" / "subfolder1" / "sub1.pdf").touch()
+    (dirname / "temp" / "subfolder2" / "sub2.jpg").touch()
 
     runner = CliRunner()
     result = runner.invoke(
@@ -984,22 +365,22 @@ def test_cli_walk_pattern_2(temp_dir):
         [
             "--set",
             "tags",
-            "FOO",
+            "test",
             "--walk",
             "--pattern",
             "*.pdf",
             "--pattern",
             "*.jpg",
-            temp_dir,
+            test_dir,
         ],
     )
     assert result.exit_code == 0
 
     md = OSXMetaData(dirname / "temp" / "subfolder1" / "sub1.pdf")
-    assert md.tags == [Tag("FOO")]
+    assert md.tags == [Tag("test", 0)]
 
     md = OSXMetaData(dirname / "temp" / "subfolder2" / "sub2.jpg")
-    assert md.tags == [Tag("FOO")]
+    assert md.tags == [Tag("test", 0)]
 
     md = OSXMetaData(dirname / "temp" / "subfolder1" / "sub1.txt")
     assert not md.tags
@@ -1008,32 +389,159 @@ def test_cli_walk_pattern_2(temp_dir):
     assert not md.tags
 
 
-def test_cli_files_only(temp_dir):
+def test_cli_files_only(test_dir):
     """test --files-only without --walk"""
-    import glob
-    import os
-    import pathlib
-
-    from osxmetadata import OSXMetaData, Tag
-    from osxmetadata.__main__ import cli
 
-    dirname = pathlib.Path(temp_dir)
+    dirname = pathlib.Path(test_dir)
     os.makedirs(dirname / "temp" / "subfolder1")
     os.makedirs(dirname / "temp" / "subfolder2")
-    create_file(dirname / "temp" / "temp1.txt")
-    create_file(dirname / "temp" / "subfolder1" / "sub1.txt")
+    (dirname / "temp" / "temp1.txt").touch()
+    (dirname / "temp" / "subfolder1" / "sub1.txt").touch()
 
     files = glob.glob(str(dirname / "temp" / "*"))
 
     runner = CliRunner()
-    result = runner.invoke(cli, ["--set", "tags", "FOO", "--files-only", *files])
+    result = runner.invoke(cli, ["--set", "tags", "test", "--files-only", *files])
     assert result.exit_code == 0
 
     md = OSXMetaData(dirname / "temp" / "temp1.txt")
-    assert md.tags == [Tag("FOO")]
+    assert md.tags == [Tag("test", 0)]
 
     md = OSXMetaData(dirname / "temp" / "subfolder1")
     assert not md.tags
 
     md = OSXMetaData(dirname / "temp" / "subfolder1" / "sub1.txt")
     assert not md.tags
+
+
+def test_cli_backup_restore(test_dir):
+    """Test --backup and --restore"""
+
+    dirname = pathlib.Path(test_dir)
+    test_file = dirname / "test_file.txt"
+    test_file.touch()
+
+    md = OSXMetaData(test_file)
+    md.tags = [Tag("test", 0)]
+    md.authors = ["John Doe", "Jane Doe"]
+    md.wherefroms = ["http://www.apple.com"]
+    md.downloadeddate = [datetime.datetime(2019, 1, 1, 0, 0, 0)]
+    md.stationerypad = True
+
+    runner = CliRunner()
+    result = runner.invoke(cli, ["--backup", test_file.as_posix()])
+    assert result.exit_code == 0
+
+    # test the backup file was written and is readable
+    backup_file = dirname / BACKUP_FILENAME
+    assert backup_file.is_file()
+    backup_data = load_backup_file(backup_file)
+    assert backup_data[test_file.name]["stationerypad"] == True
+
+    # wipe the data
+    result = runner.invoke(cli, ["--wipe", test_file.as_posix()])
+    assert not md.tags
+    assert not md.authors
+    assert not md.stationerypad
+
+    # restore the data
+    result = runner.invoke(cli, ["--restore", test_file.as_posix()])
+    assert result.exit_code == 0
+    assert md.tags == [Tag("test", 0)]
+    assert md.authors == ["John Doe", "Jane Doe"]
+    assert md.wherefroms == ["http://www.apple.com"]
+    assert md.downloadeddate == [datetime.datetime(2019, 1, 1, 0, 0, 0)]
+    assert md.stationerypad
+
+
+def test_cli_backup_walk_pattern(test_dir):
+    """test --backup --walk with --pattern"""
+
+    dirname = pathlib.Path(test_dir)
+    os.makedirs(dirname / "temp" / "subfolder1")
+    os.makedirs(dirname / "temp" / "subfolder2")
+    (dirname / "temp" / "temp1.txt").touch()
+    (dirname / "temp" / "subfolder1" / "sub1.txt").touch()
+    (dirname / "temp" / "subfolder1" / "sub1.pdf").touch()
+
+    runner = CliRunner()
+    result = runner.invoke(
+        cli,
+        ["--set", "tags", "test", "--walk", "--pattern", "*.pdf", "--backup", test_dir],
+    )
+    assert result.exit_code == 0
+
+    md = OSXMetaData(dirname / "temp" / "subfolder1" / "sub1.pdf")
+    assert md.tags == [Tag("test", 0)]
+
+    backup_file = dirname / "temp" / "subfolder1" / BACKUP_FILENAME
+    assert backup_file.is_file()
+    backup_data = load_backup_file(backup_file)
+    assert backup_data["sub1.pdf"][_kMDItemUserTags] == [["test", 0]]
+    assert backup_data.get("sub1.txt") is None
+
+
+def test_cli_order(test_dir):
+    """Test order CLI options are executed
+
+    Order of execution should be:
+    restore, wipe, copyfrom, clear, set, append, remove, mirror, get, list, backup
+    """
+
+    dirname = pathlib.Path(test_dir)
+    test_file = dirname / "test_file.txt"
+    test_file.touch()
+
+    md = OSXMetaData(test_file.name)
+    md.tags = [Tag("test", 0)]
+    md.authors = ["John Doe", "Jane Doe"]
+    md.wherefroms = ["http://www.apple.com"]
+    md.downloadeddate = [datetime.datetime(2019, 1, 1, 0, 0, 0)]
+    md.findercomment = "Hello World"
+
+    runner = CliRunner()
+
+    # first, create backup file for --restore
+    runner.invoke(cli, ["--backup", test_file])
+
+    # wipe the data
+    runner.invoke(cli, ["--wipe", test_file])
+
+    # restore the data and check order of operations
+    result = runner.invoke(
+        cli,
+        [
+            "--get",
+            "comment",
+            "--set",
+            "authors",
+            "John Smith",
+            "--restore",
+            "--set",
+            "title",
+            "Test Title",
+            "--clear",
+            "title",
+            "--append",
+            "tags",
+            "test2",
+            "--set",
+            "comment",
+            "foo",
+            "--remove",
+            "authors",
+            "Jane Doe",
+            "--append",
+            "authors",
+            "Jane Smith",
+            "--mirror",
+            "comment",
+            "findercomment",
+            test_file.name,
+        ],
+    )
+    output = parse_cli_output(result.output)
+    assert output["comment"] == "Hello World"
+    assert md.authors == ["John Smith", "Jane Smith"]
+    assert md.findercomment == "Hello World"
+    assert md.tags == [Tag("test", 0), Tag("test2", 0)]
diff --git a/tests/test_datetime_attributes.py b/tests/test_datetime_attributes.py
deleted file mode 100644
index 127974e..0000000
--- a/tests/test_datetime_attributes.py
+++ /dev/null
@@ -1,114 +0,0 @@
-""" Test datetime.datetime attributes """
-
-import datetime
-from tempfile import NamedTemporaryFile, TemporaryDirectory
-
-import pytest
-from osxmetadata.attributes import ATTRIBUTES
-
-
-@pytest.fixture(params=["file", "dir"])
-def temp_file(request):
-
-    # TESTDIR for temporary files usually defaults to "/tmp",
-    # which may not have XATTR support (e.g. tmpfs);
-    # manual override here.
-    TESTDIR = None
-    if request.param == "file":
-        tempfile = NamedTemporaryFile(dir=TESTDIR)
-        tempfilename = tempfile.name
-        yield tempfilename
-        tempfile.close()
-    else:
-        tempdir = TemporaryDirectory(dir=TESTDIR)
-        tempdirname = tempdir.name
-        yield tempdirname
-        tempdir.cleanup()
-
-
-test_data = [
-    (attr)
-    for attr in sorted(list(ATTRIBUTES.keys()))
-    if ATTRIBUTES[attr].class_ == datetime.datetime
-]
-ids = [
-    attr
-    for attr in sorted(list(ATTRIBUTES.keys()))
-    if ATTRIBUTES[attr].class_ == datetime.datetime
-]
-
-test_data2 = [
-    (attribute_name)
-    for attribute_name in {
-        ATTRIBUTES[attr].name
-        for attr in sorted(ATTRIBUTES)
-        if ATTRIBUTES[attr].class_ == datetime.datetime
-    }
-]
-ids2 = [
-    attribute_name
-    for attribute_name in {
-        ATTRIBUTES[attr].name
-        for attr in sorted(ATTRIBUTES)
-        if ATTRIBUTES[attr].class_ == datetime.datetime
-    }
-]
-
-
-@pytest.mark.parametrize("attribute", test_data, ids=ids)
-def test_str_attribute(temp_file, attribute):
-    """test set_attribute, get_attribute, etc"""
-    from osxmetadata import OSXMetaData
-    from osxmetadata.constants import _FINDER_COMMENT_NAMES
-
-    meta = OSXMetaData(temp_file)
-    expected = datetime.datetime(2021, 7, 8)
-    meta.set_attribute(attribute, expected)
-    got = meta.get_attribute(attribute)
-    assert expected == got
-
-    meta.clear_attribute(attribute)
-    assert meta.get_attribute(attribute) == None
-
-    # test setting empty string
-    meta.set_attribute(attribute, "")
-    assert meta.get_attribute(attribute) is None
-
-    with pytest.raises(TypeError):
-        meta.update_attribute(attribute, "foo")
-
-    with pytest.raises(TypeError):
-        meta.discard_attribute(attribute, "foo")
-
-    with pytest.raises(TypeError):
-        meta.remove_attribute(attribute, "foo")
-
-
-@pytest.mark.parametrize("attribute", test_data2, ids=ids2)
-def test_str_attribute_2(temp_file, attribute):
-    """test getting and setting attribute by name"""
-    from osxmetadata import OSXMetaData
-
-    meta = OSXMetaData(temp_file)
-    setattr(meta, attribute, datetime.datetime(2021, 7, 8))
-    test_attr = getattr(meta, attribute)
-    assert test_attr == datetime.datetime(2021, 7, 8)
-    assert meta.get_attribute(attribute) == datetime.datetime(2021, 7, 8)
-
-
-def test_duedate(temp_file):
-    """test functions on one of the datetime attributes"""
-    from osxmetadata import OSXMetaData
-
-    meta = OSXMetaData(temp_file)
-    meta.duedate = "2021-07-08"
-    assert meta.duedate == datetime.datetime(2021, 7, 8)
-    assert meta.get_attribute("duedate") == datetime.datetime(2021, 7, 8)
-
-    meta.duedate = None
-    assert meta.duedate is None
-    assert meta.get_attribute("duedate") is None
-
-    meta.duedate = datetime.datetime(2021, 7, 8)
-    assert meta.duedate == datetime.datetime(2021, 7, 8)
-    assert meta.get_attribute("duedate") == datetime.datetime(2021, 7, 8)
diff --git a/tests/test_datetime_handling.py b/tests/test_datetime_handling.py
new file mode 100644
index 0000000..5a9a166
--- /dev/null
+++ b/tests/test_datetime_handling.py
@@ -0,0 +1,53 @@
+"""Test datetime handling """
+
+import datetime
+import os
+import plistlib
+import time
+
+import pytest
+
+from osxmetadata import OSXMetaData
+from osxmetadata.datetime_utils import *
+
+
+def test_datetime_attribute_naive(test_file):
+    """Test datetime attribute with naive value"""
+
+    os.environ["TZ"] = "US/Pacific"
+    time.tzset()
+
+    # naive datetime in local timezone
+    duedate = datetime.datetime(2022, 10, 1, 1, 2, 3)
+    md = OSXMetaData(test_file.name)
+    md.kMDItemDueDate = duedate
+    assert md.kMDItemDueDate == duedate
+
+    # Apple stores dates as UTC, so we need to convert to UTC
+    xattr_datetime = md.get_xattr(
+        "com.apple.metadata:kMDItemDueDate", decode=plistlib.loads
+    )
+    duedate_utc = datetime_remove_tz(
+        datetime_naive_to_local(duedate).astimezone(datetime.timezone.utc)
+    )
+    assert xattr_datetime == duedate_utc
+
+
+def test_datetime_attribute_tz_aware(test_file):
+    """Test datetime attribute with timezone aware value"""
+
+    os.environ["TZ"] = "US/Pacific"
+    time.tzset()
+
+    # naive datetime in local timezone
+    duedate = datetime_naive_to_local(datetime.datetime(2022, 10, 1, 1, 2, 3))
+    md = OSXMetaData(test_file.name)
+    md.kMDItemDueDate = duedate
+    assert md.kMDItemDueDate == datetime_remove_tz(duedate)
+
+    # Apple stores dates as UTC, so we need to convert to UTC
+    xattr_datetime = md.get_xattr(
+        "com.apple.metadata:kMDItemDueDate", decode=plistlib.loads
+    )
+    duedate_utc = datetime_remove_tz(duedate.astimezone(datetime.timezone.utc))
+    assert xattr_datetime == duedate_utc
diff --git a/tests/test_datetime_utils.py b/tests/test_datetime_utils.py
index 301944c..5874e41 100644
--- a/tests/test_datetime_utils.py
+++ b/tests/test_datetime_utils.py
@@ -1,57 +1,54 @@
-""" Test datetime utils"""
-
-import os
+"""Test datetime_utils """
 import datetime
-from osxmetadata import datetime_utils
+import os
 from datetime import date, timezone
+
 import pytest
 
+import osxmetadata.datetime_utils
+
 
 def test_get_local_tz():
     os.environ["TZ"] = "US/Pacific"
 
     dt = datetime.datetime(2020, 9, 1, 21, 10, 00)
-    tz = datetime_utils.get_local_tz(dt)
+    tz = osxmetadata.datetime_utils.get_local_tz(dt)
     assert tz == datetime.timezone(offset=datetime.timedelta(seconds=-25200))
 
     dt = datetime.datetime(2020, 12, 1, 21, 10, 00)
-    tz = datetime_utils.get_local_tz(dt)
+    tz = osxmetadata.datetime_utils.get_local_tz(dt)
     assert tz == datetime.timezone(offset=datetime.timedelta(seconds=-28800))
 
 
 def test_datetime_has_tz():
-
     tz = datetime.timezone(offset=datetime.timedelta(seconds=-28800))
     dt = datetime.datetime(2020, 9, 1, 21, 10, 00, tzinfo=tz)
-    assert datetime_utils.datetime_has_tz(dt)
+    assert osxmetadata.datetime_utils.datetime_has_tz(dt)
 
     dt = datetime.datetime(2020, 9, 1, 21, 10, 00)
-    assert not datetime_utils.datetime_has_tz(dt)
+    assert not osxmetadata.datetime_utils.datetime_has_tz(dt)
 
 
 def test_datetime_tz_to_utc():
-
     tz = datetime.timezone(offset=datetime.timedelta(seconds=-25200))
     dt = datetime.datetime(2020, 9, 1, 22, 6, 0, tzinfo=tz)
-    utc = datetime_utils.datetime_tz_to_utc(dt)
+    utc = osxmetadata.datetime_utils.datetime_tz_to_utc(dt)
     assert utc == datetime.datetime(2020, 9, 2, 5, 6, 0, tzinfo=datetime.timezone.utc)
 
 
 def test_datetime_remove_tz():
-
     os.environ["TZ"] = "US/Pacific"
 
     tz = datetime.timezone(offset=datetime.timedelta(seconds=-25200))
     dt = datetime.datetime(2020, 9, 1, 22, 6, 0, tzinfo=tz)
-    dt = datetime_utils.datetime_remove_tz(dt)
+    dt = osxmetadata.datetime_utils.datetime_remove_tz(dt)
     assert dt == datetime.datetime(2020, 9, 1, 22, 6, 0)
-    assert not datetime_utils.datetime_has_tz(dt)
+    assert not osxmetadata.datetime_utils.datetime_has_tz(dt)
 
 
 def test_datetime_naive_to_utc():
-
     dt = datetime.datetime(2020, 9, 1, 12, 0, 0)
-    utc = datetime_utils.datetime_naive_to_utc(dt)
+    utc = osxmetadata.datetime_utils.datetime_naive_to_utc(dt)
     assert utc == datetime.datetime(2020, 9, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
 
 
@@ -60,7 +57,7 @@ def test_datetime_naive_to_local():
 
     tz = datetime.timezone(offset=datetime.timedelta(seconds=-25200))
     dt = datetime.datetime(2020, 9, 1, 12, 0, 0)
-    utc = datetime_utils.datetime_naive_to_local(dt)
+    utc = osxmetadata.datetime_utils.datetime_naive_to_local(dt)
     assert utc == datetime.datetime(2020, 9, 1, 12, 0, 0, tzinfo=tz)
 
 
@@ -69,7 +66,7 @@ def test_datetime_utc_to_local():
 
     tz = datetime.timezone(offset=datetime.timedelta(seconds=-25200))
     utc = datetime.datetime(2020, 9, 1, 19, 0, 0, tzinfo=datetime.timezone.utc)
-    dt = datetime_utils.datetime_utc_to_local(utc)
+    dt = osxmetadata.datetime_utils.datetime_utc_to_local(utc)
     assert dt == datetime.datetime(2020, 9, 1, 12, 0, 0, tzinfo=tz)
 
 
@@ -78,5 +75,30 @@ def test_datetime_utc_to_local_2():
 
     tz = datetime.timezone(offset=datetime.timedelta(seconds=7200))
     utc = datetime.datetime(2020, 9, 1, 19, 0, 0, tzinfo=datetime.timezone.utc)
-    dt = datetime_utils.datetime_utc_to_local(utc)
+    dt = osxmetadata.datetime_utils.datetime_utc_to_local(utc)
     assert dt == datetime.datetime(2020, 9, 1, 21, 0, 0, tzinfo=tz)
+
+
+def test_datetime_to_new_tz():
+    """Test datetime_to_new_tz"""
+    tz = datetime.timezone(offset=datetime.timedelta(seconds=-25200))
+    dt = datetime.datetime(2021, 10, 1, 0, 30, 0, tzinfo=tz)
+    dt_new = osxmetadata.datetime_utils.datetime_to_new_tz(dt, 0)
+    assert dt_new == datetime.datetime(
+        2021, 10, 1, 7, 30, 0, tzinfo=datetime.timezone.utc
+    )
+
+    dt_new = osxmetadata.datetime_utils.datetime_to_new_tz(dt, 3600)
+    tz_new = datetime.timezone(offset=datetime.timedelta(seconds=3600))
+    assert dt_new == datetime.datetime(2021, 10, 1, 8, 30, 0, tzinfo=tz_new)
+
+
+def test_utc_offset_seconds():
+
+    dt_utc = datetime.datetime(2021, 9, 1, 0, 0, 0, 0, tzinfo=datetime.timezone.utc)
+    assert osxmetadata.datetime_utils.utc_offset_seconds(dt_utc) == 0
+
+    dt_pdt = datetime.datetime(
+        2021, 9, 1, 0, 0, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(hours=-7))
+    )
+    assert osxmetadata.datetime_utils.utc_offset_seconds(dt_pdt) == -25200
diff --git a/tests/test_download_date.py b/tests/test_download_date.py
deleted file mode 100644
index 0c6d1a6..0000000
--- a/tests/test_download_date.py
+++ /dev/null
@@ -1,126 +0,0 @@
-""" Test downloadeddate """
-
-from tempfile import NamedTemporaryFile, TemporaryDirectory
-
-import pytest
-import platform
-
-
-@pytest.fixture(params=["file", "dir"])
-def temp_file(request):
-
-    # TESTDIR for temporary files usually defaults to "/tmp",
-    # which may not have XATTR support (e.g. tmpfs);
-    # manual override here.
-    TESTDIR = None
-    if request.param == "file":
-        tempfile = NamedTemporaryFile(dir=TESTDIR)
-        tempfilename = tempfile.name
-        yield tempfilename
-        tempfile.close()
-    else:
-        tempdir = TemporaryDirectory(dir=TESTDIR)
-        tempdirname = tempdir.name
-        yield tempdirname
-        tempdir.cleanup()
-
-
-def test_download_date(temp_file):
-    from osxmetadata import OSXMetaData
-    import datetime
-    import logging
-    import os
-
-    meta = OSXMetaData(temp_file)
-    dt = datetime.datetime.now()
-    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.datetime_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.datetime_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.datetime_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.datetime_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.datetime_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 is None
diff --git a/tests/test_finder_info.py b/tests/test_finder_info.py
new file mode 100644
index 0000000..1046d50
--- /dev/null
+++ b/tests/test_finder_info.py
@@ -0,0 +1,72 @@
+"""Test osxmetadata get/set com.apple.FinderInfo xattr methods"""
+
+import pytest
+
+from osxmetadata import (
+    ALL_ATTRIBUTES,
+    OSXMetaData,
+    Tag,
+    _kFinderColor,
+    _kFinderInfo,
+    _kFinderStationeryPad,
+)
+from osxmetadata.constants import (
+    FINDER_COLOR_BLUE,
+    FINDER_COLOR_GREEN,
+    FINDER_COLOR_NONE,
+)
+
+
+def test_stationerypad(test_file):
+    """test get/set stationerypad methods"""
+
+    attribute = "com.apple.metadata:kMDItemComment"
+    value = "This is my comment"
+
+    md = OSXMetaData(test_file.name)
+    md.stationerypad = True
+    assert md.stationerypad == True
+
+    md.set(_kFinderStationeryPad, False)
+    assert md.get(_kFinderStationeryPad) == False
+
+
+def test_finderinfo(test_file):
+    """Test finderinfo attribute to get raw bytes"""
+
+    md = OSXMetaData(test_file.name)
+    assert len(md.finderinfo) == 32
+
+    assert (
+        md.finderinfo
+        == b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
+        + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
+    )
+    md.stationerypad = True
+    assert (
+        md.finderinfo
+        == b"\x00\x00\x00\x00\x00\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00"
+        + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
+    )
+
+
+def test_findercolor(test_file):
+    """Test findercolor attribute to get/set color in the FinderInfo field"""
+
+    md = OSXMetaData(test_file.name)
+    assert md.findercolor == FINDER_COLOR_NONE
+    md.findercolor = FINDER_COLOR_GREEN
+    assert md.findercolor == FINDER_COLOR_GREEN
+
+    # test that setting a tag color also sets the FinderInfo color
+    md.findercolor = FINDER_COLOR_NONE
+    md.tags = [Tag("Blue", color=FINDER_COLOR_BLUE)]
+    assert md.findercolor == FINDER_COLOR_BLUE
+
+
+def test_all_attributes():
+    """Test that all Finder Info attributes are in ALL_ATTRIBUTES"""
+
+    assert _kFinderStationeryPad in ALL_ATTRIBUTES
+    assert _kFinderInfo in ALL_ATTRIBUTES
+    assert _kFinderColor in ALL_ATTRIBUTES
diff --git a/tests/test_finder_tags.py b/tests/test_finder_tags.py
new file mode 100644
index 0000000..eacd97e
--- /dev/null
+++ b/tests/test_finder_tags.py
@@ -0,0 +1,45 @@
+"""Test Finder tags (_KMDItemUserTags) on a file."""
+
+import pytest
+from osxmetadata import *
+
+
+def test_finder_tags(test_file):
+    """Test Finder tags on a file."""
+
+    md = OSXMetaData(test_file.name)
+    assert not md.tags
+    tag_values = [Tag("foo", FINDER_COLOR_NONE), Tag("bar", FINDER_COLOR_RED)]
+    md.tags = tag_values
+    assert sorted(md.tags) == sorted(tag_values)
+
+    # test that tag names are being set correctly so NSURL can read them
+    assert sorted(md.NSURLTagNamesKey) == ["bar", "foo"]
+
+    # test that we can set tags to None
+    md.tags = None
+    assert not md.tags
+
+    # test that we can set tags to empty list
+    md.tags = []
+    assert not md.tags
+
+
+def test_finder_tags_get_set(test_file):
+    """Test Finder tags on a file with get/set _kMDItemUserTags."""
+
+    md = OSXMetaData(test_file.name)
+    tag_values = [Tag("foo", FINDER_COLOR_NONE), Tag("bar", FINDER_COLOR_RED)]
+    md.set(_kMDItemUserTags, tag_values)
+    assert sorted(md.get(_kMDItemUserTags)) == sorted(tag_values)
+
+    # test that tag names are being set correctly so NSURL can read them
+    assert sorted(md.NSURLTagNamesKey) == ["bar", "foo"]
+
+    # test that we can set tags to None
+    md.set(_kMDItemUserTags, None)
+    assert not md.get(_kMDItemUserTags)
+
+    # test that we can set tags to empty list
+    md.set(_kMDItemUserTags, [])
+    assert not md.get(_kMDItemUserTags)
diff --git a/tests/test_findercomment.py b/tests/test_findercomment.py
index bc7eb78..cb9922c 100644
--- a/tests/test_findercomment.py
+++ b/tests/test_findercomment.py
@@ -1,117 +1,89 @@
 """Test findercomment """
 
-from tempfile import NamedTemporaryFile, TemporaryDirectory
-
 import pytest
-import platform
-
-
-@pytest.fixture(params=["file", "dir"])
-def temp_file(request):
 
-    # TESTDIR for temporary files usually defaults to "/tmp",
-    # which may not have XATTR support (e.g. tmpfs);
-    # manual override here.
-    TESTDIR = None
-    if request.param == "file":
-        tempfile = NamedTemporaryFile(dir=TESTDIR)
-        tempfilename = tempfile.name
-        yield tempfilename
-        tempfile.close()
-    else:
-        tempdir = TemporaryDirectory(dir=TESTDIR)
-        tempdirname = tempdir.name
-        yield tempdirname
-        tempdir.cleanup()
+from osxmetadata import OSXMetaData, kMDItemFinderComment
 
 
-def test_finder_comments(temp_file):
-    from osxmetadata import OSXMetaData
+def test_finder_comments(test_file, snooze):
 
-    meta = OSXMetaData(temp_file)
+    md = OSXMetaData(test_file.name)
     fc = "This is my new comment"
-    meta.findercomment = fc
-    assert meta.findercomment == fc
-    meta.findercomment += ", foo"
+    md.findercomment = fc
+    # Finder comment is set via AppleScript events and may take a moment to update
+    snooze()
+    assert md.findercomment == fc
+    md.findercomment += ", foo"
     fc += ", foo"
-    assert meta.findercomment == fc
+    snooze()
+    assert md.findercomment == fc
 
     # set finder comment to "" deletes it as this mirrors Finder and mdls
-    meta.findercomment = ""
-    assert meta.findercomment is None
+    md.findercomment = ""
+    snooze()
+    assert not md.findercomment
 
-    meta.findercomment = "foo"
-    assert meta.findercomment == "foo"
+    md.findercomment = "foo"
+    snooze()
+    assert md.findercomment == "foo"
 
     # set finder comment to None deletes it
-    meta.findercomment = None
-    assert meta.findercomment is None
+    md.findercomment = None
+    snooze()
+    assert not md.findercomment
 
     # can we set findercomment after is was set to None?
-    meta.findercomment = "bar"
-    assert meta.findercomment == "bar"
+    md.findercomment = "bar"
+    snooze()
+    assert md.findercomment == "bar"
 
 
-def test_finder_comments_2(temp_file):
-    """ test get/set attribute """
-
-    from osxmetadata import OSXMetaData
-    from osxmetadata.constants import kMDItemFinderComment
+def test_finder_comments_get_set(test_file, snooze):
+    """test get/set attribute"""
 
     attribute = kMDItemFinderComment
 
-    meta = OSXMetaData(temp_file)
+    md = OSXMetaData(test_file.name)
     fc = "This is my new comment"
-    meta.set_attribute(attribute, fc)
-    assert meta.findercomment == fc
-    meta.findercomment += ", foo"
+    md.set(attribute, fc)
+    snooze()
+    assert md.findercomment == fc
+    md.findercomment += ", foo"
     fc += ", foo"
-    assert meta.findercomment == fc
+    snooze()
+    assert md.findercomment == fc
 
     # set finder comment to "" deletes it as this mirrors mdls and Finder
-    meta.set_attribute(attribute, "")
-    assert meta.get_attribute(attribute) is None
+    md.set(attribute, "")
+    snooze()
+    assert not md.get(attribute)
 
-    meta.set_attribute(attribute, "foo")
-    assert meta.get_attribute(attribute) == "foo"
+    md.set(attribute, "foo")
+    snooze()
+    assert md.get(attribute) == "foo"
 
     # set finder comment to None deletes it
-    meta.set_attribute(attribute, None)
-    assert meta.get_attribute(attribute) is None
-
-    meta.clear_attribute(attribute)
-    assert meta.get_attribute(attribute) is None
+    md.set(attribute, None)
+    snooze()
+    assert not md.get(attribute)
 
     # can we set findercomment after is was set to None?
-    meta.set_attribute(attribute, "bar")
-    assert meta.get_attribute(attribute) == "bar"
-
+    md.set(attribute, "bar")
+    snooze()
+    assert md.get(attribute) == "bar"
 
-def test_finder_comments_dir():
-    """ test get/set attribute but on a directory, not on a file"""
 
-    from osxmetadata import OSXMetaData
-    from osxmetadata.constants import kMDItemFinderComment
+def test_finder_comments_dir(test_dir, snooze):
+    """test get/set attribute but on a directory, not on a file"""
 
-    with TemporaryDirectory() as temp_dir:
-        attribute = kMDItemFinderComment
-
-        meta = OSXMetaData(temp_dir)
-        fc = "This is my new comment"
-        meta.set_attribute(attribute, fc)
-        assert meta.findercomment == fc
-        meta.findercomment += ", foo"
-        fc += ", foo"
-        assert meta.findercomment == fc
-
-
-# @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
+    attribute = kMDItemFinderComment
 
-#     meta = OSXMetaData(temp_file)
-#     with pytest.raises(ValueError):
-#         meta.findercomment = "x" * _MAX_FINDERCOMMENT + "x"
+    md = OSXMetaData(test_dir)
+    fc = "This is my new comment"
+    md.set(attribute, fc)
+    snooze()
+    assert md.findercomment == fc
+    md.findercomment += ", foo"
+    fc += ", foo"
+    snooze()
+    assert md.findercomment == fc
diff --git a/tests/test_finderinfo.py b/tests/test_finderinfo.py
deleted file mode 100644
index c218bf3..0000000
--- a/tests/test_finderinfo.py
+++ /dev/null
@@ -1,213 +0,0 @@
-""" Test finderinfo and findercolor """
-
-from tempfile import NamedTemporaryFile, TemporaryDirectory
-
-import pytest
-
-
-@pytest.fixture(params=["file", "dir"])
-def temp_file(request):
-
-    # TESTDIR for temporary files usually defaults to "/tmp",
-    # which may not have XATTR support (e.g. tmpfs);
-    # manual override here.
-    TESTDIR = None
-    if request.param == "file":
-        tempfile = NamedTemporaryFile(dir=TESTDIR)
-        tempfilename = tempfile.name
-        yield tempfilename
-        tempfile.close()
-    else:
-        tempdir = TemporaryDirectory(dir=TESTDIR)
-        tempdirname = tempdir.name
-        yield tempdirname
-        tempdir.cleanup()
-
-
-def test_finderinfo_colorid(temp_file):
-
-    from osxmetadata import OSXMetaData, Tag
-    from osxmetadata.constants import _MAX_FINDER_COLOR
-    from osxmetadata.findertags import get_tag_color_name
-
-    meta = OSXMetaData(temp_file)
-
-    # check each color combo.  Setting 0 doesn't work -- the attribute gets deleted
-    for color_id in range(_MAX_FINDER_COLOR + 1, _MAX_FINDER_COLOR + 1):
-        meta.finderinfo.color = color_id
-        color_got = meta.finderinfo.color
-        assert color_got == color_id
-        color_name = get_tag_color_name(color_id)
-        assert meta.tags == [Tag(color_name, color_id)]
-
-
-def test_invalid_colorid(temp_file):
-    from osxmetadata import OSXMetaData
-    from osxmetadata.constants import _MAX_FINDER_COLOR, _MIN_FINDER_COLOR
-
-    meta = OSXMetaData(temp_file)
-
-    with pytest.raises(ValueError):
-        meta.finderinfo.color = _MAX_FINDER_COLOR + 1
-
-    with pytest.raises(ValueError):
-        meta.finderinfo.color = _MIN_FINDER_COLOR - 1
-
-
-def test_findercolor_colorid(temp_file):
-
-    from osxmetadata import OSXMetaData, Tag
-    from osxmetadata.constants import _MAX_FINDER_COLOR
-    from osxmetadata.findertags import get_tag_color_name
-
-    meta = OSXMetaData(temp_file)
-
-    # check each color combo.  Setting 0 doesn't work -- the attribute gets deleted
-    for color_id in range(_MAX_FINDER_COLOR + 1, _MAX_FINDER_COLOR + 1):
-        meta.findercolor = color_id
-        color_got = meta.findercolor
-        assert color_got == color_id
-        color_name = get_tag_color_name(color_id)
-        assert meta.tags == [Tag(color_name, color_id)]
-
-
-def test_invalid_findercolor_colorid(temp_file):
-    from osxmetadata import OSXMetaData
-    from osxmetadata.constants import _MAX_FINDER_COLOR, _MIN_FINDER_COLOR
-
-    meta = OSXMetaData(temp_file)
-
-    with pytest.raises(ValueError):
-        meta.findercolor = _MAX_FINDER_COLOR + 1
-
-    with pytest.raises(ValueError):
-        meta.findercolor = _MIN_FINDER_COLOR - 1
-
-
-def test_finderinfo_color_set_get_clear(temp_file):
-    """Test ability to set_attribute, get_attribute, clear_attribute on finderinfo"""
-    from osxmetadata import OSXMetaData, Tag
-    from osxmetadata.constants import FINDER_COLOR_GREEN, FINDER_COLOR_BLUE
-    from osxmetadata.findertags import get_tag_color_name
-
-    meta = OSXMetaData(temp_file)
-
-    assert meta.finderinfo.color == 0
-    meta.finderinfo.color = FINDER_COLOR_GREEN
-    assert meta.finderinfo.color == FINDER_COLOR_GREEN
-
-    assert {"color": FINDER_COLOR_GREEN}.items() <= meta.get_attribute(
-        "finderinfo"
-    ).items()
-
-    meta.set_attribute("finderinfo", {"color": FINDER_COLOR_BLUE})
-    assert meta.finderinfo.color == FINDER_COLOR_BLUE
-
-    meta.clear_attribute("finderinfo")
-    assert meta.finderinfo.color == 0
-
-    meta.finderinfo.color = FINDER_COLOR_GREEN
-    meta.finderinfo.color = None
-    assert meta.finderinfo.color == 0
-
-    meta.finderinfo.color = FINDER_COLOR_GREEN
-    meta.finderinfo = None
-    assert meta.finderinfo.color == 0
-
-
-def test_finderinfo_stationarypad_set_get_clear(temp_file):
-    """Test ability to set_attribute, get_attribute, clear_attribute on finderinfo"""
-    from osxmetadata import OSXMetaData, Tag
-
-    meta = OSXMetaData(temp_file)
-
-    assert meta.finderinfo.stationarypad == 0
-    meta.finderinfo.stationarypad = True
-    assert meta.finderinfo.stationarypad
-
-    assert {"stationarypad": True}.items() <= meta.get_attribute("finderinfo").items()
-
-    meta.set_attribute("finderinfo", {"stationarypad": True})
-    assert meta.finderinfo.stationarypad
-
-    meta.clear_attribute("finderinfo")
-    assert not meta.finderinfo.stationarypad
-
-    meta.finderinfo.stationarypad = None
-    assert not meta.finderinfo.stationarypad
-
-    # test string to bool
-    meta.finderinfo.stationarypad = "True"
-    assert meta.finderinfo.stationarypad
-
-
-def test_finderinfo_stationarypad_and_color(temp_file):
-    """Test ability to modify color and stationary pad independently"""
-    from osxmetadata import OSXMetaData, Tag
-    from osxmetadata.constants import (
-        FINDER_COLOR_GREEN,
-        FINDER_COLOR_BLUE,
-        FINDER_COLOR_NONE,
-    )
-
-    meta = OSXMetaData(temp_file)
-
-    assert meta.finderinfo.stationarypad == 0
-    assert meta.finderinfo.color == FINDER_COLOR_NONE
-
-    meta.finderinfo.stationarypad = True
-    assert meta.finderinfo.stationarypad
-    assert meta.finderinfo.color == FINDER_COLOR_NONE
-
-    meta.finderinfo.color = FINDER_COLOR_GREEN
-    assert meta.finderinfo.color == FINDER_COLOR_GREEN
-    assert meta.finderinfo.stationarypad
-
-    meta.finderinfo.stationarypad = False
-    assert not meta.finderinfo.stationarypad
-    assert meta.finderinfo.color == FINDER_COLOR_GREEN
-
-    assert {
-        "color": FINDER_COLOR_GREEN,
-        "stationarypad": False,
-    }.items() <= meta.get_attribute("finderinfo").items()
-
-    meta.set_attribute("finderinfo", {"stationarypad": True})
-    assert meta.finderinfo.stationarypad
-    assert meta.finderinfo.color == FINDER_COLOR_GREEN
-
-    meta.set_attribute("finderinfo", {"color": FINDER_COLOR_BLUE})
-    assert meta.finderinfo.color == FINDER_COLOR_BLUE
-    assert meta.finderinfo.stationarypad
-
-    meta.finderinfo = {"color": FINDER_COLOR_GREEN}
-    assert meta.finderinfo.color == FINDER_COLOR_GREEN
-    assert meta.finderinfo.stationarypad
-
-    meta.finderinfo = {"stationarypad": False}
-    assert meta.finderinfo.color == FINDER_COLOR_GREEN
-    assert not meta.finderinfo.stationarypad
-
-
-def test_stationarypad(temp_file):
-    """Test stationarypad attribute"""
-    from osxmetadata import OSXMetaData
-
-    meta = OSXMetaData(temp_file)
-    assert not meta.stationarypad
-
-    meta.stationarypad = True
-    assert meta.stationarypad
-
-    meta.stationarypad = 0
-    assert not meta.stationarypad
-
-    meta.stationarypad = 1
-    assert meta.stationarypad
-
-    meta.stationarypad = False
-    assert not meta.stationarypad
-    assert not meta.get_attribute("stationarypad")
-    
-    meta.set_attribute("stationarypad", True)
-    assert meta.get_attribute("stationarypad")
diff --git a/tests/test_image.jpg b/tests/test_image.jpg
new file mode 100644
index 0000000..9d89191
Binary files /dev/null and b/tests/test_image.jpg differ
diff --git a/tests/test_int_attributes.py b/tests/test_int_attributes.py
deleted file mode 100644
index 0ea3ecf..0000000
--- a/tests/test_int_attributes.py
+++ /dev/null
@@ -1,118 +0,0 @@
-""" Test int attributes """
-
-from tempfile import NamedTemporaryFile, TemporaryDirectory
-
-import pytest
-import platform
-
-from osxmetadata.attributes import ATTRIBUTES
-
-
-@pytest.fixture(params=["file", "dir"])
-def temp_file(request):
-
-    # TESTDIR for temporary files usually defaults to "/tmp",
-    # which may not have XATTR support (e.g. tmpfs);
-    # manual override here.
-    TESTDIR = None
-    if request.param == "file":
-        tempfile = NamedTemporaryFile(dir=TESTDIR)
-        tempfilename = tempfile.name
-        yield tempfilename
-        tempfile.close()
-    else:
-        tempdir = TemporaryDirectory(dir=TESTDIR)
-        tempdirname = tempdir.name
-        yield tempdirname
-        tempdir.cleanup()
-
-
-test_data = [
-    (attr) for attr in sorted(list(ATTRIBUTES.keys())) if ATTRIBUTES[attr].class_ == int
-]
-ids = [
-    attr for attr in sorted(list(ATTRIBUTES.keys())) if ATTRIBUTES[attr].class_ == int
-]
-
-test_data2 = [
-    (attribute_name)
-    for attribute_name in {
-        ATTRIBUTES[attr].name
-        for attr in sorted(ATTRIBUTES)
-        if ATTRIBUTES[attr].class_ == int
-    }
-]
-ids2 = [
-    attribute_name
-    for attribute_name in {
-        ATTRIBUTES[attr].name
-        for attr in sorted(ATTRIBUTES)
-        if ATTRIBUTES[attr].class_ == int
-    }
-]
-
-
-@pytest.mark.parametrize("attribute", test_data, ids=ids)
-def test_str_attribute(temp_file, attribute):
-    """test set_attribute, get_attribute, etc"""
-    from osxmetadata import OSXMetaData
-    from osxmetadata.constants import _FINDER_COMMENT_NAMES
-
-    meta = OSXMetaData(temp_file)
-    expected = 2
-    meta.set_attribute(attribute, expected)
-    got = meta.get_attribute(attribute)
-    assert expected == got
-
-    expected += 1
-    meta.append_attribute(attribute, 1)
-    assert meta.get_attribute(attribute) == expected
-
-    meta.clear_attribute(attribute)
-    assert meta.get_attribute(attribute) is None
-
-    with pytest.raises(TypeError):
-        meta.set_attribute(attribute, "")
-
-    with pytest.raises(TypeError):
-        meta.update_attribute(attribute, "foo")
-
-    with pytest.raises(TypeError):
-        meta.discard_attribute(attribute, "foo")
-
-    with pytest.raises(TypeError):
-        meta.remove_attribute(attribute, "foo")
-
-
-@pytest.mark.parametrize("attribute", test_data2, ids=ids2)
-def test_str_attribute_2(temp_file, attribute):
-    """test getting and setting attribute by name"""
-    from osxmetadata import OSXMetaData
-
-    meta = OSXMetaData(temp_file)
-    setattr(meta, attribute, 3)
-    test_attr = getattr(meta, attribute)
-    assert test_attr == 3
-    assert meta.get_attribute(attribute) == 3
-
-
-def test_rating(temp_file):
-    """test int functions on one of the int attributes"""
-    from osxmetadata import OSXMetaData
-
-    meta = OSXMetaData(temp_file)
-    meta.rating = 4
-    assert meta.rating == 4
-    assert meta.get_attribute("rating") == 4
-
-    meta.rating += 1
-    assert meta.rating == 5
-    assert meta.get_attribute("rating") == 5
-
-    meta.rating = None
-    assert meta.rating is None
-    assert meta.get_attribute("rating") is None
-
-    meta.rating = 5
-    assert meta.rating == 5
-    assert meta.get_attribute("rating") == 5
diff --git a/tests/test_list_attributes.py b/tests/test_list_attributes.py
deleted file mode 100644
index eaee73a..0000000
--- a/tests/test_list_attributes.py
+++ /dev/null
@@ -1,181 +0,0 @@
-"""Test list-type attributes """
-
-from tempfile import NamedTemporaryFile, TemporaryDirectory
-
-import pytest
-import platform
-
-from osxmetadata.attributes import ATTRIBUTES
-from osxmetadata.classes import _AttributeList
-
-
-@pytest.fixture(params=["file", "dir"])
-def temp_file(request):
-
-    # TESTDIR for temporary files usually defaults to "/tmp",
-    # which may not have XATTR support (e.g. tmpfs);
-    # manual override here.
-    TESTDIR = None
-    if request.param == "file":
-        tempfile = NamedTemporaryFile(dir=TESTDIR)
-        tempfilename = tempfile.name
-        yield tempfilename
-        tempfile.close()
-    else:
-        tempdir = TemporaryDirectory(dir=TESTDIR)
-        tempdirname = tempdir.name
-        yield tempdirname
-        tempdir.cleanup()
-
-
-test_data = [
-    (attr)
-    for attr in sorted(list(ATTRIBUTES.keys()))
-    if ATTRIBUTES[attr].class_ == _AttributeList and ATTRIBUTES[attr].type_ == str
-]
-ids = [
-    attr
-    for attr in sorted(list(ATTRIBUTES.keys()))
-    if ATTRIBUTES[attr].class_ == _AttributeList and ATTRIBUTES[attr].type_ == str
-]
-
-test_data2 = [
-    (attribute_name)
-    for attribute_name in {
-        ATTRIBUTES[attr].name
-        for attr in sorted(ATTRIBUTES)
-        if ATTRIBUTES[attr].class_ == _AttributeList and ATTRIBUTES[attr].type_ == str
-    }
-]
-ids2 = [
-    attribute_name
-    for attribute_name in {
-        ATTRIBUTES[attr].name
-        for attr in sorted(ATTRIBUTES)
-        if ATTRIBUTES[attr].class_ == _AttributeList and ATTRIBUTES[attr].type_ == str
-    }
-]
-
-
-@pytest.mark.parametrize("attribute", test_data, ids=ids)
-def test_list_attribute(temp_file, attribute):
-    """test set_attribute, get_attribute, etc for list items"""
-    from osxmetadata import OSXMetaData
-
-    meta = OSXMetaData(temp_file)
-    expected = ["Foo"]
-    meta.set_attribute(attribute, expected)
-    got = meta.get_attribute(attribute)
-    assert expected == got
-
-    expected += ["Bar"]
-    meta.append_attribute(attribute, ["Bar"])
-    assert meta.get_attribute(attribute) == expected
-
-    expected.remove("Bar")
-    meta.remove_attribute(attribute, "Bar")
-    assert meta.get_attribute(attribute) == expected
-
-    with pytest.raises(ValueError):
-        meta.remove_attribute(attribute, "Bar")
-
-    expected += ["Flooz"]
-    meta.update_attribute(attribute, ["Flooz"])
-    assert meta.get_attribute(attribute) == expected
-
-    meta.discard_attribute(attribute, "foo")
-    assert meta.get_attribute(attribute) == expected
-
-    meta.clear_attribute(attribute)
-    assert meta.get_attribute(attribute) is None
-
-    expected = ["Foo"]
-    meta.set_attribute(attribute, expected)
-    got = meta.get_attribute(attribute)
-    assert expected == got
-
-
-@pytest.mark.parametrize("attribute", test_data2, ids=ids2)
-def test_list_attribute_2(temp_file, attribute):
-    """test getting and setting attribute by name"""
-    from osxmetadata import OSXMetaData
-
-    meta = OSXMetaData(temp_file)
-    setattr(meta, attribute, ["Foo", "Bar"])
-    test_attr = getattr(meta, attribute)
-    assert test_attr == ["Foo", "Bar"]
-    assert meta.get_attribute(attribute) == ["Foo", "Bar"]
-
-
-def test_list_methods(temp_file):
-    """Test various list methods"""
-    from osxmetadata import OSXMetaData
-    from osxmetadata.constants import kMDItemKeywords
-
-    attribute = kMDItemKeywords
-
-    # updatekeywords
-    meta = OSXMetaData(temp_file)
-    keywordset = ["Test", "Green", "Foo"]
-
-    meta.keywords = keywordset
-    assert meta.keywords == keywordset
-    assert meta.get_attribute(attribute) == keywordset
-
-    idx = meta.keywords.index("Green")
-    assert idx == 1
-
-    count = meta.keywords.count("Test")
-    assert count == 1
-
-    count = meta.keywords.count("Bar")
-    assert count == 0
-
-    meta.keywords.sort()
-    assert meta.keywords == ["Foo", "Green", "Test"]
-    assert meta.get_attribute(attribute) == ["Foo", "Green", "Test"]
-
-    meta.keywords.sort(reverse=True)
-    assert meta.keywords == ["Test", "Green", "Foo"]
-    assert meta.get_attribute(attribute) == ["Test", "Green", "Foo"]
-
-    # sort by key
-    meta.keywords.sort(key=lambda tag: len(tag))
-    assert meta.keywords == ["Foo", "Test", "Green"]
-    assert meta.get_attribute(attribute) == ["Foo", "Test", "Green"]
-
-    meta.keywords.reverse()
-    assert meta.keywords == ["Green", "Test", "Foo"]
-    assert meta.get_attribute(attribute) == ["Green", "Test", "Foo"]
-
-    tag_expected = "Test"
-    tag_got = meta.keywords.pop(1)
-    assert tag_got == tag_expected
-    assert meta.keywords == ["Green", "Foo"]
-    assert meta.get_attribute(attribute) == ["Green", "Foo"]
-
-    # delete by assigning None
-    meta.keywords = None
-    assert meta.keywords == []
-
-
-# def test_description(temp_file):
-#     """ test string functions on one of the str attributes """
-#     from osxmetadata import OSXMetaData
-
-#     meta = OSXMetaData(temp_file)
-#     meta.description = "Foo"
-#     assert meta.description == "Foo"
-#     assert meta.get_attribute("description") == "Foo"
-
-#     meta.description += " Bar"
-#     assert meta.description == "Foo Bar"
-#     assert meta.get_attribute("description") == "Foo Bar"
-
-#     meta.description = None
-#     assert meta.description is None
-#     assert meta.get_attribute("description") is None
-
-#     meta.description = "Foo"
-#     assert meta.description == "Foo"
-#     assert meta.get_attribute("description") == "Foo"
diff --git a/tests/test_mditem_attributes.py b/tests/test_mditem_attributes.py
new file mode 100644
index 0000000..e49c11d
--- /dev/null
+++ b/tests/test_mditem_attributes.py
@@ -0,0 +1,140 @@
+"""Test osxmetadata basic mditem metadata tags"""
+
+import pytest
+
+from osxmetadata import OSXMetaData
+from osxmetadata.attribute_data import (
+    MDITEM_ATTRIBUTE_AUDIO,
+    MDITEM_ATTRIBUTE_DATA,
+    MDITEM_ATTRIBUTE_IMAGE,
+    MDITEM_ATTRIBUTE_READ_ONLY,
+    MDITEM_ATTRIBUTE_VIDEO,
+)
+
+from .conftest import value_for_type
+
+# filter out the read-only attributes
+MDITEM_ATTRIBUTES_TO_TEST = [
+    a["name"]
+    for a in MDITEM_ATTRIBUTE_DATA.values()
+    if a["name"] not in MDITEM_ATTRIBUTE_READ_ONLY
+    and a["name"] not in MDITEM_ATTRIBUTE_AUDIO
+    and a["name"] not in MDITEM_ATTRIBUTE_IMAGE
+    and a["name"] not in MDITEM_ATTRIBUTE_VIDEO
+]
+
+# Not all attributes can be cleared by setting to None
+MDITEM_ATTRIBUTES_CAN_BE_REMOVED = [
+    a for a in MDITEM_ATTRIBUTES_TO_TEST if a not in ["kMDItemContentModificationDate"]
+]
+
+
+@pytest.mark.parametrize("attribute_name", MDITEM_ATTRIBUTES_TO_TEST)
+def test_mditem_attributes_get_set(attribute_name, test_file, snooze):
+    """test mditem attributes"""
+
+    # can't use tmp_path fixture because the tmpfs filesystem doesn't support xattrs
+    attribute = MDITEM_ATTRIBUTE_DATA[attribute_name]
+    attribute_type = attribute["python_type"]
+    test_value = value_for_type(attribute_type)
+
+    md = OSXMetaData(test_file.name)
+    md.set(attribute_name, test_value)
+    if attribute_name == "kMDItemFinderComment":
+        snooze()  # Finder needs a moment to update the comment
+    assert md.get(attribute_name) == test_value
+
+
+@pytest.mark.parametrize("attribute_name", MDITEM_ATTRIBUTES_TO_TEST)
+def test_mditem_attributes_dict(attribute_name, test_file, snooze):
+    """test mditem attributes with dict access"""
+
+    attribute = MDITEM_ATTRIBUTE_DATA[attribute_name]
+    attribute_type = attribute["python_type"]
+    test_value = value_for_type(attribute_type)
+
+    md = OSXMetaData(test_file.name)
+    md[attribute_name] = test_value
+    if attribute_name == "kMDItemFinderComment":
+        snooze()  # Finder needs a bit of time to update the metadata
+    assert md[attribute_name] == test_value
+
+
+@pytest.mark.parametrize("attribute_name", MDITEM_ATTRIBUTES_TO_TEST)
+def test_mditem_attributes_property(attribute_name, test_file, snooze):
+    """test mditem attributes with property access"""
+
+    attribute = MDITEM_ATTRIBUTE_DATA[attribute_name]
+    attribute_type = attribute["python_type"]
+    test_value = value_for_type(attribute_type)
+
+    md = OSXMetaData(test_file.name)
+    setattr(md, attribute_name, test_value)
+    if attribute_name == "kMDItemFinderComment":
+        snooze()  # Finder comment is slow to update
+    assert getattr(md, attribute_name) == test_value
+
+
+@pytest.mark.parametrize("attribute_name", MDITEM_ATTRIBUTES_TO_TEST)
+def test_mditem_attributes_short_name(attribute_name, test_file, snooze):
+    """test mditem attributes"""
+
+    attribute = MDITEM_ATTRIBUTE_DATA[attribute_name]
+    attribute_type = attribute["python_type"]
+    test_value = value_for_type(attribute_type)
+
+    md = OSXMetaData(test_file.name)
+    setattr(md, attribute["short_name"], test_value)
+    if attribute_name == "findercomment":
+        snooze()  # Finder comment is slow to update
+    assert getattr(md, attribute["short_name"]) == test_value
+
+
+@pytest.mark.parametrize("attribute_name", MDITEM_ATTRIBUTE_DATA.keys())
+def test_mditem_attributes_all(attribute_name, test_file):
+    """Test that all attributes can be accessed without error"""
+
+    md = OSXMetaData(test_file.name)
+    md.get(attribute_name)
+
+
+@pytest.mark.parametrize("attribute_name", MDITEM_ATTRIBUTES_CAN_BE_REMOVED)
+def test_mditem_attributes_set_none(attribute_name, test_file, snooze):
+    """test mditem attributes can be set to None to remove"""
+
+    # can't use tmp_path fixture because the tmpfs filesystem doesn't support xattrs
+    attribute = MDITEM_ATTRIBUTE_DATA[attribute_name]
+    attribute_type = attribute["python_type"]
+    test_value = value_for_type(attribute_type)
+    md = OSXMetaData(test_file.name)
+    md.set(attribute_name, test_value)
+    if attribute_name == "kMDItemFinderComment":
+        snooze()  # Finder needs a moment to update the comment
+    assert md.get(attribute_name)
+    md.set(attribute_name, None)
+    if attribute_name == "kMDItemFinderComment":
+        snooze()
+    assert not md.get(attribute_name)
+
+
+def test_mditem_attributes_image(test_image):
+    """test mditem attributes for image files"""
+
+    md = OSXMetaData(test_image)
+    assert md.get("kMDItemLatitude") == "-34.91889166666667"
+    assert md.get("kMDItemPixelHeight") == 2754
+
+
+def test_mditem_attributes_video(test_video):
+    """test mditem attributes for video files"""
+
+    md = OSXMetaData(test_video)
+    assert sorted(md.get("kMDItemCodecs")) == sorted(["H.264", "AAC", "Timed Metadata"])
+    assert md.get("kMDItemAudioBitRate") == 64.0
+
+
+def test_mditem_attributes_audio(test_audio):
+    """test mditem attributes for audio files"""
+
+    md = OSXMetaData(test_audio)
+    assert md.get("kMDItemAudioSampleRate") == 44100.0
diff --git a/tests/test_nsurl_attributes.py b/tests/test_nsurl_attributes.py
new file mode 100644
index 0000000..4014830
--- /dev/null
+++ b/tests/test_nsurl_attributes.py
@@ -0,0 +1,43 @@
+"""Test osxmetadata NSURL metadata tags"""
+
+import pathlib
+
+import pytest
+
+from osxmetadata import OSXMetaData
+from osxmetadata.attribute_data import (
+    NSURL_RESOURCE_KEY_DATA,
+)
+
+
+@pytest.mark.parametrize("attribute_name", NSURL_RESOURCE_KEY_DATA.keys())
+def test_nsurl_attributes_all(attribute_name, test_file):
+    """Test that all NSURL attributes can be accessed without error"""
+
+    md = OSXMetaData(test_file.name)
+    md.get(attribute_name)
+
+
+def test_nsurl_attribute_NSURLNameKey(test_file):
+    """Test that NSURLNameKey can be read"""
+
+    md = OSXMetaData(test_file.name)
+    assert md.get("NSURLNameKey") == pathlib.Path(test_file.name).name
+
+
+def test_nsurl_attribute_NSURLIsRegularFileKey(test_file):
+    """Test that NSURLIsRegularFileKey can be read"""
+
+    md = OSXMetaData(test_file.name)
+    assert md.get("NSURLIsRegularFileKey") is True
+
+
+def test_nsurl_attribute_NSURLTagNamesKey(test_file):
+    """Test that NSURLTagNamesKey can be read and written"""
+
+    md = OSXMetaData(test_file.name)
+    assert md.get("NSURLTagNamesKey") is None
+    md["NSURLTagNamesKey"] = ["a", "b"]
+    assert md.NSURLTagNamesKey == ["a", "b"]
+    md.NSURLTagNamesKey = []
+    assert not md.NSURLTagNamesKey
diff --git a/tests/test_osxmetada_asdict.py b/tests/test_osxmetada_asdict.py
new file mode 100644
index 0000000..b0d7ceb
--- /dev/null
+++ b/tests/test_osxmetada_asdict.py
@@ -0,0 +1,35 @@
+"""Test osxmetadata asdict and to_json method"""
+
+import datetime
+import json
+
+from osxmetadata import ASDICT_ATTRIBUTES, OSXMetaData
+
+
+def test_asdict(test_file):
+    """Test asdict method"""
+    md = OSXMetaData(test_file.name)
+    md.authors = ["Jane Smith"]
+    asdict = md.asdict()
+    assert len(asdict) == len(ASDICT_ATTRIBUTES)
+    assert asdict["kMDItemAuthors"] == ["Jane Smith"]
+
+
+def test_asdict_subset(test_file):
+    """Test asdict method with subset of attributes"""
+    md = OSXMetaData(test_file.name)
+    md.authors = ["Jane Smith"]
+    asdict = md.asdict(attributes={"kMDItemAuthors"})
+    assert len(asdict) == 1
+    assert asdict["kMDItemAuthors"] == ["Jane Smith"]
+
+
+def test_to_json(test_file):
+    """Test to_json method"""
+    md = OSXMetaData(test_file.name)
+    md.authors = ["Jane Smith"]
+    md.duedate = datetime.datetime(2022, 10, 1)
+    json_str = md.to_json()
+    json_data = json.loads(json_str)
+    assert json_data["kMDItemAuthors"] == ["Jane Smith"]
+    assert json_data["kMDItemDueDate"] == datetime.datetime(2022, 10, 1).isoformat()
diff --git a/tests/test_osxmetada_path.py b/tests/test_osxmetada_path.py
new file mode 100644
index 0000000..bdd02ec
--- /dev/null
+++ b/tests/test_osxmetada_path.py
@@ -0,0 +1,12 @@
+"""Test osxmetadata path property"""
+
+import os
+
+from osxmetadata import OSXMetaData
+
+
+def test_asdict(test_file):
+    """Test asdict method"""
+    md = OSXMetaData(test_file.name)
+    cwd = os.getcwd()
+    assert md.path == os.path.join(cwd, test_file.name)
diff --git a/tests/test_osxmetadata.py b/tests/test_osxmetadata.py
deleted file mode 100644
index b1f51fd..0000000
--- a/tests/test_osxmetadata.py
+++ /dev/null
@@ -1,86 +0,0 @@
-""" Test OSXMetaData class """
-import platform
-from tempfile import NamedTemporaryFile, TemporaryDirectory
-
-import pytest
-
-
-@pytest.fixture(params=["file", "dir"])
-def temp_file(request):
-
-    # TESTDIR for temporary files usually defaults to "/tmp",
-    # which may not have XATTR support (e.g. tmpfs);
-    # manual override here.
-    TESTDIR = None
-    if request.param == "file":
-        tempfile = NamedTemporaryFile(dir=TESTDIR)
-        tempfilename = tempfile.name
-        yield tempfilename
-        tempfile.close()
-    else:
-        tempdir = TemporaryDirectory(dir=TESTDIR)
-        tempdirname = tempdir.name
-        yield tempdirname
-        tempdir.cleanup()
-
-
-def test_platform():
-    # this module only works on Mac (darwin)
-    import sys
-
-    assert sys.platform == "darwin"
-
-
-def test_name(temp_file):
-    from osxmetadata import OSXMetaData
-    import pathlib
-
-    meta = OSXMetaData(temp_file)
-    fname = pathlib.Path(temp_file)
-    assert meta.name == fname.resolve().as_posix()
-
-
-def test_to_json(temp_file):
-    import json
-    import pathlib
-    from osxmetadata import OSXMetaData, Tag, __version__
-    from osxmetadata.constants import FINDER_COLOR_NONE
-
-    meta = OSXMetaData(temp_file)
-    meta.tags = [Tag("foo"), Tag("bar")]
-    json_ = json.loads(meta.to_json())
-
-    assert json_["com.apple.metadata:_kMDItemUserTags"] == [
-        ["foo", FINDER_COLOR_NONE],
-        ["bar", FINDER_COLOR_NONE],
-    ]
-    assert json_["_version"] == __version__
-    assert json_["_filename"] == pathlib.Path(temp_file).name
-
-
-def test_restore(temp_file):
-    from osxmetadata import OSXMetaData, Tag, kMDItemComment
-
-    meta = OSXMetaData(temp_file)
-    meta.tags = [Tag("foo"), Tag("bar")]
-    meta.set_attribute(kMDItemComment, "Hello World!")
-    attr_dict = meta.asdict()
-    meta.tags = []
-    meta.clear_attribute(kMDItemComment)
-
-    assert meta.tags == []
-    assert meta.comment is None
-
-    meta._restore_attributes(attr_dict)
-    assert meta.tags == [Tag("foo"), Tag("bar")]
-    assert meta.get_attribute(kMDItemComment) == "Hello World!"
-
-
-def test_file_not_exists(temp_file):
-    from osxmetadata import OSXMetaData
-
-    with pytest.raises(Exception):
-        # kludge to create a file that almost certainly doesn't exist
-        # TODO: make this less kludgy
-        bad_filename = str(temp_file + temp_file)
-        _ = OSXMetaData(bad_filename)
diff --git a/tests/test_osxmetadata_exceptions.py b/tests/test_osxmetadata_exceptions.py
new file mode 100644
index 0000000..eb5c582
--- /dev/null
+++ b/tests/test_osxmetadata_exceptions.py
@@ -0,0 +1,49 @@
+"""Test exceptions raised by OSXMetaData"""
+
+import datetime
+
+import pytest
+
+from osxmetadata import OSXMetaData
+
+
+def test_get_invalid_attribute(test_file):
+    """Test get invalid attribute"""
+    md = OSXMetaData(test_file.name)
+    with pytest.raises(AttributeError):
+        md.invalid_attribute
+
+
+def test_set_invalid_attribute(test_file):
+    """Test set invalid attribute"""
+    md = OSXMetaData(test_file.name)
+    with pytest.raises(AttributeError):
+        md.invalid_attribute = "value"
+
+
+def test_set_readonly_attribute(test_file):
+    """Test set readonly attribute"""
+    md = OSXMetaData(test_file.name)
+    with pytest.raises(AttributeError):
+        md.kMDItemDateAdded = datetime.datetime.now()
+
+
+def test_get_invalid_key(test_file):
+    """Test get invalid key"""
+    md = OSXMetaData(test_file.name)
+    with pytest.raises(KeyError):
+        md["invalid_key"]
+
+
+def test_set_invalid_key(test_file):
+    """Test set invalid key"""
+    md = OSXMetaData(test_file.name)
+    with pytest.raises(KeyError):
+        md["invalid_key"] = "value"
+
+
+def test_set_readonly_key(test_file):
+    """Test set readonly key"""
+    md = OSXMetaData(test_file.name)
+    with pytest.raises(KeyError):
+        md["kMDItemDateAdded"] = datetime.datetime.now()
diff --git a/tests/test_osxphotos_attributes.py b/tests/test_osxphotos_attributes.py
deleted file mode 100644
index 0bdee21..0000000
--- a/tests/test_osxphotos_attributes.py
+++ /dev/null
@@ -1,114 +0,0 @@
-"""Test osxphotos attributes """
-
-from tempfile import NamedTemporaryFile, TemporaryDirectory
-
-import pytest
-
-from osxmetadata.attributes import ATTRIBUTES
-from osxmetadata.classes import _AttributeList
-
-
-@pytest.fixture(params=["file", "dir"])
-def temp_file(request):
-
-    # TESTDIR for temporary files usually defaults to "/tmp",
-    # which may not have XATTR support (e.g. tmpfs);
-    # manual override here.
-    TESTDIR = None
-    if request.param == "file":
-        tempfile = NamedTemporaryFile(dir=TESTDIR)
-        tempfilename = tempfile.name
-        yield tempfilename
-        tempfile.close()
-    else:
-        tempdir = TemporaryDirectory(dir=TESTDIR)
-        tempdirname = tempdir.name
-        yield tempdirname
-        tempdir.cleanup()
-
-
-def test_osxphotos_detected_text(temp_file):
-    """test set_attribute, get_attribute, etc for osxphotos items"""
-    from osxmetadata import OSXMetaData
-
-    attribute = "osxphotos_detected_text"
-
-    meta = OSXMetaData(temp_file)
-    expected = [["Foo", 0.5]]
-    meta.set_attribute(attribute, expected)
-    got = meta.get_attribute(attribute)
-    assert expected == got
-
-    expected.append(["Bar", 0.7])
-    meta.append_attribute(attribute, ["Bar", 0.7])
-    assert meta.get_attribute(attribute) == expected
-
-    expected.remove(["Bar", 0.7])
-    meta.remove_attribute(attribute, ["Bar", 0.7])
-    assert meta.get_attribute(attribute) == expected
-
-    with pytest.raises(ValueError):
-        meta.remove_attribute(attribute, "Bar")
-
-    expected += [["Flooz", 0.3]]
-    meta.update_attribute(attribute, ["Flooz", 0.3])
-    assert meta.get_attribute(attribute) == expected
-
-    expected.remove(["Flooz", 0.3])
-    meta.discard_attribute(attribute, ["Flooz", 0.3])
-    assert meta.get_attribute(attribute) == expected
-
-    meta.clear_attribute(attribute)
-    assert not meta.get_attribute(attribute)
-
-    expected = [["Foo", 0.75]]
-    meta.set_attribute(attribute, expected)
-    got = meta.get_attribute(attribute)
-    assert expected == got
-
-    meta.set_attribute(attribute, [])
-    assert not meta.get_attribute(attribute)
-
-
-def test_osxphotos_detected_text_attribute(temp_file):
-    """Test osxphotos.metadata:detected_text access by attribute"""
-    from osxmetadata import OSXMetaData
-
-    meta = OSXMetaData(temp_file)
-    expected = [["Foo", 0.5]]
-    meta.osxphotos_detected_text = expected
-    got = meta.osxphotos_detected_text
-    assert expected == got
-    assert len(meta.osxphotos_detected_text) == 1
-
-    expected.append(["Bar", 0.7])
-    meta.osxphotos_detected_text.append(["Bar", 0.7])
-    assert meta.osxphotos_detected_text == expected
-    assert len(meta.osxphotos_detected_text) == 2
-
-    expected.remove(["Bar", 0.7])
-    meta.osxphotos_detected_text.remove(["Bar", 0.7])
-    assert meta.osxphotos_detected_text == expected
-
-    with pytest.raises(ValueError):
-        meta.osxphotos_detected_text.remove("Bar")
-
-    expected += [["Flooz", 0.3]]
-    meta.osxphotos_detected_text.append(["Flooz", 0.3])
-    assert meta.osxphotos_detected_text == expected
-
-    expected.remove(["Flooz", 0.3])
-    meta.osxphotos_detected_text.remove(["Flooz", 0.3])
-    assert meta.osxphotos_detected_text == expected
-
-    meta.osxphotos_detected_text = None
-    assert not meta.osxphotos_detected_text
-    assert meta.get_attribute("osxphotos_detected_text") is None
-
-    expected = [["Foo", 0.75]]
-    meta.osxphotos_detected_text = expected
-    got = meta.osxphotos_detected_text
-    assert expected == got
-
-    meta.osxphotos_detected_text = []
-    assert not meta.osxphotos_detected_text
diff --git a/tests/test_str_attributes.py b/tests/test_str_attributes.py
deleted file mode 100644
index 2607812..0000000
--- a/tests/test_str_attributes.py
+++ /dev/null
@@ -1,126 +0,0 @@
-""" Test str attributes """
-
-from tempfile import NamedTemporaryFile, TemporaryDirectory
-
-import pytest
-import platform
-
-from osxmetadata.attributes import ATTRIBUTES
-
-
-@pytest.fixture(params=["file", "dir"])
-def temp_file(request):
-
-    # TESTDIR for temporary files usually defaults to "/tmp",
-    # which may not have XATTR support (e.g. tmpfs);
-    # manual override here.
-    TESTDIR = None
-    if request.param == "file":
-        tempfile = NamedTemporaryFile(dir=TESTDIR)
-        tempfilename = tempfile.name
-        yield tempfilename
-        tempfile.close()
-    else:
-        tempdir = TemporaryDirectory(dir=TESTDIR)
-        tempdirname = tempdir.name
-        yield tempdirname
-        tempdir.cleanup()
-
-
-test_data = [
-    (attr) for attr in sorted(list(ATTRIBUTES.keys())) if ATTRIBUTES[attr].class_ == str
-]
-ids = [
-    attr for attr in sorted(list(ATTRIBUTES.keys())) if ATTRIBUTES[attr].class_ == str
-]
-
-test_data2 = [
-    (attribute_name)
-    for attribute_name in {
-        ATTRIBUTES[attr].name
-        for attr in sorted(ATTRIBUTES)
-        if ATTRIBUTES[attr].class_ == str
-    }
-]
-ids2 = [
-    attribute_name
-    for attribute_name in {
-        ATTRIBUTES[attr].name
-        for attr in sorted(ATTRIBUTES)
-        if ATTRIBUTES[attr].class_ == str
-    }
-]
-
-
-@pytest.mark.parametrize("attribute", test_data, ids=ids)
-def test_str_attribute(temp_file, attribute):
-    """ test set_attribute, get_attribute, etc """
-    from osxmetadata import OSXMetaData
-    from osxmetadata.constants import _FINDER_COMMENT_NAMES
-
-    meta = OSXMetaData(temp_file)
-    expected = "Foo"
-    meta.set_attribute(attribute, expected)
-    got = meta.get_attribute(attribute)
-    assert expected == got
-
-    expected += " Bar"
-    meta.append_attribute(attribute, " Bar")
-    assert meta.get_attribute(attribute) == expected
-
-    meta.clear_attribute(attribute)
-    assert meta.get_attribute(attribute) == None
-
-    # test setting empty string
-    # setting finder comment to empty string clears it
-    # but other fields get set to empty string
-    # this mirrors the way finder comments work in mdls
-    meta.set_attribute(attribute, "")
-    if attribute in _FINDER_COMMENT_NAMES:
-        assert meta.get_attribute(attribute) is None
-    else:
-        assert meta.get_attribute(attribute) == ""
-
-    with pytest.raises(AttributeError):
-        meta.update_attribute(attribute, "foo")
-
-    with pytest.raises(TypeError):
-        meta.discard_attribute(attribute, "foo")
-
-    with pytest.raises(TypeError):
-        meta.remove_attribute(attribute, "foo")
-
-
-@pytest.mark.parametrize("attribute", test_data2, ids=ids2)
-def test_str_attribute_2(temp_file, attribute):
-    """ test getting and setting attribute by name """
-    from osxmetadata import OSXMetaData
-
-    meta = OSXMetaData(temp_file)
-    setattr(meta, attribute, "Foo")
-    test_attr = getattr(meta, attribute)
-    assert test_attr == "Foo"
-    assert meta.get_attribute(attribute) == "Foo"
-
-
-def test_description(temp_file):
-    """ test string functions on one of the str attributes """
-    from osxmetadata import OSXMetaData
-
-    meta = OSXMetaData(temp_file)
-    meta.description = "Foo"
-    assert meta.description == "Foo"
-    assert meta.get_attribute("description") == "Foo"
-
-    meta.description += " Bar"
-    assert meta.description == "Foo Bar"
-    assert meta.get_attribute("description") == "Foo Bar"
-
-    meta.description = None
-    assert meta.description is None
-    assert meta.get_attribute("description") is None
-
-    meta.description = "Foo"
-    assert meta.description == "Foo"
-    assert meta.get_attribute("description") == "Foo"
-
diff --git a/tests/test_tags.py b/tests/test_tags.py
deleted file mode 100644
index 7fcec30..0000000
--- a/tests/test_tags.py
+++ /dev/null
@@ -1,276 +0,0 @@
-#!/usr/bin/env python
-
-from tempfile import NamedTemporaryFile, TemporaryDirectory
-
-import pytest
-import platform
-
-
-@pytest.fixture(params=["file", "dir"])
-def temp_file(request):
-
-    # TESTDIR for temporary files usually defaults to "/tmp",
-    # which may not have XATTR support (e.g. tmpfs);
-    # manual override here.
-    TESTDIR = None
-    if request.param == "file":
-        tempfile = NamedTemporaryFile(dir=TESTDIR)
-        tempfilename = tempfile.name
-        yield tempfilename
-        tempfile.close()
-    else:
-        tempdir = TemporaryDirectory(dir=TESTDIR)
-        tempdirname = tempdir.name
-        yield tempdirname
-        tempdir.cleanup()
-
-
-def test_tags(temp_file):
-
-    from osxmetadata import OSXMetaData, Tag
-
-    # update tags
-    meta = OSXMetaData(temp_file)
-    tagset = [Tag("Test"), Tag("Green")]
-    meta.tags.extend(tagset)
-    assert meta.tags == tagset
-
-    # extend
-    meta.tags.extend([Tag("Foo")])
-    assert meta.tags == [Tag("Test"), Tag("Green"), Tag("Foo")]
-
-    # add tags
-    meta.tags.append(Tag("Hello"))
-    assert meta.tags != tagset
-    assert meta.tags == [Tag("Test"), Tag("Green"), Tag("Foo"), Tag("Hello")]
-
-    # __iadd__
-    meta.tags += [Tag("Bar")]
-    assert meta.tags == [
-        Tag("Test"),
-        Tag("Green"),
-        Tag("Foo"),
-        Tag("Hello"),
-        Tag("Bar"),
-    ]
-
-    # __repr__
-    # TODO
-
-    # remove tags
-    meta.tags.remove(Tag("Test"))
-    assert meta.tags == [Tag("Green"), Tag("Foo"), Tag("Hello"), Tag("Bar")]
-
-    # remove tag that doesn't exist, raises KeyError
-    with pytest.raises(ValueError):
-        assert meta.tags.remove(Tag("FooBar"))
-
-    # len
-    assert len(meta.tags) == 4
-
-    # clear tags
-    meta.tags.clear()
-    assert meta.tags == []
-
-
-def test_tags_2(temp_file):
-
-    from osxmetadata import OSXMetaData, Tag
-
-    # update tags
-    meta = OSXMetaData(temp_file)
-    tagset = [Tag("Test"), Tag("Green")]
-    meta.tags = tagset
-    assert meta.tags == tagset
-
-    assert Tag("Test") in meta.tags
-    assert Tag("Foo") not in meta.tags
-
-    meta.tags.remove(Tag("Test"))
-    assert Tag("Test") not in meta.tags
-
-
-def test_tags_3(temp_file):
-
-    from osxmetadata import OSXMetaData, Tag
-    from osxmetadata.constants import kMDItemUserTags
-
-    attribute = kMDItemUserTags
-
-    # update tags
-    meta = OSXMetaData(temp_file)
-    tagset = [Tag("Test"), Tag("Green")]
-
-    meta.set_attribute(attribute, tagset)
-    tags = meta.get_attribute(attribute)
-    assert tags == tagset
-
-    meta.update_attribute(attribute, [Tag("Foo")])
-    tags = meta.get_attribute(attribute)
-    assert tags == [Tag("Test"), Tag("Green"), Tag("Foo")]
-
-    meta.remove_attribute(attribute, Tag("Foo"))
-    tags = meta.get_attribute(attribute)
-    assert tags == [Tag("Test"), Tag("Green")]
-
-    meta.update_attribute(attribute, [Tag("Foo")])
-    tags = meta.get_attribute(attribute)
-    assert tags == [Tag("Test"), Tag("Green"), Tag("Foo")]
-
-    meta.clear_attribute(attribute)
-    tags = meta.get_attribute(attribute)
-    assert len(tags) == 0
-
-
-def test_tags_exception(temp_file):
-
-    from osxmetadata import OSXMetaData, Tag
-
-    # update tags
-    meta = OSXMetaData(temp_file)
-    with pytest.raises(TypeError):
-        meta.tags = ["Foo"]
-
-    with pytest.raises(TypeError):
-        meta.tags = "Foo"
-
-
-def test_tags_clear(temp_file):
-
-    from osxmetadata import OSXMetaData, Tag
-    from osxmetadata.constants import kMDItemUserTags
-
-    attribute = kMDItemUserTags
-
-    # update tags
-    meta = OSXMetaData(temp_file)
-    tagset = [Tag("Test"), Tag("Green")]
-
-    meta.set_attribute(attribute, tagset)
-    tags = meta.get_attribute(attribute)
-    assert tags == tagset
-
-    meta.clear_attribute(attribute)
-    tags = meta.get_attribute(attribute)
-    assert tags == []
-
-
-def test_tags_assign(temp_file):
-    """ test assigning one tag object to another """
-    from osxmetadata import OSXMetaData, Tag
-
-    # update tags
-    meta = OSXMetaData(temp_file)
-    tagset = [Tag("Test"), Tag("Green")]
-    meta.tags = tagset
-    assert meta.tags == tagset
-
-    # create second temp file
-    TESTDIR = None
-    tempfile2 = NamedTemporaryFile(dir=TESTDIR)
-    temp_file_2 = tempfile2.name
-
-    meta2 = OSXMetaData(temp_file_2)
-    tagset2 = [Tag("test2"), Tag("Blue")]
-    meta2.tags = tagset2
-    assert meta2.tags == tagset2
-
-    meta.tags = meta2.tags
-    assert meta.tags == tagset2
-
-    # close second tempfile, first gets closed automatically
-    tempfile2.close()
-
-
-def test_tags_4(temp_file):
-    """ Test various list methods """
-    from osxmetadata import OSXMetaData, Tag
-    from osxmetadata.constants import _kMDItemUserTags
-
-    attribute = _kMDItemUserTags
-
-    # update tags
-    meta = OSXMetaData(temp_file)
-    tagset = [Tag("Test"), Tag("Green"), Tag("Foo")]
-
-    meta.tags = tagset
-    assert meta.tags == tagset
-    assert meta.get_attribute(attribute) == tagset
-
-    idx = meta.tags.index(Tag("Green"))
-    assert idx == 1
-
-    count = meta.tags.count(Tag("Test"))
-    assert count == 1
-
-    count = meta.tags.count(Tag("Bar"))
-    assert count == 0
-
-    meta.tags.sort()
-    assert meta.tags == [Tag("Foo"), Tag("Green"), Tag("Test")]
-    assert meta.get_attribute(attribute) == [Tag("Foo"), Tag("Green"), Tag("Test")]
-
-    meta.tags.sort(reverse=True)
-    assert meta.tags == [Tag("Test"), Tag("Green"), Tag("Foo")]
-    assert meta.get_attribute(attribute) == [Tag("Test"), Tag("Green"), Tag("Foo")]
-
-    # sort by key
-    meta.tags.sort(key=lambda tag: len(tag))
-    assert meta.tags == [Tag("Foo"), Tag("Test"), Tag("Green")]
-    assert meta.get_attribute(attribute) == [Tag("Foo"), Tag("Test"), Tag("Green")]
-
-    # todo: reverse not working
-    # meta.tags.reverse()
-    # assert meta.tags == [Tag("Green"), Tag("Test"), Tag("Foo")]
-    # assert meta.get_attribute(attribute) == [Tag("Green"), Tag("Test"), Tag("Foo")]
-
-    # tag_expected = Tag("Test")
-    # tag_got = meta.tags.pop(1)
-    # assert tag_got == tag_expected
-    # assert meta.tags == [Tag("Green"), Tag("Foo")]
-    # assert meta.get_attribute(attribute) == [Tag("Green"), Tag("Foo")]
-
-    tag_expected = Tag("Test")
-    tag_got = meta.tags.pop(1)
-    assert tag_got == tag_expected
-    assert meta.tags == [Tag("Foo"), Tag("Green")]
-    assert meta.get_attribute(attribute) == [Tag("Foo"), Tag("Green")]
-
-
-def test_tag_factory():
-    from osxmetadata import Tag, FINDER_COLOR_BLUE, FINDER_COLOR_GREEN
-    from osxmetadata.findertags import tag_factory
-
-    tag_ = tag_factory("Foo")
-    assert isinstance(tag_, Tag)
-    assert tag_ == Tag("Foo")
-
-    tag_ = tag_factory("Foo,Blue")
-    assert isinstance(tag_, Tag)
-    assert tag_ == Tag("Foo", FINDER_COLOR_BLUE)
-
-    tag_ = tag_factory("Foo,blue")
-    assert isinstance(tag_, Tag)
-    assert tag_ == Tag("Foo", FINDER_COLOR_BLUE)
-
-    tag_ = tag_factory(f"Foo,{FINDER_COLOR_BLUE}")
-    assert isinstance(tag_, Tag)
-    assert tag_ == Tag("Foo", FINDER_COLOR_BLUE)
-
-    tag_ = tag_factory("green")
-    assert isinstance(tag_, Tag)
-    assert tag_ == Tag("Green", FINDER_COLOR_GREEN)
-
-
-def test_tag_on_directory():
-    import tempfile
-    from osxmetadata import OSXMetaData, Tag
-
-    tempdir = tempfile.TemporaryDirectory(prefix="osxmetadata")
-
-    md = OSXMetaData(tempdir.name)
-    md.tags = [Tag("Red")]
-    assert md.get_attribute("tags") == [Tag("Red")]
-
-    md.tags.remove(Tag("Red"))
-    assert md.get_attribute("tags") == []
diff --git a/tests/test_utils.py b/tests/test_utils.py
deleted file mode 100644
index efa1b03..0000000
--- a/tests/test_utils.py
+++ /dev/null
@@ -1,66 +0,0 @@
-from tempfile import NamedTemporaryFile, TemporaryDirectory
-
-import pytest
-
-
-@pytest.fixture(params=["file", "dir"])
-def temp_file(request):
-
-    # TESTDIR for temporary files usually defaults to "/tmp",
-    # which may not have XATTR support (e.g. tmpfs);
-    # manual override here.
-    TESTDIR = None
-    if request.param == "file":
-        tempfile = NamedTemporaryFile(dir=TESTDIR)
-        tempfilename = tempfile.name
-        yield tempfilename
-        tempfile.close()
-    else:
-        tempdir = TemporaryDirectory(dir=TESTDIR)
-        tempdirname = tempdir.name
-        yield tempdirname
-        tempdir.cleanup()
-
-
-def test_debug_enable():
-    import osxmetadata
-    import logging
-
-    osxmetadata._set_debug(True)
-    logger = osxmetadata.debug._get_logger()
-    assert logger.isEnabledFor(logging.DEBUG)
-
-
-def test_debug_disable():
-    import osxmetadata
-    import logging
-
-    osxmetadata._set_debug(False)
-    logger = osxmetadata.debug._get_logger()
-    assert not logger.isEnabledFor(logging.DEBUG)
-
-
-def test_backup_files(temp_file):
-    import pathlib
-    import osxmetadata
-    from osxmetadata.backup import load_backup_file, write_backup_file
-
-    meta = osxmetadata.OSXMetaData(temp_file)
-    meta.comment = "Hello World!"
-    meta.keywords = ["foo", "bar"]
-
-    filename = pathlib.Path(temp_file).name
-
-    backup_data = {filename: meta.asdict()}
-    
-    TESTDIR = None
-    backup_file = NamedTemporaryFile(dir=TESTDIR)
-
-    write_backup_file(backup_file.name, backup_data)
-    backup_data2 = load_backup_file(backup_file.name)
-
-    assert backup_data2[filename]["com.apple.metadata:kMDItemKeywords"] == [
-        "foo",
-        "bar",
-    ]
-    assert backup_data2[filename]["com.apple.metadata:kMDItemComment"] == "Hello World!"
diff --git a/tests/test_video.mov b/tests/test_video.mov
new file mode 100644
index 0000000..23e8682
Binary files /dev/null and b/tests/test_video.mov differ
diff --git a/tests/test_wherefroms.py b/tests/test_wherefroms.py
deleted file mode 100644
index 1b9fff5..0000000
--- a/tests/test_wherefroms.py
+++ /dev/null
@@ -1,36 +0,0 @@
-#!/usr/bin/env python
-
-from tempfile import NamedTemporaryFile, TemporaryDirectory
-
-import pytest
-import platform
-
-
-@pytest.fixture(params=["file", "dir"])
-def temp_file(request):
-
-    # TESTDIR for temporary files usually defaults to "/tmp",
-    # which may not have XATTR support (e.g. tmpfs);
-    # manual override here.
-    TESTDIR = None
-    if request.param == "file":
-        tempfile = NamedTemporaryFile(dir=TESTDIR)
-        tempfilename = tempfile.name
-        yield tempfilename
-        tempfile.close()
-    else:
-        tempdir = TemporaryDirectory(dir=TESTDIR)
-        tempdirname = tempdir.name
-        yield tempdirname
-        tempdir.cleanup()
-
-
-def test_download_where_from(temp_file):
-    from osxmetadata import OSXMetaData
-
-    meta = OSXMetaData(temp_file)
-    expected = ["http://google.com", "https://apple.com"]
-    meta.wherefroms = expected
-    wf = meta.wherefroms
-    assert sorted(wf) == sorted(expected)
-    assert sorted(meta.get_attribute("wherefroms")) == sorted(expected)
diff --git a/tests/test_xattr.py b/tests/test_xattr.py
new file mode 100644
index 0000000..7a266ae
--- /dev/null
+++ b/tests/test_xattr.py
@@ -0,0 +1,28 @@
+"""Test osxmetadata get/set/remove xattr methods"""
+
+import plistlib
+
+import pytest
+
+from osxmetadata import OSXMetaData
+
+
+def test_xattr_get_set_remove(test_file):
+    """test get/set/remove xattr methods"""
+
+    attribute = "com.apple.metadata:kMDItemComment"
+    value = "This is my comment"
+
+    md = OSXMetaData(test_file.name)
+    md.comment = value
+    xattr_comment = md.get_xattr(attribute, decode=plistlib.loads)
+    assert xattr_comment == value
+
+    value = "This is my new comment"
+    md.set_xattr(attribute, value, encode=plistlib.dumps)
+    xattr_comment = md.get_xattr(attribute, decode=plistlib.loads)
+    assert xattr_comment == value
+
+    md.remove_xattr(attribute)
+    with pytest.raises(KeyError):
+        md.get_xattr(attribute, decode=plistlib.loads)