Repository: TheFriendlyCoder/friendlypins

Issue:
Add helper to get all pins from a board
I need to add a helper method that pulls down all of the pins attached to a board with the minimal number of api calls, and include support for waiting when rate limits are exceeded to pull down more paged data. Ideally the helper would accept the ID of a board and return the raw JSON for all paged data for all pins on the board. Further, it would be good to have support for a callback method that could give updates to the caller when operations are pending like waiting for next rate limit expiration.

Discussion:

Patch:
diff --git a/src/friendlypins/board.py b/src/friendlypins/board.py
index 3426068..4ef40d4 100644
--- a/src/friendlypins/board.py
+++ b/src/friendlypins/board.py
@@ -1,34 +1,22 @@
 """Primitives for interacting with Pinterest boards"""
-import logging
-import json
 from friendlypins.pin import Pin
+from friendlypins.utils.base_object import BaseObject
 
 
-class Board(object):
+class Board(BaseObject):
     """Abstraction around a Pinterest board"""
+    @staticmethod
+    def default_url(unique_id):
+        """Generates a URL for the REST API endpoint for a board with a given
+        identification number
 
-    def __init__(self, url, rest_io, json_data=None):
-        """
         Args:
-            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_cache = json_data
-        self._relative_url = url
-        self._io = rest_io
+            unique_id (int): unique ID for the board
 
-    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
+        Returns:
+            str: URL for the API endpoint
+        """
+        return "boards/{0}".format(unique_id)
 
     @staticmethod
     def default_fields():
@@ -45,51 +33,6 @@ class Board(object):
             "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(self._data, sort_keys=True, indent=4)
-
-    def __repr__(self):
-        return "<{0} ({1})>".format(self.__class__.__name__, self.name)
-
     @property
     def unique_id(self):
         """int: The unique identifier associated with this board"""
@@ -121,21 +64,7 @@ class Board(object):
         self._log.debug('Loading pins for board %s...', self._relative_url)
 
         properties = {
-            "fields": ','.join([
-                "id",
-                "link",
-                "url",
-                "board",
-                "created_at",
-                "note",
-                "color",
-                "counts",
-                "media",
-                "attribution",
-                "image",
-                "metadata",
-                "original_link"
-            ])
+            "fields": ','.join(Pin.default_fields())
         }
 
         path = "{0}/pins".format(self._relative_url)
@@ -143,7 +72,7 @@ class Board(object):
             assert 'data' in cur_page
 
             for cur_item in cur_page['data']:
-                yield Pin(cur_item, self._io)
+                yield Pin.from_json(cur_item, self._io)
 
     def delete(self):
         """Removes this board and all pins attached to it"""
diff --git a/src/friendlypins/pin.py b/src/friendlypins/pin.py
index e90e0a7..6e896d6 100644
--- a/src/friendlypins/pin.py
+++ b/src/friendlypins/pin.py
@@ -1,27 +1,42 @@
 """Primitives for operating on Pinterest pins"""
-import logging
-import json
 from friendlypins.thumbnail import Thumbnail
+from friendlypins.utils.base_object import BaseObject
 
 
-class Pin(object):
+class Pin(BaseObject):
     """Abstraction around a Pinterest pin"""
 
-    def __init__(self, data, rest_io):
-        """
-        Args:
-            data (dict): Raw Pinterest API data describing a pin
-            rest_io (RestIO): reference to the Pinterest REST API
-        """
-        self._log = logging.getLogger(__name__)
-        self._io = rest_io
-        self._data = data
+    @staticmethod
+    def default_url(unique_id):
+        """Generates a URL for the REST API endpoint for a pin with a given
+        identification number
 
-    def __str__(self):
-        return json.dumps(dict(self._data), sort_keys=True, indent=4)
+        Args:
+            unique_id (int): unique ID for the pin
 
