Hlavní navigace

Validace datových struktur v Pythonu (2. část)

Pavel Tišnovský

Dnes dokončíme popis knihovny Schema a pak se zmíníme o další knihovně určené pro validaci datových struktur. Ta se jmenuje Voluptuous a je založena na podobných principech jako knihovny Schemagic a Schema.

Doba čtení: 26 minut

11. Kontrola unikátnosti ID zaměstnance

12. Zdrojový kód šestého demonstračního příkladu

13. Výsledky běhu šestého demonstračního příkladu

14. Knihovna Voluptuous

15. Ukázka základních možností knihovny Voluptuous v REPLu

16. První příklad – validace obsahu seznamů knihovnou Voluptuous

17. Tři varianty validace celých kladných čísel implementované ve třech knihovnách

18. Druhý příklad – validace obsahu slovníků

19. Repositář s demonstračními příklady

20. Odkazy na Internetu

1. Validace datových struktur v Pythonu (2. část)

V předchozím článku jsme se seznámili s dvojicí knihoven určených pro validaci datových struktur v Pythonu. Připomeňme si, že se jedná o knihovny pojmenované Schemagic a Schema. Obě tyto knihovny jsou založeny na tom, že samotný popis validačních schémat je nadeklarován přímo v Pythonu, takže se případní uživatelé těchto knihoven (což jsou většinou programátoři) nemusí učit nový DSL (doménově specifický jazyk). Knihovna Schemagic je zvláštní tím, že kromě vlastní validace provádí i konverzi dat. Ve stručnosti je možné říci, že validační kritéria jsou představována konverzními funkcemi, které buď konverzi provedou nebo vyhodí výjimku typu ValueError nebo TypeError. Naproti tomu knihovna Schema skutečně provádí pouze validaci datových struktur a proto jsou jinak zapisována i validační kritéria – ta jsou tvořena predikáty, tj. funkcemi vracejícími pravdivostní hodnotu True či False.

Příklad (ten nejjednodušší možný) validace knihovnou Schemagic:

>>> from schemagic import validate_against_schema
>>> validate_against_schema(int, 42)
42

Příklad validace knihovnou Schema:

>>> from schema import Schema
>>> s1 = Schema({"name": str, "surname": str})
 
>>> s1.validate({"name": "Eda", "surname": "Wasserfall"})
{'name': 'Eda', 'surname': 'Wasserfall'}
 
>>> s1.validate({"name": "Eda"})
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/tester/.local/lib/python3.4/site-packages/schema.py", line 290, in validate
    SchemaMissingKeyError('Missing keys: ' + s_missing_keys, e)
schema.SchemaMissingKeyError: Missing keys: 'surname'

2. Dokončení popisu možností nabízených knihovnou Schema

V první polovině dnešního článku nejprve dokončíme popis knihovny Schema. Pravděpodobně se jedná o nejčastěji používanou knihovnu tohoto typu (alespoň v Pythonu), ovšem to může být způsobeno tím, že třetí knihovna Voluptuous má těžko zapamatovatelné jméno, i když jsou některé její vlastnosti lepší. U knihovny Schema si ukážeme použití klauzulí And, Or a Optional i to, jakým způsobem je možné validační schéma vylepšit (a současně zjednodušit) s využitím vlastních tříd implementujících metodu validate. Díky tomu, že se pro validaci používají třídy, je totiž možné implementovat i poměrně sofistikované testy, například zjištění, zda údaje ve všech slovnících mají unikátní ID popř. že je někde ID zduplikováno (samozřejmě lze vymyslet i další testy, v nichž se využívají kontextové informace uložené do objektů).

3. Třetí demonstrační příklad – kontrola platu a pracovní pozice zaměstnanců

Již minule jsme si ukázali některé možnosti nabízené knihovnou Schema. Víme již, jak pomocí predikátů kontrolovat například i hodnoty uložené do slovníků atd. Nyní si tyto vlastnosti otestujeme na dalším příkladu, v němž budeme validovat záznamy (slovníky) obsahující informace o zaměstnancích. V těchto záznamech se budou vyskytovat údaje o platu a taktéž pracovní pozici (ta bude později kontrolována oproti číselníku). Základní validační schéma může vypadat takto:

employee = Schema({"name": str,
                   "surname": str,
                   "id": positive_integer,
                   "salary": salary,
                   "position": str})

Vidíme, že tři hodnoty musí být typu řetězec, ID zaměstnance bude celé kladné číslo a jeho plat bude kontrolován predikátem salary. Oba dva námi vytvořené predikáty jsou prozatím dosti jednoduché:

def positive_integer(value):
    return type(value) is int and value > 0
 
 
def salary(value):
    return type(value) is float and value > 10000.0 and value < 99999.9

Ve druhém predikátu kontrolujeme jak datový typ, tak i to, zda hodnota leží v určeném rozsahu.

Úplný zdrojový kód v pořadí třetího příkladu, v němž zkoušíme možnosti knihovny Schema, vypadá následovně:

#!/usr/bin/env python
# vim: set fileencoding=utf-8
 
from sys import argv
from schema import Schema, SchemaError
 
 
def validate(schema, data, verbose_mode=False):
    try:
        print("\n\n")
        if verbose_mode:
            print(schema)
        print(data)
        schema.validate(data)
        print("pass")
    except SchemaError as e:
        print(e)
 
 
def positive_integer(value):
    return type(value) is int and value > 0
 
 
def salary(value):
    return type(value) is float and value > 10000.0 and value < 99999.9
 
 
employee = Schema({"name": str,
                   "surname": str,
                   "id": positive_integer,
                   "salary": salary,
                   "position": str})
 
 
