22 сентября 2014

Документация python проекта на практике

Документация в python проектах пишется при помощи sphinx, он умеет используя расширение automodule читать докстринги и формировать документацию из кода.

Создать проект можно ответив на вопросы через sphinx-quickstart или использовать уже подготовленный шаблон который генерит дополнительно API пакета:
sphinx-apidoc -F -o docs sacrud

Creating file docs/sacrud_deform.rst.
Creating file docs/sacrud_deform.tests.rst.
Creating file docs/conf.py.
Creating file docs/index.rst.
Creating file docs/Makefile.
Creating file docs/make.bat.

Автогенератор API обычно создает много лишнего и далеко не идеально генерит названия, поэтому удалим API тестов и попереимеуем все остальное.


Для сборки доков  нужно в папке docs выполнить make html. Ниже структура получившихся файлов:
.
├── _build
│   ├── doctrees
│   │   ├── environment.pickle
│   │   ├── index.doctree
│   │   ├── readme.doctree
│   │   ├── sacrud_deform.doctree
│   │   └── sacrud_deform.tests.doctree
│   └── html
│       ├── genindex.html
│       ├── index.html
│       ├── _modules
│       │   ├── index.html
│       │   ├── sacrud_deform
│       │   │   ├── tests
│       │   │   │   └── test_form.html
│       │   │   └── widgets.html
│       │   └── sacrud_deform.html
│       ├── objects.inv
│       ├── py-modindex.html
│       ├── readme.html
│       ├── sacrud_deform.html
│       ├── sacrud_deform.tests.html
│       ├── search.html
│       ├── searchindex.js
│       ├── _sources
│       │   ├── index.txt
│       │   ├── readme.txt
│       │   ├── sacrud_deform.tests.txt
│       │   └── sacrud_deform.txt
│       └── _static
│           ├── ajax-loader.gif
│           ├── basic.css
│           ├── comment-bright.png
│           ├── comment-close.png
│           ├── comment.png
│           ├── default.css
│           ├── doctools.js
│           ├── down.png
│           ├── down-pressed.png
│           ├── file.png
│           ├── jquery.js
│           ├── minus.png
│           ├── plus.png
│           ├── pygments.css
│           ├── searchtools.js
│           ├── sidebar.js
│           ├── underscore.js
│           ├── up.png
│           ├── up-pressed.png
│           └── websupport.js
├── conf.py
├── index.rst
├── make.bat
├── Makefile
├── readme.rst
├── sacrud_deform.rst
├── _static
└── _templates

В директории build/html находится наша готовая документация в формате html. Добавим описание проекта на основную страницу. Для этого создаем readme.rst в директории docs и включаем его в index.rst, а в дальнейшем этот же readme.rst будет использоваться в README.rst в корне проекта для github и PyPi.

readme.rst
|Build Status| |Coverage Status| |Stories in Progress| |PyPI|

.. |Build Status| image:: https://travis-ci.org/ITCase/sacrud_deform.svg?branch=master
   :target: https://travis-ci.org/ITCase/sacrud_deform
.. |Coverage Status| image:: https://coveralls.io/repos/ITCase/sacrud_deform/badge.png?branch=master
   :target: https://coveralls.io/r/ITCase/sacrud_deform?branch=master
.. |Stories in Progress| image:: https://badge.waffle.io/ITCase/sacrud_deform.png?label=in%20progress&title=In%20Progress
   :target: http://waffle.io/ITCase/sacrud_defrom
.. |PyPI| image:: http://img.shields.io/pypi/dm/sacrud_deform.svg
   :target: https://pypi.python.org/pypi/sacrud_deform/

sacrud_deform
==============

Form generotor for SQLAlchemy models.

Install
=======

develop version from source

.. code-block:: bash

  pip install git+git://github.com/ITCase/sacrud_deform@develop

from pypi

.. code-block:: bash

  pip install sacrud_deform

Use
===

.. code-block:: python

        data = form_generator(dbsession=DBSession,
                                   obj=obj_of_model, table=MyModel,
                                   columns=columns_of_model)
        form, js_list = data.render()

