├── .travis.yml ├── three ├── __init__.py ├── api.py ├── cities.py └── core.py ├── requirements.txt ├── setup.py ├── LICENSE.md ├── README.md └── test.py /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.6" 4 | - "2.7" 5 | - "3.6" 6 | install: pip install -r requirements.txt 7 | script: python test.py 8 | -------------------------------------------------------------------------------- /three/__init__.py: -------------------------------------------------------------------------------- 1 | from . import core 2 | from .api import (key, city, cities, dev, discovery, post, request, 3 | requests, services, token) 4 | from .cities import CityNotFound 5 | from .core import Three 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | #------------------ 2 | # HTTP 3 | #------------------ 4 | requests>=1.0 5 | 6 | 7 | #------------------ 8 | # Parsing 9 | #------------------ 10 | relaxml 11 | simplejson 12 | 13 | 14 | #------------------ 15 | # Testing 16 | #------------------ 17 | mock 18 | responses 19 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Setup and installation for the package. 3 | """ 4 | 5 | try: 6 | from setuptools import setup 7 | except ImportError: 8 | from distutils.core import setup 9 | 10 | 11 | setup( 12 | name="three", 13 | version="0.8.0", 14 | url="http://github.com/codeforamerica/three", 15 | author="Zach Williams", 16 | author_email="hey@zachwill.com", 17 | description="An easy-to-use wrapper for the Open311 API", 18 | keywords=['three', 'open311', 'code for america'], 19 | packages=[ 20 | 'three' 21 | ], 22 | install_requires=[ 23 | 'mock', 24 | 'relaxml', 25 | 'requests >= 1.0', 26 | 'simplejson', 27 | ], 28 | license='MIT', 29 | classifiers=[ 30 | 'Development Status :: 1 - Planning', 31 | 'Intended Audience :: Developers', 32 | 'Natural Language :: English', 33 | 'License :: OSI Approved :: MIT License', 34 | 'Programming Language :: Python', 35 | 'Programming Language :: Python :: 2.6', 36 | 'Programming Language :: Python :: 2.7', 37 | ], 38 | ) 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012, Code for America 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | * Neither the name of Code for America nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 9 | 10 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 11 | -------------------------------------------------------------------------------- /three/api.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simple, top-level functions for working with the Open311 API. 3 | """ 4 | 5 | import os 6 | from simplejson import dumps 7 | 8 | from .cities import find_info 9 | from .core import Three 10 | 11 | 12 | def key(key=None): 13 | """ 14 | Save your API key to the global environment. 15 | 16 | >>> three.api_key('my_api_key') 17 | """ 18 | if key: 19 | os.environ['OPEN311_API_KEY'] = key 20 | return os.environ['OPEN311_API_KEY'] 21 | 22 | 23 | def city(name=None): 24 | """ 25 | Store the city that will be queried against. 26 | 27 | >>> three.city('sf') 28 | """ 29 | info = find_info(name) 30 | os.environ['OPEN311_CITY_INFO'] = dumps(info) 31 | return Three(**info) 32 | 33 | 34 | def cities(): 35 | """Return a list of available cities.""" 36 | info = find_info() 37 | return info 38 | 39 | 40 | def dev(endpoint, **kwargs): 41 | """ 42 | Use an endpoint and any additional keyword arguments rather than one 43 | of the pre-defined cities. Similar to the `city` function, but useful for 44 | development. 45 | """ 46 | kwargs['endpoint'] = endpoint 47 | os.environ['OPEN311_CITY_INFO'] = dumps(kwargs) 48 | return Three(**kwargs) 49 | 50 | 51 | def discovery(path=None, **kwargs): 52 | """ 53 | Check a city's Open311 discovery endpoint. 54 | 55 | >>> three.city('sf') 56 | >>> three.discovery() 57 | """ 58 | return Three().discovery(path, **kwargs) 59 | 60 | 61 | def post(code=None, **kwargs): 62 | """ 63 | Send a POST service request to a city's Open311 endpoint. 64 | 65 | >>> three.city('sf') 66 | >>> three.post('123', address='155 9th St', name='Zach Williams', 67 | ... phone='555-5555', description='My issue description'.) 68 | {'successful': {'request': 'post'}} 69 | """ 70 | return Three().post(code, **kwargs) 71 | 72 | 73 | def request(code, **kwargs): 74 | """ 75 | Find a specific request in a city. 76 | 77 | >>> three.city('sf') 78 | >>> three.request('12345') 79 | """ 80 | return Three().request(code, **kwargs) 81 | 82 | 83 | def requests(code=None, **kwargs): 84 | """ 85 | Find service requests for a city. 86 | 87 | >>> three.city('sf') 88 | >>> three.requests() 89 | """ 90 | return Three().requests(code, **kwargs) 91 | 92 | 93 | def services(code=None, **kwargs): 94 | """ 95 | Find services for a given city. 96 | 97 | >>> three.city('sf') 98 | >>> three.services() 99 | """ 100 | return Three().services(code, **kwargs) 101 | 102 | 103 | def token(code, **kwargs): 104 | """ 105 | Find service request information for a specific token. 106 | 107 | >>> three.city('sf') 108 | >>> three.token('123abc') 109 | """ 110 | return Three().token(code, **kwargs) 111 | -------------------------------------------------------------------------------- /three/cities.py: -------------------------------------------------------------------------------- 1 | """ 2 | A dict of information needed to query city Open311 servers. 3 | """ 4 | 5 | 6 | class CityNotFound(Exception): 7 | pass 8 | 9 | 10 | def find_info(name=None): 11 | """Find the needed city server information.""" 12 | if not name: 13 | return list(servers.keys()) 14 | name = name.lower() 15 | if name in servers: 16 | info = servers[name] 17 | else: 18 | raise CityNotFound("Could not find the specified city: %s" % name) 19 | return info 20 | 21 | 22 | servers = { 23 | 'bainbridge': { 24 | 'endpoint': 'http://seeclickfix.com/bainbridge-island/open311/' 25 | }, 26 | 'baltimore': { 27 | 'endpoint': 'http://311.baltimorecity.gov/open311/v2/' 28 | }, 29 | 'bloomington': { 30 | 'endpoint': 'https://bloomington.in.gov/crm/open311/v2/' 31 | }, 32 | 'boston': { 33 | 'endpoint': 'https://mayors24.cityofboston.gov/open311/v2/' 34 | }, 35 | 'brookline': { 36 | 'endpoint': 'http://spot.brooklinema.gov/open311/v2/' 37 | }, 38 | 'chicago': { 39 | 'endpoint': 'http://311api.cityofchicago.org/open311/v2/', 40 | 'discovery': 'http://311api.cityofchicago.org/open311/discovery.json' 41 | }, 42 | 'corona': { 43 | 'endpoint': 'http://seeclickfix.com/corona/open311/' 44 | }, 45 | 'darwin': { 46 | 'endpoint': 'http://seeclickfix.com/aus_darwin/open311/' 47 | }, 48 | 'dc': { 49 | 'endpoint': 'http://app.311.dc.gov/CWI/Open311/v2/', 50 | 'format': 'xml', 51 | 'jurisdiction': 'dc.gov' 52 | }, 53 | 'district of columbia': { 54 | 'endpoint': 'http://app.311.dc.gov/CWI/Open311/v2/', 55 | 'format': 'xml', 56 | 'jurisdiction': 'dc.gov' 57 | }, 58 | 'deleon': { 59 | 'endpoint': 'http://seeclickfix.com/de-leon/open311/' 60 | }, 61 | 'dunwoody': { 62 | 'endpoint': 'http://seeclickfix.com/dunwoody_ga/open311/' 63 | }, 64 | 'fontana': { 65 | 'endpoint': 'http://seeclickfix.com/fontana/open311/' 66 | }, 67 | 'grand rapids': { 68 | 'endpoint': 'http://grcity.spotreporters.com/open311/v2/' 69 | }, 70 | 'hillsborough': { 71 | 'endpoint': 'http://seeclickfix.com/hillsborough/open311/' 72 | }, 73 | 'howard county': { 74 | 'endpoint': 'http://seeclickfix.com/md_howard-county/open311/' 75 | }, 76 | 'huntsville': { 77 | 'endpoint': 'http://seeclickfix.com/huntsville/open311/' 78 | }, 79 | 'macon': { 80 | 'endpoint': 'http://seeclickfix.com/macon/open311/' 81 | }, 82 | 'manor': { 83 | 'endpoint': 'http://seeclickfix.com/manor/open311/' 84 | }, 85 | 'new haven': { 86 | 'endpoint': 'http://seeclickfix.com/new-haven/open311/' 87 | }, 88 | 'newark': { 89 | 'endpoint': 'http://seeclickfix.com/newark_2/open311/' 90 | }, 91 | 'newberg': { 92 | 'endpoint': 'http://seeclickfix.com/newberg/open311/' 93 | }, 94 | 'newnan': { 95 | 'endpoint': 'http://seeclickfix.com/newnan/open311/' 96 | }, 97 | 'olathe': { 98 | 'endpoint': 'http://seeclickfix.com/olathe/open311/' 99 | }, 100 | 'raleigh': { 101 | 'endpoint': 'http://seeclickfix.com/raleigh/open311/' 102 | }, 103 | 'richmond': { 104 | 'endpoint': 'http://seeclickfix.com/richmond/open311/' 105 | }, 106 | 'roosevelt island': { 107 | 'endpoint': 'http://seeclickfix.com/roosevelt-island/open311/' 108 | }, 109 | 'russell springs': { 110 | 'endpoint': 'http://seeclickfix.com/russell-springs/open311/' 111 | }, 112 | 'san francisco': { 113 | 'endpoint': 'https://open311.sfgov.org/V2/', 114 | 'format': 'xml', 115 | 'jurisdiction': 'sfgov.org' 116 | }, 117 | 'sf': { 118 | 'endpoint': 'https://open311.sfgov.org/V2/', 119 | 'format': 'xml', 120 | 'jurisdiction': 'sfgov.org' 121 | }, 122 | 'toronto': { 123 | 'endpoint': 'https://secure.toronto.ca/webwizard/ws/', 124 | 'jurisdiction': 'toronto.ca' 125 | }, 126 | 'tucson': { 127 | 'endpoint': 'http://seeclickfix.com/tucson/open311/' 128 | }, 129 | } 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Three 2 | ===== 3 | 4 | [![Build Status](https://secure.travis-ci.org/codeforamerica/three.png?branch=master)](http://travis-ci.org/codeforamerica/three) 5 | 6 | An updated [Open311 API](http://wiki.open311.org/GeoReport_v2) Python wrapper 7 | that was built to be as absolute **user-friendly** and **easy-to-use as 8 | possible**. Many of the design decisions made will reflect these 9 | qualities. 10 | 11 | 12 | Installation 13 | ------------ 14 | 15 | The best way to install is through `pip`. 16 | 17 | pip install three 18 | 19 | 20 | At A Glance 21 | ----------- 22 | 23 | `Three` was made to make the differences in Open311 GeoReport 24 | implementations completely unknown to the end user. Interacting with 25 | service requests should be easy. 26 | 27 | ```python 28 | >>> import three 29 | 30 | >>> three.cities() 31 | ['boston', 'macon', 'sf', ...] 32 | 33 | # Let's start off with Macon. 34 | >>> three.city('macon') 35 | >>> three.key('my_macon_api_key') 36 | 37 | >>> three.discovery() 38 | {'service': {'discovery': 'data'}} 39 | 40 | >>> three.services() 41 | {'macon': {'service': 'data'}} 42 | 43 | >>> three.services('033') 44 | {'033': {'service_code': 'info'}} 45 | 46 | >>> three.requests() 47 | {'macon': {'request': 'data'}} 48 | 49 | >>> three.requests('123') 50 | {'123': {'request': 'data'}} 51 | 52 | # Now, let's switch it up to San Francisco. 53 | >>> three.city('sf') 54 | >>> three.key('my_sf_api_key') 55 | 56 | >>> three.services() 57 | {'SF': {'service': 'data'}} 58 | 59 | >>> three.requests() 60 | {'SF': {'request': 'data'}} 61 | 62 | # And, finally Baltimore. 63 | >>> three.city('baltimore') 64 | >>> three.key('baltimore_api_key') 65 | 66 | >>> three.services() 67 | {'baltimore': {'service': 'data'}} 68 | 69 | >>> three.requests() 70 | {'baltimore': {'request': 'data'}} 71 | ``` 72 | 73 | `Three` also aims to make working with dates and result counts easier, even 74 | though not all Open311 implementations support these features. 75 | 76 | ```python 77 | >>> import three 78 | 79 | >>> three.city('macon') 80 | 81 | >>> # Let's grab requests between certain dates. 82 | ... three.requests(start='03-10-2012', end='03-17-2012') 83 | 84 | >>> # But let's use the between parameter. 85 | ... three.requests(between=['03-10-2012', '03-17-2012']) 86 | 87 | >>> # And, let's get all the requests! (Or, as many as possible...) 88 | ... three.requests(between=['03-10-2012', '03-17-2012'], count=100) 89 | 90 | >>> # We could even get requests of different types between those days. 91 | >>> requests = [] 92 | >>> dates = ['03-10-2012', '03-17-2012'] 93 | >>> requests.extend(three.requests(between=dates, count=100)) 94 | >>> requests.extend(three.requests(between=dates, count=100, status="closed")) 95 | ``` 96 | 97 | 98 | Subclassing 99 | ----------- 100 | 101 | A `Three` class can also be imported and customized, but, for casual 102 | users, working with the `three` module should feel effortless. Any pain 103 | points (such as dealing with XML, required parameters, etc.) should be 104 | abstracted away. 105 | 106 | ```python 107 | from three import Three 108 | 109 | class SF(Three): 110 | def __init__(self): 111 | super(SF, self).__init__() 112 | self.endpoint = "https://open311.sfgov.org/dev/V2/" 113 | self.format = "xml" 114 | self.jurisdiction = "sfgov.org" 115 | ``` 116 | 117 | You could then use the `SF` class just as you would an instance of 118 | `Three`. 119 | 120 | ```python 121 | >>> SF().services() 122 | 123 | >>> SF().requests() 124 | ``` 125 | 126 | 127 | Settings 128 | -------- 129 | 130 | These settings apply to the core `Three` class. 131 | 132 | A casual user of the Open311 API, by default, should not have to work 133 | with the `Three` class. 134 | 135 | ### API Key 136 | 137 | If you have an Open311 API key that you always intend to use, rather 138 | than initializing the `Three` class with it each time, you can set an 139 | `OPEN311_API_KEY` environment variable on the command line. 140 | 141 | export OPEN311_API_KEY="MY_API_KEY" 142 | 143 | Otherwise, you can initialize the class with your API key and endpoint. 144 | 145 | >>> from three import Three 146 | >>> t = Three('api.city.gov', api_key='my_api_key') 147 | 148 | 149 | ### HTTPS 150 | 151 | By default, `Three` will configure a URL without a specified schema to 152 | use `HTTPS`. 153 | 154 | >>> t = Three('api.city.gov') 155 | >>> t.endpoint == 'https://api.city.gov/' 156 | True 157 | 158 | 159 | ### Format 160 | 161 | The default format for the `Three` wrapper is `JSON` -- although not all 162 | [Open311 implementations support it](http://wiki.open311.org/GeoReport_v2#Format_Support). 163 | This is done mainly for ease-of-use (remember, that's the over-arching 164 | goal of the `Three` wrapper). You can, however, specifically request to 165 | use `XML` as your format of choice. 166 | 167 | >>> t = Three('api.city.gov', format='xml') 168 | >>> t.format == 'xml' 169 | True 170 | 171 | 172 | ### SSL/TLS version 173 | 174 | With certain combinations of the client operating system and the server application, the SSL/TLS negotiation may fail. Forcing Three to use TLS version 1.0 may help in these cases. 175 | 176 | >>> import ssl 177 | >>> t = Three('https://api.city.gov', ssl_version=ssl.PROTOCOL_TLSv1) 178 | 179 | 180 | Usage 181 | ----- 182 | 183 | ### Configure 184 | 185 | After you've initialized your `Three` class, you can readjust its 186 | settings with the `configure` method. You can also switch back to the 187 | orgininal settings with the `reset` method. 188 | 189 | >>> from three import Three 190 | >>> import ssl 191 | >>> t = Three('api.city.gov', api_key='SECRET_KEY') 192 | >>> t.services() 193 | {'service': 'data'} 194 | 195 | >>> t.configure('open311.sfgov.org/dev/V2/', format='xml', 196 | ... api_key='SF_OPEN311_API_KEY', 197 | ... ssl_version=ssl.PROTOCOL_TLSv1) 198 | >>> t.services() 199 | {'SF': {'service': 'data'}} 200 | 201 | >>> t.configure(api_key='ANOTHER_API_KEY') 202 | >>> # Switch back to original settings. 203 | ... t.reset() 204 | 205 | 206 | ### Discovery 207 | 208 | In order to use the [Open311 service discovery](http://wiki.open311.org/Service_Discovery), 209 | simply invoke the `discovery` method. 210 | 211 | >>> t = Three('api.city.gov') 212 | >>> t.discovery() 213 | {'service': {'discovery': 'data'}} 214 | 215 | Sometimes, however, service discovery paths differ from service and 216 | request URL paths -- in which case you can pass the specified URL to the 217 | `discovery` method as an argument. 218 | 219 | >>> t.discovery('http://another.path.gov/discovery.json') 220 | 221 | 222 | ### Services 223 | 224 | To see the available services provided by an Open311 implementation, use 225 | the `services` method. 226 | 227 | >>> t = Three('api.city.gov') 228 | >>> t.services() 229 | {'all': {'service_code': 'info'}} 230 | 231 | You can also specify a specific service code to get information about. 232 | 233 | >>> t.services('033') 234 | {'033': {'service_code': 'info'}} 235 | 236 | 237 | ### Requests 238 | 239 | To see available request data, use the `requests` method. 240 | 241 | >>> t = Three('api.city.gov') 242 | >>> t.requests() 243 | {'all': {'requests': 'data'}} 244 | 245 | [Most Open311 246 | implementations](http://lists.open311.org/groups/discuss/messages/topic/2y4jI0eZulj9aZTVS3JgAj) 247 | support `page` and `page_size` parameters. 248 | 249 | >>> t.requests(page_size=50) 250 | {'total': {'of': {'50': 'requests'}}} 251 | 252 | >>> t.requests(page=2, page_size=50) 253 | {'next': {'50': 'results'}} 254 | 255 | You can also specify a specific service code. 256 | 257 | >>> t.requests('123') 258 | {'123': {'requests': 'data'}} 259 | 260 | Other parameters can also be passed as keyword arguments. 261 | 262 | >>> t.requests('456', status='open') 263 | {'456': {'open': {'requests': 'data'}}} 264 | 265 | 266 | ### Request 267 | 268 | If you're looking for information on a specific Open311 request (and you 269 | have it's service code ID), you can use the `request` method. 270 | 271 | >>> t = Three('api.city.gov') 272 | >>> t.request('12345') 273 | {'request': {'service_code_id': {'12345': 'data'}}} 274 | 275 | 276 | ### Post 277 | 278 | Sometimes you might need to programmatically create a new request, which 279 | is what the `post` method can be used for. **NOTE**: the Open311 spec 280 | states that all POST service requests [require a valid API 281 | key](http://wiki.open311.org/GeoReport_v2#POST_Service_Request). 282 | 283 | >>> t = Three('api.city.gov', api_key='SECRET_KEY') 284 | >>> t.post('123', name='Zach Williams', address='85 2nd St', 285 | ... description='New service code 123 request.', 286 | ... email='zach@codeforamerica.org') 287 | {'new': {'request': 'created'}} 288 | 289 | 290 | ### Token 291 | 292 | Each service request ID can be tracked with a temporary token. If you 293 | need to find the service request ID and have the request's token, you 294 | can use the `token` method. 295 | 296 | >>> t = Three('api.city.gov') 297 | >>> t.token('12345') 298 | {'service_request_id': {'for': {'token': '12345'}}} 299 | -------------------------------------------------------------------------------- /three/core.py: -------------------------------------------------------------------------------- 1 | """ 2 | A new Python wrapper for interacting with the Open311 API. 3 | """ 4 | 5 | import os 6 | import re 7 | from collections import defaultdict 8 | from datetime import date 9 | 10 | 11 | import requests 12 | from relaxml import xml 13 | import simplejson as json 14 | 15 | try: 16 | # Python 2 17 | from future_builtins import filter 18 | except ImportError: 19 | # Python 3 20 | pass 21 | 22 | 23 | class SSLAdapter(requests.adapters.HTTPAdapter): 24 | """An HTTPS Transport Adapter that uses an arbitrary SSL version.""" 25 | def __init__(self, ssl_version=None, **kwargs): 26 | self.ssl_version = ssl_version 27 | super(SSLAdapter, self).__init__(**kwargs) 28 | 29 | def init_poolmanager(self, connections, maxsize, block): 30 | self.poolmanager = requests.packages.urllib3.poolmanager.PoolManager( 31 | num_pools=connections, 32 | maxsize=maxsize, 33 | block=block, 34 | ssl_version=self.ssl_version) 35 | 36 | 37 | class Three(object): 38 | """The main class for interacting with the Open311 API.""" 39 | 40 | def __init__(self, endpoint=None, **kwargs): 41 | keywords = defaultdict(str) 42 | keywords.update(kwargs) 43 | if endpoint: 44 | endpoint = self._configure_endpoint(endpoint) 45 | keywords['endpoint'] = endpoint 46 | elif 'OPEN311_CITY_INFO' in os.environ: 47 | info = json.loads(os.environ['OPEN311_CITY_INFO']) 48 | endpoint = info['endpoint'] 49 | endpoint = self._configure_endpoint(endpoint) 50 | keywords.update(info) 51 | keywords['endpoint'] = endpoint 52 | self._keywords = keywords 53 | self.configure() 54 | 55 | def _global_api_key(self): 56 | """ 57 | If a global Open311 API key is available as an environment variable, 58 | then it will be used when querying. 59 | """ 60 | if 'OPEN311_API_KEY' in os.environ: 61 | api_key = os.environ['OPEN311_API_KEY'] 62 | else: 63 | api_key = '' 64 | return api_key 65 | 66 | def configure(self, endpoint=None, **kwargs): 67 | """Configure a previously initialized instance of the class.""" 68 | if endpoint: 69 | kwargs['endpoint'] = endpoint 70 | keywords = self._keywords.copy() 71 | keywords.update(kwargs) 72 | if 'endpoint' in kwargs: 73 | # Then we need to correctly format the endpoint. 74 | endpoint = kwargs['endpoint'] 75 | keywords['endpoint'] = self._configure_endpoint(endpoint) 76 | self.api_key = keywords['api_key'] or self._global_api_key() 77 | self.endpoint = keywords['endpoint'] 78 | self.format = keywords['format'] or 'json' 79 | self.jurisdiction = keywords['jurisdiction'] 80 | self.proxy = keywords['proxy'] 81 | self.discovery_url = keywords['discovery'] or None 82 | 83 | # Use a custom requests session and set the correct SSL version if 84 | # specified. 85 | self.session = requests.Session() 86 | if 'ssl_version' in keywords: 87 | self.session.mount('https://', SSLAdapter(keywords['ssl_version'])) 88 | 89 | def _configure_endpoint(self, endpoint): 90 | """Configure the endpoint with a schema and end slash.""" 91 | if not endpoint.startswith('http'): 92 | endpoint = 'https://' + endpoint 93 | if not endpoint.endswith('/'): 94 | endpoint += '/' 95 | return endpoint 96 | 97 | def reset(self): 98 | """Reset the class back to the original keywords and values.""" 99 | self.configure() 100 | 101 | def _create_path(self, *args): 102 | """Create URL path for endpoint and args.""" 103 | args = filter(None, args) 104 | path = self.endpoint + '/'.join(args) + '.%s' % (self.format) 105 | return path 106 | 107 | def get(self, *args, **kwargs): 108 | """Perform a get request.""" 109 | if 'convert' in kwargs: 110 | conversion = kwargs.pop('convert') 111 | else: 112 | conversion = True 113 | kwargs = self._get_keywords(**kwargs) 114 | url = self._create_path(*args) 115 | request = self.session.get(url, params=kwargs) 116 | content = request.content 117 | self._request = request 118 | return self.convert(content, conversion) 119 | 120 | def _get_keywords(self, **kwargs): 121 | """Format GET request parameters and keywords.""" 122 | if self.jurisdiction and 'jurisdiction_id' not in kwargs: 123 | kwargs['jurisdiction_id'] = self.jurisdiction 124 | if 'count' in kwargs: 125 | kwargs['page_size'] = kwargs.pop('count') 126 | if 'start' in kwargs: 127 | start = kwargs.pop('start') 128 | if 'end' in kwargs: 129 | end = kwargs.pop('end') 130 | else: 131 | end = date.today().strftime('%m-%d-%Y') 132 | start, end = self._format_dates(start, end) 133 | kwargs['start_date'] = start 134 | kwargs['end_date'] = end 135 | elif 'between' in kwargs: 136 | start, end = kwargs.pop('between') 137 | start, end = self._format_dates(start, end) 138 | kwargs['start_date'] = start 139 | kwargs['end_date'] = end 140 | return kwargs 141 | 142 | def _format_dates(self, start, end): 143 | """Format start and end dates.""" 144 | start = self._split_date(start) 145 | end = self._split_date(end) 146 | return start, end 147 | 148 | def _split_date(self, time): 149 | """Split apart a date string.""" 150 | if isinstance(time, str): 151 | month, day, year = [int(t) for t in re.split(r'-|/', time)] 152 | if year < 100: 153 | # Quick hack for dates < 2000. 154 | year += 2000 155 | time = date(year, month, day) 156 | return time.strftime('%Y-%m-%dT%H:%M:%SZ') 157 | 158 | def convert(self, content, conversion): 159 | """Convert content to Python data structures.""" 160 | if not conversion: 161 | data = content 162 | elif self.format == 'json': 163 | data = json.loads(content) 164 | elif self.format == 'xml': 165 | content = xml(content) 166 | first = list(content.keys())[0] 167 | data = content[first] 168 | else: 169 | data = content 170 | return data 171 | 172 | def discovery(self, url=None): 173 | """ 174 | Retrieve the standard discovery file that provides routing 175 | information. 176 | 177 | >>> Three().discovery() 178 | {'discovery': 'data'} 179 | """ 180 | if url: 181 | data = self.session.get(url).content 182 | elif self.discovery_url: 183 | response = self.session.get(self.discovery_url) 184 | if self.format == 'xml': 185 | # Because, SF doesn't follow the spec. 186 | data = xml(response.text) 187 | else: 188 | # Spec calls for discovery always allowing JSON. 189 | data = response.json() 190 | else: 191 | data = self.get('discovery') 192 | return data 193 | 194 | def services(self, code=None, **kwargs): 195 | """ 196 | Retrieve information about available services. You can also enter a 197 | specific service code argument. 198 | 199 | >>> Three().services() 200 | {'all': {'service_code': 'data'}} 201 | >>> Three().services('033') 202 | {'033': {'service_code': 'data'}} 203 | """ 204 | data = self.get('services', code, **kwargs) 205 | return data 206 | 207 | def requests(self, code=None, **kwargs): 208 | """ 209 | Retrieve open requests. You can also enter a specific service code 210 | argument. 211 | 212 | >>> Three('api.city.gov').requests() 213 | {'all': {'requests': 'data'}} 214 | >>> Three('api.city.gov').requests('123') 215 | {'123': {'requests': 'data'}} 216 | """ 217 | if code: 218 | kwargs['service_code'] = code 219 | data = self.get('requests', **kwargs) 220 | return data 221 | 222 | def request(self, id, **kwargs): 223 | """ 224 | Retrieve a specific request using its service code ID. 225 | 226 | >>> Three('api.city.gov').request('12345') 227 | {'request': {'service_code': {'12345': 'data'}}} 228 | """ 229 | data = self.get('requests', id, **kwargs) 230 | return data 231 | 232 | def post(self, service_code='0', **kwargs): 233 | """ 234 | Post a new Open311 request. 235 | 236 | >>> t = Three('api.city.gov') 237 | >>> t.post('123', address='123 Any St', name='Zach Williams', 238 | ... phone='555-5555', description='My issue description.', 239 | ... media=open('photo.png', 'rb')) 240 | {'successful': {'request': 'post'}} 241 | """ 242 | kwargs['service_code'] = service_code 243 | kwargs = self._post_keywords(**kwargs) 244 | media = kwargs.pop('media', None) 245 | if media: 246 | files = {'media': media} 247 | else: 248 | files = None 249 | url = self._create_path('requests') 250 | self.post_response = self.session.post(url, 251 | data=kwargs, files=files) 252 | content = self.post_response.content 253 | if self.post_response.status_code >= 500: 254 | conversion = False 255 | else: 256 | conversion = True 257 | return self.convert(content, conversion) 258 | 259 | def _post_keywords(self, **kwargs): 260 | """Configure keyword arguments for Open311 POST requests.""" 261 | if self.jurisdiction and 'jurisdiction_id' not in kwargs: 262 | kwargs['jurisdiction_id'] = self.jurisdiction 263 | if 'address' in kwargs: 264 | address = kwargs.pop('address') 265 | kwargs['address_string'] = address 266 | if 'name' in kwargs: 267 | first, last = kwargs.pop('name').split(' ') 268 | kwargs['first_name'] = first 269 | kwargs['last_name'] = last 270 | if 'api_key' not in kwargs: 271 | kwargs['api_key'] = self.api_key 272 | return kwargs 273 | 274 | def token(self, id, **kwargs): 275 | """ 276 | Retrieve a service request ID from a token. 277 | 278 | >>> Three('api.city.gov').token('12345') 279 | {'service_request_id': {'for': {'token': '12345'}}} 280 | """ 281 | data = self.get('tokens', id, **kwargs) 282 | return data 283 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for the Three Open311 API wrapper. 3 | """ 4 | 5 | import os 6 | import json 7 | import unittest 8 | from datetime import date 9 | from mock import Mock, MagicMock, patch 10 | 11 | import three 12 | import responses 13 | from three import core, Three, CityNotFound 14 | from three.core import requests as req 15 | 16 | 17 | class ThreeInit(unittest.TestCase): 18 | 19 | def test_uninitialized_api_key(self): 20 | self.assertEqual(Three().api_key, '') 21 | 22 | def test_global_api_key(self): 23 | os.environ['OPEN311_API_KEY'] = 'OHAI' 24 | self.assertEqual(Three().api_key, 'OHAI') 25 | 26 | def test_default_format_is_json(self): 27 | self.assertEqual(Three().format, 'json') 28 | 29 | def test_format_can_be_set_to_xml(self): 30 | t = Three(format='xml') 31 | self.assertEqual(t.format, 'xml') 32 | 33 | def test_first_argument_is_endpoint(self): 34 | t = Three('api.city.gov') 35 | self.assertEqual(t.endpoint, 'https://api.city.gov/') 36 | 37 | def test_reset_method_reconfigures_defaults(self): 38 | t = Three('foo.bar') 39 | self.assertEqual(t.endpoint, 'https://foo.bar/') 40 | t.configure(endpoint='bar.bar') 41 | self.assertEqual(t.endpoint, 'https://bar.bar/') 42 | t.configure(endpoint='http://baz.bar') 43 | self.assertEqual(t.endpoint, 'http://baz.bar/') 44 | t.reset() 45 | self.assertEqual(t.endpoint, 'https://foo.bar/') 46 | 47 | def test_ssl_version(self): 48 | import ssl 49 | t = Three('foo.bar', ssl_version=ssl.PROTOCOL_TLSv1) 50 | poolmanager = t.session.adapters['https://'].poolmanager 51 | self.assertEqual(poolmanager.connection_pool_kw['ssl_version'], 52 | ssl.PROTOCOL_TLSv1) 53 | 54 | def tearDown(self): 55 | os.environ['OPEN311_API_KEY'] = '' 56 | 57 | 58 | @patch.object(req, 'Session', Mock()) 59 | class ThreeDiscovery(unittest.TestCase): 60 | 61 | def setUp(self): 62 | core.json = Mock() 63 | 64 | def test_default_discovery_method(self): 65 | t = Three('api.city.gov') 66 | t.discovery() 67 | expected = 'https://api.city.gov/discovery.json' 68 | t.session.get.assert_called_with(expected, params={}) 69 | 70 | def test_discovery_url_argument(self): 71 | t = Three('api.city.gov') 72 | t.discovery('http://testing.gov/discovery.json') 73 | t.session.get.assert_called_with('http://testing.gov/discovery.json') 74 | 75 | def test_city_discovery_keyword(self): 76 | t = Three('api.chicago.city', discovery='http://chi.api.gov') 77 | self.assertEqual(t.discovery_url, 'http://chi.api.gov') 78 | 79 | 80 | @patch.object(req, 'Session', Mock()) 81 | class ThreeServices(unittest.TestCase): 82 | 83 | def setUp(self): 84 | core.json = Mock() 85 | 86 | def test_empty_services_call(self): 87 | t = Three('api.city.gov') 88 | t.services() 89 | expected = 'https://api.city.gov/services.json' 90 | t.session.get.assert_called_with(expected, params={}) 91 | 92 | def test_specific_service_code(self): 93 | t = Three('api.city.gov') 94 | t.services('123') 95 | expected = 'https://api.city.gov/services/123.json' 96 | t.session.get.assert_called_with(expected, params={}) 97 | 98 | def test_keyword_arguments_become_parameters(self): 99 | t = Three('api.city.gov') 100 | t.services('123', foo='bar') 101 | params = {'foo': 'bar'} 102 | expected = 'https://api.city.gov/services/123.json' 103 | t.session.get.assert_called_with(expected, params=params) 104 | 105 | 106 | @patch.object(req, 'Session', Mock()) 107 | class ThreeRequests(unittest.TestCase): 108 | 109 | def setUp(self): 110 | core.json = Mock() 111 | 112 | def test_empty_requests_call(self): 113 | t = Three('api.city.gov') 114 | t.requests() 115 | expected = 'https://api.city.gov/requests.json' 116 | t.session.get.assert_called_with(expected, params={}) 117 | 118 | def test_requests_call_with_service_code(self): 119 | t = Three('api.city.gov') 120 | t.requests('123') 121 | params = {'service_code': '123'} 122 | expected = 'https://api.city.gov/requests.json' 123 | t.session.get.assert_called_with(expected, params=params) 124 | 125 | def test_requests_with_additional_keyword_arguments(self): 126 | t = Three('api.city.gov') 127 | t.requests('123', status='open') 128 | params = {'service_code': '123', 'status': 'open'} 129 | expected = 'https://api.city.gov/requests.json' 130 | t.session.get.assert_called_with(expected, params=params) 131 | 132 | 133 | @patch.object(req, 'Session', Mock()) 134 | class ThreeRequest(unittest.TestCase): 135 | 136 | def setUp(self): 137 | core.json = Mock() 138 | 139 | def test_getting_a_specific_request(self): 140 | t = Three('api.city.gov') 141 | t.request('123') 142 | expected = 'https://api.city.gov/requests/123.json' 143 | t.session.get.assert_called_with(expected, params={}) 144 | 145 | def test_start_and_end_keyword_arguments(self): 146 | t = Three('api.city.gov') 147 | t.request('456', start='03-01-2010', end='03-05-2010') 148 | expected = 'https://api.city.gov/requests/456.json' 149 | params = { 150 | 'start_date': '2010-03-01T00:00:00Z', 151 | 'end_date': '2010-03-05T00:00:00Z' 152 | } 153 | t.session.get.assert_called_with(expected, params=params) 154 | 155 | def test_only_start_keyword_arguments(self): 156 | t = Three('api.city.gov') 157 | t.request('456', start='03-01-2010') 158 | end_date = date.today().strftime('%Y-%m-%dT00:00:00Z') 159 | expected = 'https://api.city.gov/requests/456.json' 160 | params = { 161 | 'start_date': '2010-03-01T00:00:00Z', 162 | 'end_date': end_date 163 | } 164 | t.session.get.assert_called_with(expected, params=params) 165 | 166 | def test_between_keyword_argument(self): 167 | t = Three('api.city.gov') 168 | t.request('789', between=['03-01-2010', '03-05-2010']) 169 | expected = 'https://api.city.gov/requests/789.json' 170 | params = { 171 | 'start_date': '2010-03-01T00:00:00Z', 172 | 'end_date': '2010-03-05T00:00:00Z' 173 | } 174 | t.session.get.assert_called_with(expected, params=params) 175 | 176 | def test_shortened_between_keyword(self): 177 | t = Three('api.city.gov') 178 | dates = ('03-01-10', '03-05-10') 179 | t.request('123', between=dates) 180 | expected = 'https://api.city.gov/requests/123.json' 181 | params = { 182 | 'start_date': '2010-03-01T00:00:00Z', 183 | 'end_date': '2010-03-05T00:00:00Z' 184 | } 185 | t.session.get.assert_called_with(expected, params=params) 186 | 187 | def test_between_can_handle_datetimes(self): 188 | t = Three('api.city.gov') 189 | dates = (date(2010, 3, 10), date(2010, 3, 15)) 190 | t.request('123', between=dates) 191 | expected = 'https://api.city.gov/requests/123.json' 192 | params = { 193 | 'start_date': '2010-03-10T00:00:00Z', 194 | 'end_date': '2010-03-15T00:00:00Z' 195 | } 196 | t.session.get.assert_called_with(expected, params=params) 197 | 198 | @responses.activate 199 | @patch.object(req, 'Session', Mock()) 200 | class ThreePost(unittest.TestCase): 201 | 202 | def setUp(self): 203 | core.json = Mock() 204 | 205 | def test_a_default_post(self): 206 | responses.add(responses.POST, 'https://api.city.gov/requests.json', 207 | body="""[ 208 | { 209 | "service_request_id":293944, 210 | "service_notice":"The City will inspect and require the responsible party to correct within 24 hours and/or issue a Correction Notice or Notice of Violation of the Public Works Code", 211 | "account_id":null 212 | } 213 | ]""", 214 | status=201, 215 | content_type='application/json') 216 | 217 | t = Three('api.city.gov', api_key='my_api_key') 218 | resp = t.post('123', name='Zach Williams', address='85 2nd Street') 219 | params = {'first_name': 'Zach', 'last_name': 'Williams', 220 | 'service_code': '123', 'address_string': '85 2nd Street', 221 | 'api_key': 'my_api_key'} 222 | 223 | assert resp.status_code == 201 224 | 225 | def test_post_request_with_api_key_argument(self): 226 | t = Three('http://seeclicktest.com/open311/v2') 227 | t.post('1627', name='Zach Williams', address='120 Spring St', 228 | description='Just a test post.', phone='555-5555', 229 | api_key='my_api_key') 230 | params = { 231 | 'first_name': 'Zach', 'last_name': 'Williams', 232 | 'description': 'Just a test post.', 'service_code': '1627', 233 | 'address_string': '120 Spring St', 'phone': '555-5555', 234 | 'api_key': 'my_api_key' 235 | } 236 | expected = 'http://seeclicktest.com/open311/v2/requests.json' 237 | t.session.post.assert_called_with(expected, data=params, files=None) 238 | 239 | 240 | @patch.object(req, 'Session', Mock()) 241 | class ThreeToken(unittest.TestCase): 242 | 243 | def setUp(self): 244 | core.json = Mock() 245 | 246 | def test_a_default_token_call(self): 247 | t = Three('api.city.gov') 248 | t.token('12345') 249 | expected = 'https://api.city.gov/tokens/12345.json' 250 | t.session.get.assert_called_with(expected, params={}) 251 | 252 | 253 | class TopLevelFunctions(unittest.TestCase): 254 | 255 | def setUp(self): 256 | self.session = Mock() 257 | self.patch = patch.object(req, 'Session', 258 | Mock(return_value=self.session)) 259 | self.patch.start() 260 | core.json = MagicMock() 261 | 262 | def tearDown(self): 263 | self.patch.stop() 264 | 265 | def test_three_api(self): 266 | three.key('my_api_key') 267 | key = os.environ['OPEN311_API_KEY'] 268 | self.assertEqual(key, 'my_api_key') 269 | 270 | def test_cities_function_returns_a_list(self): 271 | cities = three.cities() 272 | self.assertTrue(isinstance(cities, list)) 273 | 274 | def test_three_city_info(self): 275 | three.city('sf') 276 | info = os.environ['OPEN311_CITY_INFO'] 277 | self.assertTrue(info) 278 | 279 | def test_three_city_error(self): 280 | self.assertRaises(CityNotFound, three.city, 'this is made up') 281 | 282 | def test_three_discovery(self): 283 | three.city('new haven') 284 | three.discovery() 285 | self.assertTrue(self.session.get.called) 286 | 287 | def test_three_requests(self): 288 | three.city('macon') 289 | three.requests() 290 | self.assertTrue(self.session.get.called) 291 | 292 | def test_three_request_specific_report(self): 293 | three.city('macon') 294 | three.request('123abc') 295 | self.assertTrue(self.session.get.called) 296 | 297 | def test_three_services(self): 298 | three.city('sf') 299 | three.services() 300 | self.assertTrue(self.session.get.called) 301 | 302 | def test_three_token(self): 303 | three.token('123abc') 304 | three.services() 305 | self.assertTrue(self.session.get.called) 306 | 307 | def test_three_dev_functionality(self): 308 | three.dev('http://api.city.gov') 309 | environ = os.environ['OPEN311_CITY_INFO'] 310 | expected = '{"endpoint": "http://api.city.gov"}' 311 | self.assertEqual(environ, expected) 312 | 313 | def test_three_dev_keyword_arguments(self): 314 | three.dev('http://api.city.gov', format='xml') 315 | environ = json.loads(os.environ['OPEN311_CITY_INFO']) 316 | expected = {"endpoint": "http://api.city.gov", "format": "xml"} 317 | self.assertEqual(environ, expected) 318 | 319 | def tearDown(self): 320 | os.environ['OPEN311_API_KEY'] = '' 321 | os.environ['OPEN311_CITY_INFO'] = '' 322 | 323 | 324 | if __name__ == '__main__': 325 | unittest.main() 326 | --------------------------------------------------------------------------------