momente şi schiţe de informatică şi matematică
To attain knowledge, write. To attain wisdom, rewrite.

Modelarea şi înregistrarea datelor dintr-o colecţie de partide de şah

AWK | Bash | Django | JSON | MySQL | Python | grep
2014 jul

Avem din [1] fişierele "G_n.pgn.can" (n = 1..314), reprezentând fiecare câte o partidă adnotată de Crafty, din fişierul GA_07-14-2014.pgn obţinut de la [*]; pentru extragerea şi gruparea datelor specifice din fişierul iniţial avem de folosit o gamă variată de "instrumente", încât modelarea şi înregistrarea acestor date devine o etapă de sine stătătoare în realizarea unei aplicaţii Web de prezentare a acestor partide (aproape independentă de realizarea propriu-zisă a aplicaţiei).

În fond, această etapă ţine de constituirea tabelelor de date necesare; vom folosi MySQL dar nu direct, ci prin intermediul modulului Python MySQLdb - nu numai că astfel este mai comod, dar Django (cu care intenţionăm să dezvoltăm întreaga aplicaţie) implică acest modul (şi totodată, prevede modificări minimale dacă ulterior, am opta pentru alt RDBMS în loc de MySQL).

Crearea bazei de date şi iniţierea aplicaţiei

Pe sistemul meu, userul "vb" are setat şi dreptul de a crea baze de date (rezervat altfel user-ului MySQL "root"; a vedea eventual [2]) - încât este suficient să deschid un shell mysql şi să tastez:

vb@vb:~$ mysql
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 63
Server version: 5.5.38-0ubuntu0.12.04.1 (Ubuntu)
...  Copyright (c) 2000, 2014, Oracle and/or its affiliates.  ...
mysql> CREATE DATABASE slightchess DEFAULT CHARSET utf8 COLLATE utf8_general_ci;

Această comandă creează baza de date cu numele slightchess şi setează ca implicită codificarea UTF-8, atât pentru conţinutul tabelelor care urmează să fie create cât şi pentru comunicaţiile cu programe externe; ţinem cont astfel, de faptul că vor fi de înregistrat şi de comunicat nume de jucători de diverse naţionalităţi.

Creem acum un proiect Django, folosind utilitarul django-admin.py (denumirile alese pentru baza de date, pentru proiect, aplicaţie, etc. - nu sunt neapărat importante şi poate, nici nimerite…):

vb@vb:~$   django-admin.py startproject slightchess
vb@vb:~$   cd slightchess ;  tree
├── manage.py
└── slightchess
    ├── __init__.py
    ├── settings.py
    ├── urls.py
    └── wsgi.py

Pentru etapa de lucru menţionată la început ne vor interesa numai fişierul settings.py şi utilitarul manage.py. Edităm fişierul de configurare settings.py, specificând că vrem să folosim MySQL drept "ENGINE" şi anume, pentru baza de date tocmai creată slightchess, precum şi pentru a adăuga în lista aplicaţiilor instalate automat la crearea proiectului şi aplicaţia proprie games:

INSTALLED_APPS = (
    'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes',
    # şi încă vreo două aplicaţii prevăzute automat la crearea proiectului 
    'games', # numele aplicaţiei proprii
)
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql', # MySQL (prin interfaţa MySQLdb)
        'NAME': 'slightchess', # baza de date angajată
        'USER': 'vb', 'PASSWORD': 'vb-pass', 'HOST': '', 'PORT': '',
}
AUTH_USER_MODEL = 'games.Selector' # vizează User-ul căruia îi aparţin partidele

Setarea AUTH_USER_MODEL anunţă intenţia de a subclasa definiţia de User standard, din aplicaţia django.contrib.auth, adăugând un câmp pe care să se înregistreze numele sub care joacă user-ul respectiv; astfel, oricare user autorizat va putea înregistra pe site propria colecţie de partide.

Să creem acum şi aplicaţia games, tocmai specificată în lista INSTALLED_APPS din settings.py - folosind pentru aceasta, scriptul utilitar manage.py:

vb@vb:~/slightchess$   python  manage.py startapp games
vb@vb:~/slightchess$   cd games ;  ls
admin.py  __init__.py  models.py  tests.py  views.py
vb@vb:~/slightchess/games$

Pentru etapa de lucru propusă, ne interesează doar fişierele models.py şi admin.py.

Modelarea în Django a datelor specifice aplicaţiei

Conţinutul fişierului PGN preluat de pe [*] - evidenţiat în [1] - ne sugerează următoarea structurare generală a datelor:

Land {nume, cod} - tabel în care înregistrăm numele şi codurile ţărilor, corespunzător tagurilor PGN precum [WhiteIFlag "RO"] ({cod='RO', nume='Romania'});

Partner {nume, land_id} - înregistrează numele partenerilor din colecţiile de partide înregistrate de către userii autorizaţi, împreună cu indecşii din tabelul Land corespunzători ţărilor respective; de exemplu, {nume='андрей555', land_id = 194} (la intrarea 194 în Land având "Russian Federation");

