В одном из проектов была поставлена задача типографировать текст перед публикацией. Для этой задачи было решено использовать «Типограф» Лебедева.
У студии Лебедева есть сервис типографирования текста, который так и называется типограф. Как заявляют сами разработчики, никто не напишет «Типограф» лучше них, в связи с чем они предлагают готовые клиенты для работы c api
данного сервиса.
Типограф клиент
Я не смотрел клиенты для других языков, но клиент для Python
, который студия Лебедева предлагает, откровенно сказать — ужасен. В нем xml
запрос создается с помощью простой конкатенации строк и сомнительно экранирует входные данные, socket
подключается без таймлимита, что приводит к бесконечно длительному ожиданию ответа, в случае если сервер недоступен. Что уж говорить о рекомендациях pep8
, которые там впринципе не соблюдаются. Но самым существенным было то, что последнее изменение было сделано не вчера, и даже не на прошлой неделе, а всего-ничего, от 24 мая 2007 года, еще до релиза Python 3.0
. Соответсвенно данный клиент не поддерживает Python 3.x
.
Проект был на Python 3.4.2
, в связи с чем пришлось написать собсвтенный клиент для работы с «Типографом». Поскольку адекватного описания api
взаимодействия клиент-сервер в «Типографе» не приводится. Так что все было сделано по аналогии со старым клиентом, вплоть до наименований методов, лишь за исключением, что ненавистный мне верблюжийРегистр был заменен змеиным_регистром.
Получившийся клиент для типографа доступен в pypi
Совместимость python 2.x и 3.x
Поскольку клиет типографа предельно простой, не хотелось нагружать его дополнительными зависимостями, вроде six для реализации совместимости версий python
а, так что было использовано sys.version.startswith
.
PY3 = sys.version.startswith('3.')
Единственными различиями между py 2.x
и py 3.x
, с которыми пришлось столкнутся при написании клиента, это:
- Во втором питоне, в сокет и из него отправляются строковые объекты, а в третьем байтовые.
- Файлы в памяти во втором питоне представлены объектами
StringIO.StringIO
, а в третьемio.BytesIO
.
Так что вся совместимость версий, свелась к двум-трем условиям вида:
# ... import memory file stream
if PY3:
from io import BytesIO as Container
else:
from StringIO import StringIO as Container
# ... calculate a length of request
length = len(soap_body.encode('utf-8')) if PY3 else len(soap_body)
# ... convert to and from bytes for socket connection in py3
if PY3: # convert to bytes
soap_request = soap_request.encode('utf-8')
# ...
# take a response via socket
# ...
if PY3: # convert to str
response = response.decode()
Socket timeout
Библиотека socket предусматривает возможность установки максимального времени ожидания ответа от сервера. Делается это с помощью метода settimeout
.
# send request use soket
connector = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
connector.settimeout(self._timeout)
connector.connect((self.HOST, 80))
connector.sendall(soap_request)
# call for response
response = b''
buf = '0'
while len(buf):
buf = connector.recv(8192)
response += buf
connector.close()
В случае, если время ожидания превысит установленый таймаут, соединение будет разорвано с вызовом исключения socket.timeout: timed out
.
Django Типограф
После реализации совместимого с python 3.x
клиента было необходимо прикрутить его к джанге.
Быстрое решение
Так как проект был уже запущен в продакшен, нужно было быстро подключить типографирование. Типографировать нужно было всего две модели, поэтому была создана простая функция make_typograf
, вызов которой был повешен на сигнал сохранения моделей.
# helpers/service.py
def make_typograf(instance, fields):
""" For each instance.field in fields - make typograf """
typograf = RemoteTypograf()
for field in fields:
instance.__dict__[field] = typograf.try_process_text(instance.__dict__[field])
return instance
# articles/signals.py
from django.db.models.signals import pre_save
from articles.models import Article
from helpers.service import make_typograf
def typograf(sender, instance, **kwargs):
make_typograf(instance, ('title', 'subtitle', ))
pre_save.connect(typograf, sender=Article)
# cards/signals.py
from django.db.models.signals import pre_save
from cards.models import Card
from helpers.service import make_typograf
def typograf(sender, instance, **kwargs):
make_typograf(instance, ('person_profession', 'soldier_rank', 'history', ))
pre_save.connect(typograf, sender=Card)
Данный подход имел ряд недостатков:
- В случае если бы было неоходимо добавить новую модель, то потребовалось бы дописывать ещё один сигнал.
- Модель “типографировалась” при каждом сохранении, не зависимо от изменения самого текста, что приводило к лишним запросам на сервис Лебедева при каждом сохранении.
- Наиболее существенный недостаток. Модераторы начали жаловаться на то, что текст становится невозможно проверять, из за
html
сущностей, которые затрудняют чтение. (см. пример ниже)
Пример исходного текста:
"Вы все еще кое-как верстаете в "Ворде"? - Тогда мы идем к вам!"
Пример типографированного текста:
<p>«Вы все еще кое-как верстаете в „Ворде“?
— Тогда мы идем к вам!»<br />
</p>
Пример результата:
«Вы все еще кое-как верстаете в „Ворде“? — Тогда мы идем к вам!»
Ненавязчивый типограф
Для решения указанных недостатков было решено создать пакет django-typograf
, который позволил бы автоматически типографировать указанные поля в моделях, делал это только вслучае необходимости и не влиял на отображение исходного текста в административном интефейсе.
Для того чтобы не влиять на исходный текст, будем использовать дополнительные поля, которые будут хранить в себе оттипографированный текст. Для того чтобы не отправлять на типографирование текст каждый раз, без явной на то необходимости, будем хранить хешированное значение исходного текста и сравнивать его изменения.
Хешсумма
Поскольку нам не важно какой вид будет иметь хешсумма, сравним производительность различных алгоритмов хеширования с целью выбора оптимального.
Затраты времени для md5
суммы:
$ time python -c 'from hashlib import md5
for i in xrange(int(1e+6)): md5(str(i)).hexdigest()'
real 0m1.015s
user 0m1.006s
sys 0m0.008s
Затраты времени для sha1
суммы:
$ time python -c 'from hashlib import sha1
for i in xrange(int(1e+6)): sha1(str(i)).hexdigest()'
real 0m1.095s
user 0m1.090s
sys 0m0.004s
Затраты времени для crc32
суммы:
$ time python -c 'from binascii import crc32
for i in xrange(int(1e+6)): crc32(str(i))'
real 0m0.473s
user 0m0.472s
sys 0m0.000s
Затрат времени на стравнение строк:
$ time python -c 'for i in xrange(int(1e+6)): "123" == "321"'
real 0m0.092s
user 0m0.087s
sys 0m0.008s
Затрат времени на стравнение целых чисел:
$ time python -c 'for i in xrange(int(1e+6)): 123 == 321'
real 0m0.088s
user 0m0.080s
sys 0m0.008s
Таким образом, для хеширования используем алгоритм crc32
из библиотеки binascii, так как это очень быстрая хешсумма, а также результат вычислений можно сохранить в целочисленное поле базы данных, что выгоднее чем строка, как с точки зрения затрат памяти, так и с точки зрения времени сравнения.
Несмотря на то, что целочисленные значения дают дополнительное преимущество в затратах скорости и памяти, на практике всеравно будем использовать строковое представлениет. Так как, в python 3.x
алгоритм crc32
возвращает результат в диапазоне unsigned int: 0 .. 4294967295
, а в python 2.x
в диапазоне int: -2147483648 .. 2147483647
. Целочисленное поле PostgreSQL
способно разместить только диапазон int
, даже джанговское поле PositiveIntegerField
не расширяет диапазон до 2 ** 32 - 1
, а сокращает его ровно в половину до 0 .. 2147483647
.
Скрытые поля
Служебные поля для типографированного текста и хешсуммы будут создаваться автоматически с помощью метода метакласса предка.
# ...
@classmethod
def create_typograf_fields(cls, local_typograf_fields, attrs):
"""Create helpers to the local typografed fields """
for field_name in local_typograf_fields:
# check is text field
field = attrs[field_name]
if not isinstance(field, (models.CharField, models.TextField)):
raise TypografFieldError(
'Can\'t be typografed field "{field}".'
' This must be a text or char field.'.format(field=field_name))
# create fields for store typografed text and typografed hash
typograf_field = models.TextField(blank=True, null=True)
typograf_field.creation_counter += 0.0001
typograf_field_hash = models.CharField(max_length=32, blank=True, null=True)
typograf_field_hash.creation_counter += 0.0001
# create fields name's
typograf_field_name = get_typograf_field_name(field_name)
typograf_field_hash_name = get_typograf_hash_field_name(field_name)
# update attrs
attrs.update({
typograf_field_name: typograf_field,
typograf_field_hash_name: typograf_field_hash})
return attrs
В данном методе для каждого поля из спика local_typograf_fields
, который задается в атрибутах метакласса модели наследника, создается по два служебных поля, вида typograf_{field}
и typograf_{field}_hash
.
Для того чтобы эти служебные поля были недоступны в административном интерфейсе, добавим внутренний метод _exclude
, который будет возвращать списки скрытых полей.
from django.contrib import admin
from django_typograf.utils import get_typograf_field_name, get_typograf_hash_field_name
class TypografAdmin(admin.ModelAdmin):
""" Admin class for hide typograf fields from admin site """
def _exclude(self, obj=None):
""" Mark typograf fields as exclude """
exclude = ()
if obj:
exclude += tuple((
get_typograf_field_name(field) for field in obj._meta.typografed_fields))
exclude += tuple((
get_typograf_hash_field_name(field) for field in obj._meta.typografed_fields))
return exclude
def get_form(self, request, obj=None, **kwargs):
exclude = self.exclude or ()
exclude += self._exclude(obj)
kwargs.update(dict(exclude=exclude))
return super().get_form(request, obj, **kwargs)
Использования типографа
Теперь, для автоматического типографирования текста, достаточно установить пакет django_typograf
из pypi
, наследовать модель от TypografModel
и указать поля, которые необходимо типографировать.
# articles/models.py
from django.db import models
from django_typograf.models import TypografModel
class Article(TypografModel):
""" Model for articles """
title = models.CharField(verbose_name='заголовок', max_length=200)
subtitle = models.CharField(verbose_name='подзаголовок', max_length=200)
site_url = models.URLField(verbose_name='URL')
class Meta(Sortable.Meta):
typograf = ('title', 'subtitle', )
verbose_name = 'статья'
verbose_name_plural = 'статьи'
А также, в шаблоне, не забыть работать с “типографированными” полями.
<h1>{{ article.typograf_title|default_if_none:article.title|safe }}</h1>
<h2>{{ article.typograf_subtitle|default_if_none:article.subtitle|safe }}</h2>