Repository: TheFriendlyCoder/friendlypins

Issue:
add a find board by id method to api
In another effort to minimize the hits to the pinterest api we should add a helper method that returns a Board object given an ID number. Further, we should optimize this getter so that it lazy-loads the response data describing the board only when necessary.

Discussion:
coveralls: 
[![Coverage Status](https://coveralls.io/builds/32196966/badge)](https://coveralls.io/builds/32196966)

Coverage increased (+0.05%) to 89.663% when pulling **6132dcb01b59f3b214c7a03170e47f498785dd5f on getboard** into **181c8b6a62921a7e836ca049d080a23083310407 on master**.


Patch:
diff --git a/.travis.yml b/.travis.yml
index 19aa694..5d4debb 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -11,7 +11,7 @@ python:
 install:
   # pinning coverage package until bugs with coveralls plugin is fixed
   # https://github.com/z4r/python-coveralls/issues/73
-  - pip install tox wheel python-coveralls "coverage<5.0"
+  - pip install tox tox-factor wheel python-coveralls "coverage<5.0"
 
 script:
   - echo $TRAVIS_PYTHON_VERSION
@@ -20,8 +20,7 @@ script:
   # For CPython the TravisCI env var mapping should be: 3.8 -> py38
   - '[ "$TRAVIS_PYTHON_VERSION" = "pypy3.5" ] && export PYVER=`echo $TRAVIS_PYTHON_VERSION | tr "." "\n" | head -n 1` || export PYVER=py`echo $TRAVIS_PYTHON_VERSION | sed "s/\.//" | sed "s/^py//"`'
   - echo $PYVER
-  - tox -e $PYVER-lint
-  - tox -e $PYVER-test
+  - tox -f $PYVER
   # Only publish coverage metrics for the latest supported Python version
   - '[ "$TRAVIS_PYTHON_VERSION" = "3.8" ] && coveralls || echo Skipping Coveralls'
 
diff --git a/docs/conf.py b/docs/conf.py
index 4a1aeb1..6b82dda 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,21 +1,15 @@
-# -*- coding: utf-8 -*-
-#
-# Configuration file for the Sphinx documentation builder.
-#
-# This file does only contain a selection of the most common options. For a
-# full list see the documentation:
-# http://www.sphinx-doc.org/en/stable/config
+"""
+Configuration file for the Sphinx documentation builder.
 
-# -- Path setup --------------------------------------------------------------
+This file does only contain a selection of the most common options. For a
+full list see the documentation:
+http://www.sphinx-doc.org/en/stable/config
+"""
 
-# If extensions (or modules to document with autodoc) are in another directory,
-# add these directories to sys.path here. If the directory is relative to the
-# documentation root, use os.path.abspath to make it absolute, like shown here.
-#
 import ast
 import os
 import sys
-import sphinx_rtd_theme
+from sphinx.ext.intersphinx import InventoryAdapter
 
 # If extensions (or modules to document with autodoc) are in another directory,
 # add these directories to sys.path here. If the directory is relative to the
@@ -27,7 +21,7 @@ sys.path.insert(0, _src_dir)
 
 # -- Project information -----------------------------------------------------
 
-copyright = '2020, Kevin S. Phillips'
+copyright = '2020, Kevin S. Phillips'  # pylint: disable=redefined-builtin
 author = 'Kevin S. Phillips'
 _proj_props = ast.literal_eval(open('../project.prop').read())
 _proj_dir = os.path.join(_src_dir, _proj_props["NAME"])
@@ -45,23 +39,7 @@ version = _proj_props["VERSION"]
 # The full version, including alpha/beta/rc tags.
 release = version
 
-# Configure automatic API documentation
-apidoc_module_dir = _proj_dir
-apidoc_output_dir = os.path.join(_base_path, "api")
-apidoc_separate_modules = True
-napoleon_numpy_docstring = False
-autoclass_content = "both"
-napoleon_include_special_with_doc = False
-
 # -- 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',
     'sphinx.ext.intersphinx',
@@ -72,125 +50,82 @@ extensions = [
     'sphinx_rtd_theme',
 ]
 
-# 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 master toctree document.
 master_doc = 'index'
-
-# 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
-
-# List of patterns, relative to source directory, that match files and
-# directories to ignore when looking for source files.
-# This pattern also affects html_static_path and html_extra_path .
 exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
-
-# The name of the Pygments (syntax highlighting) style to use.
 pygments_style = 'sphinx'
 
+# -- Options for apidoc/napoleon extensions ------------------------------------
+apidoc_module_dir = _proj_dir
+apidoc_output_dir = os.path.join(_base_path, "api")
+apidoc_separate_modules = True
+napoleon_numpy_docstring = False
+autoclass_content = "both"
+napoleon_include_special_with_doc = 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 = 'sphinx_rtd_theme'
-
-# 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 = {
     "style_external_links": True,
 }
 
-# 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 = []
-
-# Custom sidebar templates, must be a dictionary that maps document names
-# to template names.
-#
-# The default sidebars (for documents that don't match any pattern) are
-# defined by theme itself.  Builtin themes are using these templates by
-# default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
-# 'searchbox.html']``.
-#
-# html_sidebars = {}
-
-
-# -- Options for HTMLHelp output ---------------------------------------------
-
-# Output file base name for HTML help builder.
-htmlhelp_basename = 'friendlypinsdoc'
-
-
-# -- 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',
+# -- Options for intersphinx extension ---------------------------------------
+intersphinx_mapping = {
+    "python": ("https://docs.python.org/", None),
+    "requests": ("https://requests.readthedocs.io/en/master/", None),
 }
 
-# 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, 'friendlypins.tex', 'friendlypins Documentation',
-     'Kevin S. Phillips', 'manual'),
-]
-
-
-# -- 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, 'friendlypins', 'friendlypins Documentation',
-     [author], 1)
-]
-
-
-# -- 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, 'friendlypins', 'friendlypins Documentation',
-     author, 'friendlypins', 'One line description of project.',
-     'Miscellaneous'),
-]
-
-
-# -- Extension configuration -------------------------------------------------
+intersphinx_aliases = {
+    'py:exception': {
+        'requests.HTTPError': 'requests.exceptions.HTTPError'
+    }
+}
 
