├── tests ├── __init__.py ├── test_areas_stream.py ├── utils.py ├── test_areas_view.py └── test_areas_item.py ├── pypodio2 ├── __init__.py ├── adapters.py ├── client.py ├── api.py ├── transport.py ├── encode.py └── areas.py ├── dist └── pypodio2-0.2.tar.gz ├── .gitignore ├── tox.ini ├── setup.py ├── LICENSE ├── example.py └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pypodio2/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /dist/pypodio2-0.2.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podio/podio-py/master/dist/pypodio2-0.2.tar.gz -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | 3 | untitled 4 | test.py 5 | .tox 6 | build 7 | dist 8 | .virtualenv 9 | *.egg 10 | *.egg-info 11 | idea 12 | env 13 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py35 3 | 4 | [testenv] 5 | commands = {envpython} setup.py nosetests 6 | deps = 7 | nose 8 | mock 9 | httplib2 10 | -------------------------------------------------------------------------------- /pypodio2/adapters.py: -------------------------------------------------------------------------------- 1 | 2 | import json 3 | 4 | from .client import FailedRequest 5 | 6 | 7 | def json_response(resp): 8 | try: 9 | return json.loads(resp) 10 | except: 11 | raise FailedRequest(resp) 12 | 13 | 14 | def http_request(method, *args, **kwargs): 15 | print("Called") 16 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="pypodio2", 5 | version="0.2", 6 | description="Python wrapper for the Podio API", 7 | author="Podio", 8 | author_email="mail@podio.com", 9 | url="https://github.com/podio/podio-py", 10 | license="MIT", 11 | packages=["pypodio2"], 12 | install_requires=["httplib2"], 13 | tests_require=["nose", "mock", "tox"], 14 | test_suite="nose.collector", 15 | classifiers=[ 16 | "Development Status :: 4 - Beta", 17 | "Intended Audience :: Developers", 18 | "Operating System :: OS Independent", 19 | "Programming Language :: Python", 20 | "License :: OSI Approved :: MIT License", 21 | "Topic :: Software Development :: Libraries :: Python Modules", 22 | ] 23 | ) 24 | -------------------------------------------------------------------------------- /pypodio2/client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from . import areas 4 | 5 | 6 | class FailedRequest(Exception): 7 | def __init__(self, error): 8 | super(FailedRequest).__init__() 9 | self.error = error 10 | 11 | def __str__(self): 12 | return repr(self.error) 13 | 14 | 15 | # noinspection PyMethodMayBeStatic 16 | class Client(object): 17 | """ 18 | The Podio API client. Callers should use the factory method OAuthClient to create instances. 19 | """ 20 | 21 | def __init__(self, transport): 22 | self.transport = transport 23 | 24 | def __getattr__(self, name): 25 | new_trans = self.transport 26 | area = getattr(areas, name) 27 | return area(new_trans) 28 | 29 | def __dir__(self): 30 | """ 31 | Should return list of attribute names. 32 | Since __getattr__ looks in areas, we simply list the content of the areas module 33 | """ 34 | return dir(areas) 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2011 Podio 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | client_id = "" 2 | client_secret = "" 3 | username = "" 4 | password = "" 5 | 6 | from pypodio2 import api 7 | 8 | c = api.OAuthClient( 9 | client_id, 10 | client_secret, 11 | username, 12 | password, 13 | ) 14 | 15 | 16 | print(c.Item.find(22481)) #Get https://hoist.podio.com/api/item/22481 17 | print(c.Space.find_by_url("https://remaxtraditions.podio.com/remaxtraditions/")) #Find ID 18 | 19 | items = c.Application.get_items(48294)['items'] 20 | 21 | 22 | #To create an item 23 | item = { 24 | "fields":[ 25 | {"external_id":"org-name", "values":[{"value":"The Items API sucks"}]} 26 | ] 27 | } 28 | #print c.Application.find(179652) 29 | c.Item.create(app_id, item) 30 | 31 | #Undefined and created at runtime example 32 | #print c.transport.GET.user.status() 33 | 34 | # Other methods are: 35 | # c.transport.PUT.#{uri}.#{divided}.{by_slashes}() 36 | # c.transport.DELETE.#{uri}.#{divided}.{by_slashes}() 37 | # c.transport.POST.#{uri}.#{divided}.{by_slashes}(body=paramDict)) 38 | # For POST and PUT you can pass "type" as a kwarg and register the type as either 39 | # application/x-www-form-urlencoded or application/json to match what API expects. 40 | 41 | #items[0]['fields'][2]['values'][0]['value']['file_id'] 42 | -------------------------------------------------------------------------------- /pypodio2/api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from . import transport, client 3 | 4 | 5 | def build_headers(authorization_headers, user_agent): 6 | headers = transport.KeepAliveHeaders(authorization_headers) 7 | if user_agent is not None: 8 | headers = transport.UserAgentHeaders(headers, user_agent) 9 | return headers 10 | 11 | 12 | def OAuthClient(api_key, api_secret, login, password, user_agent=None, 13 | domain="https://api.podio.com"): 14 | auth = transport.OAuthAuthorization(login, password, 15 | api_key, api_secret, domain) 16 | return AuthorizingClient(domain, auth, user_agent=user_agent) 17 | 18 | 19 | def OAuthAppClient(client_id, client_secret, app_id, app_token, user_agent=None, 20 | domain="https://api.podio.com"): 21 | 22 | auth = transport.OAuthAppAuthorization(app_id, app_token, 23 | client_id, client_secret, domain) 24 | 25 | return AuthorizingClient(domain, auth, user_agent=user_agent) 26 | 27 | 28 | def AuthorizingClient(domain, auth, user_agent=None): 29 | """Creates a Podio client using an auth object.""" 30 | http_transport = transport.HttpTransport(domain, build_headers(auth, user_agent)) 31 | return client.Client(http_transport) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![No Maintenance Intended](http://unmaintained.tech/badge.svg)](http://unmaintained.tech/) 2 | 3 | PyPodio 4 | ===== 5 | 6 | Python wrapper for the Podio API. 7 | 8 | Install 9 | ------- 10 | 11 | Dependencies 12 | 13 | * httplib2 14 | 15 | PyPodio is not yet available on PyPI, we're waiting to have it a bit more 16 | stable. Install by cloning from the GitHub repo: 17 | 18 | $ git clone git://github.com/podio/podio-py.git 19 | $ cp -r podio-py/pypodio2 path/to/destination 20 | 21 | Alternatively, install via `pip`: 22 | 23 | $ pip install -e git+https://github.com/podio/podio-py.git#egg=podio-py 24 | 25 | 26 | Example 27 | ------- 28 | 29 | from pypodio2 import api 30 | from client_settings import * 31 | 32 | c = api.OAuthClient( 33 | client_id, 34 | client_secret, 35 | username, 36 | password, 37 | ) 38 | print c.Item.find(22342) 39 | 40 | Notes 41 | ------ 42 | 43 | It is possible to override the default response handler by passing handler as 44 | a keyword argument to a transport function call. For example: 45 | 46 | x = lambda x,y: (x,y) 47 | result = c.Item.find(11007, basic=True, handler=x) 48 | ($result, $data) #Returned info 49 | 50 | Tests 51 | ----- 52 | 53 | To run tests for the API wrapper, you need two additional dependencies: 54 | 55 | * mock 56 | * nose 57 | 58 | With those installed, run `nosetests` from the repository's root directory. 59 | 60 | 61 | Meta 62 | ---- 63 | 64 | * Code: `git clone git://github.com/podio/podio-py.git` 65 | * Home: 66 | * Bugs: 67 | -------------------------------------------------------------------------------- /tests/test_areas_stream.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Unit tests for pypodio2.areas.Stream (via pypodio2.client.Client). Works 5 | by mocking httplib2, and making assertions about how pypodio2 calls 6 | it. 7 | """ 8 | 9 | 10 | from tests.utils import check_client_method 11 | 12 | 13 | def test_find_all(): 14 | client, check_assertions = check_client_method() 15 | result = client.Stream.find_all() 16 | check_assertions(result, 'GET', '/stream/') 17 | 18 | 19 | def test_find_all_by_org_id(): 20 | org_id = 81076 21 | 22 | client, check_assertions = check_client_method() 23 | result = client.Stream.find_all_by_org_id(org_id) 24 | check_assertions(result, 'GET', '/stream/org/%s/' % org_id) 25 | 26 | 27 | def test_find_all_personal(): 28 | client, check_assertions = check_client_method() 29 | result = client.Stream.find_all_personal() 30 | check_assertions(result, 'GET', '/stream/personal/') 31 | 32 | 33 | def test_find_all_by_space_id(): 34 | space_id = 2222 35 | 36 | client, check_assertions = check_client_method() 37 | result = client.Stream.find_all_by_space_id(space_id) 38 | check_assertions(result, 'GET', '/stream/space/%s/' % space_id) 39 | 40 | 41 | def test_find_by_ref(): 42 | # It's not entirely clear what inputs are appropriate for ref_type. 43 | # But for this test's purposes, any string will do. 44 | ref_type = 'item' 45 | ref_id = 10203 46 | 47 | client, check_assertions = check_client_method() 48 | result = client.Stream.find_by_ref(ref_type, ref_id) 49 | check_assertions(result, 'GET', '/stream/%s/%s' % (ref_type, ref_id)) 50 | 51 | 52 | def test_find_item_by_external_id(): 53 | app_id = 13 54 | external_id = 37 55 | 56 | client, check_assertions = check_client_method() 57 | result = client.Item.find_all_by_external_id(app_id, external_id) 58 | check_assertions(result, 59 | 'GET', 60 | '/item/app/%s/v2/?external_id=%s' % (app_id, external_id)) 61 | 62 | 63 | def test_item_revisions(): 64 | item_id = 255 65 | 66 | client, check_assertions = check_client_method() 67 | result = client.Item.revisions(item_id) 68 | check_assertions(result, 69 | 'GET', 70 | '/item/%s/revision/' % item_id) 71 | 72 | 73 | def test_item_revision_difference(): 74 | item_id = 2 75 | from_id = 4 76 | to_id = 8 77 | 78 | client, check_assertions = check_client_method() 79 | result = client.Item.revision_difference(item_id, from_id, to_id) 80 | check_assertions(result, 81 | 'GET', 82 | '/item/%s/revision/%s/%s' % (item_id, from_id, to_id)) 83 | 84 | 85 | def test_item_revision_difference(): 86 | item_id = 2 87 | from_id = 4 88 | to_id = 8 89 | 90 | client, check_assertions = check_client_method() 91 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper methods for testing 3 | """ 4 | import json 5 | 6 | from uuid import uuid4 7 | 8 | from mock import Mock 9 | from nose.tools import eq_ 10 | 11 | import pypodio2.client 12 | import pypodio2.transport 13 | 14 | # Just in case actual HTTP calls are made, don't use a real URL 15 | URL_BASE = 'https://api.example.com' 16 | 17 | 18 | def get_client_and_http(): 19 | """ 20 | Gets a pypodio2.client.Client instance and a mocked instance of 21 | httplib2.Http that backs it. Returned as (client, Http) 22 | """ 23 | transport = pypodio2.transport.HttpTransport( 24 | URL_BASE, headers_factory=dict) 25 | client = pypodio2.client.Client(transport) 26 | 27 | http = Mock() 28 | transport._http = http 29 | 30 | return client, http 31 | 32 | 33 | # This is used a lot by test_areas_*. It's a little weird, but it 34 | # reduces the amount of code to write per test by a lot. 35 | def check_client_method(): 36 | """ 37 | Helper to test an API method -- returns a tuple of 38 | (test_api_client, check_assertions) where check_assertions will 39 | verify that the API method returned the data from http.request, 40 | and that http.request was called with the correct arguments. 41 | 42 | check_assertions' signature is: 43 | def check_assertions(object_returned_from_api, 44 | # GET, POST, etc. 45 | http_method, 46 | # Include the leading / 47 | expected_path, 48 | # Assert that this string was sent as the request body 49 | expected_body, 50 | # Assert that the request headers match this dict 51 | expected_headers) 52 | 53 | To assert that client.Org().get_all calls URL_BASE/org/ and 54 | is correctly hooked up to http.request(): 55 | 56 | client, check_assertions = check_client_method() 57 | result = client.Org.get_all() 58 | check_assertions(result, 'GET', '/org/') 59 | 60 | You can also pass body and headers to check_assertions. 61 | """ 62 | client, http = get_client_and_http() 63 | returned_object = {'uuid': uuid4().hex} 64 | 65 | response = Mock() 66 | response.status = 200 67 | http.request = Mock(return_value=( 68 | response, json.dumps(returned_object).encode("utf-8"))) 69 | 70 | def check_assertions(actual_returned, 71 | http_method, 72 | expected_path, 73 | expected_body=None, 74 | expected_headers=None): 75 | if expected_headers is None: 76 | expected_headers = {} 77 | 78 | eq_(returned_object, 79 | actual_returned, 80 | "API method didn't return the same object as http.request()") 81 | http.request.assert_called_once_with(URL_BASE + expected_path, 82 | http_method, 83 | body=expected_body, 84 | headers=expected_headers) 85 | 86 | return client, check_assertions 87 | -------------------------------------------------------------------------------- /tests/test_areas_view.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Unit tests for pypodio2.areas.View (via pypodio2.client.Client). Works 4 | by mocking httplib2, and making assertions about how pypodio2 calls 5 | it. 6 | """ 7 | 8 | import json 9 | 10 | from tests.utils import check_client_method 11 | 12 | 13 | def test_create(): 14 | app_id = 12345 15 | view_details = {} 16 | 17 | client, check_assertions = check_client_method() 18 | result = client.View.create(app_id, view_details) 19 | check_assertions(result, 'POST', '/view/app/{}/'.format(app_id), 20 | json.dumps(view_details), 21 | {'content-type': 'application/json'}) 22 | 23 | 24 | def test_delete(): 25 | view_id = 67423 26 | 27 | client, check_assertions = check_client_method() 28 | result = client.View.delete(view_id) 29 | check_assertions(result, 'DELETE', '/view/{}'.format(view_id)) 30 | 31 | 32 | def test_get_view(): 33 | app_id = 122 34 | view_id = 222 35 | view_name = 'pizzas4life' 36 | 37 | client, check_assertions = check_client_method() 38 | result = client.View.get(app_id, view_id) 39 | check_assertions(result, 'GET', '/view/app/{}/{}'.format(app_id, view_id)) 40 | 41 | client, check_assertions = check_client_method() 42 | result = client.View.get(app_id, view_name) 43 | check_assertions(result, 'GET', '/view/app/{}/{}'.format(app_id, view_name)) 44 | 45 | client, check_assertions = check_client_method() 46 | result = client.View.get(app_id, 'last') 47 | check_assertions(result, 'GET', '/view/app/{}/{}'.format(app_id, 'last')) 48 | 49 | 50 | def test_get_views(): 51 | 52 | app_id = 12346789 53 | client, check_assertions = check_client_method() 54 | result = client.View.get_views(app_id) 55 | check_assertions(result, 'GET', '/view/app/{}/?include_standard_views=false'.format(app_id)) 56 | 57 | client, check_assertions = check_client_method() 58 | result = client.View.get_views(app_id, True) 59 | check_assertions(result, 'GET', '/view/app/{}/?include_standard_views=true'.format(app_id)) 60 | 61 | client, check_assertions = check_client_method() 62 | result = client.View.get_views(app_id, False) 63 | check_assertions(result, 'GET', '/view/app/{}/?include_standard_views=false'.format(app_id)) 64 | 65 | 66 | def test_make_default(): 67 | 68 | view_id = 8675309 69 | client, check_assertions = check_client_method() 70 | result = client.View.make_default(view_id) 71 | check_assertions(result, 'POST', '/view/{}/default'.format(view_id), 72 | expected_body=json.dumps({}), 73 | expected_headers={'content-type': 'application/json'}) 74 | 75 | 76 | def test_update_last_view(): 77 | app_id = 666777888 78 | attributes = {'a': 'b', 'c': 'd'} 79 | client, check_assertions = check_client_method() 80 | result = client.View.update_last_view(app_id, attributes) 81 | check_assertions(result, 'PUT', '/view/app/{}/last'.format(app_id), 82 | expected_body=json.dumps(attributes), 83 | expected_headers={'content-type': 'application/json'}) 84 | 85 | 86 | def test_update_view(): 87 | view_id = 131314 88 | attributes = {'a': 'b', 'c': 'd'} 89 | client, check_assertions = check_client_method() 90 | result = client.View.update_view(view_id, attributes) 91 | check_assertions(result, 'PUT', '/view/{}'.format(view_id), 92 | expected_body=json.dumps(attributes), 93 | expected_headers={'content-type': 'application/json'}) 94 | 95 | -------------------------------------------------------------------------------- /tests/test_areas_item.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Unit tests for pypodio2.areas.Item (via pypodio2.client.Client). Works 4 | by mocking httplib2, and making assertions about how pypodio2 calls 5 | it. 6 | """ 7 | 8 | import json 9 | 10 | from mock import Mock 11 | from nose.tools import eq_ 12 | 13 | from tests.utils import check_client_method, get_client_and_http, URL_BASE 14 | 15 | 16 | def test_find(): 17 | item_id = 9271 18 | 19 | client, check_assertions = check_client_method() 20 | result = client.Item.find(item_id) 21 | check_assertions(result, 'GET', '/item/%s' % item_id) 22 | 23 | client, check_assertions = check_client_method() 24 | result = client.Item.find(item_id, basic=True) 25 | check_assertions(result, 'GET', '/item/%s/basic' % item_id) 26 | 27 | 28 | def test_filters(): 29 | app_id = 426 30 | attributes = {'a': 1, 'zzzz': 12345} 31 | 32 | client, check_assertions = check_client_method() 33 | result = client.Item.filter(app_id, attributes) 34 | check_assertions(result, 35 | 'POST', 36 | '/item/app/%s/filter/' % app_id, 37 | expected_body=json.dumps(attributes), 38 | expected_headers={'content-type': 'application/json'}) 39 | 40 | 41 | def test_filter_by_view(): 42 | app_id = 421 43 | view_id = 123 44 | 45 | client, check_assertions = check_client_method() 46 | result = client.Item.filter_by_view(app_id, view_id) 47 | check_assertions(result, 48 | 'POST', 49 | '/item/app/{}/filter/{}'.format(app_id, view_id), 50 | expected_body=json.dumps({}), 51 | expected_headers={'content-type': 'application/json'}) 52 | 53 | 54 | def test_find_by_external_id(): 55 | app_id = 13 56 | external_id = 37 57 | 58 | client, check_assertions = check_client_method() 59 | result = client.Item.find_all_by_external_id(app_id, external_id) 60 | check_assertions(result, 61 | 'GET', 62 | '/item/app/%s/v2/?external_id=%s' % (app_id, external_id)) 63 | 64 | 65 | def test_revisions(): 66 | item_id = 255 67 | 68 | client, check_assertions = check_client_method() 69 | result = client.Item.revisions(item_id) 70 | check_assertions(result, 71 | 'GET', 72 | '/item/%s/revision/' % item_id) 73 | 74 | 75 | def test_revision_difference(): 76 | item_id = 2 77 | from_id = 4 78 | to_id = 8 79 | 80 | client, check_assertions = check_client_method() 81 | result = client.Item.revision_difference(item_id, from_id, to_id) 82 | check_assertions(result, 83 | 'GET', 84 | '/item/%s/revision/%s/%s' % (item_id, from_id, to_id)) 85 | 86 | 87 | def test_values(): 88 | item_id = 9271 89 | 90 | client, check_assertions = check_client_method() 91 | result = client.Item.values(item_id) 92 | check_assertions(result, 'GET', '/item/%s/value' % item_id) 93 | 94 | 95 | def test_values_v2(): 96 | item_id = 9271 97 | 98 | client, check_assertions = check_client_method() 99 | result = client.Item.values_v2(item_id) 100 | check_assertions(result, 'GET', '/item/%s/value/v2' % item_id) 101 | 102 | 103 | def test_create(): 104 | 105 | app_id = 1 106 | attributes = {'1': 1, '2': 3, '5': '8'} 107 | 108 | client, check_assertions = check_client_method() 109 | result = client.Item.create(app_id, attributes) 110 | check_assertions(result, 111 | 'POST', 112 | '/item/app/%s/' % app_id, 113 | json.dumps(attributes), 114 | {'content-type': 'application/json'}) 115 | 116 | 117 | def test_update(): 118 | app_id = 1 119 | attributes = {'1': 1, '2': 3, '5': '8'} 120 | 121 | client, check_assertions = check_client_method() 122 | result = client.Item.update(app_id, attributes) 123 | check_assertions(result, 124 | 'PUT', 125 | '/item/%s' % app_id, 126 | json.dumps(attributes), 127 | {'content-type': 'application/json'}) 128 | 129 | client, check_assertions = check_client_method() 130 | result = client.Item.update(app_id, attributes, silent=True) 131 | check_assertions(result, 132 | 'PUT', 133 | '/item/%s?silent=true' % app_id, 134 | json.dumps(attributes), 135 | {'content-type': 'application/json'}) 136 | 137 | 138 | def test_delete(): 139 | item_id = 1 140 | 141 | client, http = get_client_and_http() 142 | http.request = Mock(return_value=(None, None)) 143 | 144 | result = client.Item.delete(item_id) 145 | 146 | eq_(None, result) 147 | http.request.assert_called_once_with("%s/item/%s?" % (URL_BASE, item_id), 148 | 'DELETE', 149 | body=None, 150 | headers={}) 151 | -------------------------------------------------------------------------------- /pypodio2/transport.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from httplib2 import Http 3 | 4 | try: 5 | from urllib.parse import urlencode 6 | except ImportError: 7 | from urllib import urlencode 8 | 9 | from .encode import multipart_encode 10 | 11 | 12 | import json 13 | 14 | 15 | class OAuthToken(object): 16 | """ 17 | Class used to encapsulate the OAuthToken required to access the 18 | Podio API. 19 | 20 | Do not modify its attributes manually Use the methods in the 21 | Podio API Connector, get_oauth_token and refresh_oauth_token 22 | """ 23 | def __init__(self, resp): 24 | self.expires_in = resp['expires_in'] 25 | self.access_token = resp['access_token'] 26 | self.refresh_token = resp['refresh_token'] 27 | 28 | def to_headers(self): 29 | return {'authorization': "OAuth2 %s" % self.access_token} 30 | 31 | 32 | class OAuthAuthorization(object): 33 | """Generates headers for Podio OAuth2 Authorization""" 34 | 35 | def __init__(self, login, password, key, secret, domain): 36 | body = {'grant_type': 'password', 37 | 'client_id': key, 38 | 'client_secret': secret, 39 | 'username': login, 40 | 'password': password} 41 | h = Http(disable_ssl_certificate_validation=True) 42 | headers = {'content-type': 'application/x-www-form-urlencoded'} 43 | response, data = h.request(domain + "/oauth/token", "POST", 44 | urlencode(body), headers=headers) 45 | self.token = OAuthToken(_handle_response(response, data)) 46 | 47 | def __call__(self): 48 | return self.token.to_headers() 49 | 50 | 51 | class OAuthAppAuthorization(object): 52 | 53 | def __init__(self, app_id, app_token, key, secret, domain): 54 | body = {'grant_type': 'app', 55 | 'client_id': key, 56 | 'client_secret': secret, 57 | 'app_id': app_id, 58 | 'app_token': app_token} 59 | h = Http(disable_ssl_certificate_validation=True) 60 | headers = {'content-type': 'application/x-www-form-urlencoded'} 61 | response, data = h.request(domain + "/oauth/token", "POST", 62 | urlencode(body), headers=headers) 63 | self.token = OAuthToken(_handle_response(response, data)) 64 | 65 | def __call__(self): 66 | return self.token.to_headers() 67 | 68 | 69 | class UserAgentHeaders(object): 70 | def __init__(self, base_headers_factory, user_agent): 71 | self.base_headers_factory = base_headers_factory 72 | self.user_agent = user_agent 73 | 74 | def __call__(self): 75 | headers = self.base_headers_factory() 76 | headers['User-Agent'] = self.user_agent 77 | return headers 78 | 79 | 80 | class KeepAliveHeaders(object): 81 | 82 | def __init__(self, base_headers_factory): 83 | self.base_headers_factory = base_headers_factory 84 | 85 | def __call__(self): 86 | headers = self.base_headers_factory() 87 | headers['Connection'] = 'Keep-Alive' 88 | return headers 89 | 90 | 91 | class TransportException(Exception): 92 | 93 | def __init__(self, status, content): 94 | super(TransportException, self).__init__() 95 | self.status = status 96 | self.content = content 97 | 98 | def __str__(self): 99 | return "TransportException(%s): %s" % (self.status, self.content) 100 | 101 | 102 | class HttpTransport(object): 103 | def __init__(self, url, headers_factory): 104 | self._api_url = url 105 | self._headers_factory = headers_factory 106 | self._supported_methods = ("GET", "POST", "PUT", "HEAD", "DELETE",) 107 | self._attribute_stack = [] 108 | self._method = "GET" 109 | self._posts = [] 110 | self._http = Http(disable_ssl_certificate_validation=True) 111 | self._params = {} 112 | self._url_template = '%(domain)s/%(generated_url)s' 113 | self._stack_collapser = "/".join 114 | self._params_template = '?%s' 115 | 116 | def __call__(self, *args, **kwargs): 117 | self._attribute_stack += [str(a) for a in args] 118 | self._params = kwargs 119 | 120 | headers = self._headers_factory() 121 | 122 | if 'url' not in kwargs: 123 | url = self.get_url() 124 | else: 125 | url = self.get_url(kwargs['url']) 126 | 127 | if (self._method == "POST" or self._method == "PUT") and 'type' not in kwargs: 128 | headers.update({'content-type': 'application/json'}) 129 | # Not sure if this will always work, but for validate/verfiy nothing else was working: 130 | body = json.dumps(kwargs) 131 | elif 'type' in kwargs: 132 | if kwargs['type'] == 'multipart/form-data': 133 | body, new_headers = multipart_encode(kwargs['body']) 134 | body = "".join(body) 135 | headers.update(new_headers) 136 | else: 137 | body = kwargs['body'] 138 | headers.update({'content-type': kwargs['type']}) 139 | else: 140 | body = self._generate_body() # hack 141 | response, data = self._http.request(url, self._method, body=body, headers=headers) 142 | 143 | self._attribute_stack = [] 144 | handler = kwargs.get('handler', _handle_response) 145 | return handler(response, data) 146 | 147 | def _generate_params(self, params): 148 | body = self._params_template % urlencode(params) 149 | if body is None: 150 | return '' 151 | return body 152 | 153 | def _generate_body(self): 154 | if self._method == 'POST': 155 | internal_params = self._params.copy() 156 | 157 | if 'GET' in internal_params: 158 | del internal_params['GET'] 159 | 160 | return self._generate_params(internal_params)[1:] 161 | 162 | def _clear_content_type(self): 163 | """Clear content-type""" 164 | if 'content-type' in self._headers: 165 | del self._headers['content-type'] 166 | 167 | def _clear_headers(self): 168 | """Clear all headers""" 169 | self._headers = {} 170 | 171 | def get_url(self, url=None): 172 | if url is None: 173 | url = self._url_template % { 174 | "domain": self._api_url, 175 | "generated_url": self._stack_collapser(self._attribute_stack), 176 | } 177 | else: 178 | url = self._url_template % { 179 | 'domain': self._api_url, 180 | 'generated_url': url[1:] 181 | } 182 | del self._params['url'] 183 | 184 | if len(self._params): 185 | internal_params = self._params.copy() 186 | 187 | if 'handler' in internal_params: 188 | del internal_params['handler'] 189 | 190 | if self._method == 'POST' or self._method == "PUT": 191 | if "GET" not in internal_params: 192 | return url 193 | internal_params = internal_params['GET'] 194 | url += self._generate_params(internal_params) 195 | return url 196 | 197 | def __getitem__(self, name): 198 | self._attribute_stack.append(name) 199 | return self 200 | 201 | def __getattr__(self, name): 202 | if name in self._supported_methods: 203 | self._method = name 204 | elif not name.endswith(')'): 205 | self._attribute_stack.append(name) 206 | return self 207 | 208 | 209 | def _handle_response(response, data): 210 | if not data: 211 | data = '{}' 212 | else: 213 | data = data.decode("utf-8") 214 | if response.status >= 400: 215 | raise TransportException(response, data) 216 | return json.loads(data) 217 | -------------------------------------------------------------------------------- /pypodio2/encode.py: -------------------------------------------------------------------------------- 1 | """multipart/form-data encoding module 2 | 3 | This module provides functions that faciliate encoding name/value pairs 4 | as multipart/form-data suitable for a HTTP POST or PUT request. 5 | 6 | multipart/form-data is the standard way to upload files over HTTP""" 7 | 8 | import mimetypes 9 | import os 10 | import re 11 | import urllib 12 | from email.header import Header 13 | 14 | __all__ = ['gen_boundary', 'encode_and_quote', 'MultipartParam', 15 | 'encode_string', 'encode_file_header', 'get_body_size', 'get_headers', 16 | 'multipart_encode'] 17 | 18 | try: 19 | from io import UnsupportedOperation 20 | except ImportError: 21 | UnsupportedOperation = None 22 | 23 | try: 24 | import uuid 25 | 26 | 27 | def gen_boundary(): 28 | """Returns a random string to use as the boundary for a message""" 29 | return uuid.uuid4().hex 30 | except ImportError: 31 | import random 32 | import sha 33 | 34 | 35 | def gen_boundary(): 36 | """Returns a random string to use as the boundary for a message""" 37 | bits = random.getrandbits(160) 38 | return sha.new(str(bits)).hexdigest() 39 | 40 | 41 | def encode_and_quote(data): 42 | """If ``data`` is unicode, return urllib.quote_plus(data.encode("utf-8")) 43 | otherwise return urllib.quote_plus(data)""" 44 | if data is None: 45 | return None 46 | 47 | if isinstance(data, unicode): 48 | data = data.encode("utf-8") 49 | return urllib.quote_plus(data) 50 | 51 | 52 | def _strify(s): 53 | """If s is a unicode string, encode it to UTF-8 and return the results, 54 | otherwise return str(s), or None if s is None""" 55 | if s is None: 56 | return None 57 | if isinstance(s, unicode): 58 | return s.encode("utf-8") 59 | return str(s) 60 | 61 | 62 | class MultipartParam(object): 63 | """Represents a single parameter in a multipart/form-data request 64 | 65 | ``name`` is the name of this parameter. 66 | 67 | If ``value`` is set, it must be a string or unicode object to use as the 68 | data for this parameter. 69 | 70 | If ``filename`` is set, it is what to say that this parameter's filename 71 | is. Note that this does not have to be the actual filename any local file. 72 | 73 | If ``filetype`` is set, it is used as the Content-Type for this parameter. 74 | If unset it defaults to "text/plain; charset=utf8" 75 | 76 | If ``filesize`` is set, it specifies the length of the file ``fileobj`` 77 | 78 | If ``fileobj`` is set, it must be a file-like object that supports 79 | .read(). 80 | 81 | Both ``value`` and ``fileobj`` must not be set, doing so will 82 | raise a ValueError assertion. 83 | 84 | If ``fileobj`` is set, and ``filesize`` is not specified, then 85 | the file's size will be determined first by stat'ing ``fileobj``'s 86 | file descriptor, and if that fails, by seeking to the end of the file, 87 | recording the current position as the size, and then by seeking back to the 88 | beginning of the file. 89 | 90 | ``cb`` is a callable which will be called from iter_encode with (self, 91 | current, total), representing the current parameter, current amount 92 | transferred, and the total size. 93 | """ 94 | 95 | def __init__(self, name, value=None, filename=None, filetype=None, 96 | filesize=None, fileobj=None, cb=None): 97 | self.name = Header(name).encode() 98 | self.value = _strify(value) 99 | if filename is None: 100 | self.filename = None 101 | else: 102 | if isinstance(filename, unicode): 103 | # Encode with XML entities 104 | self.filename = filename.encode("ascii", "xmlcharrefreplace") 105 | else: 106 | self.filename = str(filename) 107 | self.filename = self.filename.encode("string_escape"). \ 108 | replace('"', '\\"') 109 | self.filetype = _strify(filetype) 110 | 111 | self.filesize = filesize 112 | self.fileobj = fileobj 113 | self.cb = cb 114 | 115 | if self.value is not None and self.fileobj is not None: 116 | raise ValueError("Only one of value or fileobj may be specified") 117 | 118 | if fileobj is not None and filesize is None: 119 | # Try and determine the file size 120 | try: 121 | self.filesize = os.fstat(fileobj.fileno()).st_size 122 | except (OSError, AttributeError, UnsupportedOperation): 123 | try: 124 | fileobj.seek(0, 2) 125 | self.filesize = fileobj.tell() 126 | fileobj.seek(0) 127 | except: 128 | raise ValueError("Could not determine filesize") 129 | 130 | def __cmp__(self, other): 131 | attrs = ['name', 'value', 'filename', 'filetype', 'filesize', 'fileobj'] 132 | myattrs = [getattr(self, a) for a in attrs] 133 | oattrs = [getattr(other, a) for a in attrs] 134 | return cmp(myattrs, oattrs) 135 | 136 | def reset(self): 137 | if self.fileobj is not None: 138 | self.fileobj.seek(0) 139 | elif self.value is None: 140 | raise ValueError("Don't know how to reset this parameter") 141 | 142 | @classmethod 143 | def from_file(cls, paramname, filename): 144 | """Returns a new MultipartParam object constructed from the local 145 | file at ``filename``. 146 | 147 | ``filesize`` is determined by os.path.getsize(``filename``) 148 | 149 | ``filetype`` is determined by mimetypes.guess_type(``filename``)[0] 150 | 151 | ``filename`` is set to os.path.basename(``filename``) 152 | """ 153 | 154 | return cls(paramname, filename=os.path.basename(filename), 155 | filetype=mimetypes.guess_type(filename)[0], 156 | filesize=os.path.getsize(filename), 157 | fileobj=open(filename, "rb")) 158 | 159 | @classmethod 160 | def from_params(cls, params): 161 | """Returns a list of MultipartParam objects from a sequence of 162 | name, value pairs, MultipartParam instances, 163 | or from a mapping of names to values 164 | 165 | The values may be strings or file objects, or MultipartParam objects. 166 | MultipartParam object names must match the given names in the 167 | name,value pairs or mapping, if applicable.""" 168 | if hasattr(params, 'items'): 169 | params = params.items() 170 | 171 | retval = [] 172 | for item in params: 173 | if isinstance(item, cls): 174 | retval.append(item) 175 | continue 176 | name, value = item 177 | if isinstance(value, cls): 178 | assert value.name == name 179 | retval.append(value) 180 | continue 181 | if hasattr(value, 'read'): 182 | # Looks like a file object 183 | filename = getattr(value, 'name', None) 184 | if filename is not None: 185 | filetype = mimetypes.guess_type(filename)[0] 186 | else: 187 | filetype = None 188 | 189 | retval.append(cls(name=name, filename=filename, 190 | filetype=filetype, fileobj=value)) 191 | else: 192 | retval.append(cls(name, value)) 193 | return retval 194 | 195 | def encode_hdr(self, boundary): 196 | """Returns the header of the encoding of this parameter""" 197 | boundary = encode_and_quote(boundary) 198 | 199 | headers = ["--%s" % boundary] 200 | 201 | if self.filename: 202 | disposition = 'form-data; name="%s"; filename="%s"' % (self.name, 203 | self.filename) 204 | else: 205 | disposition = 'form-data; name="%s"' % self.name 206 | 207 | headers.append("Content-Disposition: %s" % disposition) 208 | 209 | if self.filetype: 210 | filetype = self.filetype 211 | else: 212 | filetype = "text/plain; charset=utf-8" 213 | 214 | headers.append("Content-Type: %s" % filetype) 215 | 216 | headers.append("") 217 | headers.append("") 218 | 219 | return "\r\n".join(headers) 220 | 221 | def encode(self, boundary): 222 | """Returns the string encoding of this parameter""" 223 | if self.value is None: 224 | value = self.fileobj.read() 225 | else: 226 | value = self.value 227 | 228 | if re.search("^--%s$" % re.escape(boundary), value, re.M): 229 | raise ValueError("boundary found in encoded string") 230 | 231 | return "%s%s\r\n" % (self.encode_hdr(boundary), value) 232 | 233 | def iter_encode(self, boundary, blocksize=4096): 234 | """Yields the encoding of this parameter 235 | If self.fileobj is set, then blocks of ``blocksize`` bytes are read and 236 | yielded.""" 237 | total = self.get_size(boundary) 238 | current = 0 239 | if self.value is not None: 240 | block = self.encode(boundary) 241 | current += len(block) 242 | yield block 243 | if self.cb: 244 | self.cb(self, current, total) 245 | else: 246 | block = self.encode_hdr(boundary) 247 | current += len(block) 248 | yield block 249 | if self.cb: 250 | self.cb(self, current, total) 251 | last_block = "" 252 | encoded_boundary = "--%s" % encode_and_quote(boundary) 253 | boundary_exp = re.compile("^%s$" % re.escape(encoded_boundary), 254 | re.M) 255 | while True: 256 | block = self.fileobj.read(blocksize) 257 | if not block: 258 | current += 2 259 | yield "\r\n" 260 | if self.cb: 261 | self.cb(self, current, total) 262 | break 263 | last_block += block 264 | if boundary_exp.search(last_block): 265 | raise ValueError("boundary found in file data") 266 | last_block = last_block[-len(encoded_boundary) - 2:] 267 | current += len(block) 268 | yield block 269 | if self.cb: 270 | self.cb(self, current, total) 271 | 272 | def get_size(self, boundary): 273 | """Returns the size in bytes that this param will be when encoded 274 | with the given boundary.""" 275 | if self.filesize is not None: 276 | valuesize = self.filesize 277 | else: 278 | valuesize = len(self.value) 279 | 280 | return len(self.encode_hdr(boundary)) + 2 + valuesize 281 | 282 | 283 | def encode_string(boundary, name, value): 284 | """Returns ``name`` and ``value`` encoded as a multipart/form-data 285 | variable. ``boundary`` is the boundary string used throughout 286 | a single request to separate variables.""" 287 | 288 | return MultipartParam(name, value).encode(boundary) 289 | 290 | 291 | def encode_file_header(boundary, paramname, filesize, filename=None, 292 | filetype=None): 293 | """Returns the leading data for a multipart/form-data field that contains 294 | file data. 295 | 296 | ``boundary`` is the boundary string used throughout a single request to 297 | separate variables. 298 | 299 | ``paramname`` is the name of the variable in this request. 300 | 301 | ``filesize`` is the size of the file data. 302 | 303 | ``filename`` if specified is the filename to give to this field. This 304 | field is only useful to the server for determining the original filename. 305 | 306 | ``filetype`` if specified is the MIME type of this file. 307 | 308 | The actual file data should be sent after this header has been sent. 309 | """ 310 | 311 | return MultipartParam(paramname, filesize=filesize, filename=filename, 312 | filetype=filetype).encode_hdr(boundary) 313 | 314 | 315 | def get_body_size(params, boundary): 316 | """Returns the number of bytes that the multipart/form-data encoding 317 | of ``params`` will be.""" 318 | size = sum(p.get_size(boundary) for p in MultipartParam.from_params(params)) 319 | return size + len(boundary) + 6 320 | 321 | 322 | def get_headers(params, boundary): 323 | """Returns a dictionary with Content-Type and Content-Length headers 324 | for the multipart/form-data encoding of ``params``.""" 325 | headers = {} 326 | boundary = urllib.quote_plus(boundary) 327 | headers['Content-Type'] = "multipart/form-data; boundary=%s" % boundary 328 | headers['Content-Length'] = str(get_body_size(params, boundary)) 329 | return headers 330 | 331 | 332 | class MultipartYielder: 333 | def __init__(self, params, boundary, cb): 334 | self.params = params 335 | self.boundary = boundary 336 | self.cb = cb 337 | 338 | self.i = 0 339 | self.p = None 340 | self.param_iter = None 341 | self.current = 0 342 | self.total = get_body_size(params, boundary) 343 | 344 | def __iter__(self): 345 | return self 346 | 347 | def next(self): 348 | """generator function to yield multipart/form-data representation 349 | of parameters""" 350 | if self.param_iter is not None: 351 | try: 352 | block = self.param_iter.next() 353 | self.current += len(block) 354 | if self.cb: 355 | self.cb(self.p, self.current, self.total) 356 | return block 357 | except StopIteration: 358 | self.p = None 359 | self.param_iter = None 360 | 361 | if self.i is None: 362 | raise StopIteration 363 | elif self.i >= len(self.params): 364 | self.param_iter = None 365 | self.p = None 366 | self.i = None 367 | block = "--%s--\r\n" % self.boundary 368 | self.current += len(block) 369 | if self.cb: 370 | self.cb(self.p, self.current, self.total) 371 | return block 372 | 373 | self.p = self.params[self.i] 374 | self.param_iter = self.p.iter_encode(self.boundary) 375 | self.i += 1 376 | return self.next() 377 | 378 | def reset(self): 379 | self.i = 0 380 | self.current = 0 381 | for param in self.params: 382 | param.reset() 383 | 384 | 385 | def multipart_encode(params, boundary=None, cb=None): 386 | """Encode ``params`` as multipart/form-data. 387 | 388 | ``params`` should be a sequence of (name, value) pairs or MultipartParam 389 | objects, or a mapping of names to values. 390 | Values are either strings parameter values, or file-like objects to use as 391 | the parameter value. The file-like objects must support .read() and either 392 | .fileno() or both .seek() and .tell(). 393 | 394 | If ``boundary`` is set, then it as used as the MIME boundary. Otherwise 395 | a randomly generated boundary will be used. In either case, if the 396 | boundary string appears in the parameter values a ValueError will be 397 | raised. 398 | 399 | If ``cb`` is set, it should be a callback which will get called as blocks 400 | of data are encoded. It will be called with (param, current, total), 401 | indicating the current parameter being encoded, the current amount encoded, 402 | and the total amount to encode. 403 | 404 | Returns a tuple of `datagen`, `headers`, where `datagen` is a 405 | generator that will yield blocks of data that make up the encoded 406 | parameters, and `headers` is a dictionary with the assoicated 407 | Content-Type and Content-Length headers. 408 | 409 | Examples: 410 | 411 | >>> datagen, headers = multipart_encode( [("key", "value1"), ("key", "value2")] ) 412 | >>> s = "".join(datagen) 413 | >>> assert "value2" in s and "value1" in s 414 | 415 | >>> p = MultipartParam("key", "value2") 416 | >>> datagen, headers = multipart_encode( [("key", "value1"), p] ) 417 | >>> s = "".join(datagen) 418 | >>> assert "value2" in s and "value1" in s 419 | 420 | >>> datagen, headers = multipart_encode( {"key": "value1"} ) 421 | >>> s = "".join(datagen) 422 | >>> assert "value2" not in s and "value1" in s 423 | 424 | """ 425 | if boundary is None: 426 | boundary = gen_boundary() 427 | else: 428 | boundary = urllib.quote_plus(boundary) 429 | 430 | headers = get_headers(params, boundary) 431 | params = MultipartParam.from_params(params) 432 | 433 | return MultipartYielder(params, boundary, cb), headers 434 | -------------------------------------------------------------------------------- /pypodio2/areas.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | 4 | try: 5 | from urllib.parse import urlencode 6 | except ImportError: 7 | from urllib import urlencode 8 | 9 | 10 | class Area(object): 11 | """Represents a Podio Area""" 12 | def __init__(self, transport): 13 | self.transport = transport 14 | 15 | @staticmethod 16 | def sanitize_id(item_id): 17 | if isinstance(item_id, int): 18 | return str(item_id) 19 | return item_id 20 | 21 | @staticmethod 22 | def get_options(silent=False, hook=True): 23 | """ 24 | Generate a query string with the appropriate options. 25 | 26 | :param silent: If set to true, the object will not be bumped up in the stream and 27 | notifications will not be generated. 28 | :type silent: bool 29 | :param hook: True if hooks should be executed for the change, false otherwise. 30 | :type hook: bool 31 | :return: The generated query string 32 | :rtype: str 33 | """ 34 | options_ = {} 35 | if silent: 36 | options_['silent'] = silent 37 | if not hook: 38 | options_['hook'] = hook 39 | if options_: 40 | return '?' + urlencode(options_).lower() 41 | else: 42 | return '' 43 | 44 | 45 | class Embed(Area): 46 | 47 | def __init__(self, *args, **kwargs): 48 | super(Embed, self).__init__(*args, **kwargs) 49 | 50 | def create(self, attributes): 51 | if type(attributes) != dict: 52 | return ApiErrorException('Must be of type dict') 53 | attributes = json.dumps(attributes) 54 | return self.transport.POST(url='/embed/', body=attributes, type='application/json') 55 | 56 | 57 | class Contact(Area): 58 | 59 | def __init__(self, *args, **kwargs): 60 | super(Contact, self).__init__(*args, **kwargs) 61 | 62 | def create(self, space_id, attributes): 63 | if type(attributes) != dict: 64 | return ApiErrorException('Must be of type dict') 65 | attributes = json.dumps(attributes) 66 | return self.transport.POST(url='/contact/space/%d/' % space_id, body=attributes, type='application/json') 67 | 68 | 69 | class Search(Area): 70 | 71 | def __init__(self, *args, **kwargs): 72 | super(Search, self).__init__(*args, **kwargs) 73 | 74 | def searchApp(self, app_id, attributes): 75 | if type(attributes) != dict: 76 | return ApiErrorException('Must be of type dict') 77 | attributes = json.dumps(attributes) 78 | return self.transport.POST(url='/search/app/%d/' % app_id, body=attributes, type='application/json') 79 | 80 | 81 | class Item(Area): 82 | def find(self, item_id, basic=False, **kwargs): 83 | """ 84 | Get item 85 | 86 | :param item_id: Item ID 87 | :param basic: ? 88 | :type item_id: int 89 | :return: Item info 90 | :rtype: dict 91 | """ 92 | if basic: 93 | return self.transport.GET(url='/item/%d/basic' % item_id) 94 | return self.transport.GET(kwargs, url='/item/%d' % item_id) 95 | 96 | def filter(self, app_id, attributes, **kwargs): 97 | if not isinstance(attributes, dict): 98 | raise TypeError('Must be of type dict') 99 | attributes = json.dumps(attributes) 100 | return self.transport.POST(url="/item/app/%d/filter/" % app_id, body=attributes, 101 | type="application/json", **kwargs) 102 | 103 | def filter_by_view(self, app_id, view_id): 104 | return self.transport.POST(url="/item/app/{}/filter/{}".format(app_id, view_id)) 105 | 106 | def find_all_by_external_id(self, app_id, external_id): 107 | return self.transport.GET(url='/item/app/%d/v2/?external_id=%r' % (app_id, external_id)) 108 | 109 | def revisions(self, item_id): 110 | return self.transport.GET(url='/item/%d/revision/' % item_id) 111 | 112 | def revision_difference(self, item_id, revision_from_id, revision_to_id): 113 | return self.transport.GET(url='/item/%d/revision/%d/%d' % (item_id, revision_from_id, 114 | revision_to_id)) 115 | 116 | def values(self, item_id): 117 | return self.transport.GET(url='/item/%s/value' % item_id) 118 | 119 | def values_v2(self, item_id): 120 | return self.transport.GET(url='/item/%s/value/v2' % item_id) 121 | 122 | def create(self, app_id, attributes, silent=False, hook=True): 123 | if not isinstance(attributes, dict): 124 | raise TypeError('Must be of type dict') 125 | attributes = json.dumps(attributes) 126 | return self.transport.POST(body=attributes, 127 | type='application/json', 128 | url='/item/app/%d/%s' % (app_id, 129 | self.get_options(silent=silent, 130 | hook=hook))) 131 | 132 | def update(self, item_id, attributes, silent=False, hook=True): 133 | """ 134 | Updates the item using the supplied attributes. If 'silent' is true, Podio will send 135 | no notifications to subscribed users and not post updates to the stream. 136 | 137 | Important: webhooks will still be called. 138 | """ 139 | if not isinstance(attributes, dict): 140 | raise TypeError('Must be of type dict') 141 | attributes = json.dumps(attributes) 142 | return self.transport.PUT(body=attributes, 143 | type='application/json', 144 | url='/item/%d%s' % (item_id, self.get_options(silent=silent, 145 | hook=hook))) 146 | 147 | def delete(self, item_id, silent=False, hook=True): 148 | return self.transport.DELETE(url='/item/%d%s' % (item_id, 149 | self.get_options(silent=silent, 150 | hook=hook)), 151 | handler=lambda x, y: None) 152 | 153 | 154 | class Application(Area): 155 | def activate(self, app_id): 156 | """ 157 | Activates the application with app_id 158 | 159 | :param app_id: Application ID 160 | :type app_id: str or int 161 | :return: Python dict of JSON response 162 | :rtype: dict 163 | """ 164 | return self.transport.POST(url='/app/%s/activate' % app_id) 165 | 166 | def create(self, attributes): 167 | if not isinstance(attributes, dict): 168 | raise TypeError('Must be of type dict') 169 | attributes = json.dumps(attributes) 170 | return self.transport.POST(url='/app/', body=attributes, type='application/json') 171 | 172 | def add_field(self, app_id, attributes): 173 | """ 174 | Adds a new field to app with app_id 175 | 176 | :param app_id: Application ID 177 | :type app_id: str or int 178 | :param attributes: Refer to API. 179 | :type attributes: dict 180 | :return: Python dict of JSON response 181 | :rtype: dict 182 | """ 183 | if not isinstance(attributes, dict): 184 | raise TypeError('Must be of type dict') 185 | attributes = json.dumps(attributes) 186 | return self.transport.POST(url='/app/%s/field/' % app_id, body=attributes, 187 | type='application/json') 188 | 189 | def deactivate(self, app_id): 190 | """ 191 | Deactivates the application with app_id 192 | 193 | :param app_id: Application ID 194 | :type app_id: str or int 195 | :return: Python dict of JSON response 196 | :rtype: dict 197 | """ 198 | return self.transport.POST(url='/app/%s/deactivate' % app_id) 199 | 200 | def delete(self, app_id): 201 | """ 202 | Deletes the app with the given id. 203 | 204 | :param app_id: Application ID 205 | :type app_id: str or int 206 | """ 207 | return self.transport.DELETE(url='/app/%s' % app_id) 208 | 209 | def find(self, app_id): 210 | """ 211 | Finds application with id app_id. 212 | 213 | :param app_id: Application ID 214 | :type app_id: str or int 215 | :return: Python dict of JSON response 216 | :rtype: dict 217 | """ 218 | return self.transport.GET(url='/app/%s' % app_id) 219 | 220 | def dependencies(self, app_id): 221 | """ 222 | Finds application dependencies for app with id app_id. 223 | 224 | :param app_id: Application ID 225 | :type app_id: str or int 226 | :return: Python dict of JSON response with the apps that the given app depends on. 227 | :rtype: dict 228 | """ 229 | return self.transport.GET(url='/app/%s/dependencies/' % app_id) 230 | 231 | def get_items(self, app_id, **kwargs): 232 | return self.transport.GET(url='/item/app/%s/' % app_id, **kwargs) 233 | 234 | def list_in_space(self, space_id): 235 | """ 236 | Returns a list of all the visible apps in a space. 237 | 238 | :param space_id: Space ID 239 | :type space_id: str 240 | """ 241 | return self.transport.GET(url='/app/space/%s/' % space_id) 242 | 243 | 244 | class Task(Area): 245 | def get(self, **kwargs): 246 | """ 247 | Get tasks endpoint. QueryStrings are kwargs 248 | """ 249 | return self.transport.GET('/task/', **kwargs) 250 | 251 | def delete(self, task_id): 252 | """ 253 | Deletes the app with the given id. 254 | 255 | :param task_id: Task ID 256 | :type task_id: str or int 257 | """ 258 | return self.transport.DELETE(url='/task/%s' % task_id) 259 | 260 | def complete(self, task_id): 261 | """ 262 | Mark the given task as completed. 263 | 264 | :param task_id: Task ID 265 | :type task_id: str or int 266 | """ 267 | return self.transport.POST(url='/task/%s/complete' % task_id) 268 | 269 | def create(self, attributes, silent=False, hook=True): 270 | """ 271 | https://developers.podio.com/doc/tasks/create-task-22419 272 | Creates the task using the supplied attributes. If 'silent' is true, 273 | Podio will send no notifications to subscribed users and not post 274 | updates to the stream. If 'hook' is false webhooks will not be called. 275 | """ 276 | # if not isinstance(attributes, dict): 277 | # raise TypeError('Must be of type dict') 278 | attributes = json.dumps(attributes) 279 | return self.transport.POST(url='/task/%s' % self.get_options(silent=silent, hook=hook), 280 | body=attributes, 281 | type='application/json') 282 | 283 | def create_for(self, ref_type, ref_id, attributes, silent=False, hook=True): 284 | """ 285 | https://developers.podio.com/doc/tasks/create-task-with-reference-22420 286 | If 'silent' is true, Podio will send no notifications and not post 287 | updates to the stream. If 'hook' is false webhooks will not be called. 288 | """ 289 | # if not isinstance(attributes, dict): 290 | # raise TypeError('Must be of type dict') 291 | attributes = json.dumps(attributes) 292 | return self.transport.POST(body=attributes, 293 | type='application/json', 294 | url='/task/%s/%s/%s' % (ref_type, ref_id, 295 | self.get_options(silent=silent, 296 | hook=hook))) 297 | 298 | 299 | class User(Area): 300 | def current(self): 301 | return self.transport.get(url='/user/') 302 | 303 | 304 | class Org(Area): 305 | def get_all(self): 306 | return self.transport.get(url='/org/') 307 | 308 | 309 | class Status(Area): 310 | def find(self, status_id): 311 | return self.transport.GET(url='/status/%s' % status_id) 312 | 313 | def create(self, space_id, attributes): 314 | attributes = json.dumps(attributes) 315 | return self.transport.POST(url='/status/space/%s/' % space_id, 316 | body=attributes, type='application/json') 317 | 318 | 319 | class Space(Area): 320 | def find(self, space_id): 321 | return self.transport.GET(url='/space/%s' % space_id) 322 | 323 | def find_by_url(self, space_url, id_only=True): 324 | """ 325 | Returns a space ID given the URL of the space. 326 | 327 | :param space_url: URL of the Space 328 | :param id_only: ? 329 | :return: space_id: Space url 330 | :rtype: str 331 | """ 332 | resp = self.transport.GET(url='/space/url?%s' % urlencode({'url': space_url})) 333 | if id_only: 334 | return resp['space_id'] 335 | return resp 336 | 337 | def find_all_for_org(self, org_id): 338 | """ 339 | Find all of the spaces in a given org. 340 | 341 | :param org_id: Orginization ID 342 | :type org_id: str 343 | :return: Details of spaces 344 | :rtype: dict 345 | """ 346 | return self.transport.GET(url='/org/%s/space/' % org_id) 347 | 348 | def create(self, attributes): 349 | """ 350 | Create a new space 351 | 352 | :param attributes: Refer to API. Pass in argument as dictionary 353 | :type attributes: dict 354 | :return: Details of newly created space 355 | :rtype: dict 356 | """ 357 | if not isinstance(attributes, dict): 358 | raise TypeError('Dictionary of values expected') 359 | attributes = json.dumps(attributes) 360 | return self.transport.POST(url='/space/', body=attributes, type='application/json') 361 | 362 | 363 | class Stream(Area): 364 | """ 365 | The stream API will supply the different streams. Currently 366 | supported is the global stream, the organization stream and the 367 | space stream. 368 | 369 | For details, see: https://developers.podio.com/doc/stream/ 370 | """ 371 | def find_all_by_app_id(self, app_id): 372 | """ 373 | Returns the stream for the given app. This includes items from 374 | the app and tasks on the app. 375 | 376 | For details, see: https://developers.podio.com/doc/stream/get-app-stream-264673 377 | """ 378 | return self.transport.GET(url='/stream/app/%s/' % app_id) 379 | 380 | def find_all(self): 381 | """ 382 | Returns the global stream. The types of objects in the stream 383 | can be either "item", "status", "task", "action" or 384 | "file". The data part of the result depends on the type of 385 | object and is specified on this page: 386 | 387 | https://developers.podio.com/doc/stream/get-global-stream-80012 388 | """ 389 | return self.transport.GET(url='/stream/') 390 | 391 | def find_all_by_org_id(self, org_id): 392 | """ 393 | Returns the activity stream for the given organization. 394 | 395 | For details, see: https://developers.podio.com/doc/stream/get-organization-stream-80038 396 | """ 397 | return self.transport.GET(url='/stream/org/%s/' % org_id) 398 | 399 | def find_all_personal(self): 400 | """ 401 | Returns the personal stream from personal spaces and sub-orgs. 402 | 403 | For details, see: https://developers.podio.com/doc/stream/get-personal-stream-1656647 404 | """ 405 | return self.transport.GET(url='/stream/personal/') 406 | 407 | def find_all_by_space_id(self, space_id): 408 | """ 409 | Returns the activity stream for the space. 410 | 411 | For details, see: https://developers.podio.com/doc/stream/get-space-stream-80039 412 | """ 413 | return self.transport.GET(url='/stream/space/%s/' % space_id) 414 | 415 | def find_by_ref(self, ref_type, ref_id): 416 | """ 417 | Returns an object of type "item", "status" or "task" as a 418 | stream object. This is useful when a new status has been 419 | posted and should be rendered directly in the stream without 420 | reloading the entire stream. 421 | 422 | For details, see: https://developers.podio.com/doc/stream/get-stream-object-80054 423 | """ 424 | return self.transport.GET(url='/stream/%s/%s' % (ref_type, ref_id)) 425 | 426 | 427 | class Hook(Area): 428 | def create(self, hookable_type, hookable_id, attributes): 429 | attributes = json.dumps(attributes) 430 | return self.transport.POST(url='/hook/%s/%s/' % (hookable_type, hookable_id), 431 | body=attributes, type='application/json') 432 | 433 | def verify(self, hook_id): 434 | return self.transport.POST(url='/hook/%s/verify/request' % hook_id) 435 | 436 | def validate(self, hook_id, code): 437 | return self.transport.POST(url='/hook/%s/verify/validate' % hook_id, code=code) 438 | 439 | def delete(self, hook_id): 440 | return self.transport.DELETE(url='/hook/%s' % hook_id) 441 | 442 | def find_all_for(self, hookable_type, hookable_id): 443 | return self.transport.GET(url='/hook/%s/%s/' % (hookable_type, hookable_id)) 444 | 445 | 446 | class Connection(Area): 447 | def create(self, attributes): 448 | attributes = json.dumps(attributes) 449 | return self.transport.POST(url='/connection/', body=attributes, type='application/json') 450 | 451 | def find(self, conn_id): 452 | return self.transport.GET(url='/connection/%s' % conn_id) 453 | 454 | def delete(self, conn_id): 455 | return self.transport.DELETE(url='/connection/%s' % conn_id) 456 | 457 | def reload(self, conn_id): 458 | return self.transport.POST(url='/connection/%s/load' % conn_id) 459 | 460 | 461 | class Notification(Area): 462 | def find(self, notification_id): 463 | return self.transport.GET(url='/notification/%s' % notification_id) 464 | 465 | def find_all(self): 466 | return self.transport.GET(url='/notification/') 467 | 468 | def get_inbox_new_count(self): 469 | return self.transport.GET(url='/notification/inbox/new/count') 470 | 471 | def mark_as_viewed(self, notification_id): 472 | return self.transport.POST(url='/notification/%s/viewed' % notification_id) 473 | 474 | def mark_all_as_viewed(self): 475 | return self.transport.POST(url='/notification/viewed') 476 | 477 | def star(self, notification_id): 478 | return self.transport.POST(url='/notification/%s/star' % notification_id) 479 | 480 | def unstar(self, notification_id): 481 | return self.transport.DELETE(url='/notification/%s/star' % notification_id) 482 | 483 | 484 | class Conversation(Area): 485 | def find_all(self): 486 | return self.transport.GET(url='/conversation/') 487 | 488 | def find(self, conversation_id): 489 | return self.transport.GET(url='/conversation/%s' % conversation_id) 490 | 491 | def create(self, attributes): 492 | attributes = json.dumps(attributes) 493 | return self.transport.POST(url='/conversation/', body=attributes, type='application/json') 494 | 495 | def star(self, conversation_id): 496 | return self.transport.POST(url='/conversation/%s/star' % conversation_id) 497 | 498 | def unstar(self, conversation_id): 499 | return self.transport.DELETE(url='/conversation/%s/star' % conversation_id) 500 | 501 | def leave(self, conversation_id): 502 | return self.transport.POST(url='/conversation/%s/leave' % conversation_id) 503 | 504 | 505 | class Files(Area): 506 | def find(self, file_id): 507 | pass 508 | 509 | def find_raw(self, file_id): 510 | """Returns raw file as string. Pass to a file object""" 511 | raw_handler = lambda resp, data: data 512 | return self.transport.GET(url='/file/%d/raw' % file_id, handler=raw_handler) 513 | 514 | def attach(self, file_id, ref_type, ref_id): 515 | attributes = { 516 | 'ref_type': ref_type, 517 | 'ref_id': ref_id 518 | } 519 | return self.transport.POST(url='/file/%s/attach' % file_id, body=json.dumps(attributes), 520 | type='application/json') 521 | 522 | def create(self, filename, filedata): 523 | """Create a file from raw data""" 524 | attributes = {'filename': filename, 525 | 'source': filedata} 526 | return self.transport.POST(url='/file/v2/', body=attributes, type='multipart/form-data') 527 | 528 | def copy(self, file_id): 529 | """Copy a file to generate a new file_id""" 530 | 531 | return self.transport.POST(url='/file/%s/copy' % file_id) 532 | 533 | 534 | class View(Area): 535 | 536 | def create(self, app_id, attributes): 537 | """ 538 | Creates a new view on the specified app 539 | 540 | :param app_id: the application id 541 | :param attributes: the body of the request as a dictionary 542 | """ 543 | if not isinstance(attributes, dict): 544 | raise TypeError('Must be of type dict') 545 | attributes = json.dumps(attributes) 546 | return self.transport.POST(url='/view/app/{}/'.format(app_id), 547 | body=attributes, type='application/json') 548 | 549 | def delete(self, view_id): 550 | """ 551 | Delete the associated view 552 | 553 | :param view_id: id of the view to delete 554 | """ 555 | return self.transport.DELETE(url='/view/{}'.format(view_id)) 556 | 557 | def get(self, app_id, view_specifier): 558 | """ 559 | Retrieve the definition of a given view, provided the app_id and the view_id 560 | 561 | :param app_id: the app id 562 | :param view_specifier: 563 | Can be one of the following: 564 | 1. The view ID 565 | 2. The view's name 566 | 3. "last" to look up the last view used 567 | """ 568 | return self.transport.GET(url='/view/app/{}/{}'.format(app_id, view_specifier)) 569 | 570 | def get_views(self, app_id, include_standard_views=False): 571 | """ 572 | Get all of the views for the specified app 573 | 574 | :param app_id: the app containing the views 575 | :param include_standard_views: defaults to false. Set to true if you wish to include standard views. 576 | """ 577 | include_standard = "true" if include_standard_views is True else "false" 578 | return self.transport.GET(url='/view/app/{}/?include_standard_views={}'.format(app_id, include_standard)) 579 | 580 | def make_default(self, view_id): 581 | """ 582 | Makes the view with the given id the default view for the app. The view must be of type 583 | "saved" and must be active. In addition the user most have right to update the app. 584 | 585 | :param view_id: the unique id of the view you wish to make the default 586 | """ 587 | return self.transport.POST(url='/view/{}/default'.format(view_id)) 588 | 589 | def update_last_view(self, app_id, attributes): 590 | """ 591 | Updates the last view for the active user 592 | 593 | :param app_id: the app id 594 | :param attributes: the body of the request in dictionary format 595 | """ 596 | if not isinstance(attributes, dict): 597 | raise TypeError('Must be of type dict') 598 | attribute_data = json.dumps(attributes) 599 | return self.transport.PUT(url='/view/app/{}/last'.format(app_id), 600 | body=attribute_data, type='application/json') 601 | 602 | def update_view(self, view_id, attributes): 603 | """ 604 | Update an existing view using the details supplied via the attributes parameter 605 | 606 | :param view_id: the view's id 607 | :param attributes: a dictionary containing the modifications to be made to the view 608 | :return: 609 | """ 610 | if not isinstance(attributes, dict): 611 | raise TypeError('Must be of type dict') 612 | attribute_data = json.dumps(attributes) 613 | return self.transport.PUT(url='/view/{}'.format(view_id), 614 | body=attribute_data, type='application/json') 615 | 616 | --------------------------------------------------------------------------------