Лого на страниците (малко).

Заглавна страница > Четива > Четива за глотометрията > Българска антропонимия. Материали > Практикум 2. CSV — Comma-Separated Values

Система Orphus

Ако забележите грешка, маркирайте израза с мишката и натиснете Control+Enter. Благодаря!

Александър Иванов

Практикум 2. CSV — Comma-Separated Values

В това второ упражнение (практикум) с данните за имена на български граждани (2015) ще обяснявам следните неща:

Първо, какво представлява файловият формат CSV, какви предимства предоставя той и какви са недостатъците му.

Второ, ще представя малка питонска програма, която преписва данните във формат CSV. Тя е добър пример за онова, което аз наричам изследователско програмиране — обработка на конкретни данни с конкретна цел и, най-вероятно, с еднократно използване.

Ще разяснявам устройството на програмката подробно: педагогическата ми цел е да убедя филолога изследовател, че за да създаде подобна програмка трябва: а) добре да познава данните си; б) да е съвсем наясно каква цел иска да постигне. Останалото са дребни технически сръчности, които лесно се научават. И някои дребни хитрини, които също лесно се измислят.

Дребни, дребни, но аз подробно ще ги разяснявам по дидактически причини. Смятам, че да се гледа как работи реална програма с реални данни е най-лесният начин да се научи човек да си служи с програмен език. Дори ако гледа такава семпла програмка, като разглежданата тук. Та приемете моите разяснения като урок по Python.

* * *

В Практикум. Как да се работи с данните от извадката от имена на български граждани показах как може да се направи един файл с всичките данни от 265-те директории по общини. Там му дадох име all_text.txt и този файл сега ще използвам като изходни данни, които ще пренаредя по по-удобен за работа начин.

Вие си спомняте как са формирани данните в този файл. Имената на избирателите във всяка секция са представени в списък, като с табулатор след името е въведен класификатор за пола на избирателя, когато това е възможно да се установи от името. А когато не може, се вписва класификатор n — тоест винаги има класификатор.

Пред списъка на избирателите в секцията има „заглавка“ с пет полета:

A header in the file all_text.txt.

Това е сравнително пестелив формат на данните без прекомерно много повторения. Както показах, той е удобен, когато искате да правите речници, например на личните или на фамилните имена.

Но си представете сега една малко по-друга задача — искаме да видим как едно или друго име се разпределя по територията на страната. Нещо подобно направихме в предишното упражнение с изписването на името АЛЕКСАНДАР.

Но откъде извлякохме там информация как се разпределя това изписване по територията на страната? Извлякохме я от имената на директориите, които отговарят на общините.

Ами ако искаме да направим по-обобщени наблюдения, например по избирателни райони (което отговаря приблизително на областите)? Или, напротив, по-детайлизирано — по селища? Тази хитрина с директориите няма да ни помогне.

Можем да поискаме и друго — да потърсим, например, някакви зависимости между личните и фамилните имена. Като познаваме данните си, можем да направим така: във всеки ред, който не започва с диез, отстраняваме края му от първия табулатор нататък; текстът до първата шпация е личното име; текстът от втората шпация до края е фамилното име. Това не е трудно да се направи, но е малко игра.

Ако обаче поискаме сега да потърсим някакви различия във връзката между личните и фамилните имена по територията на страната, ще се сблъскаме със същата трудност, както в предишната задачка.

Очевиден изход от ситуацията е да препишем данните си така, че след името на всеки избирател да се „влачи“ не само информацията за пол, но и за избирателен район, община, населено място и пр. При това преписване можем да „разбием“ и името на избирателя на лично, бащино и фамилно. В крайна сметка — да получим нещо подобно на таблицата отдолу.

A header in the file all_text.txt.

Така данните в реда очевидно са разбити в „полета“, а на първия ред (като заглавка на таблицата) аз съм дал и „имена“ на тези полета.

Точно с тази цел информатиците са „измислили“ файловия формат CSV (Comma-separated values), тоест „стойности“ (данни), разделени със запетайка в реда. Фактически това си е текстов файл, в който се съдържа таблица, ама написана малко нечетливо. Нечетливо за нас, хората; за програмна обработка това е много „четлив“, удобен запис.

Картинката отдолу илюстрира началото на текстов файл във формата CSV, който съдържа данни за населението (мъже и жени) по области и общини към края на 2015 г. Тези данни, разбира се, аз съм взел от Националния статистически институт.