-# -- Options for intersphinx extension ---------------------------------------
 
-# Example configuration for intersphinx: refer to the Python standard library.
-intersphinx_mapping = {'https://docs.python.org/': None}
\ No newline at end of file
+def add_intersphinx_aliases_to_inv(app):
+    """Workaround for data type mismatches between intersphinx document sources
+
+    Implementation based on a workaround found here:
+    https://github.com/sphinx-doc/sphinx/issues/5603
+
+    The calling code need only define a dictionary that maps the source object
+    as it appears in the Sphinx object tree, to the actual location for the
+    object definition in an object.inv inventory file. The syntax looks like
+    this:
+
+    intersphinx_aliases = {
+        'dt-domain': {
+            'src-obj-name': 'target-obj-name'
+        }
+    }
+
+    TIP: when debugging intersphinx mapping problems, it is helpful to use
+    the sphobjinv tool:
+        https://pypi.org/project/sphobjinv/
+
+    Example use:
+    sphobjinv suggest https://requests.readthedocs.io/en/master/ HTTPError -su
+    """
+    inventories = InventoryAdapter(app.builder.env)
+
+    for domain, mapping in app.config.intersphinx_aliases.items():
+        if domain not in inventories.main_inventory:
+            raise NotImplementedError(
+                "Domain {0} not found in Sphinx inventory".format(domain)
+            )
+        for source, target in mapping.items():
+            if source not in inventories.main_inventory[domain]:
+                raise NotImplementedError(
+                    "Source object {0} not found in Sphinx domain {1}".format(
+                        source, domain
+                    )
+                )
+            inventories.main_inventory[domain][target] = \
+                inventories.main_inventory[domain][source]
+
+
+def setup(app):
+    """Custom Sphinx extention manager entry point method"""
+    app.add_config_value('intersphinx_aliases', {}, 'env')
+    app.connect('builder-inited', add_intersphinx_aliases_to_inv)
diff --git a/src/friendlypins/api.py b/src/friendlypins/api.py
index 81bc8b0..120116c 100644
--- a/src/friendlypins/api.py
+++ b/src/friendlypins/api.py
@@ -1,6 +1,7 @@
 """Primary entry point for the Friendly Pinterest library"""
 import logging
 from friendlypins.user import User
+from friendlypins.board import Board
 from friendlypins.utils.rest_io import RestIO
 
 
@@ -17,6 +18,23 @@ class API(object):  # pylint: disable=too-few-public-methods
         self._log = logging.getLogger(__name__)
         self._io = RestIO(personal_access_token)
 