Game {pgn, partner_id, user_id} - înregistrează textul PGN al câte unei partide dintre userul indicat de user_id (din tabelul User-ilor autorizaţi) şi jucătorul din Partner referit de partner_id.

models.py (din fiecare aplicaţie înregistrată) constituie suportul interfeţei de lucru (în Python) cu bazele de date. În fişierul games/models.py definim (conform prevederilor din Django) obiectele Python corespunzătoare structurii de date convenite mai sus:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
from django.db import models
from django.contrib.auth.models import AbstractUser
from django.conf import settings
import re

class Selector(AbstractUser):
    instant_username = models.CharField(max_length=64, unique=True)

class Land(models.Model):
    nume = models.CharField(max_length=64)
    cod = models.CharField(max_length=2, unique=True)
    
    def __unicode__(self):  return self.cod

    class Meta:  ordering = ['cod']        

class Partner(models.Model):
    nume = models.CharField(max_length=80, unique=True)
    land = models.ForeignKey(Land)
    
    def __unicode__(self):  return self.nume

    class Meta:  ordering = ['nume']        

class Game(models.Model):
    pgn = models.TextField()
    partner = models.ForeignKey(Partner, on_delete=models.CASCADE)
    coach = models.ForeignKey(settings.AUTH_USER_MODEL, 
                              on_delete=models.CASCADE)    

    def my_col(self):
        nume = self.coach.instant_username
        if re.search("\[White \"%s\"\]" %nume, self.pgn):
            return 1
        return 0

    def result(self):
        return re.search("\[Result \"(.*?)\"\]", self.pgn).group(1)
    
    def __unicode__(self): 
        if self.my_col():
            return u'%s - %s(%s) %s' %(self.coach.instant_username, self.partner, 
                                       self.partner.land, self.result())
        return u'%s(%s) - %s %s' %(self.partner, self.partner.land, 
                                   self.coach.instant_username, self.result())

Pe parcurs, vom încerca să clarificăm în mod indirect anumite aspecte ale acestor definiţii (se poate consulta şi documentaţia de pe docs.djangoproject.com/1.6, pentru lămuriri "directe"). Clauza syncdb a utilitarului "manage.py" asigură crearea tabelelor de date, conform configurărilor din settings.py şi specificaţiilor din fişierele models.py ale aplicaţiilor înregistrate:

vb@vb:~/slightchess/games$  cd ..
vb@vb:~/slightchess$  python  manage.py  syncdb
# ...

În cursul operaţiei, am introdus datele cerute pentru crearea unui "superuser". Putem activa acum "serverul de dezvoltare", pentru a verifica în cel mai simplu mod cum stau lucrurile:

vb@vb:~/slightchess$  python  manage.py runserver
Validating models... (0 errors found)
# ... #
Django version 1.6.5, using settings 'slightchess.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

Tastând http://127.0.0.1:8000/admin în bara de adresă a browser-ului, obţinem (după ce completăm formularul "Log in", folosind datele pentru "superuser") ceea ce redăm parţial alături.

Pentru a fi văzute pe această interfaţă de administrare, modelele noastre trebuie "înregistrate" în prealabil, pentru aplicaţia admin - înscriind în fişierul admin.py:

from django.contrib import admin
from games.models import Selector, Land, Partner, Game
admin.site.register([Selector, Land, Partner, Game])

Click pe Users ne arată "înregistrările" existente - cea pentru "superuser"; câmpurile acesteia sunt cele înfiinţate prin modelul AbstractUser (vezi linia 2 din fişierul redat mai sus), plus câmpul adăugat - prin subclasare - de Selector (linia 6). Completăm câmpul arătat instant_username, cu vlad.bazon (numele cu care joc pe [*] şi apar în fişierul PGN) şi acţionăm butonul "Save".

Când ulterior, voi accesa o partidă proprie înregistrată deja în Game - "titlul" acesteia va fi redat prin metoda din linia 40; aceasta va accesa proprietatea my_col() adăugată în linia 31, în care se va consulta câmpul instant_username şi confruntând numele respectiv (în acest caz 'vlad.bazon', tocmai setat mai sus) cu valoarea din tagul "White" al textului PGN al partidei respective - se va putea stabili culoarea cu care am jucat eu (deci şi cea a partenerului), încât "titlul" formulat în final pentru partidă va nota jucătorii în ordinea corectă "alb - negru".

Înregistrarea ţărilor

În fişierele PGN de la [*], naţionalitatea jucătorilor este menţionată prin coduri de câte două litere (în tagurile [WhiteIFlag "LL"] şi respectiv [BlackIFlag "LL"], unde "LL" poate fi de exemplu "RO", pentru "Romania"). Este vorba în fond de standardul ISO 3166 pentru codurile ţărilor, utilizat în serviciile poştale, în denumirile domeniilor Internet ("docere.ro"), etc.

Nu avem posibilitatea de a descărca de la adresa menţionată mai sus, fişierul care ne-ar interesa (se pare că trebuie plătit - 300CHF/an - pentru aceasta); dar este oferită o platformă "on-line" - de pe care obţinem codurile atribuite oficial ţărilor, procedând chiar şi în cel mai banal mod: selectăm manual întregul tabel (dispunând de opţiunea "Results per page") şi apoi folosim mecanismul "Copy&Paste" - obţinând un fişier pe care-l denumim country_codes.txt şi care arată astfel:

English short name
French short name
Alpha-2 code
Alpha-3 code
Numeric
Afghanistan
Afghanistan (l')
AF
AFG
004
... etc.

Primele 5 linii corespund denumirilor celor 5 coloane ale tabelului original, iar restul liniilor reprezintă - tot câte cinci linii - valorile din coloanele respective; ne va interesa numai prima şi a treia coloană (de exemplu, "Afghanistan" şi "AF" - nu "Afghanistan (l')", "AFG" şi "004"). Ştergem primele 5 linii din fişier; următoarea comandă reţine numai rândurile corespunzătoare primei şi celei de-a treia coloane din tabelul original:

vb@vb:~$ awk '(NR-1) % 5 == 0 || (NR-1) % 5 == 2' country_codes.txt \
>         > slightchess/Doc/country.txt

NR este o variabilă predefinită în AWK (vezi şi [1], pentru câteva referiri la acest limbaj), indicând numărul de linii ale fişierului de intrare parcurse până la momentul curent al execuţiei; deja după preluarea primei linii, NR devine 1 - de aceea am folosit expresia (NR - 1) % 5 pentru a selecta din fiecare grup de câte 5 linii consecutive numai prima (== 0) şi a treia (== 2) linie. Am redirectat rezultatul în fişierul country.txt, în subdirectorul de lucru Doc/ creat între timp în proiect.

Următorul script Python citeşte întâi (linia 54) acest fişier (folosind codecs, pentru a evita "clasicele" necazuri legate la transfer de codificarea şirurilor ("utf-8")) şi compilează expresia regulată necesară pentru a recunoaşte grupurile de câte două linii consecutive (linia 57). Apoi, conectează MySQL (prin modulul MySQLdb) şi înscrie într-un obiect "cursor" (pregătit în linia 60) instrucţiunile INSERT (linia 63) necesare înregistrării în tabelul games_land a numelui şi codului preluate (linia 62) din fiecare grup produs prin aplicarea codului rezultat anterior la linia 57.

50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import codecs, re
import MySQLdb as mdb

f = codecs.open('country.txt', 'r', 'utf-8')
lands = f.read()
f.close()

prog = re.compile(r"^(.+?)\n(.+?)\n", re.MULTILINE)

connection = mdb.connect('localhost', 'vb', 'vb_password', 'slightchess')  
cursor = connection.cursor()

for land in prog.findall(lands):
    cursor.execute('INSERT INTO games_land SET nume = "%s", cod = "%s"' 
                    % (re.escape(land[0]), land[1]))
connection.commit()

cursor.close()
connection.close()

Precizăm că în expresiile regulate (modelate în Python prin modulul re) caracterul special "^" (vezi linia 57) identifică locul de început al textului, dar în modul re.MULTILINE identifică deasemenea, începutul fiecărei linii de text; în acest caz, şablonul ^(.+?)\n recunoaşte (şi grupează) textul aflat pe fiecare linie - de la începutul acesteia şi până ce se depistează '\n' (ne-am asigurat în prealabil, că liniile sunt încheiate cu "\n" ca în Unix şi nu cu "\r\n" ca în DOS).

Mai precizăm rostul folosirii metodei re.escape() (linia 64); pentru un nume de ţară precum Côte d'Ivoire, şablonul '%s' - încadrând cu apostrof; în linia 63 a trebuit să folosim ghilimele - ar duce la apariţia unui apostrof între apostrofuri: apostroful interior face parte din text, iar cele din capete servesc pentru specificarea textului respectiv şi pentru a distinge între aceste două semnificaţii - regula generală este de a "escapa" apostoful interior (însemnând prefixarea lui cu "\"). re.escape() va prefixa cu "\" toate caracterele non-alfanumerice din textul respectiv, ferindu-ne de erori.

Ar mai fi de subliniat faptul că Django constituie numele tabelelor (tabele MySQL, în cazul nostru) după numele aplicaţiei din care face parte models.py - rezultând tabelul games_land (linia 63), pentru obiectul Python ("modelul" de date) Land. Desigur, putem verifica direct rezultatul execuţiei programului de mai sus:

vb@vb:~/slightchess/Doc$  mysql  slightchess
...
mysql> select * from games_land;
+-----+----------------------------------------------+-----+
| id  | nume                                         | cod |
+-----+----------------------------------------------+-----+
|   1 | Afghanistan                                  | AF  |
|   2 | Åland Islands                                | AX  |
...
| 248 | Zambia                                       | ZM  |
| 249 | Zimbabwe                                     | ZW  |
+-----+----------------------------------------------+-----+
249 rows in set (0.00 sec)

Datele apar ordonate după câmpul "nume" (fiindcă în această ordine au fost create înregistrările), dar la cererea lor din interiorul proiectului - ele vor fi ordonate după câmpul "cod", datorită prevederii de ordonare implicită din linia 15 (la definiţia modelului Land).

Determinarea selectorului partidelor

Fişierul GA_07-14-2014.pgn colectează 314 partide jucate de mine; dar la fel de bine, poate fi vorba de o colecţie de partide jucate de altcineva - şi îl numim "selector". Având acest fişier, trebuie să determinăm cine este selectorul - pentru a-l înregistra ca user autorizat (completând şi câmpul instant_username, introdus de modelul Selector), urmând ca apoi să ne ocupăm de înregistrarea partenerilor săi neînregistraţi anterior, precum şi a partidelor respective.

Următorul script Bash înlănţuie - folosind operatorul "pipe" '|' (linia 108) - două comenzi AWK: prima extrage din fişierul PGN dat numele jucătorilor, iar a doua preia lista rezultată şi determină numele care apare cel mai frecvent:

100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
#!/bin/bash

awk '
/White|Black/ && !/IFlag|ITeam/    \
{   nume = $2;
    for(i=3; i <= NF; i++) { nume = nume " " $i }
    print substr(nume, 2, length(nume)-3)
}
' GA_07-14-2014.pgn  |      #  > players.txt 

awk '
{ count[$0]++ }   
END {
    nr_games = 1;    
    for(i in count) {   
        if(count[i] > nr_games) {    
            nr_games = count[i];
            selector = i
        }   
    }
    print "Selector: " selector " (" nr_games " partide)"  
}
'  #  players.txt

Nu este cazul să păstrăm rezultatul primei comenzi; dar dacă ne-ar interesa, atunci am fi înlocuit '|' cu redirectarea indicată în comentariul de la linia 108 (decomentând totodată, în linia 122). Executând scriptul (după ce îl facem "executabil", folosind chmod) obţinem:

vb@vb:~/slightchess/Doc$  ./players.sh
Selector: vlad.bazon (314 partide)

ceea ce ne permite să verificăm că într-adevăr, avem o colecţie de partide ale unui "selector": ştim că fişierul iniţial conţine 314 partide şi acum vedem că "selectorul" afişat apare exact de 314 ori, ca "White" sau "Black". Nu rămâne decât să verificăm dacă "selectorul" afişat este deja înregistrat ca User, în baza de date; dacă nu este, atunci îl putem înregistra cel mai simplu (creându-i şi contul de "user autorizat") folosind "serverul de dezvoltare", accesând /admin/.

Să facem totuşi nişte precizări asupra limbajului AWK folosit mai sus. Blocul de instrucţiuni 104-107 va prelucra acele linii din fişierul indicat pe linia 108, care se potrivesc şablonului prevăzut pe linia 103 - deci numai liniile care conţin "White" sau "Black", dar nu conţin "IFlag" şi nici "ITeam"; astfel, liniile [WhiteIFlag "RO"], sau [Result "0-1"], etc. vor fi ignorate.

Fiindcă "ITeam" apare foarte rar şi chiar nu are de ce să ne intereseze - decidem să ştergem din fişierul respectiv, tagurile "WhiteITeam" şi "BlackITeam" (simplificând operaţiile care vor urma).

Fiecare linie preluată din fişier este împărţită automat în câmpuri, având ca separator implicit spaţiul. Astfel, linia [White "Super GM Jose Raul Capabl"] este împărţită în 6 câmpuri care sunt indicate prin variabilele predefinite $1 = '[White', $2 = '"Super', $3 = 'GM', ..., $6 = 'Capabl"]' (în timp ce $0 vizează întreaga linie). Numărul de câmpuri de pe linia curentă este menţinut în variabila internă NF; în linia 104, variabila nume este iniţializată cu valoarea celui de-al doilea câmp şi apoi, în linia 105 este "extinsă", adăugându-i un spaţiu şi următorul câmp, până la ultimul existent. În linia 106 se afişează subşirul obţinut din nume prin excluderea primului caracter (fiindcă $2 începe cu '"') şi a ultimelor două (ultimul câmp fiind încheiat cu '"]').

Programul END (liniile 112-121) intră în execuţie numai după încheierea prelucrării liniilor fişierului de intrare - prelucrare realizată de programul (cu o singură instrucţiune) din linia 111: se constituie un "tablou asociativ" count[], având drept cheie linia curentă $0 şi drept valoare finală numărul de apariţii ale acelei linii - dat fiind că iniţial este în mod implicit 0, iar apoi este incrementată de câte ori linia respectivă este reîntâlnită (astfel, în final avem count['vlad.bazon'] = 314). În liniile 113 - 119 se parcurge tabloul final count[], determinând cheia de valoare maximă.

De menţionat că limbajul AWK este cel care a introdus pentru prima dată, "tablourile asociative" (modelate apoi şi de Perl prin "hash", de PHP, de Python prin "dict", etc.).

Determinarea partenerilor şi naţionalităţilor

Fiind dat un fişier ca GA_07-14-2014.pgn şi având numele "selectorului" (determinat eventual aşa cum am arătat mai sus) - să obţinem o listă a partenerilor săi (şi a codurilor de ţară aferente), pentru ca apoi să-i înregistrăm conform modelului Partner din aplicaţia games a proiectului.

Desigur că vrem o soluţie simplă, pentru a selecta din fişierul PGN iniţial numai tagurile prin care se specifică numele şi respectiv naţionalitatea, excluzând însă pe cele corespunzătoare selectorului. Comanda următoare este insuficientă:

awk '/White|Black/ && $2 !~ nume' nume="vlad.bazon"  GA_07-14-2014.pgn

Tagul de nume al selectorului este exclus, dar nu şi tagul de ţară al selectorului - cum se vede pe următoarele două secvenţe ale rezultatului obţinut:

[White "андрей555"]                 [Black "nick061"]
[WhiteIFlag "RU"]                   [WhiteIFlag "RO"]
[BlackIFlag "RO"]                   [BlackIFlag "BG"]

Iar această rescriere a comenzii, pentru a exclude şi codul de ţară al selectorului:

awk '/White|Black/ && $2 !~ nume && $2 !~ cod' nume="vlad.bazon" cod="RO"  \
    GA_07-14-2014.pgn

va exclude tagul de ţară şi pentru partenerii din aceeaşi ţară cu selectorul:

[White "K. Productions!"]
[WhiteIFlag "US"]
[White "theodoros687"]
[White "HoneyBadger"]   # Aici ar fi trebuit să urmeze [WhiteIFlag "RO"]
[WhiteIFlag "US"]

Dificultăţile provin din faptul că liniile de taguri din fişierul PGN iniţial nu sunt în ordinea favorabilă soluţionării problemei puse mai sus: [White ...] [WhiteIFlag ...] [Black ...] [BlackIFlag ...], ci sunt (totdeauna) în ordinea [White ...] [Black ...] [Result ...] [WhiteIFlag ...] [BlackIFlag ...]. În scriptul următor fişierul este parcurs de două ori, extrăgând întâi tagurile White şi apoi tagurile Black şi ignorând de fiecare dată pe cele corespunzătoare selectorului:

150
151
152
153
154
155
156
157
158
159
160
161
162
163
#!/bin/bash  # numeScript numeSelector  (./players.sh  vlad.bazon) 
for player in "White" "Black"
do
    awk  -v PLY="$player" '$0 ~ PLY' GA_07-14-2014.pgn    \ 
     | 
    awk -v selector="${1}"    \
        '{    
              if(match($2, selector)) {
                  getline; 
                  next
              } 
              print $0
        }'
