Wyginanie pythona - kontener dyskowy

W pierwszym wpisie z serii wyginania pythona zajęliśmy się dosyć niepraktyczną zabawą w robienie z pythona APL-a. Dzisiaj będzie troszeczkę praktyczniej. Znowu będziemy budować własny typ. Tym razem jednak będzie on miał konkretne zastosowanie - będzie to prosta baza danych. Oczywiście funkcjonalność jej będzie śmieszna dla każdego, kto miał do czynienia z produkcyjnymi bazami relacyjnymi czy obiektowymi. Jednak będzie ona posiadać pewną ważną cechę - praktycznie całkowitą przejrzystość w systemie obiektowym pythona. Zachowywać będzie się jak zwykły słownik, jednak dane w nim będą zapisane na dysku i trwać będą po zamknięciu interpretera.

Mały pomocnik w piklowaniu

Python oferuje nam doskonały mechanizm przechowywania obiektów na dysku zwany piklowaniem. Wykorzystując go będziemy mogli łatwo umieścić w naszej bazie wszystkie wbudowane i większość zdefiniowanych przez nas obiektów. Napiszmy najpierw prostą klasę helperową odpowiedzialną za zapis i ładowanie obiektów.

import pickle
import os

class Record(object):

    def __init__(self, file_, value = None, pos = None):
        self.file_ = file_
        if pos is not None:
            self.pos = pos
        elif value is not None:
            file_.seek(0, os.SEEK_END)
            self.pos = file_.tell()
            pickle.dump(value, file_, 2)
        else:
            raise ValueError, 'Either value or pos must be not None'

    def read(self):
        self.file_.seek(self.pos)
        return pickle.load(self.file_)
    import pickle

Obiekt klasy Record przechowuje dwie wartości. Są to: plik, w którym zapisano przechowywany obiekt oraz pozycja, od której zaczyna się serializacja naszego obiektu. Obiekt ten można zainicjalizować albo tymi dwiema danymi, albo też plikiem i obiektem do zapisania (w tym drugim przypadku nastąpi serializacja tego obiektu i zapamiętanie pozycji). Dodatkowo klasa Record dostarcza nam metody umożliwiającej odczytanie wartości.

Zgodnie z dokumentem PEP 8 używamy tutaj znaku podkreślenia dla uniknięcia konfliktu naszego argumentu file_ z wbudowanym typem file. Co prawda nie jest to obowiązkowe, bo file nie jest słowem kluczowym, ale pozwoli osobie czytającej kod uniknąć nieporozumień.

Założenia naszej bazy

Powiedzmy sobie trochę o założeniach, jakie ma spełniać nasza baza.

  • Dane mają być przechowywane na dysku.
  • W pamięci RAM znajdować się ma jedynie indeks umożliwiający ich odnalezienie.
  • Ma się ona zachowywać jak słownik, tzn. obsługiwać metody __getitem__(), __setitem__() i __delitem__(), które dają możliwość korzystania z operatora [].

Dla uproszczenia narzucimy nazewnictwo dla plików, w których ma być przechowywana baza. Oba pliki (indeksu i danych) muszą nazywać się tak samo, z tym, że plik indeksu ma mieć rozszerzenie .sdi, a plik danych .sdd. Indeks przechowamy w formacie json.

Inicjalizacja bazy danych

import json

class SimpleDB(object):

    def __init__(self, filename):
        self.filename = filename
        self.data_file = open(filename + '.sdd', 'a+')
        try:
            index_file = open(filename + '.sdi', 'r')
            self.load_index()
            index_file.close()
        except IOError:
            self.dict_ = {}


    def load_index(self):
        index_file = open(self.filename + '.sdi', 'r')
        dict_ = json.load(index_file)
        self.dict_ = dict(
            ((key, Record(self.data_file, pos = pos))
             for (key, pos) in dict_.items())
        )
        index_file.close()



    def save_index(self):
        index_file = open(self.filename + '.sdi', 'w')
        json.dump(
            dict((key, rec.pos) for key, rec in self.dict_.items()), index_file
        )
        index_file.close()

Obiekt bazy danych inicjalizujemy nazwą pliku (bez rozszerzenia). Konstruktor próbuje otworzyć istniejącą bazę, a jeśli taka nie istnieje, zakłada, że chcemy stworzyć nową, pustą. W obu przypadkach indeks przechowywany jest w pamięci w postaci słownika mapującego klucze na obiekty klasy Record. Na dysku natomiast słownik mapuje na liczbowe pozycje. Warto zwrócić uwagę, jak wykonywana jest konwersja jednego słownika na drugi. Wykorzystujemy tu komprehensję iteratorów oraz fakt, że słownik można zainicjalizować iteratorem zwracającym krotki (klucz, wartość).

Przeładowanie operatora

Jak już wspomniałem, aby nasz obiekt zachowywał się jak słownik, należy zdefiniować następujące trzy metody:

def __getitem__(self, name):
    return self.dict_[name].read()

def __setitem__(self, name, value):
    rec = Record(self.data_file, value = value)
    self.dict_[name] = rec

def __delitem__(self, name):
    del self.dict_[name]

