Repository: openstates/pyopenstates

Issue:
update for API v3
A reminder to myself, should be a fairly light lift since the methods are very similar in function.

Discussion:
ssempervirens: It looks like the build is failing because there is an issue with the API key. The code works and all tests pass on my machine. Is it possible that Travis' API key might be expired or did I break this somehow? 

Patch:
diff --git a/pyopenstates.py b/pyopenstates.py
index 2b3a4dc..8ff9c32 100644
--- a/pyopenstates.py
+++ b/pyopenstates.py
@@ -25,14 +25,18 @@ limitations under the License."""
 
 __version__ = "1.2.0"
 
-API_ROOT = "https://openstates.org/api/v1/"
+API_ROOT = "https://v3.openstates.org"
 DEFAULT_USER_AGENT = "pyopenstates/{0}".format(__version__)
+API_KEY_ENV_VAR = 'OPENSTATES_API_KEY'
 ENVIRON_API_KEY = os.environ.get('OPENSTATES_API_KEY')
 
 session = Session()
+session.headers.update({"Accept": 'application/json'})
 session.headers.update({"User-Agent": DEFAULT_USER_AGENT})
 if ENVIRON_API_KEY:
     session.headers.update({'X-Api-Key': ENVIRON_API_KEY})
+else:
+    print("Warning: No API Key found, set {}".format(API_KEY_ENV_VAR))
 
 #  Python 2 comparability hack
 if version_info[0] >= 3:
@@ -63,7 +67,6 @@ def _get(uri, params=None):
     Returns:
         JSON as a Python dictionary
     """
-
     def _convert_timestamps(result):
         """Converts a string timestamps from an api result API to a datetime"""
         if type(result) == dict:
@@ -94,7 +97,7 @@ def _get(uri, params=None):
 
         return result
 
-    url = "{0}/{1}/".format(API_ROOT.strip("/"), uri.strip("/"))
+    url = "{0}/{1}".format(API_ROOT, uri)
     for param in params.keys():
         if type(params[param]) == list:
             params[param] = ",".join(params[param])
@@ -136,53 +139,66 @@ def get_metadata(state=None, fields=None):
     Returns:
        Dict: The requested :ref:`Metadata` as a dictionary
     """
-    uri = "/metadata/"
+    uri = "jurisdictions"
+    params = dict()
     if state:
-        uri += "{0}/".format(state.lower())
-    return _get(uri, params=dict(fields=fields))
+        uri += '/' + _jurisdiction_id(state)
+        state_response = _get(uri, params=params)
+        if fields is not None:
+            return {k: state_response[k] for k in fields}
+        else:
+            return state_response
+    else:
+        params['page'] = '1'
+        params['per_page'] = '52'
+        return _get(uri, params=params)["results"]
 
 
-def download_bulk_data(state, file_object, data_format="json"):
-    """
-    Downloads a zip containing bulk data on a given state to a given file
-    object
+def get_organizations(state):
+    uri = "jurisdictions"
+    uri += '/' + _jurisdiction_id(state)
+    state_response = _get(uri, params={'include': 'organizations'})
+    return state_response['organizations']
 
-    Args:
-        state: The abbreviation of the state
-        file_object: A file or file-like object
-        data_format: ``json`` or ``csv``
 
-    .. NOTE::
-        ``json`` format provides much more detail than ``csv``.
+# def download_bulk_data(state, file_object, data_format="json"):
+#     """
+#     Downloads a zip containing bulk data on a given state to a given file
+#     object
 
-    Examples:
-        ::
+#     Args:
+#         state: The abbreviation of the state
+#         file_object: A file or file-like object
+#         data_format: ``json`` or ``csv``
 
-            # Saving Ohio's data to a file on disk
-            with open("ohio-json.zip", "wb") as ohio_zip_file:
-                pyopenstates.download_bulk_data("OH", ohio_zip_file)
+#     .. NOTE::
+#         ``json`` format provides much more detail than ``csv``.
 
-            # Or download it to memory
-            from io import BytesIO
-            mem_zip = BytesIO()
-            pyopenstates.download_bulk_data("OH", mem_zip)
+#     Examples:
+#         ::
 