done > partner.txt

Întâi pentru player="White" şi apoi pentru "Black" (linia 151) se înlănţuie (prin "|", în linia 154) două comenzi AWK. Prima (linia 153) preia în variabila AWK PLY, valoarea curentă a variabilei BASH player şi extrage din fişierul indicat liniile care se potrivesc cu PLY.

A doua comandă AWK (conectată la rezultatul primeia) instituie întâi variabila AWK selector (în linia 155), în care preia argumentul furnizat pe linia de comandă la apelarea scriptului. Amintim că Bash predefineşte variabilele $0, $1, etc. pentru a păstra numele scriptului, respectiv primul parametru furnizat pe linia de apel, etc.; aceşti parametri pot fi accesaţi în AWK folosind sintaxa "${1}" - distingând de variabila AWK de acelaşi nume (care păstrează un câmp al liniei curente).

Apoi, pentru linia curent preluată de pe fluxul de intrare - se execută programul 157-161. În linia 157 se testează dacă înregistrarea preluată conţine valoarea selector; în caz negativ, se trece la linia 161 - înregistrarea respectivă este adăugată ieşirii. În caz afirmativ, linia curentă precum şi linia de intrare care ar urma - extrasă imediat prin getline (linia 158) - sunt ignorate, datorită încheierii testului menţionat prin next (cu efect similar instrucţiunii continue - încheie "iteraţia" curentă, ceea ce înseamnă aici că se va prelua următoarea linie - de după cele două ignorate - reluând secvenţa 157-161 pentru această nouă linie).