verbose_mode = "-v" in argv
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": 15000.0,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": 15000,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": -15000.0,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": 1000000.0,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": 15000.0,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "id": 1,
                    "salary": 100000000.0,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": 45000.0,
                    "position": ""},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": 45000.0,
                    "position": "tovarnik"},
                    verbose_mode)
 
validate(employee, {"name": "",
                    "surname": "",
                    "id": 1,
                    "salary": 25000.0,
                    "position": ""},
                    verbose_mode)
Poznámka: příklad lze spustit s volbou -v pro výpis podrobnějších informací o použitém schématu.

4. Výsledky běhu třetího demonstračního příkladu

Podívejme se nyní na výsledky běhu třetího demonstračního příkladu, především na zprávy oznamující úspěšnou popř. neúspěšnou validaci:

{'salary': 15000.0, 'position': 'QA', 'name': 'Eda', 'id': 1, 'surname': 'Wasserfall'}
pass
 
 
 
{'salary': 15000, 'position': 'QA', 'name': 'Eda', 'id': 1, 'surname': 'Wasserfall'}
Key 'salary' error:
salary(15000) should evaluate to True
 
 
 
{'salary': -15000.0, 'position': 'QA', 'name': 'Eda', 'id': 1, 'surname': 'Wasserfall'}
Key 'salary' error:
salary(-15000.0) should evaluate to True
 
 
 
{'salary': 1000000.0, 'position': 'QA', 'name': 'Eda', 'id': 1, 'surname': 'Wasserfall'}
Key 'salary' error:
salary(1000000.0) should evaluate to True
 
 
 
{'salary': 15000.0, 'position': 'QA', 'name': 'Eda', 'id': 1, 'surname': 'Wasserfall'}
pass
 
 
 
{'salary': 100000000.0, 'position': 'QA', 'name': 'Eda', 'id': 1}
Key 'salary' error:
salary(100000000.0) should evaluate to True
 
 
 
{'salary': 45000.0, 'position': '', 'name': 'Eda', 'id': 1, 'surname': 'Wasserfall'}
pass
 
 
 
{'salary': 45000.0, 'position': 'tovarnik', 'name': 'Eda', 'id': 1, 'surname': 'Wasserfall'}
pass
 
 
 
{'salary': 25000.0, 'position': '', 'name': '', 'id': 1, 'surname': ''}
pass

5. Čtvrtý demonstrační příklad – použití klauzulí AndOr

Při pohledu na předchozí příklad je patrné, že validace ještě v žádném případě není dokonalá. Především se může stát, že plat bude zapsán formou celého čísla, vůbec nekontrolujeme, zda není jméno/příjmení prázdný řetězec a dále není kontrolována ani pracovní pozice. Vylepšení bude provedeno s pomocí klauzulí And a Or, které dokážou spojit více predikátů logickou spojkou. Nejprve si však vytvoříme nové predikáty, které použijeme (bez dalšího popisu – jsou prajednoduché):

def positive_integer(value):
    return type(value) is int and value > 0
 
 
def positive_float(value):
    return type(value) is float and value > 0

Dále si vytvoříme jednoduchý číselník se jmény pracovních pozic:

POSITIONS = ["QA", "DevOps", "Admin", "Docs", "HR"]

A konečně můžeme napsat nové validační schéma, tentokrát pomocí spojek And a Or:

employee = Schema({"name": And(str, len),
                   "surname": And(str, len),
                   "id": positive_integer,
                   "salary": Or(positive_integer, positive_float, lambda x: x > 10000.0 and x < 99999.0),
                   "position": And(str, lambda s: s in POSITIONS)})
Validační kritérium u „salary“ není napsáno správně – pokuste se ho opravit před přečtením dalších kapitol.

Úplný zdrojový kód v pořadí již čtvrtého příkladu, v němž zkoušíme možnosti knihovny Schema, vypadá následovně:

#!/usr/bin/env python
# vim: set fileencoding=utf-8
 
from sys import argv
from schema import Schema, SchemaError, And, Or
 
 
def validate(schema, data, verbose_mode=False):
    try:
        print("\n\n")
        if verbose_mode:
            print(schema)
        print(data)
        schema.validate(data)
        print("pass")
    except SchemaError as e:
        print(e)
 
 
def positive_integer(value):
    return type(value) is int and value > 0
 
 
def positive_float(value):
    return type(value) is float and value > 0
 
 
POSITIONS = ["QA", "DevOps", "Admin", "Docs", "HR"]
 
employee = Schema({"name": And(str, len),
                   "surname": And(str, len),
                   "id": positive_integer,
                   "salary": Or(positive_integer, positive_float, lambda x: x > 10000.0 and x < 99999.0),
                   "position": And(str, lambda s: s in POSITIONS)})
 
verbose_mode = "-v" in argv
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": 15000.0,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": 15000,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": -15000.0,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": 1000000.0,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": 15000.0,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "id": 1,
                    "salary": 100000000.0,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": 45000.0,
                    "position": ""},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": 45000.0,
                    "position": "tovarnik"},
                    verbose_mode)
 
validate(employee, {"name": "",
                    "surname": "",
                    "id": 1,
                    "salary": 25000.0,
                    "position": ""},
                    verbose_mode)

6. Výsledky běhu čtvrtého demonstračního příkladu

Pokud předchozí příklad spustíme, měly by se na standardní výstup vypsat následující zprávy o úspěchu či neúspěchu validace jednotlivých informací o zaměstnancích:

{'position': 'QA', 'id': 1, 'salary': 15000.0, 'name': 'Eda', 'surname': 'Wasserfall'}
pass
 
 
 