A header in the file all_text.txt.

И сега – няколко забележки:

1. Задължително е във всеки ред броят на полетата да е един и същ — това все пак е таблица. Казано по друг начин, броят на разделителите (запетайките) трябва да е един и същ във всеки ред. Ако някое поле е празно (няма данни в него), между разделителите просто не се пише нищо, но разделителите трябва да съществуват. Вижте на картинката втория ред „Общо за страната“: четвъртото поле „Статус“ тук е празно, но запетайката в края на реда е задължителна: тя е разделител между третото и четвъртото, празното поле.

2. Независимо от името на този файлов формат, не винаги е възможно да се използва като разделител между полетата запетайка. Например в нашите данни за имена на избиратели не бива да използваме като разделител запетайката, защото в полето „Населено_място“ понякога се изброяват няколко селища със запетайка и ще настане объркване. Затова е възможно да изберете друг символ за разделител между полетата, който не се съдържа в данните, например, точка над запетайка (;), вертикална черта (|), табулатор и пр.

3. Очевидно, когато препишем данните във формат CSV, ние ги „раздуваме“, въвеждаме много повторения в тях. След малко ще видите, че нашите данни от all_text.txt ще се утроят. Разбира се, само като размер на файла, не като повече информация.

Ако се изразим философски, това е основно диалектическо противоречие в информатиката: от една страна, данните да се съхраняват в колото е възможно по-компактен вид, а от друга страна — да се обработват по-бързо и по-удобно, което предполага по-бърз и по-пряк достъп до тях. А бързият достъп предполага „редундантност“, повторение на информацията. Днес с поевтиняването на запомнящите устройства филологът изследовател рядко ще се натъква на това противоречие. Но ако си представи хилядите петабайти информация в мрежата, които трябва да се съхраняват, да се дублират, да се възстановяват или да се преместват в различни точки на света, лесно ще се досети, че противоречието днес е все така остро, каквото е било и в миналото.

4. Данните от текстови файлове във формата CSV лесно се вкарват (импортират) в разнообразен софтуер за обработка на данни — в таблици на базите с данни, в електронни таблици от типа на Excel и Calc, в разнообразен софтуер за количествена обработка и визуализация на „големи“ данни като MatLab, R, Pandas, Dask, Spark на Apache и пр. От друга страна, данните от всичките тези „бинарни“ формати, които използват всичките тези програмни средства, могат да се извеждат (да се експортират) отново в текстов файл във формат CSV. Така на форматът CSV може да гледате като преходен, междинен формат между текстовото представяне на данните и бинарното им представяне.

* * *

Е, добре. Свърших с уводната част, време е да се захванем за работа.

Целта е да се препишат данните от списъка във файла all_text.txt като всеки ред в новия файл трябва да съдържа:

– три полета с личното, бащиното и фамилното име (бащиното в някои случаи може да остане празно);

– четвърто поле е с класификатора за пол;

– още пет полета за избирателен район, община, кметство (ако е отбелязано в данните), населено място и избирателна секция;

Резултатът трябва да изглежда както в таблицата по-горе. За разделител между полетата ще използвам вертикална черта (|), защото запетайката се среща в данните. Резултатът ще запиша във файл all_text.csv.

За да постигна това трябва:

Първо, да изчета текста от all_text.txt в паметта. Най-удобно ще е да изчета текста в списък (list) от текстови редове (низове, strings). Така ще мога по-късно да обходя текста ред по ред.

Когато после обхождам текста ред по ред отгоре надолу, ще трябва да върша няколко неща.

