add Python3 and django 1.11 support (fixes #30321)

Also remove the sample application.
This commit is contained in:
Benjamin Dauvergne 2019-02-02 11:04:09 +01:00
parent 61a8167e9e
commit e8a1a7221c
22 changed files with 319 additions and 230 deletions

50
Jenkinsfile vendored Normal file
View File

@ -0,0 +1,50 @@
@Library('eo-jenkins-lib@master') import eo.Utils
pipeline {
agent any
options { disableConcurrentBuilds() }
stages {
stage('Unit Tests') {
steps {
sh """
rm -rf htmlcov* .coverage* coverage* junit*.xml
rm -rf venv
virtualenv -p python3 venv
. venv/bin/activate
pip install tox"""
sh './venv/bin/tox -rv'
}
post {
always {
script {
utils = new Utils()
utils.publish_coverage('coverage.xml')
utils.publish_coverage_native('index.html')
utils.publish_pylint('pylint.out')
}
junit 'junit*.xml'
}
}
}
stage('Packaging') {
steps {
script {
if (env.JOB_NAME == 'django-kerberos' && env.GIT_BRANCH == 'origin/master') {
sh 'sudo -H -u eobuilder /usr/local/bin/eobuilder django-kerberos'
}
}
}
}
}
post {
always {
script {
utils = new Utils()
utils.mail_notify(currentBuild, env, 'admin+jenkins-django-kerberos@entrouvert.com')
}
}
success {
cleanWs()
}
}
}

2
README
View File

@ -3,6 +3,8 @@ Kerberos authentication for Django
Provide Kerberos authentication to Django applications.
Python 2 and 3, Django >1.8 are supported.
Basic usage
===========

13
debian/control vendored
View File

@ -1,5 +1,5 @@
Source: python-django-kerberos
Maintainer: Benjamin Dauvergne <info@entrouvert.com>
Maintainer: Benjamin Dauvergne <bdauvergne@entrouvert.com>
Section: python
Priority: optional
Build-Depends: python-setuptools (>= 0.6b3), python-all (>= 2.6), debhelper (>= 7.4.3),
@ -10,7 +10,16 @@ X-Python-Version: >= 2.6
Package: python-django-kerberos
Architecture: all
Depends: ${misc:Depends}, ${python:Depends},
python-django (>= 1.5),
python-six,
python-django (>= 1.8),
python-kerberos
Description: Kerberos authentication frontend for Authentic2
Package: python3-django-kerberos
Architecture: all
Depends: ${misc:Depends}, ${python3:Depends},
python3-six,
python3-django (>= 1.8),
python3-kerberos
Description: Kerberos authentication frontend for Authentic2

13
pylint.sh Executable file
View File

@ -0,0 +1,13 @@
#!/bin/sh
set -e -x
env
if [ -f /var/lib/jenkins/pylint.django.rc ]; then
PYLINT_RC=/var/lib/jenkins/pylint.django.rc
elif [ -f pylint.django.rc ]; then
PYLINT_RC=pylint.django.rc
else
echo No pylint RC found
exit 0
fi
pylint -f parseable --rcfile ${PYLINT_RC} "$@" | tee pylint.out || /bin/true

View File

@ -1 +0,0 @@
Rename config.py.example to config.py, and update default settings.

View File

@ -1,3 +0,0 @@
KERBEROS_SERVICE_PRINCIPAL = 'HTTP/fenouil.entrouvert.org@ENTROUVERT.ORG'
KERBEROS_DEFAULT_REALM = 'ENTROUVERT.ORG'
KERBEROS_BACKEND_ADMIN_REGEXP = '^.*/admin@.*$'

View File

@ -1,10 +0,0 @@
#!/usr/bin/env python
import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sample.settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)

View File

@ -1,98 +0,0 @@
"""
Django settings for sample project.
For more information on this file, see
https://docs.djangoproject.com/en/1.6/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.6/ref/settings/
"""
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import os
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.6/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 's0xb*2mi0#pi48tri&x6cwr96k30mmdu%e6pa28_=n9^4eh-3='
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
TEMPLATE_DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = (
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django_kerberos',
)
MIDDLEWARE_CLASSES = (
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
)
AUTHENTICATION_BACKENDS = (
'django_kerberos.backends.KerberosBackend',
'django_kerberos.backends.KerberosPasswordBackend',
)
ROOT_URLCONF = 'sample.urls'
WSGI_APPLICATION = 'sample.wsgi.application'
TEMPLATE_DIRS = ( os.path.join(BASE_DIR, 'sample', 'templates'), )
# Database
# https://docs.djangoproject.com/en/1.6/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}
# Internationalization
# https://docs.djangoproject.com/en/1.6/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.6/howto/static-files/
STATIC_URL = '/static/'
LOGIN_URL = 'kerberos-login'
LOGIN_REDIRECT_URL = '/'
KERBEROS_BACKEND_CREATE = True
KERBEROS_BACKEND_ADMIN_REGEXP = r'^.*/admin$'
execfile('config.py', globals())