{'position': 'QA', 'id': 1, 'salary': 15000, 'name': 'Eda', 'surname': 'Wasserfall'}
pass
 
 
 
{'position': 'QA', 'id': 1, 'salary': -15000.0, 'name': 'Eda', 'surname': 'Wasserfall'}
Key 'salary' error:
Or(<function positive_integer at 0x7fa41594fbf8>, <function positive_float at 0x7fa4158df730>, <function <lambda> at 0x7fa4158df7b8>) did not validate -15000.0
<lambda>(-15000.0) should evaluate to True
 
 
 
{'position': 'QA', 'id': 1, 'salary': 1000000.0, 'name': 'Eda', 'surname': 'Wasserfall'}
pass
 
 
 
{'position': 'QA', 'id': 1, 'salary': 15000.0, 'name': 'Eda', 'surname': 'Wasserfall'}
pass
 
 
 
{'id': 1, 'salary': 100000000.0, 'name': 'Eda', 'position': 'QA'}
Missing keys: 'surname'
 
 
 
{'position': '', 'id': 1, 'salary': 45000.0, 'name': 'Eda', 'surname': 'Wasserfall'}
Key 'position' error:
<lambda>('') should evaluate to True
 
 
 
{'position': 'tovarnik', 'id': 1, 'salary': 45000.0, 'name': 'Eda', 'surname': 'Wasserfall'}
Key 'position' error:
<lambda>('tovarnik') should evaluate to True
 
 
 
{'position': '', 'id': 1, 'salary': 25000.0, 'name': '', 'surname': ''}
Key 'position' error:
<lambda>('') should evaluate to True

Povšimněte si, jak se projevilo nekorektně napsané validační kritérium pro „salary“.

7. Použití klauzule Optional

Další velmi užitečnou klauzulí je klauzule Optional, která se zapisuje u klíčů. Označujeme jí ty klíče, resp. ty dvojice klíč+hodnota, které jsou nepovinné. Příklad použití pro dvě nepovinné položky „salary“ a „position“ zapsané do validačního kritéria:

employee = Schema({"name": And(str, len),
                   "surname": And(str, len),
                   "id": positive_integer,
                   Optional("salary"): And(Or(positive_integer, positive_float), lambda x: x > 10000.0 and x < 99999.0),
                   Optional("position"): And(str, lambda s: s in POSITIONS)})

8. Použití regulárních výrazů pro kontrolu dat ve slovnících

Ukažme si ještě jednoduchý predikát sloužící pro kontrolu, zda zadaná hodnota odpovídá jménu či příjmení (používáme zde velmi jednoduchý regulární výraz, ten ovšem nemusí být platný pro všechna příjmení):

def name_str(value):
    return re.fullmatch("[A-Z][a-z]+", value)

Způsob zakomponování tohoto predikátu do validačního schématu:

employee = Schema({"name": And(str, len, name_str),
                   "surname": And(str, len, name_str),
                   "id": positive_integer,
                   Optional("salary"): And(Or(positive_integer, positive_float), lambda x: x > 10000.0 and x < 99999.0),
                   Optional("position"): And(str, lambda s: s in POSITIONS)})

Je použita klauzule And, takže pro jméno a příjmení je nutné, aby následující predikáty platily v uvedeném pořadí:

  • str – jedná se o řetězec
  • len – řetězec není prázdný
  • name_str – řetězec odpovídá regulárnímu výrazu „[A-Z][a-z]+“

9. Zdrojový kód pátého demonstračního příkladu a jeho výsledky

Úplný zdrojový kód pátého příkladu vypadá následovně:

#!/usr/bin/env python
# vim: set fileencoding=utf-8
 
import re
from sys import argv
from schema import Schema, SchemaError, And, Or, Optional
 
 
def validate(schema, data, verbose_mode=False):
    try:
        print("\n\n")
        if verbose_mode:
            print(schema)
        print(data)
        schema.validate(data)
        print("pass")
    except SchemaError as e:
        print(e)
 
 
def positive_integer(value):
    return type(value) is int and value > 0
 
 
def positive_float(value):
    return type(value) is float and value > 0
 
 
def name_str(value):
    return re.fullmatch("[A-Z][a-z]+", value)
 
 
POSITIONS = ["QA", "DevOps", "Admin", "Docs", "HR"]
 
employee = Schema({"name": And(str, len, name_str),
                   "surname": And(str, len, name_str),
                   "id": positive_integer,
                   Optional("salary"): And(Or(positive_integer, positive_float), lambda x: x > 10000.0 and x < 99999.0),
                   Optional("position"): And(str, lambda s: s in POSITIONS)})
 
verbose_mode = "-v" in argv
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": 15000.0,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": 15000,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": -15000.0,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": 1000000.0,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": 15000.0,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "id": 1,
                    "salary": 100000000.0,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": 45000.0,
                    "position": ""},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": 45000.0,
                    "position": "tovarnik"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": 25000.0,},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "position": "DevOps"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1},
                    verbose_mode)
 
validate(employee, {"name": "eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": 45000.0,
                    "position": "HR"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "wasserfall",
                    "id": 1,
                    "salary": 45000.0,
                    "position": "HR"},
                    verbose_mode)

Následuje výpis výsledků validace:

{'position': 'QA', 'id': 1, 'name': 'Eda', 'surname': 'Wasserfall', 'salary': 15000.0}
pass
 
 
 
{'position': 'QA', 'id': 1, 'name': 'Eda', 'surname': 'Wasserfall', 'salary': 15000}
pass
 
 
 
{'position': 'QA', 'id': 1, 'name': 'Eda', 'surname': 'Wasserfall', 'salary': -15000.0}
Key 'salary' error:
Or(<function positive_integer at 0x7f671c0779d8>, <function positive_float at 0x7f671c085730>) did not validate -15000.0
positive_float(-15000.0) should evaluate to True
 
 
 