Cum am mai spus, scriptul trebuie făcut "executabil"; apelându-l după modelul indicat în linia 150 (indicând ca parametru numele selectorului), va rezulta fişierul partner.txt (datorită redirectării finale din linia 163), conţinând câte două linii consecutive pentru fiecare dintre partenerii "selectorului" - prima dintre acestea fiind tagul de nume, iar a doua, tagul de ţară. Este drept că un asemenea grup de două linii poate să apară de mai multe ori - în funcţie de câte partide (şi cu aceleaşi culori) a jucat selectorul cu acel partener.

Înscrierea partenerilor în baza de date

Aşadar am obţinut fişierul partner.txt, în care fiecare câte două linii consecutive (socotind desigur, de la începutul fişierului) reprezintă un partener (numele şi respectiv, codul de ţară):

[White "ahmed.rashad10"]  # Nume (ahmed.rashad10)
[WhiteIFlag "EG"]         # Ţară (EG - Egypt)
[White "CG 999,999,999"]  # ce "nume" şi-a ales acesta!…
[WhiteIFlag "US"]
...
[Black "ahmed.rashad10"]  # Tot "ahmed.rashad10", dar acum cu negrul
[BlackIFlag "EG"]
...
[Black "Индијанац Папуанац"]
[BlackIFlag "RS"]