-    """
-    if data_format.lower() == "json":
-        field = "latest_json_url"
-    elif data_format.lower() == "csv":
-        field = "latest_csv_url"
-    else:
-        raise ValueError("data_format must be json or csv")
-    url = get_metadata(state, fields=field)[field]
-    response = session.get(url)
-    if response.status_code != 200:
-        if response.status_code == 404:
-            raise NotFound("Not found: {0}".format(response.url))
-        else:
-            raise APIError(response.text)
+#             # Saving Ohio's data to a file on disk
+#             with open("ohio-json.zip", "wb") as ohio_zip_file:
+#                 pyopenstates.download_bulk_data("OH", ohio_zip_file)
+
+#             # Or download it to memory
+#             from io import BytesIO
+#             mem_zip = BytesIO()
+#             pyopenstates.download_bulk_data("OH", mem_zip)
+
+#     """
+#     if data_format.lower() == "json":
+#         field = "id"
+#     #elif data_format.lower() == "csv":
+#         #field = "latest_csv_url"
+#     else:
+#         raise ValueError("data_format must be json or csv")
+#     url = "jurisdictions"
+#     params = dict()
+#     url += "/ocd-jurisdiction/country:us/state:{0}/government".format(state.lower())
+#     response = _get(url, params=params)
 
-    file_object.write(response.content)
+#     file_object.write(response.content)
 
 
 def search_bills(**kwargs):
@@ -202,8 +218,8 @@ def search_bills(**kwargs):
     window is desired the following options can be passed to
     ``search_window``:
         - ``search_window="all"`` - Default, include all sessions.
-        - ``search_window="term"`` - Only bills from sessions within the
-        current term.
+        - ``search_window="session"`` - Only bills from sessions within the
+        current session.
         - ``search_window="session"`` - Only bills from the current session.
         - ``search_window="session:2009"`` - Only bills from the session named
         ``2009``.
@@ -241,30 +257,37 @@ def search_bills(**kwargs):
         return.
     """
     uri = "bills/"
+    if 'state' in kwargs.keys():
+        kwargs['jurisdiction'] = _jurisdiction_id(kwargs['state'])
+    
     if len(kwargs ) > 0:
-        kwargs["per_page"] = 500
+        kwargs["per_page"] = 20
         kwargs["page"] = 1
     results = []
-    new_results = _get(uri, params=kwargs)
+    new_results = _get(uri, params=kwargs)['results']
     while len(new_results) > 0:
         results += new_results
         kwargs["page"] += 1
         sleep(1)
-        new_results = _get(uri, params=kwargs)
+        # When the search runs out of pages, the API returns not found
+        try:
+            new_results = _get(uri, params=kwargs)['results']
+        except NotFound:
+            break
 
     return results
 
 
-def get_bill(uid=None, state=None, term=None, bill_id=None, **kwargs):
+def get_bill(uid=None, state=None, session=None, bill_id=None, **kwargs):
     """
     Returns details of a specific bill Can be identified my the Open States
-    unique bill id (uid), or by specifying the state, term, and
+    unique bill id (uid), or by specifying the state, session, and
     legislative bill ID
 
     Args:
         uid: The Open States unique bill ID
         state: The postal code of the state
-        term: The legislative term (see state metadata)
+        session: The legislative session (see state metadata)
         bill_id: Yhe legislative bill ID (e.g. ``HR 42``)
         **kwargs: Optional keyword argument options, such as ``fields``,
         which specifies the fields to return
@@ -273,15 +296,16 @@ def get_bill(uid=None, state=None, term=None, bill_id=None, **kwargs):
         The :ref:`Bill` details as a dictionary
     """
     if uid:
-        if state or term or bill_id:
+        if state or session or bill_id:
             raise ValueError("Must specify an Open States bill (uid), or the "
-                             "state, term, and bill ID")
-        return _get("/bills/{}".format(uid), params=kwargs)
+                             "state, session, and bill ID")
+        uid = _fix_id_string('ocd-bill/', uid)
+        return _get("bills/{}".format(uid), params=kwargs)
     else:
-        if not state or not term or not bill_id:
+        if not state or not session or not bill_id:
             raise ValueError("Must specify an Open States bill (uid), "
-                             "or the state, term, and bill ID")
-        return _get("/bills/{}/{}/{}/".format(state.lower(), term, bill_id),
+                             "or the state, session, and bill ID")
+        return _get("bills/{}/{}/{}".format(state.lower(), session, bill_id),
                     params=kwargs)
 
 
@@ -311,7 +335,7 @@ def search_legislators(**kwargs):
     return _get("/legislators/", params=kwargs)
 
 
-def get_legislator(leg_id, fields=None):
+def get_legislator(leg_id):
     """
     Gets a legislator's details
 