{'position': 'QA', 'id': 1, 'name': 'Eda', 'surname': 'Wasserfall', 'salary': 1000000.0}
Key 'salary' error:
<lambda>(1000000.0) should evaluate to True
 
 
 
{'position': 'QA', 'id': 1, 'name': 'Eda', 'surname': 'Wasserfall', 'salary': 15000.0}
pass
 
 
 
{'position': 'QA', 'id': 1, 'name': 'Eda', 'salary': 100000000.0}
Key 'salary' error:
<lambda>(100000000.0) should evaluate to True
 
 
 
{'position': '', 'id': 1, 'name': 'Eda', 'surname': 'Wasserfall', 'salary': 45000.0}
Key 'position' error:
<lambda>('') should evaluate to True
 
 
 
{'position': 'tovarnik', 'id': 1, 'name': 'Eda', 'surname': 'Wasserfall', 'salary': 45000.0}
Key 'position' error:
<lambda>('tovarnik') should evaluate to True
 
 
 
{'id': 1, 'name': 'Eda', 'surname': 'Wasserfall', 'salary': 25000.0}
pass
 
 
 
{'position': 'DevOps', 'id': 1, 'name': 'Eda', 'surname': 'Wasserfall'}
pass
 
 
 
{'id': 1, 'name': 'Eda', 'surname': 'Wasserfall'}
pass
 
 
 
{'position': 'HR', 'id': 1, 'name': 'eda', 'surname': 'Wasserfall', 'salary': 45000.0}
Key 'name' error:
name_str('eda') should evaluate to True
 
 
 
{'position': 'HR', 'id': 1, 'name': 'Eda', 'surname': 'wasserfall', 'salary': 45000.0}
Key 'surname' error:
name_str('wasserfall') should evaluate to True

10. Použití třídy pro kontrolu údajů o platu a o pracovní pozici

Knihovna Schema programátorům nabízí ještě jednu velmi zajímavou možnost definice validačních kritérií. Kritérium totiž může být implementováno třídou, u které se očekává existence metody validate, která pro neúspěšnou validaci vyhazuje výjimku typu SchemaError. Ukažme si to na tom nejjednodušším příkladu – kontrole platu:

class Salary:
    def validate(self, value):
        if not is_integer(value) and not is_float(value):
            raise SchemaError("Salary has unexpected type {t}".format(t=type(value)))
        elif value <= 10000:
            raise SchemaError("Salary {s} is too low".format(s=value))
        elif value >= 99999.9:
            raise SchemaError("Salary {s} is too high".format(s=value))

Zařazení do schématu se provede takto (vytvoří se instance třídy):

employee = Schema({"name": And(str, len, name_str)),
                   "surname": And(str, len, name_str)),
                   "id": ...,
                   Optional("salary"): Salary(),
                   Optional("position"): ...})

Podobná třída již v knihovně existuje – jedná se o třídu Regex, kterou taktéž můžeme použít. Při vytváření objektů se do konstruktoru přímo předá regulární výraz:

employee = Schema({"name": And(str, len, Regex("[A-Z][a-z]+")),
                   "surname": And(str, len, Regex("[A-Z][a-z]+")),
                   "id": ...,
                   Optional("salary"): Salary(),
                   Optional("position"): ...})

Podobně lze realizovat třídu pro kontrolu pracovní pozice:

class Position:
    POSITIONS = ["QA", "DevOps", "Admin", "Docs", "HR"]
 
    def validate(self, value):
        if value not in Position.POSITIONS:
            raise SchemaError("Unknown position '{p}'".format(p=value))

Zařazení do schématu se provede takto (vytvoří se instance třídy):

employee = Schema({"name": And(str, len, Regex("[A-Z][a-z]+")),
                   "surname": And(str, len, Regex("[A-Z][a-z]+")),
                   "id": ...,
                   Optional("salary"): Salary(),
                   Optional("position"): And(str, Position())})

11. Kontrola unikátnosti ID zaměstnance

Vzhledem k tomu, že se do schématu předává instance třídy provádějící validaci, můžeme naše validační schéma ještě více vylepšit. Můžeme totiž provést kontrolu, zda je ID zaměstnance unikátní, tj. zda neexistují nějaké duplicity. Provede se to jednoduše – do nové třídy pro kontrolu unikátnosti ID přidáme atribut _ids obsahující množinu již validovaných ID. Musí přitom platit podmínka, že ID právě kontrolovaného záznamu se v této množině nesmí vyskytovat. Implementace je snadná:

class UniqueId:
 
    def __init__(self):
        self._ids = set()
 
    def validate(self, value):
        if value in self._ids:
            raise SchemaError("ID {id} is not unique".format(id=value))
        self._ids.add(value)

Zařazení do schématu se provede takto:

employee = Schema({"name": And(str, len, Regex("[A-Z][a-z]+")),
                   "surname": And(str, len, Regex("[A-Z][a-z]+")),
                   "id": UniqueId(),
                   Optional("salary"): Salary(),
                   Optional("position"): And(str, Position())})

12. Zdrojový kód šestého demonstračního příkladu

Následuje výpis úplného zdrojového kódu šestého příkladu použití knihovny Schema:

#!/usr/bin/env python
# vim: set fileencoding=utf-8
 
import re
from sys import argv
from schema import Schema, SchemaError, And, Or, Optional, Regex
 
 
def validate(schema, data, verbose_mode=False):
    try:
        print("\n\n")
        if verbose_mode:
            print(schema)
        print(data)
        schema.validate(data)
        print("pass")
    except SchemaError as e:
        print(e)
 
 
