Posty oznaczone etykietą django

Colander i Django: ColanderSchemaField

Tym razem przedstawię rozwiązanie odwrotne do poprzedniego - zastosuję walidację schematem Colandera w formularzu Django.

Budując web API bardzo często istnieje potrzeba zwalidowania i odczytania nieco bardziej skomplikowanej struktury. W przypadku gdy po stronie serwera modelem jest obiekt klasy django.db.Model, to zapewne stosowany jest formularz klasy django.forms.ModelForm. To częsta i dosyć wygodna praktyka. Jednak jeśli wejście jest bardziej złożone, a dzieje się tak często podczas enkapsulacji interfejsu za pomocą RESTful API, to istnieje tendencja do tworzenia wielu formularzy, adapterów typu complex/composite forms, albo do zmiany modelu w celu odzwierciedlenia go w interfejsie 1:1.

Żadne z tych rozwiązań nie jest wystarczająco dobre, ponieważ wiele formularzy to bardziej złożona logika walidacji i brak spójności tejże (także w kontekście komunikatów o błędach), composite/complex forms narzucają tworzenie zlepek z wielu modeli (co też nie zawsze jest konieczne), a zmiany modelu mogą być zbędne i kosztowne.

Przypuśćmy, że bardziej złożona struktura to dodatkowy poziom zagnieżdżenia danych wejściowych. Naturalnym krokiem, szczególnie przy budowaniu JSON API, jest dodanie pola typu JSONField, ktore zdeserializuje część JSON-a do słownika. Byłoby to rozwiązanie dobre gdyby nie fakt, że walidacji podlega tylko syntaktyka przekazanej wartości. Nazwy kluczy, czyli ich prawidłowość oraz ich typy i wartości, pozostają nieweryfikowalne. I nie jest to winą JSON-a, tylko jego użycia (może wynikać wprost z zastosowania prostego pola typu JSONField).

Problem można rozwiązać używając pakietu Colander, za pomocą którego definiuje się dowolne schematy. Schematy Colander mogą być komponowane w złożone struktury, ale mogą być też używane pojedynczo. Colander nie narzuca tutaj żadnej konkretnej struktury oraz nie wprowadza zbędnych zależności, zatem użycie go jest bardzo wygodne.

Rozważmy bardzo prosty schemat opisujący współrzędne geograficzne:

class LocationSchema(colander.MappingSchema):
    lat = colander.SchemaNode(colander.Float())
    lng = colander.SchemaNode(colander.Float())

Powyższy schemat przyjmie słownik z kluczami lat oraz lng, których wartości muszą być liczbami zmiennoprzecinkowymi. Krótko mówiąc przyjmie on dane postaci: {"lat": 50.0, "lng": 50.0}.

Taki schemat można podpiąć bezpośrednio pod formularz Django, tak jak każde inne pole:

class LocationForm(forms.ModelForm):
    destination = ColanderSchemaField(LocationSchema, required=True)

Oczywiście potrzebny jest adapter - dedykowane pole dla formularzy Django, za pomocą którego proces walidacji zainstancjonuje schemat LocationSchema, zweryfikuje oraz oczyści dane wejściowe.

Ciało tego adaptera jest bardzo proste:

class ColanderSchemaField(forms.Field):
    def __init__(self, schema_factory, *args, **kwargs):
        self.schema_factory = schema_factory
        super(ColanderSchemaField, self).__init__(*args, **kwargs)

    def clean(self, value):
        if value is None:
            return

        schema = self.schema_factory()
        try:
            return schema.deserialize(value)
        except colander.Invalid as ex:
            raise forms.ValidationError(ex)

Od tej chwili formularz będzie oczekiwał klucza destination o strukturze określonej klasą LocationSchema.

Jednak zapis tej informacji oczywiście nie nastąpi, ponieważ ColanderSchemaField nie jest częścią modelu. Zapis trzeba przeprowadzić jawnie:

class LocationForm(forms.ModelForm):
    # ...

    def save(self, commit=True):
        instance = super(LocationForm, self).save(commit=False)
        destination = self.cleaned_data['destination']

        instance.destination_lat = destination['lat']
        instance.destination_lng = destination['lng']

        if commit:
            instance.save()
        return instance

Jeśli model miałby zdefiniowane pole destination klasy django.contrib.postgres.fields.JSONField lub analogicznej, to aktualizacja za pomocą ColanderSchemaField powinna przebiec automatycznie bez konieczności nadpisywania metody save(). Niestety jeszcze nie testowałem tego wariantu, do czego gorąco zachęcam.

Colander i Django: ModelChoice

Budując REST API mogą przydać się walidatory danych wejściowych bardziej elastyczne niż formularze Django. Do realizacji tego celu polecam i używam Colander.

Colander jest świetnym pakietem do (de)serializacji danych. Jest to odpowiednik formularzy Django dla Pyramida, lecz zdecydowanie bardziej elastyczny oraz bez związku HTML. Formularze Django są źle zaprojektowane - zbyt ściśle związane z HTML (widgety), których przecież nie używamy budując REST API, oraz są ograniczone do płaskich struktur. Jedynie jest są dyspozycji formsety, co pozwala zwalidować i zdeserializować listę obiektów jednego typu.

Colander wolny jest od tych wad, a ponieważ nie ma zależności od Pyramid (jest pakietem samodzielnym) to można go użyć z Django bez żadnego problemu. Jednak w niektórych sytuacjach brakuje mi pola typu ModelChoice, co utrudnia fabrykowanie instancji modeli. Z tego powodu zrobiłem poniższy snippet definiujący walidator ModelOneOf oraz klasę węzła ModelChoice. Jest na tyle przydatny, że postanowiłem się z nim podzielić jak najszybciej:

import colander
import types