Este important să verificăm codurile de ţară: în tabelul games_land noi am înregistrat numai codurile asignate oficial, iar în fişierul PGN pot apărea şi alte câteva coduri; pentru un cod "nou", va trebui să ne informăm pentru a determina denumirea ţării (sau regiunii) - pretinsă şi aceasta, în games_land. Prin urmare - va trebui să listăm codurile din partner.txt pentru a face o verificare manuală, înainte de a şi înregistra datele în tabelul games_partner.

Următorul program Python constituie un dicţionar {cod_ţară: [parteneri_din_ţară]}, evitând duplicatele şi îl înscrie într-un fişier în format JSON; scris astfel, dicţionarul respectiv (necesar pentru a înscrie în games_partner) va putea fi instituit direct la citirea fişierului JSON respectiv.

200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
import codecs, re
import json

prog = re.compile(r"^.*\"(.+?)\"\]\n.*\"(.+?)\"\]\n", re.MULTILINE)

f = codecs.open('partner.txt', 'r', 'utf-8')
d1_part = {nume: cod for nume, cod in prog.findall(f.read())}
f.close()

d_part = {}
for nume, cod in d1_part.items():
    if cod in d_part:
        d_part[cod].append(nume)
    else:
        d_part[cod] = [nume]

f = codecs.open('partner.json', 'w', 'utf-8')
f.write(json.dumps(d_part, ensure_ascii=False, encoding="utf-8",
                   sort_keys=True, indent=2, separators=(',', ': ')))  
f.close()

Şablonul din linia 203 rafinează pe cel din linia 57, pentru a prelua de pe două linii consecutive din fişier numai numele partenerului şi respectiv, codul ţării.

În linia 206 se aplică programul compilat la linia 203 textului citit din partner.txt şi se creează un dicţionar Python care asociază fiecărui nume de partener codul ţării; un nume poate apărea de mai multe ori în fişier (cum am evidenţiat mai sus), dar drept cheie în dicţionar va apărea o singură dată.

Secvenţa 209-214 "inversează" acest dicţionar, instituind un al doilea dicţionar: cheile sunt codurile de ţară, iar valorile sunt liste Python cu numele de parteneri dintr-o aceeaşi ţară.

În final, acest dicţionar este înscris în fişierul partner.json, folosind metoda json.dumps(), cu parametri adecvaţi pentru codificare "utf-8" şi pentru ordonare (după codul ţării).

În fişierul JSON rezultat găsim de exemplu:

{
...
  "EU": [
    "flam2009",
    "Josip Š"
  ],
... etc.
}

şi ne dăm seama eventual că "EU" nu este un cod de ţară standard, aflând că reprezintă European Union; îl adăugăm în games_land procedând cel mai simplu: lansăm serverul de dezvoltare pentru proiectul Django pe care l-am creat şi accesăm din browser aplicaţia /admin/, folosind facilităţile oferite pentru înscriere în baza de date.

Un mic experiment intermediar ne poate convinge eventual, că json.loads() va produce înapoi, dicţionarul Python dorit:

import codecs, json
f = codecs.open('partner.json', 'r', 'utf-8')
d_part = json.loads(f.read())
f.close()
for cod, l_nume in d_part.iteritems():
    print '\n', cod
    for nume in l_nume:
        print nume.encode('utf-8') 

N-ar rămâne decât să înlocuim secvenţa de afişare de pe ultimele patru linii, cu o secvenţă prin care (folosind MySQLdb) să adăugăm în games_partner câte o înregistrare pentru fiecare pereche "cod" şi "nume", unde "nume" este unul din lista "l_nume"; dar nu "cod" trebuie înscris, ci indexul acestuia în tabelul games_land şi în plus, trebuie să verificăm dacă "nume" n-a fost înscris cumva cu vreun prilej anterior (desigur, de data aceasta nu-i cazul să verificăm "manual").

Următorul script Python instituie dicţionarul corespunzător fişierului partner.json (liniile 222 şi 223) şi pentru fiecare cheie "cod" a acestuia (linia 230) determină indexul (valoarea din câmpul land_id) aferent în games_land - linia 231 - şi apoi înscrie câte o înregistrare în games_partner pentru fiecare nume din lista asociată în dicţionar codului respectiv (ciclul interior, 233-235).

220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
import codecs, json, MySQLdb as mdb

f = codecs.open('partner.json', 'r', 'utf-8')
d_part = json.loads(f.read())
f.close()

conn = mdb.connect('localhost', 'vb', 'vb_passw', 'slightchess', 
                    charset='utf8', use_unicode=True);
cursor = conn.cursor()

for cod in d_part:
    cursor.execute('SELECT id from games_land where cod="%s"' % cod)
    land_id = cursor.fetchone()[0]
    for nume in d_part[cod]:
        cursor.execute('INSERT IGNORE INTO games_partner SET nume="%s", land_id="%s"'
                        %(nume, land_id))    

conn.commit()
cursor.close()
conn.close()