def is_integer(value):
    return type(value) is int
 
 
def is_float(value):
    return type(value) is float
 
 
class Salary:
    def validate(self, value):
        if not is_integer(value) and not is_float(value):
            raise SchemaError("Salary has unexpected type {t}".format(t=type(value)))
        elif value <= 10000:
            raise SchemaError("Salary {s} is too low".format(s=value))
        elif value >= 99999.9:
            raise SchemaError("Salary {s} is too high".format(s=value))
 
 
class Position:
    POSITIONS = ["QA", "DevOps", "Admin", "Docs", "HR"]
 
    def validate(self, value):
        if value not in Position.POSITIONS:
            raise SchemaError("Unknown position '{p}'".format(p=value))
 
 
class UniqueId:
 
    def __init__(self):
        self._ids = set()
 
    def validate(self, value):
        if value in self._ids:
            raise SchemaError("ID {id} is not unique".format(id=value))
        self._ids.add(value)
 
 
employee = Schema({"name": And(str, len, Regex("[A-Z][a-z]+")),
                   "surname": And(str, len, Regex("[A-Z][a-z]+")),
                   "id": UniqueId(),
                   Optional("salary"): Salary(),
                   Optional("position"): And(str, Position())})
 
verbose_mode = "-v" in argv
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 1,
                    "salary": 15000.0,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 2,
                    "salary": 15000,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 3,
                    "salary": -15000.0,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 4,
                    "salary": 1000000.0,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 5,
                    "salary": 15000.0,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "id": 6,
                    "salary": 100000000.0,
                    "position": "QA"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 7,
                    "salary": 45000.0,
                    "position": ""},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 8,
                    "salary": 45000.0,
                    "position": "tovarnik"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 9,
                    "salary": 25000.0},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 10,
                    "position": "DevOps"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Wasserfall",
                    "id": 11},
                    verbose_mode)
 
validate(employee, {"id": 12},
                    verbose_mode)
 
validate(employee, {"name": "eda",
                    "surname": "Wasserfall",
                    "id": 13,
                    "salary": 45000.0,
                    "position": "HR"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "wasserfall",
                    "id": 14,
                    "salary": 45000.0,
                    "position": "HR"},
                    verbose_mode)
 
validate(employee, {"name": "Eda",
                    "surname": "Unique",
                    "id": 1,
                    "salary": 45000.0,
                    "position": "HR"},
                    verbose_mode)

13. Výsledky běhu šestého demonstračního příkladu

Po spuštění příkladu by se na standardní výstup měly vypsat tyto informace. Povšimněte si, že nyní jsou chybové zprávy velmi dobře čitelné:

{'name': 'Eda', 'salary': 15000.0, 'id': 1, 'surname': 'Wasserfall', 'position': 'QA'}
pass
 
 
 
{'name': 'Eda', 'salary': 15000, 'id': 2, 'surname': 'Wasserfall', 'position': 'QA'}
pass
 
 
 
{'name': 'Eda', 'salary': -15000.0, 'id': 3, 'surname': 'Wasserfall', 'position': 'QA'}
Key 'salary' error:
Salary -15000.0 is too low
 
 
 
{'name': 'Eda', 'salary': 1000000.0, 'id': 4, 'surname': 'Wasserfall', 'position': 'QA'}
Key 'salary' error:
Salary 1000000.0 is too high
 
 
 
{'name': 'Eda', 'salary': 15000.0, 'id': 5, 'surname': 'Wasserfall', 'position': 'QA'}
pass
 
 
 
{'name': 'Eda', 'salary': 100000000.0, 'id': 6, 'position': 'QA'}
Key 'salary' error:
Salary 100000000.0 is too high
 
 
 
{'name': 'Eda', 'salary': 45000.0, 'id': 7, 'surname': 'Wasserfall', 'position': ''}
Key 'position' error:
Unknown position ''
 
 
 
{'name': 'Eda', 'salary': 45000.0, 'id': 8, 'surname': 'Wasserfall', 'position': 'tovarnik'}
Key 'position' error:
Unknown position 'tovarnik'
 
 
 
{'name': 'Eda', 'salary': 25000.0, 'id': 9, 'surname': 'Wasserfall'}
pass
 
 
 
{'name': 'Eda', 'id': 10, 'surname': 'Wasserfall', 'position': 'DevOps'}
pass
 
 
 
{'name': 'Eda', 'id': 11, 'surname': 'Wasserfall'}
pass
 
 
 
{'id': 12}
Missing keys: 'name', 'surname'
 
 
 
{'name': 'eda', 'salary': 45000.0, 'id': 13, 'surname': 'Wasserfall', 'position': 'HR'}
Key 'name' error:
Regex('[A-Z][a-z]+') does not match 'eda'
 
 
 
{'name': 'Eda', 'salary': 45000.0, 'id': 14, 'surname': 'wasserfall', 'position': 'HR'}
Key 'surname' error:
Regex('[A-Z][a-z]+') does not match 'wasserfall'
 
 
 
{'name': 'Eda', 'salary': 45000.0, 'id': 1, 'surname': 'Unique', 'position': 'HR'}
Key 'id' error:
ID 1 is not unique

14. Knihovna Voluptuous