View File

@ -1,7 +0,0 @@
<!DOCTYPE html>
<html>
<body>
{% block content %}
{% endblock %}
</body>
</html>

View File

@ -1,14 +0,0 @@
{% extends "base.html" %}
{% block content %}
{% include "django_kerberos/autologin.html" %}
{% if user.is_authenticated %}
<p>Hello {{ user }}</p>
{% if user.is_superuser %}
<p><a href="/admin">Admin</a></p>
{% endif %}
<p><a href="{% url "logout" %}?next=/">Logout</a></p>
{% else %}
<p>Please <a href="{% url "kerberos-login" %}">login with kerberos</a> or with your <a href="{% url "login" %}">password</a></p>
{% endif %}
{% endblock %}

View File

@ -1,9 +0,0 @@
{% extends "base.html" %}
{% block content %}
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button>Login</button>
</form>
{% endblock %}

View File

@ -1,16 +0,0 @@
from django.conf.urls import patterns, include, url
from django.contrib import admin
admin.autodiscover()
from . import views
urlpatterns = patterns('',
# Examples:
url(r'^$', views.home, name='home'),
# url(r'^blog/', include('blog.urls')),
url('^accounts/kerberos/', include('django_kerberos.urls')),
url('^accounts/', include('django.contrib.auth.urls')),
url(r'^admin/', include(admin.site.urls)),
)

View File

@ -1,4 +0,0 @@
from django.shortcuts import render
def home(request):
return render(request, 'index.html')

View File

@ -1,14 +0,0 @@
"""
WSGI config for sample project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/1.6/howto/deployment/wsgi/
"""
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sample.settings")
from django.core.wsgi import get_wsgi_application
application = get_wsgi_application()

View File

