From bcec8915c1b463bcff2f0e24dec20b9de7f90461 Mon Sep 17 00:00:00 2001 From: James Ramm Date: Fri, 25 Aug 2017 08:33:47 +0100 Subject: [PATCH] Add to basket template tag & docs (#82) * fixes #61 * Update travis * Update changelog for 0.2 * Add alex as contributor * Docs update * tests in longclawcore package * Remove eggs dir * update ignores * Add last entry to changelog * fixes #79 --- .gitignore | 3 +- .travis.yml | 6 +- AUTHORS.rst | 3 +- CHANGELOG.rst | 10 + docs/usage/checkout.rst | 20 +- docs/walkthrough/checkout.rst | 213 ++++++++++++++++-- docs/walkthrough/install.rst | 24 +- docs/walkthrough/products.rst | 38 +++- .../longclawbasket/add_to_basket.html | 18 ++ .../templatetags/longclawbasket_tags.py | 11 + longclaw/longclawbasket/tests.py | 8 + longclaw/longclawcore/models.py | 2 - longclaw/longclawcore/tests.py | 26 ++- longclaw/tests/settings.py | 1 + tox.ini | 13 +- 15 files changed, 334 insertions(+), 62 deletions(-) create mode 100644 longclaw/longclawbasket/templates/longclawbasket/add_to_basket.html diff --git a/.gitignore b/.gitignore index c8a9439..558440e 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,5 @@ docs/_build webpack-stats.json -*bundle.js* \ No newline at end of file +*bundle.js* +.eggs/ \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 30f66c8..0f436ee 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,7 @@ language: python python: - "3.5" -env: +env: - TOX_ENV=py35-django-18 - TOX_ENV=py34-django-18 - TOX_ENV=py33-django-18 @@ -13,12 +13,14 @@ env: - TOX_ENV=py35-django-19 - TOX_ENV=py34-django-19 - TOX_ENV=py27-django-19 + - TOX_ENV=py35-django-110 + - TOX_ENV=py35-django-111 matrix: fast_finish: true # command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors -install: +install: - . $HOME/.nvm/nvm.sh - nvm install stable - nvm use stable diff --git a/AUTHORS.rst b/AUTHORS.rst index f00d039..529b47a 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -10,4 +10,5 @@ Development Lead Contributors ------------ -None yet. Why not be the first? +* Alex (https://github.com/alexfromvl) + diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9e2551f..0e6b95c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -6,6 +6,16 @@ History 0.2.0 (In Development) ++++++++++++++++++++++ +* Added a template tag for easy 'Add To Basket' buttons +* Added a template tag for shipping rates +* Created a client side Javascript library for the REST API +* We built basic views for Checkout and Basket +* Added template tags to help simplify integration with payment backends +* Basic checkout template in the project_template +* Bug fixes around payment gateway integrations +* Created a standard address form +* Pushed test coverage past 80% + 0.1.1 (2017-04-14) +++++++++++++++++++ diff --git a/docs/usage/checkout.rst b/docs/usage/checkout.rst index 7315708..37f00d7 100644 --- a/docs/usage/checkout.rst +++ b/docs/usage/checkout.rst @@ -3,18 +3,18 @@ Checkout ======== -Longclaw provides a simple, single checkout view. +Longclaw provides a simple, single checkout view. The URL for the checkout is ``'checkout/'``. After a successful checkout, it is redirected to ``checkout/success/``. To implement the checkout, simply provide ``'longclawcheckout/checkout.html'`` and -``'longclawcheckout/success.html'`` templates. (Empty templates will have been created if +``'longclawcheckout/success.html'`` templates. (Empty templates will have been created if you ran the longclaw CLI to start your project) There are three forms provided in the checkout view: :checkout_form: - Captures the email address and optionally the shipping option for the checkout. + Captures the email address and optionally the shipping option for the checkout. Also captures a boolean indicating whether a different billing address should be used :shipping_form: @@ -24,13 +24,13 @@ There are three forms provided in the checkout view: A second address form for capturing alternate billing information. If you do not submit this form (e.g. by not rendering it on the template), the billing and shipping addresses are assumed to be the same. -Generally, you may need to use a little javascript to optionally render the form if the user selects +Generally, you may need to use a little javascript to optionally render the form if the user selects 'different billing address'. Shipping Options and Javascript -------------------------------- -The shipping option dropdown has no options by default - this is because it is dependent on the shipping country. +The shipping option dropdown has no options by default - this is because it is dependent on the shipping country. The checkout form includes the necessary javascript to do this - you just need to include it on the page. You will typically also need to include your chosen payment gateways' client javascript: @@ -46,7 +46,7 @@ You will typically also need to include your chosen payment gateways' client jav - + The first half uses the ``gateway_client_js`` template tag to load all the payment gateway javascript. There may be one or more. The second half has three parts to it: @@ -68,8 +68,10 @@ It is up to you to render a payment form and then pass the token in the POST dat Normally, the payment gateway chosen will have a javascript integration to render a form for you and tokenize the payment method (e.g. braintrees 'hosted fields') -Longclaws' payment gateways provide some helpful utilities to load client javascript and generate tokens. +Longclaws' payment gateways provide some helpful utilities to load client javascript and generate tokens. Loading ``longclawcheckout_tags`` in your template will allow you to retrieve the gateways' javascript libraries as script tags (``{% gateway_client_js %}`` and generate a client token (``{% gateway_token %}``). -A little javascript is then required to setup your form and ask the gateway to tokenize the payment method for you. -You should then add this token to the request POST data (e.g. with a hidden input field). +A little javascript is then required to setup your form and ask the gateway to tokenize the payment method for you. +You should then add this token to the request POST data (e.g. with a hidden input field). + +For in-depth info on integration, see the walkthrough. diff --git a/docs/walkthrough/checkout.rst b/docs/walkthrough/checkout.rst index 5f9d6f8..9f12350 100644 --- a/docs/walkthrough/checkout.rst +++ b/docs/walkthrough/checkout.rst @@ -1,10 +1,10 @@ +.. checkout-walkthrough: - -Checkout with Paypal Express +Checkout with Braintree ============================ Longclaw offers integration with a few payment gateways and it is also fairly easy to integrate your own. -For this tutorial, we will use Paypal Express Checkout to process payments. +For this tutorial, we will use Braintree to process payments. Settings and Dependencies ------------------------- @@ -13,10 +13,17 @@ The payment gateway to use must be set in the settings file: .. code-block:: python - PAYMENT_GATEWAY = 'longclaw.longclawcheckout.gateways.braintree.PaypalVZeroPayment' + PAYMENT_GATEWAY = 'longclaw.longclawcheckout.gateways.braintree.BraintreePayment' -The ``PaypalVZeroPayment`` class will allow us to take payments using Paypal Express Checkout and is dependent on the -braintree SDK. + +We also need to define settings for access tokens; + +.. code-block:: python + + BRAINTREE_SANDBOX = False + BRAINTREE_MERCHANT_ID = os.environ['BRAINTREE_MERCHANT_ID'] + BRAINTREE_PUBLIC_KEY = os.environ['BRAINTREE_PUBLIC_KEY'] + BRAINTREE_PRIVATE_KEY = os.environ['BRAINTREE_PRIVATE_KEY'] We will need to install this SDK as it is not an explicit dependency of longclaw:: @@ -27,16 +34,182 @@ That is all we need to do to configure our backend! Front end integration --------------------- +We will first show how to setup a checkout page using the Checkout view provided by longclaw. +The code shown here is very similar to the implementation of the checkout page here: `Ramshackle Audio`_ + +First, we should load some templatetags which will help us: + +.. code-block:: django + + {% load longclawcheckout_tags longclawcore_tags %} + +As an aside - you may wish to display the items in the basket on our checkout page. The basket items queryset is available as ``basket`` +in the views' context. + +Next, we need to setup the forms to gather customer information. There are 2 forms in the context. We will +display and submit them as a single form. Here is an example layout: + +.. code-block:: django + +
+ {% csrf_token %} + {% for field in shipping_form %} + {% if field.is_hidden %} + {{ field }} + {% else %} + {% if field.errors %} +
+ {% else %} +
+ {% endif %} + + {{ field }} + {% if field.help_text %} +

{{ field.help_text|safe }}

+ {% endif %} +
+

{{ field.errors }}

+
+
+ {% endif %} + {% endfor %} + {% for field in checkout_form %} + + {% if field.name == 'different_billing_address' %} + {% else %} + {% if field.errors %} +
+ {% else %} +
+ {% endif %} + + {{ field }} +
+ {% for error in field.errors %} +

{{ error }}

+ {% endfor %} +
+
+ {% endif %} + {% endfor %} + +You may wish to layout the form differently. We have purposefully ignored the ``different_billing_address`` field +since the Braintree dropin-ui will collect a billing postcode anyway, for its' own security checks. + +Before we close our `` element, there are 3 further items to add: + +.. code-block:: django + + + +

Payment Details

+
+ + + +We add a hidden field. This field will contain a token (string of characters) given by braintree which represents the payment method. +Most payment gateways require something like this, although the name of the field will change between backends. + +We then add an empty div with the id ``dropin-container``. This will contain the Braintree Dropin UI. +We could manually create the fields (using e.g. Hosted Fields for braintree or Elements for stripe) for payment forms, however +most integrations offer some sort of 'dropin' which are increasingly customisable. For most purposes, this will suffice. + +Finally, we add a submit button. + +The Javascript +*************** + +OK, so now we have hidden elements, empty containers....we need to get this stuff populated! +Each payment gateway integration provides the necessary javascript libraries to interact with the gateway. +They are made available via a template tag. +Add them like this: + +.. code-block:: django + + + {% gateway_client_js as scripts %} + {% for js in scripts %} + {{ js|safe }} + {% endfor %} + + + {{ checkout_form.media }} + +The checkout form also provides a little javascript to initialise shipping options (when the user selects a shipping country). + +Finally, we need to add a little of our own javascript to create the braintree dropin: + +.. code-block:: django + + + +Two things are happening in the above code. First, we initialise the shipping options. Note we are using a template tag +to pass the longclaw API url prefix, since this is customisable in your settings.py + +Secondly, we initialise the braintree dropin. Again, we use a template tag to get a token for the gateway. +All payment backends provide the ``gateway_token`` template tag, although it is not always necessary. + +You may wish to only show the braintree payment form if the user has anything in their basket. In which case you might qualify +the above javascript with ``{% if basket.count > 0 %}`` in your template. + +As you can see, setting up the checkout is one of the most involved aspects of creating your store. We have worked to simplify this +for v0.2, but welcome any suggestions on how to make it easier! + +If you wish to forego the templatetags & forms (e.g. if making a fully React-based frontend), read on. Otherwise, that is the end of the tutorial! + + +Javascript-Only integration +---------------------------- + +Below is a walkthrough of integrating a payment gateway (PayPal) without the aid of templatetags etc.. + There is a fair amount of work to do to setup the front end when using any payment gateway. Paypal Express minimises this for us by taking charge of collecting and tokenizing payment data, although we -must still configure it. +must still configure it. The basic client payment flow with Braintree is as follows: 1. The client requests a braintree token. Longclaw provides an API endpoint to generate tokens using the braintree SDK 2. The client gathers payment details and turns this into a `payment method nonce` by interacting with the braintree server. Paypal Express Checkout will take care of this entirely. -3. The client submits the `payment method nonce` to the server to capture the payment. Longclaw provides an API endpoint for all payment captures. +3. The client submits the `payment method nonce` to the server to capture the payment. Longclaw provides an API endpoint for all payment captures. We therefore have three things we need to do in our client-side javascript: @@ -51,9 +224,9 @@ We therefore have three things we need to do in our client-side javascript: } }) -2. Following this, configure the paypal express checkout functionality. This actually has two steps. - We must first create a braintree `client` using our new token. We then use this to create a braintree - `paypal` instance. +2. Following this, configure the paypal express checkout functionality. This actually has two steps. + We must first create a braintree `client` using our new token. We then use this to create a braintree + `paypal` instance. .. code-block:: javascript @@ -70,20 +243,20 @@ We therefore have three things we need to do in our client-side javascript: if (err) { console.log("handle error creating paypal"); return; - } + } console.log("Paypal instance": paypalInstance); - }); + }); }); -3. Once paypal has created the `nonce` for the entered payment details, we must submit this - to our server so longclaw can capture the payment. +3. Once paypal has created the `nonce` for the entered payment details, we must submit this + to our server so longclaw can capture the payment. To do this, we must have a button which we want to use to launch the paypal express checkout window. We 'attach' the paypal instance we just created to the button like so: .. code-block:: javascript paypalButton.addEventListener( - 'click', + 'click', function (){ paypalInstance.tokenize({ flow: 'checkout', @@ -161,7 +334,7 @@ We can make all these nested API calls simpler if we use ES6 Promises and the fe } // This is where we actually setup paypal - export function setupBraintreePaypal(totalAmount, + export function setupBraintreePaypal(totalAmount, paypalButton, shippingAddress, shippingRate, @@ -173,7 +346,7 @@ We can make all these nested API calls simpler if we use ES6 Promises and the fe return getToken() .then(data => braintreeClientCreate(data.token)) .then(client => braintreePaypalCreate(client)) - .then(paypalInstance => paypalButton.addEventListener('click', + .then(paypalInstance => paypalButton.addEventListener('click', function (){ paypalInstance.tokenize({ flow: 'checkout', @@ -206,7 +379,7 @@ We can make all these nested API calls simpler if we use ES6 Promises and the fe let contentType = 'application/json'; const headers = { Accept: 'application/json, application/json, application/coreapi+json', - + }; if (!form) headers['Content-Type'] = contentType; const csrf = JsCookie.get('csrftoken'); @@ -239,4 +412,4 @@ We can make all these nested API calls simpler if we use ES6 Promises and the fe } The total amount, shipping address, shipping rate and email address of the customer are passed into the setup function; -it is up to the front end developer to create the necessary forms to gather these. +it is up to the front end developer to create the necessary forms to gather these. diff --git a/docs/walkthrough/install.rst b/docs/walkthrough/install.rst index 07548f9..15fbd80 100644 --- a/docs/walkthrough/install.rst +++ b/docs/walkthrough/install.rst @@ -20,8 +20,7 @@ Install Longclaw into it: (my_project) $ pip install longclaw We also need to install the client library for our payment gateway integration. We are going to -use Paypal as our payment gateway in this walkthrough. To make things easy, we will use Paypal -Express Checkout. For this we can use the Braintree SDK: +use Braintree as our payment gateway in this walkthrough. .. code-block:: bash @@ -48,18 +47,25 @@ Now we have a django project which looks like this:: requirements.txt The ``home`` and ``search`` folders are default folders used in Wagtail projects. Users of Wagtail -will be familiar with these. -The ``products`` folder contains a skeleton model for our product `variants` which we will come to later. +will be familiar with these. +The ``products`` folder contains a skeleton model for our product `variants` which we will come to later. Before proceeding, we need to setup our ``settings`` file, in ``my_shop/settings/base.py``. We need to configure which payment gateway we are using. Change the entry for ``PAYMENT_GATEWAY`` from -``'longclaw.longclawcheckout.gateways.BasePayment'`` to ``'longclaw.longclawcheckout.gateways.PaypalVZero'`` +``'longclaw.longclawcheckout.gateways.BasePayment'`` to ``'longclaw.longclawcheckout.gateways.braintree.BraintreePayment'`` -We also need to set the access token. The setting for this is ``VZERO_ACCESS_TOKEN``. Paypal access tokens -are termed something like ``access_token$sandbox`` followed by a sequence of characters. As we have different -access tokens for sandbox and live accounts, we will set ``VZERO_ACCESS_TOKEN`` in ``my_shop/settings/dev.py`` -for the sandbox account and ``my_shop/settings/production.py`` for the live account. +We also need to set the access tokens for the braintree backend. Add the following settings: + +.. codeblock:: python + + BRAINTREE_SANDBOX = False + BRAINTREE_MERCHANT_ID = os.environ['BRAINTREE_MERCHANT_ID'] + BRAINTREE_PUBLIC_KEY = os.environ['BRAINTREE_PUBLIC_KEY'] + BRAINTREE_PRIVATE_KEY = os.environ['BRAINTREE_PRIVATE_KEY'] + +For development/testing, you will probably want to set ``BRAINTREE_SANDBOX`` to ``True``. The above settings assume that +you have set environment variables on your OS with the access tokens. .. note: Don't forget that Longclaw is a Wagtail project. You may need to configure additional settings for wagtail. diff --git a/docs/walkthrough/products.rst b/docs/walkthrough/products.rst index 2a764ed..a9698f9 100644 --- a/docs/walkthrough/products.rst +++ b/docs/walkthrough/products.rst @@ -6,7 +6,7 @@ Managing the Catalogue Creating the Product Index -------------------------- Wagtails' ``Page`` models are organized in a tree structure. All our ``Product`` pages will therefore -need a parent. This is provided by the ``ProductIndex`` model. +need a parent. This is provided by the ``ProductIndex`` model. .. note:: Read more about Wagtail pages in the `Wagtail docs `_ @@ -25,7 +25,7 @@ We can now add ``Product`` models as children of ``ProductIndex``. Only pages of Adding a Product ---------------- -Under the explorer homepage, we should now see our newly created ``ProductIndex``. We can select ``Add child page`` to add our first +Under the explorer homepage, we should now see our newly created ``ProductIndex``. We can select ``Add child page`` to add our first ``Product``. The ``Product`` model is fairly minimal. It has: - A title @@ -40,10 +40,10 @@ Customising Variants -------------------- The ``ProductVariant`` model is where we can customise the attributes of our model. Running ``longclaw start`` -provided a ``products`` with a minimal implementation of a custom ``ProductVariant`` model. +provided a ``products`` with a minimal implementation of a custom ``ProductVariant`` model. We can further customise this now by opening ``my_shop/products/models.py`` in a text editor. -``ProductVariant`` inherits from ``ProductVariantBase`` which provides the ``price``, ``ref`` and ``slug`` fields. +``ProductVariant`` inherits from ``ProductVariantBase`` which provides the ``price``, ``ref`` and ``slug`` fields. The ``ref`` field is intended to be used as a short description or sub-title to help distinguish a particular variant. The ``slug`` field is autogenerated from the ``ref`` and the parent ``Product`` title. @@ -51,7 +51,7 @@ The ``slug`` field is autogenerated from the ``ref`` and the parent ``Product`` As we are creating a music shop, we are going to add a ``music_format`` field to the model. We will also remove the ``description`` field as we dont have any real need for it at the moment: -.. code:: python +.. code-block:: python class ProductVariant(ProductVariantBase): _MUSIC_FORMAT_CHOICES = ( @@ -59,7 +59,7 @@ remove the ``description`` field as we dont have any real need for it at the mom (2, 'Vinyl'), ) - music_format = models.IntegerField(max_length=3, choices=_MUSIC_FORMAT_CHOICES) + music_format = models.IntegerField(max_length=3, choices=_MUSIC_FORMAT_CHOICES) After making and running migrations, we can now select the format for each variant: @@ -84,12 +84,25 @@ For a more complete template, take a look at the `demo project ` allows all such interactions and all front end design decisions such as these are left up to the developer \ No newline at end of file +quantity and so on. The :ref:`basket API ` allows all such interactions and all front end design decisions such as these are left up to the developer. +It is worthwhile looking at the longclaw demo source code to see how e.g. a basket & item count in the page header is implemented. diff --git a/longclaw/longclawbasket/templates/longclawbasket/add_to_basket.html b/longclaw/longclawbasket/templates/longclawbasket/add_to_basket.html new file mode 100644 index 0000000..ef9a9b2 --- /dev/null +++ b/longclaw/longclawbasket/templates/longclawbasket/add_to_basket.html @@ -0,0 +1,18 @@ +{% load longclawcore_tags %} + +{% longclaw_vendors_bundle %} +{% longclaw_client_bundle %} + \ No newline at end of file diff --git a/longclaw/longclawbasket/templatetags/longclawbasket_tags.py b/longclaw/longclawbasket/templatetags/longclawbasket_tags.py index 0d8791a..3366348 100644 --- a/longclaw/longclawbasket/templatetags/longclawbasket_tags.py +++ b/longclaw/longclawbasket/templatetags/longclawbasket_tags.py @@ -10,3 +10,14 @@ def basket(context): ''' items, _ = get_basket_items(context["request"]) return items + + +@register.inclusion_tag('longclawbasket/add_to_basket.html') +def add_to_basket_btn(variant_id, btn_class="btn btn-default", btn_text="Add To Basket"): + '''Button to add an item to the basket + ''' + return { + 'btn_class': btn_class, + 'variant_id': variant_id, + 'btn_text': btn_text + } diff --git a/longclaw/longclawbasket/tests.py b/longclaw/longclawbasket/tests.py index 123e9e4..1934e6d 100644 --- a/longclaw/longclawbasket/tests.py +++ b/longclaw/longclawbasket/tests.py @@ -4,6 +4,7 @@ from django.core.urlresolvers import reverse from longclaw.tests.utils import LongclawTestCase, BasketItemFactory, ProductVariantFactory from longclaw.longclawbasket.utils import basket_id +from longclaw.longclawbasket.templatetags import longclawbasket_tags class BasketTest(LongclawTestCase): @@ -51,6 +52,13 @@ class BasketTest(LongclawTestCase): self.assertEqual(response.status_code, 400) + def test_add_to_cart_btn(self): + '''Test the add to cart tag responds + ''' + result = longclawbasket_tags.add_to_basket_btn(1) + self.assertIsNotNone(result) + + class BasketModelTest(TestCase): def setUp(self): diff --git a/longclaw/longclawcore/models.py b/longclaw/longclawcore/models.py index 71a8362..6b20219 100644 --- a/longclaw/longclawcore/models.py +++ b/longclaw/longclawcore/models.py @@ -1,3 +1 @@ -from django.db import models - # Create your models here. diff --git a/longclaw/longclawcore/tests.py b/longclaw/longclawcore/tests.py index 7ce503c..57482cb 100644 --- a/longclaw/longclawcore/tests.py +++ b/longclaw/longclawcore/tests.py @@ -1,3 +1,27 @@ +import os from django.test import TestCase +from django.contrib.staticfiles import finders -# Create your tests here. +from longclaw import settings +from longclaw.longclawcore.templatetags import longclawcore_tags + +class TagTests(TestCase): + + def _test_static_file(self, pth): + result = finders.find(pth) + print(result) + self.assertTrue(result) + + def test_vendors_bundle(self): + ctx = longclawcore_tags.longclaw_vendors_bundle() + self._test_static_file(ctx['path']) + + def test_client_bundle(self): + ctx = longclawcore_tags.longclaw_client_bundle() + self._test_static_file(ctx['path']) + + def test_api_url_prefix(self): + self.assertEqual( + settings.API_URL_PREFIX, + longclawcore_tags.longclaw_api_url_prefix() + ) diff --git a/longclaw/tests/settings.py b/longclaw/tests/settings.py index 755a895..0b65560 100644 --- a/longclaw/tests/settings.py +++ b/longclaw/tests/settings.py @@ -45,6 +45,7 @@ INSTALLED_APPS = [ 'rest_framework', 'django_extensions', + 'longclaw.longclawcore', 'longclaw.longclawsettings', 'longclaw.longclawshipping', 'longclaw.longclawproducts', diff --git a/tox.ini b/tox.ini index 08a5dba..1b9e648 100644 --- a/tox.ini +++ b/tox.ini @@ -1,20 +1,23 @@ [tox] envlist = - {py27,py33,py34,py35}-django-18 - {py27,py34,py35}-django-19 - {py27,py34,py35}-django-110 + {py27,py33,py34,py35,py36}-django-18 + {py27,py34,py35,py36}-django-19 + {py27,py34,py35,py36}-django-110 + {py27,py34,py35,py36}-django-111 [testenv] setenv = PYTHONPATH = {toxinidir}:{toxinidir}/longclaw commands = coverage run --source longclaw runtests.py - coverage xml --omit=*/apps.py,*/migrations/*,*/__init__.py,*/gateways/braintree.py,*/gateways/stripe.py + coverage xml --omit=*/apps.py,*/migrations/*,*/__init__.py,*/gateways/braintree.py,*/gateways/stripe.py,*/bin/longclaw.py deps = django-18: Django>=1.8,<1.9 django-19: Django>=1.9,<1.10 - django-110: Django>=1.10 + django-110: Django>=1.10,<1.11 + django-111: Django>=1.11 -r{toxinidir}/requirements_test.txt basepython = + py36: python3.6 py35: python3.5 py34: python3.4 py33: python3.3