Poslední knihovnou určenou pro kontrolu struktury složitějších dat, s níž se v dnešním článku alespoň ve stručnosti seznámíme, je knihovna s dosti neobvyklým názvem Voluptuous, jejíž zdrojové kódy nalezneme na adrese https://github.com/alectho­mas/voluptuous. Tato knihovna je postavena na podobných principech, s jakými jsme se již dříve setkali u knihoven SchemagicSchema – samotná struktura dat je popsána přímo v Pythonu (jako klasická nativní pythonovská datová struktura, většinou seznam či slovník) a není tedy zapotřebí používat žádný doménově specifický jazyk (DSL – Domain Specific Language). I z tohoto důvodu bude základní popis této knihovny relativně stručný, protože si její vlastnosti a rozdíly ukážeme na několika demonstračních příkladech, které budou v mnoha ohledech podobné již popsaným příkladům.

Knihovnu Voluptuous (schválně, kdo dokáže to jméno opsat bez chyby?) si nejdříve nainstalujeme, a to konkrétně s využitím nástroje pip3 (nebo pip), protože tato knihovna je samozřejmě registrována i na PyPI (Python Package Index). Pro jednoduchost provedeme instalaci jen pro právě aktivního uživatele:

$ pip3 install --user voluptuous
 
Collecting voluptuous
  Downloading voluptuous-0.11.1-py2.py3-none-any.whl
Installing collected packages: voluptuous
Successfully installed voluptuous-0.11.1

15. Ukázka základních možností knihovny Voluptuous v REPLu

Nyní si můžeme základní vlastnosti této knihovny otestovat v interaktivní smyčce REPL programovacího jazyka Python. REPL spustíme klasicky:

$ python3
 
Python 3.6.3 (default, Oct  9 2017, 12:11:29)
[GCC 7.2.1 20170915 (Red Hat 7.2.1-2)] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>

Naimportujeme objekt Schema z této knihovny:

>>> from voluptuous import Schema
>>>

Nyní si již můžeme nadeklarovat jednoduché schéma:

user = Schema({"name": str,
               "surname": str,
               "id": int})
Poznámka: povšimněte si, že deklarace schématu je v tomto jednoduchém příkladě totožná s deklarací, kterou jsme si ukázali ve druhém demonstračním příkladu používajícího knihovnu Schema.

Předchozí volání konstruktoru vytvořilo objekt typu Schema, o čemž se můžeme přesvědčit velmi snadno:

>>> user
<Schema({'name': <class 'str'>, 'surname': <class 'str'>, 'id': <class 'int'>}, extra=PREVENT_EXTRA, required=False) object at 0x7fa88cb93eb8>

Následuje ukázka použití schématu pro validaci (korektních) dat:

user({"name": "Eda",
      "surname": "Wasserfall",
      "id": 1})
 
{'name': 'Eda', 'surname': 'Wasserfall', 'id': 1}

Povšimněte si malé změny: zde se přímo volá user(data) (jakoby user byla běžná funkce a ne objekt) a nikoli user.validate(data), což je konvence, se kterou jsme se setkali v knihovně Schema. V knihovně Voluptuous je zkrácené volání implementováno díky překrytí metody __call__ vlastní implementací.

Příklad chybového výstupu pro nekorektní data bude vypadat následovně:

user({"name": 42,
      "id": "X"})
 
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
  File "/home/tester/.local/lib/python3.6/site-packages/voluptuous/schema_builder.py", line 267, in __call__
    return self._compiled([], data)
  File "/home/tester/.local/lib/python3.6/site-packages/voluptuous/schema_builder.py", line 587, in validate_dict
    return base_validate(path, iteritems(data), out)
  File "/home/tester/.local/lib/python3.6/site-packages/voluptuous/schema_builder.py", line 425, in validate_mapping
    raise er.MultipleInvalid(errors)
voluptuous.error.MultipleInvalid: expected str for dictionary value @ data['name']
>>>

16. První příklad – validace obsahu seznamů knihovnou Voluptuous

Ukažme si nyní demonstrační příklad se základním použitím této knihovny. Příklad obsahuje jediný soubor napsaný v Pythonu, podobně jako tomu bylo i ve všech předchozích demonstračních příkladech. V příkladu používáme již nám známou uživatelskou funkci validate:

#!/usr/bin/env python
# vim: set fileencoding=utf-8
 
from voluptuous import Schema
from voluptuous import Invalid
 
 
def validate(schema, data):
    try:
        print("\n\n")
        print(schema)
        print(data)
        schema(data)
        print("pass")
    except Exception as e:
        print(e)
 
 
def pos(value):
    if type(value) is not int or value <= 0:
        raise Invalid("positive integer value expected, but got {v} instead".format(v=value))
 
 
number_list = Schema([int, float, complex])
 
validate(number_list, [1, 2, 3])
validate(number_list, [1.1, 2.2, 3.3])
validate(number_list, [1+2j, 3+4j, 5j])
validate(number_list, ["1", "2", "3"])
 
binary_numbers = Schema([0, 1])
validate(binary_numbers, [0, 0, 0])
validate(binary_numbers, [1, 1, 0])
validate(binary_numbers, [1, 2, 3])
 
validate(Schema(pos), 42)
validate(Schema(pos), 0)
validate(Schema(pos), -1)
validate(Schema(pos), 1.5)

U seznamů deklarujeme, jaké vlastnosti jsou očekávány u všech prvků. Mezi vlastnostmi se implicitně používá operace „or“:

# seznam čísel libovolného typu
number_list = Schema([int, float, complex])
 
# seznam binárních číslic
binary_numbers = Schema([0, 1])

Podívejme se nyní na výsledky běhu tohoto demonstračního příkladu. První část, tj. kontrola typů prvků seznamů dopadla následovně. Pouze poslední seznam obsahující řetězce byl vyhodnocen jako nekorektní (podle očekávání):

[<class 'int'>, <class 'float'>, <class 'complex'>]
[1, 2, 3]
pass
 
 
 
[<class 'int'>, <class 'float'>, <class 'complex'>]
[1.1, 2.2, 3.3]
pass
 
 
 