@ -9,7 +9,7 @@ from setuptools.command.sdist import sdist
class eo_sdist(sdist):
def run(self):
print "creating VERSION file"
print("creating VERSION file")
if os.path.exists('VERSION'):
os.remove('VERSION')
version = get_version()
@ -17,35 +17,41 @@ class eo_sdist(sdist):
version_file.write(version)
version_file.close()
sdist.run(self)
print "removing VERSION file"
print("removing VERSION file")
if os.path.exists('VERSION'):
os.remove('VERSION')
def get_version():
'''Use the VERSION, if absent generates a version with git describe, if not
tag exists, take 0.0.0- and add the length of the commit log.
tag exists, take 0.0- and add the length of the commit log.
'''
if os.path.exists('VERSION'):
with open('VERSION', 'r') as v:
return v.read()
if os.path.exists('.git'):
p = subprocess.Popen(['git', 'describe', '--dirty', '--match=v*'], stdout=subprocess.PIPE,
p = subprocess.Popen(['git', 'describe', '--dirty=.dirty','--match=v*'], stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
result = p.communicate()[0]
if p.returncode == 0:
result = result.split()[0][1:]
result = result.decode('ascii').strip()[1:] # strip spaces/newlines and initial v
if '-' in result: # not a tagged version
real_number, commit_count, commit_hash = result.split('-', 2)
version = '%s.post%s+%s' % (real_number, commit_count, commit_hash)
else:
version = result
return version
else:
result = '0.0.0-%s' % len(subprocess.check_output(
['git', 'rev-list', 'HEAD']).splitlines())
return result.replace('-', '.').replace('.g', '+g')
return '0.0.0'
return '0.0.post%s' % len(
subprocess.check_output(
['git', 'rev-list', 'HEAD']).splitlines())
return '0.0'
setup(name="django-kerberos",
version=get_version(),
license="AGPLv3 or later",
description="Kerberos authentication for Django",
long_description=file('README').read(),
long_description=open('README').read(),
url="http://dev.entrouvert.org/projects/authentic/",
author="Entr'ouvert",
author_email="info@entrouvert.org",
@ -53,6 +59,7 @@ setup(name="django-kerberos",
maintainer_email="bdauvergne@entrouvert.com",
packages=find_packages('src'),
install_requires=[
'six',
'django>1.5',
'pykerberos',
],

View File

@ -17,7 +17,7 @@
import re
import logging
from . import app_settings
import six
from django.core.exceptions import ImproperlyConfigured
from django.utils.encoding import force_bytes
@ -27,6 +27,8 @@ from django.contrib.auth.backends import ModelBackend
import kerberos
from . import app_settings
class KerberosBackend(ModelBackend):
def __init__(self):
@ -74,7 +76,7 @@ class KerberosBackend(ModelBackend):
self.provision_user(principal, user)
return user
def authenticate(self, principal=None, **kwargs):
def authenticate(self, request=None, principal=None):
if principal and self.authorize_principal(principal):
return self.lookup_user(principal)
@ -102,22 +104,26 @@ class KerberosPasswordBackend(KerberosBackend):
' set')
return app_settings.SERVICE_PRINCIPAL
def authenticate(self, username=None, password=None, **kwargs):
def authenticate(self, request=None, username=None, password=None):
'''Verify username and password using Kerberos'''
if not username:
return
principal = force_bytes(self.principal_from_username(username))
password = force_bytes(password)
kerb_principal = principal = self.principal_from_username(username)
kerb_password = password
if six.PY2:
kerb_principal = force_bytes(kerb_principal)
kerb_password = force_bytes(kerb_principal)
try:
if not kerberos.checkPassword(principal, password,
if not kerberos.checkPassword(kerb_principal, kerb_password,
self.service_principal(),
self.default_realm()):
return
except kerberos.KrbError as e:
logging.getLogger(__name__).error(
'password validation forprincipal %r failed %s', principal, e)
'password validation for principal %r failed %s', principal, e)
return
else:
if principal and self.authorize_principal(principal):

View File

@ -14,10 +14,10 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.conf.urls import patterns, url
from django.conf.urls import url
from . import views
urlpatterns = patterns('',
urlpatterns = [
url(r'^login/$', views.login, name='kerberos-login'),
)
]

View File

@ -52,7 +52,7 @@ class NegotiateView(View):
'''Do something with the principal we received'''
self.logger.info(u'got ticket for principal %s', self.principal)
user = authenticate(principal=self.principal)
next_url = request.REQUEST.get(self.NEXT_URL_FIELD) or settings.LOGIN_REDIRECT_URL
next_url = request.POST.get(self.NEXT_URL_FIELD) or request.GET.get(self.NEXT_URL_FIELD) or settings.LOGIN_REDIRECT_URL
if user:
self.login_user(request, user)
if request.is_ajax():
@ -88,8 +88,8 @@ class NegotiateView(View):
# ensure context is finalized
try:
if result != 1:
self.logger.warning(u'authGSSServerInit result is non-zero: %s', result)
details = u'authGSSServerInit result is non-zero: %s' % result
self.logger.warning(u'authGSSServerInit result is non-one: %s', result)
details = u'authGSSServerInit result is non-one: %s' % result
return TemplateResponse(request, self.error_template_name,
context={'details': details}, status=500)
try:

View File

@ -1,5 +1,42 @@
import django
import os.path
DATABASES = {
'default': {
'ENGINE': os.environ.get('DB_ENGINE', 'django.db.backends.sqlite3'),
}
}
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(os.path.dirname(__file__), 'templates')],
'APP_DIRS': True,
},
]
if django.VERSION < (1, 10):
MIDDLEWARE_CLASSES = (
'django.middleware.common.CommonMiddleware',
'django.middleware.http.ConditionalGetMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
)
else:
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
]
INSTALLED_APPS = (
'django.contrib.contenttypes',
'django.contrib.auth',
@ -9,3 +46,8 @@ INSTALLED_APPS = (
TEMPLATE_DIRS = (os.path.join(os.path.dirname(__file__), 'templates'),)
ROOT_URLCONF = 'django_kerberos.urls'
SECRET_KEY = 'xxx'
AUTHENTICATION_BACKENDS = (
'django_kerberos.backends.KerberosBackend',
'django_kerberos.backends.KerberosPasswordBackend',
)

View File

@ -1,6 +1,9 @@
import logging
import pytest
import json
import kerberos
from django.contrib.auth.models import User
@pytest.fixture
@ -11,7 +14,8 @@ def kerberos_mock(request, mocker):
'kerberos.authGSSServerStep',
'kerberos.authGSSServerResponse',
'kerberos.authGSSServerUserName',
'kerberos.authGSSServerClean'
'kerberos.authGSSServerClean',
'kerberos.checkPassword'
)
for name in d:
if hasattr(request, 'param') and name in request.param:
@ -29,9 +33,143 @@ def test_login_no_header(client, settings, kerberos_mock):
@pytest.mark.parametrize('kerberos_mock', ['kerberos.authGSSServerInit'], indirect=True)
def test_login_missing_keytab(client, settings, kerberos_mock, caplog):
resp = client.get('/login/', HTTP_AUTHORIZATION='Negotiate coin')
for key, mock in kerberos_mock.iteritems():
for key, mock in kerberos_mock.items():
assert mock.call_count == 0
assert 'keytab problem' in resp.content
assert 'keytab problem' in caplog.text()
assert b'keytab problem' in resp.content
assert 'keytab problem' in caplog.text
def test_login(client, db, settings, kerberos_mock, caplog):
caplog.set_level(logging.INFO)
kerberos_mock['kerberos.authGSSServerInit'].side_effect = kerberos.KrbError('coin')
response = client.get('/login/', HTTP_AUTHORIZATION='Negotiate xxxx')
assert response.status_code == 500
assert b'exception during authGSSServerInit' in response.content
assert 'exception during authGSSServerInit' in caplog.text
assert b'coin' in response.content
kerberos_mock['kerberos.authGSSServerInit'].side_effect = None
caplog.clear()
kerberos_mock['kerberos.authGSSServerInit'].return_value = 0, None
response = client.get('/login/', HTTP_AUTHORIZATION='Negotiate xxxx')
assert response.status_code == 500
assert b'authGSSServerInit result is non-one' in response.content
assert 'authGSSServerInit result is non-one' in caplog.text
caplog.clear()
kerberos_mock['kerberos.authGSSServerInit'].return_value = 1, None
kerberos_mock['kerberos.authGSSServerStep'].side_effect = kerberos.KrbError('coin')
response = client.get('/login/', HTTP_AUTHORIZATION='Negotiate xxxx')
assert response.status_code == 500
assert b'exception during authGSSServerStep' in response.content
assert 'exception during authGSSServerStep' in caplog.text
assert b'coin' in response.content
kerberos_mock['kerberos.authGSSServerStep'].side_effect = None
caplog.clear()
kerberos_mock['kerberos.authGSSServerStep'].return_value = 0
response = client.get('/login/', HTTP_AUTHORIZATION='Negotiate xxxx')
assert response.status_code == 401
kerberos_mock['kerberos.authGSSServerStep'].return_value = 1
kerberos_mock['kerberos.authGSSServerUserName'].side_effect = kerberos.KrbError('coin')
response = client.get('/login/', HTTP_AUTHORIZATION='Negotiate xxxx')
assert response.status_code == 500
assert b'exception during authGSSServerUserName' in response.content
assert 'exception during authGSSServerUserName' in caplog.text
assert b'coin' in response.content
kerberos_mock['kerberos.authGSSServerUserName'].side_effect = None
caplog.clear()
kerberos_mock['kerberos.authGSSServerUserName'].return_value = 'john.doe@EXAMPLE.COM'
kerberos_mock['kerberos.authGSSServerResponse'].return_value = 'yyyy'
response = client.get('/login/', HTTP_AUTHORIZATION='Negotiate xxxx')
assert response.status_code == 302
assert 'principal john.doe@EXAMPLE.COM has no local user' in caplog.text
caplog.clear()
user = User.objects.create(username='john.doe@example.com')
assert '_auth_user_id' not in client.session
response = client.get('/login/', HTTP_AUTHORIZATION='Negotiate xxxx')
assert response.status_code == 302
assert response['WWW-Authenticate'] == 'Negotiate yyyy'
assert 'principal john.doe@EXAMPLE.COM has no local user' not in caplog.text
assert client.session['_auth_user_id'] == str(user.id)
client.logout()
user.delete()
assert User.objects.count() == 0
caplog.clear()
settings.KERBEROS_BACKEND_CREATE = True
assert '_auth_user_id' not in client.session
response = client.get('/login/', HTTP_AUTHORIZATION='Negotiate xxxx')
assert response.status_code == 302
assert 'principal john.doe@EXAMPLE.COM has no local user' not in caplog.text
assert User.objects.count() == 1
user = User.objects.get()
assert not user.is_staff
assert not user.is_superuser
assert client.session['_auth_user_id'] == str(user.id)
assert 'got ticket for principal john.doe@EXAMPLE.COM' in caplog.text
client.logout()
caplog.clear()
settings.KERBEROS_BACKEND_ADMIN_REGEXP = 'john.doe'
assert '_auth_user_id' not in client.session
response = client.get('/login/', HTTP_AUTHORIZATION='Negotiate xxxx')
assert response.status_code == 302
assert 'principal john.doe@EXAMPLE.COM has no local user' not in caplog.text
assert User.objects.count() == 1
user = User.objects.get()
assert user.is_staff
assert user.is_superuser
assert client.session['_auth_user_id'] == str(user.id)
assert 'got ticket for principal john.doe@EXAMPLE.COM' in caplog.text
assert 'giving superuser power to principal \'john.doe@EXAMPLE.COM\'' in caplog.text
client.logout()
caplog.clear()
assert '_auth_user_id' not in client.session
response = client.get('/login/', HTTP_AUTHORIZATION='Negotiate xxxx', HTTP_X_REQUESTED_WITH='XMLHttpRequest')
assert response.status_code == 200
assert json.loads(response.content.decode('ascii')) is True
def test_password_backend(db, settings, kerberos_mock, caplog):
from django.contrib.auth import authenticate
settings.KERBEROS_DEFAULT_REALM = 'EXAMPLE.COM'
settings.KERBEROS_SERVICE_PRINCIPAL = 'HTTP/SERVICE.EXAMPLE.COM@EXAMPLE.COM'
m = kerberos_mock['kerberos.checkPassword']
m.return_value = False
assert authenticate(username='john.doe', password='password') is None
assert User.objects.count() == 0
m.return_value = True
assert authenticate(username='john.doe', password='password') is None
assert User.objects.count() == 0
user = User.objects.create(username='john.doe@example.com')
assert authenticate(username='john.doe', password='password') == user
user.delete()
assert User.objects.count() == 0
settings.KERBEROS_BACKEND_CREATE = True
new_user = authenticate(username='john.doe', password='password')
assert new_user
assert User.objects.count() == 1
assert new_user.username == 'john.doe@example.com'
assert not new_user.has_usable_password()
settings.KERBEROS_KEEP_PASSWORD = True
new_user = authenticate(username='john.doe', password='password')
assert User.objects.count() == 1
assert new_user.username == 'john.doe@example.com'
assert new_user.has_usable_password()
assert new_user.check_password('password')
caplog.clear()
m.side_effect = kerberos.KrbError('coin')
assert authenticate(username='john.doe', password='password') is None
assert 'password validation for principal %r failed coin' % u'john.doe@EXAMPLE.COM' in caplog.text

48
tox.ini
View File

@ -3,40 +3,38 @@
# test suite on all supported python versions. To use it, "pip install tox"
# and then run "tox" from this directory.
[testenv:coverage]
[tox]
toxworkdir = {env:TMPDIR:/tmp}/tox-{env:USER}/django-kerberos/{env:BRANCH_NAME:}
envlist = py27-coverage-{dj18,dj111}-{pg,sqlite},py3-coverage-{dj18,dj111,dj20,djlast}-{pg,sqlite},pylint
[testenv]
whitelist_externals =
/bin/mv
/bin/rm
setenv =
DJANGO_SETTINGS_MODULE=settings
PYTHONPATH=tests
coverage: COVERAGE=--cov-branch --cov-append --cov=src/ --cov-report=html --cov-report=xml --cov-config .coveragerc
sqlite: DB_ENGINE=django.db.backends.sqlite3
pg: DB_ENGINE=django.db.backends.postgresql_psycopg2
usedevelop = true
deps =
pytest
dj18: django>1.8,<1.9
dj18: django-tables2<1.1
dj111: django<2.0
dj20: django<2.1
djlast: django
pg: psycopg2-binary
pytest<4.2
pytest-mock
pytest-django
pytest-cov
pytest-capturelog
commands =
py.test {posargs:--junit-xml=junit.xml --cov=src --cov-report xml --nomigrations tests}
py.test {env:COVERAGE:} --junit-xml=junit-{envname}.xml {posargs:tests}
[testenv:nocoverage]
setenv =
DJANGO_SETTINGS_MODULE=settings
PYTHONPATH=tests
[testenv:pylint]
deps =
pytest
pytest-mock
pytest-django
pytest-cov
pytest-capturelog
pylint<1.8
pylint-django<0.8.1
commands =
py.test {posargs:--nomigrations tests}
[testenv:package]
# eobuilder is not on pypi, too bad
deps = setuptools
pip<8
pyasn1
ndg-httpsclient
pyopenssl
commands =
pip install -U --find-links https://jenkins.entrouvert.org/packages/ eobuilder
sh -c "sudo -u eobuilder -E env HOME=/var/lib/eobuilder PATH=$PATH $VIRTUAL_ENV/bin/eobuilder-ctl -d wheezy,jessie {posargs:django-kerberos}"
pylint: ./pylint.sh src/django_kerberos/