Pentru înscrierea în games_partner am folosit (linia 234) INSERT IGNORE: câmpul nume al acestui tabel nu poate înregistra decât "valori unice", datorită specificaţiei unique=True din definiţia acestui câmp (linia 18) în cadrul modelului Partner pe baza căruia s-a creat tabelul; ca urmare, "IGNORE" asigură că în cazul unui nume deja existent în tabel, înregistrarea corespunzătoare acelui nume va fi ignorată. Dacă am fi avut unique=False - ar fi apărut duplicate ale înregistrărilor existente; iar dacă unique=True, dar lipsea "IGNORE" - atunci obţineam eroare de execuţie ("duplicate Key").

Bineînţeles că înscrierea efectivă în baza de date (prin linia 237) se face exterior ciclului în care se iterează cursor.execute() (linia 234) - după ce înregistrările respective sunt cumulate în obiectul Python "cursor" (instituit în linia 228). Este uşor de verificat că adăugând conn.commit() în interiorul ciclului dematat la linia 233 (imediat după "execute()", eliminând în acest caz linia 237) - timpul de execuţie al programului se va mări foarte mult.

Ajustarea fişierelor adnotate de Crafty

Procedând aşa cum am arătat în [1], am obţinut subdirectorul /Doc/GAcan/ conţinând fişierele PGN adnotate de Crafty, corespunzătoare partidelor din fişierul iniţial GA_07-14-2014.pgn. Avem de înscris conţinuturile acestor fişiere drept valori ale câmpului pgn din tabelul games_game - dar până atunci, să observăm totuşi că în toate aceste fişiere apar (aceleaşi) linii de date "inutile":

[Event "InstantChess"]
[Site "?"]
[Date "????.??.??"]
[Round "?"]
[White "чечня гелдаган мохьмад"]
[WhiteElo ""]
[Black "vlad.bazon"]
[BlackElo ""]
[Result "0-1"]
[Annotator "Crafty v23.4"]
{annotating both black and white moves.}
{using a scoring margin of +0.75 pawns.}
{search time limit is 10.00}

  1.      b4      e5
... etc. (continuarea listei mutărilor, cu adnotările adăugate de Crafty)
       0-1 

Crafty a procedat desigur corect, când a formulat fişierele ".can": taguri ca Site şi Date - chiar şi "vide" - sunt teoretic obligatorii pentru formatul PGN standard; iar comentariile adăugate oferă informaţii relevante: mutările cărei părţi sunt adnotate, la ce diferenţă de apreciere (între mutarea efectuată şi respectiv, mutarea pe care ar juca-o Crafty în urma evaluării poziţiei) se formulează o adnotare în fişierul ".can" (aici, 0.75 din valoarea asumată pentru pion) şi respectiv, limita de timp (aici, 10 secunde) impusă pentru analiza fiecărei mutări.

Însă pentru aplicaţia noastră (vizând "partide uşoare"), tagurile care au valori ca "?" sau "" şi chiar informaţiile relevate mai înainte, sunt "inutile"; liniile respective (2-4, 6, 8 şi cele trei comentarii informative) ocupă în total 192 de octeţi şi fiindcă avem acum 314 fişiere ".can" în /GAcan/ - rezultă deja mai mult de 60000 de octeţi "inutili" (pe care ar urma să-i înregistrăm în baza de date).

Prin urmare, iată că ne punem problema de a elimina din toate fişierele ".can" din directorul /GAcan/, toate tagurile care au valori ca "?" sau "", precum şi cele trei comentarii. Pentru o soluţie cât mai comodă, putem folosi programul utilitar grep; creem întâi un fişier patterns.txt conţinând pe câte o linie, şabloanele care să identifice liniile din fişierele ".can" pe care vrem să le ştergem:

""
"?
annotating
using
search

Se vor "şterge" linii ca [WhiteElo ""] (dar nu şi [WhiteElo "2080"]), [Round "?"], [Date "????.??.??"], sau {search time limit is 10.00} - prin următorul script Bash:

#!/bin/bash
for file in `ls -v G_*.can`
do
    grep -Fvf patterns.txt $file > "$file.1"
    mv "$file.1" $file
done

Se creează lista fişierelor "G_*.can" din directorul curent (invocând comanda ls) şi pentru fiecare dintre acestea se invocă grep - redirectând ieşirea pe un fişier auxiliar care apoi este "mutat" (prin comanda mv) peste fişierul iniţial. Pare cam întortocheat, dar modificarea dorită se obţine într-o secundă (pentru 314 fişiere, ocupând în total aproape 1MB).

grep -Fvf obţine şabloanele din fişierul indicat (opţiunea -f) patterns.txt şi le interpretează ca şiruri fixate (nu ca expresii regulate) datorită opţiunii -F; în mod normal ("default") s-ar produce liniile din fişierul de intrare care se potrivesc şablonului dat - dar opţiunea -v inversează căutarea: exclude liniile care se potrivesc şablonului.

Dacă vrem să verificăm lucrurile, putem folosi următoarea combinaţie de comenzi:

vb@vb:~/slightchess/Doc/GAcan$ find . -name 'G_*.can' | xargs wc -c | tail -1
998920 total

obţinând numărul total de caractere din fişierele respective, înainte de a executa scriptul de mai sus şi repetând-o - după execuţia acestuia (când am obţinut "941772 total").