Първо, да събирам информация от заглавката пред секцията. Нея после ще трябва да преписвам след името на всеки избирател. За щастие, аз знам, че тази заглавка винаги съдържа пет полета — те винаги започват със знак диез (#) и текстът в името на полето (с главните букви) е един и същ при всички секции. Доста време съм отделил, за да уеднаквя тези неща, но сега този труд се отплаща.

Естествено, при всяка нова секция и информацията за заглавката трябва да се обновява.

По-нататък трябва да обработя имената на избирателите, като всяко име се „разчупва“ на четири части — лично, бащино, фамилно име и класификатор за пол.

Когато и това е свършено, конструира се новият ред с деветте полета, за които вече говорих (виж таблицата) и се записва като нов ред в създавания текст.

И накрая новосъздаденият текст с девет полета във всеки ред трябва да бъде записан във файла all_text.csv.

И това е всичко.

Може да си изтеглите програмката text_csv.py.zip (MD5: 1cda838a157b1509887c83ebac98555b) и да я използвате. Пък отдолу аз съм показал основната част от програмния текст, за да мога да коментирам някои подробности.

A header in the file all_text.txt.

На ред 29. се изчита текстът и се превръща в списък (list) от текстови редове (strings). И това се запазва в списъка textin.

Да, в пайтън всичко това може да се извърши на един ред с една команда. Затова препоръчвам на филолога глотометрист да използва пайтън, най-добре Python 3. За да съм по-убедителен, ще ви предложа един мъничък питонски етюд, който прави съвършено същото, но в който подробностите се виждат по-добре и мога да ги коментирам:

A simple example for reading a text file.

Гледайте четирите реда на [6]; на [7] само проверявам дали всичко е свършено както трябва. В първия ред fh е съкращение от file handle (файлов манипулатор) — той се създава от операционната система, когато отваряме файла. И това се нуждае от малко разяснения.

Работа с файлове

Когато една приложна програма — нашите програми винаги са „приложни“, „системни програми“ пишат разработчиците на ядрото или на драйверите за външни устройства, или на нещо подобно, което може да бъде „вградено“ в ядрото — поиска да работи с някой файл, тя трябва да подаде заявка към операционната система.

В тази заявка се посочва името на файла и какво иска приложната програма да прави с този файл — възможните действия не са много: файлът може да бъде отворен за четене, за писане (понякога са смислени и двете действия, тогава файлът може да бъде отворен едновременно за четене и за писане) и за дописване (append). Тук с open('all_text.txt', 'r') заявката е да се отвори файлът all_text.txt за четене — 'r' е от read. Това в питона е подразбираща се стойност и в програмата по-горе (на ред 29) аз съм си я спестил, оставил съм я по подразбиране.

Когато операционната система получи заявка за работа с някой файл, тя извършва доста действия. Преди всичко тя приписва на файла едно „уникално“ цяло число — то е „уникално“, защото се свързва само с този файл и не се повтаря при работа с други файлове. На равнището на операционната система, точно това число наричат файлов манипулатор.

То сочи към ред в таблица, създавана от операционната система, за работа с файловете. В този ред е запазена информацията за името на файла, пътя до него, режима на работа с файла. Създава се и един указател към данните във файла, нещо като пръстче, което сочи откъде ще се четат или ще се пишат данните.

Когато отворите файла за четене или за писане, този указател се поставя върху първия байт от файла; ако отворите файла за дописване (append), операционната система насочва указателят след последния байт във файла.

Ако отваряте файла за писане, обаче, ВНИМАВАЙТЕ! Освен че поставя указателя за данни в първата му позиция, операционната система обявява и дължината на файла за нула. Тоест, ако във файла е имало данни, загубили сте ги. Внимавайте с писането!

Когато вашата програма свърши работата си с файла, тя трябва да го затвори. При това операционната система „забравя“ данните за този файл, освобождава и „файловия манипулатор“ — по-късно може да го предостави на друга програма за друг файл.

В питона вградената функция/метод open() иска като първи аргумент името на файла. Вторият аргумент е стринг и определя режима на работа с файла:

– "r" (read), файлът се отваря за четене;

– "w" (write), файлът се отваря за писане;

– "a" (append), файлът се отваря за дописване;

– "x" същото като "w", но с проверка дали файлът съществува; ако файлът съществува, изпраща се съобщение за грешка и файлът не се отваря, тоест, има защита от човешка грешка.

Ако след буквата се постави знак плюс (+), файлът се отваря едновременно за четене и за писане; най-добре използвайте само "r+" — при "w+" може да унищожите съдържанието на съществуващ файл.

И най-накрая с буквите b (binary) и t (text) се определя дали с файла ще се работи като с бинарен, или като с текстов файл. За разликата вижте Текстов файл, бинарен файл. Така става ясно – подразбиращият се режим за работа с файлове в питона е всъщност 'rt' — подразбира се отварян за четене текстов файл.

Не се чудете, че смених тук кавичките — в питона стрингове (низове) могат да се въвеждат и с двойни, и с единични кавички.

В етюдчето по-горе с fh = open() създавам „файлов обект“ (така го наричат в документацията) с идентификатор (име) fh. Това не е чудно — в питона всичко е обект. А към обектите може да се прилагат определени методи (функции), било за да получите някаква информация, било за да извършите някакви действия с тях.

Така с fh.fileno() може да прочетете номера, с който операционната система е свързала този файл. С fh.name — името на отворения файл; с fh.mode — режима, при който сте отворили файла ('r' в примера); с fh.writable() ще получите отговор False — да, в този файл не може да пишете, но с fh.readable() ще получите отговор True.

Има и метод fh.seek(), с който може да местите указателя за данни във файла. Ама надали ще ви се наложи да работите така: питонът има модули за работа с всякакъв вид таблици, а тези модули ви освобождават от необходимостта да работите с файла на „ниско равнище“.

В етюдчето във втория ред с метода fh.read() аз изчитам целия текст като един ред (стринг, низ) и го записвам в памет с име textin. Символите за нов ред при метода .read() се интерпретират просто като символи в текста.

Обърнете внимание, че не съм създавал паметта textin, нито съм определял от какъв тип трябва да бъде тя. По много мил начин интерпретаторът на Python свършва тази работа вместо мене.

Операторът (=) тук не е „знак за равенство“, нашите информатици го наричат „оператор за присвояване на стойност“ (англ. assignment). Ама глаголът присвоявам на български най-често е евфемизъм (административен) за крада — кой краде тук разни стойности!?

За тази нескопосна полукалка от руски Янакиев е написал филологическа справка (вж. Електрониката и учителят филолог [djvu] [един файл], стр. 116 и нататък). Аз ще се опитвам да я замествам с „копиране на данни в …“ или с някаква подобна перифраза.

Следователно, интерпретаторът на пайтън извършва първо действията с дясната операнда на оператора =, „научава“ типа на данните и след това създава памет с име на лявата операнда и съответния тип.

Като знаете тази последователност на действията, вече няма да ви изненадват често срещани в програмирането изрази като:

>>> num = num + 10

или синонимът му

>>> num += 10

След като съм изчел текста от файла (второто редче в етюда), трябва да приложа метода .close() и да затворя файла (третото редче в етюда). Така програмата съобщава на операционната система, че е свършила работа с файла, и операционната система може да освободи ресурсите, свързани с него.

И накрая, на четвъртото редче, прилагам към стринга метода .splitlines(), който начупва стринга на текстови редове и създава списък (list) от стрингови памети. Резултатът се записва в textin — това е нова памет вече от типа list (списък) въпреки еднаквото име със стринговата памет.

А какво става със старата стрингова памет?

Това не е работа на програмиращия. Интерпретаторът е имал грижата да я обяви за несъществуваща и по някое време (не е задължително веднага) ще изчисти боклука и ще освободи паметта, заемана от нея.

* * *

Да се върнем сега към програмния текст. Всички действия, които разглеждах в програмния етюд, тук се правят на един-единствен ред 29.

Ясно е, че open('all_text.txt') създава „файлов обект“, към който може да се приложи методът read(), а към резултата (стринг) на свой ред може да се приложи методът .splitlines(). И полученият списък (list) се преписва в textin.

На този ред можем да направим школски синтактичен анализ: има главно изречение с подлог, сказуемо и допълнение (textin = open('all_text.txt')), към допълнението има подчинено изречение (.read()), а към него — още едно подчинено изречение (.splitlines()).

Много препоръчвам на филолога, когато изучава някакъв програмен език, да интерпретира наученото в схемите на училищния синтаксис — това не само винаги е възможно, но и много ще му помага.

И все пак пред внимателния читател тук ще възникнат въпроси: щом като създаваме „файлов обект“ и отваряме файл на един ред, какво става, след като изпълним реда? Затваря ли се файлът? Съществува ли „файловият обект“ и имаме ли достъп до него?

Не, „файловият обект“ вече не съществува и няма достъп до него. И да — файлът е затворен.

Всъщност, интерпретаторът изпълнява всички действия, който показах в етюда, но ги изпълнява „скрито“ от нас и ни спестява писане.

Такива програмни конструкции в документацията наричат „контекст“ — очевидно имат предвид задължителни в даден контекст действия, които интерпретаторът извършва „под капака“, скрито от нас.

В края на програмката, на редове 75–77 аз отново използвам „контекстна“ конструкция с ключовата дума with, за да запиша резултата във файл.

На ред 75 явно се създава файловият обект (fh), работи се с него в итератора (обърнете внимание, че методът .write() не поставя символ за край на реда във файла и затова аз трябва да го прибавя), но се излиза от цялата програмна конструкция, без явно да се затваря файлът. Пак прехвърлям „черната работа“ върху интерпретатора.

След като съм изчел текста на ред 29, аз създавам и инициализирам известни работни памети. В празния засега списък text ще събирам готовите редове (с девет полета), за да мога после да ги запиша (на редове 75–77) във файл. В списъка common ще събирам информацията от заглавката на секцията, за да може по-късно да я приписвам към всеки избирател в тази секция. В списъка person ще „разчупвам“ името на избирателя — лично, бащино (ако го има), фамилно и класификатора за пол. Числовата памет counter ще е брояч на обработените имена на избиратели и ще се използва само при възникване на грешка (вижте редове 70–72).

Тук моят идеален и измислен читател би трябвало да ме попита: „Чакай! Чакай! Преди малко ми обясни как интерпретаторът на питона сам създава памет от необходимия тип. Тук що не използваш същия механизъм?“

Хубав въпрос, приятелю, с прост отговор — защото интерпретаторът не може да определи типа на паметта. Виж, например, ред 67, където броячът counter се увеличава с единица. Но това действие е възможно и с целочислена памет от типа int, и с памет за число с цяла и дробна част от типа float, па даже и за комплексно число от типа complex, защото питонът поддържа и такъв тип памет.

Може да ти се стори, че методът .append() еднозначно определя типа на паметта като list. Но това не е така. Иди в документацията и потърси „append“ — ще видиш, че има поне още пет-шест обекта в стандартната библиотека, които притежават този метод.

Аз ще посоча само два примера — deque от модула collections (deque е съкращение от double ended queue ‘двустранно достъпна опашка‘) и array ‘масив‘ от модула array.

Когато интерпретаторът попадне на такава неопределеност (двусмислица), той издава съобщение за грешка:

NameError: name '…' is not defined

Многоточието, разбира се, се заменя с името (идентификатора) на паметта, която си се опитал да използваш по този начин.

Изходът от тази многосмислица е ясен — предварително трябва да обявиш памет (тоест идентификатор) с някаква начална стойност, която разяснява типа на паметта, например празен списък (list) или 0 за целочислена памет.

Ако по някакви причини искаш паметта counter да е от типа float (число с цяла и дробна част), трябва да запишеш в него стойност от вида 0.0 или .0, или 0. (с точка накрая).

От гледище на математиката изписването 0.0 е съвсем безсмислено. Но ти тук нямаш работа с математик, а с интерпретатор (програма за синтактичен анализ) и трябва да въведеш някак точката като разделител между цялата и дробната част на числото, за да „разбере“ тя, че искаш да създадеш памет от типа float, а не от типа int.

Тоест, опитвай се да си представиш как програмата прави синтактичен анализ на твоя програмен текст, и нещата стават наистина прости.

След това (на ред 34) създавам итератор — паметта L последователно ще обходи всички текстови редове в списъка textin от първия до последния. И, ясно е, от тук нататък (от ред 35) започва работната част на програмата (до ред 72).

В тази работна част първото, което създавам, е структурата try: <…нещо…> except: <…нещо…>. Подобна конструкция има във всички съвременни обектноориентирани езици. Смисълът й е: я опитай да свършиш това, което съм описал след try:, ако нещо се оплеска, изпълни онова, което съм описал след except:. Тоест, това е конструкция за обработка на грешка, а филологът ще се досети — съчинителна (паратактична) конструкция с противопоставителен съюз.

Е, да — проста съчинителна конструкция, ама и към двете й части има подчинени (хипотактично) части. Информатиците ги наричат „блок команди“: под „блок“ имат предвид, че са на едно и също хипотактично (подчинително) равнище.

Равнището на подчиненост (хипотаксис) в питона се отбелязва с отстъп на реда от четири шпации. Точно четири! Текстовият редактор обикновено е настроен (или може да бъде настроен) да замества табулатора с четири шпации.

В това отношение питонът се отличава от повечето програмни езици, където се използват обикновено някакъв вид скоби или някакви ключови думи, за да се изрази хипотактична зависимост на блока команди спрямо предишната команда. В питона подчинителната връзка се изразява само с отстъпа. Запомнете това! И внимавайте с отстъпите — значещи са, не са само за красота и четливост.

Да разгледаме първо втория блок команди след except: (редове 70–72). Тук командите ще сработят само ако при основната обработка на текста възникне някаква грешка, например, в реда е пропуснат табулатор. И действията са прости — отпечатвам брояча на създадените редове и последния успешно формиран ред, след което спирам програмата (заради това трябваше да импортирам модула sys на ред 26, за да мога да използвам метода му .exit()).

Тази информация ми е достатъчна, за да отворя входния файл (all_text.txt), да намеря грешката и да я отстраня. След което ще стартирам програмата отново.

Полезната работа обаче се върши в блока команди между try: и except: (от ред 36 до ред 67).

С поредица от проверки (elif в питона е съкращение от else if) аз формирам списъка common, в който е информацията от заглавката на секция. Тук много ми помага, че знам и съм сигурен, че заглавката пред списъка с избиратели във всяка една секция е точно пет реда. Използвам това и когато дължината на списъка common стане по-голяма от 5 реда, това е категоричен знак, че е намерена заглавката на следващата секция: аз просто отрязвам старите пет реда в common (ред 41) и продължавам нататък.

Помага ми, разбира се, и това, че 1) етикетите в заглавката са винаги еднакви във всички секции и 2) винаги са в една и съща последователност. Така, когато формирам нов ред в новосъздавния файл (тип .csv, тоест таблица), аз нямам никаква грижа с подредбата на полетата от заглавката на секцията — те вече са подредени по колони. И сега просто ги изчитам в списъка common.