@@ -322,10 +346,11 @@ def get_legislator(leg_id, fields=None):
     Returns:
         The requested :ref:`Legislator` details as a dictionary
     """
-    return _get("/legislators/{0}/".format(leg_id), params=dict(fields=fields))
+    leg_id = _fix_id_string('ocd-person/', leg_id)
+    return _get("people/", params={'id':[leg_id]})['results'][0]
 
 
-def locate_legislators(lat, long, fields=None):
+def locate_legislators(lat, lng, fields=None):
     """
     Returns a list of legislators for the given latitude/longitude coordinates
 
@@ -338,83 +363,12 @@ def locate_legislators(lat, long, fields=None):
         A list of matching :ref:`Legislator` dictionaries
 
     """
-    return _get("/legislators/geo/", params=dict(lat=float(lat),
-                                                 long=float(long),
-                                                 fields=fields))
+    return _get("people.geo/", params=dict(lat=float(lat),
+                                                 lng=float(lng),
+                                                 fields=fields))['results']
 
 
-def search_committees(**kwargs):
-    """
-    Search for and return a list of matching committees
-
-    Args:
-        **kwargs: One or more filter keyword arguments
-
-    - ``committee`` - Name of committee.
-    - ``subcommittee`` - Name of subcommittee. (if None, object describes the
-    committee)
-    - ``chamber`` - Chamber committee belongs to: ``upper``, ``lower``, or `
-    `joint``
-    - ``state`` - State abbreviation
-
-    Returns:
-        A list of matching :ref:`Committee` dictionaries
-    """
-    return _get("/committees/", params=kwargs)
-
-
-def get_committee(com_id, fields=None):
-    """
-    Gets committee details
-
-    Args:
-        com_id: Open States committee ID
-        fields: An optional, custom set of fields to return
-
-    Returns:
-        The requested :ref:`Committee` details as a dictionary
-    """
-    return _get("/committees/{0}/".format(com_id), params=dict(fields=fields))
-
-
-def search_events(**kwargs):
-    """
-    Searches events
-
-    Events are not available in all states, to ensure that events are
-    available check the ``feature_flags`` list in a
-    states’ State Metadata.
-
-    Args:
-        **kwargs: One or more search filters
-
-    - ``state`` - State abbreviation.
-    - ``type`` - Categorized event type. (``committee:meeting`` for now)
-
-    This method also allows specifying an alternate output format, by
-    specifying ``format=rss`` or ``format=ics``.
-
-    Returns:
-        A list of matching :ref:`Event` dictionaries
-    """
-    return _get("/events/", params=kwargs)
-
-
-def get_event(event_id, fields=None):
-    """
-    Gets event details
-
-    Args:
-        event_id: The Open States Event UUID
-        fields: An optional list of fields to return
-
-    Returns:
-        The requested :ref:`Event` details as a dictionary
-    """
-    return _get("/events/{0}/".format(event_id), params=dict(fields=fields))
-
-
-def search_districts(state, chamber, fields=None):
+def search_districts(state, chamber):
     """
     Search for districts
 
@@ -426,25 +380,25 @@ def search_districts(state, chamber, fields=None):
     Returns:
        A list of matching :ref:`District` dictionaries
     """
-    uri = "/districts/{}/".format(state.lower())
     if chamber:
         chamber = chamber.lower()
-        uri += "{}/".format(chamber)
         if chamber not in ["upper", "lower"]:
             raise ValueError('Chamber must be "upper" or "lower"')
-        return _get(uri, params=dict(fields=fields))
+        organizations = get_organizations(state=state)
+        for org in organizations:
+            if org['classification'] == chamber:
+                return org['districts']
 
 
-def get_district(boundary_id, fields=None):
-    """
-    Gets district details
+def _fix_id_string(prefix, id):
+    if id.startswith(prefix):
+        return id
+    else:
+        return prefix + id
 
-    Args:
-        boundary_id: The boundary ID
-        fields: Optionally specify a custom list of fields to return
 