-    def __repr__(self):
-        return "<{0} ({1})>".format(self.__class__.__name__, self.note)
+        Returns:
+            str: URL for the API endpoint
+        """
+        return "pins/{0}".format(unique_id)
+
+    @staticmethod
+    def default_fields():
+        """list (str): list of fields we pre-populate when loading pin data"""
+        return [
+            "id",
+            "link",
+            "url",
+            "board",
+            "created_at",
+            "note",
+            "color",
+            "counts",
+            "media",
+            "attribution",
+            "image",
+            "metadata",
+            "original_link"
+        ]
 
     @property
     def url(self):
@@ -63,8 +78,8 @@ class Pin(object):
 
     def delete(self):
         """Removes this pin from it's respective board"""
-        self._log.debug('Deleting pin %s', repr(self))
-        self._io.delete('pins/{0}'.format(self.unique_id))
+        self._log.debug('Deleting pin %s', self._relative_url)
+        self._io.delete(self._relative_url)
 
 
 if __name__ == "__main__":  # pragma: no cover
diff --git a/src/friendlypins/utils/base_object.py b/src/friendlypins/utils/base_object.py
new file mode 100644
index 0000000..4368adb
--- /dev/null
+++ b/src/friendlypins/utils/base_object.py
@@ -0,0 +1,96 @@
+"""Base class exposing common functionality to all Pinterest primitives"""
+import logging
+import json
+
+
+class BaseObject:
+    """Common base class providing shared functionality for all Pinterest
+    primitives"""
+
+    def __init__(self, url, rest_io, json_data=None):
+        """
+        Args:
+            url (str): URL for this object, relative to the API root
+            rest_io (RestIO): reference to the Pinterest REST API
+            json_data (dict):
+                Optional JSON response data describing this object
+                if not provided, the class will lazy load response data
+                when needed
+        """
+        self._log = logging.getLogger(type(self).__module__)
+        self._data_cache = json_data
+        self._relative_url = url
+        self._io = rest_io
+
+    @staticmethod
+    def default_fields():
+        """Default implementation"""
+        raise NotImplementedError(
+            "All derived classes must define a default_fields method"
+        )
+
+    @staticmethod
+    def default_url(unique_id):
+        """Default implementation"""
+        raise NotImplementedError(
+            "All derived classes must define a default_url method"
+        )
+
+    def refresh(self):
+        """Updates cached response data describing the state of this pin
+
+        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
+
+    @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 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 object
+            rest_io (RestIO):
+                pre-initialized session object for communicating with the
+                REST API
+
+        Returns:
+            instance of this derived class, pre-initialized with the provided
+            response data
+        """
+
+        return cls(cls.default_url(json_data["id"]), 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(self._data, sort_keys=True, indent=4)
+
+    def __repr__(self):
+        return "<{0} ({1}, {2}, {3})>".format(
+            self.__class__.__name__,
+            self._relative_url,
+            self._io,
+            self._data_cache
+        )


Test patch:
diff --git a/tests/test_pin.py b/tests/test_pin.py
index 0d79543..3f6c030 100644
--- a/tests/test_pin.py
+++ b/tests/test_pin.py
@@ -19,7 +19,7 @@ def test_pin_properties():
     }
 
     mock_io = mock.MagicMock()
-    obj = Pin(sample_data, mock_io)
+    obj = Pin("pins/1234", mock_io, sample_data)
 
     assert obj.unique_id == expected_id
     assert obj.note == expected_note
@@ -41,7 +41,7 @@ def test_pin_missing_media_type():
     }
 
     mock_io = mock.MagicMock()
-    obj = Pin(sample_data, mock_io)
+    obj = Pin("pins/1234", mock_io, sample_data)
 
     assert obj.unique_id == expected_id
     assert obj.note == expected_note
@@ -74,7 +74,7 @@ def test_get_thumbnail():
     }
 
     mock_io = mock.MagicMock()
-    obj = Pin(data, mock_io)
+    obj = Pin("pins/1234", mock_io, data)
     result = obj.thumbnail
 
     assert result.url == expected_url