[<class 'int'>, <class 'float'>, <class 'complex'>]
[(1+2j), (3+4j), 5j]
pass
 
 
 
[<class 'int'>, <class 'float'>, <class 'complex'>]
['1', '2', '3']
expected complex @ data[0]

Dále testujeme, jestli prvky seznamu tvoří řetězec binárních číslic, opět s očekávaným výsledkem:

[0, 1]
[0, 0, 0]
pass
 
 
 
[0, 1]
[1, 1, 0]
pass
 
 
 
[0, 1]
[1, 2, 3]
not a valid value @ data[1]

A nakonec test, zda jsou hodnoty celými kladnými čísly:

<function pos at 0x7f421daff0d0>
42
pass
 
 
 
<function pos at 0x7f421daff0d0>
0
positive integer value expected, but got 0 instead
 
 
 
<function pos at 0x7f421daff0d0>
-1
positive integer value expected, but got -1 instead
 
 
 
<function pos at 0x7f421daff0d0>
1.5
positive integer value expected, but got 1.5 instead

17. Tři varianty validace celých kladných čísel implementované ve třech knihovnách

Zajímavá je především definice funkce pos kontrolující, zda je jejím parametrem celé kladné číslo. Tato funkce musí v případě nekorektní hodnoty vyhodit výjimku typu Invalid (třídu s touto výjimkou jsme importovali na začátku). Je pro osvěžení si porovnejme implementaci této funkce pro všechny tři popsané knihovny:

Schemagic

def positive_integer(value):
    if type(value) is not int or value <= 0:
        raise TypeError("positive integer value expected, but got {v} instead".format(v=value))

V případě nevalidních dat se vyhazuje výjimka typu TypeError nebo ValueError.

Schema

def positive_integer(value):
    return type(value) is int and value > 0

Predikát, který u nevalidních dat vrací pravdivostní hodnotu False.

Voluptuous

def positive_integer(value):
    if type(value) is not int or value <= 0:
        raise Invalid("positive integer value expected, but got {v} instead".format(v=value))

V případě nevalidních dat se vyhazuje výjimka typu Invalid.

18. Druhý příklad – validace obsahu seznamů

Ve druhém příkladu založeném na knihovně Voluptuous si ukážeme nám již dobře známý problém – verifikaci, jestli předaný slovník obsahuje všechny očekávané klíče a zda jsou hodnoty na tyto klíče navázané očekávaného typu. Zápis validačních kritérií vypadá naprosto stejně, jako u předchozí popisované knihovny Schema:

user = Schema({"name": str,
               "surname": str,
               "id": pos})

Následně toto schéma použijeme pro validaci slovníků s různým obsahem, například:

validate(user, {"name": "Eda",
                "surname": "Wasserfall",
                "id": 1})
 
validate(user, {"name": "Eda",
                "id": 1})

Úplný kód tohoto demonstračního příkladu vypadá následovně:

MIF 2018 tip v článku Mikulenka

#!/usr/bin/env python
# vim: set fileencoding=utf-8
 
from voluptuous import Schema
from voluptuous import Invalid
 
 
def validate(schema, data):
    try:
        print("\n\n")
        print(schema)
        print(data)
        schema(data)
        print("pass")
    except Exception as e:
        print(e)
 
 
def pos(value):
    if type(value) is not int or value <= 0:
        raise Invalid("positive integer value expected, but got {v} instead".format(v=value))
 
 
number_list = Schema([int, float, complex])
 
validate(number_list, [1, 2, 3])
validate(number_list, [1.1, 2.2, 3.3])
validate(number_list, [1+2j, 3+4j, 5j])
validate(number_list, ["1", "2", "3"])
 
binary_numbers = Schema([0, 1])
validate(binary_numbers, [0, 0, 0])
validate(binary_numbers, [1, 1, 0])
validate(binary_numbers, [1, 2, 3])
 
validate(Schema(pos), 42)
validate(Schema(pos), 0)
validate(Schema(pos), -1)
validate(Schema(pos), 1.5)
 
user = Schema({"name": str,
               "surname": str,
               "id": pos})
 
validate(user, {"name": "Eda",
                "surname": "Wasserfall",
                "id": 1})
 
validate(user, {"name": "Eda",
                "id": 1})
 
validate(user, {"name": "Eda",
                "surname": "Wasserfall",
                "id": 0})

Opět se podívejme na výstup vyprodukovaný tímto příkladem:

[<class 'int'>, <class 'float'>, <class 'complex'>]
[1, 2, 3]
pass
 
 
 
[<class 'int'>, <class 'float'>, <class 'complex'>]
[1.1, 2.2, 3.3]
pass
 
 
 
[<class 'int'>, <class 'float'>, <class 'complex'>]
[(1+2j), (3+4j), 5j]
pass
 
 
 
[<class 'int'>, <class 'float'>, <class 'complex'>]
['1', '2', '3']
expected complex @ data[0]
 
 
 
[0, 1]
[0, 0, 0]
pass
 
 
 
[0, 1]
[1, 1, 0]
pass
 
 
 
[0, 1]
[1, 2, 3]
not a valid value @ data[1]
 
 
 
<function pos at 0x7f2fb79b50d0>
42
pass
 
 
 
<function pos at 0x7f2fb79b50d0>
0
positive integer value expected, but got 0 instead
 
 
 
<function pos at 0x7f2fb79b50d0>
-1
positive integer value expected, but got -1 instead
 
 
 
<function pos at 0x7f2fb79b50d0>
1.5
positive integer value expected, but got 1.5 instead
 
 
 
