This tutorial should teach you how to build a light-weight API wrapper for Jira.
1. Python3.6+ is installed
2. You have access to a Jira Cloud instance
1. If you do not have access to Jira, please email [Jonny](slack://user?team={TBGGJGQAY}&id={UBG77CB25})
- Log into Jira and generate a personal API token
- Navigate to https://id.atlassian.com/manage/api-tokens
- Click Create API Token
- Save token value somewhere safe
- Create new Python project for the Jira API wrapper, mine will be named "jira-api-wrapper"
- The project structure should look similar to this:
Note: Creating a good readme is important, please use this as a template.jira-api-wrapper ├── jira_api_wrapper/ │ ├── api/ │ │ ├── fields/ │ │ │ ├── fields.py │ │ ├── issue/ │ │ │ ├── issue.py │ │ ├── exceptions.py │ │ ├── endpoints.py │ │ ├── wrapper.py │ ├── wrapper/ │ │ ├── jira_wrapper.py ├── test/ │ ├── test_api/ │ │ ├── test_fields/ │ │ │ ├── test_fields.py │ │ ├── test_issue/ │ │ │ ├── test_issue.py │ │ ├── test_wrapper.py │ ├── test_jira_wrapper/ │ │ ├── test_jira_wrapper.py ├── etc/ │ ├── version.txt │ ├── .bumpversion.cfg │ ├── .pylintrc ├── setup.py ├── README.md └──
- Since we already know how our project is going to be laid out we can create setup.py.
- Now we are ready to define the URLs to the endpoints by creating jira_api_wrapper/api/endpoints.py.
I have provided an example for only two endpoints. To extend the functionality of this wrapper to include additional endpoints, you can add them using by using the JIRA Cloud REST API documentation. I use the endpoint name as the dictionary key, and the path to the endpoint as the value.
If the endpoint uses path parameters we need to replace it with a placeholder. This endpointSet application property /rest/api/2/application-properties/{id}
would be written as'set_application_property': f'{api}/application-properties/{{}}'
- Some endpoints will use path parameters (
/rest/api/2/issue/{issueIdOrKey}/assignee
) while others do not (/rest/api/2/field
). One scenario that quickly comes to mind is "What happens when a user tries to set a path parameter when it doesn't take one?". To accommodate for this scenario we can create a custom exception to make it very clear to the user if they are attempting to set path parameters on an endpoint that does not accept them. To do this we must create jira_api_wrapper/api/exceptions.py. - At this point we have what we need to create jira_api_wrapper/api/base_api.py.
This provides us with a base class that will be inherited by the classes that interact with the API in the next steps. - Finally we get to actually interact with the API by creating jira_api_wrapper/api/fields/fields.py.
All the logic for interacting with the/rest/api/2/issue/*
endpoint should be contained within this class. - Let's add another endpoint just as we did in the previous step by creating issue.py.
- To help us easily manage our imports, we will create jira_api_wrapper/api/__init__.py
- It is time to add the actual wrapper that will be exposed to the user by creating jira_api_wrapper/wrapper/jira_wrapper.py.
This class,JiraWrapper
, will inherit from all the API classes (such asJiraFields
andJiraIssue
) which meansJiraWrapper
now contains all the methods as the parent class, such as.get_fields()
and.get_issue()
- In the current state of this project, after a user installs this wrapper, importing it feel strange.
To fix this we will create jira_api_wrapper/__init__.py.
Now we can do:
from jira_api_wrapper import JiraWrapper
Instead of:
from jira_api_wrapper.wrapper.jira_wrapper import JiraWrapper
-
Unfortunately the correct answer has more complexity than this Stackoverflow answer:
setup.py is a python file, which usually tells you that the module/package you are about to install has been packaged and distributed with Distutils, which is the standard for distributing Python Modules.
This allows you to easily install Python packages. Often it's enough to write:
$ python setup.py install
and the module will install itself.While the above is true, it does not speak about Setuptools. Setuptools was created due to limitations presented by Distutils (such as not being easily able to package/consume non-python files within a module).
One issue is that Setuptools monkeypatches (redefines functionality) Distutils under the hood which created a bit of chaos.
This becomes especially confusing when the functionality of the Python package you produce changes greatly just from the import you picked in setup.py:from setuptools import setup
vsfrom distutils import setup
Things get even worse when we bring pip into the mix. Pip is the standard Python package manager. Pip uses setuptools, but it's distutils that's built into Python, not setuptools.
For all these reasons and more, I ask everyone: Please help end this madness. Start using Pip and Setuptools for everything, instead of the old distutils way.
- Use
from setuptools import setup
pip install -e .[dev]
instead ofpython setup.py develop
pip install -e .[dev]
instead ofpip install -r requirements.txt
pip install .
instead ofpython setup.py install
- Use
-
from setuptools import setup with open('etc/version.txt') as file: version = file.read().strip() requirements = [ 'requests>=2.18.4' ] dev_requirements = [ 'pytest>=3.5.1' ] setup(name='jira_api_wrapper', version=version, description='Configuration driven web scraping framework', author='Jonathon Carlyon', author_email='JonathonCarlyon@gmail.com', url='https://github.com/JonnyFb421/scrapeit', install_requires=requirements, extras_require={'dev': dev_requirements}, packages=['jira_api_wrapper', 'jira_api_wrapper.api', wrapper], )
-
- How application should use it:
pip install .
- How developers should use it:
pip install -e .[dev]
- How Jenkins (or other CI systems) should use it:
pip install .[dev]
Note:
.
can be substituted for a path to the directory where setup.py lives.
The-e
flag used here means "editable" so the module will be loaded from your local workspace instead of the module being installed to site-packages:/usr/local/lib/python3.6/dist-packages
- How application should use it:
-
Do away with it. Setup.py can contain the application runtime dependencies (set from the
install_requires
attribute) as well as extra dependency sets (set from theextras_require
attribute).
Using thesetup.py
example above,pip install .[dev]
would install the requests module (my runtime dependency) and pytest (my testing dependency) -
- Use
pip show <module>
to see what version of a module you're using
- Use
-
This file is simply to define all the endpoints we want our wrapper to be capable of hitting. You will need to consult the API Documentation to see the full list of endpoints.
-
class JiraEndpoints: def __init__(self, host): api = f'{host}/rest/api/2' self.endpoint = { # Myself 'get_current_user': f'{api}/myself', # Fields 'get_fields': f'{api}/field', 'create_custom_field': f'{api}/field', 'get_all_issue_field_options': f'{api}/field/{{}}/option', 'create_issue_field_option': f'{api}/field/{{}}/option', 'get_issue_field_option': f'{api}/field/{{}}/option/{{}}', 'update_issue_field_option': f'{api}/field/{{}}/option/{{}}', 'delete_issue_field_option': f'{api}/field/{{}}/option/{{}}', 'replace_issue_field_option': f'{api}/field/{{}}/option/{{}}/issue', 'get_selectable_issue_field_options': f'{api}/field/{{}}/option/suggestions/edit', 'get_visible_issue_field_options': f'{api}/field/{{}}/option/suggestions/search', }
-
We are creating a class here to contain all the JiraEndpoints. The class only has one attribute:
endpoint
. Endpoint is a dictionary that contains the Jira endpoint's name as the dictionary key, and the URL as the dictionary value.
Python3.6 introduced f-strings which makes string interpolation a lot cleaner since you can now specify the variable within curly braces.my_var='hello' print(f'{my_var} world') > hello world"
In some of the URLs above you can see string values like:
f'{api}/field/{{}}/option'
When this gets evaluated, Python will substitute {api} with the value of that variable.
The double brackets (between field and option) are how you escape curly brackets within an f-string. This is particularly useful since you can still call the old string interpolation method on strings that contain empty curly brackets by using"{} world.format(my_var)
. This will be needed in order to pass variables back into the path since REST design "is that path params are used to identify a specific resource or resources, while query parameters are used to sort/filter those resources."Note: This does not have to be a class, and some would argue that it shouldn't be it's own class since it's only setting a dictionary, but I think it is better organized when lumped into a class and inherited by the wrapper
-
The class inside the endpoints module, JiraEndpoints, will be used as the base class for our base API class.
-
Although it is a slipperly slope to get into this mentality, I do not think tests are required for this class. There is no logic, it just sets a dictionary containing static set of URLs with placeholders.
It's possible to write tests to assert that certain endpoints exist inside this dictionary, but this will be covered when we run the tests on the child classes.
-
This module contains all of the custom exceptions that our API is going to throw. This becomes powerful since you can use try/except blocks when calling your wrapper while catching for your custom exceptions instead of broader HTTP exceptions.
-
class NotEligibleForPathParams(Exception): """ The following endpoint: {} is not eligible to use path parameters """
-
We are defining a new class,
NotEligibleForPathParams
, and inheriting the Exception class. The definition of these exception classes don't need any logic and often contain onlypass
. Instead of usingpass
, I like to use docstrings (a docstring is the value encapsulated in triple quotes). When using a docstring we are actually setting a special attribute of the class,__doc__
. I like to take advantage of this by defining the error message I want the exception to raise as a docstring.
I also leave a placeholder in the docstring so we can callNotEligibleForPathParams.__doc__.format(endpoint)
which will return:
The following endpoint: http://example.com/rest/api/2/myself is not eligible to use path parameters
-
We can add an extremely simple tests for this.
Createtests/test_api/test_exceptions.py
import pytest def test_exception_exists_NotEligibleForPathParams(): from jira_api_wrapper.api.exceptions import NotEligibleForPathParams assert NotEligibleForPathParams
We are using pytest to create and run our tests, so the first step to writing a test is importing pytest.
For pytest to run this test, a couple conditions need to be hit:
1. The test file lives in a directory that starts with test_*
2. The test method begins with test_*
When writing test method names do not be afraid to break Pep 8 (the official python style guide) conventions as long as it makes sense. In my example above, I have violated the function name convention by naming my test function with snake case with title case, but it was done to increase readability of what this test is actually doing.
Now we can talk about what is going on inside of this test. 1. We import the exception class NotEligibleForPathParams 2. We callassert NotEligibleForPathParams
which would fail the test ifNotEligibleForPathParams
was None.
This is how we can verify the exception exists.Let's add one more test to be sure that the placeholder exists in the doc string, because if someone unknowingly removes the placeholder, we will get an exception when calling
.format()
on the docstring elsewhere in the code.def test_placeholder_exists_in_NotEligibleForPathParams_docstring(): from jira_api_wrapper.api.exceptions import NotEligibleForPathParams assert '{}' in NotEligibleForPathParams.__doc__
This test will help us ensure we don't unknowingly remove code that will break something else.
-
This module will contain the base class that all API classes inherit from.
Any logic that we want to be in every API class should live here. -
import requests from jira_api_wrapper.api.exceptions import * from jira_api_wrapper.api.endpoints import JiraEndpoints class BaseApi(JiraEndpoints): """ Base class to be consumed in all API classes """ def __init__(self, host, user, token): super().__init__(host) self.session = requests.Session() self.session.auth = (user, token) @staticmethod def parse_response(response): response.raise_for_status() return response.json() @staticmethod def set_path_params(endpoint, *args): if '{}' in endpoint: return endpoint.format(*args) else: raise NotEligibleForPathParams( NotEligibleForPathParams.__doc__.format(endpoint) )
-
class BaseApi(JiraEndpoints)
We are defining a class BaseAPI and inheriting JiraEndpionts.def __init__(self, host, user, token):
We are stating that this class requires three parameters to initialize:host, user, token
super().__init__(host)
We are usingsuper()
to run the__init__()
method of the parent class (JiraEndpoints) which requires ahost
argument. In this case, the__init__()
method of the parent class sets theself.endpoint
dictionary.self.session = requests.Session()
callingrequests.Session()
will return a Session object. A Session object is basically a wrapper forrequests
that allows you to persist data.self.session.auth = (user, token)
this will attach a HTTP Header for authorization with every request. That means when we callself.session.get('http://my-domain.com')
we will be implicitly passing a header that looks like this:Authorization: user:token
@staticmethod
The static method decorator means that the method below does not have access to the rest of the class, and does not needself
as the first parameter.response.raise_for_status()
Therequests.Response
object contains some really handy built-in methods. This method will raise an exception if an HTTP error has occurred. It's important to note that therequests
module will catch HTTP errors and store them into the response object. If you're not careful, you may get a bad response and unknowingly access the json. In this scenario, Python will throw an exception complaining that you're trying to access something that does not exist, which may be confusing when the reality is just that you got a bad response back from the server you sent a request to.return response.json()
Now that we know the response was good (we checked using.raise_for_status()
), we are safe to return the json of the response object using the.json()
methoddef set_path_params(endpoint, *args)
Here we are using *args which allows flexibility around how many arguments are passed to this method. Since we are setting path parameters which may vary depending on API endpoint, the use of*args
is appropriate. Make note:*args
should only be used when you have cases like this, where you know you are going to be passing a different amount of arguments. The Python community places a high value on [Explicit is better than implicit](Explicit is better than implicit.).if '{}' in endpoint:
Here we are checking if the string literal"{}"
appears in the url for the endpoint.return endpoint.format(*args)
If the placeholders ("{}"
) were found in the string, use the.format()
method and pass in*args
.raise NotEligibleForPathParams(NotEligibleForPathParams.__doc__.format(endpoint))
We are raising a custom exception, then using the exception's docstring as it's error message. The docstring for the exception has a placeholder ("{}"
) in it, so again we can call.format()
and pass in the relevant data (the url to the endpoint in this case).
-
This module is responsible for holding all the logic for the
fields
endpoints. -
from jira_api_wrapper.api.base_api import BaseApi class JiraFields(BaseApi): def __init__(self, host, user, token): super().__init__(host, user, token) def get_fields(self): response = self.session.get( self.endpoint['get_fields'] ) return self.parse_response(response) def get_all_issue_field_options(self, field_key): response = self.session.get( self.set_path_params( self.endpoint['get_all_issue_field_options'], field_key ) ) return self.parse_response(response)
-
class JiraFields(BaseApi):
We are creating a new class and inheriting from the BaseApi classdef __init__(self, host, user, token):
We are defining our__init__
method, and requiringhost, user, token
to be passed in when initializing this class.super().__init__(host, user, token)
We are usingsuper()
to call the__init__
method of the parent class, BaseApi.response = self.session.get(self.endpoint['get_fields'])
We are making a GET request to theget_fields
endpoint. Because we are using session, the authorization header will be implicitly passed.return self.parse_response(response)
This will return the json from the response objectself.set_path_params(self.endpoint['get_all_issue_field_options'], field_key)
This endpoint uses the path parameters, so we must call theset_path_params
and pass in the url and path parameters.
-
Just like
fields.py
is responsible for containing the logic for thefields
endpoints,issue.py
is responsible for containing the logic for theissue
endpoints.
from jira_api_wrapper.api.base_api import BaseApi
class JiraIssue(BaseApi):
def __init__(self, host, user, token):
super().__init__(host, user, token)
def get_issue(self, issue_id_or_key):
response = self.session.get(
self.set_path_params(
self.endpoint['get_issue'],
issue_id_or_key
)
)
return self.parse_response(response)
-
class JiraIssue(BaseApi)
We are creating a new class for this endpoint, and inheriting fromBaseApi
.- We call
super().__init__(host, user, token)
which will call the__init__
method of the parent class. get_issue
creates a GET request to theget_issue
endpoint, and uses theissue_id_or_key
parameter as the path parameter to construct the URL. If the response was healthy, we will return the JSON.
-
In the good ol' days of Python2,
__init__.py
was required for to "make Python treat the directories as containing packages", which basically means that Python would assume there is no code in a directory if__init__.py
was not present. Python3.3 and later does not have this requirement, but it is still commonly used (mostly because our IDEs place it there for us).__init__.py
Can contain logic but are usually only used to help manage imports. Warning:__init__.py
will be executed upon importing the package. -
from jira_api_wrapper.api.fields.fields import JiraFields from jira_api_wrapper.api.issue.issue import JiraIssue
-
The purpose of this is to have a sane way to manage our imports. Now in other files when we import these classes, instead of having to have each endpoint class on it's own line, we can simply do:
from jira_api_wrapper.api import JiraFields, JiraIssue
-
This module will contain a class that inherits from the api classes we have previously created.
-
from jira_api_wrapper.api import JiraIssue, JiraFields class JiraWrapper(JiraFields, JiraIssue): def __init__(self, host, user, token): super().__init__(host, user, token)
-
This is just the class that ties all the other classes together into one object. Now we can do things like
wrapper = JiraWrapper('http://learn-automation.atlassian.net', 'jonathoncarlyon@gmail.com', 'my-super-secret-token') wrapper.get_fields() wrapper.get_issue('EI-1')
-
from jira_api_wrapper.wrapper.jira_wrapper import JiraWrapper
-
Once again the answer to why
__init__.py
comes down to imports. We know the only thing anybody using this will care about is top level class that inherits from all the other classes, so we can make this easy on the users consuming it.
Now when other projects consume this Python package all they need to write is:
from jira_api_wrapper import JiraWrapper
instead of:
from jira_api_wrapper.wrapper.jira_wrapper import JiraWrapper