+    def get_board_by_id(self, board_id):
+        """Locates a specific Pinterest board given it's internal identifier
+
+        NOTE: this API assumes that the ID provided references a valid board.
+        If it does not, the object returned will be invalid and any attempts
+        to access data from the board will result in an error.
+
+        Args:
+            board_id (int):
+                the unique identifier for the board
+
+        Returns:
+            Board: reference to the Pinterest board
+        """
+        board_url = "boards/{0}".format(board_id)
+        return Board(board_url, self._io)
+
     @property
     def user(self):
         """User: Gets all primitives associated with the authenticated user"""
diff --git a/src/friendlypins/board.py b/src/friendlypins/board.py
index 7eb42de..04966e8 100644
--- a/src/friendlypins/board.py
+++ b/src/friendlypins/board.py
@@ -7,18 +7,86 @@ from friendlypins.pin import Pin
 class Board(object):
     """Abstraction around a Pinterest board"""
 
-    def __init__(self, data, rest_io):
+    def __init__(self, url, rest_io, json_data=None):
         """
         Args:
-            data (dict): Raw Pinterest API data describing the board
+            url (str): URL for this board, relative to the API root
             rest_io (RestIO): reference to the Pinterest REST API
+            json_data (dict):
+                Optional JSON response data describing this board
+                if not provided, the class will lazy load response data
+                when needed
         """
         self._log = logging.getLogger(__name__)
-        self._data = data
+        self._data_cache = json_data
+        self._relative_url = url
         self._io = rest_io
 
+    def refresh(self):
+        """Updates cached response data describing the state of this board
+
+        NOTE: This method simply clears the internal cache, and updated
+        information will automatically be pulled on demand as additional
+        queries are made through the API"""
+        self._data_cache = None
+
+    @staticmethod
+    def default_fields():
+        """list (str): list of fields we pre-populate when loading board data"""
+        return [
+            "id",
+            "name",
+            "url",
+            "description",
+            "creator",
+            "created_at",
+            "counts",
+            "image",
+            "reason",
+            "privacy"
+        ]
+
+    @property
+    def _data(self):
+        """dict: gets response data, either from the internal cache or from the
+        REST API"""
+        if self._data_cache is not None:
+            return self._data_cache
+        self._log.debug("Lazy loading board data for: %s", self._relative_url)
+        properties = {
+            "fields": ','.join(self.default_fields())
+        }
+        temp = self._io.get(self._relative_url, properties)
+        assert "data" in temp
+        self._data_cache = temp["data"]
+
+        return self._data_cache
+
+    @classmethod
+    def from_json(cls, json_data, rest_io):
+        """Factory method that instantiates an instance of this class
+        from JSON response data loaded by the caller
+
+        Args:
+            json_data (dict):
+                pre-loaded response data describing the board
+            rest_io (RestIO):
+                pre-initialized session object for communicating with the
+                REST API
+
+        Returns:
+            Board: instance of this class encapsulating the response data
+        """
+        board_url = "boards/{0}".format(json_data["id"])
+        return Board(board_url, rest_io, json_data)
+
+    @property
+    def json(self):
+        """dict: returns raw json representation of this object"""
+        return self._data
+
     def __str__(self):
-        return json.dumps(dict(self._data), sort_keys=True, indent=4)
+        return json.dumps(self._data, sort_keys=True, indent=4)
 
     def __repr__(self):
         return "<{0} ({1})>".format(self.__class__.__name__, self.name)
@@ -51,7 +119,7 @@ class Board(object):
     @property
     def pins(self):
         """Pin: Generator for iterating over the pins linked to this board"""
-        self._log.debug('Loading pins for board %s...', self.name)
+        self._log.debug('Loading pins for board %s...', self._relative_url)
 
         properties = {
             "fields": ','.join([
@@ -72,7 +140,7 @@ class Board(object):
             ])
         }
 
-        path = "boards/{0}/pins".format(self.unique_id)
+        path = "{0}/pins".format(self._relative_url)
         for cur_page in self._io.get_pages(path, properties):
             assert 'data' in cur_page
 
@@ -81,8 +149,8 @@ class Board(object):
 
     def delete(self):
         """Removes this board and all pins attached to it"""
-        self._log.debug('Deleting board %s', repr(self))
-        self._io.delete('boards/{0}'.format(self.unique_id))
+        self._log.debug('Deleting board %s', self._relative_url)
+        self._io.delete(self._relative_url)
 
 
 if __name__ == "__main__":  # pragma: no cover