class ModelOneOf(object):
    def __init__(self, qs):
        self._qs = qs

    def __call__(self, node, value):
        if not self._qs.filter(pk=value).exists():
            raise colander.Invalid(node, '%r is not valid choice' % value)


class ModelChoice(colander.SchemaType):
    def __init__(self, qs, *args, **kw):
        self._qs = qs
        self._model = qs.model
        self._validate = ModelOneOf(self._qs)

        super(ModelChoice, self).__init__(*args, **kw)

    def serialize(self, node, appstruct):
        if appstruct is colander.null:
            return colander.null
        if not isinstance(appstruct, self._model):
            raise colander.Invalid(
                    node, '%r is not a %s' % (appstruct, self._model))
        return appstruct.pk

    def deserialize(self, node, cstruct):
        if cstruct is colander.null:
            return colander.null
        if not isinstance(cstruct, (types.StringType, int)):
            raise colander.Invalid(
                    node, '%r is not a string nor int' % cstruct)

        self._validate(node, cstruct)
        return self._qs.get(pk=cstruct)

Przykładowe użycie (forms.py):

import colander
from .models import MyModel


class MySchema(colander.MappingSchema):
    model = colander.SchemaNode(ModelChoice(MyModel.objects.all()))
In [1]: s = MySchema()
In [2]: s.deserialize({'model': '1'})  # gdzie '1' to wartość PK
Out[2]: {'model': <MyModel pk=1>} 

Teraz zdeserializowane dane wejściowe da się wprost przekazać do fabryk lub konstruktora modeli, np:

instance = MyModel(**s.deserialize(...))

W ten sposób można całkowicie pominąć formularze django.

Django - migracje bazy, które nie zawsze działają

O kiepskim podejściu do migracji schematów baz danych w Django pisałem już w 2014r. Co jakiś czas jednak temat do mnie wraca, gdyż w niektórych projektach używam (niestety) tego rozwiązania. Powody są różne, a główny to "oszczędność czasu". No bo trudno zaprzeczyć, że automatyczne wygenerowanie plików z migracjami jest wolniejsze od klepania XML-i Liquibase lub plain SQL, prawda? Sam z tego przecież korzystam...

Jednak bywają takie momenty, gdzie zostaję z tymi migracjami w "czterech literach", gdzie nawet nie dochodzi ani jeden promyk światła. I taką sytuacją jest m.in. usuwanie atrybutu z modelu, co generuje operację RemoveField.

Bodaj wszystkie wbudowane w Django operacje posiadają mechanikę pozwalającą wycofać daną operację, RemoveField także. Ta akurat nie zadziała, gdy pole było zadeklarowane jako NOT NULL, ponieważ:

  • Django nie ustawia defaultów na poziomie baz danych, więc nigdy nie wygeneruje defaulta na poziomie SQL (co byłoby rozwiązaniem w sytuacjach, gdy default masz określony)
  • Implementacja backward w RemoveField jest oparta o pseudo-automat, tzw. schema editor, a ten "wie" że ma być NOT NULL (i nikt go nie przekona)
  • Sekwencja rollback dla przypadku usuwania pola jest bardziej złożona i zależna od kontekstu (dodanie pola nullable, wypełnienie danymi, zmiana na not null).

Często jest tak, że sekwencja backward (rollback) jest inna od sekwencji forward i nie zawsze każdej operacji forward odpowiada dokładnie jedna operacja backward (i odwrotnie). To skłania mnie do postawienia tezy:

Sekwencje rollback (backward operations) powinny być definiowane niezależne od forward operations, tj. plik migracji powinien mieć dwie oddzielne ścieżki, które mogą być oczywiście automatycznie generowane. 

To kolejny argument za stwierdzeniem, że wbudowane w Django migracje są zaimplementowane w oparciu o wadliwy koncept. Ale jest na to trochę nieczytelne obejście - operacja RunSQL i puste przebiegi forward.

Rozwiązanie tego konkretnego zagadnienia?

Odstawić czarodzieja schema editor, czyli zamienić operację RemoveField na RunSQL usuwającą kolumnę w forwardzie, ale przywracającą w backwardzie z dozwolonym null. Następnie zadeklarować RunSQL uzupełniający dane usuwanej kolumny w backwardzie, a w forwardzie nie robiący nic (''). Tę operację (lub ich sekwencję) należy umieścić przed alterem usuwającym kolumnę, aby w backwardzie wykonała się po przywróceniu tejże, a zakończyć (rozpocząć) od operacji RunSQL zakładającej w backwardzie not null. Partyzanckie, ale działa.

Czyli:

operations = [ 
    migrations.RemoveField('table', 'attribute'),
]

zamieniamy na:

operations = [
     migrations.RunSQL('', 'alter table <table> alter column <column> set not null'),
     migrations.RunSQL('', 'update <table> set <column> = ....'),
     migrations.RunSQL(
         'alter table <table> drop column <column>',
         'alter table <table> add column <column> [...] NULL'),
]

A na przyszłość usuwając pola z modeli należy wygenerować trzy oddzielne migracje:

  • pierwsza ma ustawiać nullable
  • druga ma być data migration, która w forwadzie nie robi nic (lub robi, jeśli dane przenosimy), a w backwardzie ma kod uzupełniający dane
  • trzecia usuwa kolumnę.

Odwracając proces uzyskamy:

  • dodanie kolumny nullable (schema editor będzie wówczas "mądrzejszy")
  • uzupełnienie kolumny danymi
  • założenie not null constraint.

Kij w mrowisko: Django Sprint 2014, Kraków

15 i 16 lutego 2014 odbył się Django Sprint w Krakowie, na którym najlepsi programiści Python poprawiali błędy Django. A ja zapytuję - po co poprawiać coś, co jest w wielu miejscach źle zrealizowane "by design"?