По-нататък, в командите след else:, нещата са ясни — обработват се редовете, които не започват с диез и етикет, тоест редовете с имената на избирателите в секцията. И накрая (на ред 65) се формира текстов ред с девет полета (за разделител се използва вертикалната черта, както се уговорихме) и се записва в списъка с резултата text.

И всичко това се повтаря за всички редове във входния текст от textin.

Е, и най-накрая (редове 75–77) резултатът (от text) се записва във файл all_text.csv.

И толкова. Не е много сложно.

* * *

Вие може да изтеглите програмаката ми (по-горе има препратка), можете да си направите общ файл с данни all_text.txt, както е обяснено във втората част на Практикум. Как да се работи с данните от извадката от имена на български граждани, да я стартирате и да получите нещо подобно: all_text.csv.zip (MD5: 0ecf017e335d44bbbdf10e8eab198206).

Това, разбира се, е архивен файл, извадете от него файла all_text.csv и го сравнете с резултата, получен при вас.

Ще откриете известни разлики. Уточнил съм изписването на някои селища съобразно с Единния класификатор на административно-териториалните и териториалните единици (ЕКАТТЕ), уточнил съм и пола в известни случаи. Но поправките не са достатъчни, за да обявя „нова редакция“ на данните.

Ще забележите също, че файлът all_text.csv е почти три пъти по-голям от изходния файл all_text.txt. И няма как да не е по-голям, нали в него многократно се повтарят имената на избирателни райони, общини и пр.

Ако искате да овладеете питона — прекрасен инструмент за обработка на текстова информация, — не използвайте моята програма. Отворете си редактора и си препишете програмния текст. Четенето и преписването на чужди програми е най-добрият начин да се научите да програмирате — пред вас ще излязат много въпроси и докато им отговорите, ще научите много нови неща.

А още повече неща ще научите, ако експериментирате с програмата. Например, не е нужно да изчитате в паметта входния текст от all_text.txt, нито пък да създавате резултата в списъка text, пък после да го записвате. Може да отворите двата файла — съответно за четене и за писане, и да извършвате обработката ред по ред. Опитайте.

* * *

И за завършек — най-главното: защо ви показвам как да препишем избирателните списъци от файла all_text.txt в табличен вид, във файла all_text.csv?

Защото мисля следващия път да ви покажа как се работи с pandas — популярен питонски модул за научни изследвания и за работа с голямо количество данни.

Страница: А. И.
Електронна поща
Дата на публикуване: 19.VIII.2019
Последна редакция: 22.VIII.2019
Съобразено с
html5/css3