diff --git a/src/friendlypins/exceptions.py b/src/friendlypins/exceptions.py
index 3a4be89..e0278c6 100644
--- a/src/friendlypins/exceptions.py
+++ b/src/friendlypins/exceptions.py
@@ -2,7 +2,7 @@
 from datetime import datetime
 from dateutil import tz
 from humanize import naturaltime
-from requests import HTTPError
+from requests.exceptions import HTTPError
 from friendlypins.headers import Headers
 
 
@@ -19,7 +19,7 @@ class RateLimitException(HTTPError):
 
     @property
     def rate_renewal(self):
-        """datetime.datemtime: time, in the current locale, when requests will
+        """datetime.datetime: time, in the current locale, when requests will
         be allowed once again"""
         return self.pin_headers.time_to_refresh
 
diff --git a/src/friendlypins/user.py b/src/friendlypins/user.py
index 0d8670d..a74c40d 100644
--- a/src/friendlypins/user.py
+++ b/src/friendlypins/user.py
@@ -18,6 +18,22 @@ class User(object):
         self._relative_url = url
         self._data_cache = None
 
+    @staticmethod
+    def default_fields():
+        """list (str): list of fields we pre-populate when loading user data"""
+        return [
+            "id",
+            "username",
+            "first_name",
+            "last_name",
+            "bio",
+            "created_at",
+            "counts",
+            "image",
+            "account_type",
+            "url"
+        ]
+
     def refresh(self):
         """Updates cached response data describing the state of this user
 
@@ -35,25 +51,14 @@ class User(object):
         limitations.
         """
         if self._data_cache is not None:
-            return self._data_cache["data"]
+            return self._data_cache
         self._log.debug("Getting authenticated user details...")
 
-        fields = ",".join([
-            "id",
-            "username",
-            "first_name",
-            "last_name",
-            "bio",
-            "created_at",
-            "counts",
-            "image",
-            "account_type",
-            "url"
-        ])
-        self._data_cache = self._io.get(self._relative_url, {"fields": fields})
-        assert 'data' in self._data_cache
-        return self._data_cache["data"]
-
+        fields = ",".join(self.default_fields())
+        temp = self._io.get(self._relative_url, {"fields": fields})
+        assert 'data' in temp
+        self._data_cache = temp["data"]
+        return self._data_cache
 
     def __str__(self):
         return json.dumps(dict(self._data), sort_keys=True, indent=4)
@@ -108,18 +113,7 @@ class User(object):
         self._log.debug('Loading boards for user %s...', self._relative_url)
 
         properties = {
-            "fields": ','.join([
-                "id",
-                "name",
-                "url",
-                "description",
-                "creator",
-                "created_at",
-                "counts",
-                "image",
-                "reason",
-                "privacy"
-            ])
+            "fields": ','.join(Board.default_fields())
         }
 
         board_url = "{0}/boards".format(self._relative_url)
@@ -127,7 +121,7 @@ class User(object):
             assert 'data' in cur_page
 
             for cur_item in cur_page['data']:
-                yield Board(cur_item, self._io)
+                yield Board.from_json(cur_item, self._io)
 
     def create_board(self, name, description=None):
         """Creates a new board for the currently authenticated user