Oczywiście przesadzam, ale nie mogę oprzeć się krytyce, skoro na prawdę istotne wady Django nie są korygowane a istotne feature requesty są odrzucane za "niezgodność z Django ethos". Kilka najbardziej irytujących błędów i braków na mojej liście:

Brak model.reload()

Odświeżenie properties obiektu jest czasem konieczne, bo DjangoORM nie ma sensownego persistence managera (błąd projektowy polega na własnej i przeciętnej implementacji ORM, zamiast zastosowania sprawdzonych wzorców lub gotowych rozwiązań).

Obecnie trzeba obchodzić przez sekwencję

obj = Model.objects.get(pk=obj.pk)

Skopany transaction management

Do v1.6 mamy do dyspozycji dekoratory commit_on_success oraz commit_manually, a także low-level API z funkcjami commit, rollback, savepoint. Problemy są następujące:

  • obsługa commit_manually sprowadza się do zabawy w dostawianie rollbacków i commitów w odpowiednich miejscach, np. przed/po response, w przeciwnym razie otrzymujemy TransactionManagementError z poziomu template/middleware.
  • przy commit_manually każdy wyjątek opakowywany jest w TransactionManagementError, więc trzeba używać debuggera lub usuwać wsparcie dla transakcji, aby dowiedzieć się o szczegółach błędu (sic!)
  • od wersji 1.6 mamy atomic, który zastępuje powyższe funkcje i upraszcza obsługę transakcji, ale od v1.8 nie będzie już żadnej możliwości niskopoziomowego kontrolowania transakcji (będzie prościej i jednoznacznie, ale ze związanymi rękoma)
  • wsparcie multidb jest skopane, więc szereg funkcji i metod ma dodatkowy parametr using, który domyślnie oznacza bazędefault. W przypadku dekoratorów i funkcji związanych z transakcjami ma to fatalny skutek w postaci zarządzania wyłącznie transakcjami dla bazy domyślnej, ale niekoniecznie używanej w danym widoku (!!!), chyba że jawnie użyje się N dekoratorów dla wszystkich używanych baz (to nie jest czytelne i takie oczywiste, szczególnie przy class-based views).

Formularze spaghetti

Związek formularzy z HTML (widgetami) to fatalny błąd projektowy. Formularze służą walidacji i czyszczeniu danych i czasem zawierają w sobie jakieś konkretne operacje (np. form.save). Niestety każdy field jest ściśle związany ze swoim widgetem HTML, co widać wyraźnie przy próbie odczytania błędów formularza, gdzie jako reprezentację ciągu otrzymujemy HTML! Implementując RESTful service albo jakąkolwiek inną usługę nie związaną z HTML borykamy się z bagażem zależności, które trzeba sprytnie omijać.

Widgety zaimplementowane są w stylu "spaghetti" - w kodzie pythona odnajdujemy rzeź generującą koślawe HTML-e (nienależyta separacja prezentacji od kontrolera). Aby dostosować prezentację trzeba uciekać się do pakietów rozszerzających możliwości parametryzowania prezentacji (Uniform, Crispy forms), ale one też ostatecznie nie są elastycznym i poprawnym rozwiązaniem. Problemy wynikają właściwie z nieco ułomnego systemu szablonów (zbyt wiele działań oddelegowanych jest do helperów).

Wadliwa jest obsługa pól, gdzie definicja Meta.fields jest "magicznie" uzupełniana o wszystkie pola dodane w klasie formularza. Dziedziczenie po takim formularzu uniemożliwia proste ograniczanie używanych pól i trzeba usuwać je w __init__ (!).

Wadliwa jest obsługa formularzy bez pól, w przypadku których Django zakłada zawsze brak błędów, mimo zdefiniowania metody clean ze ścisłymi regułami!

Ograniczony system szablonów

Django ethos nie pozwala na dostarczenie tak podstawowego template taga czy rozwiązania, jakim jest dostęp do danych przez klucz/indeks określony zmienną. Za każdym razem trzeba pisać swój template tag lookup czy get_key

Ze względu na strukturę ograniczone są możliwości łatwej wizualizacji bardziej skomplikowanych obiektów, jak np. formularzy.

Co robić, wuju?

Jak radzić sobie z Django w prawdziwych projektach podpowiada Christophe Pettus z PostgreSQL Experts, Inc w PDF Unbreaking Django.

Migracje struktur baz danych w Django

Nie tak dawno temu wielu zarzucało Django, że w przeciwieństwie do Railsów nie posiada wbudowanego rozwiązania umożliwiającego wersjonowanie i modyfikowanie struktur bazodanowych. Niedługo po tym pojawił się South - dosyć obiecujące rozwiązanie, które oczywiście szybko przygarnąłem do projektów. Z czasem jednak okazało się, że architektura tego rozwiązania ma szereg wad.

Na południe

Podstawowym problemem a zarazem zaletą South jest związek migracji z aplikacjami Django. To poszczególne aplikacje są "dostawcami" migracji. Ma to na celu umożliwienie łatwiejszego utrzymania reusable apps, których upgrade w projektach był bez South bardzo trudny - wymagał poznania zmian struktury i własnoręcznego przygotowania skryptów SQL. O ile było to mozolne ale do zrobienia, to ustalenie migracji danych nie sposób było odtworzyć. Dzięki South sprawa się uprościła.

Podczas utrzymania systemu pojawia się jednak kilka problemów, na które South już częściowo jest odporny:

  • kolejność wykonywania migracji (changesetów) jest bardzo istotna
  • często istnieje potrzeba customizacji reusable apps "pod projekt"

South uruchamia migracje kolejno wg listy settings.INSTALLED_APPS, a dalej w porządku leksykograficznym nazw plików migracyjnych. Jednak nie zawsze jest to dobra kolejność. Choć problem ten został częściowo rozwiązany przez mechanizm zależności, to nie zawsze jest on idealnym rozwiązaniem:

System migracji wbudowany w Django

Niedawno developerzy Django zapowiedzieli, że od wersji 1.7 będzie wbudowane wsparcie dla migracji. Po zapoznaniu się z treścią dokumentu widzimy zmutowanego South. Teraz developerzy reusable apps przygotowują swoje aplikacje pod to "jedynie słuszne" i niestety wadliwe rozwiązanie. Ten mechanizm migracji nie sprawdzi się w większych projektach, bo ma w sobie te same wady, które posiada South. Django developers podjęli kolejną fatalną decyzję projektową. Trochę mnie to nie dziwi, bo kiedy tracą ogrom czasu i sił na pierdoły, to na inne tematy nie mają należytego czasu.

Za wadę obydwóch rozwiązań uważam ścisły związek z ORM, oraz tym samym przygotowywanie migracji w Pythonie. To pozwala niezbyt rozsądnym programistom chadzać na zwodnicze skróty, np. odwoływać się do funkcji/metod wyparowujących z czasem z projektu czy używać modeli bezpośrednio z models.py zamiast z zamrożonych definicji... Migracje schematów baz danych są związane tylko z bazami danych i warstwa aplikacji jest w tym procesie nie tylko zbędna, ale też furtką dla złych praktyk oraz nawet swego rodzaju narzutem.

Jedynymi zaletami migracji South/Django jest ich prostota i automatyczne generowanie.

Jak powinno zarządzać się zmianami struktury i danych w bazie

  • obowiązujące changesety powinny być prowadzone project-wide (Nashvegas, Liquibase - przy czym Nashvegas jest skopany - nie używać)
  • reusable apps powinny dostarczać jedynie bazowe changesety, które maintainer projektu mógłby świadomie wybierać do migracji project-wide
  • powinno istnieć narzędzie ułatwiające opracowywanie oraz importowanie changesetów do projektu

Dlaczego to zadziała?

  • mamy kontrolę nad każdym changesetem, w tym wprowadzania potrzebnych zmian
  • kolejność wykonywania changesetów jest niezmienna, niezależnie od zainstalowanych reusable apps i zawartości settings.INSTALLED_APPS
  • mamy możliwość dodawania w cały proces swoich changesetów (customizacji) z zachowaniem precyzji w kolejności ich wykonania
  • migracje baz danych są niezależne od ORM - struktura BD może się nieco różnić, a także mamy swobodę w budowaniu różnych mappingów w warstwie aplikacji
  • w migracjach SQL nie da się odwoływać do warstwy aplikacji, zatem są odporne na zmiany samej aplikacji i wykonają się zawsze (niezależnie od zmian w warstwie aplikacji)

W praktyce, w dużych projektach, używam Liquibase wraz z dodatkiem Liquimigrate do Django. To działa ZAWSZE. Kosztem jest brak automatu przy tworzeniu changesetów, ale SQL jest wygodniejszy i lepszy do tego zastosowania.

Liquimigrate docelowo widzę jako pakiet niezależny od Django, ale z mostkami do środowiska Django i innych frameworków, które pozwolą na łatwiejszą integrację i konfigurację.

System migracji Django 1.7 będzie niestety w takich projektach bezużyteczny, a sam framework rozwija się sukcesywnie w niezbyt dobrym kierunku.

Django i None/null w BooleanField (forms)

Dostałem kolejnym issue związany z "nietypowym" zastosowaniem Django, a typowym w prawdziwym świecie.

Powszechnie wiadomo, że formularze w Django służą do walidacji danych. Niestety błąd projektowy Django polega na tym, że formularze są ściśle związane z danymi wejściowymi pochodzącymi z HTML, a konkretnie związane są widgetami HTML.

Formularze można wykorzystać do walidacji jakichkolwiek wejściowych danych (np. przy imporcie z CSV), przełykając niepotrzebne widgety HTML oraz godząc się na komunikaty o błędach walidacji formatowane do HTML (co jest także swoistym nonsensem, ale od v1.7 będzie już możliwość zwrócenia jako dict lub JSON). Jednak kiedy spróbujecie zrobić API i formularz walidujący dane wejściowe z requestu z użyciem NullBooleanField, to się niemiło zaskoczycie.

Praktyka, czyli rozbicie się o mur

Potrzebujemy filtrowania zasobu restful API i parametry przkazywane przez GET mają charakter opcjonalny. W przypadku parametrów typu bool potrzebujemy rozróżnienia trzech stanów:

  • nie użyto filtra
  • filtr dla wartości True - cleaning z [1,'1','true','True'], jak w to_python()
  • filtr dla wartości False

Dobrym zwyczajem walidację filtra przepuszczamy przez formularz. Podczas definicji formularza mamy dwie możliwości:

  • forms.BooleanField, który mimo required=False defaultowo zwraca zawsze False, zatem odpada
  • forms.NullBooleanField, który zezwala na None

Niestety żadne z powyższych nie działa dla wartości wejściowych ze zbioru [1,'1','True','true']. Pierwsze wiadomo dlaczego: z definicji nie przyjmuje None, więc mamy False by default. Ale dlaczego nie działa NullBooleanField? Bo NullBoolenField w cleaningu odwołuje się do parsowania danych za pomocą... widgetu NullBooleanSelect, którego implementacja wygląda tak:

def value_from_datadict(self, data, files, name):
    value = data.get(name, None)
    return {u'2': True,
            True: True,
            'True': True,
            u'3': False,
            'False': False,
            False: False}.get(value, None)

Niech was gęś kopnie, cudaczni developerzy Django! Nie wszędzie używa się wejścia z waszych wbudowanych widgetów HTML! Dlaczego widget HTML jest domyślny i używany nawet przy wejściu nie pochodzącym z tego widgetu? Nie wiedzieć... Może to Django ethos.

