From 23ac288888312375a882221031076203b8e48180 Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Sat, 15 Dec 2018 09:30:22 -0800 Subject: [PATCH] Moving code into module and added unittests --- AUTHOR.md | 9 + COPYING.md | 29 +++ LICENSE.md | 21 ++ README.md | 66 ++++++ osxmetadata.py | 425 +++++++++++++++++++++++++++++++++++++++ setup.py | 54 +++++ tests/__init__.py | 21 ++ tests/test_osxmetdata.py | 52 +++++ 8 files changed, 677 insertions(+) create mode 100644 AUTHOR.md create mode 100644 COPYING.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100755 osxmetadata.py create mode 100755 setup.py create mode 100644 tests/__init__.py create mode 100755 tests/test_osxmetdata.py diff --git a/AUTHOR.md b/AUTHOR.md new file mode 100644 index 0000000..93223d1 --- /dev/null +++ b/AUTHOR.md @@ -0,0 +1,9 @@ +# osxmetadata Authors + +## Rhet Turnbull + + * Personal website: https://github.com/RhetTbull + * EMail: rturnbull+git [:AT:] gmail [:DOT:] com + * Location: California, USA + * Twitter: @RhetTurnbull + * Github: RhetTbull diff --git a/COPYING.md b/COPYING.md new file mode 100644 index 0000000..0bd34e9 --- /dev/null +++ b/COPYING.md @@ -0,0 +1,29 @@ +# Copyright + +Copyright © 2018, Rhet Turnbull <[rturnbull+git@gmail.com](https://github.com/RhetTbull)>
+All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation files +(the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of the Software, +and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..dac30ea --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +### License ### + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation files +(the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of the Software, +and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bd02279 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +osxmetadata [![Build Status]()](https://github.com/RhetTbull/osxmetadata) +======== + +What is osxmetadata? +----------------- + +zzz OSDetect is a small python module which is able to get some information +about your system and python implementation, like the Operating System +or the hardware platform. + +Supported operating systems +--------------------------- + +As of now, only GNU/Linux, Mac OS X, Windows NT and Windows NT/Cygwin are supported. At the +moment, I'm working on support for a wider range of operating systems. + +Since version 1.1.0, Python 2 and Python 3 are both supported. + +Note that the information available on the different platforms may differ. + +Installation instructions +------------------------- + +Since OSDetect uses setuptools, you simply need to run + + python setup.py install + +Command Line Usage +------------------ + +OSDetect includes a function which is executed if the module is directly called. So give it +a try and run: + + python -m OSDetect + +Example uses of the module +-------------------------- + +```python +# Get a dict containing all gathered information +from OSDetect import info as os_info +print(os_info.getInfo()) + +# Get a specific value +print(os_info.getDistribution()) +# or using the dict key (a dot means a dict containing a dict) +print(os_info.get("Python.Version")) +``` + +On a ArchLinux system, it looks like this: + +```python +{ + 'Python': { + 'Version': '3.6.0', + 'Implementation': 'CPython' + }, + 'Machine': 'i686', + 'OS': 'Linux', + 'OSVersion': '4.10.6-1-ARCH', + 'Distribution': 'Arch Linux' +} + +'ArchLinux' +'3.6.0' +``` diff --git a/osxmetadata.py b/osxmetadata.py new file mode 100755 index 0000000..8df46d9 --- /dev/null +++ b/osxmetadata.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python + +from plistlib import loads, dumps, FMT_BINARY +from pathlib import Path +import pprint +from xattr import xattr +import os.path +import sys +import subprocess +import datetime + +#this was inspired by osx-tags by "Ben S / scooby" and is published under +#the same MIT license. See: https://github.com/scooby/osx-tags + +#TODO: What to do about colors +#TODO: Add ability to remove key instead of just clear contents + +# color labels +_COLORNAMES = { + 'None' : 0, + 'Gray' : 1, + 'Green': 2, + 'Purple': 3, + 'Blue': 4, + 'Yellow': 5, + 'Red': 6, + 'Orange' : 7} + +_COLORIDS = { + 0 : 'None', + 1 : 'Gray', + 2 : 'Green', + 3 : 'Purple', + 4 : 'Blue', + 5 : 'Yellow', + 6 : 'Red', + 7 : 'Orange'} + +_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 + +_TAGS = 'com.apple.metadata:_kMDItemUserTags' +_FINDER_COMMENT = 'com.apple.metadata:kMDItemFinderComment' +_WHERE_FROM = 'com.apple.metadata:kMDItemWhereFroms' +_DOWNLOAD_DATE = 'com.apple.metadata:kMDItemDownloadedDate' + +class _NullsInString(Exception): + """Nulls in string.""" +#class _NullsInString + +def _onError(e): + sys.stderr.write(str(e) + "\n") +#_onError + +class _Tags: + + def __init__(self, xa: xattr): + self.__attrs = xa + self.__load_tags() + + def __tag_split(self, tag): + """ + Extracts the color information from a Finder tag. + """ + parts = tag.rsplit('\n', 1) + if len(parts) == 1: + return parts[0], 0 + elif len(parts[1]) != 1 or parts[1] not in _VALID_COLORIDS: # Not a color number + return tag, 0 + else: + return parts[0], int(parts[1]) + + def __load_tags(self): + self.__tags = {} + try: + self.__tagvalues = self.__attrs[_TAGS] + #load the binary plist value + self.__tagvalues = loads(self.__tagvalues) + for x in self.__tagvalues: + (tag, color) = self.__tag_split(x) + self.__tags[tag] = color + #self.__tags = [self.__tag_strip_color(x) for x in self.__tagvalues] + except KeyError: + self.__tags = None + if self.__tags: + self.__tag_set = set(self.__tags.keys()) + else: + self.__tag_set = set([]) + + def __iter__(self): + self.__load_tags() + self.__tag_list = list(self.__tag_set) + self.__tag_count = len(self.__tag_list) + self.__tag_counter = 0 + return self + + def __next__(self): + if self.__tag_counter < self.__tag_count: + tag = self.__tag_list[self.__tag_counter] + self.__tag_counter += 1 + return tag + else: + raise StopIteration + + def __len__(self): + self.__load_tags() + return len(self.__tag_set) + + def __repr__(self): + self.__load_tags() + return self.__tag_set + + def __str__(self): + self.__load_tags() + return ', '.join(self.__tag_set) + + def add(self, tag): + if not isinstance(tag, str): + raise TypeError("Tags must be strings") + self.__load_tags() + tags = set(map(self.__tag_normalize,self.__tag_set)) + tags.add(self.__tag_normalize(tag)) + self.__write_tags(*tags) + + def update(self, *tags): + if not all(isinstance(tag, str) for tag in tags): + raise TypeError("Tags must be strings") + self.__load_tags() + old_tags = set(map(self.__tag_normalize,self.__tag_set)) + new_tags = old_tags.union(set(map(self.__tag_normalize, tags))) + self.__write_tags(*new_tags) + + def clear(self): + try: + self.__attrs.remove(_TAGS) + except (IOError, OSError): + pass + + def remove(self, tag): + self.__load_tags() + if not isinstance(tag, str): + raise TypeError("Tags must be strings") + tags = set(map(self.__tag_normalize,self.__tag_set)) + tags.remove(self.__tag_normalize(tag)) + self.__write_tags(*tags) + + def discard(self, tag): + self.__load_tags() + if not isinstance(tag, str): + raise TypeError("Tags must be strings") + tags = set(map(self.__tag_normalize,self.__tag_set)) + tags.discard(self.__tag_normalize(tag)) + self.__write_tags(*tags) + + def __iadd__(self, tag): + self.add(tag) + return self + + def __write_tags(self, *tags): + """ + Overwrites the existing tags with the iterable of tags provided. + """ + if not all(isinstance(tag, str) for tag in tags): + raise TypeError("Tags must be strings") + tag_plist = dumps(list(map(self.__tag_normalize, tags)),fmt=FMT_BINARY) + self.__attrs.set(_TAGS, tag_plist) + + def __tag_colored(self, tag, color): + """ + Sets the color of a tag. + + Parameters: + tag(str): a tag name + color(int): an integer from 1 through 7 + + Return: + (str) the tag with encoded color. + """ + return '{}\n{}'.format(self.__tag_nocolor(tag), color) + + def __tag_normalize(self, tag): + """ + Ensures a color is set if not none. + :param tag: a possibly non-normal tag. + :return: A colorized tag. + """ + tag, color = self.__tag_split(tag) + if tag.title() in _COLORNAMES: + #ignore the color passed and set proper color name + return self.__tag_colored(tag.title(),_COLORNAMES[tag.title()]) + else: + return self.__tag_colored(tag, color) + + def __tag_nocolor(self, tag): + """ + Removes the color information from a Finder tag. + """ + return tag.rsplit('\n', 1)[0] + +class OSXMetaData: + + def __init__(self, fname): + + self.__fname = Path(fname) + try: + os.path.exists(self.__fname) + except ValueError: + print('file does not exist %s' % (self.__fname),file=sys.stderr) + + try: + self.__attrs = xattr(self.__fname) + except (IOError, OSError) as e: + quit(_onError(e)) + + self.__tags = {} + self.__findercomment = None + self.__wherefrom = [] + self.__downloaddate = None + + self.tags = _Tags(self.__attrs) + + #TODO: Lot's of repetitive code here + #need to read these dynamically + """ Get Finder comment """ + self.__load_findercomment() + + """ Get Where From (for downloaded files) """ + self.__load_download_wherefrom() + + """ Get Download Date (for downloaded files) """ + self.__load_download_date() + + # @property + # def colors(self): + # """ return list of color labels from tags + # do not return None (e.g. ignore tags with no color) + # """ + # colors = [] + # if self.__tags: + # for t in self.__tags.keys(): + # c = self.__tags[t] + # if c == 0: continue + # colors.append(_COLORIDS[c]) + # return colors + # else: + # return None + + def __load_findercomment(self): + try: + self.__fcvalue = self.__attrs[_FINDER_COMMENT] + #load the binary plist value + self.__findercomment = loads(self.__fcvalue) + except KeyError: + self.__findercomment = None + + @property + def finder_comment(self): + self.__load_findercomment() + return self.__findercomment + + @finder_comment.setter + def finder_comment(self, fc): + if fc is None: + fc = "" + elif not isinstance(fc, str): + raise TypeError("Finder comment must be strings") + + if len(fc) > _MAX_FINDERCOMMENT: + raise ValueError("Finder comment limited to %d characters" % _MAX_FINDERCOMMENT) + + script = '\'tell application "Finder" to set comment of ' \ + '(POSIX file "%s" as alias) to "%s"\'' \ + % (self.__fname.resolve().as_posix(), fc) + + setcmd = "%s %s %s" % ('osascript', '-e', script) + try: + subprocess.run(setcmd, check=True, shell=True, + stdout=subprocess.PIPE) + except subprocess.CalledProcessError as e: + sys.exit("subprocess error calling command %s %s: " % (setcmd, e)) + ###self.__attrs[_FINDER_COMMENT] = dumps(str(fc),fmt=FMT_BINARY) + self.__load_findercomment() + + def __load_download_wherefrom(self): + try: + self.__wfvalue = self.__attrs[_WHERE_FROM] + #load the binary plist value + self.__wherefrom = loads(self.__wfvalue) + except KeyError: + self.__wherefrom = None + + @property + def where_from(self): + self.__load_download_wherefrom() + return self.__wherefrom + + @where_from.setter + def where_from(self, wf): + if wf is None: + wf = [] + elif not isinstance(wf, list): + raise TypeError("Where from must be a list of one or more URL strings") + + for w in wf: + if len(w) > _MAX_WHEREFROM: + raise ValueError("Where from URL limited to %d characters" % _MAX_WHEREFROM) + print("wf = %s" % w) + + wf_plist = dumps(wf,fmt=FMT_BINARY) + self.__attrs.set(_WHERE_FROM, wf_plist) + self.__load_download_wherefrom() + + def __load_download_date(self): + try: + self.__ddvalue = self.__attrs[_DOWNLOAD_DATE] + #load the binary plist value + #returns an array with a single datetime.datetime object + self.__downloaddate = loads(self.__ddvalue)[0] + except KeyError: + self.__downloaddate = None + + @property + def download_date(self): + self.__load_download_date() + return self.__downloaddate + + @download_date.setter + def download_date(self, dt): + if dt is None: + dt = [] + elif not isinstance(dt, datetime.datetime): + raise TypeError("Download date must be a datetime object") + + dt_plist = dumps([dt],fmt=FMT_BINARY) + self.__attrs.set(_DOWNLOAD_DATE, dt_plist) + self.__load_download_date() + + @property + def name(self): + return self.__fname.resolve().as_posix() + +""" +args = sys.argv +if len(args) != 2: + quit() + +fname = args[1] + +meta = OSXMetaData(fname) +print(meta.name) +print(meta.finder_comment) +print(meta.tags) +print(meta.where_from) +print(str(meta.download_date)) + +fc = "This is my new comment" +meta.finder_comment = fc +meta.finder_comment += ", foo" + +print(meta.finder_comment) + +for t in meta.tags: + print("tag = %s" % t) + +print(', '.join(meta.tags)) + +meta.tags.add("Foo") +meta.tags += "PURPLE" +print("There are %d tags" % len(meta.tags)) +for t in meta.tags: + print("tag = %s" % t) + +meta.tags.clear() +print("There are %d tags" % len(meta.tags)) +for t in meta.tags: + print("tag = %s" % t) + +meta.tags.add("Green") +meta.tags.add("Foo") +print("There are %d tags" % len(meta.tags)) +for t in meta.tags: + print("tag = %s" % t) + +if "Foo" in meta.tags: + print("Have tag Foo") + meta.tags.remove("Foo") +if "Foo2" in meta.tags: + print("Have tag Foo2") + meta.tags.remove("Foo2") + +meta.tags.discard("Foo2") +meta.tags.discard("Green") +meta.tags.add("Red") +meta.tags.add("Test") +meta.tags.update("Gray","Foo") + +print("There are %d tags" % len(meta.tags)) +for t in meta.tags: + print("tag = %s" % t) + +wf = meta.where_from +print("where from:") +if wf is not None: + for w in wf: + print(w) + +meta.where_from = ['http://google.com','http://adobe.com'] + +wf = meta.where_from +print("where from:") +if wf is not None: + for w in wf: + print(w) + +dt = meta.download_date +print("date1 = " + str(dt)) +meta.download_date = datetime.datetime.now() +dt = meta.download_date +print("date2 = " + str(dt)) +meta.download_date = None +dt = meta.download_date +print("date3 = " + str(dt)) + +""" \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..3c63e0d --- /dev/null +++ b/setup.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# setup.py script for osxmetdata +# +# Copyright (c) 2018 Rhet Turnbull, rturnbull+git@gmail.com +# All rights reserved. +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +#from distutils.core import setup +from setuptools import setup + +setup( + name = 'osxmetadata', + version = '0.9', + description = 'Read and write meta data, such as tags/keywords, on Mac OS X files', + author = 'Rhet Turnbull', + author_email = 'rturnbull+git@gmail.com', + url = 'https://github.com/RhetTbull/', + project_urls = { + 'GitHub': 'https://github.com/RhetTbull/osxmetadata' + }, + download_url = 'https://github.com/RhetTbull/osxmetadata', + py_modules = ['osxmetadata'], + 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', + 'Topic :: Software Development :: Libraries :: Python Modules', + ] + ) \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e1b99fa --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,21 @@ +import os +import sys +import unittest + + +def all_tests_suite(): + suite = unittest.TestLoader().loadTestsFromNames([ + 'osxmetadata.tests.test_osxmetdata', + ]) + return suite + + +def main(): + runner = unittest.TextTestRunner() + suite = all_tests_suite() + runner.run(suite) + + +if __name__ == '__main__': + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + main() diff --git a/tests/test_osxmetdata.py b/tests/test_osxmetdata.py new file mode 100755 index 0000000..a1f2757 --- /dev/null +++ b/tests/test_osxmetdata.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python + +import unittest +from tempfile import NamedTemporaryFile + +from osxmetadata import OSXMetaData + +class TestOSXMetaData(unittest.TestCase): + # TESTDIR for temporary files usually defaults to "/tmp", + # which may not have XATTR support (e.g. tmpfs); + # manual override here. + TESTDIR = None + + def test_tags(self): + # # Not using setlocale(LC_ALL, ..) to set locale because + # # sys.getfilesystemencoding() implementation falls back + # # to user's preferred locale by calling setlocale(LC_ALL, ''). + # xattr.compat.fs_encoding = 'UTF-8' + + #can we update tags? + meta = OSXMetaData(self.tempfilename) + tagset = ['Test','Green'] + meta.tags.update(*tagset) + self.assertEqual(set(meta.tags),set(tagset)) + + #add tags + meta.tags.add('Foo') + self.assertNotEqual(set(meta.tags),set(tagset)) + self.assertEqual(set(meta.tags),set(['Test','Green','Foo'])) + + #__iadd__ + meta.tags += 'Bar' + self.assertEqual(set(meta.tags),set(['Test','Green','Foo','Bar'])) + + #remove tags + meta.tags.remove('Test') + self.assertEqual(set(meta.tags),set(['Green','Foo','Bar'])) + + #clear tags + meta.tags.clear() + self.assertEqual(set(meta.tags),set([])) + + def setUp(self): + self.tempfile = NamedTemporaryFile(dir=self.TESTDIR) + self.tempfilename = self.tempfile.name + + def tearDown(self): + self.tempfile.close() + +if __name__ == '__main__': + unittest.main() +