find produce o listă a fişierelor respective; apoi, xargs pune în execuţie comanda wc pentru fiecare dintre fişierele ale căror nume i-au fost listate de comanda "find" (căreia îi este conectată); tail -1 afişează ultima linie produsă de "wc" - totalul caracterelor existente în fişierele respective.

Înscrierea partidelor din fişierele .can

Următorul program citeşte de pe linia de comandă numele "selectorului" (trebuind apelat sub forma python nume_program nume_selector, de exemplu python st_game.py vlad.bazon), determină (liniile 265-267) indexul selectorului în tabelul games_selector, constituie lista numelor fişierelor "*.can" din directorul /GAcan şi apoi (ciclul 271-280) - citeşte fiecare fişier, determină indexul în games_partner (liniile 276-277) corespunzător partenerului din acea partidă al selectorului şi înscrie textul acelei partide şi indecşii determinaţi în games_game.

250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
import sys, codecs, os, re
import MySQLdb as mdb

selector = re.escape(sys.argv[1]) # python store_game.py  nume_selector
prog = re.compile(r"\[White \"%s\"\]" % selector)

def get_partner(pgn):
    if re.search(prog, pgn):
        return re.search("\[Black \"(.*?)\"", pgn).group(1)
    return re.search("\[White \"(.*?)\"", pgn).group(1)

conn = mdb.connect('localhost', 'vb', 'vb-passw', 'slightchess', 
                   charset='utf8', use_unicode=True);
cursor = conn.cursor()

cursor.execute('SELECT id FROM games_selector WHERE instant_username="%s"' 
                % selector)
selector_id = cursor.fetchone()[0]

_, _, can_files = next(os.walk("GAcan"))

for f_name in can_files:
    fn = codecs.open("GAcan/" + f_name, 'r', 'utf-8')
    can = fn.read()
    fn.close()
    part = get_partner(can)
    cursor.execute('SELECT id FROM games_partner WHERE nume="%s"' % part)
    part_id = cursor.fetchone()[0]
    cursor.execute(
        'INSERT INTO games_game SET coach_id="%s", pgn="%s", partner_id="%s"' 
         %(selector_id, re.escape(can), part_id))

conn.commit()
cursor.close()
conn.close()

Funcţia get_partner() (liniile 256-259) determină numele partenerului selectorului, din partida al cărei text PGN îi este indicat ca argument: se testează dacă numele selectorului apare ca valoare a tagului White - dacă da, atunci se returnează valoarea din tagul Black, iar altfel (selectorul a avut negrul) pe cea din tagul White.

Metoda os.walk() produce un "3-tuple" (cale_director, lista_nume_subdirector, lista_nume_fişier) - din care ne-a interesat (linia 269) numai lista fişierelor. Se subînţelege - directorul /GAcan/ trebuie să nu conţină alte fişiere, decât cele 314 "G_*.can", iar programul trebuie apelat din /Doc/.

Probabil merită menţionat: execuţia programului (pentru cele 314 fişiere) a durat neaşteptat de puţin - sub 0.5 secunde (pe un calculator modest). Dar de menţionat este faptul că… "n-a mers" de prima dată, întâlnind o eroare de "integritate": IntegrityError: (1452, 'Cannot add or update a child row: a foreign key constraint fails (...)).

La originea acestei erori stă faptul că începând cu versiunea 5.5, MySQL are InnoDB, drept "default storage engine"; aceasta m-a şi scutit, să adaug în configurarea DATABASES din fişierul settings.py al proiectului Django opţiunea de iniţializare: 'OPTIONS': { 'init_command': 'set storage_engine = INNODB' } - cum tot procedam pentru versiunile MySQL anterioare. De subliniat că după "upgrade" la noua versiune, aplicaţiile mele deja existente "merg" fără nicio modificare - nu am obţinut vreo eroare de "integritate", dar probabil pentru motivul că ele folosesc nu direct modulul MySQLdb, ci folosesc metodele de lucru cu bazele de date oferite de Django.

InnoDB verifică la fiecare operaţie asupra înregistrărilor, respectarea condiţiilor de "integritate referenţială": de exemplu, nu permite ştergerea unei înregistrări, dacă ea este referită dintr-un alt tabel; sau, pentru încă un exemplu - la ştergerea unei înregistrări asigură automat ştergerea tuturor înregistrărilor referite prin câmpuri "Foreign Key" ale acesteia (pe când în modelul "clasic" MyISAM, programatorul trebuie să se ocupe de acest aspect).

Numai că verificarea menţionată presupune totuşi existenţa unei specificaţii explicite de integritate. Adăugând în models.py (liniile 27 şi 29) on_delete = models.CASCADE (corespunzând specificaţiei din MySQL, ON DELETE CASCADE) - programul s-a executat fără a mai produce "IntegrityError". Desigur că n-a fost necesar să o luăm de la capăt cu întregul proiect; este suficient să se şteargă tabelul games_game din baza de date (folosind "DROP TABLE") şi să se folosească apoi manage.py syncdb - tabelul va fi recreat conform modificării făcute în models.py.

vezi Cărţile mele (de programare)

docerpro | Prev | Next