├── MANIFEST.in ├── docs ├── source │ ├── .static │ │ └── .gitignore │ ├── modules.rst │ ├── index.md │ ├── pypco.rst │ ├── conf.py │ ├── gettingstarted.md │ └── apitour.md └── Makefile ├── .coveragerc ├── .python-version ├── tests ├── assets │ └── test_upload.jpg ├── test_pypco_module.py ├── cassettes │ ├── TestPublicRequestFunctions.test_empty_response.yaml │ ├── TestPublicRequestFunctions.test_patch.yaml │ ├── TestPublicRequestFunctions.test_post.yaml │ ├── TestPublicRequestFunctions.test_delete.yaml │ ├── TestPublicRequestFunctions.test_request_json.yaml │ ├── TestPublicRequestFunctions.test_put.yaml │ ├── TestPublicRequestFunctions.test_request_response.yaml │ └── TestPublicRequestFunctions.test_get.yaml ├── __init__.py ├── test_auth_config.py ├── test_user_auth_helpers.py └── test_pco.py ├── .gitignore ├── Pipfile ├── .github └── workflows │ ├── build.yml │ └── coverage.yml ├── pypco ├── __init__.py ├── exceptions.py ├── auth_config.py ├── user_auth_helpers.py └── pco.py ├── tox.ini ├── LICENSE ├── contributing.md ├── setup.py ├── CHANGELOG.txt ├── tools └── random_people_generator.py ├── README.md └── .pylintrc /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md -------------------------------------------------------------------------------- /docs/source/.static/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = pypco -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.6.15 2 | 3.7.13 3 | 3.8.13 4 | 3.9.1 5 | 3.10.6 -------------------------------------------------------------------------------- /docs/source/modules.rst: -------------------------------------------------------------------------------- 1 | pypco 2 | ===== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | pypco 8 | -------------------------------------------------------------------------------- /tests/assets/test_upload.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/billdeitrick/pypco/HEAD/tests/assets/test_upload.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # General 2 | __pycache__ 3 | *.pyc 4 | 5 | # Python build 6 | dist 7 | build 8 | *.egg-info 9 | 10 | # IDEs 11 | .vscode 12 | .idea 13 | 14 | # Project-specific 15 | .env 16 | logs 17 | 18 | # CI 19 | .tox 20 | .coverage 21 | 22 | # Finder 23 | .DS_Store -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.python.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | requests = "*" 8 | 9 | [dev-packages] 10 | pylint = "*" 11 | vcrpy = "*" 12 | vcrpy-unittest = "*" 13 | docopt = "*" 14 | "jinja2" = "*" 15 | tox = "*" 16 | twine = "*" 17 | versionbump = "*" 18 | recommonmark = "*" 19 | sphinx-rtd-theme = "*" 20 | sphinx = "*" 21 | -------------------------------------------------------------------------------- /docs/source/index.md: -------------------------------------------------------------------------------- 1 | 2 | # Welcome to the pypco Documentation! 3 | 4 | Pypco provides a simple Python interface to communicate with the [Planning Center Online](https://planning.center) REST API. With automatic rate limit handling, pagination handling, object templating, and support for the full breadth of the PCO API, pypco can support any Python application or script needing to utilize the PCO API. 5 | 6 | # Contents 7 | 8 | * [Getting Started](gettingstarted) 9 | * [API Tour](apitour) 10 | * [Modules](modules) 11 | 12 | # Indices and tables 13 | 14 | * [Index](genindex) 15 | * [Modules](modules) 16 | * [Search](search) 17 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # Test with Tox: https://github.com/ymyzk/tox-gh-actions#workflow-configuration 2 | 3 | name: Build & Test 4 | 5 | on: 6 | - push 7 | - pull_request 8 | - workflow_dispatch 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | python -m pip install tox tox-gh-actions 27 | - name: Test with tox 28 | run: tox -------------------------------------------------------------------------------- /docs/source/pypco.rst: -------------------------------------------------------------------------------- 1 | pypco package 2 | ============= 3 | 4 | Submodules 5 | ---------- 6 | 7 | pypco.auth\_config module 8 | ------------------------- 9 | 10 | .. automodule:: pypco.auth_config 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | pypco.exceptions module 16 | ----------------------- 17 | 18 | .. automodule:: pypco.exceptions 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | pypco.pco module 24 | ---------------- 25 | 26 | .. automodule:: pypco.pco 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | pypco.user\_auth\_helpers module 32 | -------------------------------- 33 | 34 | .. automodule:: pypco.user_auth_helpers 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | Module contents 40 | --------------- 41 | 42 | .. automodule:: pypco 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | -------------------------------------------------------------------------------- /pypco/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | A Pythonic Object-Oriented wrapper to the PCO API 3 | 4 | pypco is a Python wrapper for the Planning Center Online (PCO) REST API 5 | intended to help you accomplish useful things with Python and the PCO API 6 | more quickly. pypco provides simple helpers wrapping the REST calls you'll 7 | place against the PCO API, meaning that you'll be spending your time 8 | directly in the PCO API docs rather than those specific to your API wrapper 9 | tool of choice. 10 | 11 | usage: 12 | >>> import pypco 13 | >>> pco = pypco.PCO() 14 | 15 | pypco supports both OAUTH and Personal Access Token (PAT) authentication. 16 | """ 17 | 18 | # PyPCO Version 19 | __version__ = "1.2.0" 20 | 21 | # Export the interface we present to clients 22 | 23 | # The primary PCO interface object 24 | from .pco import PCO 25 | 26 | # Utility functions for OAUTH 27 | from .user_auth_helpers import * 28 | 29 | # Import exceptions 30 | from .exceptions import * 31 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = source 8 | BUILDDIR = build 9 | SPHINXAPIDOC = sphinx-apidoc 10 | MODULEDIR = ../pypco 11 | 12 | # Put it first so that "make" without argument is like "make help". 13 | help: 14 | $(info ** Run `make apidoc` first to build automatic API docs. **) 15 | $(info Generally, run `make apidoc` then `make html` to build pypco docs.) 16 | $(info `make apidoc` produces output that should be committed.) 17 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 18 | 19 | .PHONY: help Makefile 20 | 21 | apidoc: 22 | @$(SPHINXAPIDOC) -f -o "$(SOURCEDIR)" "$(MODULEDIR)" 23 | 24 | # Catch-all target: route all unknown targets to Sphinx using the new 25 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 26 | %: Makefile 27 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # tox (https://tox.readthedocs.io/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = py{37,38,39,310,311}, pylint, docs,mypy 8 | 9 | [gh-actions] 10 | python = 11 | 3.7: py37 12 | 3.8: py38 13 | 3.9: py39 14 | 3.10: py310, pylint 15 | 3.11: py311, docs, mypy 16 | 17 | [testenv:pylint] 18 | deps = 19 | requests 20 | vcrpy-unittest 21 | vcrpy 22 | pylint==2.4.4 23 | commands = 24 | pylint pypco 25 | 26 | [testenv:docs] 27 | deps = 28 | sphinx 29 | recommonmark 30 | sphinx-rtd-theme 31 | commands = 32 | sphinx-apidoc -f -o "docs/source" pypco 33 | sphinx-build -M html "docs/source" "{toxworkdir}/docs/build" -n 34 | 35 | [testenv:mypy] 36 | deps= 37 | mypy 38 | types-requests 39 | commands= 40 | mypy ./pypco 41 | 42 | [testenv] 43 | deps = 44 | requests 45 | vcrpy-unittest 46 | vcrpy 47 | commands = 48 | python -m unittest discover 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 William Deitrick 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | Thanks for your interest in contributing to pypco! 2 | 3 | Contributions are always welcome. Here are a few things to keep in mind if you're contributing code: 4 | 5 | 1. Don't make major changes without creating an issue to talk about it first. 6 | 2. Don't break backwards compatibility for anyone who is currently using the library. 7 | 3. Provide unit tests with your code. Code won't be merged until it has test coverage; the quickest way to make that happen is for you to provide tests with your pull request. If you're fixing a bug, write a unit test that fails by triggering the bug. Then, make the change to fix the bug, proving that your code resolves the problem. 8 | 4. Update the documentation if your code makes a change (or update/improvement) to user-facing behavior. Code that makes user-facing changes will not be merged until those changes are documented. The quickest way to make that happen is for you to update the documenation appropriately as part of your pull request. 9 | 5. Provide helpful docstrings for any new functions you add, and update docstrings as needed if you make enhancements or add new parameters a user might want to know about. 10 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Code Coverage 5 | 6 | on: 7 | - push 8 | - pull_request 9 | - workflow_dispatch 10 | 11 | jobs: 12 | build: 13 | 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | python-version: ["3.11"] 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v3 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | python -m pip install pipenv codecov coverage requests vcrpy-unittest vcrpy 30 | pipenv install 31 | - name: Run with coverage 32 | run: | 33 | coverage run -m unittest discover 34 | - name: Upload coverage report 35 | uses: codecov/codecov-action@v3 36 | with: 37 | fail_ci_if_error: true 38 | verbose: true 39 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | 2 | from setuptools import ( 3 | setup, 4 | find_packages 5 | ) 6 | 7 | with open('README.md', 'r') as fh: 8 | long_description = fh.read() 9 | 10 | setup( 11 | name='pypco', 12 | version='1.2.0', 13 | description='A Python wrapper for the Planning Center Online API.', 14 | long_description=long_description, 15 | long_description_content_type='text/markdown', 16 | url='https://github.com/billdeitrick/pypco', 17 | author='Bill Deitrick', 18 | author_email='hello@billdeitrick.com', 19 | python_requires='>=3.7.0', 20 | license='MIT', 21 | packages=find_packages( 22 | exclude=[ 23 | 'tests.*', 24 | 'tests' 25 | ] 26 | ), 27 | install_requires=[ 28 | 'requests' 29 | ], 30 | zip_safe=True, 31 | classifiers=[ 32 | 'Development Status :: 5 - Production/Stable', 33 | 'Intended Audience :: Developers', 34 | 'License :: OSI Approved :: MIT License', 35 | 'Operating System :: OS Independent', 36 | 'Programming Language :: Python :: 3.7', 37 | 'Programming Language :: Python :: 3.8', 38 | 'Programming Language :: Python :: 3.9', 39 | 'Programming Language :: Python :: 3.10', 40 | 'Programming Language :: Python :: 3.11', 41 | 'Topic :: Software Development :: Libraries' 42 | ] 43 | ) 44 | -------------------------------------------------------------------------------- /tests/test_pypco_module.py: -------------------------------------------------------------------------------- 1 | """Test pypco module.""" 2 | 3 | #pylint: disable=unused-import 4 | 5 | import unittest 6 | 7 | import pypco 8 | 9 | class TestExpectedImports(unittest.TestCase): 10 | """Verify expected classes and functions can be resolved from primary module.""" 11 | 12 | def test_main_class_available(self): 13 | """Verify primary PCO class can be resolved.""" 14 | 15 | try: 16 | from pypco import PCO 17 | except ImportError as err: 18 | self.fail(err.msg) 19 | 20 | def test_exception_classes_available(self): 21 | """Verify exception classes can be resolved.""" 22 | 23 | try: 24 | from pypco import PCOException 25 | from pypco import PCOCredentialsException 26 | from pypco import PCORequestTimeoutException 27 | from pypco import PCOUnexpectedRequestException 28 | from pypco import PCORequestTimeoutException 29 | except ImportError as err: 30 | self.fail(err.msg) 31 | 32 | def test_user_auth_helper_classes_available(self): 33 | """Verify user auth helper classes can be resolved.""" 34 | 35 | try: 36 | from pypco import get_browser_redirect_url 37 | from pypco import get_oauth_access_token 38 | from pypco import get_oauth_refresh_token 39 | except ImportError as err: 40 | self.fail(err.msg) 41 | -------------------------------------------------------------------------------- /pypco/exceptions.py: -------------------------------------------------------------------------------- 1 | """All pypco exceptions.""" 2 | 3 | class PCOException(Exception): 4 | """A base class for all pypco exceptions.""" 5 | 6 | class PCOCredentialsException(PCOException): 7 | """Unusable credentials are supplied to pypco.""" 8 | 9 | class PCORequestTimeoutException(PCOException): 10 | """Request to PCO timed out after the maximum number of retries.""" 11 | 12 | class PCOUnexpectedRequestException(PCOException): 13 | """An unexpected exception has occurred attempting to make the request. 14 | 15 | We don't have any additional information associated with this exception. 16 | """ 17 | 18 | class PCORequestException(PCOException): 19 | """The response from the PCO API indicated an error with your request. 20 | 21 | Args: 22 | status_code (int): The HTTP status code corresponding to the error. 23 | message (str): The error message string. 24 | response_body (str): The body of the response (may include helpful information). 25 | Defaults to None. 26 | 27 | Attributes: 28 | status_code (int): The HTTP status code returned. 29 | message (str): The error message string. 30 | response_body (str): Text included in the response body. Often 31 | includes additional informative errors describing the problem 32 | encountered. 33 | """ 34 | 35 | def __init__(self, status_code, message, response_body=None): #pylint: disable=super-init-not-called 36 | self.status_code = status_code 37 | self.message = message 38 | self.response_body = response_body 39 | -------------------------------------------------------------------------------- /CHANGELOG.txt: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [1.2.0] - 2023-03-03 8 | ### Added 9 | - New build pipeline on Github Actions 10 | - Add typing and mypy testing (thanks @07pepa!) 11 | - Add Church Center Org Token authentication (thanks @pastorhudson!) 12 | - Add support and testing for Python versions 3.7 - 3.11 13 | 14 | ### Changed 15 | - Readability improvements: replace .format() with f strings (thanks @07pepa!) 16 | 17 | ### Fixed 18 | - Fix 204 response handling from PCO API (thanks @warmach, @pastorhudson!) 19 | 20 | ## [1.1.0] - 2020-03-10 21 | ### Added 22 | - Utilize a session object to enable connection pooling 23 | 24 | ## [1.0.0] - 2020-01-25 25 | ### Changed 26 | - 1.0 is almost a complete rewrite, and is not backwards compatible with v0. There are too many changes to list here, so check out the [docs](https://pypco.readthedocs.io/en/stable/). 27 | 28 | ## [0.0.2] - 2019-01-01 29 | ### Fixed 30 | - Request timeout handling added. HTTP calls that never generate a response will no longer cause applications to hang. If three subsequent HTTP calls time out, an exception will be raised. 31 | - Order and per_page parameters not being passed when using rel.list() 32 | 33 | ## [0.0.1] - 2018-11-26 34 | ### Changed 35 | - Now using the User-Agent "pypco" for all HTTP calls to the PCO API 36 | 37 | ### Fixed 38 | - Fix missing imports preventing Resources and Webhooks APIs from working 39 | 40 | ## [0.0.0] - 2018-11-24 41 | ### Added 42 | - Initial alpha release -------------------------------------------------------------------------------- /tests/cassettes/TestPublicRequestFunctions.test_empty_response.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - pypco 13 | method: GET 14 | uri: https://api.planningcenteronline.com/people/v2/lists/1097503/run 15 | response: 16 | body: 17 | string: '' 18 | headers: 19 | Access-Control-Allow-Credentials: 20 | - 'true' 21 | Access-Control-Allow-Headers: 22 | - Accept, Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, 23 | If-Unmodified-Since, Origin, X-Requested-With, X-CSRF-Token, X-PCO-API-Version, 24 | X-PCO-API-Log-Level, X-PCO-API-Tracer-Class, X-PCO-API-Tracer-Secret 25 | Access-Control-Allow-Methods: 26 | - GET, POST, PATCH, PUT, DELETE, OPTIONS 27 | Access-Control-Allow-Origin: 28 | - '*' 29 | Access-Control-Expose-Headers: 30 | - ETag, Link, X-PCO-API-Auth-Method, X-PCO-API-Request-Rate-Count, X-PCO-API-Request-Rate-Limit, 31 | X-PCO-API-Request-Rate-Period, Retry-After 32 | Cache-Control: 33 | - no-cache 34 | Connection: 35 | - keep-alive 36 | Date: 37 | - Fri, 20 Jan 2023 01:42:25 GMT 38 | Referrer-Policy: 39 | - no-referrer-when-downgrade 40 | Strict-Transport-Security: 41 | - max-age=31536000; includeSubDomains 42 | Vary: 43 | - Accept, Accept-Encoding, Origin 44 | X-Content-Type-Options: 45 | - nosniff 46 | X-Download-Options: 47 | - noopen 48 | X-Frame-Options: 49 | - SAMEORIGIN 50 | X-PCO-API-Auth-Method: 51 | - HTTPBasic 52 | X-PCO-API-Processed-As-Version: 53 | - '2022-07-14' 54 | X-PCO-API-Processor: 55 | - ENG_4.8.1 56 | X-PCO-API-Request-Rate-Count: 57 | - '1' 58 | X-PCO-API-Request-Rate-Limit: 59 | - '100' 60 | X-PCO-API-Request-Rate-Period: 61 | - '20' 62 | X-Permitted-Cross-Domain-Policies: 63 | - none 64 | X-Request-Id: 65 | - 6960500b-2f96-4431-b4ed-01fbc44d6fa0 66 | X-Runtime: 67 | - '0.594637' 68 | X-XSS-Protection: 69 | - 1; mode=block 70 | status: 71 | code: 204 72 | message: No Content 73 | version: 1 74 | -------------------------------------------------------------------------------- /tools/random_people_generator.py: -------------------------------------------------------------------------------- 1 | """A quick script to generate arbitrary numbers of random people in PCO. 2 | 3 | Usage: `python tools/random_people_generator.py 100` 4 | (100 is number of records to generate) 5 | 6 | Useful for quickly mocking a large amount of test data. 7 | 8 | Expects a PCO_APP_ID and PCO_SECRET to be present as environment variables 9 | """ 10 | 11 | import os 12 | import random 13 | import sys 14 | 15 | # Not quite sure why we're needing to do this for this import, 16 | # but it makes the import work. 17 | sys.path.append('..') 18 | sys.path.append('.') 19 | 20 | PCO_APP_ID = os.environ['PCO_APP_ID'] 21 | PCO_SECRET = os.environ['PCO_SECRET'] 22 | 23 | from pypco import PCO #pylint: disable=wrong-import-position 24 | 25 | def generate_rand_string(length): 26 | """Generate a random string of the requested length. 27 | 28 | Returns lowercase letters. 29 | 30 | Args: 31 | length (int): The length of the random string. 32 | 33 | Returns 34 | (str): The random string. 35 | """ 36 | 37 | output = [] 38 | 39 | for ndx in range(length): #pylint: disable=unused-variable 40 | output += chr(random.randint(97,122)) 41 | 42 | return ''.join(output) 43 | 44 | def generate_people(num_people): 45 | """Generate the specified number of random people objects. 46 | 47 | Also generates the emails for the random people using the Mailinator service 48 | (https://www.mailinator.com/) 49 | """ 50 | 51 | pco = PCO(PCO_APP_ID, PCO_SECRET) 52 | 53 | for ndx in range(num_people): 54 | 55 | new_person = PCO.template( 56 | 'Person', 57 | { 58 | 'first_name': generate_rand_string(6).title(), 59 | 'last_name': generate_rand_string(8).title(), 60 | } 61 | ) 62 | 63 | person = pco.post( 64 | '/people/v2/people', 65 | new_person 66 | ) 67 | 68 | person_id = person['data']['id'] 69 | 70 | new_email = PCO.template( 71 | 'Email', 72 | { 73 | 'address': f'{generate_rand_string(8)}@mailinator.com', 74 | 'location': 'Home', 75 | 'primary': True, 76 | } 77 | ) 78 | 79 | pco.post( 80 | f'/people/v2/people/{person_id}/emails', 81 | new_email 82 | ) 83 | 84 | sys.stdout.write(f'Created {ndx+1} of {num_people}\r') 85 | sys.stdout.flush() 86 | 87 | sys.stdout.write('\n') 88 | 89 | if __name__ == '__main__': 90 | generate_people(int(sys.argv[1])) 91 | -------------------------------------------------------------------------------- /pypco/auth_config.py: -------------------------------------------------------------------------------- 1 | """Internal authentication helper objects for pypco.""" 2 | 3 | import base64 4 | from enum import Enum, auto 5 | 6 | from typing import Optional 7 | 8 | from pypco.user_auth_helpers import get_cc_org_token 9 | from .exceptions import PCOCredentialsException 10 | 11 | 12 | class PCOAuthType(Enum): # pylint: disable=R0903 13 | """Defines PCO authentication types.""" 14 | 15 | PAT = auto() 16 | OAUTH = auto() 17 | ORGTOKEN = auto() 18 | 19 | 20 | class PCOAuthConfig: 21 | """Auth configuration for PCO. 22 | 23 | Args: 24 | application_id (str): The application ID for your application (PAT). 25 | secret (str): The secret for your application (PAT). 26 | token (str): The token for your application (OAUTH). 27 | cc_name (str): The vanity name portion of the .churchcenter.com url 28 | auth_type (PCOAuthType): The authentication type specified by this config object. 29 | """ 30 | 31 | def __init__( 32 | self, 33 | application_id: Optional[str] = None, # pylint: disable=unsubscriptable-object 34 | secret: Optional[str] = None, # pylint: disable=unsubscriptable-object 35 | token: Optional[str] = None, # pylint: disable=unsubscriptable-object 36 | cc_name: Optional[str] = None # pylint: disable=unsubscriptable-object 37 | ): 38 | 39 | self.application_id = application_id 40 | self.secret = secret 41 | self.token = token 42 | self.cc_name = cc_name 43 | 44 | @property 45 | def auth_type(self) -> PCOAuthType: 46 | """The authentication type specified by this configuration. 47 | 48 | Raises: 49 | PCOCredentialsException: You have specified invalid authentication information. 50 | 51 | Returns: 52 | PCOAuthType: The authentication type for this config. 53 | """ 54 | 55 | if self.application_id and self.secret and not (self.token or self.cc_name): # pylint: disable=no-else-return 56 | return PCOAuthType.PAT 57 | elif self.token and not (self.application_id or self.secret or self.cc_name): 58 | return PCOAuthType.OAUTH 59 | elif self.cc_name and not (self.application_id or self.secret or self.token): 60 | return PCOAuthType.ORGTOKEN 61 | else: 62 | raise PCOCredentialsException( 63 | "You have specified invalid authentication information. " 64 | "You must specify either an application id and a secret for " 65 | "your Personal Access Token (PAT) or an OAuth token." 66 | ) 67 | 68 | @property 69 | def auth_header(self) -> str: 70 | """Get the authorization header for this authentication configuration scheme. 71 | 72 | Returns: 73 | str: The authorization header text to pass as a request header. 74 | """ 75 | 76 | # If PAT, use Basic auth 77 | if self.auth_type == PCOAuthType.PAT: 78 | return "Basic " \ 79 | f"{base64.b64encode(f'{self.application_id}:{self.secret}'.encode()).decode()}" 80 | 81 | if self.auth_type == PCOAuthType.ORGTOKEN: 82 | return f"OrganizationToken {get_cc_org_token(self.cc_name)}" 83 | 84 | # Otherwise OAUTH using the Bearer scheme 85 | return f"Bearer {self.token}" 86 | -------------------------------------------------------------------------------- /tests/cassettes/TestPublicRequestFunctions.test_patch.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: '{"data": {"type": "Song", "attributes": {"author": "Anna Bartlett Warner"}}}' 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | Content-Length: 12 | - '76' 13 | Content-Type: 14 | - application/json 15 | User-Agent: 16 | - pypco 17 | method: PATCH 18 | uri: https://api.planningcenteronline.com/services/v2/songs/18338876 19 | response: 20 | body: 21 | string: '{"data":{"type":"Song","id":"18338876","attributes":{"admin":null,"author":"Anna 22 | Bartlett Warner","ccli_number":null,"copyright":null,"created_at":"2019-11-23T21:52:41Z","hidden":false,"last_scheduled_at":null,"last_scheduled_short_dates":null,"notes":null,"themes":null,"title":"Jesus 23 | Loves Me","updated_at":"2019-11-23T22:14:23Z"},"links":{"arrangements":"https://api.planningcenteronline.com/services/v2/songs/18338876/arrangements","assign_tags":"https://api.planningcenteronline.com/services/v2/songs/18338876/assign_tags","attachments":"https://api.planningcenteronline.com/services/v2/songs/18338876/attachments","last_scheduled_item":"https://api.planningcenteronline.com/services/v2/songs/18338876/last_scheduled_item","song_schedules":"https://api.planningcenteronline.com/services/v2/songs/18338876/song_schedules","tags":"https://api.planningcenteronline.com/services/v2/songs/18338876/tags","self":"https://api.planningcenteronline.com/services/v2/songs/18338876"}},"included":[],"meta":{"parent":{"id":"263468","type":"Organization"}}}' 24 | headers: 25 | Access-Control-Allow-Credentials: 26 | - 'true' 27 | Access-Control-Allow-Headers: 28 | - Accept, Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, 29 | If-Unmodified-Since, Origin, X-Requested-With, X-CSRF-Token, X-PCO-API-Version, 30 | X-PCO-API-Log-Level, X-PCO-API-Tracer-Class, X-PCO-API-Tracer-Secret 31 | Access-Control-Allow-Methods: 32 | - GET, POST, PATCH, PUT, DELETE, OPTIONS 33 | Access-Control-Allow-Origin: 34 | - '*' 35 | Access-Control-Expose-Headers: 36 | - ETag, Link, X-PCO-API-Auth-Method 37 | Cache-Control: 38 | - max-age=0, private, must-revalidate 39 | Connection: 40 | - keep-alive 41 | Content-Type: 42 | - application/vnd.api+json; charset=utf-8 43 | Date: 44 | - Sat, 23 Nov 2019 22:14:23 GMT 45 | ETag: 46 | - W/"85d2c2b699221c49dbbb33f0c66417b7" 47 | Referrer-Policy: 48 | - no-referrer-when-downgrade 49 | Server: 50 | - nginx 51 | Set-Cookie: 52 | - request_method=PATCH; path=/; secure 53 | Strict-Transport-Security: 54 | - max-age=15552000 55 | Transfer-Encoding: 56 | - chunked 57 | Vary: 58 | - Accept, Accept-Encoding, Origin 59 | X-Content-Type-Options: 60 | - nosniff 61 | X-Frame-Options: 62 | - SAMEORIGIN 63 | X-PCO-API-Auth-Method: 64 | - HTTPBasic 65 | X-PCO-API-Processed-As-Version: 66 | - '2018-08-01' 67 | X-PCO-API-Processor: 68 | - ENG_2.2.2 69 | X-PCO-API-Request-Rate-Count: 70 | - '1' 71 | X-PCO-API-Request-Rate-Limit: 72 | - '100' 73 | X-PCO-API-Request-Rate-Period: 74 | - '20' 75 | X-Request-Id: 76 | - 2a917cb9-acad-4478-81a6-68ed3c4bc2c1 77 | X-Runtime: 78 | - '0.157006' 79 | X-XSS-Protection: 80 | - 1; mode=block 81 | status: 82 | code: 200 83 | message: OK 84 | version: 1 85 | -------------------------------------------------------------------------------- /tests/cassettes/TestPublicRequestFunctions.test_post.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: '{"data": {"type": "Song", "attributes": {"title": "Jesus Loves Me", "author": 4 | "Public Domain"}}}' 5 | headers: 6 | Accept: 7 | - '*/*' 8 | Accept-Encoding: 9 | - gzip, deflate 10 | Connection: 11 | - keep-alive 12 | Content-Length: 13 | - '96' 14 | Content-Type: 15 | - application/json 16 | User-Agent: 17 | - pypco 18 | method: POST 19 | uri: https://api.planningcenteronline.com/services/v2/songs 20 | response: 21 | body: 22 | string: '{"data":{"type":"Song","id":"18338876","attributes":{"admin":null,"author":"Public 23 | Domain","ccli_number":null,"copyright":null,"created_at":"2019-11-23T21:52:41Z","hidden":false,"last_scheduled_at":null,"last_scheduled_short_dates":null,"notes":null,"themes":null,"title":"Jesus 24 | Loves Me","updated_at":"2019-11-23T21:52:41Z"},"links":{"arrangements":"https://api.planningcenteronline.com/services/v2/songs/18338876/arrangements","assign_tags":"https://api.planningcenteronline.com/services/v2/songs/18338876/assign_tags","attachments":"https://api.planningcenteronline.com/services/v2/songs/18338876/attachments","last_scheduled_item":"https://api.planningcenteronline.com/services/v2/songs/18338876/last_scheduled_item","song_schedules":"https://api.planningcenteronline.com/services/v2/songs/18338876/song_schedules","tags":"https://api.planningcenteronline.com/services/v2/songs/18338876/tags","self":"https://api.planningcenteronline.com/services/v2/songs/18338876"}},"included":[],"meta":{"parent":{"id":"263468","type":"Organization"}}}' 25 | headers: 26 | Access-Control-Allow-Credentials: 27 | - 'true' 28 | Access-Control-Allow-Headers: 29 | - Accept, Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, 30 | If-Unmodified-Since, Origin, X-Requested-With, X-CSRF-Token, X-PCO-API-Version, 31 | X-PCO-API-Log-Level, X-PCO-API-Tracer-Class, X-PCO-API-Tracer-Secret 32 | Access-Control-Allow-Methods: 33 | - GET, POST, PATCH, PUT, DELETE, OPTIONS 34 | Access-Control-Allow-Origin: 35 | - '*' 36 | Access-Control-Expose-Headers: 37 | - ETag, Link, X-PCO-API-Auth-Method 38 | Cache-Control: 39 | - max-age=0, private, must-revalidate 40 | Connection: 41 | - keep-alive 42 | Content-Type: 43 | - application/vnd.api+json; charset=utf-8 44 | Date: 45 | - Sat, 23 Nov 2019 21:52:41 GMT 46 | ETag: 47 | - W/"0e2954c4f87b54199de56030c7f6e11c" 48 | Referrer-Policy: 49 | - no-referrer-when-downgrade 50 | Server: 51 | - nginx 52 | Set-Cookie: 53 | - request_method=POST; path=/; secure 54 | Strict-Transport-Security: 55 | - max-age=15552000 56 | Transfer-Encoding: 57 | - chunked 58 | Vary: 59 | - Accept, Accept-Encoding, Origin 60 | X-Content-Type-Options: 61 | - nosniff 62 | X-Frame-Options: 63 | - SAMEORIGIN 64 | X-PCO-API-Auth-Method: 65 | - HTTPBasic 66 | X-PCO-API-Processed-As-Version: 67 | - '2018-08-01' 68 | X-PCO-API-Processor: 69 | - ENG_2.2.2 70 | X-PCO-API-Request-Rate-Count: 71 | - '1' 72 | X-PCO-API-Request-Rate-Limit: 73 | - '100' 74 | X-PCO-API-Request-Rate-Period: 75 | - '20' 76 | X-Request-Id: 77 | - 178428a3-3031-4c5d-b7ba-b48a67659a6e 78 | X-Runtime: 79 | - '0.306442' 80 | X-XSS-Protection: 81 | - 1; mode=block 82 | status: 83 | code: 201 84 | message: Created 85 | version: 1 86 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit tests for pypco""" 2 | 3 | import os 4 | import logging 5 | import logging.handlers 6 | import unittest 7 | import vcr_unittest 8 | import pypco 9 | 10 | PYCO_LOGGER_EXISTS = False 11 | 12 | def get_creds_from_environment(): 13 | """Gets authentication credentials from environment. 14 | 15 | If they don't exist, raises an error. 16 | 17 | We expect the following token elements to be present as environment variables: 18 | PCO_APP_ID 19 | PCO_SECRET 20 | 21 | Returns: 22 | Dict: Personal access token for connecting to PCO {application_id: ..., secret: ...} 23 | 24 | Raises: 25 | CredsNotFoundError 26 | """ 27 | 28 | try: 29 | creds = {} 30 | 31 | creds['application_id'] = os.environ['PCO_APP_ID'] 32 | creds['secret'] = os.environ['PCO_SECRET'] 33 | 34 | except KeyError as exc: 35 | raise CredsNotFoundError( 36 | "PCO_APP_ID and/or PCO_SECRET environment variables not found." 37 | ) from exc 38 | 39 | return creds 40 | 41 | def build_logging_environment(): 42 | """Builds logging for the environment. 43 | 44 | If the necessary environment variables don't exist, skip logging. 45 | We look for the following vars: 46 | PYPCO_LOG_DIR (the directory in which logs should be stored) 47 | """ 48 | 49 | global PYCO_LOGGER_EXISTS #pylint: disable=W0603 50 | 51 | try: 52 | if not PYCO_LOGGER_EXISTS: 53 | log_dir = os.environ['PYPCO_LOG_DIR'] 54 | 55 | # Build logger 56 | log = logging.getLogger('pypco') 57 | log.setLevel(logging.DEBUG) 58 | 59 | # Build file handler 60 | file_handler = logging.handlers.RotatingFileHandler( 61 | f"{log_dir}{os.sep}pypco.log", 62 | maxBytes=50000, 63 | backupCount=10) 64 | file_handler.setLevel(logging.DEBUG) 65 | 66 | # Build formatter 67 | formatter = logging.Formatter( 68 | '%(levelname)8s %(asctime)s [%(module)s|%(lineno)d] %(message)s' 69 | ) 70 | file_handler.setFormatter(formatter) 71 | 72 | # Add file handler to logger 73 | log.addHandler(file_handler) 74 | 75 | PYCO_LOGGER_EXISTS = True 76 | 77 | except KeyError: 78 | pass 79 | 80 | class CredsNotFoundError(Exception): 81 | """Exception indicating environment variables not found.""" 82 | 83 | class BasePCOTestCase(unittest.TestCase): 84 | """"A base class for unit tests on pypco library. 85 | 86 | This class provides boilerplate for pulling authentication and 87 | logging configuration. 88 | """ 89 | 90 | def __init__(self, *args, **kwargs): 91 | 92 | unittest.TestCase.__init__(self, *args, **kwargs) 93 | 94 | build_logging_environment() 95 | 96 | class BasePCOVCRTestCase(vcr_unittest.VCRTestCase): 97 | """"A base class for unit tests on pypco with the VCR library. 98 | 99 | This class provides boilerplate for pulling authentication and 100 | logging configuration. 101 | 102 | Attributes: 103 | creds (dict): PCO personal tokens for executing test requests. 104 | """ 105 | 106 | def __init__(self, *args, **kwargs): 107 | 108 | vcr_unittest.VCRTestCase.__init__(self, *args, **kwargs) 109 | 110 | try: 111 | self.creds = get_creds_from_environment() 112 | except CredsNotFoundError: 113 | self.creds = {} 114 | self.creds['application_id'] = 'pico' 115 | self.creds['secret'] = 'robot' 116 | 117 | self.pco = pypco.PCO( 118 | self.creds['application_id'], 119 | self.creds['secret'] 120 | ) 121 | 122 | build_logging_environment() 123 | 124 | def _get_vcr(self, **kwargs): 125 | custom_vcr = super(BasePCOVCRTestCase, self)._get_vcr( 126 | filter_headers=['Authorization'], 127 | **kwargs 128 | ) 129 | 130 | return custom_vcr 131 | -------------------------------------------------------------------------------- /tests/test_auth_config.py: -------------------------------------------------------------------------------- 1 | """Test the PCO auth configuration module.""" 2 | 3 | from pypco.auth_config import PCOAuthConfig, PCOAuthType 4 | from pypco.exceptions import PCOCredentialsException 5 | from tests import BasePCOTestCase 6 | 7 | 8 | class TestPCOAuthConfig(BasePCOTestCase): 9 | """Test the PCOAuthConfig class.""" 10 | 11 | def test_pat(self): 12 | """Verify class functionality with personal access token.""" 13 | 14 | auth_config = PCOAuthConfig('app_id', 'secret') 15 | 16 | self.assertIsInstance(auth_config, PCOAuthConfig, "Class is not instnace of PCOAuthConfig!") 17 | 18 | self.assertIsNotNone(auth_config.application_id, "No application_id found on object!") 19 | self.assertIsNotNone(auth_config.secret, "No secret found on object!") 20 | 21 | self.assertEqual(auth_config.auth_type, PCOAuthType.PAT, "Wrong authentication type!") 22 | 23 | def test_oauth(self): 24 | """Verify class functionality with OAuth.""" 25 | 26 | auth_config = PCOAuthConfig(token="abcd1234") 27 | 28 | self.assertIsInstance(auth_config, PCOAuthConfig, "Class is not instnace of PCOAuthConfig!") 29 | 30 | self.assertIsNotNone(auth_config.token, "No token found on object!") 31 | 32 | self.assertEqual(auth_config.auth_type, PCOAuthType.OAUTH, "Wrong authentication type!") 33 | 34 | def test_org_token(self): 35 | """Verify class functionality with ORGTOKEN.""" 36 | 37 | auth_config = PCOAuthConfig(cc_name="carlsbad") 38 | 39 | self.assertIsInstance(auth_config, PCOAuthConfig, "Class is not instance of PCOAuthConfig!") 40 | 41 | self.assertIsNotNone(auth_config.cc_name, "No token found on object!") 42 | 43 | self.assertEqual(auth_config.auth_type, PCOAuthType.ORGTOKEN, "Wrong authentication type!") 44 | 45 | def test_invalid_auth(self): 46 | """Verify an error when we try to get auth type with bad auth.""" 47 | 48 | # Test with only auth_id 49 | with self.assertRaises(PCOCredentialsException): 50 | PCOAuthConfig('bad_app_id').auth_type # pylint: disable=W0106 51 | 52 | # Test with only secret 53 | with self.assertRaises(PCOCredentialsException): 54 | PCOAuthConfig(secret='bad_app_secret').auth_type # pylint: disable=W0106 55 | 56 | # Test with token and auth_id 57 | with self.assertRaises(PCOCredentialsException): 58 | PCOAuthConfig(application_id='bad_app_id', token='token').auth_type # pylint: disable=W0106 59 | 60 | # Test with token and secret 61 | with self.assertRaises(PCOCredentialsException): 62 | PCOAuthConfig(secret='bad_secret', token='bad_token').auth_type # pylint: disable=W0106 63 | 64 | # Test with org_token and auth_id 65 | with self.assertRaises(PCOCredentialsException): 66 | PCOAuthConfig(application_id='bad_app_id', cc_name='carlsbad').auth_type # pylint: disable=W0106 67 | 68 | # Test with org_token and secret 69 | with self.assertRaises(PCOCredentialsException): 70 | PCOAuthConfig(secret='bad_secret', cc_name='carlsbad').auth_type # pylint: disable=W0106 71 | 72 | # Test with org_token and token 73 | with self.assertRaises(PCOCredentialsException): 74 | PCOAuthConfig(token='bad_token', cc_name='carlsbad').auth_type # pylint: disable=W0106 75 | 76 | # Test with no args 77 | with self.assertRaises(PCOCredentialsException): 78 | PCOAuthConfig().auth_type # pylint: disable=W0106 79 | 80 | def test_auth_headers(self): 81 | """Verify that we get the correct authentication headers.""" 82 | 83 | # PAT 84 | auth_config = PCOAuthConfig('app_id', 'secret') 85 | self.assertEqual(auth_config.auth_header, "Basic YXBwX2lkOnNlY3JldA==", 86 | "Invalid PAT authentication header.") 87 | 88 | # OAUTH 89 | auth_config = PCOAuthConfig(token="abcd1234") 90 | self.assertEqual(auth_config.auth_header, "Bearer abcd1234", 91 | "Invalid OAUTH authentication header.") 92 | 93 | # ORGTOKEN 94 | auth_config = PCOAuthConfig(cc_name="carlsbad") 95 | self.assertIn('OrganizationToken', auth_config.auth_header, 96 | "Invalid ORGTOKEN authentication header.") 97 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pypco ReadMe 2 | 3 | [![Pypi Version](https://img.shields.io/pypi/v/pypco)](https://pypi.org/project/pypco/) [![Documentation Status](https://readthedocs.org/projects/pypco/badge/?version=latest)](https://pypco.readthedocs.io/en/latest/?badge=latest) [![Build](https://github.com/billdeitrick/pypco/actions/workflows/build.yml/badge.svg)](https://github.com/billdeitrick/pypco/actions/workflows/build.yml?query=branch%3Amaster) [![codecov](https://codecov.io/gh/billdeitrick/pypco/branch/master/graph/badge.svg)](https://codecov.io/gh/billdeitrick/pypco) [![Pypi Python Versions](https://img.shields.io/pypi/pyversions/pypco)](https://pypi.org/project/pypco/) [![Checked with mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](https://pypi.org/project/pypco/) 4 | 5 | Stop writing boilerplate code to communicate with the [Planning Center Online](https://planning.center) REST API, and start using pypco! Pypco is a Python wrapper library that supports the full breadth of the PCO REST API. With pypco, you'll spend less time worrying about building and managing HTTP requests and more time building cool things. 6 | 7 | [>>> Read the Docs (stable) <<<](https://pypco.readthedocs.io/en/stable/) 8 | 9 | ## Features 10 | 11 | * *Boilerplate Done for You:* No need to manage HTTP requests, and useful helper functions included (authentication, iteration/pagination, new object templating, etc.)! 12 | * *Automatic rate limit handling:* If you hit your rate limit, pypco will automatically pause requests and continue once your rate limit period has expired. 13 | * *Automatic Pagination:* Pypco provides automatic pagination/object iteration, allowing you to quickly and easily retrieve large numbers of objects off from the PCO API. 14 | * *Simple Wrapper API:* Using API wrappers can feel like learning *two* new APIs: the REST API itself and the wrapper you're using. Pypco's simple approach is built around the HTTP verbs you already know: GET, POST, PATCH, and DELETE. As a result, after a few minutes with the pypco docs you'll be spending your time in the PCO API docs instead and be on your way to getting things done. 15 | * *Full API Support:* Pypco supports all versions of the PCO v2 REST API, and supports any combination of API versions you might use for each of the PCO apps. 16 | 17 | ## Examples 18 | 19 | ```python 20 | import pypco 21 | pco = pypco.PCO("", "") 22 | 23 | # Print first and last names of everyone in People 24 | for person in pco.iterate('/people/v2/people'): 25 | print(f'{person["data"]["attributes"]["first_name"]} '\ 26 | f'{person["data"]["attributes"]["last_name"]}') 27 | 28 | # Create, save, and print a new person's attribs 29 | payload = pco.template( 30 | 'Person', 31 | {'first_name': 'John', 'last_name': 'Doe'} 32 | ) 33 | new_person = pco.post('/people/v2/people', payload) 34 | print(new_person['data']['attributes']) 35 | 36 | # Change our new person's last name and print attribs 37 | payload = pco.template( 38 | 'Person', 39 | {'last_name': 'Smith'} 40 | ) 41 | updated_person = pco.patch( 42 | f'/people/v2/people/{new_person["data"]["id"]}', 43 | payload 44 | ) 45 | print(updated_person['data']['attributes']) 46 | 47 | # Add an email address for our person 48 | payload = pco.template( 49 | 'Email', 50 | { 51 | 'address': 'john.doe@mailinator.com', 52 | 'location': 'Home' 53 | } 54 | ) 55 | email_object = pco.post( 56 | f'/people/v2/people/{updated_person["data"]["id"]}/emails', 57 | payload 58 | ) 59 | 60 | # Iterate through our person's email addresses 61 | for email in pco.iterate( 62 | f'/people/v2/people/{updated_person["data"]["id"]}/emails' 63 | ): 64 | print(email['data']['attributes']['address']) 65 | 66 | ``` 67 | 68 | ## Version 1.0 69 | 70 | Code written for pypco v0 will not be compatible with the v1 release. Because of changes in the PCO API (primarily the introduction of versioning) and the need for significantly improved performance, v1 is almost a complete rewrite. The result is a much more flexible, performant, and robust API wrapper. Though perhaps a bit less "pythonic", pypco v1.0.x will be much more maintainable going forward. 71 | 72 | If you're relying on pypco v0, you can still find the source code [here](https://github.com/billdeitrick/pypco/tree/v0). Development will not continue on the v0 release, but feel free to submit an issue if you're relying on v0 and need help with a specific problem. 73 | 74 | ## License 75 | 76 | Pypco is licensed under the terms of the MIT License. -------------------------------------------------------------------------------- /tests/cassettes/TestPublicRequestFunctions.test_delete.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | Content-Length: 12 | - '0' 13 | User-Agent: 14 | - pypco 15 | method: DELETE 16 | uri: https://api.planningcenteronline.com/services/v2/songs/18420243 17 | response: 18 | body: 19 | string: '' 20 | headers: 21 | Access-Control-Allow-Credentials: 22 | - 'true' 23 | Access-Control-Allow-Headers: 24 | - Accept, Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, 25 | If-Unmodified-Since, Origin, X-Requested-With, X-CSRF-Token, X-PCO-API-Version, 26 | X-PCO-API-Log-Level, X-PCO-API-Tracer-Class, X-PCO-API-Tracer-Secret 27 | Access-Control-Allow-Methods: 28 | - GET, POST, PATCH, PUT, DELETE, OPTIONS 29 | Access-Control-Allow-Origin: 30 | - '*' 31 | Access-Control-Expose-Headers: 32 | - ETag, Link, X-PCO-API-Auth-Method 33 | Cache-Control: 34 | - no-cache 35 | Connection: 36 | - keep-alive 37 | Date: 38 | - Sun, 08 Dec 2019 03:09:09 GMT 39 | Referrer-Policy: 40 | - no-referrer-when-downgrade 41 | Server: 42 | - nginx 43 | Set-Cookie: 44 | - request_method=DELETE; path=/; secure 45 | Strict-Transport-Security: 46 | - max-age=15552000 47 | Vary: 48 | - Accept, Accept-Encoding, Origin 49 | X-Content-Type-Options: 50 | - nosniff 51 | X-Frame-Options: 52 | - SAMEORIGIN 53 | X-PCO-API-Auth-Method: 54 | - HTTPBasic 55 | X-PCO-API-Processed-As-Version: 56 | - '2018-08-01' 57 | X-PCO-API-Processor: 58 | - ENG_2.2.2 59 | X-PCO-API-Request-Rate-Count: 60 | - '1' 61 | X-PCO-API-Request-Rate-Limit: 62 | - '100' 63 | X-PCO-API-Request-Rate-Period: 64 | - '20' 65 | X-Request-Id: 66 | - 2d72bfa8-c55c-498c-91c0-a76f6a74a6dd 67 | X-Runtime: 68 | - '0.252774' 69 | X-XSS-Protection: 70 | - 1; mode=block 71 | status: 72 | code: 204 73 | message: No Content 74 | - request: 75 | body: null 76 | headers: 77 | Accept: 78 | - '*/*' 79 | Accept-Encoding: 80 | - gzip, deflate 81 | Connection: 82 | - keep-alive 83 | User-Agent: 84 | - pypco 85 | method: GET 86 | uri: https://api.planningcenteronline.com/services/v2/songs/18420243 87 | response: 88 | body: 89 | string: !!binary | 90 | H4sIAAAAAAAAAx2LMQqAQAwEvxJSW1hY+QBLKzuxUG9F4TCYSwoR/26wmxmYh6EqWrgdHy42mwdy 91 | UzdcsR2WEdaLUSd+pmgJNh854rCDFEVcV9AtHnI5iiHRKp4TnXEtoO0f3+n9AEjrraVqAAAA 92 | headers: 93 | Access-Control-Allow-Credentials: 94 | - 'true' 95 | Access-Control-Allow-Headers: 96 | - Accept, Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, 97 | If-Unmodified-Since, Origin, X-Requested-With, X-CSRF-Token, X-PCO-API-Version, 98 | X-PCO-API-Log-Level, X-PCO-API-Tracer-Class, X-PCO-API-Tracer-Secret 99 | Access-Control-Allow-Methods: 100 | - GET, POST, PATCH, PUT, DELETE, OPTIONS 101 | Access-Control-Allow-Origin: 102 | - '*' 103 | Access-Control-Expose-Headers: 104 | - ETag, Link, X-PCO-API-Auth-Method 105 | Cache-Control: 106 | - no-cache 107 | Connection: 108 | - keep-alive 109 | Content-Encoding: 110 | - gzip 111 | Content-Type: 112 | - application/json; charset=utf-8 113 | Date: 114 | - Sun, 08 Dec 2019 03:09:09 GMT 115 | Referrer-Policy: 116 | - no-referrer-when-downgrade 117 | Server: 118 | - nginx 119 | Strict-Transport-Security: 120 | - max-age=15552000 121 | Transfer-Encoding: 122 | - chunked 123 | Vary: 124 | - Accept-Encoding 125 | - Accept, Accept-Encoding, Origin 126 | X-Content-Type-Options: 127 | - nosniff 128 | X-Frame-Options: 129 | - SAMEORIGIN 130 | X-PCO-API-Auth-Method: 131 | - HTTPBasic 132 | X-PCO-API-Processed-As-Version: 133 | - '2018-08-01' 134 | X-PCO-API-Processor: 135 | - ENG_2.2.2 136 | X-PCO-API-Request-Rate-Count: 137 | - '2' 138 | X-PCO-API-Request-Rate-Limit: 139 | - '100' 140 | X-PCO-API-Request-Rate-Period: 141 | - '20' 142 | X-Request-Id: 143 | - 12a37246-473f-4abd-9395-f9155b5c4eec 144 | X-Runtime: 145 | - '0.042480' 146 | X-XSS-Protection: 147 | - 1; mode=block 148 | status: 149 | code: 404 150 | message: Not Found 151 | version: 1 152 | -------------------------------------------------------------------------------- /pypco/user_auth_helpers.py: -------------------------------------------------------------------------------- 1 | """User-facing authentication helper functions for pypco.""" 2 | 3 | import urllib 4 | from typing import List, Optional 5 | from json import JSONDecodeError 6 | 7 | import requests 8 | 9 | from .exceptions import PCORequestException 10 | from .exceptions import PCORequestTimeoutException 11 | from .exceptions import PCOUnexpectedRequestException 12 | 13 | 14 | def get_browser_redirect_url(client_id: str, redirect_uri: str, scopes: List[str]) -> str: 15 | """Get the URL to which the user's browser should be redirected. 16 | 17 | This helps you perform step 1 of PCO OAUTH as described at: 18 | https://developer.planning.center/docs/#/introduction/authentication 19 | 20 | Args: 21 | client_id (str): The client id for your app. 22 | redirect_uri (str): The redirect URI. 23 | scopes (list): A list of the scopes to which you will authenticate (see above). 24 | 25 | Returns: 26 | str: The url to which a user's browser should be directed for OAUTH. 27 | """ 28 | 29 | url = "https://api.planningcenteronline.com/oauth/authorize?" 30 | 31 | params = [ 32 | ('client_id', client_id), 33 | ('redirect_uri', redirect_uri), 34 | ('response_type', 'code'), 35 | ('scope', ' '.join(scopes)) 36 | ] 37 | 38 | return f"{url}{urllib.parse.urlencode(params)}" 39 | 40 | 41 | def _do_oauth_post(url: str, **kwargs) -> requests.Response: 42 | """Do a Post request to facilitate the OAUTH process. 43 | 44 | Handles error handling appropriately and raises pypco exceptions. 45 | 46 | Args: 47 | url (str): The url to which the request should be made. 48 | **kwargs: Data fields sent as the request payload. 49 | 50 | Raises: 51 | PCORequestTimeoutException: The request timed out. 52 | PCOUnexpectedRequestException: Something unexpected went wrong with the request. 53 | PCORequestException: The HTTP response from PCO indicated an error. 54 | 55 | Returns: 56 | requests.Response: The response object from the request. 57 | """ 58 | 59 | try: 60 | response = requests.post( 61 | url, 62 | data={ 63 | **kwargs 64 | }, 65 | headers={ 66 | 'User-Agent': 'pypco' 67 | }, 68 | timeout=30 69 | ) 70 | except requests.exceptions.Timeout as err: 71 | raise PCORequestTimeoutException() from err 72 | except Exception as err: 73 | raise PCOUnexpectedRequestException(str(err)) from err 74 | 75 | try: 76 | response.raise_for_status() 77 | except requests.HTTPError as err: 78 | raise PCORequestException( 79 | response.status_code, 80 | str(err), 81 | response_body=response.text 82 | ) from err 83 | 84 | return response 85 | 86 | 87 | def get_oauth_access_token(client_id: str, client_secret: str, code: int, redirect_uri: str) -> dict: 88 | """Get the access token for the client. 89 | 90 | This assumes you have already completed steps 1 and 2 as described at: 91 | https://developer.planning.center/docs/#/introduction/authentication 92 | 93 | Args: 94 | client_id (str): The client id for your app. 95 | client_secret (str): The client secret for your app. 96 | code (int): The code returned by step one of your OAUTH sequence. 97 | redirect_uri (str): The redirect URI, identical to what was used in step 1. 98 | 99 | Raises: 100 | PCORequestTimeoutException: The request timed out. 101 | PCOUnexpectedRequestException: Something unexpected went wrong with the request. 102 | PCORequestException: The HTTP response from PCO indicated an error. 103 | 104 | Returns: 105 | dict: The PCO response to your OAUTH request. 106 | """ 107 | 108 | return _do_oauth_post( 109 | 'https://api.planningcenteronline.com/oauth/token', 110 | client_id=client_id, 111 | client_secret=client_secret, 112 | code=code, 113 | redirect_uri=redirect_uri, 114 | grant_type="authorization_code" 115 | ).json() 116 | 117 | 118 | def get_oauth_refresh_token(client_id: str, client_secret: str, refresh_token: str) -> dict: 119 | """Refresh the access token. 120 | 121 | This assumes you have already completed steps 1, 2, and 3 as described at: 122 | https://developer.planning.center/docs/#/introduction/authentication 123 | 124 | Args: 125 | client_id (str): The client id for your app. 126 | client_secret (str): The client secret for your app. 127 | refresh_token (str): The refresh token for the user. 128 | 129 | Raises: 130 | PCORequestTimeoutException: The request timed out. 131 | PCOUnexpectedRequestException: Something unexpected went wrong with the request. 132 | PCORequestException: The HTTP response from PCO indicated an error. 133 | 134 | Returns: 135 | dict: The PCO response to your token refresh request. 136 | """ 137 | 138 | return _do_oauth_post( 139 | 'https://api.planningcenteronline.com/oauth/token', 140 | client_id=client_id, 141 | client_secret=client_secret, 142 | refresh_token=refresh_token, 143 | grant_type='refresh_token' 144 | ).json() 145 | 146 | 147 | def get_cc_org_token(cc_name: Optional[str] = None) -> Optional[str]: # pylint: disable=unsubscriptable-object 148 | """Get a non-authenticated Church Center OrganizationToken. 149 | 150 | Args: 151 | cc_name (str): The organization_name part of the organization_name.churchcenter.com url. 152 | 153 | Raises: 154 | 155 | Returns: 156 | str: String of organization token 157 | """ 158 | try: 159 | response = requests.post( 160 | f'https://{cc_name}.churchcenter.com/sessions/tokens', 161 | timeout=30 162 | ) 163 | 164 | except requests.exceptions.Timeout as err: 165 | raise PCORequestTimeoutException() from err 166 | except Exception as err: 167 | raise PCOUnexpectedRequestException(str(err)) from err 168 | 169 | try: 170 | response.raise_for_status() 171 | except requests.HTTPError as err: 172 | raise PCORequestException( 173 | response.status_code, 174 | str(err), 175 | response_body=response.text 176 | ) from err 177 | try: 178 | response.json() 179 | return str(response.json()['data']['attributes']['token']) 180 | except JSONDecodeError as err: 181 | raise PCOUnexpectedRequestException("Invalid Church Center URL") from err 182 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | from datetime import datetime 18 | sys.path.insert(0, os.path.abspath('../../')) 19 | 20 | # -- AutoStructify ----------------------------------------------------- 21 | 22 | # Import and enable AutoStructify...this enables rich support and cool stuff for 23 | # editing in md instead of rst. 24 | 25 | import recommonmark 26 | from recommonmark.transform import AutoStructify 27 | 28 | # -- Project information ----------------------------------------------------- 29 | 30 | project = 'pypco' 31 | copyright = f'{datetime.now().year}, Bill Deitrick' 32 | author = 'Bill Deitrick' 33 | 34 | # The short X.Y version 35 | version = '' 36 | # The full version, including alpha/beta/rc tags 37 | release = '1.2.0' 38 | 39 | 40 | # -- General configuration --------------------------------------------------- 41 | 42 | # If your documentation needs a minimal Sphinx version, state it here. 43 | # 44 | # needs_sphinx = '1.0' 45 | 46 | # Add any Sphinx extension module names here, as strings. They can be 47 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 48 | # ones. 49 | extensions = [ 50 | 'sphinx.ext.autodoc', 51 | 'sphinx.ext.viewcode', 52 | 'sphinx.ext.napoleon' 53 | ] 54 | 55 | # Add any paths that contain templates here, relative to this directory. 56 | templates_path = ['.templates'] 57 | 58 | # The suffix(es) of source filenames. 59 | # You can specify multiple suffix as a list of string: 60 | # 61 | # source_suffix = ['.rst', '.md'] 62 | source_suffix = ['.rst', '.md'] 63 | 64 | # Add source parsers. 65 | # 66 | # Here we're adding the CommonMark Parser to support Markdown. 67 | # See https://www.sphinx-doc.org/en/master/usage/markdown.html 68 | source_parsers = { 69 | '.md': 'recommonmark.parser.CommonMarkParser' 70 | } 71 | 72 | # The master toctree document. 73 | master_doc = 'index' 74 | 75 | # The language for content autogenerated by Sphinx. Refer to documentation 76 | # for a list of supported languages. 77 | # 78 | # This is also used if you do content translation via gettext catalogs. 79 | # Usually you set "language" from the command line for these cases. 80 | language = 'en' 81 | 82 | # List of patterns, relative to source directory, that match files and 83 | # directories to ignore when looking for source files. 84 | # This pattern also affects html_static_path and html_extra_path. 85 | exclude_patterns = [] 86 | 87 | # The name of the Pygments (syntax highlighting) style to use. 88 | pygments_style = None 89 | 90 | 91 | # -- Options for HTML output ------------------------------------------------- 92 | 93 | # The theme to use for HTML and HTML Help pages. See the documentation for 94 | # a list of builtin themes. 95 | # 96 | html_theme = 'sphinx_rtd_theme' 97 | 98 | # Theme options are theme-specific and customize the look and feel of a theme 99 | # further. For a list of options available for each theme, see the 100 | # documentation. 101 | # 102 | # html_theme_options = {} 103 | 104 | # Add any paths that contain custom static files (such as style sheets) here, 105 | # relative to this directory. They are copied after the builtin static files, 106 | # so a file named "default.css" will overwrite the builtin "default.css". 107 | html_static_path = ['.static'] 108 | 109 | # Custom sidebar templates, must be a dictionary that maps document names 110 | # to template names. 111 | # 112 | # The default sidebars (for documents that don't match any pattern) are 113 | # defined by theme itself. Builtin themes are using these templates by 114 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 115 | # 'searchbox.html']``. 116 | # 117 | # html_sidebars = {} 118 | 119 | 120 | # -- Options for HTMLHelp output --------------------------------------------- 121 | 122 | # Output file base name for HTML help builder. 123 | htmlhelp_basename = 'pypcodoc' 124 | 125 | 126 | # -- Options for LaTeX output ------------------------------------------------ 127 | 128 | latex_elements = { 129 | # The paper size ('letterpaper' or 'a4paper'). 130 | # 131 | # 'papersize': 'letterpaper', 132 | 133 | # The font size ('10pt', '11pt' or '12pt'). 134 | # 135 | # 'pointsize': '10pt', 136 | 137 | # Additional stuff for the LaTeX preamble. 138 | # 139 | # 'preamble': '', 140 | 141 | # Latex figure (float) alignment 142 | # 143 | # 'figure_align': 'htbp', 144 | } 145 | 146 | # Grouping the document tree into LaTeX files. List of tuples 147 | # (source start file, target name, title, 148 | # author, documentclass [howto, manual, or own class]). 149 | latex_documents = [ 150 | (master_doc, 'pypco.tex', 'pypco Documentation', 151 | 'Bill Deitrick', 'manual'), 152 | ] 153 | 154 | 155 | # -- Options for manual page output ------------------------------------------ 156 | 157 | # One entry per manual page. List of tuples 158 | # (source start file, name, description, authors, manual section). 159 | man_pages = [ 160 | (master_doc, 'pypco', 'pypco Documentation', 161 | [author], 1) 162 | ] 163 | 164 | 165 | # -- Options for Texinfo output ---------------------------------------------- 166 | 167 | # Grouping the document tree into Texinfo files. List of tuples 168 | # (source start file, target name, title, author, 169 | # dir menu entry, description, category) 170 | texinfo_documents = [ 171 | (master_doc, 'pypco', 'pypco Documentation', 172 | author, 'pypco', 'One line description of project.', 173 | 'Miscellaneous'), 174 | ] 175 | 176 | 177 | # -- Options for Epub output ------------------------------------------------- 178 | 179 | # Bibliographic Dublin Core info. 180 | epub_title = project 181 | 182 | # The unique identifier of the text. This can be a ISBN number 183 | # or the project homepage. 184 | # 185 | # epub_identifier = '' 186 | 187 | # A unique identification for the text. 188 | # 189 | # epub_uid = '' 190 | 191 | # A list of files that should not be packed into the epub file 192 | epub_exclude_files = ['search.html'] 193 | 194 | # -- Pypco option additions -------------------------------------------------- 195 | 196 | # Ignore errors for specific references with nitpick on 197 | nitpick_ignore = [ 198 | ('py:class', 'object'), 199 | ('py:class', 'str'), 200 | ('py:class', 'enum.Enum'), 201 | ('py:class', 'Exception'), 202 | ('py:class', 'int'), 203 | ('py:class', 'requests.Response'), 204 | ('py:class', 'dict'), 205 | ('py:class', 'obj'), 206 | ('py:class', 'list') 207 | ] 208 | 209 | # -- Extension configuration ------------------------------------------------- 210 | 211 | # Configure AutoStructify 212 | def setup(app): 213 | app.add_config_value('recommonmark_config', { 214 | 'url_resolver': lambda url: url, 215 | 'auto_toc_tree_section': 'Contents', 216 | }, True) 217 | app.add_transform(AutoStructify) 218 | -------------------------------------------------------------------------------- /tests/cassettes/TestPublicRequestFunctions.test_request_json.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - pypco 13 | method: GET 14 | uri: https://api.planningcenteronline.com/people/v2/people 15 | response: 16 | body: 17 | string: '{"links":{"self":"https://api.planningcenteronline.com/people/v2/people"},"data":[{"type":"Person","id":"40135868","attributes":{"accounting_administrator":true,"anniversary":null,"avatar":"https://people.planningcenteronline.com/static/no_photo_thumbnail_gray.png","birthdate":null,"child":false,"created_at":"2018-08-03T01:13:32Z","demographic_avatar_url":"https://people.planningcenteronline.com/static/no_photo_thumbnail_gray.png","first_name":"Bill","gender":null,"given_name":null,"grade":null,"graduation_year":null,"inactivated_at":null,"last_name":"Deitrick","medical_notes":null,"membership":null,"middle_name":null,"name":"Bill 18 | Deitrick","nickname":null,"passed_background_check":false,"people_permissions":null,"remote_id":null,"school_type":null,"site_administrator":true,"status":"active","updated_at":"2018-11-25T20:19:47Z"},"relationships":{"primary_campus":{"data":null}},"links":{"self":"https://api.planningcenteronline.com/people/v2/people/40135868"}},{"type":"Person","id":"45029164","attributes":{"accounting_administrator":false,"anniversary":null,"avatar":"https://people.planningcenteronline.com/static/no_photo_thumbnail_gray.png","birthdate":null,"child":false,"created_at":"2018-11-25T20:31:31Z","demographic_avatar_url":"https://people.planningcenteronline.com/static/no_photo_thumbnail_gray.png","first_name":"Paul","gender":null,"given_name":null,"grade":null,"graduation_year":null,"inactivated_at":null,"last_name":"Revere","medical_notes":null,"membership":null,"middle_name":null,"name":"Paul 19 | Revere","nickname":null,"passed_background_check":false,"people_permissions":null,"remote_id":null,"school_type":null,"site_administrator":false,"status":"active","updated_at":"2018-11-25T20:31:31Z"},"relationships":{"primary_campus":{"data":null}},"links":{"self":"https://api.planningcenteronline.com/people/v2/people/45029164"}}],"included":[],"meta":{"total_count":2,"count":2,"can_order_by":["given_name","first_name","nickname","middle_name","last_name","birthdate","anniversary","gender","grade","child","school_type","graduation_year","site_administrator","accounting_administrator","people_permissions","membership","inactivated_at","status","medical_notes","created_at","updated_at","remote_id"],"can_query_by":["given_name","first_name","nickname","middle_name","last_name","birthdate","anniversary","gender","grade","child","school_type","graduation_year","site_administrator","accounting_administrator","people_permissions","membership","inactivated_at","status","medical_notes","created_at","updated_at","search_name","search_name_or_email","search_name_or_email_or_phone_number","remote_id","id"],"can_include":["addresses","emails","field_data","households","inactive_reason","marital_status","name_prefix","name_suffix","organization","person_apps","phone_numbers","platform_notifications","primary_campus","school","social_profiles"],"can_filter":["created_since","admins","organization_admins"],"parent":{"id":"263468","type":"Organization"}}}' 20 | headers: 21 | Access-Control-Allow-Credentials: 22 | - 'true' 23 | Access-Control-Allow-Headers: 24 | - Accept, Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, 25 | If-Unmodified-Since, Origin, X-Requested-With, X-CSRF-Token, X-PCO-API-Version, 26 | X-PCO-API-Log-Level, X-PCO-API-Tracer-Class, X-PCO-API-Tracer-Secret 27 | Access-Control-Allow-Methods: 28 | - GET, POST, PATCH, PUT, DELETE, OPTIONS 29 | Access-Control-Allow-Origin: 30 | - '*' 31 | Access-Control-Expose-Headers: 32 | - ETag, Link, X-PCO-API-Auth-Method 33 | Cache-Control: 34 | - max-age=0, private, must-revalidate 35 | Connection: 36 | - keep-alive 37 | Content-Type: 38 | - application/vnd.api+json; charset=utf-8 39 | Date: 40 | - Sat, 23 Nov 2019 06:14:09 GMT 41 | ETag: 42 | - W/"7adc698a65856c3e50e70b77bb145069" 43 | Referrer-Policy: 44 | - no-referrer-when-downgrade 45 | Server: 46 | - nginx 47 | Strict-Transport-Security: 48 | - max-age=31536000; includeSubDomains 49 | Transfer-Encoding: 50 | - chunked 51 | Vary: 52 | - Accept, Accept-Encoding, Origin 53 | X-Content-Type-Options: 54 | - nosniff 55 | X-Download-Options: 56 | - noopen 57 | X-Frame-Options: 58 | - SAMEORIGIN 59 | X-PCO-API-Auth-Method: 60 | - HTTPBasic 61 | X-PCO-API-Processed-As-Version: 62 | - '2018-08-01' 63 | X-PCO-API-Processor: 64 | - ENG_2.2.2 65 | X-PCO-API-Request-Rate-Count: 66 | - '1' 67 | X-PCO-API-Request-Rate-Limit: 68 | - '100' 69 | X-PCO-API-Request-Rate-Period: 70 | - '20' 71 | X-Permitted-Cross-Domain-Policies: 72 | - none 73 | X-Request-Id: 74 | - 45a26c37-95fd-4fcf-b069-c913d46a6d21 75 | X-Runtime: 76 | - '0.043124' 77 | X-XSS-Protection: 78 | - 1; mode=block 79 | status: 80 | code: 200 81 | message: OK 82 | - request: 83 | body: null 84 | headers: 85 | Accept: 86 | - '*/*' 87 | Accept-Encoding: 88 | - gzip, deflate 89 | Connection: 90 | - keep-alive 91 | User-Agent: 92 | - pypco 93 | method: GET 94 | uri: https://api.planningcenteronline.com/bogus 95 | response: 96 | body: 97 | string: !!binary | 98 | H4sIAAAAAAAAAx2LMQqAQAwEvxJSW1hY+QBLKzuxUG9F4TCYSwoR/26wmxmYh6EqWrgdHy42mwdy 99 | UzdcsR2WEdaLUSd+pmgJNh854rCDFEVcV9AtHnI5iiHRKp4TnXEtoO0f3+n9AEjrraVqAAAA 100 | headers: 101 | Access-Control-Allow-Credentials: 102 | - 'true' 103 | Access-Control-Allow-Headers: 104 | - Accept, Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, 105 | If-Unmodified-Since, Origin, X-Requested-With, X-CSRF-Token, X-PCO-API-Version 106 | Access-Control-Allow-Methods: 107 | - GET, POST, PATCH, PUT, DELETE, OPTIONS 108 | Access-Control-Allow-Origin: 109 | - '*' 110 | Access-Control-Expose-Headers: 111 | - ETag, Link, X-PCO-API-Auth-Method 112 | Cache-Control: 113 | - no-cache 114 | Connection: 115 | - keep-alive 116 | Content-Encoding: 117 | - gzip 118 | Content-Type: 119 | - application/json; charset=utf-8 120 | Date: 121 | - Sat, 23 Nov 2019 06:14:09 GMT 122 | Server: 123 | - nginx 124 | Strict-Transport-Security: 125 | - max-age=15552000; includeSubDomains 126 | Transfer-Encoding: 127 | - chunked 128 | Vary: 129 | - Accept-Encoding 130 | - Accept, Accept-Encoding, Origin 131 | X-Content-Type-Options: 132 | - nosniff 133 | X-Frame-Options: 134 | - SAMEORIGIN 135 | X-PCO-API-Request-Rate-Count: 136 | - '1' 137 | X-PCO-API-Request-Rate-Limit: 138 | - '100' 139 | X-PCO-API-Request-Rate-Period: 140 | - 20 seconds 141 | X-Request-Id: 142 | - 767a84fb-8c92-4208-b583-3bc21635c62a 143 | X-Runtime: 144 | - '0.018828' 145 | X-XSS-Protection: 146 | - 1; mode=block 147 | status: 148 | code: 404 149 | message: Not Found 150 | version: 1 151 | -------------------------------------------------------------------------------- /tests/cassettes/TestPublicRequestFunctions.test_put.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: '{"data": {"type": "Song", "attributes": {"author": "Anna Bartlett Warner"}}}' 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | Content-Length: 12 | - '76' 13 | Content-Type: 14 | - application/json 15 | User-Agent: 16 | - pypco 17 | method: PUT 18 | uri: https://api.planningcenteronline.com/services/v2/songs/18338876 19 | response: 20 | body: 21 | string: !!binary | 22 | H4sIAAAAAAAAA9Vca5Obxpr+nl/BypU6ThVouAqY1UwuY+dT9lLlVKXOR4SYEWeQ0AHG8eRU/vs+ 23 | T3cDjQBJdnaTWssSPdD99nu/dLe0/rd3//Xw89//+72xa/bF/VdrefnKMNa7LNneo4FmkzdFdu/b 24 | /vpGNuXtIj88G7sqe7xb7JrmWN/e3DyWh6ZePpXlU5Elx7xepuX+Jq3rbx+TfV683v2UNOXCqLLi 25 | blE3r0VW77KsWdx/JQGKW0bzeszuFk32qeFIPCQO/EfcTGNTbl9NY5t/NI36mBxMIzkei6wxjXLz 26 | jyzFNX+skn1m9sMc09i5eHt4+3gHeK9M4whYRZk+//OlbDL8WWljEoDdbCp8plV5eN2jsd1WWV1j 27 | TP5kGmnOIWm51cZsM2C3fQRGGfrne3TLDxjwvNmaxj+BLf4n+2OPWL1PCgypmyp/BjRcywMG1S8b 28 | fgC9BtR8TKp+BB68ACpmzg5Npj3Ycmr03mKqEu0XvIu8H/iYZ8W2JpceywrYFcmG2BbZU3bY9t2a 29 | ZFOQruTY5CUIaSSzm8eyxNCGKoEL2NLs8NYGJlWTpxya1Dl4AgiHjwkI3mZNkhdoZPtNpvV/zJ9e 30 | wG8D124yTgKaDM4irk9VCS50ctxnBxB/SCD48qU5vgCj6mUDXaghdoFu/bLfJ9WrRk8ORTBw7xmY 31 | vWzzEvwEfqXxrx5qUj3lh1vD/vfu1hGyzg9Pg3ubsgJSg1vU9VuIeJdVedOP5m2rzn/Lbg3Htr/u 32 | H3zMyKSksJIif8KMm6TOYEJZ3+P3DgXyXUNyk6TP5MZha6VlUQKPN34Qvf/xx35se//x8bG/uc3r 33 | Y5G83hqPRfapv/2Pl7rJH18BC1pEIqQ69R0EhhZ0fF+PHxKWtc0ryXU8L4uX/eGEAdLeb42/0eL/ 34 | Rs0/1FYNTmnY6ZwKjhp+5Iq1y/KnHXBzloHOoY5Fy631qLFohtSzlPwu/Q59y/KYp0O1+GT9mm+b 35 | 3a3hRbaOXIvXyW0dltR6a19uk+KsFKunTfLWDeCQ+g976X7TEyz1zqqSbf4CWUQ6JvtkDsc5ZCxw 36 | lcalIWXQPGABVlMeod46fAPOotNl9+SRGrUpm6bc3xqur4+cmx++SZu6tzPDyfYzQhYORLESo4t8 37 | ZLtWi8NVQCxL+pnLqjOyEoSbNLM2WfNrlmn6flbFlPwEc53jJ6Mui3xrTMvd1+Te8Qbqfw1vOrKO 38 | GmGTPkGTjeRnMtAGOebNwI2Mh9zuSriziYEk7a2DaOsj6jp+oJE0pxQd6stNczhrLk0FL3JMKvis 39 | UwvRuTtEfpIJuusZaO6JvQ3cUi8TXSLpS1XTIx/LnDG5R0xgmzMy3RoI9Ia9dGro7yFL9E5Ic6xt 40 | lpZVInseymFE0PydZgoDno2E0ZLs+f777x/Osuq0y8AlWu6Utbpz+rivLGdsnZVy43OjRh6JmV8b 41 | I08Dk/Q6J35Kx1lB062gGzPtYdSI3Rh1uP5sb9h8nUQ3Gd69gUsUKvWrClqbsthOT6diroi9CZRB 42 | NyLdE2PqaQB1Y2tMfsyL4tYYKg0TyWekH2/8d3z1YOSDNq65wdL2/dFTaihysltD5Bujx3tkBVWR 43 | 48L8Zg5FnZkSxTeRzdfcAF3T1IAfXb7mBngjJrx5v+JrboA/HuCJf3MDAm1ACdefN0ik7LlspG5W 44 | 0/1nKQjHCP0o/s0hFF2coOXcWTDxeF5vFdvvv5+b1xkr3BvHW/nvotkRE/J330er97O0ORMK8IPH 45 | 1+wcExrgfM/X7IixCswYzruIrx7O0HB0tVdP/ojRfAevsXnOG+s5exWla20k1d5KDvleSMr+Wlza 46 | XiKssIq7RSWGZpE0mbd9azlfm4bl48MWMXeie1U26Iue2+xpuo9VwluzGmLlYgSyemFV4l2HhI3Z 47 | LyPgfv780V88v+DHXy4FEei+QzL82yVdYZdrFeWk71hLBh0uq8iZuQf6MT1vrxzXTdtpxp87ba8Q 48 | Z+Yd2eQ0yZpBXkez0oPykhaUV+vAoOdYA7THl+U/O+tA+lMz9rK/ZsJO8n/WhL3MZ2ccSXyKTE3e 49 | 19AppX0+OFxp61q3kZC7ZxclPDOZLt7xRJ1sL8/TCvb/ep5OnjMTnQpzTFQvyctUCTEuGdlFRSSi 50 | CWO8qvu6kB/UWFN8zA/IsxnHMUw4/dkcwSqyRwltPkQpYV8R+F0Aca6K+60CXRHNh7nEmVj+xTDP 51 | xecvJv5suB2zfdq59zyf9u1zDJ+GZl0KjzOs/l+BNhvzRhy+itQzUWzM3Ck/2rN2yo3OMXYKksbW 52 | KVAzTP3DkGYCyoidUzidkKfcxDhKjFg59mIdH8dObIaJYxg9B8dAptn3R2BMee5Tro3xGLNMOeTO 53 | g074Y8m/a50ydxHEGruq3LgVdc43CSRVBafHT9ZcKL+uKTkwTAQqWfZxYBsyaIW9QpxH7cRBXIXX 54 | +TFjpBjG9Jz1PEYD67oKn3Mjxtgwql7JHU1Vr0FkvvsYC7Xz1OuMpoI9fz5L/bLXTF88+ItU0DCm 55 | ilVB0gSCoad0/USnauyXZn9/68jliumwQjvx+5QlDM6CUqsj14FafT3k3h/HbiLoXeTJQK+HHJmK 56 | DHP8mAKjuHENmFNeTIFTcroC3CheXeKCZlQDFozd/Az9YwCS+MsATigfA5JkXwQkK4CW0NMCYMgA 57 | x50qA+Cy9tk2T4w6rbAHifpha7zVtoFDG1sS33TruyenCKb2r9ojB4Pd9PM7/P0QfW+W5Qz/qU1z 58 | Br12/bTbmhfHbdq73baMoW0odbDJjY6ODoC+XDuJxsTu7nliDGO0zXu653Rmf/c8Dif7mROcMYzL 59 | m11yY36wt70pP/GEiTijojYscatlrETq96/WN+I8FQ513cizXGvqA49TrXF6ykiLpK5xckscCFCn 60 | rNY75/5Duc+aHWAbv2Cf1fiFh5IAwVFnwY73v+ySxngtX7BKnRlFWT6zLxakjbzGRhTuHJ6wq5Wk 61 | KY5L5ThPtFzfHMWsN5hWHPXS5+cpjtGeWItO/fGpxVMoBHcYF1LF7haR4yyxd7vAgSEW1HeLIHaX 62 | OCCxwBmf7Ncfyk93C+7diW6GeNadJVt3YFF2i9V2UZNrZ83Wx6TZtXNjy21hbO8W/+GHq2Vo+ra9 63 | DFPbtE1ntXRNx19Gpu8vYyvAFMZNf2DtBAhQl0BCDPDCaOn+5IddOw2WK9Neema8jM0AnyvTsZer 64 | wsIJANNzl05q8Sl6WXwaW3zKbjtbmZ5hpEBhxW4WOwT4XIluBWBYhPHgr0KCi1wg7of89IBCjHaH 65 | yG+Chg7mOiuK/FjjXF678XC3wKJFlX96ay9j3zE4nxcF2BpTF3EzjpdBsIoN1wYSsed/s+i56aIN 66 | 6QQBJIPm693CizF60c1pGBWe+yvys+LjC5z1JGeDKFo6IC5crlILkrKcCAzwHPDCXZFvEJflgNCU 67 | XPFMT3YJwcXIcn1yPEz9pWM5LsTsYFLeJfctD/17NuOuZ0UUO6gE/ZFJ0BgN0B64CegRZBmaLgBZ 68 | Iadyl8FDACrBdqhPYLKN27HHwT3en8P80I3J/FXkk/nqIm6CA24c+4YPrXfiwNOZT46T+RS7Yj6Q 69 | HzEfLFDMj/BwoNVpXuGsXy/OFmIEDiqIEQ47QHR3Cxfqpo1e3zy15jG2QZZhF03Qg9LQBL2lSxO0 70 | OhO0PsMEvYC2gxNcy/AnrS3MhzZI8zGF+Ugb7EyQMmMfYYHiGS2wACK9cigTFDYq7FiaIO1Y2qC3 71 | guFgdh+EeEJxiImPdofV56gB9H1FPYhdJ4IeqIu868TQ1JW7giZAr203mjBDNwQirRl6Y02Qxkcz 72 | lAY57+CUGboeXBtoiuAlQ2li+BMCozWBryEtDK0U/HNMn4bDAcJoXNgk//RSy1v6NEQ4OtighdMK 73 | nrBCYc+ax8OICBy2YIq+GEP4dMpxavngKsAG4H1MW4TgKHcAfXBBVgA9omjZxm2wCL165KUQZFqD 74 | oHXOD4auK0wxdmKaoryImw79vO/7hgtvGzhBPGGLboD4ItxgAAd26gaF/ZH90iR19s9YouvD7FtL 75 | FD72vCXyKKdBh2tDWxYGEYGoadYyR6ibPuq6wrLbkCv/6lDqbVo7sVo3SVXjdHOg23bnBsjefhhO 76 | UWi9xLNhIIaiMoY6NqQW0AcIxy78ZwTdtZwAXIb04YXh/eF90xWct0P1gitCHICKwXeE0BQvheJD 77 | 4OweoQGrhMMWOhn35kwcUvEIykeTR0gxg4CKBeJh39CxFCkGnkMN6fxjB/odQl0RQdADkYijoZXA 78 | CJ/EBHHXgj5gYoQ4LeoxgjsCCMhyLBg0PRB02oUWhdTs6CGmrvrUYRM3obY+g0rHkKHSMuvSWD38 79 | Y8hZeHvBWagbTCVeOgW8IUKoIAFT4O3jzXAX0tvw/RMZ6zousBZ5ByhkwAUHYY+FsDzA6I1VgnAZ 80 | GgkKVwGm6CZSIBCZ4SJIp2nzoTDilHTST8qRIUfzLeaBYYcpR/AmrwIJ00f6pJ5qLkMCIUUEQook 81 | ICIrpnpwwXS4AnZwwIq22fHmxDVMcjK0QwAJwhiaJhIPMFbkIwDK2IA3SILKcqruCgpAh2eKsICe 82 | FDFjDXuzJyOQGgH64NI0ukQOIjIT0h+KKMZxIFAMsjCNunIaYoQ0CfaBvsRMYfUQ2lDxiFwULVDA 83 | VkfNNcT7HO3AnUX/z2iX1rRCXBJmFSA17ki5hvAQKRc8yipOEXUgxdiC1K2Iygt+IzAxWlGjqJ+p 84 | g0yCso7NyIwgX9wTMhT9hHyR9gMcxISMwUKngRqLrgAXiasEaTlI/CnX2MK8DyHcEXxVyOCq2qsY 85 | c3R46kRNpmf4kohYBL+cmoF0pGZwd75MzWguCK1QcWomvBdCyrnwxZiFMORFKjH1VyKjZPBihnqm 86 | tAJgOi+UIVBcBDH4LmTv1HGiZIMjdLnwRoWNFlwJkvKQNiZdNl2uaBXILjEMCR1Q9jtePwAlCCCI 87 | mNZwpJgEbTXdWcVw9YAusETqTbMCP0QVKTIReilEqMjygFVK9NiCz/dgmCjaXHywigk+ePD94Cbs 88 | EyljD+oqOV5Z5mJm+N5WkCIfE4KUBe/VgvRhQ6rA+2xRImXCfFKYSpZClEjLBflDUQo31klRitKa 89 | kaXvIVMUsvSFaUtZavN9njR9UZB00rRaaSJmK2nWkGFIGcIfSBk+BMy0kEYwj/BjPpHy1IBdlKdY 90 | MOSSzrxhotAWKw4oLyBPpC4+DQIpNAteqFFEHkGzWKQgf4avgeSRIcGZrAIGUnqRFTSQ9W2YMnxw 91 | KJN5ZB8Mo/iAHUE/elvxA6ZVvlxiwCfayK/A7R6LsxxWVYSPsIOhtnCBolQivkhMaIe0Ceb0QkUt 92 | j0U6yBB1AhdiQBA+4BMZ+lCls5JAJPRExEdpoAXNczSl5IZwqwBNyzRXcB7M7aIHUsP6gZ6Nbdb1 93 | KLGwkNKhfZZKuDghGpiIh6HB0oXPYugQcRkXei60kCjiijc8F5wCPBefqvuQlHymRoqBvMkO4ioG 94 | mhioycfnhNA7aD94w6nR7tD4DKyZUf+pWMP/IcBIvDl5izfbn4E36+w/FW/Wpi3eoshX/Gb7Ojvn 95 | Mu18/dSWpn29xoISoZSJPFfbUNK5ZB6CKQOsDKu4CfNHgNVislbnjoAFXNPrgXF56AuAqSC/Ql6t 96 | Vh1cFFKsUbkqxQlErYuq/jJiCpaL+kvVu18OSy1K+lzOlUQ6LBQVXmKxjIihMsC9IceG6b9yuiu4 97 | BrhSBEuaLpwRckC6V7gorljAZ2J5CPmxBYeBCgvLDkhBmB0itUDqSBfnwM8ieUITHpDLjbbFchN5 98 | C0tNPGNkDrT6EWDgG6BUAjKqRHnlDPACEgeTOHCdJaLfFs6UmSFwIAomUTCJAmEjhW+nWaUSA1Hw 99 | Itwi5Wyx0zF4CCIudjJ/UAzgClTbJjOGdjoq8FvvDzHQ5TPscGXPA+3IvAILXIGLB/1YVUUwoz8m 100 | O7hqg+IegUu0GJN7rstFJRaZXCNE2qZ66JzzkI2R/8jWyT+CR6HOYCJmkiiYQAHsFw/AAD4gBhSI 101 | gGoSA7YQzwUGLc+JAap/4b0AUvRlDw0D5ARiOR7GgF6oOwTxqD46Row4N9gc4ba6wa31k1xdLKKM 102 | HQT4LHJtTDfpIJQdIsCeOogpcLRkAU3k6cLdINtQHoJ5mTIeYehD49HXJ/SloG6flDSd5q3niQoQ 103 | 05QN0+v1juqLiAqgFaog+SyiNNmeXTuMYzfk4iHWkzx+SVBe5F3kPH7oeQbiu+27jr526CgJag7L 104 | jQO91pC7KHAHELDwqWLp7pzrclQdFSDLY/YnPA/zJuZTrlhsh/tCSKFTuuAGW1huFLCcFbAUKCzB 105 | CVDmNKjhKpW+OAlJgg4kpKS9XZsEdWJrFQRCTO3KJNt6rTlccDxdOHVUvCQzlQKzDgPfoNSiMWTb 106 | aLyKRD4ykdYARHzDeKiPvgUyWJLrSMVm7UegKHRmsD2q7bH325fa/u3RcvW8YOe1jNG/hN5+73uw 107 | tvqf+J6l8aHJ8MMdnapio9fT9i9L/CQIMZL/1kV+/658wZauke6y9Jk/CGGs5c9W3Kvfx8CvGlTc 108 | eOau8b04n7BPnjP8MgP2itld/GYIGvgT28X42ivFix1igD6Z6Yd2VNnNpu07t9PW+KGCbIsT000p 109 | 4CuI3H7G1xcJWeEyNcU60X6+BL9egh/dyCr6nSV+NeFwwL62PAtQHvj9LvFbJov7n6tXfIt7nxmR 110 | 9UPeGD+DluT4ur5J7odTrG967qmtb8VGTXpDIalvph+xya1vIt9/aPAtT2OXfOROO1hLEXyrdtUV 111 | zKQVO75DDSMQP8qyxzfnm/IWVB3LqvmupWkpifoWvzPCH0y5Qy1gvK+qEocAHvilVOwFfJBDSJSS 112 | ikZB11SN9Y08V8BzBuJHZP4Hb3LkK1VGAAA= 113 | headers: 114 | Connection: 115 | - keep-alive 116 | Content-Encoding: 117 | - gzip 118 | Content-Type: 119 | - text/html; charset=utf-8 120 | Date: 121 | - Sat, 23 Nov 2019 22:10:29 GMT 122 | Server: 123 | - nginx 124 | Strict-Transport-Security: 125 | - max-age=15552000 126 | Transfer-Encoding: 127 | - chunked 128 | Vary: 129 | - Accept-Encoding 130 | X-Request-Id: 131 | - 91381772-b596-48de-8854-459c8172b697 132 | X-Runtime: 133 | - '0.024123' 134 | status: 135 | code: 404 136 | message: Not Found 137 | version: 1 138 | -------------------------------------------------------------------------------- /docs/source/gettingstarted.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Installation 4 | 5 | [`pip`](https://pypi.org/project/pip/) is the easiest way to install pypco. Run the single command below and you're done. 6 | 7 | ```bash 8 | pip install pypco 9 | ``` 10 | 11 | The excellent [`pipenv`](https://pypi.org/project/pipenv/) from [Kenneth Reitz](https://github.com/kennethreitz) is also highly recommended. Assuming you already have pipenv installed, run the single command below and you're ready to go. 12 | 13 | ```bash 14 | pipenv install pypco 15 | ``` 16 | 17 | Alternatively, if you want the bleeding edge or you want the source more readily available, you can install from [GitHub](https://github.com/billdeitrick/pypco). 18 | 19 | Either clone the repository: 20 | 21 | ```bash 22 | git clone git://github.com/billdeitrick/pypco.git 23 | ``` 24 | 25 | Or download a tarball: 26 | 27 | ```bash 28 | curl -OL https://github.com/billdeitrick/pypco/tarball/master 29 | ``` 30 | 31 | You can also substitute "zipball" for "tarball" in the URL above to get a zip file instead. 32 | 33 | Once you've extracted the code and have a shell in the extracted directory, you can install pypco to your Python's site-packages with: 34 | 35 | ```bash 36 | python setup.py install 37 | ``` 38 | 39 | Alternatively, you could simply embed the pypco source directory in your own Python package. 40 | 41 | Once you've gotten pypco installed, make sure you can import it in your Python script or at the interactive Python prompt with: 42 | 43 | ```python 44 | import pypco 45 | ``` 46 | 47 | Once you can import the module, you're ready to go! 48 | 49 | ## Authentication 50 | 51 | The PCO API offers two options for authentication: OAuth or Personal Access Tokens (hereafter referred to as PAT). Typically, you would use PAT for applications that will access your own data (i.e. applications that you create and use yourself) and OAuth for applications that will access a third-party's data (i.e. applications you will package and make available for others to use.) 52 | 53 | PCO provides much more detail about authentication in their [API Documentation](https://developer.planning.center/docs/#/introduction/authentication). 54 | 55 | ### Personal Access Token (PAT) Authentication 56 | 57 | PAT authentication is the simplest method (and probably what you'll use if you're wanting to kick the tires), so we'll start there. 58 | 59 | First, you'll need to generate your application id and secret. Sign into your account at [api.planningcenteronline.com](https://api.planningcenteronline.com/). Click **Applications** in the toolbar and then click **New Personal Access Token** under the Personal Access Tokens heading. Enter a description of your choosing, and select the desired API version for your app. Pypco can support any API version or combination of versions. Click **Submit**, and you'll see your generated token. 60 | 61 | Now, you're ready to connect to the PCO API. The example below demonstrates authentication with a PAT and executes a simple query to get and display single person from your account. 62 | 63 | ```python 64 | import pypco 65 | 66 | # Get an instance of the PCO object using your personal access token. 67 | pco = pypco.PCO("", "") 68 | 69 | # Get a single person from your account and display their information 70 | # The iterate() function provides an easy way to retrieve lists of objects 71 | # from an API endpoint, and automatically handles pagination 72 | people = pco.iterate('/people/v2/people') 73 | person = next(people) 74 | print(person) 75 | ``` 76 | 77 | If you can run the above example and see output for one of the people in your PCO account, you have successfully connected to the API. Continue to the [API Tour](apitour) to learn more, or learn about OAuth Authentication below. 78 | 79 | ### OAuth Authentication 80 | 81 | OAuth is the more complex method for authenticating against PCO, but is what you'll want to use if you're building an app that accesses third-party data. 82 | 83 | Before diving in, it's helpful to have an understanding of OAuth basics, both in general and as they apply to the PCO API specifically. You'll want to familiarize yourself with [PCO's Authentication docs](https://developer.planning.center/docs/#/introduction/authentication), and if you're looking to learn more about OAuth in particular you can learn everything you need to know over at [oauth.net](https://oauth.net/2/). 84 | 85 | To get started, you'll need to register your OAuth app with PCO. To do this, sign into your account at [api.planningcenteronline.com](https://api.planningcenteronline.com/). Click **Applications** in the toolbar and then click **New Application** under the My Developer Tokens (OAuth) heading. Fill out the required information and click **Submit**, and you'll see your generated client id and secret. 86 | 87 | Now, you're ready to connect to the PCO API. The example below demonstrates authentication with OAuth. Note that you'll have significantly more work to do than with PAT; you'll need to use a browser to display PCO's authentication page with the appropriate parameters and have a redirect page which will be able to hand you the returned code parameter (which you'll use to get your access and refresh tokens). While most of the heavy lifting is up to you, pypco does provide a few convenience functions to help with the process as demonstrated in the example below. 88 | 89 | ```python 90 | import pypco 91 | 92 | # Generate the login URI 93 | redirect_url = pypco.get_browser_redirect_url( 94 | "", 95 | "", 96 | ["scope_1", "scope_2"] 97 | ) 98 | 99 | # Now, you'll have the URI to which you need to send the user for authentication 100 | # Here is where you would handle that and get back the code parameter PCO returns. 101 | 102 | # For this example, we'll assume you've handled this and now have the code 103 | # parameter returned from the API 104 | 105 | # Now, we'll get the OAuth access token json response using the code we received from PCO 106 | token_response = pypco.get_oauth_access_token( 107 | "", 108 | "", 109 | "", 110 | "" 111 | ) 112 | 113 | # The response you'll receive from the get_oauth_access_token function will include your 114 | # access token, your refresh token, and other metadata you may need later. 115 | # You may wish/need to store this entire response on disk as securely as possible. 116 | # Once you've gotten your access token, you can initialize a pypco object like this: 117 | pco = pypco.PCO(token=token_response['access_token']) 118 | 119 | # Now, you're ready to go. 120 | # The iterate() function provides an easy way to retrieve lists of objects 121 | # from an API endpoint, and automatically handles pagination 122 | people = pco.iterate('/people/v2/people') 123 | person = next(people) 124 | print(person) 125 | ``` 126 | 127 | OAuth tokens will work for up to two hours after they have been issued, and can be renewed with a refresh token. Again, pypco helps you out here by providing a simple convenience function you can use to refresh OAuth tokens. 128 | 129 | ```python 130 | import pypco 131 | 132 | # Refresh the access token 133 | token_response = pypco.get_oauth_refresh_token("", "", "") 134 | 135 | # You'll get back a response similar to what you got calling get_oauth_access_token 136 | # the first time you authenticated your user against PCO. Now, you can initialize a PCO object and make some API calls. 137 | pco = pypco.PCO(token=token_response['access_token']) 138 | people = pco.iterate('/people/v2/people') 139 | person = next(people) 140 | print(person) 141 | ``` 142 | 143 | ### Church Center API Organization Token (OrganizationToken) Authentication 144 | 145 | If you want to access api.churchcenter.com endpoints you need to use an OrganizationToken. 146 | We have added the ability for pypco to get these OrganizationTokens for you using `cc_name` as an auth option. 147 | 148 | You need to pass the vanity portion of the church center url as cc_name when initializing the PCO object. 149 | To auth for https://carlsbad.churchcenter.com use `carlsbad` as the `cc_name`. 150 | 151 | 152 | Now, you're ready to connect to the Church Center API. The example below demonstrates authentication with an Org Token, the steps needed to change the base url, and executes a simple query to get and display events from the church center api. 153 | 154 | ```python 155 | import pypco 156 | 157 | # Get an instance of the PCO object using your personal access token. 158 | # Set the api_base to https://api.churchcenter.com 159 | pco = pypco.PCO(cc_name='carlsbad', 160 | api_base="https://api.churchcenter.com") 161 | 162 | # Get the events from api.churchcenter.com 163 | # The iterate() function provides an easy way to retrieve lists of objects 164 | # from an API endpoint, and automatically handles pagination 165 | 166 | events = pco.iterate('/calendar/v2/events') 167 | event = next(events) 168 | print(event) 169 | 170 | ``` 171 | 172 | If you can run the above example and see output for one of the events in Test Church Center account, you have successfully connected to the API. Continue to the [API Tour](apitour) to learn more. 173 | 174 | OrgTokens are generated with every request to the Church Center API so they should always be fresh. 175 | 176 | ## Conclusion 177 | 178 | Once you've authenticated and been able to make a simple API call, you're good to go. Head over to the [API Tour](apitour) document for a brief tour of the pypco API; this document will show you how pypco calls relate to their PCO API counterparts. Once you've read through the API Tour, you should be ready to fully leverage the capabilities of pypco (and hopefully be done reading pypco documentation…you'll be able to know exactly what pypco calls to make by reading the PCO API docs). -------------------------------------------------------------------------------- /tests/cassettes/TestPublicRequestFunctions.test_request_response.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - pypco 13 | method: GET 14 | uri: https://api.planningcenteronline.com/people/v2/people 15 | response: 16 | body: 17 | string: '{"links":{"self":"https://api.planningcenteronline.com/people/v2/people"},"data":[{"type":"Person","id":"40135868","attributes":{"accounting_administrator":true,"anniversary":null,"avatar":"https://people.planningcenteronline.com/static/no_photo_thumbnail_gray.png","birthdate":null,"child":false,"created_at":"2018-08-03T01:13:32Z","demographic_avatar_url":"https://people.planningcenteronline.com/static/no_photo_thumbnail_gray.png","first_name":"Bill","gender":null,"given_name":null,"grade":null,"graduation_year":null,"inactivated_at":null,"last_name":"Deitrick","medical_notes":null,"membership":null,"middle_name":null,"name":"Bill 18 | Deitrick","nickname":null,"passed_background_check":false,"people_permissions":null,"remote_id":null,"school_type":null,"site_administrator":true,"status":"active","updated_at":"2018-11-25T20:19:47Z"},"relationships":{"primary_campus":{"data":null}},"links":{"self":"https://api.planningcenteronline.com/people/v2/people/40135868"}},{"type":"Person","id":"45029164","attributes":{"accounting_administrator":false,"anniversary":null,"avatar":"https://people.planningcenteronline.com/static/no_photo_thumbnail_gray.png","birthdate":null,"child":false,"created_at":"2018-11-25T20:31:31Z","demographic_avatar_url":"https://people.planningcenteronline.com/static/no_photo_thumbnail_gray.png","first_name":"Paul","gender":null,"given_name":null,"grade":null,"graduation_year":null,"inactivated_at":null,"last_name":"Revere","medical_notes":null,"membership":null,"middle_name":null,"name":"Paul 19 | Revere","nickname":null,"passed_background_check":false,"people_permissions":null,"remote_id":null,"school_type":null,"site_administrator":false,"status":"active","updated_at":"2018-11-25T20:31:31Z"},"relationships":{"primary_campus":{"data":null}},"links":{"self":"https://api.planningcenteronline.com/people/v2/people/45029164"}}],"included":[],"meta":{"total_count":2,"count":2,"can_order_by":["given_name","first_name","nickname","middle_name","last_name","birthdate","anniversary","gender","grade","child","school_type","graduation_year","site_administrator","accounting_administrator","people_permissions","membership","inactivated_at","status","medical_notes","created_at","updated_at","remote_id"],"can_query_by":["given_name","first_name","nickname","middle_name","last_name","birthdate","anniversary","gender","grade","child","school_type","graduation_year","site_administrator","accounting_administrator","people_permissions","membership","inactivated_at","status","medical_notes","created_at","updated_at","search_name","search_name_or_email","search_name_or_email_or_phone_number","remote_id","id"],"can_include":["addresses","emails","field_data","households","inactive_reason","marital_status","name_prefix","name_suffix","organization","person_apps","phone_numbers","platform_notifications","primary_campus","school","social_profiles"],"can_filter":["created_since","admins","organization_admins"],"parent":{"id":"263468","type":"Organization"}}}' 20 | headers: 21 | Access-Control-Allow-Credentials: 22 | - 'true' 23 | Access-Control-Allow-Headers: 24 | - Accept, Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, 25 | If-Unmodified-Since, Origin, X-Requested-With, X-CSRF-Token, X-PCO-API-Version, 26 | X-PCO-API-Log-Level, X-PCO-API-Tracer-Class, X-PCO-API-Tracer-Secret 27 | Access-Control-Allow-Methods: 28 | - GET, POST, PATCH, PUT, DELETE, OPTIONS 29 | Access-Control-Allow-Origin: 30 | - '*' 31 | Access-Control-Expose-Headers: 32 | - ETag, Link, X-PCO-API-Auth-Method 33 | Cache-Control: 34 | - max-age=0, private, must-revalidate 35 | Connection: 36 | - keep-alive 37 | Content-Type: 38 | - application/vnd.api+json; charset=utf-8 39 | Date: 40 | - Sat, 23 Nov 2019 06:14:09 GMT 41 | ETag: 42 | - W/"7adc698a65856c3e50e70b77bb145069" 43 | Referrer-Policy: 44 | - no-referrer-when-downgrade 45 | Server: 46 | - nginx 47 | Strict-Transport-Security: 48 | - max-age=31536000; includeSubDomains 49 | Transfer-Encoding: 50 | - chunked 51 | Vary: 52 | - Accept, Accept-Encoding, Origin 53 | X-Content-Type-Options: 54 | - nosniff 55 | X-Download-Options: 56 | - noopen 57 | X-Frame-Options: 58 | - SAMEORIGIN 59 | X-PCO-API-Auth-Method: 60 | - HTTPBasic 61 | X-PCO-API-Processed-As-Version: 62 | - '2018-08-01' 63 | X-PCO-API-Processor: 64 | - ENG_2.2.2 65 | X-PCO-API-Request-Rate-Count: 66 | - '3' 67 | X-PCO-API-Request-Rate-Limit: 68 | - '100' 69 | X-PCO-API-Request-Rate-Period: 70 | - '20' 71 | X-Permitted-Cross-Domain-Policies: 72 | - none 73 | X-Request-Id: 74 | - 35c2799f-2a3f-4b02-bb04-b0716965b0bc 75 | X-Runtime: 76 | - '0.060894' 77 | X-XSS-Protection: 78 | - 1; mode=block 79 | status: 80 | code: 200 81 | message: OK 82 | - request: 83 | body: null 84 | headers: 85 | Accept: 86 | - '*/*' 87 | Accept-Encoding: 88 | - gzip, deflate 89 | Connection: 90 | - keep-alive 91 | User-Agent: 92 | - pypco 93 | method: GET 94 | uri: https://api.planningcenteronline.com/bogus 95 | response: 96 | body: 97 | string: !!binary | 98 | H4sIAAAAAAAAAx2LMQqAQAwEvxJSW1hY+QBLKzuxUG9F4TCYSwoR/26wmxmYh6EqWrgdHy42mwdy 99 | UzdcsR2WEdaLUSd+pmgJNh854rCDFEVcV9AtHnI5iiHRKp4TnXEtoO0f3+n9AEjrraVqAAAA 100 | headers: 101 | Access-Control-Allow-Credentials: 102 | - 'true' 103 | Access-Control-Allow-Headers: 104 | - Accept, Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, 105 | If-Unmodified-Since, Origin, X-Requested-With, X-CSRF-Token, X-PCO-API-Version 106 | Access-Control-Allow-Methods: 107 | - GET, POST, PATCH, PUT, DELETE, OPTIONS 108 | Access-Control-Allow-Origin: 109 | - '*' 110 | Access-Control-Expose-Headers: 111 | - ETag, Link, X-PCO-API-Auth-Method 112 | Cache-Control: 113 | - no-cache 114 | Connection: 115 | - keep-alive 116 | Content-Encoding: 117 | - gzip 118 | Content-Type: 119 | - application/json; charset=utf-8 120 | Date: 121 | - Sat, 23 Nov 2019 06:14:09 GMT 122 | Server: 123 | - nginx 124 | Strict-Transport-Security: 125 | - max-age=15552000; includeSubDomains 126 | Transfer-Encoding: 127 | - chunked 128 | Vary: 129 | - Accept-Encoding 130 | - Accept, Accept-Encoding, Origin 131 | X-Content-Type-Options: 132 | - nosniff 133 | X-Frame-Options: 134 | - SAMEORIGIN 135 | X-PCO-API-Request-Rate-Count: 136 | - '2' 137 | X-PCO-API-Request-Rate-Limit: 138 | - '100' 139 | X-PCO-API-Request-Rate-Period: 140 | - 20 seconds 141 | X-Request-Id: 142 | - bb1f1fe0-8d96-49c0-ac62-835901e2b1bc 143 | X-Runtime: 144 | - '0.016988' 145 | X-XSS-Protection: 146 | - 1; mode=block 147 | status: 148 | code: 404 149 | message: Not Found 150 | - request: 151 | body: '{}' 152 | headers: 153 | Accept: 154 | - '*/*' 155 | Accept-Encoding: 156 | - gzip, deflate 157 | Connection: 158 | - keep-alive 159 | Content-Length: 160 | - '2' 161 | Content-Type: 162 | - application/json 163 | User-Agent: 164 | - pypco 165 | method: POST 166 | uri: https://api.planningcenteronline.com/people/v2/people 167 | response: 168 | body: 169 | string: '{"errors":[{"status":"400","title":"Bad Request","code":"invalid_resource_payload","detail":"The 170 | payload given does not contain a ''data'' key."}]}' 171 | headers: 172 | Access-Control-Allow-Credentials: 173 | - 'true' 174 | Access-Control-Allow-Headers: 175 | - Accept, Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, 176 | If-Unmodified-Since, Origin, X-Requested-With, X-CSRF-Token, X-PCO-API-Version, 177 | X-PCO-API-Log-Level, X-PCO-API-Tracer-Class, X-PCO-API-Tracer-Secret 178 | Access-Control-Allow-Methods: 179 | - GET, POST, PATCH, PUT, DELETE, OPTIONS 180 | Access-Control-Allow-Origin: 181 | - '*' 182 | Access-Control-Expose-Headers: 183 | - ETag, Link, X-PCO-API-Auth-Method 184 | Cache-Control: 185 | - no-cache 186 | Connection: 187 | - keep-alive 188 | Content-Type: 189 | - application/json; charset=utf-8 190 | Date: 191 | - Sat, 23 Nov 2019 06:14:10 GMT 192 | Referrer-Policy: 193 | - no-referrer-when-downgrade 194 | Server: 195 | - nginx 196 | Strict-Transport-Security: 197 | - max-age=31536000; includeSubDomains 198 | Transfer-Encoding: 199 | - chunked 200 | Vary: 201 | - Accept, Accept-Encoding, Origin 202 | X-Content-Type-Options: 203 | - nosniff 204 | X-Download-Options: 205 | - noopen 206 | X-Frame-Options: 207 | - SAMEORIGIN 208 | X-PCO-API-Auth-Method: 209 | - HTTPBasic 210 | X-PCO-API-Processed-As-Version: 211 | - '2018-08-01' 212 | X-PCO-API-Processor: 213 | - ENG_2.2.2 214 | X-PCO-API-Request-Rate-Count: 215 | - '5' 216 | X-PCO-API-Request-Rate-Limit: 217 | - '100' 218 | X-PCO-API-Request-Rate-Period: 219 | - '20' 220 | X-Permitted-Cross-Domain-Policies: 221 | - none 222 | X-Request-Id: 223 | - 57d418c5-b002-424a-b27b-80310e79e6ee 224 | X-Runtime: 225 | - '0.022942' 226 | X-XSS-Protection: 227 | - 1; mode=block 228 | status: 229 | code: 400 230 | message: Bad Request 231 | version: 1 232 | -------------------------------------------------------------------------------- /docs/source/apitour.md: -------------------------------------------------------------------------------- 1 | # API Tour 2 | 3 | ## Introduction 4 | 5 | This API Tour will quickly introduce you to pypco conventions and so you'll be on your way to building cool stuff. Once you've spent a few minutes learning the ropes with pypco, you'll spend the rest of your time directly in the PCO API docs to figure out how to craft your requests. 6 | 7 | For the purposes of this document, we'll assume you've already been able to authenticate successfully using the methods described in the [Getting Started Guide](gettingstarted). Thus, each of the examples below will assume that you've already done something like the following to import the pypco module and initialize an instance of the PCO object: 8 | 9 | ```python 10 | >>> import pypco 11 | 12 | >>> pco = pypco.PCO("", "") 13 | ``` 14 | 15 | Also for purposes of this guide, we'll assume you're already somewhat familiar with the PCO API ([docs here](https://developer.planning.center/docs/#/introduction)). If you're not, you might want to read the [Introduction](https://developer.planning.center/docs/#/introduction) and then come back here. 16 | 17 | ### URL Passing and `api_base` 18 | 19 | As you'll see shortly, for most requests you'll specify portions of the URL corresponding to the API endpoint against which you would like to make a REST call. You don't need to specify the protocol and hostname portions of the URL; these are automatically prepended for you whenever you pass a URL into pypco. Pypco refers to the automatically prepended protocol and hostname as `api_base`. By default, `api_base` is `https://api.planningcenteronline.com` (though an alternative can be [passed as an argument](pypco.html#module-pypco.pco)). So, you'll want to include a forward slash at the beginning of any URL argument you pass. Don't worry if this is confusing right now; it will all make sense once you've read the examples below. 20 | 21 | Often times, you may find it would be easier to pass a URL to pypco that includes `api_base`. You might want to do this in situations where you've pulled a URL to a specific object in the PCO API directly from an attribute on an object you've already retrieved (such as a "links" attribute). Pypco has no problem if you include the `api_base` in a URL you pass in; it's smart enough to detect that it doesn't need to prepend `api_base` again in this situation so there's no need for you to worry about stripping it out. 22 | 23 | ## The Basics: GET, POST, PATCH, and DELETE 24 | 25 | ### GET: Retrieving Objects 26 | 27 | Let's start with a simple GET request where we retrieve a specific person from the People API and print their name: 28 | 29 | ```python 30 | # Retrieve the person with ID 71059458 31 | # (GET https://api.planningcenteronline.com/people/v2/people/71059458) 32 | >>> person = pco.get('/people/v2/people/71059458') # Returns a Dict 33 | >>> print(person['data']['attributes']['name']) 34 | John Smith 35 | ``` 36 | 37 | If you need to pass any URL parameters, you can pass them to `get()` as keyword arguments, like this: 38 | 39 | ```python 40 | >>> person = pco.get('/people/v2/people/71059458', test='hello', test2='world') 41 | ``` 42 | 43 | This would result in the following request to the API: `GET https://api.planningcenteronline.com/people/v2/people/71059458?test=hello&test2=world` 44 | 45 | Using keyword arguments you can pass any parameters supported by the PCO API for a given endpoint. 46 | 47 | > **NOTE:** URL parameter keyword arguments must be passed as strings. If you are passing an object like a `dict` or a `list` for instance, cast it to a `str` and verify it is in the format required by the PCO API! 48 | 49 | There may be times where you need to pass URL parameters that you can't pass as function arguments (for example, when the URL parameters contain characters that can't be used in a Python variable name). In these situations, create a `dict` and pass the keyword arguments using the double splat operator: 50 | 51 | ```python 52 | >>> params = { 53 | 'where[first_name]': 'pico', 54 | 'where[last_name]': 'robot' 55 | } 56 | >>> result = pco.get('/people/v2/people', **params) 57 | ``` 58 | 59 | You can learn more about the `get()` function in the [PCO module docs](pypco.html#pypco.pco.PCO.get). 60 | 61 | ### PATCH: Updating Objects 62 | 63 | When altering existing objects in the PCO API, you only need to pass the attributes in your request payload that you wish to change. The easiest way to to generate the necessary payload for your request is using the [`template()` function](pypco.html#pypco.pco.PCO.template). The `template()` function takes the object type and attributes as arguments and returns a `dict` object that you can pass to `patch()` (which will serialize the object to JSON for you). There is, of course, no reason you have to use the `template()` function, but this is provided for you to help speed the process of generating request payloads. 64 | 65 | In this example we'll change an existing person object's last name, using the `template()` function to generate the appropriate payload. 66 | 67 | ```python 68 | # First we'll retrieve the existing person and print their last name 69 | >>> person = pco.get('/people/v2/people/71059458') # Returns a Dict 70 | >>> print(person['data']['attributes']['name']) 71 | John Smith 72 | 73 | # Next, we'll use the template() function to build our JSON payload 74 | # for the PATCH request 75 | >>> update_payload = pco.template('Person', {'last_name': 'Rolfe'}) 76 | 77 | # Perform the PATCH request; patch() will return the updated object 78 | >>> updated_person = pco.patch(person['data']['links']['self'], payload=update_payload) 79 | >>> print(updated_person['data']['attributes']['name']) 80 | John Rolfe 81 | ``` 82 | 83 | Be sure to consult the PCO API Docs for whatever object you are attempting to update to ensure you are passing assignable attributes your payload. If you receive an exception when attempting to update an object, be sure to read [exception handling](#exception-handling) below to learn how to find the most information possible about what went wrong. 84 | 85 | Aside from the `payload` keyword argument, any additional arguments you pass to the `patch()` function will be sent as query parameters with your request. 86 | 87 | You can learn more about the `patch()` function in the [PCO module docs](pypco.html#pypco.pco.PCO.patch). 88 | 89 | ### POST: Creating Objects 90 | 91 | Similarly to altering existing objects via a PATCH request, the first step towards creating new objects in the PCO API is generally using the [`template()` function](pypco.html#pypco.pco.PCO.template) to generate the necessary payload. 92 | 93 | In the following example, we'll create a new person in PCO using the `template()` function to generate the payload for the request. 94 | 95 | ```python 96 | # Create a payload for the request 97 | >>> create_payload = pco.template( 98 | 'Person', 99 | { 100 | 'first_name': 'Benjamin', 101 | 'last_name': 'Franklin', 102 | 'nickname': 'Ben' 103 | } 104 | ) 105 | 106 | # Create the person object and print the name attribute 107 | >>> person = pco.post('/people/v2/people', payload=create_payload) 108 | >>> print(person['data']['attributes']['name']) 109 | Benjamin Franklin 110 | ``` 111 | 112 | Just like `patch()`, always be sure to consult the PCO API docs for the object type you are attempting to create to be sure you are passing assignable attributes in the correct format. If you do get stuck, the [exception handling](#exception-handling) section below will help you learn how to get the most possible information about what went wrong. 113 | 114 | Also just like `patch()`, any keyword arguments you pass to `post()` aside from the `payload` argument will be added as parameters to your API request. 115 | 116 | Aside from object creation, HTTP POST requests are also used by various PCO API endpoints for "Actions". These are endpoint-specific operations supported by various endpoints, such as the [Song Endpoint](https://developer.planning.center/docs/#/apps/services/2018-11-01/vertices/song). You can use the `post()` function for Action operations as needed; be sure to pass in the appropriate argument to the `payload` parameter (as a `dict`, which will automatically be serialized to JSON for you). 117 | 118 | You can learn more about the `post()` function in the [PCO module docs](pypco.html#pypco.pco.PCO.post). 119 | 120 | ### DELETE: Removing Objects 121 | 122 | Removing objects is probably the simplest operation to perform. Simply pass the desired object's URL to the `delete()` function: 123 | 124 | ```python 125 | >>> response = pco.delete('/people/v2/people/71661010') 126 | >>> print(response) 127 | 128 | ``` 129 | 130 | Note that the `delete()` function returns a [Requests](https://requests.readthedocs.io/en/master/) `Response` object instead of a `dict` since the PCO API always returns an empty payload for a DELETE request. The `Response` object returned by a successful DELETE request will have a `status_code` value of 204. 131 | 132 | As usual, any keyword arguments you pass to `delete()` will be passed to the PCO API as query parameters (though you typically won't need query parameters for DELETE requests). 133 | 134 | You can learn more about the `delete()` function in the [PCO module docs](pypco.html#pypco.pco.PCO.delete). 135 | 136 | ## Advanced: Object Iteration and File Uploads 137 | 138 | ### Object Iteration with `iterate()` 139 | 140 | Querying an API endpoint that returns a (possibly quite large) list of results is probably something you'll need to do at one time or another. To simplify this common use case, pypco provides the `iterate()` function. `iterate()` is a [generator function](https://wiki.python.org/moin/Generators) that performs GET requests against API endpoints that return lists of objects, transparently handling pagination. 141 | 142 | Let's look at a simple example, where we iterate through all of the `person` objects in PCO People and print out their names: 143 | 144 | ```python 145 | >>> for person in pco.iterate('/people/v2/people'): 146 | >>> print(person['data']['attributes']['name']) 147 | John Rolfe 148 | Benjamin Franklin 149 | ... 150 | ``` 151 | 152 | Just like `get()`, any keyword arguments you pass to `iterate()` will be added to your HTTP request as query parameters. For many API endpoints, this will allow you to build specific queries to pull data from PCO. In the example below, we demonstrate searching for all `person` objects with the last name "Rolfe". Note the use of the double splat operator to pass parameters as explained [above](#get-retrieving-objects). 153 | 154 | ```python 155 | >>> params = { 156 | 'where[last_name]': 'Rolfe' 157 | } 158 | >>> for person in pco.iterate('/people/v2/people', **params): 159 | >>> print(person['data']['attributes']['name']) 160 | John Rolfe 161 | ... 162 | ``` 163 | 164 | Often you will want to use includes to return associated objects with your call to `iterate()`. To accomplish this, you can simply pass `includes` as a keyword argument to the `iterate()` function. To save you from having to find which includes are associated with a particular object yourself, `iterate()` will return objects to you with only their associated includes. 165 | 166 | You can learn more about the `iterate()` function in the [PCO module docs](pypco.html#pypco.pco.PCO.iterate). 167 | 168 | ### File Uploads with `upload()` 169 | 170 | Pypco provides a simple function to support file uploads to PCO (such as song attachments in Services, avatars in People, etc). To facilitate file uploads as described in the [PCO API docs for file uploads](https://developer.planning.center/docs/#/introduction/file-uploads), you'll first use the `upload()` function to upload files from your disk to PCO. This action will return to you a unique ID (UUID) for your newly uploaded file. Once you have the file UUID, you'll pass this to an endpoint that accepts a file. 171 | 172 | In the example below, we upload a avatar image for a person in PCO People and associate it with the appropriate person object: 173 | 174 | ```python 175 | # Upload the file, receive response containing UUID 176 | >>> upload_response = pco.upload('john.jpg') 177 | # Update the avatar attribute on the appropriate person object 178 | # and print the resulting URL 179 | >>> avatar_update = pco.template( 180 | 'Person', 181 | {'avatar': upload_response['data'][0]['id']} 182 | ) 183 | >>> person = pco.patch('/people/v2/people/71059458', payload=avatar_update) 184 | >>> print(person['data']['attributes']['avatar']) 185 | https://avatars.planningcenteronline.com/uploads/person/71059458-1578368234/avatar.2.jpg 186 | ``` 187 | 188 | As usual, any keyword arguments you pass to `upload()` will be passed to the PCO API as query parameters (though you typically won't need query parameters for file uploads). 189 | 190 | You can learn more about the `upload()` function in the [PCO module docs](pypco.html#pypco.pco.PCO.upload). 191 | 192 | ## Exception Handling 193 | 194 | Pypco provides custom exception types for error handling purposes. All exceptions are defined in the [exceptions](pypco.html#module-pypco.exceptions) module, and inherit from the base [PCOExceptions](pypco.html#pypco.exceptions.PCOException) class. 195 | 196 | Most of the pypco exception classes are fairly mundane, though the [PCORequestException](pypco.html#pypco.exceptions.PCORequestException) class is worth a closer look. This exception is raised in circumstances where a connection was made to the API, but the API responds with a status code indicative of an error (other than a rate limit error, as these are handled transparently as discussed below). To provide as much helpful diagnostic information as possible, `PCORequestException` provides three attributes with more data about the failed request: `status_code`, `message`, and `response_body`. You can find more details about each of these attributes in the [PCORequestException Docs](pypco.html#pypco.exceptions.PCORequestException). A brief example is provided below showing what sort of information each of these variables might contain when a request raises this exception: 197 | 198 | ```python 199 | # Create an invalid payload to use as an example 200 | >>> bad_payload = pco.template( 201 | 'Person', 202 | {'bogus': 'bogus'} 203 | ) 204 | 205 | # Our bad payload will raise an exception...print out attributes 206 | # from PCORequestException 207 | >>> try: 208 | >>> result = pco.patch('/people/v2/people/71059458', payload=bad_payload) 209 | >>> except Exception as e: 210 | >>> print(f'{e.status_code}\n-\n{e.message}\n-\n{e.response_body}') 211 | 422 212 | - 213 | 422 Client Error: Unprocessable Entity for url: 214 | - 215 | https://api.planningcenteronline.com/people/v2/people/71059458 216 | {"errors":[{"status":"422","title":"Forbidden Attribute","detail":"bogus cannot be assigned"}]} 217 | ``` 218 | 219 | You can find more information about all types of exceptions raised by pypco in the [PCOExceptions module docs](pypco.html#module-pypco.exceptions). 220 | 221 | ## Rate Limit Handling 222 | 223 | Pypco automatically handles rate limiting for you. When you've hit your rate limit, pypco will look at the value of the `Retry-After` header from the PCO API and automatically pause your requests until your rate limit for the current period has expired. Pypco uses the `sleep()` function from Python's `time` package to do this. While the `sleep()` function isn't reliable as a measure of time per se because of the underlying kernel-level mechanisms on which it relies, it has proven accurate enough for this use case. -------------------------------------------------------------------------------- /tests/cassettes/TestPublicRequestFunctions.test_get.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - pypco 13 | method: GET 14 | uri: https://api.planningcenteronline.com/people/v2/people/45029164 15 | response: 16 | body: 17 | string: '{"data":{"type":"Person","id":"45029164","attributes":{"accounting_administrator":false,"anniversary":null,"avatar":"https://people.planningcenteronline.com/static/no_photo_thumbnail_gray.png","birthdate":null,"child":false,"created_at":"2018-11-25T20:31:31Z","demographic_avatar_url":"https://people.planningcenteronline.com/static/no_photo_thumbnail_gray.png","first_name":"Paul","gender":null,"given_name":null,"grade":null,"graduation_year":null,"inactivated_at":null,"last_name":"Revere","medical_notes":null,"membership":null,"middle_name":null,"name":"Paul 18 | Revere","nickname":null,"passed_background_check":false,"people_permissions":null,"remote_id":null,"school_type":null,"site_administrator":false,"status":"active","updated_at":"2018-11-25T20:31:31Z"},"relationships":{"primary_campus":{"data":null}},"links":{"":"https://api.planningcenteronline.com/people/v2/people/45029164/","addresses":"https://api.planningcenteronline.com/people/v2/people/45029164/addresses","apps":"https://api.planningcenteronline.com/people/v2/people/45029164/apps","connected_people":"https://api.planningcenteronline.com/people/v2/people/45029164/connected_people","emails":"https://api.planningcenteronline.com/people/v2/people/45029164/emails","field_data":"https://api.planningcenteronline.com/people/v2/people/45029164/field_data","household_memberships":"https://api.planningcenteronline.com/people/v2/people/45029164/household_memberships","households":"https://api.planningcenteronline.com/people/v2/people/45029164/households","inactive_reason":null,"marital_status":null,"message_groups":"https://api.planningcenteronline.com/people/v2/people/45029164/message_groups","messages":"https://api.planningcenteronline.com/people/v2/people/45029164/messages","name_prefix":null,"name_suffix":null,"notes":"https://api.planningcenteronline.com/people/v2/people/45029164/notes","organization":"https://api.planningcenteronline.com/people/v2/people/45029164/organization","person_apps":"https://api.planningcenteronline.com/people/v2/people/45029164/person_apps","phone_numbers":"https://api.planningcenteronline.com/people/v2/people/45029164/phone_numbers","platform_notifications":"https://api.planningcenteronline.com/people/v2/people/45029164/platform_notifications","primary_campus":null,"school":null,"social_profiles":"https://api.planningcenteronline.com/people/v2/people/45029164/social_profiles","workflow_cards":"https://api.planningcenteronline.com/people/v2/people/45029164/workflow_cards","self":"https://api.planningcenteronline.com/people/v2/people/45029164"}},"included":[],"meta":{"can_include":["addresses","emails","field_data","households","inactive_reason","marital_status","name_prefix","name_suffix","organization","person_apps","phone_numbers","platform_notifications","primary_campus","school","social_profiles"],"parent":{"id":"263468","type":"Organization"}}}' 19 | headers: 20 | Access-Control-Allow-Credentials: 21 | - 'true' 22 | Access-Control-Allow-Headers: 23 | - Accept, Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, 24 | If-Unmodified-Since, Origin, X-Requested-With, X-CSRF-Token, X-PCO-API-Version, 25 | X-PCO-API-Log-Level, X-PCO-API-Tracer-Class, X-PCO-API-Tracer-Secret 26 | Access-Control-Allow-Methods: 27 | - GET, POST, PATCH, PUT, DELETE, OPTIONS 28 | Access-Control-Allow-Origin: 29 | - '*' 30 | Access-Control-Expose-Headers: 31 | - ETag, Link, X-PCO-API-Auth-Method 32 | Cache-Control: 33 | - max-age=0, private, must-revalidate 34 | Connection: 35 | - keep-alive 36 | Content-Type: 37 | - application/vnd.api+json; charset=utf-8 38 | Date: 39 | - Sat, 23 Nov 2019 21:12:37 GMT 40 | ETag: 41 | - W/"a1d4be2bfa39d9c8000228bcb9b50726" 42 | Referrer-Policy: 43 | - no-referrer-when-downgrade 44 | Server: 45 | - nginx 46 | Strict-Transport-Security: 47 | - max-age=31536000; includeSubDomains 48 | Transfer-Encoding: 49 | - chunked 50 | Vary: 51 | - Accept, Accept-Encoding, Origin 52 | X-Content-Type-Options: 53 | - nosniff 54 | X-Download-Options: 55 | - noopen 56 | X-Frame-Options: 57 | - SAMEORIGIN 58 | X-PCO-API-Auth-Method: 59 | - HTTPBasic 60 | X-PCO-API-Processed-As-Version: 61 | - '2018-08-01' 62 | X-PCO-API-Processor: 63 | - ENG_2.2.2 64 | X-PCO-API-Request-Rate-Count: 65 | - '1' 66 | X-PCO-API-Request-Rate-Limit: 67 | - '100' 68 | X-PCO-API-Request-Rate-Period: 69 | - '20' 70 | X-Permitted-Cross-Domain-Policies: 71 | - none 72 | X-Request-Id: 73 | - 19250fef-7e8a-444f-bc9b-5ce187cb1d47 74 | X-Runtime: 75 | - '0.031433' 76 | X-XSS-Protection: 77 | - 1; mode=block 78 | status: 79 | code: 200 80 | message: OK 81 | - request: 82 | body: null 83 | headers: 84 | Accept: 85 | - '*/*' 86 | Accept-Encoding: 87 | - gzip, deflate 88 | Connection: 89 | - keep-alive 90 | User-Agent: 91 | - pypco 92 | method: GET 93 | uri: https://api.planningcenteronline.com/people/v2/people/45029164?include=emails%2Corganization 94 | response: 95 | body: 96 | string: '{"data":{"type":"Person","id":"45029164","attributes":{"accounting_administrator":false,"anniversary":null,"avatar":"https://people.planningcenteronline.com/static/no_photo_thumbnail_gray.png","birthdate":null,"child":false,"created_at":"2018-11-25T20:31:31Z","demographic_avatar_url":"https://people.planningcenteronline.com/static/no_photo_thumbnail_gray.png","first_name":"Paul","gender":null,"given_name":null,"grade":null,"graduation_year":null,"inactivated_at":null,"last_name":"Revere","medical_notes":null,"membership":null,"middle_name":null,"name":"Paul 97 | Revere","nickname":null,"passed_background_check":false,"people_permissions":null,"remote_id":null,"school_type":null,"site_administrator":false,"status":"active","updated_at":"2018-11-25T20:31:31Z"},"relationships":{"primary_campus":{"data":null},"emails":{"links":{"related":"https://api.planningcenteronline.com/people/v2/people/45029164/emails"},"data":[{"type":"Email","id":"29871166"}]},"organization":{"links":{"related":"https://api.planningcenteronline.com/people/v2/people/45029164/organization"},"data":{"type":"Organization","id":"263468"}}},"links":{"":"https://api.planningcenteronline.com/people/v2/people/45029164/","addresses":"https://api.planningcenteronline.com/people/v2/people/45029164/addresses","apps":"https://api.planningcenteronline.com/people/v2/people/45029164/apps","connected_people":"https://api.planningcenteronline.com/people/v2/people/45029164/connected_people","emails":"https://api.planningcenteronline.com/people/v2/people/45029164/emails","field_data":"https://api.planningcenteronline.com/people/v2/people/45029164/field_data","household_memberships":"https://api.planningcenteronline.com/people/v2/people/45029164/household_memberships","households":"https://api.planningcenteronline.com/people/v2/people/45029164/households","inactive_reason":null,"marital_status":null,"message_groups":"https://api.planningcenteronline.com/people/v2/people/45029164/message_groups","messages":"https://api.planningcenteronline.com/people/v2/people/45029164/messages","name_prefix":null,"name_suffix":null,"notes":"https://api.planningcenteronline.com/people/v2/people/45029164/notes","organization":"https://api.planningcenteronline.com/people/v2/people/45029164/organization","person_apps":"https://api.planningcenteronline.com/people/v2/people/45029164/person_apps","phone_numbers":"https://api.planningcenteronline.com/people/v2/people/45029164/phone_numbers","platform_notifications":"https://api.planningcenteronline.com/people/v2/people/45029164/platform_notifications","primary_campus":null,"school":null,"social_profiles":"https://api.planningcenteronline.com/people/v2/people/45029164/social_profiles","workflow_cards":"https://api.planningcenteronline.com/people/v2/people/45029164/workflow_cards","self":"https://api.planningcenteronline.com/people/v2/people/45029164"}},"included":[{"type":"Email","id":"29871166","attributes":{"address":"paul.revere@mailinator.com","created_at":"2018-11-25T20:31:31Z","location":"Home","primary":true,"updated_at":"2018-11-25T20:31:31Z"},"relationships":{"person":{"data":{"type":"Person","id":"45029164"}}},"links":{"self":"https://api.planningcenteronline.com/people/v2/emails/29871166"}},{"type":"Organization","id":"263468","attributes":{"country_code":null,"date_format":"%m/%d/%Y","name":"Pypco 98 | Dev","time_zone":"America/New_York"},"links":{"self":"https://api.planningcenteronline.com/people/v2/people/45029164/organization"}}],"meta":{"can_include":["addresses","emails","field_data","households","inactive_reason","marital_status","name_prefix","name_suffix","organization","person_apps","phone_numbers","platform_notifications","primary_campus","school","social_profiles"],"parent":{"id":"263468","type":"Organization"}}}' 99 | headers: 100 | Access-Control-Allow-Credentials: 101 | - 'true' 102 | Access-Control-Allow-Headers: 103 | - Accept, Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, 104 | If-Unmodified-Since, Origin, X-Requested-With, X-CSRF-Token, X-PCO-API-Version, 105 | X-PCO-API-Log-Level, X-PCO-API-Tracer-Class, X-PCO-API-Tracer-Secret 106 | Access-Control-Allow-Methods: 107 | - GET, POST, PATCH, PUT, DELETE, OPTIONS 108 | Access-Control-Allow-Origin: 109 | - '*' 110 | Access-Control-Expose-Headers: 111 | - ETag, Link, X-PCO-API-Auth-Method 112 | Cache-Control: 113 | - max-age=0, private, must-revalidate 114 | Connection: 115 | - keep-alive 116 | Content-Type: 117 | - application/vnd.api+json; charset=utf-8 118 | Date: 119 | - Sat, 23 Nov 2019 21:12:37 GMT 120 | ETag: 121 | - W/"bc56094c1d944bf8064e97d9d7f0dee8" 122 | Referrer-Policy: 123 | - no-referrer-when-downgrade 124 | Server: 125 | - nginx 126 | Strict-Transport-Security: 127 | - max-age=31536000; includeSubDomains 128 | Transfer-Encoding: 129 | - chunked 130 | Vary: 131 | - Accept, Accept-Encoding, Origin 132 | X-Content-Type-Options: 133 | - nosniff 134 | X-Download-Options: 135 | - noopen 136 | X-Frame-Options: 137 | - SAMEORIGIN 138 | X-PCO-API-Auth-Method: 139 | - HTTPBasic 140 | X-PCO-API-Processed-As-Version: 141 | - '2018-08-01' 142 | X-PCO-API-Processor: 143 | - ENG_2.2.2 144 | X-PCO-API-Request-Rate-Count: 145 | - '2' 146 | X-PCO-API-Request-Rate-Limit: 147 | - '100' 148 | X-PCO-API-Request-Rate-Period: 149 | - '20' 150 | X-Permitted-Cross-Domain-Policies: 151 | - none 152 | X-Request-Id: 153 | - 63bfef40-e318-42d4-bb3f-5d7563739ba4 154 | X-Runtime: 155 | - '0.031582' 156 | X-XSS-Protection: 157 | - 1; mode=block 158 | status: 159 | code: 200 160 | message: OK 161 | - request: 162 | body: null 163 | headers: 164 | Accept: 165 | - '*/*' 166 | Accept-Encoding: 167 | - gzip, deflate 168 | Connection: 169 | - keep-alive 170 | User-Agent: 171 | - pypco 172 | method: GET 173 | uri: https://api.planningcenteronline.com/people/v2/people?where%5Bfirst_name%5D=paul 174 | response: 175 | body: 176 | string: '{"links":{"self":"https://api.planningcenteronline.com/people/v2/people?where[first_name]=paul"},"data":[{"type":"Person","id":"45029164","attributes":{"accounting_administrator":false,"anniversary":null,"avatar":"https://people.planningcenteronline.com/static/no_photo_thumbnail_gray.png","birthdate":null,"child":false,"created_at":"2018-11-25T20:31:31Z","demographic_avatar_url":"https://people.planningcenteronline.com/static/no_photo_thumbnail_gray.png","first_name":"Paul","gender":null,"given_name":null,"grade":null,"graduation_year":null,"inactivated_at":null,"last_name":"Revere","medical_notes":null,"membership":null,"middle_name":null,"name":"Paul 177 | Revere","nickname":null,"passed_background_check":false,"people_permissions":null,"remote_id":null,"school_type":null,"site_administrator":false,"status":"active","updated_at":"2018-11-25T20:31:31Z"},"relationships":{"primary_campus":{"data":null}},"links":{"self":"https://api.planningcenteronline.com/people/v2/people/45029164"}}],"included":[],"meta":{"total_count":1,"count":1,"can_order_by":["given_name","first_name","nickname","middle_name","last_name","birthdate","anniversary","gender","grade","child","school_type","graduation_year","site_administrator","accounting_administrator","people_permissions","membership","inactivated_at","status","medical_notes","created_at","updated_at","remote_id"],"can_query_by":["given_name","first_name","nickname","middle_name","last_name","birthdate","anniversary","gender","grade","child","school_type","graduation_year","site_administrator","accounting_administrator","people_permissions","membership","inactivated_at","status","medical_notes","created_at","updated_at","search_name","search_name_or_email","search_name_or_email_or_phone_number","remote_id","id"],"can_include":["addresses","emails","field_data","households","inactive_reason","marital_status","name_prefix","name_suffix","organization","person_apps","phone_numbers","platform_notifications","primary_campus","school","social_profiles"],"can_filter":["created_since","admins","organization_admins"],"parent":{"id":"263468","type":"Organization"}}}' 178 | headers: 179 | Access-Control-Allow-Credentials: 180 | - 'true' 181 | Access-Control-Allow-Headers: 182 | - Accept, Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, 183 | If-Unmodified-Since, Origin, X-Requested-With, X-CSRF-Token, X-PCO-API-Version, 184 | X-PCO-API-Log-Level, X-PCO-API-Tracer-Class, X-PCO-API-Tracer-Secret 185 | Access-Control-Allow-Methods: 186 | - GET, POST, PATCH, PUT, DELETE, OPTIONS 187 | Access-Control-Allow-Origin: 188 | - '*' 189 | Access-Control-Expose-Headers: 190 | - ETag, Link, X-PCO-API-Auth-Method 191 | Cache-Control: 192 | - max-age=0, private, must-revalidate 193 | Connection: 194 | - keep-alive 195 | Content-Type: 196 | - application/vnd.api+json; charset=utf-8 197 | Date: 198 | - Sat, 23 Nov 2019 21:12:38 GMT 199 | ETag: 200 | - W/"0f70d17bf615f35ec3164dcf186a6491" 201 | Referrer-Policy: 202 | - no-referrer-when-downgrade 203 | Server: 204 | - nginx 205 | Strict-Transport-Security: 206 | - max-age=31536000; includeSubDomains 207 | Transfer-Encoding: 208 | - chunked 209 | Vary: 210 | - Accept, Accept-Encoding, Origin 211 | X-Content-Type-Options: 212 | - nosniff 213 | X-Download-Options: 214 | - noopen 215 | X-Frame-Options: 216 | - SAMEORIGIN 217 | X-PCO-API-Auth-Method: 218 | - HTTPBasic 219 | X-PCO-API-Processed-As-Version: 220 | - '2018-08-01' 221 | X-PCO-API-Processor: 222 | - ENG_2.2.2 223 | X-PCO-API-Request-Rate-Count: 224 | - '3' 225 | X-PCO-API-Request-Rate-Limit: 226 | - '100' 227 | X-PCO-API-Request-Rate-Period: 228 | - '20' 229 | X-Permitted-Cross-Domain-Policies: 230 | - none 231 | X-Request-Id: 232 | - 70c9eb4b-acf8-49e6-916a-48df87640942 233 | X-Runtime: 234 | - '0.023947' 235 | X-XSS-Protection: 236 | - 1; mode=block 237 | status: 238 | code: 200 239 | message: OK 240 | version: 1 241 | -------------------------------------------------------------------------------- /tests/test_user_auth_helpers.py: -------------------------------------------------------------------------------- 1 | """Test pypco utility methods.""" 2 | 3 | import unittest 4 | from unittest import mock 5 | from unittest.mock import MagicMock 6 | 7 | import requests 8 | from requests import HTTPError 9 | from requests import ConnectionError as RequestsConnectionError 10 | from requests import Timeout 11 | 12 | import pypco 13 | from pypco.exceptions import PCORequestException 14 | from pypco.exceptions import PCORequestTimeoutException 15 | from pypco.exceptions import PCOUnexpectedRequestException 16 | 17 | def mock_oauth_response(*args, **kwargs): #pylint: disable=E0211 18 | """Provide mocking for an oauth request 19 | 20 | Read more about this technique for mocking HTTP requests here: 21 | https://stackoverflow.com/questions/15753390/python-mock-requests-and-the-response/28507806#28507806 22 | """ 23 | 24 | class MockOAuthResponse: 25 | """Mocking class for OAuth response 26 | 27 | Args: 28 | json_data (dict): JSON data returned by the mocked API. 29 | status_code (int): The HTTP status code returned by the mocked API. 30 | """ 31 | 32 | def __init__(self, json_data, status_code): 33 | 34 | self.json_data = json_data 35 | self.status_code = status_code 36 | self.text = '{"test_key": "test_value"}' 37 | 38 | def json(self): 39 | """Return our mock JSON data""" 40 | 41 | return self.json_data 42 | 43 | def raise_for_status(self): 44 | """Raise HTTP exception if status code >= 400.""" 45 | 46 | if 400 <= self.status_code <= 500: 47 | raise HTTPError( 48 | u'%s Client Error: %s for url: %s' % \ 49 | ( 50 | self.status_code, 51 | 'Unauthorized', 52 | 'https://api.planningcenteronline.com/oauth/token' 53 | ), 54 | response=self 55 | ) 56 | 57 | # If we have this attrib, we're getting an access token 58 | if 'code' in kwargs.get('data'): 59 | if args[0] != "https://api.planningcenteronline.com/oauth/token": 60 | return MockOAuthResponse(None, 404) 61 | 62 | if kwargs.get('data')['code'] == 'good': 63 | return MockOAuthResponse( 64 | { 65 | 'access_token': '863300f2f093e8be25fdd7f40f218f4276ecf0b5814a558d899730fcee81e898', #pylint: disable=C0301 66 | 'token_type': 'bearer', 67 | 'expires_in': 7200, 68 | 'refresh_token': '63d68cb3d8a46eea1c842f5ba469b2940a88a657992f915206be1253a175b6ad', #pylint: disable=C0301 69 | 'scope': 'people', 70 | 'created_at': 1516054388 71 | }, 72 | 200 73 | ) 74 | 75 | if kwargs.get('data')['code'] == 'bad': 76 | return MockOAuthResponse( 77 | { 78 | 'error': 'invalid_client', 79 | 'error_description': 'Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method.' #pylint: disable=C0301 80 | }, 81 | 401 82 | ) 83 | 84 | if kwargs.get('data')['code'] == 'server_error': 85 | return MockOAuthResponse( 86 | {}, 87 | 500 88 | ) 89 | 90 | if kwargs.get('data')['code'] == 'timeout': 91 | raise Timeout() 92 | 93 | if kwargs.get('data')['code'] == 'connection': 94 | raise RequestsConnectionError() 95 | 96 | # If we have this attrib, we're attempting a refresh 97 | if 'refresh_token' in kwargs.get('data'): 98 | if kwargs.get('data')['refresh_token'] == 'refresh_good': 99 | return MockOAuthResponse( 100 | { 101 | 'access_token': '863300f2f093e8be25fdd7f40f218f4276ecf0b5814a558d899730fcee81e898', #pylint: disable=C0301 102 | 'token_type': 'bearer', 103 | 'expires_in': 7200, 104 | 'refresh_token': '63d68cb3d8a46eea1c842f5ba469b2940a88a657992f915206be1253a175b6ad', #pylint: disable=C0301 105 | 'created_at': 1516054388 106 | }, 107 | 200 108 | ) 109 | 110 | if kwargs.get('data')['refresh_token'] == 'refresh_bad': 111 | return MockOAuthResponse( 112 | { 113 | 'error': 'invalid_request', 114 | 'error_description': 'The refresh token is no longer valid' 115 | }, 116 | 401 117 | ) 118 | 119 | return MockOAuthResponse(None, 400) 120 | 121 | 122 | class TestGetBrowserRedirectUrl(unittest.TestCase): 123 | """Test pypco functionality for getting browser redirect URL.""" 124 | 125 | def test_valid_url_single_scope(self): 126 | """Test the get_browser_redirect_url function with one OAUTH scope.""" 127 | 128 | redirect_url = pypco.get_browser_redirect_url( 129 | 'abc123', 130 | 'https://nowhere.com?someurl', 131 | ['people'] 132 | ) 133 | 134 | self.assertEqual( 135 | "https://api.planningcenteronline.com/oauth/authorize?" 136 | "client_id=abc123&redirect_uri=https%3A%2F%2Fnowhere.com%3Fsomeurl&" 137 | "response_type=code&scope=people", 138 | redirect_url 139 | ) 140 | 141 | def test_valid_url_multiple_scopes(self): 142 | """Test the get_browser_redirect_url function with multiple OAUTH scopes.""" 143 | 144 | redirect_url = pypco.get_browser_redirect_url( 145 | 'abc123', 146 | 'https://nowhere.com?someurl', 147 | ['people', 'giving'] 148 | ) 149 | 150 | self.assertEqual( 151 | "https://api.planningcenteronline.com/oauth/authorize?" 152 | "client_id=abc123&redirect_uri=https%3A%2F%2Fnowhere.com%3Fsomeurl&" 153 | "response_type=code&scope=people+giving", 154 | redirect_url 155 | ) 156 | 157 | class TestDoOAUTHPost(unittest.TestCase): 158 | """Test the internal _do_oauth_post function.""" 159 | 160 | @mock.patch('requests.post', side_effect=mock_oauth_response) 161 | def test_oauth_general_errors(self, mock_post): #pylint: disable=unused-argument 162 | """Ensure error response with invalid status code""" 163 | 164 | # Request timeout 165 | with self.assertRaises(PCORequestTimeoutException): 166 | pypco.user_auth_helpers._do_oauth_post( #pylint: disable=protected-access 167 | 'https://api.planningcenteronline.com/oauth/token', 168 | client_id='id', 169 | client_secret='secret', 170 | code='timeout', 171 | redirect_uri='https://www.site.com', 172 | grant_type='authorization_code' 173 | ) 174 | 175 | # Request connection error 176 | with self.assertRaises(PCOUnexpectedRequestException): 177 | pypco.user_auth_helpers._do_oauth_post( #pylint: disable=protected-access 178 | 'https://api.planningcenteronline.com/oauth/token', 179 | client_id='id', 180 | client_secret='secret', 181 | code='connection', 182 | redirect_uri='https://www.site.com', 183 | grant_type='authorization_code' 184 | ) 185 | 186 | @mock.patch('requests.post', side_effect=mock_oauth_response) 187 | def test_http_errors(self, mock_post): #pylint: disable=unused-argument 188 | """Ensure error response with http errors.""" 189 | 190 | # Incorrect code 191 | with self.assertRaises(PCORequestException): 192 | pypco.user_auth_helpers._do_oauth_post( #pylint: disable=protected-access 193 | 'https://api.planningcenteronline.com/oauth/token', 194 | client_id='id', 195 | client_secret='secret', 196 | code='bad', 197 | redirect_uri='https://www.site.com', 198 | grant_type='authorization_code' 199 | ) 200 | 201 | # Server error 202 | with self.assertRaises(PCORequestException): 203 | pypco.user_auth_helpers._do_oauth_post( #pylint: disable=protected-access 204 | 'https://api.planningcenteronline.com/oauth/token', 205 | client_id='id', 206 | client_secret='secret', 207 | code='server_error', 208 | redirect_uri='https://www.site.com', 209 | grant_type='authorization_code' 210 | ) 211 | 212 | @mock.patch('requests.post', side_effect=mock_oauth_response) 213 | def test_successful_post(self, mock_post): #pylint: disable=unused-argument 214 | """Ensure successful post request execution with correct parameters.""" 215 | 216 | response = pypco.user_auth_helpers._do_oauth_post( #pylint: disable=protected-access 217 | 'https://api.planningcenteronline.com/oauth/token', 218 | client_id='id', 219 | client_secret='secret', 220 | code='good', 221 | redirect_uri='https://www.site.com', 222 | grant_type='authorization_code' 223 | ) 224 | 225 | self.assertEqual(200, response.status_code) 226 | 227 | mock_post.assert_called_once_with( 228 | 'https://api.planningcenteronline.com/oauth/token', 229 | data={ 230 | 'client_id': 'id', 231 | 'client_secret': 'secret', 232 | 'code': 'good', 233 | 'redirect_uri': 'https://www.site.com', 234 | 'grant_type': 'authorization_code' 235 | }, 236 | headers={ 237 | 'User-Agent': 'pypco' 238 | }, 239 | timeout=30 240 | ) 241 | 242 | class TestGetOAuthAccessToken(unittest.TestCase): 243 | """Test pypco functionality for getting oauth access tokens""" 244 | 245 | @mock.patch('requests.post', side_effect=mock_oauth_response) 246 | def test_valid_creds(self, mock_post): #pylint: disable=W0613 247 | """Ensure we can authenticate successfully with valid creds.""" 248 | 249 | self.assertIn( 250 | 'access_token', 251 | list( 252 | pypco.get_oauth_access_token( 253 | 'id', 254 | 'secret', 255 | 'good', 256 | 'https://www.site.com/').keys() 257 | ) 258 | ) 259 | 260 | mock_post.assert_called_once_with( 261 | 'https://api.planningcenteronline.com/oauth/token', 262 | data={ 263 | 'client_id': 'id', 264 | 'client_secret': 'secret', 265 | 'code': 'good', 266 | 'redirect_uri': 'https://www.site.com/', 267 | 'grant_type': 'authorization_code' 268 | }, 269 | headers={ 270 | 'User-Agent': 'pypco' 271 | }, 272 | timeout=30 273 | ) 274 | 275 | @mock.patch('requests.post', side_effect=mock_oauth_response) 276 | def test_invalid_code(self, mock_post): #pylint: disable=W0613 277 | """Ensure error response with invalid status code""" 278 | 279 | with self.assertRaises(PCORequestException) as err_cm: 280 | pypco.get_oauth_access_token( 281 | 'id', 282 | 'secret', 283 | 'bad', 284 | 'https://www.site.com/' 285 | ) 286 | 287 | self.assertEqual(401, err_cm.exception.status_code) 288 | self.assertEqual('{"test_key": "test_value"}', err_cm.exception.response_body) 289 | 290 | mock_post.assert_called_once_with( 291 | 'https://api.planningcenteronline.com/oauth/token', 292 | data={ 293 | 'client_id': 'id', 294 | 'client_secret': 'secret', 295 | 'code': 'bad', 296 | 'redirect_uri': 'https://www.site.com/', 297 | 'grant_type': 'authorization_code' 298 | }, 299 | headers={ 300 | 'User-Agent': 'pypco' 301 | }, 302 | timeout=30 303 | ) 304 | 305 | class TestGetOAuthRefreshToken(unittest.TestCase): 306 | """Test pypco functionality for getting oauth refresh tokens""" 307 | 308 | @mock.patch('requests.post', side_effect=mock_oauth_response) 309 | def test_valid_refresh_token(self, mock_post): 310 | """Verify successful refresh with valid token.""" 311 | 312 | self.assertIn( 313 | 'access_token', 314 | list( 315 | pypco.get_oauth_refresh_token( 316 | 'id', 317 | 'secret', 318 | 'refresh_good' 319 | ).keys() 320 | ) 321 | ) 322 | 323 | mock_post.assert_called_once_with( 324 | 'https://api.planningcenteronline.com/oauth/token', 325 | data={ 326 | 'client_id': 'id', 327 | 'client_secret': 'secret', 328 | 'refresh_token': 'refresh_good', 329 | 'grant_type': 'refresh_token' 330 | }, 331 | headers={ 332 | 'User-Agent': 'pypco' 333 | }, 334 | timeout=30 335 | ) 336 | 337 | @mock.patch('requests.post', side_effect=mock_oauth_response) 338 | def test_invalid_refresh_token(self, mock_post): 339 | """Verify refresh fails with invalid token.""" 340 | 341 | with self.assertRaises(PCORequestException) as err_cm: 342 | pypco.get_oauth_refresh_token( 343 | 'id', 344 | 'secret', 345 | 'refresh_bad' 346 | ).keys() 347 | 348 | self.assertEqual(401, err_cm.exception.status_code) 349 | self.assertEqual('{"test_key": "test_value"}', err_cm.exception.response_body) 350 | 351 | mock_post.assert_called_once_with( 352 | 'https://api.planningcenteronline.com/oauth/token', 353 | data={ 354 | 'client_id': 'id', 355 | 'client_secret': 'secret', 356 | 'refresh_token': 'refresh_bad', 357 | 'grant_type': 'refresh_token' 358 | }, 359 | headers={ 360 | 'User-Agent': 'pypco' 361 | }, 362 | timeout=30 363 | ) 364 | 365 | 366 | class TestGetCcOrgToken(unittest.TestCase): 367 | """Test pypco functionality for getting church center organization tokens""" 368 | 369 | # @mock.patch('requests.post', side_effect=mock_org_token_response) 370 | def test_valid_org_token(self): 371 | """Verify successful refresh with valid token.""" 372 | 373 | """Existing valid cc subdomain""" 374 | self.assertIs( 375 | str, 376 | type(pypco.get_cc_org_token( 377 | 'carlsbad', 378 | )), 379 | "No String Returned" 380 | ) 381 | 382 | def test_invalid_org_token(self): 383 | with self.assertRaises(PCOUnexpectedRequestException): 384 | pypco.get_cc_org_token( 385 | 'carlsbadtypo', 386 | ) 387 | 388 | @mock.patch('requests.post', side_effect=Timeout) 389 | def test_get_cc_org_token_timeout(self, mock_get): # pylint: disable=unused-argument 390 | """Ensure error response with timeoute""" 391 | 392 | # Request timeout 393 | with self.assertRaises(PCORequestTimeoutException): 394 | pypco.user_auth_helpers.get_cc_org_token( 395 | 'yourcbcfamily', 396 | ) 397 | 398 | @mock.patch('requests.post', side_effect=Exception) 399 | def test_get_cc_org_token_general_exception(self, mock_get): # pylint: disable=unused-argument 400 | """Ensure error response with invalid status code""" 401 | 402 | # Request timeout 403 | with self.assertRaises(PCOUnexpectedRequestException): 404 | pypco.user_auth_helpers.get_cc_org_token( 405 | 'yourcbcfamily', 406 | ) 407 | 408 | @mock.patch('requests.post') 409 | def test_get_cc_org_token_raise_for_status(self, mock_requests): # pylint: disable=unused-argument 410 | """Ensure error response with invalid status code""" 411 | exception = HTTPError(mock.Mock(status=404), "not found") 412 | mock_requests(mock.ANY).raise_for_status.side_effect = exception 413 | with self.assertRaises(PCORequestException): 414 | pypco.user_auth_helpers.get_cc_org_token( 415 | 'yourcbcfamily', 416 | ) 417 | 418 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code. 6 | extension-pkg-whitelist= 7 | 8 | # Add files or directories to the blacklist. They should be base names, not 9 | # paths. 10 | ignore=CVS 11 | 12 | # Add files or directories matching the regex patterns to the blacklist. The 13 | # regex matches against base names, not paths. 14 | ignore-patterns= 15 | 16 | # Python code to execute, usually for sys.path manipulation such as 17 | # pygtk.require(). 18 | #init-hook= 19 | 20 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 21 | # number of processors available to use. 22 | jobs=1 23 | 24 | # Control the amount of potential inferred values when inferring a single 25 | # object. This can help the performance when dealing with large functions or 26 | # complex, nested conditions. 27 | limit-inference-results=100 28 | 29 | # List of plugins (as comma separated values of python modules names) to load, 30 | # usually to register additional checkers. 31 | load-plugins= 32 | 33 | # Pickle collected data for later comparisons. 34 | persistent=yes 35 | 36 | # Specify a configuration file. 37 | #rcfile= 38 | 39 | # When enabled, pylint would attempt to guess common misconfiguration and emit 40 | # user-friendly hints instead of false-positive error messages. 41 | suggestion-mode=yes 42 | 43 | # Allow loading of arbitrary C extensions. Extensions are imported into the 44 | # active Python interpreter and may run arbitrary code. 45 | unsafe-load-any-extension=no 46 | 47 | 48 | [MESSAGES CONTROL] 49 | 50 | # Only show warnings with the listed confidence levels. Leave empty to show 51 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. 52 | confidence= 53 | 54 | # Disable the message, report, category or checker with the given id(s). You 55 | # can either give multiple identifiers separated by comma (,) or put this 56 | # option multiple times (only on the command line, not in the configuration 57 | # file where it should appear only once). You can also use "--disable=all" to 58 | # disable everything first and then reenable specific checks. For example, if 59 | # you want to run only the similarities checker, you can use "--disable=all 60 | # --enable=similarities". If you want to run only the classes checker, but have 61 | # no Warning level messages displayed, use "--disable=all --enable=classes 62 | # --disable=W". 63 | disable=print-statement, 64 | parameter-unpacking, 65 | unpacking-in-except, 66 | old-raise-syntax, 67 | backtick, 68 | long-suffix, 69 | old-ne-operator, 70 | old-octal-literal, 71 | import-star-module-level, 72 | non-ascii-bytes-literal, 73 | raw-checker-failed, 74 | bad-inline-option, 75 | locally-disabled, 76 | file-ignored, 77 | suppressed-message, 78 | useless-suppression, 79 | deprecated-pragma, 80 | use-symbolic-message-instead, 81 | apply-builtin, 82 | basestring-builtin, 83 | buffer-builtin, 84 | cmp-builtin, 85 | coerce-builtin, 86 | execfile-builtin, 87 | file-builtin, 88 | long-builtin, 89 | raw_input-builtin, 90 | reduce-builtin, 91 | standarderror-builtin, 92 | unicode-builtin, 93 | xrange-builtin, 94 | coerce-method, 95 | delslice-method, 96 | getslice-method, 97 | setslice-method, 98 | no-absolute-import, 99 | old-division, 100 | dict-iter-method, 101 | dict-view-method, 102 | next-method-called, 103 | metaclass-assignment, 104 | indexing-exception, 105 | raising-string, 106 | reload-builtin, 107 | oct-method, 108 | hex-method, 109 | nonzero-method, 110 | cmp-method, 111 | input-builtin, 112 | round-builtin, 113 | intern-builtin, 114 | unichr-builtin, 115 | map-builtin-not-iterating, 116 | zip-builtin-not-iterating, 117 | range-builtin-not-iterating, 118 | filter-builtin-not-iterating, 119 | using-cmp-argument, 120 | eq-without-hash, 121 | div-method, 122 | idiv-method, 123 | rdiv-method, 124 | exception-message-attribute, 125 | invalid-str-codec, 126 | sys-max-int, 127 | bad-python3-import, 128 | deprecated-string-function, 129 | deprecated-str-translate-call, 130 | deprecated-itertools-function, 131 | deprecated-types-field, 132 | next-method-defined, 133 | dict-items-not-iterating, 134 | dict-keys-not-iterating, 135 | dict-values-not-iterating, 136 | deprecated-operator-function, 137 | deprecated-urllib-function, 138 | xreadlines-attribute, 139 | deprecated-sys-function, 140 | exception-escape, 141 | comprehension-escape, 142 | duplicate-code 143 | 144 | # Enable the message, report, category or checker with the given id(s). You can 145 | # either give multiple identifier separated by comma (,) or put this option 146 | # multiple time (only on the command line, not in the configuration file where 147 | # it should appear only once). See also the "--disable" option for examples. 148 | enable=c-extension-no-member 149 | 150 | 151 | [REPORTS] 152 | 153 | # Python expression which should return a note less than 10 (10 is the highest 154 | # note). You have access to the variables errors warning, statement which 155 | # respectively contain the number of errors / warnings messages and the total 156 | # number of statements analyzed. This is used by the global evaluation report 157 | # (RP0004). 158 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 159 | 160 | # Template used to display messages. This is a python new-style format string 161 | # used to format the message information. See doc for all details. 162 | #msg-template= 163 | 164 | # Set the output format. Available formats are text, parseable, colorized, json 165 | # and msvs (visual studio). You can also give a reporter class, e.g. 166 | # mypackage.mymodule.MyReporterClass. 167 | output-format=text 168 | 169 | # Tells whether to display a full report or only the messages. 170 | reports=no 171 | 172 | # Activate the evaluation score. 173 | score=yes 174 | 175 | 176 | [REFACTORING] 177 | 178 | # Maximum number of nested blocks for function / method body 179 | max-nested-blocks=5 180 | 181 | # Complete name of functions that never returns. When checking for 182 | # inconsistent-return-statements if a never returning function is called then 183 | # it will be considered as an explicit return statement and no message will be 184 | # printed. 185 | never-returning-functions=sys.exit 186 | 187 | 188 | [LOGGING] 189 | 190 | # Format style used to check logging format string. `old` means using % 191 | # formatting, while `new` is for `{}` formatting. 192 | logging-format-style=old 193 | 194 | # Logging modules to check that the string format arguments are in logging 195 | # function parameter format. 196 | logging-modules=logging 197 | 198 | 199 | [SPELLING] 200 | 201 | # Limits count of emitted suggestions for spelling mistakes. 202 | max-spelling-suggestions=4 203 | 204 | # Spelling dictionary name. Available dictionaries: none. To make it working 205 | # install python-enchant package.. 206 | spelling-dict= 207 | 208 | # List of comma separated words that should not be checked. 209 | spelling-ignore-words= 210 | 211 | # A path to a file that contains private dictionary; one word per line. 212 | spelling-private-dict-file= 213 | 214 | # Tells whether to store unknown words to indicated private dictionary in 215 | # --spelling-private-dict-file option instead of raising a message. 216 | spelling-store-unknown-words=no 217 | 218 | 219 | [MISCELLANEOUS] 220 | 221 | # List of note tags to take in consideration, separated by a comma. 222 | notes=FIXME, 223 | XXX, 224 | TODO 225 | 226 | 227 | [TYPECHECK] 228 | 229 | # List of decorators that produce context managers, such as 230 | # contextlib.contextmanager. Add to this list to register other decorators that 231 | # produce valid context managers. 232 | contextmanager-decorators=contextlib.contextmanager 233 | 234 | # List of members which are set dynamically and missed by pylint inference 235 | # system, and so shouldn't trigger E1101 when accessed. Python regular 236 | # expressions are accepted. 237 | generated-members= 238 | 239 | # Tells whether missing members accessed in mixin class should be ignored. A 240 | # mixin class is detected if its name ends with "mixin" (case insensitive). 241 | ignore-mixin-members=yes 242 | 243 | # Tells whether to warn about missing members when the owner of the attribute 244 | # is inferred to be None. 245 | ignore-none=yes 246 | 247 | # This flag controls whether pylint should warn about no-member and similar 248 | # checks whenever an opaque object is returned when inferring. The inference 249 | # can return multiple potential results while evaluating a Python object, but 250 | # some branches might not be evaluated, which results in partial inference. In 251 | # that case, it might be useful to still emit no-member and other checks for 252 | # the rest of the inferred objects. 253 | ignore-on-opaque-inference=yes 254 | 255 | # List of class names for which member attributes should not be checked (useful 256 | # for classes with dynamically set attributes). This supports the use of 257 | # qualified names. 258 | ignored-classes=optparse.Values,thread._local,_thread._local 259 | 260 | # List of module names for which member attributes should not be checked 261 | # (useful for modules/projects where namespaces are manipulated during runtime 262 | # and thus existing member attributes cannot be deduced by static analysis. It 263 | # supports qualified module names, as well as Unix pattern matching. 264 | ignored-modules= 265 | 266 | # Show a hint with possible names when a member name was not found. The aspect 267 | # of finding the hint is based on edit distance. 268 | missing-member-hint=yes 269 | 270 | # The minimum edit distance a name should have in order to be considered a 271 | # similar match for a missing member name. 272 | missing-member-hint-distance=1 273 | 274 | # The total number of similar names that should be taken in consideration when 275 | # showing a hint for a missing member. 276 | missing-member-max-choices=1 277 | 278 | 279 | [VARIABLES] 280 | 281 | # List of additional names supposed to be defined in builtins. Remember that 282 | # you should avoid defining new builtins when possible. 283 | additional-builtins= 284 | 285 | # Tells whether unused global variables should be treated as a violation. 286 | allow-global-unused-variables=yes 287 | 288 | # List of strings which can identify a callback function by name. A callback 289 | # name must start or end with one of those strings. 290 | callbacks=cb_, 291 | _cb 292 | 293 | # A regular expression matching the name of dummy variables (i.e. expected to 294 | # not be used). 295 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 296 | 297 | # Argument names that match this expression will be ignored. Default to name 298 | # with leading underscore. 299 | ignored-argument-names=_.*|^ignored_|^unused_ 300 | 301 | # Tells whether we should check for unused import in __init__ files. 302 | init-import=no 303 | 304 | # List of qualified module names which can have objects that can redefine 305 | # builtins. 306 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 307 | 308 | 309 | [FORMAT] 310 | 311 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 312 | expected-line-ending-format= 313 | 314 | # Regexp for a line that is allowed to be longer than the limit. 315 | ignore-long-lines=^\s*(# )??$ 316 | 317 | # Number of spaces of indent required inside a hanging or continued line. 318 | indent-after-paren=4 319 | 320 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 321 | # tab). 322 | indent-string=' ' 323 | 324 | # Maximum number of characters on a single line. 325 | max-line-length=120 326 | 327 | # Maximum number of lines in a module. 328 | max-module-lines=1000 329 | 330 | # List of optional constructs for which whitespace checking is disabled. `dict- 331 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 332 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 333 | # `empty-line` allows space-only lines. 334 | no-space-check=trailing-comma, 335 | dict-separator 336 | 337 | # Allow the body of a class to be on the same line as the declaration if body 338 | # contains single statement. 339 | single-line-class-stmt=no 340 | 341 | # Allow the body of an if to be on the same line as the test if there is no 342 | # else. 343 | single-line-if-stmt=no 344 | 345 | 346 | [SIMILARITIES] 347 | 348 | # Ignore comments when computing similarities. 349 | ignore-comments=yes 350 | 351 | # Ignore docstrings when computing similarities. 352 | ignore-docstrings=yes 353 | 354 | # Ignore imports when computing similarities. 355 | ignore-imports=no 356 | 357 | # Minimum lines number of a similarity. 358 | min-similarity-lines=4 359 | 360 | 361 | [BASIC] 362 | 363 | # Naming style matching correct argument names. 364 | argument-naming-style=snake_case 365 | 366 | # Regular expression matching correct argument names. Overrides argument- 367 | # naming-style. 368 | #argument-rgx= 369 | 370 | # Naming style matching correct attribute names. 371 | attr-naming-style=snake_case 372 | 373 | # Regular expression matching correct attribute names. Overrides attr-naming- 374 | # style. 375 | #attr-rgx= 376 | 377 | # Bad variable names which should always be refused, separated by a comma. 378 | bad-names=foo, 379 | bar, 380 | baz, 381 | toto, 382 | tutu, 383 | tata 384 | 385 | # Naming style matching correct class attribute names. 386 | class-attribute-naming-style=any 387 | 388 | # Regular expression matching correct class attribute names. Overrides class- 389 | # attribute-naming-style. 390 | #class-attribute-rgx= 391 | 392 | # Naming style matching correct class names. 393 | class-naming-style=PascalCase 394 | 395 | # Regular expression matching correct class names. Overrides class-naming- 396 | # style. 397 | #class-rgx= 398 | 399 | # Naming style matching correct constant names. 400 | const-naming-style=UPPER_CASE 401 | 402 | # Regular expression matching correct constant names. Overrides const-naming- 403 | # style. 404 | #const-rgx= 405 | 406 | # Minimum line length for functions/classes that require docstrings, shorter 407 | # ones are exempt. 408 | docstring-min-length=-1 409 | 410 | # Naming style matching correct function names. 411 | function-naming-style=snake_case 412 | 413 | # Regular expression matching correct function names. Overrides function- 414 | # naming-style. 415 | #function-rgx= 416 | 417 | # Good variable names which should always be accepted, separated by a comma. 418 | good-names=i, 419 | j, 420 | k, 421 | ex, 422 | Run, 423 | _ 424 | 425 | # Include a hint for the correct naming format with invalid-name. 426 | include-naming-hint=no 427 | 428 | # Naming style matching correct inline iteration names. 429 | inlinevar-naming-style=any 430 | 431 | # Regular expression matching correct inline iteration names. Overrides 432 | # inlinevar-naming-style. 433 | #inlinevar-rgx= 434 | 435 | # Naming style matching correct method names. 436 | method-naming-style=snake_case 437 | 438 | # Regular expression matching correct method names. Overrides method-naming- 439 | # style. 440 | #method-rgx= 441 | 442 | # Naming style matching correct module names. 443 | module-naming-style=snake_case 444 | 445 | # Regular expression matching correct module names. Overrides module-naming- 446 | # style. 447 | #module-rgx= 448 | 449 | # Colon-delimited sets of names that determine each other's naming style when 450 | # the name regexes allow several styles. 451 | name-group= 452 | 453 | # Regular expression which should only match function or class names that do 454 | # not require a docstring. 455 | no-docstring-rgx=^_ 456 | 457 | # List of decorators that produce properties, such as abc.abstractproperty. Add 458 | # to this list to register other decorators that produce valid properties. 459 | # These decorators are taken in consideration only for invalid-name. 460 | property-classes=abc.abstractproperty 461 | 462 | # Naming style matching correct variable names. 463 | variable-naming-style=snake_case 464 | 465 | # Regular expression matching correct variable names. Overrides variable- 466 | # naming-style. 467 | #variable-rgx= 468 | 469 | 470 | [STRING] 471 | 472 | # This flag controls whether the implicit-str-concat-in-sequence should 473 | # generate a warning on implicit string concatenation in sequences defined over 474 | # several lines. 475 | check-str-concat-over-line-jumps=no 476 | 477 | 478 | [IMPORTS] 479 | 480 | # Allow wildcard imports from modules that define __all__. 481 | allow-wildcard-with-all=no 482 | 483 | # Analyse import fallback blocks. This can be used to support both Python 2 and 484 | # 3 compatible code, which means that the block might have code that exists 485 | # only in one or another interpreter, leading to false positives when analysed. 486 | analyse-fallback-blocks=no 487 | 488 | # Deprecated modules which should not be used, separated by a comma. 489 | deprecated-modules=optparse,tkinter.tix 490 | 491 | # Create a graph of external dependencies in the given file (report RP0402 must 492 | # not be disabled). 493 | ext-import-graph= 494 | 495 | # Create a graph of every (i.e. internal and external) dependencies in the 496 | # given file (report RP0402 must not be disabled). 497 | import-graph= 498 | 499 | # Create a graph of internal dependencies in the given file (report RP0402 must 500 | # not be disabled). 501 | int-import-graph= 502 | 503 | # Force import order to recognize a module as part of the standard 504 | # compatibility libraries. 505 | known-standard-library= 506 | 507 | # Force import order to recognize a module as part of a third party library. 508 | known-third-party=enchant 509 | 510 | 511 | [CLASSES] 512 | 513 | # List of method names used to declare (i.e. assign) instance attributes. 514 | defining-attr-methods=__init__, 515 | __new__, 516 | setUp 517 | 518 | # List of member names, which should be excluded from the protected access 519 | # warning. 520 | exclude-protected=_asdict, 521 | _fields, 522 | _replace, 523 | _source, 524 | _make 525 | 526 | # List of valid names for the first argument in a class method. 527 | valid-classmethod-first-arg=cls 528 | 529 | # List of valid names for the first argument in a metaclass class method. 530 | valid-metaclass-classmethod-first-arg=cls 531 | 532 | 533 | [DESIGN] 534 | 535 | # Maximum number of arguments for function / method. 536 | max-args=5 537 | 538 | # Maximum number of attributes for a class (see R0902). 539 | max-attributes=7 540 | 541 | # Maximum number of boolean expressions in an if statement. 542 | max-bool-expr=5 543 | 544 | # Maximum number of branch for function / method body. 545 | max-branches=12 546 | 547 | # Maximum number of locals for function / method body. 548 | max-locals=15 549 | 550 | # Maximum number of parents for a class (see R0901). 551 | max-parents=7 552 | 553 | # Maximum number of public methods for a class (see R0904). 554 | max-public-methods=20 555 | 556 | # Maximum number of return / yield for function / method body. 557 | max-returns=6 558 | 559 | # Maximum number of statements in function / method body. 560 | max-statements=50 561 | 562 | # Minimum number of public methods for a class (see R0903). 563 | min-public-methods=2 564 | 565 | 566 | [EXCEPTIONS] 567 | 568 | # Exceptions that will emit a warning when being caught. Defaults to 569 | # "BaseException, Exception". 570 | overgeneral-exceptions=BaseException, 571 | Exception 572 | -------------------------------------------------------------------------------- /pypco/pco.py: -------------------------------------------------------------------------------- 1 | """The primary module for pypco containing main wrapper logic.""" 2 | 3 | import time 4 | import logging 5 | import re 6 | 7 | from typing import Any, Iterator, Optional 8 | import requests 9 | 10 | from .auth_config import PCOAuthConfig 11 | from .exceptions import PCORequestTimeoutException, \ 12 | PCORequestException, PCOUnexpectedRequestException 13 | 14 | 15 | class PCO: # pylint: disable=too-many-instance-attributes 16 | """The entry point to the PCO API. 17 | 18 | Note: 19 | You must specify either an application ID and a secret or an oauth token. 20 | If you specify an invalid combination of these arguments, an exception will be 21 | raised when you attempt to make API calls. 22 | 23 | Args: 24 | application_id (str): The application_id; secret must also be specified. 25 | secret (str): The secret for your app; application_id must also be specified. 26 | token (str): OAUTH token for your app; application_id and secret must not be specified. 27 | api_base (str): The base URL against which REST calls will be made. 28 | Default: https://api.planningcenteronline.com 29 | timeout (int): How long to wait (seconds) for requests to timeout. Default 60. 30 | upload_url (str): The URL to which files will be uploaded. 31 | Default: https://upload.planningcenteronline.com/v2/files 32 | upload_timeout (int): How long to wait (seconds) for uploads to timeout. Default 300. 33 | timeout_retries (int): How many times to retry requests that have timed out. Default 3. 34 | """ 35 | 36 | def __init__( # pylint: disable=too-many-arguments 37 | self, 38 | application_id: Optional[str] = None, # pylint: disable=unsubscriptable-object 39 | secret: Optional[str] = None, # pylint: disable=unsubscriptable-object 40 | token: Optional[str] = None, # pylint: disable=unsubscriptable-object 41 | cc_name: Optional[str] = None, # pylint: disable=unsubscriptable-object 42 | api_base: str = 'https://api.planningcenteronline.com', 43 | timeout: int = 60, 44 | upload_url: str = 'https://upload.planningcenteronline.com/v2/files', 45 | upload_timeout: int = 300, 46 | timeout_retries: int = 3, 47 | ): 48 | 49 | self._log = logging.getLogger(__name__) 50 | 51 | self._auth_config = PCOAuthConfig(application_id, secret, token, cc_name) 52 | self._auth_header = self._auth_config.auth_header 53 | 54 | self.api_base = api_base 55 | self.timeout = timeout 56 | 57 | self.upload_url = upload_url 58 | self.upload_timeout = upload_timeout 59 | 60 | self.timeout_retries = timeout_retries 61 | 62 | self.session = requests.Session() 63 | 64 | self._log.debug("Pypco has been initialized!") 65 | 66 | def _do_request( 67 | self, 68 | method: str, 69 | url: str, 70 | payload: Optional[Any] = None, # pylint: disable=unsubscriptable-object 71 | upload: Optional[str] = None, # pylint: disable=unsubscriptable-object 72 | **params 73 | ) -> requests.Response: 74 | """Builds, executes, and performs a single request against the PCO API. 75 | 76 | Executed request could be one of the standard HTTP verbs or a file upload. 77 | 78 | Args: 79 | method (str): The HTTP method to use for this request. 80 | url (str): The URL against which this request will be executed. 81 | payload (obj): A json-serializable Python object to be sent as the post/put payload. 82 | upload(str): The path to a file to upload. 83 | params (obj): A dictionary or list of tuples or bytes to send in the query string. 84 | 85 | Returns: 86 | requests.Response: The response to this request. 87 | """ 88 | 89 | # Standard header 90 | headers = { 91 | 'User-Agent': 'pypco', 92 | 'Authorization': self._auth_header, 93 | } 94 | 95 | # Standard params 96 | request_params = { 97 | 'headers': headers, 98 | 'params': params, 99 | 'json': payload, 100 | 'timeout': self.upload_timeout if upload else self.timeout 101 | } 102 | 103 | # Add files param if upload specified 104 | if upload: 105 | upload_fh = open(upload, 'rb') 106 | request_params['files'] = {'file': upload_fh} 107 | 108 | self._log.debug( 109 | "Executing %s request to '%s' with args %s", 110 | method, 111 | url, 112 | {param: value for (param, value) in request_params.items() if param != 'headers'} 113 | ) 114 | 115 | # The moment we've been waiting for...execute the request 116 | try: 117 | response = self.session.request( 118 | method, 119 | url, 120 | **request_params # type: ignore[arg-type] 121 | ) 122 | finally: 123 | if upload: 124 | upload_fh.close() 125 | 126 | return response 127 | 128 | def _do_timeout_managed_request( 129 | self, 130 | method: str, 131 | url: str, 132 | payload: Optional[Any] = None, # pylint: disable=unsubscriptable-object 133 | upload: Optional[str] = None, # pylint: disable=unsubscriptable-object 134 | **params 135 | ) -> requests.Response: 136 | """Performs a single request against the PCO API with automatic retried in case of timeout. 137 | 138 | Executed request could be one of the standard HTTP verbs or a file upload. 139 | 140 | Args: 141 | method (str): The HTTP method to use for this request. 142 | url (str): The URL against which this request will be executed. 143 | payload (obj): A json-serializable Python object to be sent as the post/put payload. 144 | upload(str): The path to a file to upload. 145 | params (obj): A dictionary or list of tuples or bytes to send in the query string. 146 | 147 | Raises: 148 | PCORequestTimeoutException: The request to PCO timed out the maximum number of times. 149 | 150 | Returns: 151 | requests.Response: The response to this request. 152 | """ 153 | 154 | timeout_count = 0 155 | 156 | while True: 157 | try: 158 | return self._do_request(method, url, payload, upload, **params) 159 | 160 | except requests.exceptions.Timeout as exc: 161 | timeout_count += 1 162 | 163 | self._log.debug("The request to \"%s\" timed out after %d tries.", 164 | url, timeout_count) 165 | 166 | if timeout_count == self.timeout_retries: 167 | self._log.debug("Maximum retries (%d) hit. Will raise exception.", 168 | self.timeout_retries) 169 | 170 | raise PCORequestTimeoutException( 171 | f"The request to \"{url}\" timed out after {timeout_count} tries.") from exc 172 | 173 | continue 174 | 175 | def _do_ratelimit_managed_request( 176 | self, 177 | method: str, 178 | url: str, 179 | payload: Optional[Any] = None, # pylint: disable=unsubscriptable-object 180 | upload: Optional[str] = None, # pylint: disable=unsubscriptable-object 181 | **params 182 | ) -> requests.Response: 183 | """Performs a single request against the PCO API with automatic rate limit handling. 184 | 185 | Executed request could be one of the standard HTTP verbs or a file upload. 186 | 187 | Args: 188 | method (str): The HTTP method to use for this request. 189 | url (str): The URL against which this request will be executed. 190 | payload (obj): A json-serializable Python object to be sent as the post/put payload. 191 | upload(str): The path to a file to upload. 192 | params (obj): A dictionary or list of tuples or bytes to send in the query string. 193 | 194 | Raises: 195 | PCORequestTimeoutException: The request to PCO timed out the maximum number of times. 196 | 197 | Returns: 198 | requests.Response: The response to this request. 199 | """ 200 | 201 | while True: 202 | 203 | response = self._do_timeout_managed_request(method, url, payload, upload, **params) 204 | 205 | if response.status_code == 429: 206 | self._log.debug("Received rate limit response. Will try again after %d sec(s).", 207 | int(response.headers['Retry-After'])) 208 | 209 | time.sleep(int(response.headers['Retry-After'])) 210 | continue 211 | 212 | return response 213 | 214 | def _do_url_managed_request( 215 | self, 216 | method: str, 217 | url: str, 218 | payload: Optional[Any] = None, # pylint: disable=unsubscriptable-object 219 | upload: Optional[str] = None, # pylint: disable=unsubscriptable-object 220 | **params 221 | ) -> requests.Response: 222 | """Performs a single request against the PCO API, automatically cleaning up the URL. 223 | 224 | Executed request could be one of the standard HTTP verbs or a file upload. 225 | 226 | Args: 227 | method (str): The HTTP method to use for this request. 228 | url (str): The URL against which this request will be executed. 229 | payload (obj): A json-serializable Python object to be sent as the post/put payload. 230 | upload(str): The path to a file to upload. 231 | params (obj): A dictionary or list of tuples or bytes to send in the query string. 232 | 233 | Raises: 234 | PCORequestTimeoutException: The request to PCO timed out the maximum number of times. 235 | 236 | Returns: 237 | requests.Response: The response to this request. 238 | """ 239 | 240 | self._log.debug("URL cleaning input: \"%s\"", url) 241 | 242 | if not upload: 243 | url = url if url.startswith(self.api_base) else f'{self.api_base}{url}' 244 | url = re.subn(r'(? requests.Response: 258 | """A generic entry point for making a managed request against PCO. 259 | 260 | This function will return a Requests response object, allowing access to 261 | all request data and metadata. Executed request could be one of the standard 262 | HTTP verbs or a file upload. If you're just looking for your data (json), use 263 | the request_json() function or get(), post(), etc. 264 | 265 | Args: 266 | method (str): The HTTP method to use for this request. 267 | url (str): The URL against which this request will be executed. 268 | payload (obj): A json-serializable Python object to be sent as the post/put payload. 269 | upload(str): The path to a file to upload. 270 | params (obj): A dictionary or list of tuples or bytes to send in the query string. 271 | 272 | Raises: 273 | PCORequestTimeoutException: The request to PCO timed out the maximum number of times. 274 | PCOUnexpectedRequestException: An unexpected error occurred when making your request. 275 | PCORequestException: The response from the PCO API indicated an error with your request. 276 | 277 | Returns: 278 | requests.Response: The response to this request. 279 | """ 280 | 281 | try: 282 | response = self._do_url_managed_request(method, url, payload, upload, **params) 283 | except Exception as err: 284 | self._log.debug("Request resulted in unexpected error: \"%s\"", str(err)) 285 | raise PCOUnexpectedRequestException(str(err)) from err 286 | 287 | try: 288 | response.raise_for_status() 289 | except requests.HTTPError as err: 290 | self._log.debug("Request resulted in API error: \"%s\"", str(err)) 291 | raise PCORequestException( 292 | response.status_code, 293 | str(err), 294 | response_body=response.text 295 | ) from err 296 | 297 | return response 298 | 299 | def request_json( 300 | self, 301 | method: str, 302 | url: str, 303 | payload: Optional[Any] = None, # pylint: disable=unsubscriptable-object 304 | upload: Optional[str] = None, # pylint: disable=unsubscriptable-object 305 | **params: str 306 | ) -> Optional[dict]: # pylint: disable=unsubscriptable-object 307 | """A generic entry point for making a managed request against PCO. 308 | 309 | This function will return the payload from the PCO response (a dict). 310 | 311 | Args: 312 | method (str): The HTTP method to use for this request. 313 | url (str): The URL against which this request will be executed. 314 | payload (obj): A json-serializable Python object to be sent as the post/put payload. 315 | upload(str): The path to a file to upload. 316 | params (obj): A dictionary or list of tuples or bytes to send in the query string. 317 | 318 | Raises: 319 | PCORequestTimeoutException: The request to PCO timed out the maximum number of times. 320 | PCOUnexpectedRequestException: An unexpected error occurred when making your request. 321 | PCORequestException: The response from the PCO API indicated an error with your request. 322 | 323 | Returns: 324 | dict: The payload from the response to this request. 325 | """ 326 | 327 | response = self.request_response(method, url, payload, upload, **params) 328 | if response.status_code == 204: 329 | return_value = None 330 | else: 331 | return_value = response.json() 332 | 333 | return return_value 334 | 335 | def get(self, url: str, **params) -> Optional[dict]: # pylint: disable=unsubscriptable-object 336 | """Perform a GET request against the PCO API. 337 | 338 | Performs a fully managed GET request (handles ratelimiting, timeouts, etc.). 339 | 340 | Args: 341 | url (str): The URL against which to perform the request. Can include 342 | what's been set as api_base, which will be ignored if this value is also 343 | present in your URL. 344 | params: Any named arguments will be passed as query parameters. 345 | 346 | Raises: 347 | PCORequestTimeoutException: The request to PCO timed out the maximum number of times. 348 | PCOUnexpectedRequestException: An unexpected error occurred when making your request. 349 | PCORequestException: The response from the PCO API indicated an error with your request. 350 | 351 | Returns: 352 | dict: The payload returned by the API for this request. 353 | """ 354 | 355 | return self.request_json('GET', url, **params) 356 | 357 | def post( 358 | self, 359 | url: str, 360 | payload: Optional[dict] = None, # pylint: disable=unsubscriptable-object 361 | **params: str 362 | ) -> Optional[dict]: # pylint: disable=unsubscriptable-object 363 | """Perform a POST request against the PCO API. 364 | 365 | Performs a fully managed POST request (handles ratelimiting, timeouts, etc.). 366 | 367 | Args: 368 | url (str): The URL against which to perform the request. Can include 369 | what's been set as api_base, which will be ignored if this value is also 370 | present in your URL. 371 | payload (dict): The payload for the POST request. Must be serializable to JSON! 372 | params: Any named arguments will be passed as query parameters. Values must 373 | be of type str! 374 | 375 | Raises: 376 | PCORequestTimeoutException: The request to PCO timed out the maximum number of times. 377 | PCOUnexpectedRequestException: An unexpected error occurred when making your request. 378 | PCORequestException: The response from the PCO API indicated an error with your request. 379 | 380 | Returns: 381 | dict: The payload returned by the API for this request. 382 | """ 383 | 384 | return self.request_json('POST', url, payload, **params) 385 | 386 | def patch( 387 | self, 388 | url: str, 389 | payload: Optional[dict] = None, # pylint: disable=unsubscriptable-object 390 | **params: str 391 | ) -> Optional[dict]: # pylint: disable=unsubscriptable-object 392 | """Perform a PATCH request against the PCO API. 393 | 394 | Performs a fully managed PATCH request (handles ratelimiting, timeouts, etc.). 395 | 396 | Args: 397 | url (str): The URL against which to perform the request. Can include 398 | what's been set as api_base, which will be ignored if this value is also 399 | present in your URL. 400 | payload (dict): The payload for the PUT request. Must be serializable to JSON! 401 | params: Any named arguments will be passed as query parameters. Values must 402 | be of type str! 403 | 404 | Raises: 405 | PCORequestTimeoutException: The request to PCO timed out the maximum number of times. 406 | PCOUnexpectedRequestException: An unexpected error occurred when making your request. 407 | PCORequestException: The response from the PCO API indicated an error with your request. 408 | 409 | Returns: 410 | dict: The payload returned by the API for this request. 411 | """ 412 | 413 | return self.request_json('PATCH', url, payload, **params) 414 | 415 | def delete(self, url: str, **params: str) -> requests.Response: 416 | """Perform a DELETE request against the PCO API. 417 | 418 | Performs a fully managed DELETE request (handles ratelimiting, timeouts, etc.). 419 | 420 | Args: 421 | url (str): The URL against which to perform the request. Can include 422 | what's been set as api_base, which will be ignored if this value is also 423 | present in your URL. 424 | params: Any named arguments will be passed as query parameters. Values must 425 | be of type str! 426 | 427 | Raises: 428 | PCORequestTimeoutException: The request to PCO timed out the maximum number of times. 429 | PCOUnexpectedRequestException: An unexpected error occurred when making your request. 430 | PCORequestException: The response from the PCO API indicated an error with your request. 431 | 432 | Returns: 433 | requests.Response: The response object returned by the API for this request. 434 | A successful delete request will return a response with an empty payload, 435 | so we return the response object here instead. 436 | """ 437 | 438 | return self.request_response('DELETE', url, **params) 439 | 440 | def iterate(self, url: str, offset: int = 0, per_page: int = 25, **params: str) -> Iterator[dict]: # pylint: disable=too-many-branches 441 | """Iterate a list of objects in a response, handling pagination. 442 | 443 | Basically, this function wraps get in a generator function designed for 444 | processing requests that will return multiple objects. Pagination is 445 | transparently handled. 446 | 447 | Objects specified as includes will be injected into their associated 448 | object and returned. 449 | 450 | Args: 451 | url (str): The URL against which to perform the request. Can include 452 | what's been set as api_base, which will be ignored if this value is also 453 | present in your URL. 454 | offset (int): The offset at which to start. Usually going to be 0 (the default). 455 | per_page (int): The number of results that should be requested in a single page. 456 | Valid values are 1 - 100, defaults to the PCO default of 25. 457 | params: Any additional named arguments will be passed as query parameters. Values must 458 | be of type str! 459 | 460 | Raises: 461 | PCORequestTimeoutException: The request to PCO timed out the maximum number of times. 462 | PCOUnexpectedRequestException: An unexpected error occurred when making your request. 463 | PCORequestException: The response from the PCO API indicated an error with your request. 464 | 465 | Yields: 466 | dict: Each object returned by the API for this request. Returns "data", 467 | "included", and "meta" nodes for each response. Note that data is processed somewhat 468 | before being returned from the API. Namely, includes are injected into the object(s) 469 | with which they are associated. This makes it easier to process includes associated with 470 | specific objects since they are accessible directly from each returned object. 471 | """ 472 | 473 | while True: # pylint: disable=too-many-nested-blocks 474 | 475 | response = self.get(url, offset=offset, per_page=per_page, **params) 476 | 477 | if response is None: 478 | return 479 | 480 | for cur in response['data']: 481 | record = { 482 | 'data': cur, 483 | 'included': [], 484 | 'meta': {} 485 | } 486 | 487 | if 'can_include' in response['meta']: 488 | record['meta']['can_include'] = response['meta']['can_include'] 489 | 490 | if 'parent' in response['meta']: 491 | record['meta']['parent'] = response['meta']['parent'] 492 | 493 | if 'relationships' in cur: 494 | for key in cur['relationships']: 495 | relationships = cur['relationships'][key]['data'] 496 | 497 | if relationships is not None: 498 | if isinstance(relationships, dict): 499 | for include in response['included']: 500 | if include['type'] == relationships['type'] and \ 501 | include['id'] == relationships['id']: 502 | record['included'].append(include) 503 | 504 | elif isinstance(relationships, list): 505 | for relationship in relationships: 506 | for include in response['included']: 507 | if include['type'] == relationship['type'] and \ 508 | include['id'] == relationship['id']: 509 | record['included'].append(include) 510 | 511 | yield record 512 | 513 | offset += per_page 514 | 515 | if 'next' not in response['links']: 516 | break 517 | 518 | def upload(self, file_path: str, **params) -> Optional[dict]: # pylint: disable=unsubscriptable-object 519 | """Upload the file at the specified path to PCO. 520 | 521 | Args: 522 | file_path (str): The path to the file to be uploaded to PCO. 523 | params: Any named arguments will be passed as query parameters. Values must 524 | be of type str! 525 | 526 | Raises: 527 | PCORequestTimeoutException: The request to PCO timed out the maximum number of times. 528 | PCOUnexpectedRequestException: An unexpected error occurred when making your request. 529 | PCORequestException: The response from the PCO API indicated an error with your request. 530 | 531 | Returns: 532 | dict: The PCO response from the file upload. 533 | """ 534 | 535 | return self.request_json('POST', self.upload_url, upload=file_path, **params) 536 | 537 | def __del__(self): 538 | """Close the requests session when the PCO object goes out of scope.""" 539 | 540 | self.session.close() 541 | 542 | @staticmethod 543 | def template( 544 | object_type: str, 545 | attributes: Optional[dict] = None # pylint: disable=unsubscriptable-object 546 | ) -> dict: 547 | """Get template JSON for creating a new object. 548 | 549 | Args: 550 | object_type (str): The type of object to be created. 551 | attributes (dict): The new objects attributes. Defaults to empty. 552 | 553 | Returns: 554 | dict: A template from which to set the new object's attributes. 555 | """ 556 | 557 | return { 558 | 'data': { 559 | 'type': object_type, 560 | 'attributes': {} if attributes is None else attributes 561 | } 562 | } 563 | -------------------------------------------------------------------------------- /tests/test_pco.py: -------------------------------------------------------------------------------- 1 | """Test the primary pypco entry point -- the PCO object""" 2 | 3 | #pylint: disable=protected-access,global-statement 4 | 5 | import os 6 | import json 7 | from unittest.mock import Mock, patch 8 | 9 | import requests 10 | from requests.exceptions import SSLError 11 | 12 | import pypco 13 | from pypco.exceptions import PCORequestTimeoutException, \ 14 | PCORequestException, PCOUnexpectedRequestException 15 | from pypco.auth_config import PCOAuthConfig 16 | from tests import BasePCOTestCase, BasePCOVCRTestCase 17 | 18 | # region Side Effect Functions 19 | 20 | # Side effect functions and global vars 21 | 22 | ## Timeout testing 23 | REQUEST_COUNT = 0 24 | TIMEOUTS = 0 25 | 26 | def timeout_se(*_, **__): 27 | """A function to mock requests timeouts over multiple responses. 28 | 29 | You must set REQUEST_COUNT global variable to 0 and TIMEOUTS global variable to desired 30 | number before this function is being called. 31 | 32 | Returns: 33 | Mock: A mock response object. 34 | """ 35 | 36 | global REQUEST_COUNT, TIMEOUTS #pylint: disable=global-statement 37 | 38 | REQUEST_COUNT += 1 39 | 40 | if REQUEST_COUNT == TIMEOUTS + 1: 41 | response = Mock() 42 | response.text = '' 43 | response.status_code = 200 44 | 45 | return response 46 | 47 | raise requests.exceptions.Timeout() 48 | 49 | ## Rate limit handling 50 | RL_REQUEST_COUNT = 0 51 | RL_LIMITED_REQUESTS = 0 52 | 53 | def ratelimit_se(*_, **__): #pylint: disable=unused-argument 54 | """Simulate rate limiting. 55 | 56 | You must define RL_REQUEST_COUNT and RL_LIMITED_REQUESTS as 57 | global variables before calling this function. 58 | 59 | Returns: 60 | Mock: A mock response object. 61 | """ 62 | 63 | global RL_REQUEST_COUNT, RL_LIMITED_REQUESTS 64 | 65 | RL_LIMITED_REQUESTS += 1 66 | 67 | class RateLimitResponse: 68 | """Mocking class for rate limited response 69 | When this class is called with a req_count > 0, it mock a successful 70 | request. Otherwise a rate limited request is mocked. 71 | """ 72 | 73 | @property 74 | def status_code(self): 75 | """Mock the status code property""" 76 | 77 | if RL_LIMITED_REQUESTS > RL_REQUEST_COUNT: 78 | return 200 79 | 80 | return 429 81 | 82 | @property 83 | def headers(self): 84 | """Mock the headers property""" 85 | 86 | if RL_LIMITED_REQUESTS > RL_REQUEST_COUNT: 87 | return {} 88 | 89 | return {"Retry-After": RL_REQUEST_COUNT * 5} 90 | 91 | @property 92 | def text(self): 93 | """Mock the text property""" 94 | 95 | return json.dumps(RateLimitResponse.json()) 96 | 97 | @staticmethod 98 | def json(): 99 | """Mock the json function""" 100 | 101 | if RL_LIMITED_REQUESTS > RL_REQUEST_COUNT: 102 | return { 103 | "hello": "world" 104 | } 105 | 106 | return { 107 | "errors": [ 108 | { 109 | "code": "429", 110 | "detail": "Rate limit exceeded: 118 of 100 requests per 20 seconds" 111 | } 112 | ] 113 | } 114 | 115 | def raise_for_status(self): 116 | """Placeholder function for requests.response""" 117 | 118 | return RateLimitResponse() 119 | 120 | def connection_error_se(*_, **__): 121 | """Simulate a requests SSLError being thrown.""" 122 | 123 | raise SSLError() 124 | 125 | # endregion 126 | 127 | class TestPrivateRequestFunctions(BasePCOTestCase): 128 | """Test low-level request mechanisms.""" 129 | 130 | @patch('requests.Session.request') 131 | @patch('builtins.open') 132 | def test_do_request(self, mock_fh, mock_request): 133 | """Test dispatching single requests; HTTP verbs, file uploads, etc.""" 134 | 135 | # Setup PCO object and request mock 136 | pco = pypco.PCO( 137 | application_id='app_id', 138 | secret='secret' 139 | ) 140 | 141 | mock_response = Mock() 142 | mock_response.status_code = 200 143 | mock_response.text = '{"hello": "world"}' 144 | mock_request.return_value = mock_response 145 | 146 | # GET 147 | pco._do_request( 148 | 'GET', 149 | 'https://api.planningcenteronline.com/somewhere/v2/something', 150 | include='test', 151 | per_page=100 152 | ) 153 | 154 | mock_request.assert_called_with( 155 | 'GET', 156 | 'https://api.planningcenteronline.com/somewhere/v2/something', 157 | params={ 158 | 'include' : 'test', 159 | 'per_page' : 100 160 | }, 161 | headers={ 162 | 'User-Agent': 'pypco', 163 | 'Authorization': 'Basic YXBwX2lkOnNlY3JldA==', 164 | }, 165 | json=None, 166 | timeout=60, 167 | ) 168 | 169 | # POST 170 | pco._do_request( 171 | 'POST', 172 | 'https://api.planningcenteronline.com/somewhere/v2/something', 173 | payload={ 174 | 'type': 'Person', 175 | 'attributes': { 176 | 'a': 1, 177 | 'b': 2 178 | } 179 | } 180 | ) 181 | 182 | mock_request.assert_called_with( 183 | 'POST', 184 | 'https://api.planningcenteronline.com/somewhere/v2/something', 185 | json={ 186 | 'type': 'Person', 187 | 'attributes': { 188 | 'a': 1, 189 | 'b': 2 190 | } 191 | }, 192 | headers={ 193 | 'User-Agent': 'pypco', 194 | 'Authorization': 'Basic YXBwX2lkOnNlY3JldA==' 195 | }, 196 | params={}, 197 | timeout=60 198 | ) 199 | 200 | # File Upload 201 | mock_fh.name = "open()" 202 | 203 | pco._do_request( 204 | 'POST', 205 | 'https://api.planningcenteronline.com/somewhere/v2/something', 206 | upload='/file/path', 207 | ) 208 | 209 | mock_fh.assert_called_once_with('/file/path', 'rb') 210 | 211 | @patch('requests.Session.request', side_effect=timeout_se) 212 | def test_do_timeout_managed_request(self, mock_request): 213 | """Test requests that automatically will retry on timeout.""" 214 | 215 | global REQUEST_COUNT, TIMEOUTS 216 | 217 | # Setup PCO object and request mock 218 | pco = pypco.PCO( 219 | application_id='app_id', 220 | secret='secret' 221 | ) 222 | 223 | REQUEST_COUNT = 0 224 | TIMEOUTS = 0 225 | 226 | pco._do_timeout_managed_request( 227 | 'GET', 228 | '/test', 229 | ) 230 | 231 | self.assertEqual(REQUEST_COUNT, 1, "Successful request not executed exactly once.") 232 | 233 | REQUEST_COUNT = 0 234 | TIMEOUTS = 1 235 | 236 | pco._do_timeout_managed_request( 237 | 'GET', 238 | '/test', 239 | ) 240 | 241 | self.assertEqual(REQUEST_COUNT, 2, "Successful request not executed exactly once.") 242 | 243 | REQUEST_COUNT = 0 244 | TIMEOUTS = 1 245 | 246 | pco._do_timeout_managed_request( 247 | 'GET', 248 | '/test', 249 | ) 250 | 251 | self.assertEqual(REQUEST_COUNT, 2, "Successful request not executed exactly once.") 252 | 253 | REQUEST_COUNT = 0 254 | TIMEOUTS = 2 255 | 256 | pco._do_timeout_managed_request( 257 | 'GET', 258 | '/test', 259 | ) 260 | 261 | self.assertEqual(REQUEST_COUNT, 3, "Successful request not executed exactly once.") 262 | 263 | REQUEST_COUNT = 0 264 | TIMEOUTS = 3 265 | 266 | with self.assertRaises(PCORequestTimeoutException): 267 | pco._do_timeout_managed_request( 268 | 'GET', 269 | '/test', 270 | ) 271 | 272 | mock_request.assert_called_with( 273 | 'GET', 274 | '/test', 275 | headers={'User-Agent': 'pypco', 'Authorization': 'Basic YXBwX2lkOnNlY3JldA=='}, 276 | json=None, 277 | params={}, 278 | timeout=60 279 | ) 280 | 281 | # Let's try with only two retries permitted 282 | pco = pypco.PCO( 283 | application_id='app_id', 284 | secret='secret', 285 | timeout_retries=2 286 | ) 287 | 288 | REQUEST_COUNT = 0 289 | TIMEOUTS = 2 290 | 291 | with self.assertRaises(PCORequestTimeoutException): 292 | pco._do_timeout_managed_request( 293 | 'GET', 294 | '/test', 295 | ) 296 | 297 | @patch('requests.Session.request', side_effect=ratelimit_se) 298 | @patch('time.sleep') 299 | def test_do_ratelimit_managed_request(self, mock_sleep, mock_request): 300 | """Test automatic rate limit handling.""" 301 | 302 | global RL_REQUEST_COUNT, RL_LIMITED_REQUESTS 303 | 304 | # Setup PCO object 305 | pco = pypco.PCO( 306 | 'app_id', 307 | 'secret' 308 | ) 309 | 310 | # Test with no rate limiting 311 | RL_REQUEST_COUNT = 0 312 | RL_LIMITED_REQUESTS = 0 313 | 314 | pco._do_ratelimit_managed_request( 315 | 'GET', 316 | '/test' 317 | ) 318 | 319 | mock_request.assert_called_once() 320 | mock_sleep.assert_not_called() 321 | 322 | # Test with rate limiting 323 | RL_REQUEST_COUNT = 1 324 | RL_LIMITED_REQUESTS = 0 325 | 326 | pco._do_ratelimit_managed_request( 327 | 'GET', 328 | '/test' 329 | ) 330 | 331 | mock_sleep.assert_called_once_with(5) 332 | 333 | # Test with rate limiting (three limited responses) 334 | RL_REQUEST_COUNT = 3 335 | RL_LIMITED_REQUESTS = 0 336 | 337 | result = pco._do_ratelimit_managed_request( 338 | 'GET', 339 | '/test' 340 | ) 341 | 342 | mock_sleep.assert_called_with(15) 343 | self.assertIsNotNone(result, "Didn't get response returned!") 344 | 345 | @patch('requests.Session.request') 346 | def test_do_url_managed_request(self, mock_request): 347 | """Test requests with URL cleanup.""" 348 | 349 | base = 'https://api.planningcenteronline.com' 350 | 351 | # Setup PCO object 352 | pco = pypco.PCO( 353 | 'app_id', 354 | 'secret' 355 | ) 356 | 357 | pco._do_url_managed_request('GET', '/test') 358 | 359 | mock_request.assert_called_with( 360 | 'GET', 361 | f'{base}/test', 362 | headers={'User-Agent': 'pypco', 'Authorization': 'Basic YXBwX2lkOnNlY3JldA=='}, 363 | json=None, 364 | params={}, 365 | timeout=60 366 | ) 367 | 368 | pco._do_url_managed_request('GET', 'https://api.planningcenteronline.com/test') 369 | 370 | mock_request.assert_called_with( 371 | 'GET', 372 | f'{base}/test', 373 | headers={'User-Agent': 'pypco', 'Authorization': 'Basic YXBwX2lkOnNlY3JldA=='}, 374 | json=None, 375 | params={}, 376 | timeout=60 377 | ) 378 | 379 | pco._do_url_managed_request('GET', 'https://api.planningcenteronline.com//test') 380 | 381 | mock_request.assert_called_with( 382 | 'GET', 383 | f'{base}/test', 384 | headers={'User-Agent': 'pypco', 'Authorization': 'Basic YXBwX2lkOnNlY3JldA=='}, 385 | json=None, 386 | params={}, 387 | timeout=60 388 | ) 389 | 390 | pco._do_url_managed_request('GET', 'https://api.planningcenteronline.com//test') 391 | 392 | mock_request.assert_called_with( 393 | 'GET', 394 | f'{base}/test', 395 | headers={'User-Agent': 'pypco', 'Authorization': 'Basic YXBwX2lkOnNlY3JldA=='}, 396 | json=None, 397 | params={}, 398 | timeout=60 399 | ) 400 | 401 | pco._do_url_managed_request('GET', 'https://api.planningcenteronline.com//test///test1/test2/////test3/test4') 402 | 403 | mock_request.assert_called_with( 404 | 'GET', 405 | f'{base}/test/test1/test2/test3/test4', 406 | headers={'User-Agent': 'pypco', 'Authorization': 'Basic YXBwX2lkOnNlY3JldA=='}, 407 | json=None, 408 | params={}, 409 | timeout=60 410 | ) 411 | 412 | @patch('pypco.PCO._do_ratelimit_managed_request') 413 | def _test_do_url_managed_upload_request(self, mock_request): 414 | """Test upload request with URL cleanup (should ignore).""" 415 | 416 | # Setup PCO object 417 | pco = pypco.PCO( 418 | 'app_id', 419 | 'secret' 420 | ) 421 | 422 | pco._do_url_managed_request( 423 | 'POST', 424 | 'https://upload.planningcenteronline.com/v2/files', 425 | upload='test', 426 | ) 427 | 428 | mock_request.assert_called_with( 429 | 'POST', 430 | 'https://upload.planningcenteronline.com/v2/files', 431 | headers={'User-Agent': 'pypco', 'Authorization': 'Basic YXBwX2lkOnNlY3JldA=='}, 432 | upload='test', 433 | json=None, 434 | params={}, 435 | timeout=60 436 | ) 437 | 438 | class TestPublicRequestFunctions(BasePCOVCRTestCase): 439 | """Test public PCO request functions.""" 440 | 441 | @patch('requests.Session.request', side_effect=connection_error_se) 442 | def test_request_resonse_general_err(self, _): #pylint: disable=unused-argument 443 | """Test the request_response() function when a general error is thrown.""" 444 | 445 | pco = self.pco 446 | 447 | with self.assertRaises(PCOUnexpectedRequestException): 448 | pco.request_response('GET', '/test') 449 | 450 | def test_request_response(self): 451 | """Test the request_response function.""" 452 | 453 | pco = self.pco 454 | 455 | response = pco.request_response('GET', '/people/v2/people') 456 | 457 | self.assertIsInstance(response, requests.Response, "Wrong type of object returned.") 458 | self.assertIsNotNone(response.json()['data'], "Expected to receive data but didn't.") 459 | 460 | with self.assertRaises(PCORequestException) as exception_ctxt: 461 | pco.request_response('GET', '/bogus') 462 | 463 | err = exception_ctxt.exception 464 | self.assertEqual(err.status_code, 404) 465 | self.assertEqual( 466 | err.response_body, 467 | '{"errors":[{"status":"404","title":"Not Found",' 468 | '"detail":"The resource you requested could not be found"}]}' 469 | ) 470 | 471 | with self.assertRaises(PCORequestException) as exception_ctxt: 472 | pco.request_response( 473 | 'POST', 474 | '/people/v2/people', 475 | payload={} 476 | ) 477 | 478 | err = exception_ctxt.exception 479 | self.assertEqual(err.status_code, 400) 480 | self.assertEqual( 481 | err.response_body, 482 | '{"errors":[{"status":"400","title":"Bad Request",' 483 | '"code":"invalid_resource_payload",' 484 | '"detail":"The payload given does not contain a \'data\' key."}]}' 485 | ) 486 | 487 | def test_request_json(self): 488 | """Test the request_json function.""" 489 | 490 | pco = self.pco 491 | 492 | response = pco.request_json('GET', '/people/v2/people') 493 | 494 | self.assertIsInstance(response, dict) 495 | self.assertIsNotNone(response['data']) 496 | 497 | with self.assertRaises(PCORequestException) as exception_ctxt: 498 | pco.request_response('GET', '/bogus') 499 | 500 | err = exception_ctxt.exception 501 | self.assertEqual(err.status_code, 404) 502 | 503 | def test_get(self): 504 | """Test the get function.""" 505 | 506 | pco = self.pco 507 | 508 | # A basic get 509 | result = pco.get('/people/v2/people/45029164') 510 | self.assertEqual(result['data']['attributes']['name'], 'Paul Revere') 511 | 512 | # Get with includes 513 | result = pco.get('/people/v2/people/45029164', include='emails,organization') 514 | self.assertEqual(result['included'][0]['type'], 'Email') 515 | self.assertEqual(result['included'][0]['attributes']['address'], 'paul.revere@mailinator.com') 516 | self.assertEqual(result['included'][1]['type'], 'Organization') 517 | self.assertEqual(result['included'][1]['attributes']['name'], 'Pypco Dev') 518 | 519 | # Get with filter 520 | params = { 521 | 'where[first_name]': 'paul' 522 | } 523 | 524 | result = pco.get('/people/v2/people', **params) 525 | self.assertEqual(len(result['data']), 1) 526 | self.assertEqual(result['data'][0]['attributes']['first_name'], 'Paul') 527 | 528 | def test_post(self): 529 | """Test the post function.""" 530 | 531 | pco = self.pco 532 | 533 | new_song = pco.template( 534 | 'Song', 535 | { 536 | 'title': 'Jesus Loves Me', 537 | 'author': 'Public Domain' 538 | } 539 | ) 540 | 541 | result = pco.post('/services/v2/songs', payload=new_song) 542 | 543 | self.assertEqual(result['data']['attributes']['title'], 'Jesus Loves Me') 544 | 545 | def test_patch(self): 546 | """Test the patch function.""" 547 | 548 | pco = self.pco 549 | 550 | song = pco.template( 551 | 'Song', 552 | { 553 | 'author': 'Anna Bartlett Warner' 554 | } 555 | ) 556 | 557 | response = pco.patch('/services/v2/songs/18338876', song) 558 | 559 | self.assertEqual(response['data']['attributes']['title'], 'Jesus Loves Me') 560 | self.assertEqual(response['data']['attributes']['author'], 'Anna Bartlett Warner') 561 | 562 | def test_delete(self): 563 | """Test the delete function.""" 564 | 565 | pco = self.pco 566 | 567 | response = pco.delete('/services/v2/songs/18420243') 568 | 569 | self.assertEqual(response.status_code, 204) 570 | 571 | with self.assertRaises(PCORequestException): 572 | pco.get('/services/v2/songs/18420243') 573 | 574 | def test_iterate(self): 575 | """Test the iterate function.""" 576 | 577 | pco = self.pco 578 | 579 | # Get all people w/ default page size of 25 580 | all_people = [row for row in pco.iterate('/people/v2/people')] 581 | self.assertEqual(200, len(all_people), 'Should have been 200 results in People query.') 582 | 583 | # Make sure we got all 200 unique ids 584 | id_set = {person['data']['id'] for person in all_people} 585 | self.assertEqual(200, len(id_set), 'Expected 200 unique people ids.') 586 | 587 | # Change default page size to 50 588 | all_people = [row for row in pco.iterate('/people/v2/people', per_page=50)] 589 | self.assertEqual(200, len(all_people), 'Should have been 200 results in People query.') 590 | 591 | # Make sure we got all 200 unique ids 592 | id_set = {person['data']['id'] for person in all_people} 593 | self.assertEqual(200, len(id_set), 'Expected 200 unique people ids.') 594 | 595 | # Start with a non-zero offset 596 | all_people = [row for row in pco.iterate('/people/v2/people', offset=25)] 597 | self.assertEqual(175, len(all_people), 'Should have been 150 results in People query.') 598 | 599 | # Make sure we got all 200 unique ids 600 | id_set = {person['data']['id'] for person in all_people} 601 | self.assertEqual(175, len(id_set), 'Expected 150 unique people ids.') 602 | 603 | # Get a single include, excluding admins to avoid publishing email in VCR data 604 | query = { 605 | 'where[site_administrator]': 'false', 606 | } 607 | 608 | all_people = [row for row in pco.iterate('/people/v2/people', include='emails', **query)] 609 | self.assertEqual(199, len(all_people), 'Query did not return expected number of people.') 610 | 611 | for person in all_people: 612 | self.assertEqual(1, len(person['included']), 'Expected exactly one include.') 613 | self.assertEqual('Email', person['included'][0]['type'], 'Unexpected include type.') 614 | self.assertEqual( 615 | person['data']['relationships']['emails']['data'][0]['id'], 616 | person['included'][0]['id'], 617 | 'Email id did not match as expected.' 618 | ) 619 | self.assertIn("can_include", person["meta"]) 620 | self.assertIn("parent", person["meta"]) 621 | 622 | # Test multiple includes, again excluding admins 623 | query = { 624 | 'where[site_administrator]': 'false', 625 | } 626 | 627 | all_people = [row for row in pco.iterate( 628 | '/people/v2/people', 629 | include='emails,organization', 630 | **query 631 | )] 632 | 633 | for person in all_people: 634 | self.assertEqual(2, len(person['included']), 'Expected exactly two includes.') 635 | 636 | for included in person['included']: 637 | self.assertIn( 638 | included['type'], 639 | ['Email', 'Organization'], 640 | 'Unexpected include type' 641 | ) 642 | 643 | if included['type'] == 'Email': 644 | self.assertEqual( 645 | included['relationships']['person']['data']['id'], 646 | person['data']['id'], 647 | 'Email id did not match as expected.' 648 | ) 649 | 650 | # Test multiple included objects of same type 651 | query = { 652 | 'where[first_name]': 'Paul', 653 | } 654 | 655 | all_pauls = [row for row in pco.iterate('/people/v2/people', include='addresses', **query)] 656 | 657 | self.assertEqual(2, len(all_pauls), 'Unexpected number of people returned.') 658 | 659 | for person in all_pauls: 660 | 661 | included_person_ids = set() 662 | 663 | for included in person['included']: 664 | self.assertEqual(included['type'], 'Address') 665 | included_person_ids.add(included['relationships']['person']['data']['id']) 666 | 667 | self.assertEqual(1, len(included_person_ids)) 668 | self.assertEqual(included_person_ids.pop(), person['data']['id']) 669 | 670 | def test_iterate_no_relationships(self): 671 | """Test iterate when the relationships attribute is missing.""" 672 | 673 | pco = self.pco 674 | 675 | report_templates = [record for record in pco.iterate('/services/v2/report_templates')] 676 | 677 | self.assertEqual( 678 | 36, 679 | len(report_templates), 680 | 'Unexpected number of report templates returned.' 681 | ) 682 | 683 | @patch('pypco.PCO.get') 684 | def test_iterate_response_none(self, get_mock): 685 | """Test iterate when PCO API returns 204 / None.""" 686 | 687 | get_mock.return_value = None 688 | 689 | pco = self.pco 690 | 691 | # No response, should give back an empty list 692 | self.assertEqual([], list(pco.iterate('/people/v2/people'))) 693 | 694 | def test_template(self): 695 | """Test the template function.""" 696 | 697 | pco = self.pco 698 | 699 | template = pco.template('Test') 700 | 701 | self.assertEqual( 702 | template, 703 | { 704 | 'data': { 705 | 'type': 'Test', 706 | 'attributes': {} 707 | } 708 | } 709 | ) 710 | 711 | template = pco.template('Test2', {'test_attr': 'hello'}) 712 | 713 | self.assertEqual( 714 | template, 715 | { 716 | 'data': { 717 | 'type': 'Test2', 718 | 'attributes': { 719 | 'test_attr': 'hello' 720 | } 721 | } 722 | } 723 | ) 724 | 725 | def test_upload(self): 726 | """Test the file upload function.""" 727 | 728 | pco = self.pco 729 | 730 | base_path = os.path.dirname(os.path.abspath(__file__)) 731 | file_path = '/assets/test_upload.jpg' 732 | 733 | upload_response = pco.upload(f'{base_path}{file_path}') 734 | 735 | self.assertEqual(upload_response['data'][0]['type'], 'File') 736 | self.assertIsInstance(upload_response['data'][0]['id'], str) 737 | self.assertEqual(upload_response['data'][0]['attributes']['name'], 'test_upload.jpg') 738 | 739 | def test_upload_and_use_file(self): 740 | """Verify we can use an uploaded file in PCO.""" 741 | 742 | pco = self.pco 743 | 744 | base_path = os.path.dirname(os.path.abspath(__file__)) 745 | file_path = '/assets/test_upload.jpg' 746 | 747 | upload_response = pco.upload(f'{base_path}{file_path}') 748 | 749 | query = { 750 | 'where[first_name]': 'Paul', 751 | 'where[last_name]': 'Revere', 752 | } 753 | 754 | paul = next(pco.iterate('/people/v2/people', **query)) 755 | 756 | user_template = pco.template('Person', {'avatar': upload_response['data'][0]['id']}) 757 | 758 | patch_result = pco.patch(paul['data']['links']['self'], user_template) 759 | 760 | self.assertNotRegex(patch_result['data']['attributes']['avatar'], r'.*no_photo_thumbnail.*') 761 | 762 | def test_empty_response(self): 763 | """Test getting an empty response. (204) from pco api""" 764 | 765 | pco = self.pco 766 | 767 | # Refresh a list returns a 204 768 | result = pco.get('/people/v2/lists/1097503/run') 769 | self.assertEqual(result, None) 770 | 771 | 772 | class TestPCOInitialization(BasePCOTestCase): 773 | """Test initializing PCO objects with various argument combinations.""" 774 | 775 | def test_pco_initialization(self): 776 | """Test initializing the PCO object with various combinations of arguments.""" 777 | 778 | # region Minimal Args: PAT Auth 779 | pco = pypco.PCO( 780 | 'app_id', 781 | 'app_secret', 782 | ) 783 | 784 | self.assertIsInstance(pco._auth_config, PCOAuthConfig) 785 | self.assertIsInstance(pco._auth_header, str) 786 | self.assertEqual(pco.api_base, 'https://api.planningcenteronline.com') 787 | self.assertEqual(pco.timeout, 60) 788 | self.assertEqual(pco.upload_url, 'https://upload.planningcenteronline.com/v2/files') 789 | self.assertEqual(pco.upload_timeout, 300) 790 | self.assertEqual(pco.timeout_retries, 3) 791 | 792 | # endregion 793 | 794 | # region Minimal Args: OAUTH 795 | pco = pypco.PCO( 796 | token='abc' 797 | ) 798 | 799 | self.assertIsInstance(pco._auth_config, PCOAuthConfig) 800 | self.assertIsInstance(pco._auth_header, str) 801 | self.assertEqual(pco.api_base, 'https://api.planningcenteronline.com') 802 | self.assertEqual(pco.timeout, 60) 803 | self.assertEqual(pco.upload_url, 'https://upload.planningcenteronline.com/v2/files') 804 | self.assertEqual(pco.upload_timeout, 300) 805 | self.assertEqual(pco.timeout_retries, 3) 806 | 807 | # endregion 808 | 809 | # region: Change all defaults 810 | pco = pypco.PCO( 811 | 'app_id', 812 | 'app_secret', 813 | api_base='https://bogus.base', 814 | timeout=120, 815 | upload_url='https://upload.files', 816 | upload_timeout=50, 817 | timeout_retries=500, 818 | ) 819 | 820 | self.assertIsInstance(pco._auth_config, PCOAuthConfig) 821 | self.assertIsInstance(pco._auth_header, str) 822 | self.assertEqual(pco.api_base, 'https://bogus.base') 823 | self.assertEqual(pco.timeout, 120) 824 | self.assertEqual(pco.upload_url, 'https://upload.files') 825 | self.assertEqual(pco.upload_timeout, 50) 826 | self.assertEqual(pco.timeout_retries, 500) 827 | --------------------------------------------------------------------------------