@@ -140,18 +134,7 @@ class User(object):
             Board: reference to the newly created board
         """
         properties = {
-            "fields": ','.join([
-                "id",
-                "name",
-                "url",
-                "description",
-                "creator",
-                "created_at",
-                "counts",
-                "image",
-                "reason",
-                "privacy"
-            ])
+            "fields": ','.join(Board.default_fields())
         }
 
         data = {"name": name}
@@ -159,7 +142,7 @@ class User(object):
             data["description"] = description
 
         result = self._io.post("boards", data, properties)
-        return Board(result['data'], self._io)
+        return Board.from_json(result['data'], self._io)
 
 
 if __name__ == "__main__":  # pragma: no cover
diff --git a/tox.ini b/tox.ini
index 7ae8817..b764b48 100644
--- a/tox.ini
+++ b/tox.ini
@@ -30,6 +30,8 @@ deps =
 # version - v3.8 in this case
 commands =
     py38-lint: python -m pylint setup.py
+    py38-lint: python -m pylint -d invalid-name docs/conf.py
     py38-lint: python -m pylint ./src/{env:PROJECT_PATH}
     test: python -m pytest {posargs} ./tests -v --cov-report html --cov {env:PROJECT_PATH} --no-cov-on-fail
-    py38-docs: python -m sphinx -b html -a -n -W --keep-going ./docs ./htmldocs
+    py38-docs: python -c "import shutil; shutil.rmtree('htmldocs', ignore_errors=True)"
+    py38-docs: python -m sphinx -b html -anW --keep-going ./docs ./htmldocs


Test patch:
diff --git a/tests/test_board.py b/tests/test_board.py
index 94753aa..df1aaf1 100644
--- a/tests/test_board.py
+++ b/tests/test_board.py
@@ -17,19 +17,42 @@ def test_board_properties():
     }
 
     mock_io = mock.MagicMock()
-    obj = Board(sample_data, mock_io)
+    obj = Board("boards/"+str(expected_id), mock_io, sample_data)
     assert obj.unique_id == expected_id
     assert obj.name == expected_name
     assert obj.url == expected_url
     assert obj.num_pins == expected_pin_count
 
 
-def test_get_pins():
+def test_cache_refresh():
+    expected_url = 'https://www.pinterest.com/MyUserName/'
     data = {
-        'id': '987654321',
-        'name': 'MyBoard'
+        'url': expected_url,
     }
 
+    mock_io = mock.MagicMock()
+    mock_io.get.return_value = {"data": data}
+    obj = Board("boards/1234", mock_io)
+    # If we make multiple requests for API data, we should only get
+    # a single hit to the remote API endpoint
+    assert expected_url == obj.url
+    assert expected_url == obj.url
+    mock_io.get.assert_called_once()
+
+    # Calling refresh should clear our internal response cache
+    # which should not require any additional API calls
+    obj.refresh()
+    mock_io.get.assert_called_once()
+
+    # Subsequent requests for additional data should reload the cache,
+    # and then preserve / reuse the cache data for all subsequent calls,
+    # limiting the number of remote requests
+    assert expected_url == obj.url
+    assert expected_url == obj.url
+    assert mock_io.get.call_count == 2
+
+
+def test_get_pins():
     expected_id = 1234
     expected_url = "https://www.pinterest.ca/MyName/MyPin/"
     expected_note = "My Pin descriptive text"
@@ -52,7 +75,7 @@ def test_get_pins():
 
     mock_io = mock.MagicMock()
     mock_io.get_pages.return_value = [expected_data]
-    obj = Board(data, mock_io)
+    obj = Board("boards/1234", mock_io)
 
     result = list()
     for item in obj.pins:
@@ -66,13 +89,9 @@ def test_get_pins():
 
 
 def test_delete():
-    data = {
-        "id": "12345678",
-        "name": "MyBoard"
-    }
-
     mock_io = mock.MagicMock()
-    obj = Board(data, mock_io)
+    expected_url = "boards/1234"
+    obj = Board(expected_url, mock_io)
     obj.delete()
 
-    mock_io.delete.assert_called_once()
+    mock_io.delete.assert_called_once_with(expected_url)
diff --git a/tests/test_user.py b/tests/test_user.py
index ffacb62..b25d47a 100644
--- a/tests/test_user.py
+++ b/tests/test_user.py
@@ -61,11 +61,6 @@ def test_cache_refresh():
 
 
 def test_get_boards():
-    data = {
-        "first_name": "John",
-        "last_name": "Doe"
-    }
-
     expected_id = 1234
     expected_name = "MyBoard"
     expected_url = "https://www.pinterest.ca/MyName/MyBoard/"
@@ -82,7 +77,7 @@ def test_get_boards():
 
     mock_io = mock.MagicMock()
     mock_io.get_pages.return_value = [expected_data]
-    obj = User(data, mock_io)
+    obj = User("me", mock_io)
 
     result = list()
     for item in obj.boards:
@@ -97,11 +92,6 @@ def test_get_boards():
 def test_create_board():
     expected_name = "My Board"
     expected_desc = "My new board is about this stuff..."
-    data = {
-        "id": "1234",
-        "first_name": "Jonh",
-        "last_name": "Doe"
-    }
     mock_io = mock.MagicMock()
     mock_io.post.return_value = {
         "data": {
@@ -110,7 +100,7 @@ def test_create_board():
             "id": "12345"
         }
     }
-    obj = User(data, mock_io)
+    obj = User("me", mock_io)
 
     board = obj.create_board(expected_name, expected_desc)
     mock_io.post.assert_called_once()