diff --git a/.gitignore b/.gitignore index 2dcd055..16be19f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ **/config.ini **/node_modules/** dist/** +docs/_build build/** diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..811cf44 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,192 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " applehelp to make an Apple Help Book" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo " coverage to run coverage check of the documentation (if enabled)" + +clean: + rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Elodie.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Elodie.qhc" + +applehelp: + $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp + @echo + @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." + @echo "N.B. You won't be able to view it unless you put it in" \ + "~/Library/Documentation/Help or install it in your application" \ + "bundle." + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/Elodie" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Elodie" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +coverage: + $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage + @echo "Testing of coverage in the sources finished, look at the " \ + "results in $(BUILDDIR)/coverage/python.txt." + +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..5159e7f --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,291 @@ +# -*- coding: utf-8 -*- +# +# Elodie documentation build configuration file, created by +# sphinx-quickstart on Fri Jan 8 14:42:49 2016. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +import os +import shlex +import sys + +import mock + +# Add the parent folder to the Python path so Sphinx can import elodie modules. +sys.path.insert(0, os.path.abspath('..')) + +# Mock out the pyexiv2 module so we don't have to install it when we build +# docs on ReadTheDocs. +sys.modules['pyexiv2'] = mock.Mock() + + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +#needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'sphinx.ext.autodoc', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +# You can specify multiple suffix as a list of string: +# source_suffix = ['.rst', '.md'] +source_suffix = '.rst' + +# The encoding of source files. +#source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = u'Elodie' +copyright = u'2016, Jaisen Mathai' +author = u'Jaisen Mathai' + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = u'0.1.0' +# The full version, including alpha/beta/rc tags. +release = u'0.1.0' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# +# This is also used if you do content translation via gettext catalogs. +# Usually you set "language" from the command line for these cases. +language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +#today = '' +# Else, today_fmt is used as the format for a strftime call. +#today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ['_build'] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +#default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +#add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +#add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +#show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'sphinx' + +# A list of ignored prefixes for module index sorting. +#modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +#keep_warnings = False + +# If true, `todo` and `todoList` produce output, else they produce nothing. +todo_include_todos = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# html_theme = 'alabaster' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +#html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Language to be used for generating the HTML full-text search index. +# Sphinx supports the following languages: +# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' +# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' +#html_search_language = 'en' + +# A dictionary with options for the search language support, empty by default. +# Now only 'ja' uses this config value +#html_search_options = {'type': 'default'} + +# The name of a javascript file (relative to the configuration directory) that +# implements a search results scorer. If empty, the default will be used. +#html_search_scorer = 'scorer.js' + +# Output file base name for HTML help builder. +htmlhelp_basename = 'Elodiedoc' + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', + +# Latex figure (float) alignment +#'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'Elodie.tex', u'Elodie Documentation', + u'Jaisen Mathai', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +#latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +#latex_use_parts = False + +# If true, show page references after internal links. +#latex_show_pagerefs = False + +# If true, show URL addresses after external links. +#latex_show_urls = False + +# Documents to append as an appendix to all manuals. +#latex_appendices = [] + +# If false, no module index is generated. +#latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'elodie', u'Elodie Documentation', + [author], 1) +] + +# If true, show URL addresses after external links. +#man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'Elodie', u'Elodie Documentation', + author, 'Elodie', 'One line description of project.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +#texinfo_appendices = [] + +# If false, no module index is generated. +#texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +#texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +#texinfo_no_detailmenu = False diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..f38ef7d --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,103 @@ +.. toctree:: + :hidden: + + self + +Hello, I'm Elodie +================= + +*~~ Your Personal EXIF-based Photo, Video and Audio Assistant ~~* + +.. image:: ../creative/logo@300x.png + :align: center + +I work tirelessly to make sure your photos are always sorted and organized so +you can focus on more important things. By photos I mean JPEG, DNG, NEF and +common video and audio files. + +You don't love me yet but you will. + +I only do 3 things. + +- Firstly I organize your existing collection of photos. +- Second I help make it easy for all the photos you haven't taken yet to flow + into the exact location they belong. +- Third but not least I promise to do all this without a yucky proprietary + database that some friends of mine use. + +You can find out more information about me on `GitHub`_. + +.. _GitHub: https://github.com/jmathai/elodie + +API Documentation +================= + +This documentation is generated from the Python code. + +.. contents:: Modules + :local: + +elodie.media +------------ + +.. automodule:: elodie.media.media + :members: + +.. automodule:: elodie.media.audio + :members: + +.. automodule:: elodie.media.photo + :members: + +.. automodule:: elodie.media.video + :members: + +elodie.arguments +---------------- + +.. automodule:: elodie.arguments + :members: + +elodie.constants +---------------- + +.. automodule:: elodie.constants + :members: + +elodie.dependencies +------------------- + +.. automodule:: elodie.dependencies + :members: + +elodie.filesystem +----------------- + +.. automodule:: elodie.filesystem + :members: + +elodie.geolocation +------------------ + +.. automodule:: elodie.geolocation + :members: + +elodie.localstorage +------------------- + +.. automodule:: elodie.localstorage + :members: + +elodie.plist_parser +------------------- + +.. automodule:: elodie.plist_parser + :members: + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..4e910d9 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,5 @@ +LatLon +docopt +requests +mock +sphinx diff --git a/elodie/arguments.py b/elodie/arguments.py index 23afc73..fc43f95 100644 --- a/elodie/arguments.py +++ b/elodie/arguments.py @@ -1,11 +1,22 @@ """ +Command line argument parsing for helper scripts. """ + import getopt import sys from re import sub def parse(argv, options, long_options, usage): + """Parse command line arguments. + + :param list(str) argv: Arguments passed to the program. + :param str options: String of characters for allowed short options. + :param list(str) long_options: List of strings of allowed long options. + :param str usage: Help text, to print in the case of an error or when + the user asks for it. + :returns: dict + """ try: opts, args = getopt.getopt(argv, options, long_options) except getopt.GetoptError: diff --git a/elodie/constants.py b/elodie/constants.py index 9622635..c3c0f61 100644 --- a/elodie/constants.py +++ b/elodie/constants.py @@ -1,8 +1,23 @@ +""" +Settings used by Elodie. +""" + from os import path +#: If True, debug messages will be printed. debug = True + +#: Directory in which to store Elodie settings. application_directory = '{}/.elodie'.format(path.expanduser('~')) + +#: File in which to store details about media Elodie has seen. hash_db = '{}/hash.json'.format(application_directory) + +#: File in which to store geolocation details about media Elodie has seen. location_db = '{}/location.json'.format(application_directory) + +#: Elodie installation directory. script_directory = path.dirname(path.dirname(path.abspath(__file__))) + +#: Path to Elodie's ExifTool config file. exiftool_config = '%s/configs/ExifTool_config' % script_directory diff --git a/elodie/dependencies.py b/elodie/dependencies.py index 010a24f..e8fc7a2 100644 --- a/elodie/dependencies.py +++ b/elodie/dependencies.py @@ -1,10 +1,14 @@ -"""Helpers for checking external dependencies.""" +""" +Helpers for checking for an interacting with external dependencies. These are +things that Elodie requires, but aren't installed automatically for the user. +""" import os import sys from distutils.spawn import find_executable +#: Error to print when exiftool can't be found. EXIFTOOL_ERROR = u""" It looks like you don't have exiftool installed, which Elodie requires. Please take a look at the installation steps in the readme: @@ -12,6 +16,7 @@ Please take a look at the installation steps in the readme: https://github.com/jmathai/elodie#install-everything-you-need """.lstrip() +#: Template for the error to print when pyexiv2 can't be found. PYEXIV2_ERROR = u""" {error_class_name}: {error} @@ -27,7 +32,7 @@ def get_exiftool(): We wrap this since we call it in a few places and we do a fallback. - @returns, None or string + :returns: str or None """ path = find_executable('exiftool') # If exiftool wasn't found we try to brute force the homebrew location @@ -44,7 +49,7 @@ def verify_dependencies(): Prints a message to stderr and returns False if any dependencies are missing. - @returns, bool + :returns: bool """ exiftool = get_exiftool() if exiftool is None: diff --git a/elodie/filesystem.py b/elodie/filesystem.py index 435d7ff..be0f58e 100644 --- a/elodie/filesystem.py +++ b/elodie/filesystem.py @@ -1,7 +1,9 @@ """ -Author: Jaisen Mathai -General file system methods +General file system methods. + +.. moduleauthor:: Jaisen Mathai """ + import os import re import shutil @@ -12,14 +14,17 @@ from elodie import constants from elodie.localstorage import Db -class FileSystem: - """ - Create a directory if it does not already exist.. +class FileSystem(object): + + """A class for interacting with the file system.""" - @param, directory_name, string, A fully qualified path of the - directory to create. - """ def create_directory(self, directory_path): + """Create a directory if it does not already exist. + + :param str directory_name: A fully qualified path of the + to create. + :returns: bool + """ try: if os.path.exists(directory_path): return True @@ -32,15 +37,15 @@ class FileSystem: return False - """ - Delete a directory only if it's empty. - Instead of checking first using `len([name for name in - os.listdir(directory_path)]) == 0` we catch the OSError exception. - - @param, directory_name, string, A fully qualified path of the directory - to delete. - """ def delete_directory_if_empty(self, directory_path): + """Delete a directory only if it's empty. + + Instead of checking first using `len([name for name in + os.listdir(directory_path)]) == 0`, we catch the OSError exception. + + :param str directory_name: A fully qualified path of the directory + to delete. + """ try: os.rmdir(directory_path) return True @@ -49,13 +54,12 @@ class FileSystem: return False - """ - Recursively get all files which match a path and extension. - - @param, path, string, Path to start recursive file listing - @param, extensions, tuple, File extensions to include (whitelist) - """ def get_all_files(self, path, extensions=None): + """Recursively get all files which match a path and extension. + + :param str path string: Path to start recursive file listing + :param tuple(str) extensions: File extensions to include (whitelist) + """ files = [] for dirname, dirnames, filenames in os.walk(path): # print path to all filenames. @@ -67,25 +71,25 @@ class FileSystem: files.append('%s/%s' % (dirname, filename)) return files - """ - Get the current working directory - - @returns, string - """ def get_current_directory(self): + """Get the current working directory. + + :returns: str + """ return os.getcwd() - """ - Generate file name for a photo or video using its metadata. - We use an ISO8601-like format for the file name prefix. - Instead of colons as the separator for hours, minutes and seconds we use a - hyphen. - https://en.wikipedia.org/wiki/ISO_8601#General_principles - - @param, media, Photo|Video, A Photo or Video instance - @returns, string or None for non-photo or non-videos - """ def get_file_name(self, media): + """Generate file name for a photo or video using its metadata. + + We use an ISO8601-like format for the file name prefix. Instead of + colons as the separator for hours, minutes and seconds we use a hyphen. + https://en.wikipedia.org/wiki/ISO_8601#General_principles + + :param media: A Photo or Video instance + :type media: :class:`~elodie.media.photo.Photo` or + :class:`~elodie.media.video.Video` + :returns: str or None for non-photo or non-videos + """ if(not media.is_valid()): return None @@ -124,22 +128,20 @@ class FileSystem: metadata['extension']) return file_name.lower() - """ - Get date based folder name. - - @param, time_obj, time, Time object to be used to determine folder name. - @returns, string - """ def get_folder_name_by_date(self, time_obj): + """Get date based folder name. + + :param time time_obj: Time object to be used to determine folder name. + :returns: str + """ return time.strftime('%Y-%m-%b', time_obj) - """ - Get folder path by various parameters. - - @param, time_obj, time, Time object to be used to determine folder name. - @returns, string - """ def get_folder_path(self, metadata): + """Get folder path by various parameters. + + :param time time_obj: Time object to be used to determine folder name. + :returns: str + """ path = [] if(metadata['date_taken'] is not None): path.append(time.strftime('%Y-%m-%b', metadata['date_taken'])) @@ -212,11 +214,13 @@ class FileSystem: return dest_path - """ - Set the modification time on the file based on the file path. - Noop if the path doesn't match the format YYYY-MM/DD-IMG_0001.JPG. - """ def set_date_from_path_video(self, video): + """Set the modification time on the file based on the file path. + + Noop if the path doesn't match the format YYYY-MM/DD-IMG_0001.JPG. + + :param elodie.media.video.Video video: An instance of Video. + """ date_taken = None video_file_path = video.get_file_path() diff --git a/elodie/geolocation.py b/elodie/geolocation.py index 80c9b80..a615761 100644 --- a/elodie/geolocation.py +++ b/elodie/geolocation.py @@ -1,3 +1,5 @@ +"""Look up geolocation information for media objects.""" + from os import path from ConfigParser import ConfigParser import fractions @@ -11,14 +13,18 @@ from elodie.localstorage import Db class Fraction(fractions.Fraction): + """Only create Fractions from floats. + + Should be compatible with Python 2.6, though untested. + >>> Fraction(0.3) Fraction(3, 10) >>> Fraction(1.1) Fraction(11, 10) """ + def __new__(cls, value, ignore=None): - """Should be compatible with Python 2.6, though untested.""" return fractions.Fraction.from_float(value).limit_denominator(99999) diff --git a/elodie/localstorage.py b/elodie/localstorage.py index 8a9342a..9373ef4 100644 --- a/elodie/localstorage.py +++ b/elodie/localstorage.py @@ -1,3 +1,7 @@ +""" +Methods for interacting with information Elodie caches about stored media. +""" + import hashlib import json from math import radians, cos, sqrt @@ -8,6 +12,9 @@ from elodie import constants class Db(object): + + """A class for interacting with the JSON files created by Elodie.""" + def __init__(self): # verify that the application directory (~/.elodie) exists, # else create it @@ -47,26 +54,49 @@ class Db(object): pass def add_hash(self, key, value, write=False): + """Add a hash to the hash db. + + :param str key: + :param str value: + :param bool write: If true, write the hash db to disk. + """ self.hash_db[key] = value if(write is True): self.update_hash_db() def check_hash(self, key): + """Check whether a hash is present for the given key. + + :param str key: + :returns: bool + """ return key in self.hash_db def get_hash(self, key): + """Get the hash value for a given key. + + :param str key: + :returns: str or None + """ if(self.check_hash(key) is True): return self.hash_db[key] return None def update_hash_db(self): + """Write the hash db to disk.""" with open(constants.hash_db, 'w') as f: json.dump(self.hash_db, f) - """ - http://stackoverflow.com/a/3431835/1318758 - """ def checksum(self, file_path, blocksize=65536): + """Create a hash value for the given file. + + See http://stackoverflow.com/a/3431835/1318758. + + :param str file_path: Path to the file to create a hash for. + :param int blocksize: Read blocks of this size from the file when + creating the hash. + :returns: str or None + """ hasher = hashlib.sha256() with open(file_path, 'r') as f: buf = f.read(blocksize) @@ -79,14 +109,21 @@ class Db(object): # Location database # Currently quite simple just a list of long/lat pairs with a name - # If it gets many entryes a lookup might takt to long and a better + # If it gets many entries a lookup might take too long and a better # structure might be needed. Some speed up ideas: # - Sort it and inter-half method can be used # - Use integer part of long or lat as key to get a lower search list - # - Cache a smal number of lookups, photos is likey to be taken i clusters - # around a spot during import. + # - Cache a small number of lookups, photos are likely to be taken in + # clusters around a spot during import. def add_location(self, latitude, longitude, place, write=False): + """Add a location to the database. + + :param float latitude: Latitude of the location. + :param float longitude: Longitude of the location. + :param str place: Name for the location. + :param bool write: If true, write the location db to disk. + """ data = {} data['lat'] = latitude data['long'] = longitude @@ -96,10 +133,18 @@ class Db(object): self.update_location_db() def get_location_name(self, latitude, longitude, threshold_m): + """Find a name for a location in the database. + + :param float latitude: Latitude of the location. + :param float longitude: Longitude of the location. + :param int threshold_m: Location in the database must be this close to + the given latitude and longitude. + :returns: str, or None if a matching location couldn't be found. + """ last_d = sys.maxint name = None for data in self.location_db: - # As threshold is quite smal use simple math + # As threshold is quite small use simple math # From http://stackoverflow.com/questions/15736995/how-can-i-quickly-estimate-the-distance-between-two-latitude-longitude-points # noqa # convert decimal degrees to radians @@ -120,6 +165,11 @@ class Db(object): return name def get_location_coordinates(self, name): + """Get the latitude and longitude for a location. + + :param str name: Name of the location. + :returns: tuple(float), or None if the location wasn't in the database. + """ for data in self.location_db: if data['name'] == name: return (data['lat'], data['long']) @@ -127,5 +177,6 @@ class Db(object): return None def update_location_db(self): + """Write the location db to disk.""" with open(constants.location_db, 'w') as f: json.dump(self.location_db, f) diff --git a/elodie/media/audio.py b/elodie/media/audio.py index 92475a6..fdf2517 100644 --- a/elodie/media/audio.py +++ b/elodie/media/audio.py @@ -1,18 +1,25 @@ """ -Author: Jaisen Mathai -Audio package that handles all audio operations -Inherits from Video package +The audio module contains classes specifically for dealing with audio files. +The :class:`Audio` class inherits from the :class:`~elodie.media.video.Video` +class. + +.. moduleauthor:: Jaisen Mathai """ from video import Video class Audio(Video): + + """An audio object. + + :param str source: The fully qualified path to the audio file. + """ + __name__ = 'Audio' + + #: Valid extensions for audio files. extensions = ('m4a',) - """ - @param, source, string, The fully qualified path to the audio file - """ def __init__(self, source=None): super(Audio, self).__init__(source) diff --git a/elodie/media/media.py b/elodie/media/media.py index f309b87..57813f1 100644 --- a/elodie/media/media.py +++ b/elodie/media/media.py @@ -1,6 +1,12 @@ """ -Author: Jaisen Mathai -Media package that's a parent class for media objects +The media module provides a base :class:`Media` class for all objects that +are tracked by Elodie. The Media class provides some base functionality used +by all the media types, but isn't itself used to represent anything. Its +sub-classes (:class:`~elodie.media.audio.Audio`, +:class:`~elodie.media.photo.Photo`, and :class:`~elodie.media.video.Video`) +are used to represent the actual files. + +.. moduleauthor:: Jaisen Mathai """ # load modules @@ -15,12 +21,14 @@ import subprocess class Media(object): - # class / static variable accessible through get_valid_extensions() + + """The base class for all media objects. + + :param str source: The fully qualified path to the video file. + """ + __name__ = 'Media' - """ - @param, source, string, The fully qualified path to the video file - """ def __init__(self, source=None): self.source = source self.exif_map = { @@ -33,12 +41,11 @@ class Media(object): self.exiftool_attributes = None self.metadata = None - """ - Get album from EXIF - - @returns, None or string - """ def get_album(self): + """Get album from EXIF + + :returns: None or string + """ if(not self.is_valid()): return None @@ -48,29 +55,31 @@ class Media(object): return exiftool_attributes['album'] - """ - Get the full path to the video. - - @returns string - """ def get_file_path(self): + """Get the full path to the video. + + :returns: string + """ return self.source - """ - Define is_valid to always return false. - This should be overridden in a child class. - """ def is_valid(self): + """The default is_valid() always returns false. + + This should be overridden in a child class to return true if the + source is valid, and false otherwise. + + :returns: bool + """ return False - """ - Read EXIF from a photo file. - We store the result in a member variable so we can call get_exif() often - without performance degredation - - @returns, list or none for a non-photo file - """ def get_exif(self): + """Read EXIF from a photo file. + + We store the result in a member variable so we can call get_exif() + often without performance degredation. + + :returns: list or none for a non-photo file + """ if(not self.is_valid()): return None @@ -84,6 +93,10 @@ class Media(object): return self.exif def get_exiftool_attributes(self): + """Get attributes for the media object from exiftool. + + :returns: dict, or False if exiftool was not available. + """ if(self.exiftool_attributes is not None): return self.exiftool_attributes @@ -122,25 +135,24 @@ class Media(object): return self.exiftool_attributes - """ - Get the file extension as a lowercased string. - - @returns, string or None for a non-video - """ def get_extension(self): + """Get the file extension as a lowercased string. + + :returns: string or None for a non-video + """ if(not self.is_valid()): return None source = self.source return os.path.splitext(source)[1][1:].lower() - """ - Get a dictionary of metadata for a photo. - All keys will be present and have a value of None if not obtained. - - @returns, dictionary or None for non-photo files - """ def get_metadata(self, update_cache=False): + """Get a dictionary of metadata for a photo. + + All keys will be present and have a value of None if not obtained. + + :returns: dict or None for non-photo files + """ if(not self.is_valid()): return None @@ -163,12 +175,11 @@ class Media(object): return self.metadata - """ - Get the mimetype of the file. - - @returns, string or None for a non-video - """ def get_mimetype(self): + """Get the mimetype of the file. + + :returns: str or None for a non-video + """ if(not self.is_valid()): return None @@ -179,12 +190,11 @@ class Media(object): return mimetype[0] - """ - Get the title for a photo of video - - @returns, string or None if no title is set or not a valid media type - """ def get_title(self): + """Get the title for a photo of video + + :returns: str or None if no title is set or not a valid media type + """ if(not self.is_valid()): return None @@ -195,14 +205,12 @@ class Media(object): return exiftool_attributes['title'] - """ - Set album for a photo - - @param, name, string, Name of album - - @returns, boolean - """ def set_album(self, name): + """Set album for a photo + + :param str name: Name of album + :returns: bool + """ if(name is None): return False @@ -252,27 +260,26 @@ class Media(object): self.set_album(folder) return True - """ - Specifically update the basename attribute in the metadata - dictionary for this instance. - This is used for when we update the EXIF title of a media file. - Since that determines the name of a file if we update the - title of a file more than once it appends to the file name. - I.e. 2015-12-31_00-00-00-my-first-title-my-second-title.jpg - - @param, string, new_basename, New basename of file - (with the old title removed) - """ def set_metadata_basename(self, new_basename): + """Update the basename attribute in the metadata dict for this instance. + + This is used for when we update the EXIF title of a media file. Since + that determines the name of a file if we update the title of a file + more than once it appends to the file name. + + i.e. 2015-12-31_00-00-00-my-first-title-my-second-title.jpg + + :param str new_basename: New basename of file (with the old title + removed). + """ self.get_metadata() self.metadata['base_name'] = new_basename - """ - Method to manually update attributes in metadata. - - @params, named paramaters - """ def set_metadata(self, **kwargs): + """Method to manually update attributes in metadata. + + :params dict kwargs: Named parameters to update. + """ metadata = self.get_metadata() for key in kwargs: if(key in metadata): @@ -287,3 +294,11 @@ class Media(object): return i(_file) return None + + @classmethod + def get_valid_extensions(cls): + """Static method to access static extensions variable. + + :returns: tuple(str) + """ + return cls.extensions diff --git a/elodie/media/photo.py b/elodie/media/photo.py index d17501a..e1fd499 100644 --- a/elodie/media/photo.py +++ b/elodie/media/photo.py @@ -1,6 +1,8 @@ """ -Author: Jaisen Mathai -Photo package that handles all photo operations +The photo module contains the :class:`Photo` class, which is used to track +image objects (JPG, DNG, etc.). + +.. moduleauthor:: Jaisen Mathai """ import imghdr @@ -17,25 +19,28 @@ from elodie import geolocation class Photo(Media): + + """A photo object. + + :param str source: The fully qualified path to the photo file + """ + __name__ = 'Photo' + + #: Valid extensions for photo files. extensions = ('jpg', 'jpeg', 'nef', 'dng', 'gif') - """ - @param, source, string, The fully qualified path to the photo file - """ def __init__(self, source=None): super(Photo, self).__init__(source) # We only want to parse EXIF once so we store it here self.exif = None - """ - Get the duration of a photo in seconds. - Uses ffmpeg/ffprobe - - @returns, string or None for a non-photo file - """ def get_duration(self): + """Get the duration of a photo in seconds. Uses ffmpeg/ffprobe. + + :returns: str or None for a non-photo file + """ if(not self.is_valid()): return None @@ -53,12 +58,13 @@ class Photo(Media): ).group(1).replace('.', ':') return None - """ - Get latitude or longitude of photo from EXIF - - @returns, float or None if not present in EXIF or a non-photo file - """ def get_coordinate(self, type='latitude'): + """Get latitude or longitude of photo from EXIF + + :param str type: Type of coordinate to get. Either "latitude" or + "longitude". + :returns: float or None if not present in EXIF or a non-photo file + """ if(not self.is_valid()): return None @@ -99,13 +105,13 @@ class Photo(Media): except KeyError: return None - """ - Get the date which the photo was taken. - The date value returned is defined by the min() of mtime and ctime. - - @returns, time object or None for non-photo files or 0 timestamp - """ def get_date_taken(self): + """Get the date which the photo was taken. + + The date value returned is defined by the min() of mtime and ctime. + + :returns: time object or None for non-photo files or 0 timestamp + """ if(not self.is_valid()): return None @@ -135,13 +141,14 @@ class Photo(Media): return time.gmtime(seconds_since_epoch) - """ - Check the file extension against valid file extensions as returned - by self.extensions - - @returns, boolean - """ def is_valid(self): + """Check the file extension against valid file extensions. + + The list of valid file extensions come from self.extensions. This + also checks whether the file is an image. + + :returns: bool + """ source = self.source # gh-4 This checks if the source file is an image. @@ -151,14 +158,12 @@ class Photo(Media): return os.path.splitext(source)[1][1:].lower() in self.extensions - """ - Set the date/time a photo was taken - - @param, time, datetime, datetime object of when the photo was taken - - @returns, boolean - """ def set_date_taken(self, time): + """Set the date/time a photo was taken. + + :param datetime time: datetime object of when the photo was taken + :returns: bool + """ if(time is None): return False @@ -172,15 +177,13 @@ class Photo(Media): exif_metadata.write() return True - """ - Set lat/lon for a photo - - @param, latitude, float, Latitude of the file - @param, longitude, float, Longitude of the file - - @returns, boolean - """ def set_location(self, latitude, longitude): + """Set latitude and longitude for a photo. + + :param float latitude: Latitude of the file + :param float longitude: Longitude of the file + :returns: bool + """ if(latitude is None or longitude is None): return False @@ -196,15 +199,12 @@ class Photo(Media): exif_metadata.write() return True - """ - Set title for a photo - - @param, latitude, float, Latitude of the file - @param, longitude, float, Longitude of the file - - @returns, boolean - """ def set_title(self, title): + """Set title for a photo. + + :param str title: Title of the photo. + :returns: bool + """ if(title is None): return False @@ -216,12 +216,3 @@ class Photo(Media): exif_metadata.write() return True - - """ - Static method to access static __valid_extensions variable. - - @returns, tuple - """ - @classmethod - def get_valid_extensions(cls): - return cls.extensions diff --git a/elodie/media/video.py b/elodie/media/video.py index 97e549e..ad5021a 100644 --- a/elodie/media/video.py +++ b/elodie/media/video.py @@ -1,6 +1,8 @@ """ -Author: Jaisen Mathai -Video package that handles all video operations +The video module contains the :class:`Video` class, which represents video +objects (AVI, MOV, etc.). + +.. moduleauthor:: Jaisen Mathai """ # load modules @@ -21,24 +23,27 @@ from media import Media class Video(Media): + + """A video object. + + :param str source: The fully qualified path to the video file. + """ + __name__ = 'Video' + + #: Valid extensions for video files. extensions = ('avi', 'm4v', 'mov', 'mp4', '3gp') - """ - @param, source, string, The fully qualified path to the video file - @param, Audio, class or none, The Audio class if being extendted - by the Audio class - """ def __init__(self, source=None): super(Video, self).__init__(source) - """ - Get path to executable avmetareadwrite binary. - We wrap this since we call it in a few places and we do a fallback. - - @returns, None or string - """ def get_avmetareadwrite(self): + """Get path to executable avmetareadwrite binary. + + We wrap this since we call it in a few places and we do a fallback. + + :returns: None or string + """ avmetareadwrite = find_executable('avmetareadwrite') if(avmetareadwrite is None): avmetareadwrite = '/usr/bin/avmetareadwrite' @@ -47,12 +52,11 @@ class Video(Media): return avmetareadwrite - """ - Get latitude or longitude of photo from EXIF - - @returns, time object or None for non-video files or 0 timestamp - """ def get_coordinate(self, type='latitude'): + """Get latitude or longitude of photo from EXIF. + + :returns: time object or None for non-video files or 0 timestamp + """ exif_data = self.get_exif() if(exif_data is None): return None @@ -75,13 +79,13 @@ class Video(Media): return decimal_degrees - """ - Get the date which the video was taken. - The date value returned is defined by the min() of mtime and ctime. - - @returns, time object or None for non-video files or 0 timestamp - """ def get_date_taken(self): + """Get the date which the video was taken. + + The date value returned is defined by the min() of mtime and ctime. + + :returns: time object or None for non-video files or 0 timestamp + """ if(not self.is_valid()): return None @@ -114,13 +118,13 @@ class Video(Media): return time.gmtime(seconds_since_epoch) - """ - Get the duration of a video in seconds. - Uses ffmpeg/ffprobe - - @returns, string or None for a non-video file - """ def get_duration(self): + """Get the duration of a video in seconds. + + This uses ffmpeg/ffprobe. + + :returns: str or None for a non-video file + """ if(not self.is_valid()): return None @@ -138,14 +142,14 @@ class Video(Media): ).group(1).replace('.', ':') return None - """ - Get exif data from video file. - Not all video files have exif and this currently relies on - the CLI exiftool program - - @returns, string or None if exiftool is not found - """ def get_exif(self): + """Get exif data from video file. + + Not all video files have exif and this currently relies on the CLI + exiftool program. + + :returns: str or None if exiftool is not found + """ exiftool = get_exiftool() if(exiftool is None): return None @@ -158,24 +162,24 @@ class Video(Media): ) return process_output.stdout.read() - """ - Check the file extension against valid file extensions as - returned by self.extensions - - @returns, boolean - """ def is_valid(self): + """Check the file extension against valid file extensions. + + The list of valid file extensions come from self.extensions. + + :returns: bool + """ source = self.source return os.path.splitext(source)[1][1:].lower() in self.extensions - """ - Set the date/time a photo was taken - - @param, time, datetime, datetime object of when the photo was taken - - @returns, boolean - """ def set_date_taken(self, date_taken_as_datetime): + """ + Set the date/time a photo was taken + + :param datetime date_taken_as_datetime: datetime object of when the + video was recorded. + :returns: bool + """ if(time is None): return False @@ -193,56 +197,51 @@ class Video(Media): return result - """ - Set lat/lon for a video - - @param, latitude, float, Latitude of the file - @param, longitude, float, Longitude of the file - - @returns, boolean - """ def set_location(self, latitude, longitude): + """ + Set latitude and longitude for a video. + + :param float latitude: Latitude of the file + :param float longitude: Longitude of the file + :returns: bool + """ if(latitude is None or longitude is None): return False result = self.__update_using_plist(latitude=latitude, longitude=longitude) # noqa return result - """ - Set title for a video - - @param, title, string, Title for the file - - @returns, boolean - """ - def set_title(self, title): + """Set title for a video. + + :param str title: Title for the file + :returns: bool + """ if(title is None): return False result = self.__update_using_plist(title=title) return result - """ - Updates video metadata using avmetareadwrite. - This method is a does a lot more than it should. - The general steps are... - 1) Check if avmetareadwrite is installed - 2) Export a plist file to a temporary location from the source file - 3) Regex replace values in the plist file - 4) Update the source file using the updated plist and save it to a - temporary location - 5) Validate that the metadata in the updated temorary movie is valid - 6) Copystat permission and time bits from the source file to the - temporary movie - 7) Move the temporary file to overwrite the source file - - @param, latitude, float, Latitude of the file - @param, longitude, float, Longitude of the file - - @returns, boolean - """ def __update_using_plist(self, **kwargs): + """Updates video metadata using avmetareadwrite. + + This method does a lot more than it should. The general steps are... + + 1. Check if avmetareadwrite is installed + 2. Export a plist file to a temporary location from the source file + 3. Regex replace values in the plist file + 4. Update the source file using the updated plist and save it to a + temporary location + 5. Validate that the metadata in the updated temorary movie is valid + 6. Copystat permission and time bits from the source file to the + temporary movie + 7. Move the temporary file to overwrite the source file + + :param float latitude: Latitude of the file + :param float longitude: Longitude of the file + :returns: bool + """ if( 'latitude' not in kwargs and 'longitude' not in kwargs and @@ -400,17 +399,13 @@ class Video(Media): return True - """ - Static method to access static __valid_extensions variable. - - @returns, tuple - """ - @classmethod - def get_valid_extensions(cls): - return cls.extensions - class Transcode(object): - # Constructor takes a video object as it's parameter + + """Constructor takes a video object as its parameter. + + :param Video video: Video object. + """ + def __init__(self, video=None): self.video = video diff --git a/elodie/plist_parser.py b/elodie/plist_parser.py index 530c5d5..ba8699b 100644 --- a/elodie/plist_parser.py +++ b/elodie/plist_parser.py @@ -1,8 +1,7 @@ """ -Author: Jaisen Mathai Parse OS X plists. -Wraps standard lib plistlib (https://docs.python.org/3/library/plistlib.html) -Plist class to parse and interact with a plist file. + +.. moduleauthor:: Jaisen Mathai """ # load modules @@ -12,15 +11,34 @@ import plistlib class Plist(object): + + """Parse and interact with a plist file. + + This class wraps the `plistlib module`_ from the standard library. + + .. _plistlib module: https://docs.python.org/3/library/plistlib.html + + :param str source: Source to read the plist from. + """ + def __init__(self, source): if not path.isfile(source): raise IOError('Could not load plist file %s' % source) - self.source = source self.plist = plistlib.readPlist(self.source) def update_key(self, key, value): + """Update a value in the plist. + + :param str key: Key to modify. + :param value: New value. + """ self.plist[key] = value def write_file(self, destination): + """Save the plist. + + :param destination: Write the plist here. + :type destination: str or file object + """ plistlib.writePlist(self.plist, destination)