Obejście problemu

class MyNullBooleanSelectWidget(forms.NullBooleanSelect):
    def __init__(self, *args, **kw):
        super(MyNullBooleanSelectWidget, self).__init__(*args, **kw)
        self.choices = ((u'', _('Unknown')),
                   (u'1', _('Yes')),
                   (u'0', _('No')))

    def render(self, *args, **kw):
        """
        tu mozna sobie zaimplementowac rendering w razie potrzeby,
        bo Django developers zlamali DRY i nie uzywaja choices 
        w renderingu; (kolejny klops na kolejny wpis)
        """
        raise NotImplementedError("Not used")

    def value_from_datadict(self, data, files, name):
        value = data.get(name, None)
        if value is None or value=='':
            return None
        if value in ('True', 'true', '1', 1):
            return True
        return False


class MyNullBooleanField(forms.NullBooleanField):
    widget = MyNullBooleanSelectWidget

W formsach API używamy MyNullBooleanField zamiast forms.NullBooleanField. A dla profesjonalnych zastosowań w kontekście budowania API polecam Colander i oczywiście Pyramid.

Konkluzja

Nie pchajcie się ludzie w Django, jeśli nie robicie kolejnego bloga albo frontendu dla jakiegoś portalu.

Opisane zachowanie oparłem o Django v1.4, ale w źródłach master widzę ten sam gnój.

Django handler500, request.is_ajax() i AngularJS

Kolejny wypadek Django przy pracy, który powinien przekonać niedowiarków i wyznawców Django, że ten framework nadaje się do stawiania blogasków, ale niespecjalnie jako server side dla aplikacji.

handler500 i DEBUG=True

Przy włączonym DEBUG handlery 403,404 i 500 nie są używane. Do akcji wkraczają tzw. technical responses, które generują specjalny output dla poszczególnych błędów. Z reguły są one bardzo przydatne, ale pisząc server pod klienta w AngularJS w response otrzymujemy... piękny html ze stylami i css. Przeczytanie błędu graniczy z cudem, a zdevelopowanie poprawnej obsługi błędu w kliencie jest niemożliwe w trybie DEBUG. Niemożliwe, bo nie da się podmienić technical responses. Są one wołane w core handlerze, więc nie ma na to szans.

Możemy w konfiguracji podmienić exception filter, ale on jest używany przez exeption reporter a to na jego wymianie lub rozszerzeniu nam zależy, jeśli chcemy cokolwiek sensownego zrobić z obsługą błędów 403,404,500 w trybie DEBUG!

AngularJS i request.is_ajax()

Implementacja is_ajax() opiera się na jakiejś konwencji. Po pierwsze - skoro to konwencja, to powinno dać się ją zmienić na potrzeby projektu. Oczywiście nie da się, podobnie jak nie da się prosto wymienić klasy HttpRequest, w której podmieniłbym is_ajax (trzeba grzebać low-level na poziomie handlerów wsgi). Monkey patching mnie nie interesuje - readability counts.

Natomiast można skonfigurować AngularJS do współpracy ze zmaszczonym niespecjalnie rozsądnymi rozwiązaniami Django, wystarczy skonfigurować nagłówek:

angular.module('myApp').config(function($httpProvider) {
    $httpProvider.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
});

A co zrobili Django developers?

Ano dodali kiedyś takiego paszkwila w odpowiedzi na zgłoszenie:

if request.is_ajax():
    text = reporter.get_traceback_text()
    return HttpResponseServerError(text, content_type='text/plain')

Świetnie, ale to nie rozwiązuje problemu, bo:

  • HTTP_ACCEPT mam application/json i chcę mieć w response JSON z błędem (!!!)
  • niewiadomo jaki tekstowy response jest słabo parsowalny przez jakąkolwiek implementację JSON-a

Django developers są szczęśliwi i dumni ze swojego podejścia znanego jako "Django ethos". Sukcesów życzę i omijania Django w dużych projektach.

Django CSRF + AngularJS

Zapisuję w formie notatki, aby nikogo (w tym mnie) nie kusiło użycie csrf_exempt ;)

Dodanie obsługi CSRF w AngularJS 1.1.5+ polega na skonfigurowaniu nazw nagłówka i cookie:

.config(function($httpProvider) {
    $httpProvider.defaults.xsrfHeaderName = 'X-CSRFToken';
    $httpProvider.defaults.xsrfCookieName = 'csrftoken';
})

Brak wyjątku przy błędach Django compilemessages [v1.4]

Zauważyłem, że compilemessages nie rzuca wyjątków przy błędach msgfmt. Jest to typowe wyciszenie błędu przez brak sprawdzania returncode. To praktyka naruszająca Zen of Python, tj. regułę "Errors should never pass silently".

Przed zgłoszeniem ticketa warto sprawdzić, czy ktoś już tego nie raportował. Okazuje się, że zgłoszenie już jest od dwóch miesięcy, ale uwaga! Jest już poprawka zamieniającą os.system() na subprocess.Popen()!

W przeciwieństwie do ticketów otwartych od wielu lat to na prawdę szybka reakcja. Jestem mile zaskoczony!

https://code.djangoproject.com/ticket/19584

Django NoDB Test Runner - czyli czuję zapach świeżej mogiły

A tak se wstawiłem, a co...

Developerzy Django swego czasu nie potrafili wyobrazić sobie, że może istnieć w naszym niemałym programistycznym świecie potrzeba odpalania testów bez bazy (baz) danych. Minęły trzy lata i zaserwowali nam w końcu (1.2) możliwość zdefiniowania własnego test runnera w prosty sposób (class based).

Prymitywny test runner bez bazy danych:

class NoDbTestRunner(DjangoTestSuiteRunner):
  """ A test runner to test without database creation """

  def setup_databases(self, **kwargs):
    """ Override the database creation defined in parent class """
    pass

  def teardown_databases(self, old_config, **kwargs):
    """ Override the database teardown defined in parent class """
    pass

który podpina się w settings równie trywialnie:

TEST_RUNNER = 'testrunner.NoDbTestRunner'

Pal sześć, że potrzebuję suite do testów z bazą równocześnie z suite bez niej, i żeby to osiągnąć będę musiał się jeszcze sporo napocić dorabiając najlepiej wykrywanie potrzeby użycia testowych baz. Najgorsze jednak w tym wszystkim jest to, że połączenia testowe tworzone są przez defaultowy test runner, który właśnie wywaliliśmy w kosmos. Jeśli myślicie, że brak jego odpalenia będzie skutkował brakiem zdefiniowanych połączeń i wyjątkami, to jesteście w błędzie. Oryginalny test runner ZAMIENIA parametry definicji ISTNIEJĄCYCH połączeń.

Skutkiem tego jest uruchamianie testów na ustawieniach defaultowych. A czy wiecie co robi Django przy wczytwaniu fixturek do testów? Flush DB. Dziękuję Ci, Django. Mam nadzieję, że teraz nie odpalicie NoDbTests na swoich serwerach live.

Fatalny ORM i, jak się okazuje, fatalne rozwiązanie frameworka testowego w Django zaczynają przekonywać mnie do skierowania się w stronę innego produktu, szczególnie gdy realizuje się systemy większe niż "przeciętny blogasek".

Django Page CMS

Jest taki projekt, jak w tytule. Linkował nie będę, radzę tylko omijać.

  • niedeterministyczna identyfikacja stron wg slug ("item1/item2" jest tożsamy z "item1/costam/gdziestam/item2")
  • brak szablonów w BD (jedynie konfigurowane w settings; ich zmiana nie powoduje błędu tylko problemy wyświetlania)
  • nieoptymalne generowanie menu
  • publikacja strony odbywa się przez ustawienie wartości Published na "in navigation" lub "hidden" (sic!)
  • problemy z cache (źle użyty mechanizm powoduje problemy przy backendzie innym niż memcached)
  • parszywa implementacja wielojęzykowości
  • ograniczony panel admina przez przesłonięte szablony dla changelist_view (sztywne kolumny, brak features, etc)
  • wyświetlanie stron cms zawsze gdy 404 domyślna trasa do wyświetlenia strony z regexp .* (w połączeniu z błędem rozpoznawania po slug powoduje niesamowite wrażenia)

Lepiej od razu iść w kierunku https://www.django-cms.org/ albo własnej implementacji pod konkretny cel.

Kiedy Django zawodzi

Słoneczny dzień

Django jest świetnym rozwiązaniem należącym do kategorii full stack framework. Spójna budowa, setki aplikacji, rozwiązania generyczne (content types framework, comments framework) - batteries included. Świetne narzędzie do realizacji małych i średnich projektów, pozwala zaoszczędzić wiele czasu, nawet gdy istnieje potrzeba napisania od podstaw własnych rozszerzeń. Ostatnio jednak natrafiłem na mur.

Nadciągają cumulusy, czy rozumiesz co to znaczy?

Realizuję zupełnie nową funkcjonalność do portalu górskiego , która generuje sporo żądań XHR. Z rozpędu dodałem widoki w Django i podpiąłem je do szablonów. Rezultat powalił mnie... powolnością odpowiedzi. I to nie chodzi już o benchmarki, tylko o zwykłe odczucia użytkownika.

Szukając rozwiązania udałem się w kierunku Twisted i Tornado. Twisted jest dojrzałym frameworkiem sieciowym sterowanym zdarzeniami, a Tornado młodym web serverem napisanym specjalnie na potrzeby FriendFeed. Obydwa produkty cechuje wysoka wydajność, lecz z racji prostoty Tornado zająłem się nim na pierwszym miejscu. Niestety polubiłem go i mam nadzieję, że kiedyś wrócę do Twisted przy realizacji jakiegoś projektu. Do rzeczy.

Tornado

Tornado wieje z siłą urywającą nie łeb, ale setki głów. Niech poniższy kod (zacytowany ze strony Tornado) posłuży za prosty benchmark i przykład jednocześnie:

import tornado.httpserver
import tornado.ioloop
import tornado.web

class MainHandler(tornado.web.RequestHandler):
    def get(self):
        self.write("Hello, world")

application = tornado.web.Application([
    (r"/", MainHandler),
])

if __name__ == "__main__":
    http_server = tornado.httpserver.HTTPServer(application)
    http_server.listen(8888)
    tornado.ioloop.IOLoop.instance().start()

Wyniki prostego pomiaru na mojej niespecjalnej maszynie:

$ ab -c 1000 -n 10000 http://127.0.0.1:8888/
>> 3208.29 [#/sec]

Zainteresowanych dokładniejszymi testami odsyłam do porównania serwerów asynchronicznych.

Oczywiście docelowo nie będzie tak różowo. Zapytania bazodanowe i kosztowne algorytmy osłabią ten wynik. Krótkie podsumowanie wydajności tej samej funkcji REST API:

  • Django/ORM: 2.47 rq/sec
  • Django/plain db queries (podsystem jako osobny pakiet): 66 rq/sec
  • Tornado/plain db queries (dokladnie to samo, co wyżej): 215 rq/sec (-c100 -n10000, 0 failed)
  • Tornado/plain db queries/in memory cache: 2183 rq/sec (-c100 -n10000, 0 failed)

Django server zapychał się już przy 400 requestach przy concurrency 10.

Jak przetrwać burzę

Jeśli projekt oparłeś w całości na Django, to skazany będziesz albo na refaktoryzację, albo na kombinatorykę pozwalającą importować moduły z aplikacji Django. Ja popełniłem ten błąd i umieściłem API oraz algorytmy w pakiecie zależnym od Django. Na szczęście był to dopiero prototyp i przeniosłem całość do odrębnego pakietu.

Prawidłowe rozwarstwienie podsystemu uczyni go łatwym do integracji. Dobrze jest wydzielić core do oddzielnego pakietu. Nie stosować w nim rozwiązań opierających się na elementach frameworków typu full-stack, szczególnie nie przywiązywać się do warstwy dostępu do danych. Zastosować wzorzec proxy, który ułatwi również opracowanie unit testów. Nie ulegać magii ani kucom na każdym kroku.

Symfony forms vs Django forms

Mam okazję pracować z obydwoma frameworkami i mogę je porównać w praktyce. Django zacząłem używać jakieś dwa lata temu, a Symfony nieco wcześniej (od wydania stabilnej wersji 1.0).

Ostatnimi czasy, z braku możliwości upgrade Symfony w projekcie, przeportowałem mechanizm formularzy z wersji 1.1 do 1.0.20.

Piersze wrażenie

Mechanika formularzy w Symfony mocno przypomina newforms z Django. Powszechnie wiadomo, że Fabien jest fanem Django, więc nie zdziwiło mnie zbyt specjalnie, że wzorował się właśnie na nim.

Mamy do dyspozycji podstawową klasę formularza sfForm (django.forms.Form), oraz klasę dedykowaną do modelu Propela sfPropelForm (django.forms.ModelForm).

Jest do dyspozycji zbiór widgetów w klasach sfWidgetForm (django.forms.widgets) do renderingu pól oraz zestaw walidatorów w klasach sfValidator (w Django sprawdzanie poprawności danych jest rozwiązane przez wywoływanie metod clean_FIELDNAME() formularza, o ile zostały zdefiniowane, oraz metody clean() każdego pola). W Symfony jest możliwość napisania własnych specyficznych widgetów i walidatorów.

Używanie formularzy jest podobne - w widoku (sf: akcji), najczęściej przy requescie wysłanym POST-em instancjonujemy formularz i wypelniamy go danymi z requestu, sprawdzamy czy jest prawidlowy (sfForm::is_valid()). W przypadku sfPropelForm (ModelForm) po walidacji wywołujemy save().

Definiowanie formularzy

W Django używamy class properties i zagnieżdżonej klasy Meta.

class MyForm(forms.Form):
  name = forms.CharField()
  birthdate = formd.DateField()

  class Meta:
    exclude = ('birthdate',)

W Symfony formularz konfigurowany jest w metodzie sfForm::configure(), która uruchamiana jest domyslnie w konstruktorze.

class MyForm extends sfForm
{
  protected function configure() {
    $this->setWidgets(array(
        'name' => new sfWidgetFormInput(array('required'=>true,)),
        'birthdate' => new sfWidgetFormDate(array('required'=>true,)),
        ));
  }
}

Ops, ale.. jak wykluczyć datę urodzin?

Google dają odpowiedź:

  • Q: How to exclude a field?
  • A: You have to remove the widget and the validator.

W Symfony zwykle kończy się to na klasie pochodnej, przeciążeniu configure() z serią unset na widgetSchema i validators. Możliwe jest (wszystko jest możlwie do realizacji, ale z różną efektywnością i poziomem trudności), ale trochę uciążliwe.

Django-Symfony 1:0

Uważny czytelnik zwróci uwagę, że w Symfony nie definiujemy pół formularza (sic!) tylko oddzielnie widgety i validatory. Mimo, że istnieje klasa sfFormField, jest ona używana już po bindingu (zawiera w sobie wartość pola - w Django jest BoundField, które opakowuje Field i dostarcza wartość).

Jaka jest zatem kolejna wada formularzy Symfony? Nie ma zdefiniowanych klas pól formularzy, które naturalnie łączą widgety i walidaję danych, np. CharField związane jest domyślnie z widgetem TextInput i posiada domyślną walidację w metodzie CharField.clean(). W Symfony trzeba dodać walidację samodzielnie:

class MyForm extends sfForm
{
  protected function configure() {
    $this->setWidgets(array(
        'name' => new sfWidgetFormInput(array('required'=>true,)),
        'birthdate' => new sfWidgetFormDate(array('required'=>true,)),
        ));

    $this->setValidators(array(
        'name' => new sfValidatorString(array('required'=>true)),
        ));
  }
}

Walidacja pola jest zwykle specyficzna. W Symfony trzeba napisać dedykowaną klasę walidatora. W Django implementujemy metodę clean_FIELDNAME w klasie formularza. Można też przygotować dedykowaną klasę Field.

Django-Symfony 2:0

I18n (czyli formularze po polsku)

W Django etykiety i komunikaty walidacji muszą być opakowane w wywołanie funkcji gettext. W komunikatach podstawowych pól używany jest ugettext_lazy. Etykiety musimy zdefiniować ręcznie (za pomocą argumentu label przy definiowaniu pola formularza).

class MyForm(forms.Form):
  name = forms.CharField(label=_('Name'))
  birthdate = formd.DateField(label=_('Birth date'))

  class Meta:
    exclude = ('birthdate',)

Po stronie Symfony zrealizowano to nieco inaczej - przez nastawienie funkcji callbacka do funkcji translate:

  protected function configure() {
      $this->widgetSchema->getFormFormatter()->setTranslationCallable(
          array(sfContext::getInstance()->getI18N(), '__'));
      /* ... */
  }

Pełny kod formularza w Symfony:

class MyForm extends sfForm
{
  protected function configure() {

    $this->widgetSchema->setNameFormat('my_form[%s]');
    $this->widgetSchema->getFormFormatter()->setTranslationCallable(
        array(sfContext::getInstance()->getI18N(), '__'));

    $this->setWidgets(array(
        'name' => new sfWidgetFormInput(array('required'=>true,)),
        'birthdate' => new sfWidgetFormDate(array('required'=>true,)),
        ));

    $this->setValidators(array(
        'name' => new sfValidatorString(array('required'=>true)),
        'birthdate' => new sfValidatorDate(array('required'=>true)),
        ));
  }
}

Pamiętacie jeszcze, że w Django ten sam opis formularza składa się tylko 5 linii kodu? :)