Здесь вроде все понятно, но есть нюансы, если например в директиве .. code-block не указать язык или не дописывать "=" в заголовках то документация будет нормально генериться, но PyPi её не поймет и выведет что-то типа этого:

Нужно быть внимательным и проверять как PyPi подхватил README.rst. Дальше в наш index.rst добавим readme.rst что бы он был по презентабельнее.

index.rst

Welcome to sacrud_deform's documentation!
=========================================

.. include:: readme.rst

Contents:

.. toctree::
   :maxdepth: 4

   sacrud_deform


Indices and tables
==================

* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

Теперь главная страница документации выглядит так:

В принципе уже хорошо, добавим это описание ещё для github и PyPi. Они по умолчанию берут файл README.rst из корня проекта и ничего про папку docs не знают. Можно было бы заинклудить readme.rst в корневой README.rst(..include:: docs/readme.rst), но github и PyPi инклуды не понимают, поэтому придется писать свой велосипед для копирования. Либо тупо делать это вручную. Я написал на коленке простой скрипт который это делает и заменяет директивы include содержанием их файлов.

make_README.py
import fileinput
import os
from shutil import copyfile

src = "readme.rst"
src_path = os.path.dirname(os.path.realpath(src))
dst = "../README.rst"
copyfile(src, dst)


def read_file(path):
    with open(path, 'r') as f:
        return f.read()

for line in fileinput.input(dst, inplace=1):
    splitted = line.rstrip().split('.. include:: ')
    if len(splitted) == 2:
        line = read_file(os.path.join(src_path, splitted[1]))
        print line
    else:
        print line.rstrip()

Теперь если запустить из папки docs команду python make_README.py, то в корне проекта появится или перезапишется файл README.rst

Что бы это делалось автоматически при каждой сборке документации добавим в Makefile:
...

readme:
 python make_README.py

readme_html: html readme

При вызове "make readme_html" у нас будет собираться документация html и копипаститься readme.

Конфиг для sphinx лежит в папке документации с названием conf.py. В нем можно настраивать документацию как угодно, к примеру оформим тему как у проекта Pyramid.

Добавим в conf.py
import sys
import os

# Add and use Pylons theme
if 'sphinx-build' in ' '.join(sys.argv):  # protect against dumb importers
    from subprocess import call, Popen, PIPE

    p = Popen('which git', shell=True, stdout=PIPE)
    git = p.stdout.read().strip()
    cwd = os.getcwd()
    _themes = os.path.join(cwd, '_themes')

    if not os.path.isdir(_themes):
        call([git, 'clone', 'git://github.com/Pylons/pylons_sphinx_theme.git',
              '_themes'])
    else:
        os.chdir(_themes)
        call([git, 'checkout', 'master'])
        call([git, 'pull'])
        os.chdir(cwd)

    sys.path.append(os.path.abspath('_themes'))

    parent = os.path.dirname(os.path.dirname(__file__))
    sys.path.append(os.path.abspath(parent))
    wd = os.getcwd()
    os.chdir(parent)
    os.chdir(wd)

    for item in os.listdir(parent):
        if item.endswith('.egg'):
            sys.path.append(os.path.join(parent, item))

Этот код я скопировал из проекта Pyramid, он просто выкачивает их тему с github в директорию _themes. Далее укажем в конфиге название темы и где их искать.
# -- Options for HTML output ----------------------------------------------

# The theme to use for HTML and HTML Help pages.  See the documentation for

# a list of builtin themes.

html_theme = 'pyramid'

# Add any paths that contain custom themes here, relative to this directory.

html_theme_path = ['_themes']

После сборки проект будет выглядеть так:

Вообщем конфиг умеет много чего, есть еще много разных расширений, можно писать свои, либо переопределять/добавлять директивы итд. Вот например как вставлять ссылки на сторонние проекты:
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions += [
    'sphinx.ext.intersphinx'
]

intersphinx_mapping = {
    'sacrud': ('http://sacrud.readthedocs.org/en/latest/', None),
}

Теперь можно писать так:
Use :py:class:`sacrud.common.TableProperty` decorator.