-    Returns:
-        The requested :ref:`District` details as a dictionary
-    """
-    uri = "/districts/boundary/{0}/".format(boundary_id)
-    return _get(uri, params=dict(fields=fields))
+def _jurisdiction_id(state):
+    if state.startswith('ocd-jurisdiction/'):
+        return state
+    else:
+        return "ocd-jurisdiction/country:us/state:{0}/government".format(state.lower())
\ No newline at end of file


Test patch:
diff --git a/test_pyopenstates.py b/test_pyopenstates.py
index 1191d21..0432497 100644
--- a/test_pyopenstates.py
+++ b/test_pyopenstates.py
@@ -48,22 +48,17 @@ class Test(unittest.TestCase):
     def testStateMetadata(self):
         """All default state metadata fields are returned"""
         state_code = "NC"
-        fields = ['name', 'latest_csv_url', 'latest_csv_date', 'chambers',
-                  'capitol_timezone', 'id', 'latest_json_url',
-                  'session_details', 'terms','latest_json_date',
-                  'latest_update', 'abbreviation', 'legislature_name',
-                  'feature_flags', 'legislature_url']
+        fields = ['id', 'name', 'classification', 'division_id', 'url']
         metadata = pyopenstates.get_metadata(state_code)
         keys = metadata.keys()
         for field in fields:
             self.assertIn(field, keys)
-        self.assertEqual(metadata["abbreviation"], state_code.lower())
+        self.assertEqual(metadata["name"], "North Carolina")
 
     def testSubsetStateMetadataFields(self):
         """Requesting specific fields in state metadata returns only those
         fields"""
-        requested_fields = ["id", "latest_json_date", "latest_json_url",
-                            "latest_update"]
+        requested_fields = ["id", "name", "url"]
         metadata = pyopenstates.get_metadata("OH", fields=requested_fields)
         returned_fields = metadata.keys()
 
@@ -71,21 +66,29 @@ class Test(unittest.TestCase):
             self.assertIn(field, returned_fields)
         for field in returned_fields:
             self.assertIn(field, requested_fields)
+    
+    def testGetOrganizations(self):
+        """Get all organizations for a given state"""
+        state_code = "NC"
+        orgs = pyopenstates.get_organizations(state_code)
+        names = [org['name'] for org in orgs]
+        self.assertIn('North Carolina General Assembly', names)
+
+    # def testDownloadCSV(self):
+    #     """Downloading bulk data on a state in CSV format"""
+    #     zip_file = BytesIO()
+    #     pyopenstates.download_bulk_data("AK", zip_file, data_format="csv")
+    #     zip = ZipFile(zip_file)
+    #     for filename in zip.namelist():
+    #         self.assertTrue(filename.endswith(".csv"))
+
+    # def testDownloadJSON(self):
+    #     """Downloading bulk data on a state in JSON format"""
+    #     zip_file = BytesIO()
+    #     pyopenstates.download_bulk_data("AK", zip_file)
+    #     zip = ZipFile(zip_file)
+    #     self.assertIn("metadata.json", zip.namelist())
 
-    def testDownloadCSV(self):
-        """Downloading bulk data on a state in CSV format"""
-        zip_file = BytesIO()
-        pyopenstates.download_bulk_data("AK", zip_file, data_format="csv")
-        zip = ZipFile(zip_file)
-        for filename in zip.namelist():
-            self.assertTrue(filename.endswith(".csv"))
-
-    def testDownloadJSON(self):
-        """Downloading bulk data on a state in JSON format"""
-        zip_file = BytesIO()
-        pyopenstates.download_bulk_data("AK", zip_file)
-        zip = ZipFile(zip_file)
-        self.assertIn("metadata.json", zip.namelist())
 
     def testInvalidState(self):
         """Specifying an invalid state raises a NotFound exception"""
@@ -96,7 +99,7 @@ class Test(unittest.TestCase):
         """A basic full-text search returns results that contain the query
         string"""
         query = "taxi"
-        results = pyopenstates.search_bills(state="dc", q=query)
+        results = pyopenstates.search_bills(state="ny", q=query)
         self.assertGreater(len(results), 1)
         match = False
         for result in results:
@@ -107,26 +110,18 @@ class Test(unittest.TestCase):
 
     def testBillDetails(self):
         """Bill details"""
-        state = "ca"
-        term = "20092010"
-        bill_id = "AB 667"
-        title = "An act to amend Section 1750.1 of the Business and " \
-                "Professions Code, and to amend Section 104830 " \
-                "of, and to add Section 104762 to, the Health and Safety " \
-                "Code, relating to oral health."
+        state = "nc"
+        session = "2019"
+        bill_id = "HB 1105"
 
-        bill = pyopenstates.get_bill(state=state, term=term, bill_id=bill_id)
+        bill = pyopenstates.get_bill(state=state, session=session, bill_id=bill_id)
 
-        self.assertEqual(bill["bill_id"], bill_id)
-        self.assertEqual(bill["title"], title)
+        self.assertEqual(bill["identifier"], bill_id)
 
     def testBillDetailsByUID(self):
         """Bill details by UID"""
-        _id = "CAB00004148"
-        title = "An act to amend Section 1750.1 of the Business and " \
-                "Professions Code, and to amend Section 104830 " \
-                "of, and to add Section 104762 to, the Health and Safety " \
-                "Code, relating to oral health."
+        _id = "6dc08e5d-3d62-42c0-831d-11487110c800"
+        title = "Coronavirus Relief Act 3.0."
 
         bill = pyopenstates.get_bill(_id)
 
@@ -134,22 +129,25 @@ class Test(unittest.TestCase):
 
     def testBillDetailInputs(self):
         """Bill detail inputs"""
-        state = "ca"
-        term = "20092010"
-        bill_id = "AB 667"
-        _id = "CAB00004148"
+        state = "nc"
+        session = "2019"
+        bill_id = "HB 1105"
+        _id = "6dc08e5d-3d62-42c0-831d-11487110c800"
 
-        self.assertRaises(ValueError, pyopenstates.get_bill, _id, state, term,
+        self.assertRaises(ValueError, pyopenstates.get_bill, _id, state, session,
                           bill_id)
         self.assertRaises(ValueError, pyopenstates.get_bill, _id, state)
 
-    def testBillSearchSort(self):
-        """Sorting bill search results"""
-        sorted_bills = pyopenstates.search_bills(state="dc",
-                                                 search_window="term",
-                                                 sort="created_at")
-        self.assertGreater(sorted_bills[0]["created_at"],
-                           sorted_bills[-1]["created_at"])
+# with previous API versions, you could request 500 bills per page, but with v3,
+# can only request 20 per page. this test always hits the rate limit so not sure
+# this test makes sense anymore
+    # def testBillSearchSort(self):
+    #     """Sorting bill search results"""
+    #     sorted_bills = pyopenstates.search_bills(state="dc",
+    #                                              search_window="term",
+    #                                              sort="created_at")
+    #     self.assertGreater(sorted_bills[0]["created_at"],
+    #                        sorted_bills[-1]["created_at"])
 
     def testBillSearchMissingFilter(self):
         """Searching for bills with no filters raises APIError"""
@@ -167,53 +165,29 @@ class Test(unittest.TestCase):
 
     def testLegislatorDetails(self):
         """Legislator details"""
-        _id = "DCL000012"
-        full_name = "Marion Barry"
-        self.assertEqual(pyopenstates.get_legislator(_id)["full_name"],
-                         full_name)
+        _id = "adb58f21-f2fd-4830-85b6-f490b0867d14"
+        name = "Bryce E. Reeves"
+        self.assertEqual(pyopenstates.get_legislator(_id)["name"],
+                         name)
 
     def testLegislatorGeolocation(self):
         """Legislator geolocation"""
         lat = 35.79
-        long = -78.78
-        state = "nc"
-        results = pyopenstates.locate_legislators(lat, long)
+        lng = -78.78
+        state = "North Carolina"
+        results = pyopenstates.locate_legislators(lat, lng)
         self.assertGreater(len(results), 0)
         for legislator in results:
-            self.assertEqual(legislator["state"], state.lower())
-
-    def testCommitteeSearch(self):
-        """Committee search"""
-        state = "dc"
-        results = pyopenstates.search_committees(state=state)
-        self.assertGreater(len(results), 2)
-        for committee in results:
-            self.assertEqual(committee["state"], state.lower())
-
-    def testCommitteeDetails(self):
-        """Committee details"""
-        _id = "DCC000028"
-        comittee = "Transportation and the Environment"
-        self.assertEqual(pyopenstates.get_committee(_id)["committee"],
-                         comittee)
+            self.assertEqual(legislator["jurisdiction"]['name'], state)
 
     def testDistrictSearch(self):
         """District search"""
         state = "nc"
         chamber = "lower"
-        results = pyopenstates.search_districts(state=state, chamber=chamber)
+        results = pyopenstates.search_districts(state, chamber)
         self.assertGreater(len(results), 2)
         for district in results:
-            self.assertEqual(district["abbr"], state.lower())
-            self.assertEqual(district["chamber"], chamber.lower())
-
-    def testDistrictBoundary(self):
-        """District boundary details"""
-        boundary_id = "ocd-division/country:us/state:nc/sldl:10"
-        _id = "nc-lower-10"
-        boundry = pyopenstates.get_district(boundary_id)
-        self.assertEqual(boundry["boundary_id"], boundary_id)
-        self.assertEqual(boundry["id"], _id)
+            self.assertEqual(district["role"], 'Representative')
 
     def testTimestampConversionInList(self):
         """Timestamp conversion in a list"""