{'name': <class 'str'>, 'surname': <class 'str'>, 'id': <function pos at 0x7f2fb79b50d0>}
{'name': 'Eda', 'surname': 'Wasserfall', 'id': 1}
pass
 
 
 
{'name': <class 'str'>, 'surname': <class 'str'>, 'id': <function pos at 0x7f2fb79b50d0>}
{'name': 'Eda', 'id': 1}
pass
 
 
 
{'name': <class 'str'>, 'surname': <class 'str'>, 'id': <function pos at 0x7f2fb79b50d0>}
{'name': 'Eda', 'surname': 'Wasserfall', 'id': 0}
positive integer value expected, but got 0 instead for dictionary value @ data['id']
Poznámka: možnosti této knihovny jsou samozřejmě mnohem větší, ovšem podrobnosti si ukážeme až ve třetí a současně i závěrečné části tohoto miniseriálu.

19. Repositář s demonstračními příklady

Všechny demonstrační projekty, které jsme si v dnešním článku popsali, byly uloženy do Git repositáře, který naleznete na adrese https://github.com/tisnik/python-schema-checks. V tabulce pod tímto odstavcem jsou vypsány odkazy na všechny projekty rozdělené podle použité knihovny. Z tohoto důvodu zde naleznete i projekty zmíněné minule.

Schemagic

Projekt Stručný popis Cesta
schemagic-demo-1 základní vlastnosti knihovny Schemagic https://github.com/tisnik/python-schema-checks/tree/master/schemagic-demo-1
schemagic-demo-2 konverze prováděné při validaci https://github.com/tisnik/python-schema-checks/tree/master/schemagic-demo-2
schemagic-demo-3 vlastní validační funkce https://github.com/tisnik/python-schema-checks/tree/master/schemagic-demo-3
schemagic-demo-4 vylepšení předchozího příkladu https://github.com/tisnik/python-schema-checks/tree/master/schemagic-demo-4
schemagic-demo-5 validace slovníků https://github.com/tisnik/python-schema-checks/tree/master/schemagic-demo-5
schemagic-demo-6 validace slovníků podruhé https://github.com/tisnik/python-schema-checks/tree/master/schemagic-demo-6

Schema

Projekt Stručný popis Cesta
schema-demo-1 základní vlastnosti knihovny Schema https://github.com/tisnik/python-schema-checks/tree/master/schema-demo-1
schema-demo-2 validace slovníků a dalších typů https://github.com/tisnik/python-schema-checks/tree/master/schema-demo-2
schema-demo-3 validace slovníků a dalších typů https://github.com/tisnik/python-schema-checks/tree/master/schema-demo-3
schema-demo-4 validace slovníků a dalších typů https://github.com/tisnik/python-schema-checks/tree/master/schema-demo-4
schema-demo-5 validace slovníků a dalších typů https://github.com/tisnik/python-schema-checks/tree/master/schema-demo-5
schema-demo-6 validace slovníků a dalších typů https://github.com/tisnik/python-schema-checks/tree/master/schema-demo-6

Voluptuous

Projekt Stručný popis Cesta
voluptuous-demo-1 základní vlastnosti knihovny Voluptuous https://github.com/tisnik/python-schema-checks/tree/master/voluptuous-demo-1
voluptuous-demo-2 validace obsahu slovníků https://github.com/tisnik/python-schema-checks/tree/master/voluptuous-demo-2

20. Odkazy na Internetu

  1. 7 Best Python Libraries for Validating Data
    https://www.yeahhub.com/7-best-python-libraries-validating-data/
  2. voluptuous na (na PyPi)
    https://pypi.python.org/py­pi/voluptuous
  3. voluptuous (na GitHubu)
    https://github.com/alectho­mas/voluptuous
  4. schemagic 0.9.1 (na PyPi)
    https://pypi.python.org/py­pi/schemagic/0.9.1
  5. Schemagic / Schemagic.web (na GitHubu)
    https://github.com/Mechrop­hile/schemagic
  6. schema 0.6.7 (na PyPi)
    https://pypi.python.org/pypi/schema
  7. schema (na GitHubu)
    https://github.com/keleshev/schema
  8. XML Schema validator and data conversion library for Python
    https://github.com/brunato/xmlschema
  9. xmlschema 0.9.7
    https://pypi.python.org/py­pi/xmlschema/0.9.7
  10. jsonschema 2.6.0
    https://pypi.python.org/py­pi/jsonschema
  11. warlock 1.3.0
    https://pypi.python.org/pypi/warlock
  12. Python Virtual Environments – A Primer
    https://realpython.com/python-virtual-environments-a-primer/
  13. pip 1.1 documentation: Requirements files
    https://pip.readthedocs.i­o/en/1.1/requirements.html
  14. unittest.mock — mock object library
    https://docs.python.org/3­.5/library/unittest.mock.html
  15. mock 2.0.0
    https://pypi.python.org/pypi/mock
  16. An Introduction to Mocking in Python
    https://www.toptal.com/python/an-introduction-to-mocking-in-python
  17. Unit testing (Wikipedia)
    https://en.wikipedia.org/wi­ki/Unit_testing
  18. Unit testing
    https://cs.wikipedia.org/wi­ki/Unit_testing
  19. Test-driven development (Wikipedia)
    https://en.wikipedia.org/wiki/Test-driven_development
  20. Pip (dokumentace)
    https://pip.pypa.io/en/stable/
  21. 5 Differences between clojure.spec and Schema
    https://lispcast.com/clojure.spec-vs-schema/
  22. Schema: Clojure(Script) library for declarative data description and validation
    https://github.com/plumatic/schema
  23. clojure.spec – Rationale and Overview
    https://clojure.org/about/spec
Našli jste v článku chybu?