И наверно последний этап это добавление документации на readthedocs. Сложностей там особо нету, регаешься, добавляешь гитхаб профиль, синхронизируешь репы и выбираешь нужные. Хук на коммиты в таком случае вешается автоматически, единственное что нужно отметить что если в документации используется automodule нужно ставить галку virtualenv(в настройках readthedocs) иначе он просто будет пустым.
Готовый пример можно посмотреть здесь http://sacrud-deform.readthedocs.org/en/develop/

05 сентября 2014

REST API для Pyramid при помощи Cornice и SACRUD

Mozilla использует в своих проектах Pyramid и у них есть отличный модуль для создания REST API https://cornice.readthedocs.org/en/latest/

REST API обычно меняет, создает и удаляет записи, которые хранятся в БД. Что бы не писать много кода на SQLAlchemy я использую заготовленные функции из sacrud

Итак поехали, представим сервис REST API для платежных карт с моделью типа:

class Card(Base):
    __tablename__ = 'card'

    id = Column(Integer, primary_key=True)
    number = Column(BigInteger, nullable=False, unique=True)
    uid = Column(BYTEA, nullable=False, unique=True)
    balance = Column(Numeric(10, 2), nullable=False, default=0)
    preference = Column(GUID())

    def __json__(self):
        return {'id': self.id,
                'number': self.number,
                'uid': str(self.uid),
                'balance': str(self.balance)
                }

Создадим отдельную папку в pyramid проекте с названием rest,  где будут храниться функции api, и пропишем это в основном конфиге.

    # REST API
    config.include("cornice")
    config.include("myapp.rest", route_prefix="/api")

В pyramid'е есть такая штука как config.scan() она смотрит все файлы в проекте с расширением *.py (от текущей директории) и ищет вьюхи обернутые декоратором @view_config. Я предпочитаю включать в основной конфиг папочки через инклуд как в примере выше, а локально в каждой папке уже вызывать config.scan()

Структура папки rest
rest/
├── card.py
├── __init__.py
└── validators.py

Файл __init__.py

def includeme(config):
    config.scan()

В card.py сама реализация REST API

GET

import json

from cornice import Service

from myapp.models import DBSession
from myapp.models import Card
from myapp.rest.validators import _400, _404, valid_hex

from sacrud.action import CRUD
from sqlalchemy.exc import DataError
from sqlalchemy.orm.exc import NoResultFound

card_api = Service(name='card', path='/card/{UID}',
                   description="REST API for card")


@card_api.get(validators=valid_hex)
def get_card(request):
    """``GET``: возвращает данные о карте.

    .. code-block:: bash

        $ curl -D - http://0.0.0.0:6543/api/card/07a8e29d

        HTTP/1.1 200 OK
        Content-Length: 69
        Content-Type: application/json; charset=UTF-8
        Date: Sat, 02 Aug 2014 11:22:52 GMT
        Server: waitress

        {"uid": "07a8e29d", "balance": 507.05, "id": 3, "number": 8224548674}
    """
    key = request.validated['UID']
    card = DBSession.query(Card)
    if key:
        try:
            card = card.filter_by(uid=key).one()
        except NoResultFound:
            raise _404()
        except DataError:
            raise _400()
        return card.__json__()

Декоратор card_api превращает функцию get_card во вьюху, ожидающею HTTP метод GET, и config.scan() её автоматически подхватит. Внутри можно реализовывать все что угодно. Про метод validated ниже.

POST

@card_api.post(validators=valid_hex)
def set_card(request):
    """``POST``: передает параметры для редактирования карты.

    .. code-block:: bash

        $ curl -H 'Accept: application/json'\\
            -H 'Content-Type: application/json'\\
            http://0.0.0.0:6543/api/card/07a8e29d\\
            -d '{"number": "8224548674", "balance": "507.05"}'

        {"uid": "07a8e29d", "balance": 507.05, "id": 3, "number": 8224548674}

    Если карты нету, то создается новая

    .. code-block:: bash

        $ curl -H 'Accept: application/json'\\
            -H 'Content-Type: application/json'\\
            http://0.0.0.0:6543/api/card/550e8400\\
            -d '{"balance": "100.11", "preference": "956bfc40"}'

        {"uid": "550e8400", "balance": 100.11, "id": 7, "number": 91328937986}
    """
    key = request.validated['UID']
    data = {'request': json.loads(request.body)}

    card = DBSession.query(Card).filter_by(uid=key).first()
    if card:
        data['pk'] = {'id': card.id}
    else:
        data['request']['uid'] = key

    try:
        CRUD(DBSession, Card, **data).add()
    except Exception as e:
        raise _400(msg=str(e.message))
    card = DBSession.query(Card).filter_by(uid=key).one()
    return card.__json__()