Mimo innego sposobu używania gettext(), to zarówno Django, jak i w Symfony dają radę. Remis.

Django-Symfony 3:1

Domyślne dane (initial parameters)

Django:

  data = {'name': 'Stranger',}
  form = MyForm(initial=data)

Symfony:

  $form = new MyForm();
  $form->setDefaults(array('name'=>'Stranger'));

Praktycznie to samo. Jednak zauważyłem, że pomimo nastawienia wartości domyślnych w formularzu sfPropelForm, pusta wartość pola modelu napisuje to, co przekazujemy w defaults. Jest to dość duży problem, ale może dotyczyć tylko wersji formularzy z Symfony 1.1. Remis.

Django-Symfony 4:2

Binding

Formularze Django w konstruktorze przyjmują parametr data, do którego zwykle przekazuje się request.POST. Analogicznie jest z przesłanymi plikami - request.FILES. Przykład:

def my_form(request):
  if request.method == 'POST':
      form = MyForm(request.POST, request.FILES)
      if form.is_valid():
          model = form.save()
          return redirect(model)

W Symfony jest dość podobnie, aczkolwiek binding należy wykonać oddzielną metodą bind():

public function executeMy_form() {
  $form = new MyForm();
  if($this->getRequest()->getMethod() == sfWebRequest::POST) {
      $form->bind($this->getRequestParameter('my_form'),
        $this->getRequest()->getFiles('my_form'));
      if($form->isValid()) {
          $form->save();
          $this->redirect('/somewhere/');
      }
   }
}

Należy też pamiętać o użyciu prawidłowej zmiennej z requestu - w klasie formularza definiujemy name format (setNameFormat) i te obydwie części muszą się zgadzać. W ten sposób złamano DRY i nie jestem, czy w Symfony da się ten problem jakoś ominąć. Remis.

Django-Symfony 5:3

Częste problemy/tips

  • Własny rendering formularzy. W Symfony musicie pamiętać o jawnym używaniu gettext(), jeśli nie używacie metod render*() pól/formularza.

  • Dostosowywanie formularzy do potrzeb projektu. Django daje duże możliwości dzięki przestrzeniom nazw Pythona i konfiguracji urls/settings. Zwykle w projekcie dodaje się dedykowaną aplikację z modułem zawierającym dostosowane formularze i najczęściej zmienia się konfigurację urls (w reusable apps mamy zwykle do dyspozycji parametr form_class w widokach). Symfony, przynajmniej w wersji 1.0.X, nie daje mechanizmu zbliżonego do urls, chociaż można użyć factories (http://www.symfony-project.org/book/1_0/17-Extending-Symfony#chapter_17_factories) albo innych rozwiązań (ja stosowałem własny kontener IoC). Warto o tym pamiętać, bo we wdrożeniach bardzo często dostosowuje się właśnie formularze.

  • Odpowiednik klasy ModelForm w Symfony nie potrafi "w locie" zbudować definicji formularza. Bazowe klasy dla poszczególnych modeli muszą być wygenerowane (sic!). Zatem użyteczność sfPropelForm bez wygenerowanych bazowych klas jest niewielka i ogranicza się do zaimplementowanej metody save().

Fantazje Fabiena

Na koniec, jako ciekawostkę, dodam krótkie podsumowanie tego, co Fabien zaimplementował będąc albo przemęczonym, albo na kacu ;)

  • schemat widgetów/validatorów (kontener/dict) dziedziczy bezpośrednio po klasie Widget/Validator (znakomity przykład do czego nie używać dziedziczenia)

  • forms embedding (formularze w formularzach) - przyznam, że nie mogę tego pojąć. Nie mamy formsetow, ale możemy zagnieżdżać formularze w sobie.

  • form merging (łączenie formularzy) - nie widze zastosowania (formularz hermetyzuje logikę, sprawdzanie poprawności i sposób prezentacji - po co łączyć, użyjmy kilku formularzy lub Formsetu)

  • I18N w kontekście translacji (gettext) - zahermetyzowane wewnątrz formularza, lecz z możliwością wymiany callable na dowolny; trudne/każdorazowe definiowanie domyślnego callable; gettext powinien być używany, nie zawarty wewnątrz klasy formularza

Podsumowanie

Formularze w Symfony są, a to już lepsze niż nic, które było w wersji 1.0.X. Ostateczny wynik rozgrywki Django-Symfony 5:3. Jeśli ktoś nie używał formularzy w Symfony, to gorąco polecam. Jeśli ktoś ma dylemat co wybrać, polecam Django z racji szerszych możliwości i skrócenia czasu realizacji zadań.

Zmiana silnika bloga

Nadszedł ten czas. Rezygnuję z Bloggera na rzecz własnej strony domowej, dla realizacji której Blogger nie wystarcza.

Postanowiłem użyć aplikacji django-simpler-blog i rozszerzyć ją o brakujące funkcjonalności. Dzięki temu zabiegowi projekt mojej strony domowej jest łatwiejszy do realizacji, a swoją drogą mam wpływ na każdy detal. Ponad to pisanie w edytorze WYSIWYG lub bezopośrednio HTML nie jest sympatyczne. Wolę zdecydowanie Markdown.

Do realizacji tego bloga użyłem również:

  • django.contrib.comments
  • django-tagging