Ponieważ dla prostoty nie stosujemy tutaj żadnej metody usuwania starych danych (metody __setitem__ i __delitem__ nie kasują starego pikla), baza nasza przydatna jest raczej do takich zastosowań, gdzie raz ustawione wartości pozostają w bazie na bardzo długo. Oczywiście możemy też spróbuwać napisać metodę vacuum, która będzie porządkować nam bazę. To pozostawiam jako proste zadanie dla czytelnika. Poza tym myślę, że implementacja tych trzech metod nie wymaga komentarza i jest zrozumiała.

Baza w naszej obecnej postaci wymaga wywołania funkcji save_index explicite. Unikamy w ten sposób wielokrotnej serializacji ideksu po każdym zapisie. Jeśli jednak chcemy pełnej przejrzystości, możemy dodać jej wywołanie do metod __setitem__() i __delitem__().

Testy

I już. Nasza baza jest funkcjonalna. Możemy się z nią pobawić w sesji interaktywnej i zobaczyć, jak przechowuje swoje dane. Możemy też rozwijać ją dalej i dodawać nowe funkcjonalności. Zanim jednak to zrobimy dobrze jest napisać automatyczny test już istniejących (aby przy każdej zmianie móc zobaczyć, czy nie zepsuliśmy czegoś). Do testów użyjemy pakietu nose.

Naszą pracę zaczniemy od utworzenia bazy ze znanymi danymi i zrzuceniu jej zawartości. Prosta baza zawierająca pod kluczem 'a' string 'hello', a pod kluczem 'b' słownik {'a': 1, 'b': 2} posiada indeks '{"a": 0, "b": 12}' oraz zapiklowane dane '\x80\x02U\x05helloq\x00.\x80\x02}q\x00(U\x01aq\x01K\x01U\x01bq\x02K\x02u.)' Ostatni string odczytałem ładując plik z wiersza interaktywnego pythona. W ten sposób otrzymałem jego postać taką, jaka będzie podobać się pythonowi w jego kodzie. Jak widać większość znaków została tutaj wyescape'owana.

Te wartości posłużą nam jako test fixture, czyli ustalony stan, w którym będą odbywać się wszystkie testy. Stan ten będzie resetowany przed każdym testem, tak że efekty uboczne jednego nie będą wpływały na inne testy. Za ustawienie i posprzątanie odpowiadać będą metody setUp() i tearDown(), które nose wykryje w naszej klasie testowej:

import os

from simpledb import SimpleDB

class TestSimpleDB(object):
    def setUp(self):
        fdata = open('test.sdd', 'w')
        fdata.write(
            '\x80\x02U\x05helloq\x00.\x80\x02}q\x00(U\x01aq\x01K\x01U\x01bq'
            '\x02K\x02u.)'
        )
        fdata.close()

        findex = open('test.sdi', 'w')
        findex.write('{"a": 0, "b": 12}')
        findex.close()

        self.db = SimpleDB('test')

    def tearDown(self):
        os.remove('test.sdd')
        os.remove('test.sdi')

Nazwa klasy jest tutaj istotna, ponieważ nose przegląda tylko te klasy, które "wyglądają jak test". Podobnie ważne jest, aby "jak test wyglądały" metody, w których przetestujemy działanie naszego obiektu.

def test_read(self):
    assert self.db['a'] == 'hello'
    assert self.db['b'] == {'a': 1, 'b': 2}


def test_write(self):
    self.db['c'] = ('a', 'tuple')
    assert self.db['c'] == ('a', 'tuple')
    assert self.db['a'] == 'hello'
    assert self.db['b'] == {'a': 1, 'b': 2}

def test_delete(self):
    del self.db['b']
    try:
        self.db['b']
    except KeyError:
        assert True
    else:
        assert False
    assert self.db['a'] == 'hello'

W każdym z testów używamy słowa kluczowego assert aby sprawdzić pewne założenia. W każdej metodzie możemy użyć takiego wyrażenia kilka razy. Nasze metody sprawdzają, czy da się odczytać dane z bazy, czy da się do niej dopisać, usunąć z niej, a także czy te dwie operacje nie naruszają istniejących wartości. Jeśli zapiszemy klasę testową w pliku o nazwie np. test_sdb.py, to wystarczy wydać polecenie nosetests w katalogu, gdzie się znajduje, aby wszystkie testy zostały wykryte automatycznie. Otrzymamy wtedy następujący komunikat:

$ nosetests
...
----------------------------------------------------------------------
Ran 3 tests in 0.016s

OK

Nose oferuje nam też inne możliwości. Np. jeśli mamy zainstalowany moduł nose-cov możemy wydać polecenie:

$ nosetests --with-coverage --cover-package simpledb

gdzie simpledb to nazwa pliku z naszym kodem i dowiedzieć się, jaką część kodu wywoływana jest w czasie testów, a które linijki jeszcze musimy przetestować. Dla pełnego obrazu odsyłam do dokumentacji nose.

Python jak widzimy umożliwia bardzo łatwe tworzenie obiektów zachowujących się podobnie do wbudowanych, a przez to oferuje niesamowite możliwości rozszerzania własnej funkcjonalności.

Meta

Published: Kwi 1, 2011

Author: zefciu

Comments:  

Word Count: 1,468

Next: Paczkujemy i dystrybuujemy cz. 1

Previous: Społecznościówka w 3 krokach - krok 3

Bookmark and Share

Tags

apl baza_danych nose overloading przeciążenie przeładowanie testy vcs wersjonowanie

Follows Up On

Article Links

  1. PEP 8 -- Style Guide for Python Code | Python.org
  2. Page not found - Something About Orange
Comments powered by Disqus