Commit 63f5b2ca authored by Valentin Samir's avatar Valentin Samir

Merge branch 'master' into federate

parents a4d70d30 a1edd2f3
[run]
branch = True
source = cas_server
omit =
cas_server/migrations*
cas_server/management/*
cas_server/tests/*
[report] [report]
exclude_lines = exclude_lines =
pragma: no cover pragma: no cover
......
...@@ -2,19 +2,20 @@ language: python ...@@ -2,19 +2,20 @@ language: python
python: python:
- "2.7" - "2.7"
env: env:
global:
- PIP_DOWNLOAD_CACHE=$HOME/.pip_cache
matrix: matrix:
- TOX_ENV=coverage
- TOX_ENV=flake8
- TOX_ENV=check_rst
- TOX_ENV=py27-django17 - TOX_ENV=py27-django17
- TOX_ENV=py27-django18 - TOX_ENV=py27-django18
- TOX_ENV=py27-django19 - TOX_ENV=py27-django19
- TOX_ENV=py34-django17 - TOX_ENV=py34-django17
- TOX_ENV=py34-django18 - TOX_ENV=py34-django18
- TOX_ENV=py34-django19 - TOX_ENV=py34-django19
- TOX_ENV=flake8
cache: cache:
directories: directories:
- $HOME/.pip-cache/ - $HOME/.cache/pip/
- $HOME/build/nitmir/django-cas-server/.tox/
install: install:
- "travis_retry pip install setuptools --upgrade" - "travis_retry pip install setuptools --upgrade"
- "pip install tox" - "pip install tox"
...@@ -22,4 +23,3 @@ script: ...@@ -22,4 +23,3 @@ script:
- tox -e $TOX_ENV - tox -e $TOX_ENV
after_script: after_script:
- cat .tox/$TOX_ENV/log/*.log - cat .tox/$TOX_ENV/log/*.log
.PHONY: clean build install dist test_venv test_project .PHONY: build dist
VERSION=`python setup.py -V` VERSION=`python setup.py -V`
build: build:
python setup.py build python setup.py build
install: install: dist
python setup.py install pip -V
pip install --no-deps --upgrade --force-reinstall --find-links ./dist/django-cas-server-${VERSION}.tar.gz django-cas-server
uninstall:
pip uninstall django-cas-server || true
clean_pyc: clean_pyc:
find ./ -name '*.pyc' -delete find ./ -name '*.pyc' -delete
...@@ -16,18 +20,23 @@ clean_tox: ...@@ -16,18 +20,23 @@ clean_tox:
rm -rf .tox rm -rf .tox
clean_test_venv: clean_test_venv:
rm -rf test_venv rm -rf test_venv
clean: clean_pyc clean_build clean_coverage:
clean_all: clean_pyc clean_build clean_tox clean_test_venv rm -rf coverage.xml .coverage htmlcov
clean_tild_backup:
find ./ -name '*~' -delete
clean: clean_pyc clean_build clean_coverage clean_tild_backup
clean_all: clean clean_tox clean_test_venv
dist: dist:
python setup.py sdist python setup.py sdist
test_venv: test_venv/bin/python:
mkdir -p test_venv
virtualenv test_venv virtualenv test_venv
test_venv/bin/pip install -U --requirement requirements.txt test_venv/bin/pip install -U --requirement requirements-dev.txt Django
test_venv/cas/manage.py: test_venv/cas/manage.py: test_venv
mkdir -p test_venv/cas mkdir -p test_venv/cas
test_venv/bin/django-admin startproject cas test_venv/cas test_venv/bin/django-admin startproject cas test_venv/cas
ln -s ../../cas_server test_venv/cas/cas_server ln -s ../../cas_server test_venv/cas/cas_server
...@@ -38,20 +47,16 @@ test_venv/cas/manage.py: ...@@ -38,20 +47,16 @@ test_venv/cas/manage.py:
test_venv/bin/python test_venv/cas/manage.py migrate test_venv/bin/python test_venv/cas/manage.py migrate
test_venv/bin/python test_venv/cas/manage.py createsuperuser test_venv/bin/python test_venv/cas/manage.py createsuperuser
test_project: test_venv test_venv/cas/manage.py test_venv: test_venv/bin/python
test_project: test_venv/cas/manage.py
@echo "##############################################################" @echo "##############################################################"
@echo "A test django project was created in $(realpath test_venv/cas)" @echo "A test django project was created in $(realpath test_venv/cas)"
run_test_server: test_project run_server: test_project
test_venv/bin/python test_venv/cas/manage.py runserver test_venv/bin/python test_venv/cas/manage.py runserver
coverage: test_venv run_tests: test_venv
test_venv/bin/pip install coverage python setup.py check --restructuredtext --stric
test_venv/bin/coverage run --source='cas_server' --omit='cas_server/migrations*' run_tests test_venv/bin/py.test --cov=cas_server --cov-report html
test_venv/bin/coverage html
rm htmlcov/coverage_html.js # I am really pissed off by those keybord shortcuts rm htmlcov/coverage_html.js # I am really pissed off by those keybord shortcuts
coverage_codacy: coverage
test_venv/bin/coverage xml
test_venv/bin/pip install codacy-coverage
test_venv/bin/python-codacy-coverage -r coverage.xml
...@@ -22,11 +22,11 @@ CAS Server is a Django application implementing the `CAS Protocol 3.0 Specificat ...@@ -22,11 +22,11 @@ CAS Server is a Django application implementing the `CAS Protocol 3.0 Specificat
By defaut, the authentication process use django internal users but you can easily By defaut, the authentication process use django internal users but you can easily
use any sources (see auth classes in the auth.py file) use any sources (see auth classes in the auth.py file)
The defaut login/logout template use `django-bootstrap3 <https://github.com/dyve/django-bootstrap3>`_ The defaut login/logout template use `django-bootstrap3 <https://github.com/dyve/django-bootstrap3>`__
but you can use your own templates using settings variables. but you can use your own templates using settings variables.
Note that for Django 1.7 compatibility, you need a version of Note that for Django 1.7 compatibility, you need a version of
`django-bootstrap3 <https://github.com/dyve/django-bootstrap3>`_ < 7.0.0 `django-bootstrap3 <https://github.com/dyve/django-bootstrap3>`__ < 7.0.0
like the 6.2.2 version. like the 6.2.2 version.
Features Features
...@@ -43,7 +43,7 @@ Features ...@@ -43,7 +43,7 @@ Features
Quick start Quick start
----------- -----------
0. If you want to make a virtualenv for ``django-cas-server``, you will need the following 1. If you want to make a virtualenv for ``django-cas-server``, you will need the following
dependencies on a bare debian like system:: dependencies on a bare debian like system::
virtualenv build-essential python-dev libxml2-dev libxslt1-dev zlib1g-dev virtualenv build-essential python-dev libxml2-dev libxslt1-dev zlib1g-dev
...@@ -53,7 +53,7 @@ Quick start ...@@ -53,7 +53,7 @@ Quick start
If you intend to run the tox tests you will also need ``python3.4-dev`` depending of the current If you intend to run the tox tests you will also need ``python3.4-dev`` depending of the current
version of python3 on your system. version of python3 on your system.
1. Add "cas_server" to your INSTALLED_APPS setting like this:: 2. Add "cas_server" to your INSTALLED_APPS setting like this::
INSTALLED_APPS = ( INSTALLED_APPS = (
'django.contrib.admin', 'django.contrib.admin',
...@@ -71,7 +71,7 @@ Quick start ...@@ -71,7 +71,7 @@ Quick start
... ...
) )
2. Include the cas_server URLconf in your project urls.py like this:: 3. Include the cas_server URLconf in your project urls.py like this::
urlpatterns = [ urlpatterns = [
url(r'^admin/', admin.site.urls), url(r'^admin/', admin.site.urls),
...@@ -79,22 +79,22 @@ Quick start ...@@ -79,22 +79,22 @@ Quick start
url(r'^cas/', include('cas_server.urls', namespace="cas_server")), url(r'^cas/', include('cas_server.urls', namespace="cas_server")),
] ]
3. Run `python manage.py migrate` to create the cas_server models. 4. Run `python manage.py migrate` to create the cas_server models.
4. You should add some management commands to a crontab: ``clearsessions``, 5. You should add some management commands to a crontab: ``clearsessions``,
``cas_clean_tickets`` and ``cas_clean_sessions``. ``cas_clean_tickets`` and ``cas_clean_sessions``.
* ``clearsessions``: please see `Clearing the session store <https://docs.djangoproject.com/en/stable/topics/http/sessions/#clearing-the-session-store>`_. * ``clearsessions``: please see `Clearing the session store <https://docs.djangoproject.com/en/stable/topics/http/sessions/#clearing-the-session-store>`_.
* ``cas_clean_tickets``: old tickets and timed-out tickets do not get purge from * ``cas_clean_tickets``: old tickets and timed-out tickets do not get purge from
the database automatically. They are just marked as invalid. ``cas_clean_tickets`` the database automatically. They are just marked as invalid. ``cas_clean_tickets``
is a clean-up management command for this purpose. It send SingleLogOut request is a clean-up management command for this purpose. It send SingleLogOut request
to services with timed out tickets and delete them. to services with timed out tickets and delete them.
* ``cas_clean_sessions``: Logout and purge users (sending SLO requests) that are * ``cas_clean_sessions``: Logout and purge users (sending SLO requests) that are
inactive since more than ``SESSION_COOKIE_AGE``. The default value for is ``1209600`` inactive since more than ``SESSION_COOKIE_AGE``. The default value for is ``1209600``
seconds (2 weeks). You probably should reduce it to something like ``86400`` seconds (1 day). seconds (2 weeks). You probably should reduce it to something like ``86400`` seconds (1 day).
You could for example do as bellow : You could for example do as bellow :
.. code-block:: .. code-block::
...@@ -102,11 +102,11 @@ Quick start ...@@ -102,11 +102,11 @@ Quick start
*/5 * * * * cas-user /path/to/project/manage.py cas_clean_tickets */5 * * * * cas-user /path/to/project/manage.py cas_clean_tickets
5 0 * * * cas-user /path/to/project/manage.py cas_clean_sessions 5 0 * * * cas-user /path/to/project/manage.py cas_clean_sessions
5. Start the development server and visit http://127.0.0.1:8000/admin/ 6. Start the development server and visit http://127.0.0.1:8000/admin/
to add a first service allowed to authenticate user agains the CAS to add a first service allowed to authenticate user agains the CAS
(you'll need the Admin app enabled). (you'll need the Admin app enabled).
6. Visit http://127.0.0.1:8000/cas/ to login with your django users. 7. Visit http://127.0.0.1:8000/cas/ to login with your django users.
...@@ -138,7 +138,7 @@ Template settings: ...@@ -138,7 +138,7 @@ Template settings:
Authentication settings: Authentication settings:
* ``CAS_AUTH_CLASS``: A dotted path to a class or a class implementing * ``CAS_AUTH_CLASS``: A dotted path to a class or a class implementing
``cas_server.auth.AuthUser``. The default is ``"cas_server.auth.DjangoAuthUser"`` ``cas_server.auth.AuthUser``. The default is ``"cas_server.auth.DjangoAuthUser"``
* ``SESSION_COOKIE_AGE``: This is a django settings. Here, it control the delay in seconds after * ``SESSION_COOKIE_AGE``: This is a django settings. Here, it control the delay in seconds after
which inactive users are logged out. The default is ``1209600`` (2 weeks). You probably should which inactive users are logged out. The default is ``1209600`` (2 weeks). You probably should
...@@ -217,15 +217,15 @@ Mysql backend settings. Only usefull if you are using the mysql authentication b ...@@ -217,15 +217,15 @@ Mysql backend settings. Only usefull if you are using the mysql authentication b
The default is ``"SELECT user AS usersame, pass AS password, users.* FROM users WHERE user = %s"`` The default is ``"SELECT user AS usersame, pass AS password, users.* FROM users WHERE user = %s"``
* ``CAS_SQL_PASSWORD_CHECK``: The method used to check the user password. Must be one of the following: * ``CAS_SQL_PASSWORD_CHECK``: The method used to check the user password. Must be one of the following:
* ``"crypt"`` (see <https://en.wikipedia.org/wiki/Crypt_(C)>), the password in the database * ``"crypt"`` (see <https://en.wikipedia.org/wiki/Crypt_(C)>), the password in the database
should begin this $ should begin this $
* ``"ldap"`` (see https://tools.ietf.org/id/draft-stroeder-hashed-userpassword-values-01.html) * ``"ldap"`` (see https://tools.ietf.org/id/draft-stroeder-hashed-userpassword-values-01.html)
the password in the database must begin with one of {MD5}, {SMD5}, {SHA}, {SSHA}, {SHA256}, the password in the database must begin with one of {MD5}, {SMD5}, {SHA}, {SSHA}, {SHA256},
{SSHA256}, {SHA384}, {SSHA384}, {SHA512}, {SSHA512}, {CRYPT}. {SSHA256}, {SHA384}, {SSHA384}, {SHA512}, {SSHA512}, {CRYPT}.
* ``"hex_HASH_NAME"`` with ``HASH_NAME`` in md5, sha1, sha224, sha256, sha384, sha512. * ``"hex_HASH_NAME"`` with ``HASH_NAME`` in md5, sha1, sha224, sha256, sha384, sha512.
The hashed password in the database is compare to the hexadecimal digest of the clear The hashed password in the database is compare to the hexadecimal digest of the clear
password hashed with the corresponding algorithm. password hashed with the corresponding algorithm.
* ``"plain"``, the password in the database must be in clear. * ``"plain"``, the password in the database must be in clear.
The default is ``"crypt"``. The default is ``"crypt"``.
......
...@@ -7,6 +7,6 @@ ...@@ -7,6 +7,6 @@
# along with this program; if not, write to the Free Software Foundation, Inc., 51 # along with this program; if not, write to the Free Software Foundation, Inc., 51
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
# #
# (c) 2015 Valentin Samir # (c) 2015-2016 Valentin Samir
"""A django CAS server application"""
default_app_config = 'cas_server.apps.CasAppConfig' default_app_config = 'cas_server.apps.CasAppConfig'
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
# along with this program; if not, write to the Free Software Foundation, Inc., 51 # along with this program; if not, write to the Free Software Foundation, Inc., 51
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
# #
# (c) 2015 Valentin Samir # (c) 2015-2016 Valentin Samir
"""module for the admin interface of the app""" """module for the admin interface of the app"""
from django.contrib import admin from django.contrib import admin
from .models import ServiceTicket, ProxyTicket, ProxyGrantingTicket, User, ServicePattern from .models import ServiceTicket, ProxyTicket, ProxyGrantingTicket, User, ServicePattern
......
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License version 3 for
# more details.
#
# You should have received a copy of the GNU General Public License version 3
# along with this program; if not, write to the Free Software Foundation, Inc., 51
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#
# (c) 2015-2016 Valentin Samir
"""django config module"""
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.apps import AppConfig from django.apps import AppConfig
class CasAppConfig(AppConfig): class CasAppConfig(AppConfig):
"""django CAS application config class"""
name = 'cas_server' name = 'cas_server'
verbose_name = _('Central Authentication Service') verbose_name = _('Central Authentication Service')
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
# along with this program; if not, write to the Free Software Foundation, Inc., 51 # along with this program; if not, write to the Free Software Foundation, Inc., 51
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
# #
# (c) 2015 Valentin Samir # (c) 2015-2016 Valentin Samir
"""Some authentication classes for the CAS""" """Some authentication classes for the CAS"""
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
...@@ -26,6 +26,7 @@ from .models import FederatedUser ...@@ -26,6 +26,7 @@ from .models import FederatedUser
class AuthUser(object): class AuthUser(object):
"""Authentication base class"""
def __init__(self, username): def __init__(self, username):
self.username = username self.username = username
......
...@@ -90,6 +90,8 @@ setting_default( ...@@ -90,6 +90,8 @@ setting_default(
} }
) )
setting_default('CAS_ENABLE_AJAX_AUTH', False)
setting_default('CAS_FEDERATE', False) setting_default('CAS_FEDERATE', False)
# A dict of "provider suffix" -> (provider CAS server url, CAS version, verbose name) # A dict of "provider suffix" -> (provider CAS server url, CAS version, verbose name)
setting_default('CAS_FEDERATE_PROVIDERS', {}) setting_default('CAS_FEDERATE_PROVIDERS', {})
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
# along with this program; if not, write to the Free Software Foundation, Inc., 51 # along with this program; if not, write to the Free Software Foundation, Inc., 51
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
# #
# (c) 2015 Valentin Samir # (c) 2015-2016 Valentin Samir
"""forms for the app""" """forms for the app"""
from .default_settings import settings from .default_settings import settings
...@@ -19,6 +19,7 @@ import cas_server.models as models ...@@ -19,6 +19,7 @@ import cas_server.models as models
class WarnForm(forms.Form): class WarnForm(forms.Form):
"""Form used on warn page before emiting a ticket"""
service = forms.CharField(widget=forms.HiddenInput(), required=False) service = forms.CharField(widget=forms.HiddenInput(), required=False)
renew = forms.BooleanField(widget=forms.HiddenInput(), required=False) renew = forms.BooleanField(widget=forms.HiddenInput(), required=False)
gateway = forms.CharField(widget=forms.HiddenInput(), required=False) gateway = forms.CharField(widget=forms.HiddenInput(), required=False)
......
"""Clean deleted sessions management command"""
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
...@@ -5,6 +6,7 @@ from ... import models ...@@ -5,6 +6,7 @@ from ... import models
class Command(BaseCommand): class Command(BaseCommand):
"""Clean deleted sessions"""
args = '' args = ''
help = _(u"Clean deleted sessions") help = _(u"Clean deleted sessions")
......
"""Clean old trickets management command"""
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
...@@ -5,6 +6,7 @@ from ... import models ...@@ -5,6 +6,7 @@ from ... import models
class Command(BaseCommand): class Command(BaseCommand):
"""Clean old trickets"""
args = '' args = ''
help = _(u"Clean old trickets") help = _(u"Clean old trickets")
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
# along with this program; if not, write to the Free Software Foundation, Inc., 51 # along with this program; if not, write to the Free Software Foundation, Inc., 51
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
# #
# (c) 2015 Valentin Samir # (c) 2015-2016 Valentin Samir
"""models for the app""" """models for the app"""
from .default_settings import settings from .default_settings import settings
...@@ -20,7 +20,6 @@ from django.utils import timezone ...@@ -20,7 +20,6 @@ from django.utils import timezone
from picklefield.fields import PickledObjectField from picklefield.fields import PickledObjectField
import re import re
import os
import sys import sys
import logging import logging
from importlib import import_module from importlib import import_module
...@@ -79,6 +78,7 @@ class User(models.Model): ...@@ -79,6 +78,7 @@ class User(models.Model):
@classmethod @classmethod
def clean_old_entries(cls): def clean_old_entries(cls):
"""Remove users inactive since more that SESSION_COOKIE_AGE"""
users = cls.objects.filter( users = cls.objects.filter(
date__lt=(timezone.now() - timedelta(seconds=settings.SESSION_COOKIE_AGE)) date__lt=(timezone.now() - timedelta(seconds=settings.SESSION_COOKIE_AGE))
) )
...@@ -88,6 +88,7 @@ class User(models.Model): ...@@ -88,6 +88,7 @@ class User(models.Model):
@classmethod @classmethod
def clean_deleted_sessions(cls): def clean_deleted_sessions(cls):
"""Remove user where the session do not exists anymore"""
for user in cls.objects.all(): for user in cls.objects.all():
if not SessionStore(session_key=user.session_key).get('authenticated'): if not SessionStore(session_key=user.session_key).get('authenticated'):
user.logout() user.logout()
...@@ -112,10 +113,10 @@ class User(models.Model): ...@@ -112,10 +113,10 @@ class User(models.Model):
for ticket_class in ticket_classes: for ticket_class in ticket_classes:
queryset = ticket_class.objects.filter(user=self) queryset = ticket_class.objects.filter(user=self)
for ticket in queryset: for ticket in queryset:
ticket.logout(request, session, async_list) ticket.logout(session, async_list)
queryset.delete() queryset.delete()
for future in async_list: for future in async_list:
if future: if future: # pragma: no branch (should always be true)
try: try:
future.result() future.result()
except Exception as error: except Exception as error:
...@@ -143,13 +144,21 @@ class User(models.Model): ...@@ -143,13 +144,21 @@ class User(models.Model):
(a.name, a.replace if a.replace else a.name) for a in service_pattern.attributs.all() (a.name, a.replace if a.replace else a.name) for a in service_pattern.attributs.all()
) )
replacements = dict( replacements = dict(
(a.name, (a.pattern, a.replace)) for a in service_pattern.replacements.all() (a.attribut, (a.pattern, a.replace)) for a in service_pattern.replacements.all()
) )
service_attributs = {} service_attributs = {}
for (key, value) in self.attributs.items(): for (key, value) in self.attributs.items():
if key in attributs or '*' in attributs: if key in attributs or '*' in attributs:
if key in replacements: if key in replacements:
value = re.sub(replacements[key][0], replacements[key][1], value) if isinstance(value, list):
for index, subval in enumerate(value):
value[index] = re.sub(
replacements[key][0],
replacements[key][1],
subval
)
else:
value = re.sub(replacements[key][0], replacements[key][1], value)
service_attributs[attributs.get(key, key)] = value service_attributs[attributs.get(key, key)] = value
ticket = ticket_class.objects.create( ticket = ticket_class.objects.create(
user=self, user=self,
...@@ -173,6 +182,7 @@ class User(models.Model): ...@@ -173,6 +182,7 @@ class User(models.Model):
class ServicePatternException(Exception): class ServicePatternException(Exception):
"""Base exception of exceptions raised in the ServicePattern model"""
pass pass
...@@ -426,77 +436,57 @@ class Ticket(models.Model): ...@@ -426,77 +436,57 @@ class Ticket(models.Model):
).delete() ).delete()
# sending SLO to timed-out validated tickets # sending SLO to timed-out validated tickets
if cls.TIMEOUT and cls.TIMEOUT > 0: async_list = []
async_list = [] session = FuturesSession(
session = FuturesSession( executor=ThreadPoolExecutor(max_workers=settings.CAS_SLO_MAX_PARALLEL_REQUESTS)
executor=ThreadPoolExecutor(max_workers=settings.CAS_SLO_MAX_PARALLEL_REQUESTS) )
) queryset = cls.objects.filter(
queryset = cls.objects.filter( creation__lt=(timezone.now() - timedelta(seconds=cls.TIMEOUT))
creation__lt=(timezone.now() - timedelta(seconds=cls.TIMEOUT)) )
) for ticket in queryset:
for ticket in queryset: ticket.logout(session, async_list)
ticket.logout(None, session, async_list) queryset.delete()
queryset.delete() for future in async_list:
for future in async_list: if future: # pragma: no branch (should always be true)
if future: try:
try: future.result()
future.result() except Exception as error:
except Exception as error: logger.warning("Error durring SLO %s" % error)
logger.warning("Error durring SLO %s" % error) sys.stderr.write("%r\n" % error)
sys.stderr.write("%r\n" % error)