Метод POST меняет параметры карты или создает новую если такой нету. Карта создается при помощи мега модуля sacrud. Подробнее о создании записей через sacrud здесь http://sacrud.readthedocs.org/en/latest/plain_usage.html#create-action

DELETE

@card_api.delete(validators=valid_hex)
def del_card(request):
    """``DELETE``: удаляет карту по UID

    .. code-block:: bash

        $ curl -H 'Accept: application/json'\\
            -H 'Content-Type: application/json'\\
            http://0.0.0.0:6543/api/card/550e8400 -X DELETE

        {"Goodbye": "550e8400"}
    """
    key = request.validated['UID']
    card = DBSession.query(Card).filter_by(uid=key).first()
    if not card:
        raise _404()
    try:
        CRUD(DBSession, Card, pk={'id': card.id}).delete()
    except DataError as e:
        raise _400(msg=str(e.message))
    return {'Goodbye': key}

Удаление происходит при вызове HTTP метода DELETE. sacrud опять же несколько упрощает эту операцию http://sacrud.readthedocs.org/en/latest/plain_usage.html#delete-action

В файле validators.py хранятся всякие исключения и сами проверки. Если в декораторе card_api указан валидатор, то валидные данные нужно будет выбирать не через request.matchdict а через request.validated

Пример файла validators.py

import json
import string

from webob import exc, Response


class _404(exc.HTTPError):
    def __init__(self, msg='Not Found'):
        body = {'status': 404, 'message': msg}
        Response.__init__(self, json.dumps(body))
        self.status = 404
        self.content_type = 'application/json'


class _400(exc.HTTPError):
    def __init__(self, msg='Bad Request'):
        body = {'status': 400, 'message': msg}
        Response.__init__(self, json.dumps(body))
        self.status = 400
        self.content_type = 'application/json'


def valid_hex(request):
    key = request.matchdict['UID']

    if not all(c in string.hexdigits for c in key):
        raise _400(msg="Not valid UID '%s'" % key)

    request.validated['UID'] = str(key)

Таким образом можно довольно просто создать API для вашего проекта на пирамиде. Cornice делает много за вас, создает вьюхи, пути, валидацию, может генерить автоматически  Sphinx документацию, а SACRUD упрощает работу с БД.

19 августа 2014

Chameleon, deform и маленькая хитрость

Deform - это такая штука которая генерит формы, а шаблоны для виджетов в нем написаны в формате Chameleon шаблонизатора.

Простой пример формы(http://deformdemo.xo7.de/nonrequiredfields/):
        class Schema(colander.Schema):
            required = colander.SchemaNode(
                colander.String(),
                description='Required Field'
                )
            notrequired = colander.SchemaNode(
                colander.String(),
                missing=unicode(''),
                description='Unrequired Field')
        schema = Schema()
        form = deform.Form(schema, buttons=('submit',))
        return self.render_form(form)



К полям можно добавлять описание, но если вставить html то он заэскапируется(https://github.com/Pylons/deform/blob/bb4fc86913884deafa9350de86d87fb5232263fa/deform/templates/form.pt#L42). Можно воспользоваться хитрой возможностью Chamelon'а что бы вывести html.

class HTMLText(object):
    def __init__(self, text):
        self.text = text

    def __html__(self):
        return unicode(self.text)

notrequired = colander.SchemaNode(
                colander.String(),
                missing=unicode(''),
                description=HTMLText('Hello <hr color="red" /> World!!! <hr />'))

Chameleon по умолчанию ищет метод __html__ у объектов и если он есть то выводит его результат в чистом виде. Вот.