Перейти до вмісту

Пориньте у Python 3/Версія для друку

Матеріал з Вікіпідручника

Що нового?

[ред.]

❝ Isn’t this where we came in? ❞
— Pink Floyd, The Wall

також відомий під назвою “мінусовий рівень"

[ред.]

Ви вже Python програміст? Ви перечитали в оригіналі “Занурення в Python”? Ви купили паперовий примірник? (Якщо так, дякую!) Ви готові з головою пірнути в Python 3? Коли так, читайте далі. Але якщо ні, вам варто почати спочатку.

Python 3 йде в комплекті зі скриптом, що називається 2to3. Вивчіть його. Покохайте його. Використовуйте його. “Перенесення коду в Python 3 за допомогою 2to3” — це довідник усіх змін, які можуть автоматично виправити інструменти 2to3. Оскільки більшість змін є синтаксичними, їх вивчення стане гарним початком. (print відтепер функція, `x` не працює та інше)

“Приклад: перенесення chardet на Python 3” розповідає про мої (врешті-решт, успішні) спроби перенести нетривіальну бібліотеку з Python 2 на Python 3. Можливо, це вам допоможе, а можливо — ні. Навчальна крива виходить доволі крутою, оскільки вам потрібно гарно розібратися в бібліотеці, перш ніж ви зможете зрозуміти, чому вона зламалася і як я все виправив. Найбільш вразливі місця часто розташовані навколо рядків. До речі, щодо рядків...

Рядки... Отакої... Із чого ж розпочати?.. У Python 2 були “strings” та “Unicode strings”. У Python 3 є “bytes” та “strings”. Це означає, що усі рядки відтепер Unicode, і якщо ви хочете працювати із купкою байтів, використовуйте новий байтовий тип. Python 3 ніколи автоматично не здійснюватиме перетворення між strings та bytes, тому, якщо ви не певні будь-якої миті з чим саме працюєте, ваш код майже точно зламається. Для більш детальної інформації дивіться розділ "Текст".

Тема протистояння bytes і strings знову і знову виринатиме на поверхню у цій книзі.

  • У розділі "Файли" ви дізнаєтесь різницю між читанням файлу в режимі “binary” та “text”. Читання (та запис!) в текстовому режимі потребують параметру encoding. Деякі методи текстових файлів рахують символи, а інші рахують байти. Якщо ваш код спирається на те, що один символ == один байт, він зламається на багатобайтових символах.
  • У розділі "Веб сервіси http" модуль httplib2 виокремлює заголовки та дані з http. Заголовки повертаються як рядки, а тіло http повертається у вигляді байтів.
  • У розділі "Серіалізація об'єктів" ви дізнаєтесь, чому модуль pickle у Python 3 визначає новий формат даних, що є несумісним із Python 2. (Натякну: це через відмінності між bytes та strings). До того ж Python 3 підтримує серіалізацію об'єктів json, який взагалі не має bytes. Я покажу вам, як оминути це.
  • У “Приклад: перенесення chardet на Python 3” просто кривава мішанина із bytes та strings.

Навіть якщо ви не турбуєтесь про Unicode (хоча доведеться), все одно мусите прочитати про форматування рядків у Python 3, яке цілковито відрізняється від тієї ж процедури у Python 2.

Ітератори містяться скрізь у Python 3, і зараз я знаю їх набагато краще, ніж п'ять років тому, коли писав “Занурення в Python”. Вам також треба розуміти їх, адже більшість функцій, які у Python 2 повертали списки, тепер повертають ітератори. Як мінімум, ви мусите прочитати другу половину розділу "Класи та ітератори" і другу половину розділу "Детальніше про ітератори".

За численними проханнями я додав розділ "Імена магічних методів", що схожий на розділ документації "Модель даних", тільки більш гумористичний.

Коли я писав “Занурення в Python”, усі доступні XML бібліотеки були справжнім лайном. Пізніше Фредрик Лунд (Fredrik Lundh) написав ElementTree, яка вже не була лайном. Боги Python мудро включили ElementTree до складу стандартної бібліотеки, і зараз вона стала основою для мого нового розділу "XML". Старі шляхи парсингу XML лишаються доступними, але їх слід уникати, адже вони — справжнє лайно!

Також нове в Python — але не у мові, а у спільноті — виникнення репозиторіїв, схожих на Python Package Index (PyPI). Python має утиліти для пакетування вашого коду в стандартних форматах і подальшого його розповсюдження через PyPI. Докладніше про це в розділі "Пакування бібліотек".

Встановлення

[ред.]

Tempora mutantur nos et mutamur in illis. (Змінюються часи, і ми змінюємося разом з ними.)
Давньоримська приказка

Занурення

[ред.]

Перш ніж ви зможете почати програмувати на Python 3, вам треба його встановити. Чи вже маєте?


Який Python найкращий для вас?

[ред.]

Якщо ви користуєтесь віддаленим сервером, ваш хостинг-провайдер, ймовірно, вже має встановлений Python 3. Якщо у вас Linux вдома, то швидше за все ви також маєте Python 3. Більшість популярних дистрибутивів GNU/Linux типово йдуть в комплекті із Python 2, у невеликій, але постійно збільшуваній кількості дистрибутивів також включений і Python 3. Mac OS X містить консольну версію Python 2, проте не містить Python 3. Microsoft Windows не має жодної версії Python. Але не впадайте у відчай! Незалежно від вашої операційної системи, шлях до встановлення Python складатиметься з кількох клацань мишею.

Найпростіше перевірити, чи є Python 3 на вашій операційній системі Linux або Mac OS X - через консоль. Отримавши запрошення командного рядка, наберіть python3 (маленькими літерами, без пробілів), натисніть ENTER і подивіться, що відбуватиметься. На моєму домашньому Linux Python 3.1 вже є, і тому ця команда запускає інтерактивне середовище Python.

mark@atlantis:~$ python3
Python 3.0.1+ (r301:69556, Apr 15 2009, 17:25:52)
[GCC 4.3.3] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>>

(Наберіть exit та натисніть ENTER, аби вийти з інтерактивного середовища)

Мій хостинг-провайдер також має Linux і надає доступ до командного рядка, проте на моєму сервері немає встановленого Python 3. (Ганьба!)

mark@manganese:~$ python3
bash: python3: command not found

Отже, повернімося до запитання, з якого і розпочався цей розділ: який Python найкращий для вас? Будь-який, що працює на вашому комп'ютері.

Встановлення на Microsoft Windows

[ред.]

Зараз існують системи Microsoft Windows для двох архітектур: 32-бітної і 64-бітної. Звичайно, є безліч версій Windows - XP, Vista, Windows 7 - Python працюватиме на кожній з них. Набагато важливішою є різниця між 32-бітною і 64-бітною версіями. Якщо ви і гадки не маєте, яка архітектура вашого комп'ютера, ймовірно, вона 32-бітна.

Відвідайте python.org/download і завантажте відповідний встановник Python 3 для вашої архітектури Windows. Ваш вибір буде схожим на щось таке:

  • Python 3.1 Windows installer (Windows binary — does not include source)
  • Python 3.1 Windows AMD64 installer (Windows AMD64 binary — does not include source)

Я не хочу давати тут прямі посилання на файли, оскільки в Python постійно відбуваються якісь незначні оновлення, а мені б не хотілося відповідати за те, що вас омине щось важливе. Ви завжди можете встановити найсвіжішу версію Python 3.x, якщо ваша релігія вам це дозволяє.

Коли .msi файл завантажиться, клацніть на ньому двічі. На екрані з'явиться віконце-застереження Windows з попередженням, що ви запускаєте виконуваний код. Офіційний встановник Python має цифровий підпис від Python Software Foundation, некомерційної організації, що здійснює нагляд за розробкою Python. Остерігайтеся підробок!

Натисніть кнопку "Run", аби розпочати інсталяцію Python 3.

Перше питання, яке поставить вам встановник: чи бажаєте ви встановити Python 3 для всіх користувачів цього комп'ютера, чч тільки для вас. Вибір за замовчуванням "встановлювати для усіх користувачів" - найкращий варіант, якщо ви не маєте серйозних причин для іншого. (Одна з можливих причин вибору "тільки для мене" така, що ви встановлюєте Python на свій робочий комп'ютер, не маючи адміністративних повноважень. Але чому ви встановлюєте Python без дозволу адміністратора вашої компанії?.. Не втягуйте мене в неприємності!)

Клацніть "Next", щоб підтвердити обраний тип встановлення.

Далі встановник запропонує вам обрати кінцеву папку. Для усіх версій Python 3.1.x типовим є C:\Python31\, що є прийнятним для більшості користувачів, якщо немає якихось специфічних причин її змінювати. Якщо у вас окремий логічний диск для встановлення застосунків, ви можете обрати його, користуючись вбудованими інструментами, або просто вписати шлях у відповідному полі. Python можна встановлювати не лише на диск C: - у виборі логічного диска і кінцевої папки вас ніхто не обмежує.

Клацніть"Next", щоб підтвердити вибір кінцевої папки.

Наступне вікно виглядає заскладним, але це лише враження. Так само, як у інших встановниках, ви маєте можливість відмовитися від деяких окремих компонентів Python 3. Якщо вільного місця на диску катма мало, скористайтесь цим діалогом.

  • Register Extensions дозволяє запускати скрипти Python (.py) подвійним клацанням. Рекомендовано, але не обов'язково. (Це налаштування не займає місця на диску, а отже, мало сенсу його видаляти.)
  • Tcl/Tk - це графічна бібліотека, що використовується середовищем Python, до якої ви звертатиметеся під час читання цієї книги. Я наполегливо рекомендую залишити її.
  • Опція документації встановлює файли довідки, що містять переважну більшість інформації з docs.python.org. Рекомендовано, якщо у вас dialup або обмежений доступ до інтернету.
  • Utility Scripts містить скрипт 2to3.py, про який ви прочитаєте у цій книзі трохи пізніше. Необхідно, якщо ви хочете дізнатися, як переносити наявний код із Python 2 на Python 3. Якщо у вас немає коду на Python 2, можете вилучити цей компонент.
  • Test Suite - це набір скриптів для тестування самого інтерпретатора Python. Ми не використовуватимемо їх у цій книзі, та і я за весь час програмування на Python жодного разу не користувався ними. Цілком необов'язково.

Якщо ви не певні, скільки у вас вільного дискового простору, клацніть по кнопці "Disk Usage". Встановник покаже список логічних дисків, порахує, скільки на кожному з них вільного простору зараз і скільки залишиться після встановлення. Клацніть "Ok", щоб повернутися до діалогу вибору компонентів Python.

Якщо ви вирішили вилучити один з компонентів, натисніть на кнопку перед ним і у спадному меню оберіть "Entire feature will be unavailable". Для прикладу, вилучення test suite збереже вам 7908 кілобайт дискового простору.

Клацніть"Next", щоб підтвердити свій вибір опцій.

Встановник копіюватиме усі необхідні файли до кінцевої папки. (Це відбувається так швидко, що мені пощастило зробити скріншот лише з третьої спроби!)

[1] Клацніть"Finish", щоб завершити роботу встановника.

У вашому меню "Пуск" має з'явитися новий пункт із назвою Python 3.1. Всередині нього буде програма названа IDLE. Вибір цього елемента запустить інтерактивне середовище Python.

Перейти до "Використання середовища Python".


* * *


Встановлення на Mac OS X

[ред.]

Усі сучасні комп'ютери Macintosh використовують процесори Intel (так само, як і більшість Windows PC). Старіші Mac'и використовували процесори PowerPC. Вам не потрібно розуміти відмінність між ними, адже існує лише один встановник Python для Mac'а.

Відвідайте python.org/download і завантажте встановник для Mac'а. Він називатиметься якось схоже на Python 3.1 Mac Installer Disk Image, номер версії може варіюватися. Переконайтеся, що завантажуєте саме версію 3.x, а не 2.x.

Ваш браузер має автоматично підмонтувати образ диска і відкрити вікно Finder, щоб показати його вміст. (Якщо це не відбулося, вам потрібно знайти образ диска у вашій папці завантажень і двічі клацнути по ньому мишею для підмонтування. Він називатиметься якось схоже на python-3.1.dmg.) Образ диска міститиме певну кількість текстових файлів (Build.txt, License.txt, ReadMe.txt) і, власне, пакет встановника Python.mpkg.

Двічі клацніть пакет встановника Python.mpkg, щоб розпочалася інсталяція для Mac.

Перше вікно встановника покаже вам короткий опис Python і порадить звернутися до файла ReadMe.txt (який ви не читали, чи не так?) за більш детальними відомостями.

Клацніть"Continue", щоб продовжити встановлення.

Наступне вікно містить справді важливу інформацію: для встановлення Python вам необхідно мати Mac OS X 10.3 або пізніші версії. Якщо у вас ще досі Mac OS X 10.2, то її дійсно треба оновити. Apple більше не випускає оновлення безпеки для вашої операційної системи, і ваш комп'ютер наражається на небезпеку, просто під'єднуючись до інтернету. Не кажучи вже про те, що на ньому не працюватиме Python 3.

Клацніть"Continue", щоб перейти до наступного кроку.

Як й інші пристойні встановники, встановник Python демонструє ліцензійну угоду програмного забезпечення. Python - це програмне забезпечення із відкритим вихідним кодом, і його ліцензія схвалена Open Source Initiative. За час свого існування Python змінив певну кількість власників і спонсорів, кожен з яких залишив свій слід у його ліцензії. Але в кінцевому результаті Python має вільний вихідний код і ви можете використовувати його на будь-якій платформі, з будь-якою метою, безкоштовно, без жодних зобов'язань.

Клацніть"Continue" ще раз.

Через вибрики в стандартах Apple installer framework ви маєте "погодитися" із ліцензійною угодою, щоб продовжити встановлення. Оскільки Python - відкрите програмне забезпечення, "згода" радше додасть вам повноважень, аніж обмежить їх.

Клацніть"Agree", щоб продовжити.

Наступне вікно дозволить вам змінити місце інсталяції. Вам потрібно встановити Python на системний диск, але через обмеження встановника це не перевіряється. Відверто кажучи, мені ніколи не доводилося змінювати місце інсталяції.

Тут ви також можете обрати компоненти, які бажаєте встановити, і вилучити непотрібні. Для цього клацніть "Customize", а в іншому випадку - на "Install".

Якщо ви хочете обрати серед компонентів Python, встановник покаже вам наступний список:

  • Python Framework. Це основна складова Python , вона завжди вибрана і неактивна, оскільки обов'язково має бути встановлена.
  • GUI Applications - це IDLE, графічне середовище Python, яким ви будете користуватися під час читання цієї книги. Я наполегливо рекомендую залишити цей компонент.
  • UNIX command-line tools - це консольні застосунки для Python 3. Я також наполегливо рекомендую залишити цей компонент.
  • Опція документації встановлює файли довідки, що містять переважну більшість інформації з docs.python.org. Рекомендовано, якщо у вас dialup або обмежений доступ до інтернету.
  • Shell profile updater керує оновленнями вашого профілю середовища (використовується у Terminal.app)для того, щоб забезпечити перебування цієї версії Python у шляхах пошуку середовища. Швидше за все, вам не доведеться змінювати цю опцію.
  • Fix system Python краще не обирати. (Після цього ваш Mac використовуватиме python3 для виконання усіх скриптів Python включно із системними скриптами від Apple). Це дуже погано, адже більшість таких скриптів написані для Python 2 і запустити їх під Python 3 без помилок буде неможливо.

Клацніть"Install", щоб продовжити.

Через те, що системний фреймворк і бінарні файли встановлюватимемуться до /usr/local/bin/, встановник запитає у вас пароль адміністратора. Встановити Python на Mac без повноважень адміністратора неможливо.

Клацніть"Ok" для початку інсталяції.

Поки встановлюються обрані вами елементи, буде показуватися індикатор прогресу.

Якщо усе добре, встановник покаже вам великий зелений прапорець, що означає успішне завершення інсталяції.

Клацніть"Close", аби завершити роботу встановника.

Якщо припустити, що ви не змінювали місце інсталяції, щойно встановлені файли можна буде знайти в папці Python 3.1, яка всередині /Applications. Найважливіший серед них IDLE - графічне середовище Python.

Двічі клацніть IDLE, щоб запустити середовище Python.

Середовище Python - те місце, де ви перебуватимете майже весь час, вивчаючи Python. Приклади у цій книзі розраховані на те, що ви знаєте, як знайти середовище Python на своїй системі.

Перейти до "Використання середовища Python".

Встановлення на Ubuntu Linux

[ред.]

Сучасні дистрибутиви Linux спираються на величезні репозиторії попередньо скомпільованих застосунків, вже готових до встановлення. Точні інструкції варіюються для різних дистрибутивів, але в Ubuntu Linux найпростіший шлях встановити Python 3 - програма "Add/Remove", що є у вашому меню застосунків.

Коли ви вперше запустите "Add/Remove", вона покаже вам список попередньо обраних застосунків у різних категоріях. Деякі у вас вже встановлені, але більшість - ні. Оскільки репозиторій містить понад 10 000 застосунків, існують різні фільтри, за допомогою яких ви можете переглянути невелику частину вмісту репозиторію. Фільтр - "Canonical-maintained applications" - демонструє частку застосунків, офіційно підтримуваних Canonical - компанії, що колись створила і досі опікується Ubuntu Linux.

Python 3 не підтримується Canonical, тож першим кроком для вас стане вибір фільтру "All Open Source applications" у спадному меню.

Коли ви оберете фільтр, який включає усі застосунки із відкритим вихідним кодом, скористуйтеся рядком пошуку, аби віднайти у тому переліку Python 3.

Тепер список застосунків скоротився до переліку таких, що відповідають запиту "Python 3". Вам треба відмітити два пакети. Перший - Python (v3.0). У ньому міститься власне інтерпретатор Python.

Другий пакет розташований прямо над першим: IDLE (using Python-3.0). Це - графічне середовище Python, яке ви будете використовувати під час читання цієї книги.

Після того, як ви відмітите ці два пакети, клацніть Apply Changes, щоб продовжити.

Пакетний менеджер запитає, чи справді ви хочете додати ці два пакети.

Клацніть Apply, щоб продовжити.

Поки завантажуються пакети із інтернет-репозиторію Canonical, пакетний менеджер показуватиме вам індикатор прогресу завантаження.

Одразу після завантаження розпочнеться автоматичне встановлення пакетів.

Якщо все пройшло добре, пакетний менеджер підтвердить, що обидва пакети були успішно встановлені. Звідси ви зможете запустити середовище Python, двічі клацніть IDLE, і закрити пакетний менеджер, клацніть Close.

Ви завжди можете запустити середовище Python з меню застосунків, клацніть позначку IDLE у підменю Programming.

Середовище Python - місце, де ви проведете більшість часу, досліджуючи Python. Приклади у цій книзі розраховані на те, що ви знаєте, як його запустити.

Перейти до "Використання середовища Python".

Встановлення на інші платформи

[ред.]

Python 3 доступний для великої кількості різноманітних платформ. Якщо говорити конкретніше, він доступний майже для всіх дистрибутивів Linux, bsd і Solaris. Наприклад, RedHat Linux використовує пакетний менеджер yum. У FreeBSD є свої порти і колекції пакетів, у suse є zypper, а у Solaris - pkgadd. Швидкий веб-пошук зі словами "Python 3 + назва вашої операційної системи" покаже вам, чи є для неї пакет Python 3, і якщо так, то яким чином його встановити.

Використання середовища Python

[ред.]

Середовище Python - те місце, де ви зможете досліджувати синтаксис Python, отримувати інтерактивну допомогу по командах і зневаджувати короткі програми. Графічне середовище Python (IDLE) також містить і непоганий текстовий редактор, що підтримує забарвлення синтаксичних елементів Python і безпосередньо поєднано з інтерактивним середовищем. Якщо у вас ще немає улюбленого текстового редактора, спробуйте IDLE.

Перш за все, інтерактивне середовище - чудовий ігровий майданчик для випробування Python. У книзі вам часто траплятимуться приклади, схожі на щось таке:

>>> 1 + 1
2

Три праві кутові дужки, >>>, це запрошення середовища Python. Його не треба набирати. Воно лише означає, що поданий приклад слід вводити у середовищі Python.

1 + 1 - це та частина, яку ви набираєте. Ви можете набрати будь-який коректний вираз або команду Python в інтерактивному середовищі. Не соромтеся - воно не кусається! Найгірше, що може трапитися - ви отримаєте повідомлення про помилку. Команди виконуються одразу ж (як тільки ви натиснете "ENTER"), значення виразів обчислюються теж одразу, а середовище Python показує вам результат.

2 є результатом обчислення цього виразу. Як виявилося, 1 + 1 є коректним виразом на мові Python, результатом якого, ясна річ, є 2. Спробуйте ще одне:

>>> print('Hello world!')
Hello world!

Надзвичайно просто, чи не так? Але є ще безліч речей, які ви можете робити у середовищі Python. Якщо ви колись застрягнете - не зможете згадати команду або потрібні аргументи для якоїсь функції - у середовищі Python завжди можна отримати інтерактивну довідку. Просто наберіть "help" та натисніть "ENTER".

>>> help
Type help() for interactive help, or help(object) for help about object.

[2]


Існує два режими довідки. Скориставшись довідкою про поодинокі об'єкти, ви побачите документацію об'єкта, а потім - знову запрошення середовища Python. Також ви можете увійти в режим інтерактивної довідки, де не обчислюватимуться результати виразів Python. У ньому можна просто набирати ключові слова або назви команд, і на екран виведеться усе, що відомо про команду.

Аби увійти в режим інтерактивної довідки, наберіть help() і натисніть "ENTER".

>>> help()
Welcome to Python 3.0!  This is the online help utility.

If this is your first time using Python, you should definitely check out the tutorial on the Internet at http://docs.python.org/tutorial/.

Enter the name of any module, keyword, or topic to get help on writing Python programs and using Python modules.  To quit this help utility and return to the interpreter, just type "quit".

To get a list of available modules, keywords, or topics, type "modules", "keywords", or "topics".  Each module also comes with a one-line summary of what it does; to list the modules whose summaries contain a given word such as "spam", type "modules spam".

[3]

help>

Зауважте, що запрошення із >>> змінилося на help>. Це нагадує, що ви перебуваєте в режимі інтерактивної довідки. Зараз ви можете ввести будь-яке ключове слово, команду, назву модуля або функції - будь-що зрозуміле для Python - і прочитати документацію по ньому.

help> print

[4]

Help on built-in function print in module builtins:

print(...)
    print(value, ..., sep=' ', end='\n', file=sys.stdout)
    
    Prints the values to a stream, or to sys.stdout by default.
    Optional keyword arguments:
    file: a file-like object (stream); defaults to the current sys.stdout.
    sep:  string inserted between values, default a space.
    end:  string appended after the last value, default a newline.

[5]

help> PapayaWhip
no Python documentation found for 'PapayaWhip'

[6] [7]

help> quit

[8]

You are now leaving help and returning to the Python interpreter.
If you want to ask for help on a particular object directly from the
interpreter, you can type "help(object)".  Executing "help('string')"
has the same effect as typing a particular string at the help> prompt.

[9]

>>>

[10]


IDLE, графічне середовище Python, також містить пристосований для Python текстовий редактор.

Редактори коду та IDE для Python

[ред.]

Проте IDLE зовсім не унікальна, коли йдеться про написання програм мовою Python. Починаючи програмування на Python із вивчення мови, багато розробників надають перевагу іншим текстовим редакторам або інтегрованим середовищам розробки (IDE). Я не хочу тут докладно на них зупинятися, але спільнота Python має перелік редакторів із підтримкою Python, що охоплюють широке коло платформ і ліцензій.

Ви також можете переглянути список IDE із підтримкою Python, але поки що небагато з них можуть працювати із Python3. Одне з таких PyDev, плагін для Eclipse, що перетворює його на повноцінне середовище розробки на Python. І PyDev, і Eclipse є кросплатформовими та відкритими.

Серед комерційного програмного забезпечення є Komodo IDE від ActiveState. Воно потребує ліцензії для кожного користувача, але студенти матимуть знижки, і можна скористатися безкоштовною версією, що діє обмежений термін.

Я пишу свої програми на Python вже дев'ять років і роблю це в редакторі GNU Emacs, а зневаджую у середовищі Python в консолі. В розробці на Python немає хибних чи правильних шляхів. Знайдіть саме той, що працюватиме для вас!

Примітки

[ред.]
  1. Спеціальна Windows подяка Марку Геммонду (Mark Hammond), що присвятив багато років роботі у напрямку Windows, без нього Python для Windows ще й досі був би Python для DOS.
  2. Наберіть help() для інтерактивної довідки або help(object) для довідки про об'єкт.
  3. Ласкаво просимо до Python 3.0! Вас вітає утиліта онлайн-довідки. Якщо ви вперше використовуєте Python, вам неодмінно варто ознайомитися з інтернет-підручником на http://docs.python.org/tutorial/. Введіть назву будь-якого модуля, ключове слово або тему, щоб дізнатися, як писати програми мовою Python, і використовувати модулі Python. Щоб завершити роботу утиліти довідки і повернутися до інтерпретатора, просто наберіть "quit". Щоб отримати список доступних модулів, ключових слів або тем, наберіть "modules", "keywords" або "topics". Кожний модуль має стислий опис того, що він робить. Щоб отримати список модулів, чий опис містить потрібне вам слово, наприклад, "spam", наберіть "modules spam".
  4. Щоб побачити документацію по функції print(), просто наберіть print і натисніть "ENTER". Інтерактивна довідка покаже вам щось схоже на сторінку man: ім'я функції, короткий опис, аргументи функції, їхні значення за замовчуванням та інше. Якщо документація видається вам незрозумілою, не нервуйте. Ви більше дізнаєтеся про це у кількох наступних розділах. (прим. авт.)
  5. Довідка по вбудованій функції print з модуля builtins. Друкує значення до потоку або до sys.stdout за замовчуванням. Необов'язкові ключові аргументи:
    • file - файлоподібний об'єкт (потік); за замовчуванням sys.stdout.
    • sep - рядок, що додається між двома значеннями; за замовчуванням пробіл.
    • end - рядок, що додається після останього значення; за замовчуванням символ нового рядка.
  6. Немає документації Python для "мусс із папайї"
  7. Звичайно, інтерактивна довідка не знає усе. Якщо ви введете слово, що не є командою Python, назвою модуля або функції чи іншим ключовим словом, їй доведеться лише знизати своїми віртуальними плечима. (прим. авт.)
  8. Щоб вийти з режиму інтерактивної допомоги, наберіть quit та натисніть "ENTER". (прим. авт.)
  9. Ви виходите з режиму інтерактивної довідки і повертаєтесь до інтерпретатора Python. Якщо вам знадобиться довідка з якогось конкретного об'єкту безпосередньо з інтерпретатора, наберіть "help(object)". Виконання "help('string')" матиме той самий ефект, що і введення її після запрошення help>.
  10. Запрошення знову стало >>>, щоб повідомити про вихід з режиму інтерактивної допомоги та повернення до середовища Python. (прим. авт.)


Ваша перша програма

[ред.]

Не ховай свої турботи у святій тиші. Маєш проблему? Чудово. Возвеселися, занурся і пізнавай.
Високоповажний Генепола Ґунаратана


За традицією, я повинен занудити вас фундаментальними елементами програмування, аби ми могли повільно перейти до створення чогось корисного. Давайте все це пропустимо. Ось завершена програма, написана на Python. Вона, мабуть, взагалі не містить для вас жодного сенсу. Не хвилюйтесь через це, тому що ми збираємось розібрати кожен її рядок. Але спробуйте все-таки прочитати і перевірити, наскільки ви її зрозумієте.

Пірнаймо!

[ред.]
SUFFIXES = {1000: ['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
            1024: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']}

def approximate_size(size, a_kilobyte_is_1024_bytes=True):
    '''Convert a file size to human-readable form.

    Keyword arguments:
    size -- file size in bytes
    a_kilobyte_is_1024_bytes -- if True (default), use multiples of 1024
                                if False, use multiples of 1000

    Returns: string

    '''
    if size < 0:
        raise ValueError('number must be non-negative')

    multiple = 1024 if a_kilobyte_is_1024_bytes else 1000
    for suffix in SUFFIXES[multiple]:
        size /= multiple
        if size < multiple:
            return '{0:.1f} {1}'.format(size, suffix)

    raise ValueError('number too large')

if __name__ == '__main__':
    print(approximate_size(1000000000000, False))
    print(approximate_size(1000000000000))

Тепер, давайте запустимо її в командному рядку (перед цим збережіть текст в файл на зразок humansize.py). Під Windows це виглядатиме так:

c:\home\diveintopython3\examples> c:\python31\python.exe humansize.py
1.0 TB
931.3 GiB

На MacOS та Linux так:

you@localhost:~/diveintopython3/examples$ python3 humansize.py
1.0 TB
931.3 GiB

Що щойно відбулось? Ви запустили свою першу Python-програму, викликавши в командному рядку інтерпретатор і передавши йому назву скрипта, який хотіли запустити. Скрипт описує єдину функцію approximate_size(), яка отримує точний розмір файлу в байтах, і повертає "гарний", але приблизний розмір.(Ви могли бачити такий в Windows Explorer, чи Mac OS X Finder, чи Nautilus чи Dolphin чи Thunar на Linux, і в багатьох інших файлових менеджерах. Якщо каталог містить файл TODO розміром 1093 байт, файловий менеджер не виводить TODO 1093 bytes; він показує щось на зразок TODO 1 KB натомість.)

У кінці скрипта ви побачите дві команди - print(approximate_size(arguments)). Це виклики функцій. Один викликає approximate_size з аргументами і передає її результат в функцію print. Функція print - вбудована, ви ніколи не побачите її явного опису. Зате ви можете її використовувати будь-де і будь-коли. (Є ціла купа вбудованих функцій і ще більше функцій, які розділені за модулями. Терпіння...)

То чому запуск скрипта з консолі щоразу дає одні й ті ж результати? Давайте розберемось. І спершу подивимось на функцію approximate_size().


* * *


Опис функцій

[ред.]

Python, як і більшість інших мов програмування, має функції, але не має окремих заголовкових файлів як C++ чи секції interface/implementation в Pascal. Коли вам необхідна функція, ви просто оголошуєте її, ось так:

def approximate_size(size, a_kilobyte_is_1024_bytes=True):

Оголошення функції починається з ключового слова def, за яким іде ім’я функції, а після неї - параметри в дужках. Якщо параметрів кілька, вони розділяються комами.

Також варто зауважити, що для функції не задається тип, який вона повертає. Функції в Python не фіксують тип даних, який вони повертають. Вони навіть не визначають наперед чи буде взагалі повертатись якесь значення (хоча насправді кожна функція в Python повертає результат. Якщо в ній виконується оператор return - передається значення виразу після нього. Якщо ж після нього нічого не має, або оператор взагалі не використовується, повертається стандартне значення None, тобто "нічого").

В деяких мовах програмування підпрограми поділяються на функції, які повертають результат, та процедури, які просто виконують певні дії. Python не має такого поділу, тому всі функції повертають хоч щось, навіть якщо це щось - нічого (None).

Функція approximate_size() отримує два аргументи: size та a_kilobyte_is_1024_bytes, проте для жодного з них не задається тип даних. В Python змінні ніколи явно не отримують тип. Python сам з’ясовує якого типу значення містить змінна.

В Java та інших статично типізованих мовах потрібно задавати тип значення, що повертається функцією, та типи її аргументів. В Python тип явно ніколи не описується. Python стежить за дотриманням типів значень, які ви присвоюєте змінним, самостійно.

Необов’язкові та іменовані аргументи

[ред.]

Python дозволяє задавати для аргументів типові значення. Якщо значення аргументу не передається при виклику функції, то йому нададуть типового значення. Крім того, аргументи можна передавати в довільному порядку, якщо використовувати іменовані аргументи.

Для прикладу, розглянемо ще раз функцію approximate_size():

def approximate_size(size, a_kilobyte_is_1024_bytes=True):

Другий аргумент a_kilobyte_is_1024_bytes має типове значення True. Це означає, що аргумент необов’язковий: ви можете викликати функцію без нього, а функція буде поводити себе, ніби їй передали True другим аргументом.

Зверніть увагу на кінець скрипту:

if __name__ == '__main__':
    print(approximate_size(1000000000000, False))  
    print(approximate_size(1000000000000))
  • У другому рядку ми викликаємо функцію з двома аргументами. Всередині функції змінна a_kilobyte_is_1024_bytes матиме значення False, тому що ми задали його свідомо.
  • В останньому рядку approximate_size викликається з одним аргументом. Це нормально, бо другий є необов’язковим і всередині функції матиме значення True.

Також можна передавати значення в функцію за іменем аргументу:

>>> from humansize import approximate_size 
>>> approximate_size(4000, a_kilobyte_is_1024_bytes=False)
'4.0 KB'

Це викликає функцію approximate_size(), передавши 4000 в якості першого аргумента (size) та False для аргумента a_kilobyte_is_1024_bytes. (Який є другим аргументом випадково, але незабаром ви побачите, що не обов'язково дотримуватись порядку аргументів).

>>> approximate_size(size=4000, a_kilobyte_is_1024_bytes=False) 
'4.0 KB'

Це викликає функцію approximate_size(), передавши 4000 аргументу з іменем size та False для аргументу, названого a_kilobyte_is_1024_bytes.

>>> approximate_size(a_kilobyte_is_1024_bytes=False, size=4000) 
'4.0 KB'

Це викликає функцію approximate_size(), передавши False аргументу з іменем a_kilobyte_is_1024_bytes та 4000 для аргумента size. (Бачите? Я ж казав, що порядок не має значення.)

>>> approximate_size(a_kilobyte_is_1024_bytes=False, 4000) 
  File "<stdin>", line 1 
SyntaxError: non-keyword arg after keyword arg

Цей виклик не працює, бо ви передали неіменований (позиційний) аргумент після іменованого, а таке ніколи не спрацьовує. Якщо читати список аргументів зліва направо, то після того як ви натрапили на іменований аргумент, всі наступні також мусять бути іменованими.

>>> approximate_size(size=4000, False) 
  File "<stdin>", line 1 
SyntaxError: non-keyword arg after keyword arg

Цей виклик теж не працює, з тієї ж причини, що й попередній. Це було несподіванкою, правда? Зрештою, ви передали 4000 аргументу з іменем size, тоді "очевидно", що значення False призначалось аргументу a_kilobyte_is_1024_bytes. Але Python так не працює. Як тільки він зустрічає іменований аргумент, він очікує що всі інші після нього теж будуть іменованими.


* * *


Написання читабельного коду

[ред.]

Не буду знуджувати вас довгою повчальною промовою про важливість документування свого коду. Просто пам’ятайте, що код пишеться раз, а читається багато разів, і найбільш важливою аудиторією читачів є ви самі шість місяців по тому (після того, як ви вже все забули, але мусите щось виправити). Python спрощує написання читабельного коду, і цим варто скористатись. Ви подякуєте мені через шість місяців.

Рядки документації

[ред.]

Функції можна документувати, задаючи їм рядки документації (в народі докстрінги). У цій програмі функція approximate_size() має докстрінг:

def approximate_size(size, a_kilobyte_is_1024_bytes=True):
    '''Convert a file size to human-readable form.

    Keyword arguments:
    size -- file size in bytes
    a_kilobyte_is_1024_bytes -- if True (default), use multiples of 1024
                                if False, use multiples of 1000

    Returns: string

    '''
Кожна функція варта гідного докстрінгу

Потрійні лапки задають багаторядковий літерал (текстову константу). Усе між лапками, що відкривають, і лапками, які закривають, входить до літерала, включно з переходами на новий рядок, відступами на початку та іншими лапками. Такі "багаторядкові рядки" (а це саме Python-рядки, а не окремий тип!) можна зустріти всюди, та найчастіше саме в докстрінгах.

Потрійні лапки також є простим способом задати рядок, що містить як одинарні, так і подвійні рядки.

Рядок документації, якщо він є, мусить бути першим з того, що описується в функції (тобто йти одразу за оголошенням функції). Формально вам не потрібно задавати докстрінг для функції, проте це завжди бажано. Я знаю, про це казали на всіх уроках програмування, але Python дає додатковий стимул писати документацію: вона доступна під час виконання як атрибут функції.

Багато середовищ розробки використовують рядки документації для надання контекстної підказки, виводячи їх як тільки ви напишете ім’я функції чи затримаєте над ним курсор. Це може бути дуже корисним, але залежить від того, як добре ви напишете документацію.


* * *


Шляхи пошуку для import

[ред.]

Доки я не зайшов занадто далеко, розповім ще про шляхи пошуку бібліотек. Коли ви робите спробу імпортувати модуль, Python шукає його в кількох місцях. Якщо конкретно - він дивиться у всіх каталогах, перелічених у списку sys.path. Це стандартний список, і ви можете легко його переглядати та модифікувати, використовуючи звичайні методи для роботи зі списками. (Ви дізнаєтесь про це більше у розділі Стандартні типи даних).

>>> import sys

Імпортування модуля sys відкриває нам доступ до його функцій та інших атрибутів.

>>> sys.path
['',
 '/usr/lib/python31.zip',
 '/usr/lib/python3.1',
 '/usr/lib/python3.1/plat-linux2@EXTRAMACHDEPPATH@',
 '/usr/lib/python3.1/lib-dynload',
 '/usr/lib/python3.1/dist-packages',
 '/usr/local/lib/python3.1/dist-packages']

sys.path - це список шляхів до каталогів, у яких Python шукає файли для імпорту. (Він матиме різний вигляд, залежно від операційної системи, версії інтерпретатора й каталогу, до якого його встановили. Python перевірить ці каталоги (в такому ж порядку) на наявність файла з розширенням *.py, ім’я якого збігається з назвою модуля, який ви хочете імпортувати.

>>> sys
<module 'sys' (built-in)>

Чесно кажучи, я збрехав. Правда набагато складніша, тому що не всі модулі зберігаються як файли *.py. Деякі є вбудованими прямо в Python. Вбудовані модулі поводяться так само, як і звичайні, тільки їх вихідний Python-код недоступний, бо вони не написані мовою Python!

>>> sys.path.insert(0, '/home/mark/diveintopython3/examples')

Ви можете додати новий каталог до шляхів імпорту під час виконання скрипта, і після цього Python при кожній спробі імпорту буде шукати модулі і в цьому каталозі. Ефект буде зберігатись, доки працює інтерпретатор або доки хтось не видалить каталог зі списку.

>>> sys.path 
['/home/mark/diveintopython3/examples',
 '',
 '/usr/lib/python31.zip',
 '/usr/lib/python3.1',
 '/usr/lib/python3.1/plat-linux2@EXTRAMACHDEPPATH@',
 '/usr/lib/python3.1/lib-dynload',
 '/usr/lib/python3.1/dist-packages',
 '/usr/local/lib/python3.1/dist-packages']

Користуючись sys.path.insert(0, new_path), ви додали новий каталог першим до списку sys.path list, і тому Python буде спершу шукати в ньому. Це майже завжди те, що нам потрібно. У випадку конфліктів імен (наприклад, якщо Python поставляється з однією версією якоїсь бібліотеки, а вам потрібна інша), це гарантує, що Python буде використовувати ваші модулі, а не ті, що постачались з системою.

Все — об’єкт

[ред.]

Якщо ви раптом пропустили: я нещодавно сказав, що функції в Python мають атрибути і що ці атрибути доступні під час виконання програми. Функція, як і все інше в Python, є об’єктом.

Запустіть інтерактивну оболонку Python і прямуйте за мною:

>>> import humansize

Цей рядок імпортує нашу програму humansize як модуль - шматок коду, який ви можете використовувати або інтерактивно, або з іншої програми Python. Тільки-но ви імпортуєте модуль, у вас з’явиться можливість звертатись до його публічних функцій, класів та атрибутів. Саме таким способом скрипти отримують доступ до функціональності інших модулів. Ви в інтерактивній оболонці також можете цим користуватись. Це справді важлива властивість Пайтона, і ми розглянемо її детальніше впродовж цієї книжки.

>>> print(humansize.approximate_size(4096, True))
4.0 KiB

Якщо ви хочете використовувати функції, описані в імпортованих модулях, ви повинні додавати також ім’я модуля. Не достатньо написати просто approximate_size, потрібно humansize.approximate_size. Якщо ви використовували класи в Java, вам це повинно здатись знайомим.

>>> print(humansize.approximate_size.__doc__)
Convert a file size to human-readable form.

    Keyword arguments:
    size -- file size in bytes
    a_kilobyte_is_1024_bytes -- if True (default), use multiples of 1024
                                if False, use multiples of 1000

    Returns: string

Замість того, щоб викликати функцію, як це зазвичай робиться, ми просимо значення одного з її атрибутів, __doc__.

import в Python схожий на require в Perl. Тільки-но ви імпортуєте модуль module в Pyhton, можна звертатись до його функцій за допомогою ідентифікатора module.function, тільки-но ви завантажите модуль в Perl, можна буде використовувати його функції через ідентифікатор module::function.

Що таке об’єкт?

[ред.]

Усе в Python - об’єкт, і все може мати атрибути та методи. Всі функції мають вбудованй атрибут __doc__, який повертає докстрінг, описаний в коді функції. Модуль sys є об’єктом, який має (серед багатьох інших) атрибут path. І так далі.

Поки що це не відповідає на більш фундаментальне запитання: що таке об’єкт? Різні мови програмування описують "об’єкт" різними способами. У деяких це означає, що всі об’єкти повинні мати атрибути та методи, в інших - що від всіх об’єктів можна успадковувати функціонал. У Python означення вільніше. Деякі об’єкти не мають ані атрибутів, ані методів, але можуть їх мати. Не від всіх об’єктів можна наслідуватись. Але все є об’єктом в тому сенсі, що все може бути присвоєно змінній чи передано як аргумент у функцію.

Ви могли чути термін "об’єкт першого класу" в інших контекстах. У мові Python функції є об’єктами першого класу. Ви можете передати функцію як аргумент іншій функції. Модулі є об’єктами першого класу. Ви можете передати цілий модуль як аргумент функції. Класи є об’єктами першого класу, і кожен екземпляр класу також є об’єктом першого класу.

Я збираюсь повторити це ще раз, у випадку якщо ви пропустили попередні рази: все в Python є об’єктом. Рядки - об’єкти. Списки - об’єкти. Функції - об’єкти. Класи - об’єкти. Екземпляри класів - об’єкти. Навіть модулі - об’єкти.


* * *


Відступи в коді

[ред.]

Функції в Pyton не мають явних операторів begin/end або фігурних дужок для позначення початку й кінця коду функції. Єдиним розділювачем є двокрапка (:) та відступи в коді.

def approximate_size(size, a_kilobyte_is_1024_bytes=True):     # 1
    if size < 0:                                               # 2
        raise ValueError('number must be non-negative')        # 3
                                                               # 4
    multiple = 1024 if a_kilobyte_is_1024_bytes else 1000
    for suffix in SUFFIXES[multiple]:                          # 5
        size /= multiple
        if size < multiple:
            return '{0:.1f} {1}'.format(size, suffix)

    raise ValueError('number too large')
  1. Блоки коду описуються їх відступами. Під терміном "блок коду" я маю на увазі функції, умовні оператори, тіла циклів і так далі. Відступ позначає початок блоку, а прибирання відступу - його закінчення. Явні дужки чи ключові слова не потрібні. Це означає, що невидимі символи є значимими і повинні бути однорідними. У прикладі вище код функції розділено на блоки чотирма пробілами. Це не обов’язково мусять бути чотири пробіли, головне, щоб кількість їх постійно була однакова. Перший рядок без відступу позначає кінець функції.
  2. У Python після оператора if іде блок коду. Якщо умова в ньому набуває істинного значення, виконується блок з відступом, інакше виконується блок, що йде після else (якщо такий присутній). Дужки навколо умови в if необов’язкові.
  3. Цей рядок знаходиться всередині блоку if. Оператор raise запустить виключення (типу ValueError), але тільки при умові size < 0.
  4. Це ще не кінець функції. Порожні рядки не враховуються при розбитті на блоки. Вони можуть зробити функцію більш читабельною, але не відіграють роль розділювачів.
  5. Цикл for також позначає початок блоку. Блоки коду можуть містити як завгодно багато рядків, аби тільки вони мали однаковий відступ. Цей цикл for містить три рядки коду. Іншого спеціального синтаксису для багаторядкових блоків немає. Тільки робіть відступи - і живіть далі.

Після початкових протестів і кількох жалюгідних аналогій з Фортраном, ви примиритесь і побачите переваги такого синтаксису. Однією з основних переваг є те, що всі програми мовою Python виглядають подібно, тому що відступи - це вимога мови, а не рекомендація стилю. Це спрощує читання і розуміння чужого коду.

Python використовує перехід на новий рядок для розділення операторів і двокрапку з відступами для відділення блоків коду. C++ та Java використовують крапку з комою та фігурні дужки для відділення блоків коду


* * *


Виняткові ситуації

[ред.]

Виняткові ситуації в Python використовуються повсюди. Їх використовує буквально кожен модуль, і сам Python створює їх за найрізноманітніших обставин. Впродовж даної книжки ви їх чимало зустрінете.

Що таке виняткова ситуація, або виняток? Зазвичай це помилка, сигнал про те, що щось пішло не так, як планувалось. (Чесно кажучи, не всі винятки є помилками, але поки що не забивайте собі цим голови.) Деякі мови програмування заохочують повертати коди помилок, які потім потрібно перевіряти. Python заохочує створення виключень, які ви можете врахувати і обробити.

Коли помилка відбувається в командній оболонці Python, інтепретатор друкує певні подробиці про отриману виняткову ситуацію і як вона сталась. Таку ситуацію називають необробленою (unhandled). Коли створюється виключення і немає коду, який би явно зауважив його та розібрався, то воно спливає нагору, аж до рівня оболонки Python, яка випльовує деяку зневаджувальну інформацію і припиняє виконувати дану йому команду. В командній оболонці Python це не страшно, але коли таке відбудеться у вашому скрипті, програма припинить свою роботу, якщо виняткова ситуація не буде оброблена. Можливо це саме те, що вам потрібно, але можливо й ні.

На відміну від Java, функції в Python не декларують, які виняткові ситуації вони можуть створити. Це повністю ваша турбота - визначити, які ситуації вам доведеться відловлювати.

Виняткова ситуація не обов’язково мусить спричиняти зупинку програми. Вона може бути обробленою. Іноді така ситуація виникає через написання свого коду з помилками (наприклад, намагаєтесь отримати значення змінної, що не існує), але трапляються також ситуації, котрі можна передбачити. Якщо ви відкриваєте файл, його може не існувати. Якщо ви імпортуєте модуль, він може бути не встановленим. Якщо ви під’єднуєтесь до бази даних, вона може бути недоступною або ваші дані прав доступу можуть бути неправильними. Якщо ви знаєте рядок коду, що може створити виняткову ситуацію, то можете обробити останню використовуючи блок try ... except.

Python використовує блоки try...except для обробки виняткових ситуацій і оператор raise для їх генерації. Java та C++ використовують блоки try...catch для обробки та оператор throw для генерації.

Функція approximate_size() генерує виняток в двох різних випадках: якщо переданий розмір більший, ніж може бути оброблений фунцією, або якщо він від’ємний.

if size < 0:
    raise ValueError('number must be non-negative')

Синтаксис створення винятку досить простий. Використайте команду raise, за якою напишіть назву виключної ситуації і необов’язковий рядок з поясненням того, що сталось. Цей синтаксис нагадує виклик функції. (Насправді, механізм виключних ситуацій реалізовано як набір класів, і цей оператор raise є фактично створенням екземпляра класу ValueError і передачі рядка 'number must be non-negative' його конструктору. Але це ми забігаємо наперед!)

Вам не потрібно обробляти винятки функції, що їх створила. Якщо функція їх не обробляє, винятки передаються функції, яка її викликала, потім функції, що викликала ту функцію, і так далі вгору по стеку. Якщо виняток ніде не обробляється, то програма аварійно завершиться, а Python надрукує "слід" до стандартного потоку виводу помилок і на цьому кінець. Знову ж таки, можливо - це саме те, що вам потрібно. Все залежить від того, що робить ваша програма.

Обробка помилок імпорту

[ред.]

Один з вбудованих в Python типів виключень - ImportError, яке генерується, коли ви намагаєтесь імпортувати модуль, і ця спроба виявляється невдалою. Це може трапитись через низку причин, але найпоширеніша - модуль не знайдений в шляхах для пошуку модуля. Можна використати цей виняток для додавання необов’язкових функцій до своєї програми. Наприклад, бібліотека chardet дозволяє автоматично визначати кодування символів. Можливо, ваша програма захоче використовувати цю бібліотеку якщо вона існує, але виховано не перериватиме роботи, навіть якщо користувач її не встановив. Це можна зробити за допомогою блока try .. except.

try:
    import chardet
except ImportError:
    chardet = None

Пізніше можна перевірити наявність модуля chardet простим умовним оператором:

if chardet:
    # do something
else:
    # continue anyway

Іншим типовим використанням виняткових ситуацій ImportError є випадок, коли дві бібліотеки реалізують однакове API, але використання однієї з них більш бажане (наприклад, вона швидша, або використовує менше пам’яті). Ви можете спробувати імпортувати перший модуль, і якщо не вийде, завантажити інший. Наприклад, розділ про XML розповідає про два модулі, що реалізують спільне API, наване ElementTree. Перший lxml - сторонній модуль, який потрібно скачувати і встановлювати самостійно. Інший xml.etree.ElementTree - повільніший, але є частиною стандартної бібліотеки Python 3.

try:
    from lxml import etree
except ImportError:
    import xml.etree.ElementTree as etree

Після виконання цього блоку try..except ви імпортували якийсь модуль і назвали його etree. Оскільки обидва модулі реалізують однакове API, решта вашого коду не повинна перевіряти, який модуль був імпортованим. І оскільки модуль, що імпортується, завжди називається etree, решта коду не буде перемежовуватись умовними операторами для виклику по-різному названих модулів.


* * *


Незв’язані змінні

[ред.]

Погляньмо ще раз на рядок коду з функції approximate_size():

multiple = 1024 if a_kilobyte_is_1024_bytes else 1000

Ви ніде не оголошували змінну multiple, ви просто присвоюєте їй значення. Це нормально, бо Python дозволяє вам так роботи. Що Python вам ніколи не дозволить - це звертатись до змінної, якій ще не було присвоєно значення. Така спроба спровокує виняткову ситуацію NameError.

>>> x
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'x' is not defined
>>> x = 1
>>> x
1

Якось ви подякуєте Python-у за це.


* * *


Усе чутливе до регістру

[ред.]

Усі імена в Python чутливі до регістру: імена змінних, імена функцій, імена класів, імена модулів, імена виняткових ситуацій. Якщо ви можете отримати певне значення, встановити його, викликати або імпортувати - отже, воно чутливе до регістру.

>>> an_integer = 1
>>> an_integer
1
>>> AN_INTEGER
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'AN_INTEGER' is not defined
>>> An_Integer
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'An_Integer' is not defined
>>> an_inteGer
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'an_inteGer' is not defined

І так далі...


* * *


Запуск скриптів

[ред.]
Усе в Python - об’єкти

Модулі в Python є об’єктами і мають кілька корисних атрибутів. Один з таких корисних атрибутів можна використати для тестування модуля. За його допомогою до модуля можна додати спеціальний блок, що буде виконуватись лише при запуску інтерпретатора для самого модуля, а не для скрипта, що його імпортує. Подивіться на кілька останніх рядків humansize.py:

if __name__ == '__main__':
    print(approximate_size(1000000000000, False))
    print(approximate_size(1000000000000))

Як і C, Python використовує == для порівняння та = для присвоєння. На відміну від C, Python не дозволяє присвоєння в виразах, тому не має можливості випадково надати змінній значення при порівнянні.

То що ж робить цей if особливим? Ну, модулі - це об’єкти, і всі модулі мають вбудований атрибут __name__. Він залежить від того, як ви використовуєте модуль. Якщо ви імпортуєте його, тоді __name__ - це назва файлу модуля без розширення файлу та шляху до нього.

>>> import humansize
>>> humansize.__name__
'humansize'

Але ви також можете запускати модуль напряму як окрему програму, і в такому випадку __name__ прийме спеціальне стандартне значення: "__main__". Python обчислить умову в if, вона виявиться істинною, і вкладений блок виконається. У нашому випадку він надрукує два значення.

c:\home\diveintopython3> c:\python31\python.exe humansize.py
1.0 TB
931.3 GiB

І це й є наша перша програма!


* * *


Для подальшого читання

[ред.]

Стандартні типи даних

[ред.]

В основі будь-якої філософії лежить здивування, її розвитком є дослідження, її кінцем – незнання.
Мішель де Монтень


Типи даних. Давайте на хвилинку відкладемо в бік вашу першу програму, і поговоримо про типи даних. В Python кожне значення має тип, але ви не повинні оголошувати тип змінної. Як це працює? Покладаючись на початково присвоєне змінній значення, Python визначає якому типу воно належить і запам’ятовує його самостійно.

Python має багато вбудованих типів даних. Ось деякі найважливіші:

  1. Логічні (Булеві) змінні приймають значеня True або False.
  2. Числа можуть бути цілими (1 і 2), з десятковими дробами (1.1 і 1.2), звичайними дробами (1/2 and 2/3), чи навіть комплексними.
  3. Рядки є послідовностями символів Юнікоду (наприклад HTML документ)
  4. Байти та масиви байтів (наприклад зображення в форматі JPEG)
  5. Списки є впорядкованими послідовностями значень.
  6. Кортежі є впорядкованими незмінними послідовностями значень.
  7. Множини є невпорядкованими наборами значень.
  8. Словники є невпорядкованими наборами пар ключ-значення.

Звичайно існує ще багато типів крім вищеперелічених. В Python все - об’єкт, тому є такі типи як модуль, функція, клас, метод, файл і навіть відкомпільований код. Ви вже зустрічались з деякими з них: модулі мали імена, функції мали докстрінґи. Ви дізнаєтесь про класи в розділі Класи та ітератори, і про файли в розділі Файли.

Рядки і байти достатньо важливі і достатньо складні щоб присвятити їм окремий розділ. Давайте спершу розглянемо інші.


* * *


Булевий тип

[ред.]
У булевому контексті можна використовувати фактично будь-які вирази

Булеві змінні приймають лише істинне чи хибне значення. Python має для цих значень дві відповідні константи: True та False, які можна використовувати для присвоєння значень змінним. Окрім констант, булеві значення можуть приймати вирази. А в деяких місцях (наприклад, в операторі if) Python навіть очікує того, що значення виразу можна буде привести до булевого типу. Такі місця називаються булевими контекстами. Ви можете використати майже будь-який вираз в булевому контексті, і Python намагатиметься визначити його істинність. Різні типи даних мають різні правила щодо того, які значення є істинними, а які - хибними в булевому контексті. (Сенс останніх речень стане зрозумілішим, коли ви побачите деякі приклади далі в цьому розділі.)

Візьмімо для прикладу цей шматочок коду з humansize.py:

if size < 0:
    raise ValueError('number must be non-negative')

size - ціле число, 0 теж ціле, а < - оператор над числами. Результат виразу size < 0 завжди булевий. Ви можете переконатись в цьому самостійно в інтерактивній оболонці Python:

>>> size = 1
>>> size < 0
False
>>> size = 0
>>> size < 0
False
>>> size = -1
>>> size < 0
True

Від Python 2 була успадкована цікава особливість: за потреби вважати булеві значення числами. True це 1, а False - 0:

>>> True + True
2
>>> True - False
1
>>> True * False
0
>>> True / False
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ZeroDivisionError: int division or modulo by zero

Ей, ей, ей! Ніколи не робіть так. Забудьте навіть, що я вам про таке розповідав. Серйозно.

Числа

[ред.]

Числа класні. Їх так багато, що кожен зможе вибрати собі до вподоби скільки схоче. Python підтримує як цілі, так і дробові числа. Вказувати тип числа явно не потрібно, Python сам визначить його за наявністю чи відсутністю десяткової крапки.

>>> type(1) 
<class 'int'>

Функцію type() можна використовувати, щоб визначати тип значення змінної. Як можна було очікувати, 1 - ціле (int).

>>> isinstance(1, int)
True

Для перевірки приналежності типу можна використовувати функцію isinstance().

>>> 1 + 1
2

Додавання двох цілих дає нам цілий результат.

>>> 1 + 1.0
2.0
>>> type(2.0)
<class 'float'>

Результатом додавання цілого та дробового числа є дробове. Для виконання додавання Python приводить ціле число до типу float і повертає результат цього ж типу.

Перетворення цілих в дробові та навпаки

[ред.]

Щойно ви бачили, що деякі оператори (такі, як додавання) за потреби перетворюють цілі числа в дробові. Ви можете зробити це саме самостійно.

>>> float(2)
2.0

Можна явно перетворювати int у float, використовуючи функцію float()

>>> int(2.0) 
2

Нічого дивного і в тому, що ви можете зробити навпаки за допомогою функції int

>>> int(2.5) 
2

Функція int просто відкидає дробову частину, а не округлює.

>>> int(-2.5) 
-2

Функція int для від’ємних повертає найменше ціле число, більше або рівне даному (тобто теж просто відкидає дробову частину). Тому не плутайте її з функцією math.floor.

>>> 1.12345678901234567890
1.1234567890123457

Десяткові дроби мають точність до 15 знаків після коми.

>>> type(1000000000000000)
<class 'int'>

Цілі можуть бути як завгодно великими.

Python 2 має окремі типи int і long для цілих і довгої арифметики. Тип int був обмежений значенням sys.maxint, яке залежало від платформи (зазвичай ). Python 3 має лише один цілий тип, який загалом поводиться так, як тип long в Python 2. Див. PEP 237 для деталей.

Основні числові операції

[ред.]

З числами можна робити все:

>>> 11 / 2      
5.5

Оператор / виконує ділення чисел з плаваючою крапкою. Він повертає float, навіть якщо чисельник та знаменник цілі.

>>> 11 // 2     
5

Оператор // виконує хитрий вид цілочисельного ділення. Коли результат додатній, ви можете вважати його відкиданням (не округленням) дробової частини звичайного ділення, але будьте обережними з цим.

>>> 11 // 2    
6

При цілочисельному діленні від’ємних чисел оператор // округлює "вгору" до найближчого цілого. Хоча, говорячи формально, він округлує вниз, тому що -6 менше за -5.

>>> 11.0 // 2   
5.0

Оператор // не завжди повертає цілі. Якщо чисельник чи знаменник дробові, // повертає float, яке, щоправда, все одно округлене до найближчого цілого.

>>> 11 ** 2     
121

Оператор ** означає піднесення до степеня.

>>> 11 % 2      
1

Оператор % повертає остачу від цілочисельного ділення.

У Python 2 оператор / зазвичай означав цілочисельне ділення (якщо застосовувався до цілих), але ви могли просто змусити його поводитись як дробове ділення, включаючи спеціальну директиву у свій код:

>>> from __future__ import division
>>> 1/2

У Python 3 оператор / завжди означає ділення чисел з плаваючою комою. Див. PEP 238 для деталей.


Звичайні дроби

[ред.]

Python не обмежується тільки цілими числами та десятковими дробами. Він також знає арифметику зі звичайними дробами, яку ви вивчили і одразу забули ще в школі.

>>> import fractions

Щоб почати використовувати звичайні дроби, потрібно імпортувати модуль fractions.

>>> x = fractions.Fraction(1, 3) 
>>> x
Fraction(1, 3)

Щоб створити дріб, використовують об’єкт типу Fraсtion і передають його конструктору чисельник та знаменник.

>>> x * 2
Fraction(2, 3)

З дробами можна виконувати звичайні математичні дії, результатом яких буде новий об’єкт Fraсtion.

>>> fractions.Fraction(6, 4) 
Fraction(3, 2)

Об’єкт Fraсtion автоматично скорочує дроби.

>>> fractions.Fraction(0, 0)
Traceback (most recent call last): 
  File "<stdin>", line 1, in <module> 
  File "fractions.py", line 96, in __new__ 
    raise ZeroDivisionError('Fraction(%s, 0)' % numerator)
ZeroDivisionError: Fraction(0, 0)

Python не дозволить створити дріб з нульовим знаменником.

Тригонометрія

[ред.]

В Python також доступні тригонометричні функції.

>>> import math 
>>> math.pi 
3.1415926535897931

Модуль math має константу для - відношення довжини кола до його діаметру.

>>> math.sin(math.pi / 2)
1.0

math містить всі базові тригонометричні функції: sin(), cos(), tan() і навіть такі, як asin().

>>> math.tan(math.pi / 4)
0.99999999999999989

Щоправда, варто зауважити, що Python не дає абсолютної точності. tan(π / 4) мусить повертати 1.0, а не 0.99999999999999989.

Числа в булевому контексті

[ред.]
Нульові значення вважаються хибними, ненульові - істинними

Ви можете використовувати числа в булевому контексті, такому, як умова в операторі if. Нульові значення вважаються хибними, ненульові - істинними.

>>> def is_it_true(anything): 
...   if anything: 
...     print("yes, it's true")
...   else: 
...     print("no, it's false")
...

Ви знали, що можна описувати свої функції прямо в інтерактивній сесії Python? Просто натискайте Enter в кінці кожного рядка і додайте один порожній рядок в кінці функції.

>>> is_it_true(1)
 yes, it's true 
>>> is_it_true(-1)
 yes, it's true 
>>> is_it_true(0)
 no, it's false

У булевому контексті всі цілі, окрім нуля, дорівнюють True, 0 - False.

>>> is_it_true(0.1)
 yes, it's true 
>>> is_it_true(0.0)
 no, it's false

Ненульові десяткові дроби - True, 0.0 - False. Будьте обережними з цим! Найменша похибка округлення (цілком ймовірна річ, як ви могли побачити в попередньому розділі) - і Python буде перевіряти 0.0000000000001 замість 0.0 та отримає значення True.

>>> import fractions 
>>> is_it_true(fractions.Fraction(1, 2))
 yes, it's true 
>>> is_it_true(fractions.Fraction(0, 1))
 no, it's false

Звичайні дроби теж можна використати в булевому контексті. Fraction(0, n) - хибне для будь-якого n. Усі інші дроби істинні.


* * *


Списки

[ред.]

Списки - це робоча кобилка мови Python. Коли я кажу "список", ви можете подумати "масив, розмір якого я повинен задати наперед і який мусить містити елементи одного типу і т.д.". Не думайте так. Списки набагато крутіші за масиви.

Список у Python - це як масив у Perl 5. У Perl 5 змінні, які містять масиви, завжди починаються з символу @. У Python змінні можна називати як завгодно, а Python сам визначить тип.

Список у Python - це набагато більше, ніж масив в Java (хоча й може використовуватись як такий, якщо це все, що вам потрібно від життя). Кращим аналогом міг би бути клас ArrayList, який може містити довільні об’єкти і динамічно розширюватись при додаванні нових елементів.

Створення списку

[ред.]

Створити список дуже просто - помістіть список значень, розділених комою, в квадратні дужки:

>>> a_list = ['a', 'b', 'mpilgrim', 'z', 'example'] 
>>> a_list
['a', 'b', 'mpilgrim', 'z', 'example']

Створюємо список з п’яти елементів. Їхній порядок зберігається, що не є випадковим, бо список - впорядкований набір елементів.

>>> a_list[0]
'a'

Нумерація елементів списку починається з нуля. Першим елементом непорожнього списку a_list є a_list[0].

>>> a_list[4]
'example'

Останнім елементом списку a_list з п’яти елементів є a_list[4]. Тому що нумерація починається з нуля.

>>> a_list[-1]
'example'

Від’ємні індекси в масиві задають відповідні по порядку елементи, якщо рахувати справа наліво. Останнім елементом непорожнього списку a_list завжди є a_list[-1].

>>> a_list[-3]
'mpilgrim'

Якщо від’ємні індекси для вас трохи заплутані, думайте про них так: a_list[-n] == a_list[len(a_list) - n]. Тому в нашому випадку: a_list[-3] == a_list[5 - 3] == a_list[2].

Зрізання списків

[ред.]
a_list[0] - перший елемент a_list.

Щойно створивши список, ви можете отримати будь-яку його частину як новий список. Ця частина називатиметься зрізом.

>>> a_list
['a', 'b', 'mpilgrim', 'z', 'example'] 
>>> a_list[1:3]
['b', 'mpilgrim']

Ви можете отримати зріз списку, задавши два індекси. Отримане значення - новий список, який містить всі елементи списку по порядку, починаючи від першого індексу (тут a_list[1]) і аж до другого (тут a_list[3]), але не включаючи його.

>>> a_list[1:-1]
['b', 'mpilgrim', 'z']

Зріз утворюється і тоді, коли один чи обидва з індексів від’ємні. Якщо це допоможе, можете думати так: ми читаємо список зліва направо. Першим індексом задається перший елемент з тих, які нам потрібні. Елемент, що задається другим індексом, - перший з тих, які нам не потрібні. Повертаються лише ті значення, які потрібні.


>>> a_list[0:3]
['a', 'b', 'mpilgrim']

Нумерація елементів в списку починається з нуля, тому a_list[0:3] поверне перші три елементи списку, починаючи з a_list[0] аж до a_list[3] невключно.

>>> a_list[:3]
['a', 'b', 'mpilgrim']

Якщо лівий індекс зрізу дорівнює нулю, його можна не вказувати. Тому a_list[:3] - це те саме, що й a_list[0:3].

>>> a_list[3:]
['z', 'example']

Аналогічно, якщо правий індекс - довжина списку, його теж можна не вказувати. Тому a_list[3:] - такий самий, як a_list[3:5], бо цей список має п’ять елементів. Тут є приємна симетрія. В даному п’ятиелементному списку a_list[:3] повертає перші три елементи, а a_list[3:] - останні два. Взагалі, a_list[:n] поверне перших n елементів, а a_list[n:] - решту, незалежно від довжини списку. (Якщо n - більше чи рівне довжини списку, всі елементи попадуть в перший зріз, а решта відповідно буде порожньою.)

>>> a_list[:]
['a', 'b', 'mpilgrim', 'z', 'example']

Якщо пропустити обидва індекси, всі елементи списку будуть включені до зрізу. Але це те саме, що й оригінальна змінна a_list. Це новий список, який містить ті самі елементи, що й оригінал. a_list[:] - скорочення для створення копії списку.

Додавання елементів до списку

[ред.]

Існує чотири способи додавання елементів в список.

>>> a_list = ['a'] 
>>> a_list = a_list + [2.0, 3]

Оператор + конкатенує два списки, щоб утворити новий. Список може містити довільну кількість елементів (обмежень на розмір списку немає, окрім, звісно, об’єму доступної пам’яті). Проте ви мусите пам’ятати, що конкатенація списків створює додатковий список в пам’яті. В даному випадку він негайно присвоюється існуючій змінній a_list. Тому цей рядок коду насправді працює в два етапи: створення нового списку, і потім присвоєння. Якщо список довгий, то в проміжку між етапами програма може потребувати досить великі об’єми пам’яті.

>>> a_list 
['a', 2.0, 3]

Список може містити елементи будь-яких типів, і елементи в списку не мусять бути одного типу. Тут ми маємо список, що складається з рядка, числа з плаваючою крапкою і цілого числа.

>>> a_list.append(True) 
>>> a_list 
['a', 2.0, 3, True]

Метод append() додає один елемент до кінця списку. (Тепер в списку вже чотири різні типи даних!)

>>> a_list.extend(['four', 'Ω']) 
>>> a_list 
['a', 2.0, 3, True, 'four', 'Ω']

Списки реалізовані як класи. Створення списку - це насправді ініціалізація екземпляру класу. І, так само як й інші класи, списки мають методи, щоб оперувати на них. Метод extend() приймає один аргумент - список, і додає всі елементи цього списку до початкового.

>>> a_list.insert(0, 'Ω') 
>>> a_list 
['Ω', 'a', 2.0, 3, True, 'four', 'Ω']

Метод insert() вставляє один елемент в список. Його перший аргумент - індекс першого аргументу в списку, який буде зсунутий зі своєї позиції. Елементи списку не мусять бути унікальними; наприклад, зараз список містить два різні елементи зі значенням 'Ω': перший елемент (a_list[0]) і останній елемент (a_list[6]).

Метод a_list.insert(0, value) схожий на функцію unshift() в Perl. Він додає елемент на початок списку, і всі інші елементи змінюють свій індекс, щоб звільнити місце.

Давайте детальніше розглянемо різницю між append() та extend().

>>> a_list = ['a', 'b', 'c'] 
>>> a_list.extend(['d', 'e', 'f']) 
>>> a_list 
['a', 'b', 'c', 'd', 'e', 'f']

Метод extend() отримує один аргумент, який завжди мусить бути списком, і додає кожен з елементів аргументу в a_list.

>>> len(a_list) 
6 
>>> a_list[-1]
'f'

Якщо для списку з трьох елементів викликати метод extend(), якому теж передати список з трьох елементів, то в результаті отримаємо шестиелементний список.

>>> a_list.append(['g', 'h', 'i']) 
>>> a_list
['a', 'b', 'c', 'd', 'e', 'f', ['g', 'h', 'i']]

З іншого боку, append() теж отримує єдиний аргумент, який щоправда може бути будь-якого типу. Тут ми викликаємо append() теж для списку з трьох елементів.

>>> len(a_list) 
7 
>>> a_list[-1] 
['g', 'h', 'i']

Якщо для списку з шести елементів викликати метод append() і передати йому список, ми отримаємо список з семи елементів. Чому семи? Тому що останній елемент, незважаючи на те, що є списком, все одно буде одним елементом. Списки можуть містити будь-які дані, в тому числі й інші списки.

Пошук значень в списку

[ред.]
>>> a_list = ['a', 'b', 'new', 'mpilgrim', 'new'] 
>>> a_list.count('new') 
2

Як і можна було очікувати, метод count() (підрахуй) повертає кількість входжень певного елемента в список.

>>> 'new' in a_list 
True 
>>> 'c' in a_list 
False

А якщо вам лише потрібно знати чи є якийсь елемент в списку, чи його немає, оператор in буде дещо швидшим. Оператор in завжди повертає True або False, він не рахує скільки разів елемент входить в список.

>>> a_list.index('mpilgrim') 
3

Ні оператор in, ні метод count() не говорять вам, де саме в списку перебуває елемент. Якщо вам потрібна така інформація - зверніться до методу index(). За замовчуваням він шукає по всьому списку, хоча ви можете передати необов’язковий другий елемент - індекс, з якого починати пошук, і навіть необов’язковий третій аргумент - індекс, на якому закінчувати пошук.

>>> a_list.index('new') 
2

Метод index() знаходить місце першого входження елемента в список. У даному випадку, 'new' двічі зустрічається в списку, в елементах a_list[2] та a_list[4], але index() повертає лише позицію першого входження.

>>> a_list.index('c') 
Traceback (innermost last):
 File "<interactive input>", line 1, in ? 
ValueError: list.index(x): x not in list

Ви могли не очікувати такого, але якщо значення не знаходиться в списку, метод index() згенерує виняток.

Стривайте, що? Все правильно: якщо index() не знаходить значення в списку, він генерує виняток. Це значно відрізняється від поведінки аналогічних методів ув інших мовах, які б повертали якийсь недійсний індекс, наприклад -1. І хоча це спершу здається незручним, думаю, з часом ви це оціните. Це означає, що у вашій програмі відбудеться збій прямо в місці виникнення проблеми, а не триватиме робота з неправильними даними, що в майбутньому призведе до дивних незрозумілих помилок. Пам’ятаєте --1 - це допустимий індекс. Якщо метод index() поверне -1, це призведе до не надто веселих сеансів зневадження.


Видалення елементів зі списку

[ред.]
Списки ніколи не містять порожнин

Списки можуть розширюватись та скорочуватись автоматично. Ви вже бачити частину з доповненням списку. Так сам є кілька різних способів вилучення елементів зі списку.

>>> a_list = ['a', 'b', 'new', 'mpilgrim', 'new'] 
>>> a_list[1] 
'b' 
>>> del a_list[1] 
>>> a_list ['a', 'new', 'mpilgrim', 'new']

Для того, щоб видалити конкретний елемент зі списку, можна використати інструкцію del.

>>> a_list[1]
'new'

Спроба отримати елемент з індексом 1 після видалення елемента з таким індексом не спричинює помилку. Всі елементи, які перебували в списку після видаленого, зсувають свій індекс, щоб "заповнити порожнину", яка виникла в результаті видалення елемента.

Не знаєте індекс елемента? Не проблема, їх можна видаляти знаючи значення.

>>> a_list.remove('new') 
>>> a_list
['a', 'mpilgrim', 'new']

Елементи можна також видаляти зі списку за допомогою методу remove(). Метод remove() отримує значення і видаляє перше входження елемента з таким значенням зі списку. Знову ж таки, всі елементи після видаленого зсунуться, щоб заповнити порожнину. Списки ніколи не містять порожнин.

>>> a_list.remove('new') 
>>> a_list
['a', 'mpilgrim'] 
>>> a_list.remove('new')
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
ValueError: list.remove(x): x not in list

Метод remove() можна викликати так часто, як вам захочеться, проте він буде генерувати винятки при спробах видалити елемент, відсутній у списку.

Видалення елементів зі списку: бонусний раунд

[ред.]

Іншим цікавим методом списку є pop(). Метод pop() - це просто ще один спосіб видалити елемент зі списку, але з викрутасом.

>>> a_list = ['a', 'b', 'new', 'mpilgrim'] 
>>> a_list.pop()
'mpilgrim' 
>>> a_list
['a', 'b', 'new']

При виклику без аргументів pop() видаляє останній елемент зі списку і повертає його значення.

>>> a_list.pop(1)
'b' 
>>> a_list
['a', 'new'] 
>>> a_list.pop()
'new' 
>>> a_list.pop()
'a'

Ви можете витягувати зі списку довільні елементи. Просто передавайте в pop() їхній індекс, і він самостійно видалить їх, зсуне індекси всіх інших і поверне нам значення.

>>> a_list.pop()
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
IndexError: pop from empty list

Виклик pop() для порожнього списку генерує виняток.

Виклик методу pop() без аргументів подібний функції pop() в Perl. Він видаляє останній елемент зі списку і повертає значення видаленого елементу. Perl містить іншу функцію shift(), яка видаляє перший елемент і повертає його значення. В Python це еківалентно виклику pop(0).

Списки в булевому контексті

[ред.]
Порожні списки хибні в булевому контексті; всі інші списки істинні.

Списки також можна використовувати в булевих контекстах таких, як, наприклад умова в if.

>>> def is_it_true(anything):
...   if anything:
...     print("yes, it's true")
...   else:
...     print("no, it's false")
... 
>>> is_it_true([])
no, it's false

У булевому контексті будь-який порожній список хибний.

>>> is_it_true(['a'])
yes, it's true

Будь-який список, який містить хоча б один елемент, істинний у булевому контексті.

>>> is_it_true([False])
yes, it's true

Незалежно від значення елементів, які він містить, непорожній список - істинний.


* * *


Кортежі

[ред.]

Кортеж - це незмінюваний список. Він завжди залишається таким, яким його створили.

>>> a_tuple = ("a", "b", "mpilgrim", "z", "example") 
>>> a_tuple
('a', 'b', 'mpilgrim', 'z', 'example')

Кортеж записується майже так само, як список, але замість квадратних дужок використовуються круглі.

>>> a_tuple[0]
'a'

Елементи кортежу мають визначений порядок, так само, як і в списку. Так само індексація починається з нуля.

>>> a_tuple[-1]
'example'

Від’ємні індекси відраховуються від кінця кортежу, так само, як і в списках.

>>> a_tuple[1:3]
('b', 'mpilgrim')

Зрізи теж працюють. Коли ви робите зріз списку - отримуєте новий список, робите зріз кортежу - отримуєте новий кортеж.

Основна відмінність між списками та кортежами: кортежі не можна змінювати. Технічно - вони незмінні. Практично - вони не мають методів, які б дозволили вам змінити їх. Списки містять такі методи: append(), extend(), insert(), remove() і pop(). Кортежі не містять таких методів. Ми можемо зробити зріз кортежу (тому що це створює новий кортеж) і перевірити, чи кортеж містить певне значення (тому що це не змінює кортеж), і ... на цьому все.

>>> a_tuple
('a', 'b', 'mpilgrim', 'z', 'example')
>>> a_tuple.append("new")               
Traceback (innermost last):
  File "<interactive input>", line 1, in ?
AttributeError: 'tuple' object has no attribute 'append'

Доповнювати: не можна.

>>> a_tuple.remove("z")                 
Traceback (innermost last):
  File "<interactive input>", line 1, in ?
AttributeError: 'tuple' object has no attribute 'remove'

Вилучати: не можна.

>>> a_tuple.index("example")            
4

Можна шукати елементи, бо це не змінює кортеж.

>>> "z" in a_tuple                      
True

Ну і можна перевіряти, чи елемент міститься в кортежі.

Тоді навіщо вони потрібні?

  • Кортежі працюють швидше, ніж списки. Якщо вам потрібен незмінний набір значень і все що ви будете робити - ітерувати по ньому, то використовуйте кортеж замість списку.
  • Ваш код стане безпечнішим, якщо ви захистите від запису дані, які не мусять змінюватись.
  • Деякі кортежі можуть використовуватись як ключі в словнику (кажучи конкретно - кортежі, які містять лише незмінні елементи, тобто числа, рядки чи інші кортежі). Списки ніколи не можна використовувати як ключі словника, тому що списки можуть змінюватись.

Кортежі можна перетворювати в списки, і навпаки. Вбудована функція tuple() отримує список і повертає кортеж з такими ж елементами, а функція list() отримує кортеж і повертає список. tuple() ніби "заморожує" список, а list() "розморожує" кортеж.

Кортежі в булевому контексті

[ред.]
>>> def is_it_true(anything):
...   if anything:
...     print("yes, it's true")
...   else:
...     print("no, it's false")
... 
>>> is_it_true(())
no, it's false

У булевому контексті порожній кортеж еквівалентний False.

>>> is_it_true(('a', 'b'))
yes, it's true

Будь-який кортеж, що містить хоча б один елемент, - істинний.

>>> is_it_true((False,))
yes, it's true

Будь-який непорожній кортеж - істинний, незалежно від значень його елементів. Але ви помітили кому?

>>> type((False))
<class 'bool'> 
>>> type((False,))
<class 'tuple'>

Щоб створити кортеж з одного елемента, ви мусите написати після нього кому. Якщо коми немає, Python думає, що ви просто взяли вираз в дужки, що не матиме жодного ефекту.

Присвоєння кількох значень за раз

[ред.]

Кортежі дозволяють робити класний трюк: присвоювати по кілька значень за раз:

>>> v = ('a', 2, True) 
>>> (x, y, z) = v 
>>> x
'a' 
>>> y
2 
>>> z
True

v - кортеж з трьох елементів, а (x, y, z) - кортеж з трьох змінних. Присвоєння одного іншому присвоює кожній змінній кортежу значення з v.

Це можна використовувати дуже різноманітно. Припустимо, ви хочете присвоїти імена наборові змінних. Ви можете використати вбудовану функцію range() разом з присвоєнням і кортежем змінних, щоб швидко роздати їм відповідні значення.

>>> (MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY) = range(7)

Вбудована функція range() створює послідовність цілих чисел. (Правду кажучи, вона повертає ітератор, а не список чи кортеж, але ми розглянемо це детальніше потім.) MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY - змінні, які ми оголошуємо. (Цей приклад взятий з модуля calendar, маленького цікавого модуля, який друкує календарі, аналогічно програмі cal в Unix. Цей модуль містить константи для всіх днів тижня.)

>>> MONDAY
0 
>>> TUESDAY
1 
>>> SUNDAY
6

Тепер кожна змінна має своє значення: MONDAY = 0, TUESDAY = 1 і так далі.

Також можна використовувати присвоєння багатьох значень для створення функцій, які повертають кілька значень, повертаючи кортеж з ними. Той, хто викликав функцію, може зберігати результат як єдиний кортеж, а може присвоїти його значення окремим змінним. Багато стандартних бібліотек мови Python містять такі функції. Однією з них є модуль os, про який ви дізнаєтесь в наступному розділі.


* * *


Множини

[ред.]

Множини - це "сумки" з невпорядкованими унікальними значеннями. Множина може містити значення будь-якого незмінюваного типу. Якщо маєте дві множини, то можете здійснювати над ними звичні операції над множинами: об’єднання, перетин і різницю.

Створення множини

[ред.]

Почнемо за порядком. Створити множину просто.

>>> a_set = {1} 
>>> a_set
{1}

Щоб створити множину з одного елемента, помістіть цей елемент у фігурні дужки.

>>> type(a_set)
<class 'set'>

Множини, як і всі інші типи в Python, реалізовані як класи. Але про це пізніше.

>>> a_set = {1, 2} 
>>> a_set
{1, 2}

Щоб створити множину з кількох елементів, перелічіть їх у фігурних дужках через кому.

Множину також можна створити зі списку.

>>> a_list = ['a', 'b', 'mpilgrim', True, False, 42] 
>>> a_set = set(a_list)

Щоб створити множину зі списку, використайте функцію set(). (Педанти, які знають про те, як реалізовані множини, вкажуть, що це насправді не виклик функції, а створення екземпляру класу. Я обіцяю, що далі в цій книжці ви дізнаєтесь про різницю. А поки що просто знайте, що set() поводить себе як функція, що повертає множину.)

>>> a_set
{'a', False, 'b', True, 'mpilgrim', 42}

Як я раніше згадував, множини можуть містити значення будь-якого типу. І як я казав, вони невпорядковані. Ця множина не зберігає початковий порядок елементів списку, з якого ви її створили. І якщо ви додасте до неї ще елементи, вона не буде пам’ятати, в якому порядку ви їх додали.

>>> a_list
['a', 'b', 'mpilgrim', True, False, 42]

Початковий список не змінюється.

Не маєте ніяких значень взагалі? Не проблема! Можна створити порожню множину.

>>> a_set = set()

Щоб створити порожню множину, викличте set() без параметрів.

>>> a_set
set()

Текстове представлення порожньої множини виглядає дещо дивно. Ви ж напевне очікували побачити {}? Це б означало порожній словник, а не порожню множину. Про словники ми дізнаємось трохи далі в цьому ж розділі.

>>> type(a_set)
<class 'set'>

Незважаючи на те, що вона дивно виглядає, порожня множина - це все одно множина.

>>> len(a_set)
0

Яка не містить елементів.

>>> not_sure = {} 
>>> type(not_sure)
<class 'dict'>

З історичних причин дві фігурні дужки позначають не порожню множину, а порожній словник.

Зміна множини

[ред.]

Існує два способи додавати елементи до множини: метод add() і метод update().

>>> a_set = {1, 2} 
>>> a_set.add(4) 
>>> a_set
{1, 2, 4}

Метод add() приймає один аргумент будь-якого незмінного типу і додає його в множину.

>>> len(a_set)
3

Тепер множина містить три елементи.

>>> a_set.add(1) 
>>> a_set
{1, 2, 4} 
>>> len(a_set)
3

Множина - це сумка з унікальними елементами. Якщо ви спробуєте додати до множини елемент, який там вже був, нічого не станеться. Не відбудеться ніякої помилки, і нічого не зміниться. Множина і далі міститиме три елементи.

>>> a_set = {1, 2, 3} 
>>> a_set
{1, 2, 3} 
>>> a_set.update({2, 4, 6})

Метод update() приймає множину і додає всі елементи цієї множини до нашої. Це те ж саме, що викликати add() для кожного елемента множини-параметра.

>>> a_set
{1, 2, 3, 4, 6}

Значення, які вже були в множині, ігноруються, бо множини не містять дублікатів.

>>> a_set.update({3, 6, 9}, {1, 2, 3, 5, 8, 13}) 
>>> a_set
{1, 2, 3, 4, 5, 6, 8, 9, 13}

Також update() можна викликати з довільним числом параметрів, це те ж саме, що викликати цей метод послідовно для кожного з них окремо.

>>> a_set.update([10, 20, 30]) 
>>> a_set
{1, 2, 3, 4, 5, 6, 8, 9, 10, 13, 20, 30}

Окрім множин, update() може приймати багато інших типів, наприклад, списки. Для них він поводиться аналогічно - додає кожен їх елемент в множину.

Видалення елементів з множини

[ред.]

Є три способи видаляти елементи з множини. Перші два - discard() і remove() - мають одну тонку відмінність.

>>> a_set = {1, 3, 6, 10, 15, 21, 28, 36, 45} 
>>> a_set
{1, 3, 36, 6, 10, 45, 15, 21, 28} 
>>> a_set.discard(10) 
>>> a_set
{1, 3, 36, 6, 45, 15, 21, 28}

Метод discard() приймає єдине значення як параметр, і видаляє це значення з множини.

>>> a_set.discard(10) 
>>> a_set
{1, 3, 36, 6, 45, 15, 21, 28}

Якщо викликати discard() для значення, якого немає в множині, в ній нічого не зміниться. Не згенерується жодного винятку.

>>> a_set.remove(21) 
>>> a_set
{1, 3, 36, 6, 45, 15, 28}

Метод remove() також приймає єдине значення як аргумент, і також видаляє це значення з множини.

>>> a_set.remove(21)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
KeyError: 21

А ось і тонка відмінність. Якщо значення немає в множині, метод remove() кидає виняток KeyError.

Як і списки, множини мають метод pop().

>>> a_set = {1, 3, 6, 10, 15, 21, 28, 36, 45} 
>>> a_set.pop()
1 
>>> a_set.pop()
3 
>>> a_set.pop()
36 
>>> a_set
{6, 10, 45, 15, 21, 28}

Метод pop() видаляє одне значення з множини і повертає його. Щоправда, оскільки множини - це невпорядковані набори, то "останнього" немає елемента - важко передбачити, який елемент буде повернуто.

>>> a_set.clear() 
>>> a_set
set()

Метод clear() видаляє всі значення множини, роблячи її порожньою. Це дорівнює присвоєнню a_set = set(), яке просто створить нову порожню множину і замінить посилання на неї в змінній.

>>> a_set.pop()
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
KeyError: 'pop from an empty set'

Спроба виклику pop() для порожньої множини створить виняток KeyError.

Теоретико-множинні операції

[ред.]

Розділ названо так тому, що зараз ми розглянемо ті дії з множинами, які вивчає відповідний розділ математики.

>>> a_set = {2, 4, 5, 9, 12, 21, 30, 51, 76, 127, 195} 
>>> 30 in a_set
True 
>>> 31 in a_set
False

Щоб визначити, чи належить множині елемент , використовуйте оператор in. Працює так само, як і зі списками.

>>> b_set = {1, 2, 3, 5, 6, 8, 9, 12, 15, 17, 18, 21} 
>>> a_set.union(b_set)
{1, 2, 195, 4, 5, 6, 8, 12, 76, 15, 17, 18, 3, 21, 30, 51, 9, 127}

Метод union() (об’єднання) повертає множину, що складається з елементів, які належать хоча б одній з двох множин.

>>> a_set.intersection(b_set)
{9, 2, 12, 5, 21}

Метод intersection() (перетин) повертає множину, що складається з елементів, які належать одночасно двом множинам.

>>> a_set.difference(b_set)
{195, 4, 76, 51, 30, 127}

Метод difference() (різниця) повертає множину з тих елементів a_list, які не належать b_list.

>>> a_set.symmetric_difference(b_set)
{1, 3, 4, 6, 8, 76, 15, 17, 18, 195, 127, 30, 51}

Метод symmetric_difference (симетрична різниця) повертає множину з тих елементів, які належать рівно одній з множин.

Троє з цих методів симетричні.

>>> b_set.symmetric_difference(a_set)                                       
{3, 1, 195, 4, 6, 8, 76, 15, 17, 18, 51, 30, 127}

Симетрична різниця між a_set і b_set може відрізнятись від симетричної різниці між ними ж у протилежному порядку, але пам’ятайте, що множини не є впорядкованими. Будь-які дві множини, що містять однаковий набір значень, вважаються рівними.

>>> b_set.symmetric_difference(a_set) == a_set.symmetric_difference(b_set)  
True

Що власне й доводить порівняння вище. Не вводьтесь ув оману друкованим представленням множин оболонкою Python. Вони містять однакові значення, тому є рівними.

>>> b_set.union(a_set) == a_set.union(b_set)                                
True

Об’єднання двох множин теж симетричне.

>>> b_set.intersection(a_set) == a_set.intersection(b_set)                  
True

І перетин множин симетричний.

>>> b_set.difference(a_set) == a_set.difference(b_set)                      
False

А різниця - ні. І це логічно, бо різниця множин аналогічна відніманню чисел, тож порядок операндів має значення.

І нарешті, є кілька запитань про множини, на які можна отримати відповідь:

>>> a_set = {1, 2, 3} 
>>> b_set = {1, 2, 3, 4} 
>>> a_set.issubset(b_set)
True

a_set - підмножина (subset) множини b_set — всі елементи a_set також належать b_set.

>>> b_set.issuperset(a_set)
True

Таке саме запитання, лишень навпаки. b_set - надмножина a_set, тому що всі члени a_set також є членами b_set.

>>> a_set.add(5) 
>>> a_set.issubset(b_set)
False 
>>> b_set.issuperset(a_set)
False

Як тільки ви додасте до a_set значення, що не міститься в b_set, обидві перевірки повернуть False.

Множини в булевому контексті

[ред.]

Тут, як завжди, все просто:

>>> def is_it_true(anything):
...   if anything:
...     print("yes, it's true")
...   else:
...     print("no, it's false")
... 
>>> is_it_true(set())
no, it's false 
>>> is_it_true({'a'})
yes, it's true 
>>> is_it_true({False})
yes, it's true

Порожня множина - False, а непорожня, не залежно від вмісту елементів - True.


* * *


Словники

[ред.]

Словники - це невпорядковані набори пар "ключ - значення". Коли ви додаєте до словника новий ключ, завжди також повинні додати туди і значення (яке можна буде змінити пізніше). Вони оптимізовані для отримання значення, якщо ви знаєте відповідний ключ, але ніяк не навпаки.

Словник у Python - це як Perl 5. У Perl 5 змінні, що зберігають хеші, завжди починаються з символу %. У мові Python змінні можна називати довільним чином, Python запам’ятає їхній тип самостійно.

Створення словника

[ред.]

Створити словник нескладно. Синтаксис подібний до синтаксису множин, але замість значень - пари "ключ-значення". Щойно ви отримали словник, можете перевіряти значення за їхніми ключами.

>>> a_dict = {'server': 'db.diveintopython3.org', 'database': 'mysql'}
>>> a_dict
{'server': 'db.diveintopython3.org', 'database': 'mysql'}

Спершу створюємо новий словник з двома елементами і присвоюємо їхні значення змінній a_dict. Кожен елемент - це пара "ключ-значення", і вся множина з елементів поміщена в фігурні дужки.

>>> a_dict['server']
'db.diveintopython3.org'

'server' - це ключ і пов'язане з ним значення, яке іменується як a_dict['server'] - 'db.diveintopython3.org'.

>>> a_dict['database']
'mysql'

'database' - ключ, який пов'язаний зі значенням 'mysql'.

>>> a_dict['db.diveintopython3.org']
Traceback (most recent call last):
 File "<stdin>", line 1, in <module> 
KeyError: 'db.diveintopython3.org'

Можна отримати значення за ключем, але не можна отримувати ключі за значенням. Тому a_dict['server'] дорівнює 'db.diveintopython3.org', а a_dict['db.diveintopython3.org'] генерує винятки, бо 'db.diveintopython3.org' - не ключ.

Модифікація словника

[ред.]

Словники не мають попередньо заданого обмеження в розмірі. Ви в будь-який час можете додавати до словника нові пари "ключ-значення" чи змінювати значення чинної пари. Розвиваючи попередній приклад:

>>> a_dict
{'server': 'db.diveintopython3.org', 'database': 'mysql'} 
>>> a_dict['database'] = 'blog' 
>>> a_dict
{'server': 'db.diveintopython3.org', 'database': 'blog'}

Словник не може містити дублікатів ключів. Присвоєння значення чинному ключу затре старе значення.

>>> a_dict['user'] = 'mark'

Нові пари можна додавати коли завгодно. Синтаксис аналогічний синтаксису модифікації чинної пари.

>>> a_dict
{'server': 'db.diveintopython3.org', 'user': 'mark', 'database': 'blog'}

Новий елемент (ключ 'user' і значення 'mark') з'являється всередині. Насправді те, що елементи були впорядковані в першому прикладі, - лише випадковість, як і те, що зараз вони не впорядковані.

>>> a_dict['user'] = 'dora'
>>> a_dict
{'server': 'db.diveintopython3.org', 'user': 'dora', 'database': 'blog'}

Присвоєння значення чинному ключу просто замінює старе на нове.

>>> a_dict['User'] = 'mark'
>>> a_dict
{'User': 'mark', 'server': 'db.diveintopython3.org', 'user': 'dora', 'database': 'blog'}

Чи замінить це значення ключа 'user' назад на "mark"? Ні! Придивіться до ключа уважніше - він починається з великої літери. Ключі словника чутливі до регістру, тому ця інструкція створить нову пару "ключ-значення", а не перепише чинну. Вони може бути схожими для вас, але для Python вони цілковито різні.

Словники змішаних значень

[ред.]

Словники створені не тільки для рядків. Словникові значення можуть мати будь-який тип, включаючи цілі, булеві, довільні об'єкти і навіть інші словники. І всередині одного словника значення не мусять мати один тип. Обмеження на ключі словника більш суворі, але вони теж можуть бути рядками, цілими та кількома іншими типами, і їх теж можна змішувати всередині одного словника.

Насправді ви вже бачили словник з нерядковими ключами і значеннями в вашій першій програмі.

SUFFIXES = {1000: ['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
            1024: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']}

Давайте розберемо це в інтерактивній оболонці.

>>> SUFFIXES = {1000: ['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
...             1024: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']}
>>> len(SUFFIXES)
2

Як і для списків з множинами, функція len() повертає кількість елементів у словнику.

>>> 1000 in SUFFIXES
True

І як і в списках та множинах, ви можете використовувати оператор in для того, щоб з'ясувати, чи перебуває певний ключ у словнику.

>>> SUFFIXES[1000]
['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']

1000 - це ключ в словнику SUFFIXES. Його значення - список з восьми елементів.

>>> SUFFIXES[1024]
['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']

Так само з ключем 1024.

>>> SUFFIXES[1000][3]
'TB'

Оскільки SUFFIXES[1000] - список, ми можемо звернутись до окремого елемента списку за його індексом.

Словники в булевому контексті

[ред.]
Порожні словники хибні, всі інші - істинні

Словники теж можна використовувати в булевому контексті.

>>> def is_it_true(anything):
...   if anything:
...     print("yes, it's true")
...   else:
...     print("no, it's false")
...
>>> is_it_true({})
no, it's false

Порожній словник завжди дорівнює False в булевому контексті.

>>> is_it_true({'a': 1})
yes, it's true

Будь-який словник, що містить хоча б один елемент, дорівнює True в булевому контексті.


* * *


None

[ред.]

None - спеціальна константа мови Python. Це значення, що позначає відсутність значення. None - це не те ж саме що й False. None не 0. При порівнянні з None будь-що, окрім None, завжди поверне False.

None - єдине значення свого власного типу NoneType. Ви можете присвоїти значення None будь-яку змінну, але не можете створити інші об'єкти класу NoneType. Всі змінні зі значенням None дорівнюють одна одній.

>>> type(None)
<class 'NoneType'>
>>> None == False
False
>>> None == 0
False
>>> None == ''
False
>>> None == None
True
>>> x = None
>>> x == None
True
>>> y = None
>>> x == y
True

None в булевому контексті

[ред.]

У булевому контексті None еквівалентно False, а not None - True.

>>> def is_it_true(anything):
...   if anything:
...     print("yes, it's true")
...   else:
...     print("no, it's false")
...
>>> is_it_true(None)
no, it's false
>>> is_it_true(not None)
yes, it's true

Для подальшого читання

[ред.]


Вирази над структурами

[ред.]

Наша уява напружена до країв, не як в художній літературі, щоб уявити собі речі яких немає, а щоб зрозуміти ті речі, які насправді є.
Річард Фейнман


Кожна мова програмування має щось, що могло б бути складним, але в ній воно спеціально зроблено просто. Якщо ви прийшли від іншої мови, ви можете це запросто пропустити, через те, що ваша стара мова не робила це просто (тому що вона натомість робила простим щось інше). Цей розділ розповість вам про спискові вирази, словникові вирази, і множинні вирази: три пов’язані ідеї, які зібрані навколо одної дуже потужної техніки. Але спершу, я хочу здійснити невелику екскурсію, в два модулі, які допоможуть вам переміщуватись по файловій системі.


* * *


Робота з файлами та каталогами

[ред.]

Python 3 поширюється з модулем os, що містить купу функцій для отримання інформації і у деяких випадках для маніпуляції, локальними каталогами, файлами, процесами та змінними середовища. Python робить все що може, щоб надати уніфіковане API на всіх підтримуваних операційних системах щоб ваші програми могли працювати на будь-якому комп’ютері, з якомога меншою кількістю платформо-залежного коду.

Поточна робоча директорія

[ред.]

Якщо ви тільки-но встановили Python, ви будете проводити багато часу в інтерактивній оболонці. Протягом цієї книжки, ви побачити приклади, які працюють за такою схемою:

  1. Імпортують один з прикладів з каталогу examples.
  2. Викликають функцію з того модуля
  3. Пояснюють результат
Хоч якийсь з каталогів завжди повинен бути поточним робочим.

Якщо ви не знаєте свою поточну робочу директорію, перший крок напевне буде невдалим, і закінчиться з ImportError. Чому? Бо Python буде шукати модуль в шляхах пошуку імпортів, але не знайде, бо там немає каталогу examples. Щоб це виправити можна зробити дві речі:

  1. Додати каталог examples в шляхи імпорту.
  2. Змінити поточну робочу директорію на examples

Поточна робоча директорія - невидима змінна яку Python ввесь час зберігає в пам’яті. Поточна робоча директорія завжди задана, незалежно чи ви в інтерактивній оболонці, запустили скрипт з командного рядка, чи виконуєте CGI скрипт на якомусь веб-сервері.

Модуль os містить дві функції для роботи з поточною робочою директорією.

>>> import os

Він поширюється разом з Python, і його можна імпортувати будь-де в будь-який момент.

>>> print(os.getcwd())
C:\Python31

Щоб визначити поточну робочу директорію використайте функцію os.getcwd(). Коли ви запустаєте графічну оболонку інтерпретатора - поточна директорія зазвичай та, де знаходиться виконуваний файл ітерпретатора. На Windows це зазвиай там, куди ви встановили Python, за замовучуванням C:\Python31. Якщо ви запускаєте інтерпретатор з командної оболонки, то поточна робоча директорія - та сама що й директорія в якій ви були, коли запустили python3.

>>> os.chdir('/Users/pilgrim/diveintopython3/examples')

Використовуйте функцію os.chdir() щоб змінити поточну робочу директорію.

>>> print(os.getcwd())
C:\Users\pilgrim\diveintopython3\examples

Коли я викликав функцію os.chdir(), я використав Лінукс-синтаксис запису шляху (прямі слеші, відсутні буквенні позначення логічних дисків) навіть незважаючи на те, що я в цей момент працюю на Windows. Це одне з місць в яких Python намагається стерти різницю між операційними системами.

Робота з іменами файлів та директорій

[ред.]

Поки ми говоримо про директорії, я хочу вказати на те, що модуль os.path містить функції для маніпулювання іменами файлів та директорій.

>>> import os 
>>> print(os.path.join('/Users/pilgrim/diveintopython3/examples/', 'humansize.py'))
/Users/pilgrim/diveintopython3/examples/humansize.py

Функція os.path.join() складає шлях з кількох часткових шляхів. В даному випадку - просто послідовно з’єднує рядки.

>>> print(os.path.join('/Users/pilgrim/diveintopython3/examples', 'humansize.py'))
/Users/pilgrim/diveintopython3/examples\humansize.py

В цьому дещо менш тривіальному випадку, виклик os.path.join() додає додатковий слеш до імені перед його приєднанням до імені файла. Це зворотній, а не прямий слеш, тому що я пишу цей приклад під Windows. Якщо ви повторите такі ж дії в Linux чи Mac OS X, ви побачите замість цього прямий слеш. Не жартуйте зі слешами, завжди використовуйте os.path.join() і дозвольте мові Python зробити все правильно.

>>> print(os.path.expanduser('~'))
c:\Users\pilgrim

Функція os.path.expanduser() розгортає шлях який використовує ~ для позначення домашнього каталога корисувача в повний. Вона працює на будь-якій платформі де користувачі можуть мати домашню категорію, включно з Linux, Mac OS X та Windows. Шлях який повертається не містить останнього слеша, але функція os.path.join() не звертає на таке увагу.

>>> print(os.path.join(os.path.expanduser('~'), 'diveintopython3', 'examples', 'humansize.py'))
c:\Users\pilgrim\diveintopython3\examples\humansize.py

Комбінуючи ці техніки, можна просто конструювати шляхи для директорій та файлів в домашньому каталозі. Функція os.path.join() приймає будь-яку кількість аргументів. Я дуже радів, коли відкрив це, тому що addSlashIfNecessary() - одна з тих дурних маленьких функцій які я завжди писав, коли створював свій інструментарій в новій мові. Не пишіть такі малі дурні функції в Python - про це вже замість вас подбали розумні люди.

os.path також містить функції для поділу повних імен файлів та каталогів на їх складові частини.

>>> pathname = '/Users/pilgrim/diveintopython3/examples/humansize.py'
>>> os.path.split(pathname)
('/Users/pilgrim/diveintopython3/examples', 'humansize.py')

Функція split() розділяє повне ім'я файла, і повертає кортеж що містить шлях та назву.

>>> (dirname, filename) = os.path.split(pathname)

Пам'ятаєте, коли я казав що можна використовувати множинне присвоювання щоб отримувати кілька значень з функції? os.path.split() - саме така функція. Ми присвоюємо результат її роботи кортежу з двох змінних. Кожна змінна приймає значення відповідного елемента кортежу що повертається.

>>> dirname
'/Users/pilgrim/diveintopython3/examples'

Перша змінна, dirname, приймає значення першого елементу результату os.path.split() - шлях до файла.

>>> filename
'humansize.py'

Друга змінна, filename приймає значення другого елементу кортежу - ім'я файла.

>>> (shortname, extension) = os.path.splitext(filename)
>>> shortname
'humansize'
>>> extension
'.py'

Модуль os.path також містить функцію os.path.splitext(), яка відділяє від імені файла його розширення, і повертає кортеж з двох елементів. Ви можете використати подібну техніку щоб присвоїти ці значення різним змінним.

Лістинги директорій

[ред.]
Модуль glob використовує шаблони як в командному рядку

Модуль glob - інший інструмент зі стандартної бібліотеки. Це простий спосіб отримувати вміст директорій в своїх програмах, і він використовує шаблони з якими ви вже могли бути знайомими працюючи з командного рядка.

>>> os.chdir('/Users/pilgrim/diveintopython3/')
>>> import glob
>>> glob.glob('examples/*.xml')
['examples\\feed-broken.xml',
 'examples\\feed-ns0.xml',
 'examples\\feed.xml']

Модуль glob отримує шаблон, і повертає шляхи до всіх файлів і директорій які цьому шаблону відповідають. Наприклад, шаблон "*.xml", відповідає будь-якому файла з розширенням .xml.

>>> os.chdir('examples/')

Тепер перейдемо в підкаталог examples функція os.chdir() може приймати відносні шляхи.

>>> glob.glob('*test*.py')
['alphameticstest.py',
 'pluraltest1.py',
 'pluraltest2.py',
 'pluraltest3.py',
 'pluraltest4.py',
 'pluraltest5.py',
 'pluraltest6.py',
 'romantest1.py',
 'romantest10.py',
 'romantest2.py',
 'romantest3.py',
 'romantest4.py',
 'romantest5.py',
 'romantest6.py',
 'romantest7.py',
 'romantest8.py',
 'romantest9.py']

В шаблон можна додавати кілька загальних символів. НАприклад в цьому прикладі шаблон шукає всі файли, назви яких закінчуються розширенням .py і містять слово test де-небудь.

Отримання метаданих файла

[ред.]

Кожна сучасна файлова система зберігає певні метадані про кожен файл: час створення, час останньї зміни, розмір і т.п. Python надає єдине API для доступу до цих метаданих. Вам не потрібно відкривати файл, все що потрібно, це назва файла.

>>> import os
>>> print(os.getcwd())                
c:\Users\pilgrim\diveintopython3\examples

Поточна робоча директорія - examples.

>>> metadata = os.stat('feed.xml')

feed.xml - файл в каталозі examples. Виклик функції os.stat() повертає об'єкт, який містить кілька різних видів відомостей про файл.

>>> metadata.st_mtime                  
1247520344.9537716

st_mtime - час модифікації, але записаний в не надто зручному форматі. (Технічно це кількість секунд від початку Епохи, яка почалася першого січня 1970-того року. Серйозно.)

>>> import time

Модуль time - частина стандартної бібліотеки. Він містить функції для перетворень між різними форматами часу, настройки часових зон, та форматування часу в тексті.

>>> time.localtime(metadata.st_mtime)  
time.struct_time(tm_year=2009, tm_mon=7, tm_mday=13, tm_hour=17,
  tm_min=25, tm_sec=44, tm_wday=0, tm_yday=194, tm_isdst=1)

Функція time.localtime() перетворює часове значення часу з секунд-від-початку-епохи (які є властивістю файла st_mtime значення якої повернула нам функція os.stat()) в більш зручну структуру з року, місяця, дня, години, хвилини, секунд, і так далі.

>>> metadata.st_size                              
3070

Функція os.stat() також повертає розмір файла в атрибуті st_size. Файл feed.xml містить 3070 байт.

>>> import humansize
>>> humansize.approximate_size(metadata.st_size)  
'3.0 KiB'

Ми можемо передати атрибут st_size функції approximate_size().

Отримання абсолютних шляхів

[ред.]

У попередній секції функція glob.glob() повернула список відносних шляхів. Перший приклад використовував шляхи вигляду 'examples\feed.xml', а другий - навіть коротші відносні шляхи що складались лише з імені файла. Поки ви залишаєтесь в тій самій поточній директорії, ці відносні шляхи будуть працювати для відкриття файлів та отримання їх метаданих. Але якщо вам потрібно отримати абсолютні шляхи (тобто такі що включають всі директорії аж до кореневої, або до літери що позначає диск) - тоді вам потрібна функція os.path.realpath().

>>> import os
>>> print(os.getcwd())
c:\Users\pilgrim\diveintopython3\examples
>>> print(os.path.realpath('feed.xml'))
c:\Users\pilgrim\diveintopython3\examples\feed.xml


* * *


Спискові вирази

[ред.]
Спискові вирази можуть містити довільні вирази мови Python

Спискові вирази (англ. list comprehensions) - це компактний спосіб створення списку з іншої послідовності, застосовуючи певну функцію до кожного елемента послідовності. (Іноді українською їх ще називали генераторні списки, але цей термін невдалий, тому що генератори, і генераторні вирази - зовсім інша історія).

>>> a_list = [1, 9, 8, 4]
>>> [elem * 2 for elem in a_list]
[2, 18, 16, 8]

Щоб зрозуміти що твориться прочитайте вираз справа наліво. a_list - список з якого ми беремо елементи. Інтерпретатор бере кожний елемент по черзі, і тимчасово присвоює його значення змінній elem. Після цього обчислює значення функції elem * 2 і додає результат до списку який повертається.

>>> a_list
[1, 9, 8, 4]

Спискові вирази створюють нові списки, старі залишаються незмінними.

>>> a_list = [elem * 2 for elem in a_list]
>>> a_list
[2, 18, 16, 8]

Крім того, можна цілком безпечно присвоювати результат спискового виразу змінній яка використовується у виразі. Python спочатку створить новий список в пам’яті, і тільки після завершення обчислення його елементів присвоїть отримані значення оригінальній змінній.

Всередині спискових виразів можна використовувати довільні вирази мови Python, в тому числі і функції модуля os.

>>> import os, glob
>>> glob.glob('*.xml')                                 
['feed-broken.xml', 'feed-ns0.xml', 'feed.xml']

Це поверне список всіх .xml в поточній робочій директорії.

>>> [os.path.realpath(f) for f in glob.glob('*.xml')]  
['c:\\Users\\pilgrim\\diveintopython3\\examples\\feed-broken.xml',
 'c:\\Users\\pilgrim\\diveintopython3\\examples\\feed-ns0.xml',
 'c:\\Users\\pilgrim\\diveintopython3\\examples\\feed.xml']

А цей списковий вираз перетворить список імен файлів на список повних шляхів до них.

Спискові вирази також можуть фільтрувати елементи, включаючи в кінцевий список не кожні з них.

>>> import os, glob
>>> [f for f in glob.glob('*.py') if os.stat(f).st_size > 6000]  
['pluraltest6.py',
 'romantest10.py',
 'romantest6.py',
 'romantest7.py',
 'romantest8.py',
 'romantest9.py']

Щоб відфільтрувати елементи списку можна додати оператор if в кінець спискового виразу. Вираз після ключового слова if буде обчислений для кожного елемента списку, і якщо він прийме істинне значення, елемент буде доданий до результуючого списку. Цей списковий вираз отримує список всіх файлів .py поточного каталогу, і вираз після if відфільтровує всі елементи розмір яких не більший за 6000. Таких файлів шість, тому кінцевий список містить шість елементів.

Поки що всі приклади спискових виразів містили прості вирази: домножини значення на константу, викликати функцію, чи взагалі просто повернути оригінальне значення списку (після фільтрування). Але обмежень на складність спискового немає.

>>> import os, glob
>>> [(os.stat(f).st_size, os.path.realpath(f)) for f in glob.glob('*.xml')]
[(3074, 'c:\\Users\\pilgrim\\diveintopython3\\examples\\feed-broken.xml'),
 (3386, 'c:\\Users\\pilgrim\\diveintopython3\\examples\\feed-ns0.xml'),
 (3070, 'c:\\Users\\pilgrim\\diveintopython3\\examples\\feed.xml')]

Цей списковий вираз знаходить всі xml-файли в поточному каталозі, отримує розмір кожного файла (викликаючи функцію os.stat()), і створює кортеж з розміру файла і абсолютного шляху до нього (викликаючи функцію os.path.realpath()).

>>> import humansize
>>> [(humansize.approximate_size(os.stat(f).st_size), f) for f in glob.glob('*.xml')]
[('3.0 KiB', 'feed-broken.xml'),
 ('3.3 KiB', 'feed-ns0.xml'),
 ('3.0 KiB', 'feed.xml')]

А цей списковий вираз - покращення попереднього за допомогою виклику функції approximate_size(), для розміру кожного файла.


* * *


Словникові вирази

[ред.]

Словникові вирази - це так само як спискові вирази, тільки в результаті ми отримуємо словник а не список.

>>> import os, glob
>>> metadata = [(f, os.stat(f)) for f in glob.glob('*test*.py')]

Це не словниковий вираз, це все ще списковий вираз. Він знаходить всі файли .py, що містять в імені слово test, і будує кортежі з імені файла і його метаданих (які повертаються функцією os.stat()).

>>> metadata[0]
('alphameticstest.py', nt.stat_result(st_mode=33206, st_ino=0, st_dev=0,
 st_nlink=0, st_uid=0, st_gid=0, st_size=2509, st_atime=1247520344,
 st_mtime=1247520344, st_ctime=1247520344))

Кожен елемент результату - кортеж.

>>> metadata_dict = {f:os.stat(f) for f in glob.glob('*test*.py')}

А оце - словниковий вираз. Синтаксис подібний до спискового виразу, з двома основними відмінностями. По-перше, його поміщено в фігурні дужки замість квадратних. По-друге, замість єдиного виразу для кожного елемента, він містить два вирази розділені двокрапкою. Вираз перед двокрапкою (в цьому прикладі f) є ключем, а вираз після двокрапки (в даному випадку os.stat(f)) - значення.

>>> type(metadata_dict)
<class 'dict'>

Результат обчислення словникового виразу - словник. Який сюрприз!

>>> list(metadata_dict.keys())
['romantest8.py', 'pluraltest1.py', 'pluraltest2.py', 'pluraltest5.py',
 'pluraltest6.py', 'romantest7.py', 'romantest10.py', 'romantest4.py',
 'romantest9.py', 'pluraltest3.py', 'romantest1.py', 'romantest2.py',
 'romantest3.py', 'romantest5.py', 'romantest6.py', 'alphameticstest.py',
 'pluraltest4.py']

Ключами даного словника є імена файла отримані з функції glob.glob('*test*.py').

>>> metadata_dict['alphameticstest.py'].st_size
2509

Значення пов’язане з кожним ключем було обчислене функцією os.stat(). Це означає що ми можемо отримувати зі словника метадані файла за його назвою. Одне із полів метаданих - st_size, розмір файла. Файл alphameticstest.py має розмір 2509 байт.

Як і спискові, словникові вирази можуть містити оператор if щоб фільтрувати вхідну послідовність на основі певного булевого виразу.

>>> import os, glob, humansize
>>> metadata_dict = {f:os.stat(f) for f in glob.glob('*')}

Цей вираз будує словник який містить метаінформацію для кожного файла в поточній директорії.

>>> humansize_dict = {os.path.splitext(f)[0]:humansize.approximate_size(meta.st_size) \     
...                   for f, meta in metadata_dict.items() if meta.st_size > 6000}

Цей словниковий вираз побудовано на основі попереднього. Він відбирає тільки файли розмір яких більший за 6000 байт (if meta.st_size > 6000), та використовує їх щоб створити словник, ключами якого є імена файлів без розширення (os.path.splitext(f)[0]) а значення яких - приблизний розмір кожного файла (humansize.approximate_size(meta.st_size)).

>>> list(humansize_dict.keys()) 
['romantest9', 'romantest8', 'romantest7', 'romantest6', 'romantest10', 'pluraltest6']

Ми вже бачили що таких файлів шість.

>>> humansize_dict['romantest9'] 
'6.5 KiB'

Значення кожного ключа - рядок що обчислений функцією approximate_size().

Інші трюки з словниковими виразами

[ред.]

Ось трюк з використанням словникого виразу, який можливо колись стане в нагоді: обмін місцями ключів і значень словника.

>>> a_dict = {'a': 1, 'b': 2, 'c': 3}
>>> {value:key for key, value in a_dict.items()}
{1: 'a', 2: 'b', 3: 'c'}

Звичайно, це працює тільки якщо значення словника належать до незмінного типу, як рядки чи кортежі. Якщо ви спробуєте зробити це з словником який містить списки, все завершиться ефектною невдачею.

>>> a_dict = {'a': [1, 2, 3], 'b': 4, 'c': 5}
>>> {value:key for key, value in a_dict.items()}
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "<stdin>", line 1, in <dictcomp>
TypeError: unhashable type: 'list'


* * *


Множинні вирази

[ред.]

Ну, і не варто забувати про те, що множини теж мають синтаксис виразів які дозволяють їх утворювати. Він надзвичайно схожий на синтаксис словникових виразів, тільки містить звичайні значення замість пар ключ:значення.

>>> a_set = set(range(10))
>>> a_set
{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
>>> {x ** 2 for x in a_set}           
{0, 1, 4, 81, 64, 9, 16, 49, 25, 36}

Множинні вирази (як і всі інші) можуть приймати на вхід множину. Тут ми будуємо множину квадратів натуральних чисел від нуля до 9.

>>> {x for x in a_set if x % 2 == 0}  
{0, 8, 2, 4, 6}

І як і всі попередні, множинні вирази теж можуть містити оператор if для фільтрації.

>>> {2**x for x in range(10)}         
{32, 1, 2, 4, 8, 64, 128, 256, 16, 512}

Ну, і множинним виразам не обов’язково використовувати як базові дані множину, підійде будь-яка послідовність.


* * *


Для подальшого читання

[ред.]

Текст

[ред.]

I’m telling you this ’cause you’re one of my friends.
My alphabet starts where your alphabet ends!


Dr. Seuss, On Beyond Zebra!


Деяка нудна теорія яку ви повинні зрозуміти перед тим як зайнятись практикою

[ред.]

Мало хто з людей про це думає, але тексти неймовірно складні. Взяти б для початку хоча б алфавіт. Люди з острова Бугенвіль мають найменший алфавіт в світі; письмо на їх мові, що зветься ротока, складається лише з дванадцяти літер: A, E, G, I, K, O, P, R, S, T, U та V. З іншого кінця спектру, такі мови як китайська, японська та корейська мають тисячі символів. Англійська має 26 літер, чи 52 якщо рахувати окремо великі і маленькі, і ще купку знаків пунктуації: !@#$%&.

Коли ви кажете "текст", ви напевне маєте на увазі "букви та інші символи на екрані мого комп’ютера". Але комп’ютери не працюють з символами, вони працюють з бітами і байтами. Будь-який текст який ви бачите насправді зберігається в певному кодуванні. Говорячи дуже грубо, кодування символів - це бінарне відношення між зображенням символів які ви бачите на екрані, і даними які комп’ютер насправді зберігає в пам’яті та на диску. Існує багато різноманітних кодувань символів, деякі з них оптимізовані для конкретних мов, наприклад англійської, китайської або української, а інші можуть використовуватись в багатьох мовах.

Насправді все ще трохи складніше. Багато символів є спільними для різних кодувань, але кожне кодування використовує іншу послідовність біт, для збереження тих символів у пам’яті чи на диску. Тому ви можете думати про кодування тексту, як про певний вид криптографічного ключа. Як тільки хтось дає вам послідовність байт - файл чи веб-сторінку, не важливо - і стверджує що це "текст", вам потрібно знати яке кодування вони використали, щоб мати змогу перетворити байти в символи. Якщо вони передали вам неправильний ключ, або взагалі ніякого ключа, задача розшифровки тих даних залишається для вас, і я вам не заздрю. Існує велика ймовірність що ви виберете неправильне кодування, і отримаєте абракадабру.

Все що ви думали що знали про текст - неправда.

Звичайно ви бачили подібні веб-сторінки, з дивними знаками питання в тих місцях де повинні бути апострофи. Це зазвичай означає, що автор сторінки не вказав кодування правильно, ваш браузер спробував вгадати, і вийшла суміш очікуваних і неочікуваних символів. В англійській мові це може трошки дратувати. В інших мовах результат може бути цілком нечитабельним.

Існує кодування символів для кожної основної мови в світі. Оскільки всі мови різні, і пам’ять та дисковий простір історично були дорогими, кожне кодування символів оптимізується під конкретну мову. Під цим я маю на увазі, що кожне кодування що використовувало одні й ті ж цифри (0 - 255) для позначення символів тієї мови. Наприклад, ви напевне знайомі з кодуванням ASCII яке зберігає символи англійської мови, як числа що знаходяться між 0 та 127. (65 велике “A”, 97 маленьке “a”, і т.п.) Англійська має дуже простий алфавіт, тому він може бути повністю записаний менш ніж 128 числами. Якщо хтось із вас знає двійкову систему числення, для цього потрібно 7 з восьми біт у байті.

Західноєвропейські мови, такі як французька, іспанська та німецька мають більше символів ніж англійська. Або, точніше, вони мають букви, які комбінуються з різноманітними діакритичними знаками, такі як наприклад символ "ñ" в іспанській. Найтиповішим кодуванням для цих мов є CP-1252, також відоме як “windows-1252” тому що воно широко використовується на платформі Microsoft Windows. Кодування CP-1252 в діапазоні 0-127 має такі ж символи як і ASCII, та потім доповнює їх в діапазоні 128-255 такими символами як n-з-тильдою-зверху (241), u-з-двома-крапками-зверху (252) і т.п. Це все ще однобайтове кодування, символ з найбільшим кодом, 255, все ще можна записати в одному байті.

Крім того, є ще такі мови як китайська, японська та корейська, які мають так багато символів що потребують багатобайтових кодувань. Тобто кожен символ в них закодовано двобайтовим числом від 0 до 65535. Але різні багатобайтові кодування все ще мають таку ж спільну проблему як і однобайтові - вони використовують однакові числа для запису різних символів. Просто діапазон кодів став ширшим, тому що символів які потрібно закодувати набагато більше.

Це було майже нормальним у світі без мережі, де "текст" - це щось що ви самостійно набрали, і вряди-годи друкували. Не було багато "звичайного тексту". Код програм записувався в ASCII, а всі решта використовували текстові процесори, які описували свої власні (нетекстові) форми, які зберігали інформацію про кодування разом з оформленням та іншими даними. Люди відкривали ці документи тими ж текстовими процесорами що й автор, тому все більш-менш працювало.

Тепер подумайте про зростання глобальних мереж, таких як пошта, чи веб. Багато "звичайного тексту" літає навколо світу, створюючись на одному комп’ютері, і пересилаючись за допомогою другого комп’ютера на третій. Комп’ютери можуть бачити тільки числа, але числа можуть означати різні речі. О ні! Що робити? Ну, системи повинні створюватись так, щоб зберігати інформацію про кодування з кожним шматком "звичайного тексту". Пам’ятайте, це ключ до розшифрування чисел які читає комп’ютер, і перетворення їх в букви які можуть читати люди. Загублений ключ розшифрування означає спотворений текст, абракадабру, чи ще щось гірше.

Тепер подумайте про спробу зберігати різноманітні шматки тексту в одному місці, наприклад в одній таблиці бази даних яка зберігає всі листи які ви отримували. Вам потрібно зберігати кодування кожного щоб могти його правильно відобразити. Думаєте це важко? Спробуйте здійснити пошук по вашій базі даних, що означатиме конвертацію тексту між різними кодуваннями на льоту. Хіба це не звучить весело?

Тепер подумайте про можливість існування багатомовних документів, де символи з кількох мов використовуються поряд. (Підказка: програми які намагались працювати з такими документами зазвичай використовували спеціальні символи для перемикання "режимів". Опа, ви в кириличному кодуванні koi8-r, тому 241 означає Я; опа, тепер ви в грецькому кодуванні для Mac тому, 241 означає ώ.) І звичайно ви теж колись захочете робити пошук по таких документах.

Тепер багато плачте, тому що все що ви знали про текст - неправда. Не існує такої штуки як "простий текст".

Юнікод

[ред.]

З’являється Юнікод.

Юнікод - система створена для запису кожного символа кожної мови. Юнікод представляє кожну букву, символ чи ідеограму як чотирибайтове число. Кожне число задає унікальний символ якої-небудь мови. (Не всі числа використовуються, але більш ніж 65535 з них так, тому двох байтів не достатньо). Символи що використовуються в багатьох мовах зазвичай мають один номер, якщо не існує достатньої етимологічної причини для протилежного. Кожному символу відповідає одне число, і кожному числу відповідає один символ. Кожне число завжди означає одне і те ж, бо немає "режимів" за якими потрібно слідкувати. U+0041 - завжди 'A', навіть якщо мова вашого тексту не містить цієї літери.

На перший погляд це виглядає як чудова ідея. Одне кодування щоб керувати ними всіма.[1] Багато мов в одному документі. Більш ніяких "змін режиму" щоб змінювати кодування посеред файла. Але в вас має виникнути законне запитання. Чотири байти? На кожен символ‽ Це виглядає страшенно витратно, особливо для таких мов як англійська чи іспанська, які потребують менше одного байта (256 символів) щоб виразити всі можливі символи мови. Насправді це марнотратство і в ієрогліфічних мовах (таких як китайська), яким ніколи не стане потрібно більше ніж два байти на символ.

Існує кодування Юнікоду яке використовує чотири байти на символ. Воно називається UTF-32, тому що 32 біти це і є 4 байти. UTF-32 - пряме кодування. Воно бере кожен номер символу Юнікоду, і записує його в цих чотирьох байтах. Це має певні переваги, найважливішою з яких є те, що ви можете знайти n-тий символ рядка за константний час, тому що n-тий символ починається з (4*n)-того байта. Це також має кілька недоліків, найочевиднішим з яких є те, що щоб зберегти кожен чортів символ потрібно аж чотири чортових байти.

І хоча є страшенно багато символів Юнікоду, виявляється що більшість людей майже ніколи не використовують символів поза першими 65535. Тому, існує інше кодування Юнікоду, назване UTF-16 (тому що 16 бітів = 2 байти). UTF-16 кодує кожен символ від 0 до 65535 двома байтами, після чого використовує деякі брудні хаки в випадках коли вам потрібно записати рідковживані символи юнікоду з "астрального простору" поза 65535-тим символом. Найбільш очевидна перевага: UTF-16 займає вдвічі менше місця ніж UTF-32, тому що кожен символ потребує лише двох байт місця замість чотирьох (звісно окрім тих які таки потребують). І ви все ще можете просто знаходити n-тий символ за константний час, якщо припустите, що рядок не містить жодних символів з "астрального простору", що є гарним припущенням аж поки воно не стає невірним.

Але існує також не такий очевидний недолік як кодування UTF-32 так і UTF-16. Різні комп’ютерні системи зберігають окремі байти різними способами. Це означає що символ U+4E2D може зберігатись в UTF-16 як 4E 2D та як 2D 4E, залежно від порядку байтів в системі. (Для UTF-32 взагалі можливі чотири різні порядки запису). Поки ваші документи ніколи не покидають вашого комп’ютера, ви в безпеці - різні додатки одного і того ж комп’ютера будуть використовувати один і той самий порядок байтів. Але якщо ви захочете передати документи між системами, чи можливо у всесвітню мережу, вам потрібно буде якимось способом вказати порядок байт вашого кодування. Інакше система що отримує дані не буде знати чи двобайтова послідовність 4E 2D означає U+4E2D U+2D4E.

Щоб розв’язати цю проблему, багатобайтові кодування Юнікоду описують "Мітку порядку байт" (англ. Byte order mark), яка є спеціальним недрукованим символом який ви можете вставити на початку свого документа щоб вказати в якому порядку розміщені його байти. Для UTF-16 мітка порядку байт - U+FEFF. Якщо ви отримуєте документ, що починається з байт FF FE, ви знаєте що байти йдуть в одному порядку, якщо FE FF - в протилежному.

І все ж, UTF-16 не ідеальна, особливо коли вам доводиться мати справу з великою кількістю символів ASCII. Якщо подумати, то навіть китайська веб-сторінка міститиме багато ASCII символів - елементи та атрибути HTML що оточуватимуть друковані китайські ієрогліфи. Здатність знайти n-тий символ за одиничний час це добре, але все ще залишається надокучлива проблема тих символів з астральної площини, яка означає що ви не можете гарантувати те що кожен символ це рівно два байти, тому ви насправді не можете знайти n-тий символ за константний час, якщо звісно не заведете окремий індекс. І звичайно на світі море тексту ASCII...

Інші люди задумувались над цими питаннями і прийшли до рішення:

UTF-8

UTF-8 - система кодування Юнікоду змінної довжини. Тобто, різні символи можуть займати різну кількість байт. Для символів ASCII (A-Z, і т.п.) UTF-8 використовує лише один байт на символ. Насправді вона навіть використовує точно такі ж байти як і в ASCII: перші 128 кодів символів (0-127) в UTF-8 не відрізняються від кодів тих самих символів в ASCII. "Розширені латинські" символи, такі як ñ та ö, і кирилиця займають два байти. (Байти це не простий номер символа в таблиці Юнікоду, а хитрим чином закодований). Китайські символи, такі як 中, займають три байти. Рідко використовувані символи з "астральної площини" займають чотири байти.

Недоліки: через те що кожен символ займає різну кількість байт, знаходження n-того символа це операція складності O(N), тобто чим довший рядок, тим більше часу потрібно щоб знайти певний символ в ньому. Також, потрібні певні маніпуляції над бітами для того щоб отримати код символа з байт які його кодують, та навпаки.

Переваги: дуже ефективне кодування стандартних символів ASCII. Не гірше ніж UTF-16 для кирилиці, і розширеної латиниці. Краще ніж UTF-32 для китайських ієрогліфів. Також (і ви не мусите мені тут вірити, тому що я не збираюсь показувати вам математику), через природу необхідних операцій з бітами пропадає проблема порядку байт. Документ записаний в UTF-8 використовує однакову послідовність байт на всіх комп’ютерах.


* * *


Пірнаймо!

[ред.]

В Python 3 всі рядки є послідовностями символів Юнікоду. В Python не існує такої штуки як рядок закодований в UTF-8, чи рядок закодований як cp1251. "Це рядок в UTF-8?" - неправильне запитання. UTF-8 - спосіб кодування символів як послідовностей байт. Якщо ви хочете взяти рядок і перетворити його на послідовність байт в певному кодуванні, Python 3 може допомогти в цьому. Якщо ви хочете взяти послідовність байт, і перетворити в послідовність символів Python може допомогти і в цьому. Байти не є символами: байти це байти. Символи - це абстракція. Рядок - послідовність таких абстракцій.

>>> s = '深入 Python'

Щоб створити рядок, помістіть його в лапки. Рядки в Python можуть описуватись як одиничними ('), так і подвійними (") лапками.

>>> len(s)
9

Вбудована функція len() повертає довжину рядка, тобто кількість символів. Це та ж сама функція яку ви використовували щоб знайти довжину рядка, кортежу, множини чи словника. Рядок - це як кортеж з символів.

>>> s[0]
'深'

Так само я ви можете отримати окремі елементи списку, ви можете отримувати окремі символи рядка за індексом.

>>> s + ' 3'
'深入 Python 3'

Так як і в списках, ви можете конкатенувати рядки використовуючи оператор +.


* * *


Форматування рядків

[ред.]

Давайте ще раз глянемо на humansize.py.

SUFFIXES = {1000: ['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
            1024: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']}

'KB', 'MB', 'KiB' - це все рядки.

def approximate_size(size, a_kilobyte_is_1024_bytes=True):
    '''Convert a file size to human-readable form.

    Keyword arguments:
    size -- file size in bytes
    a_kilobyte_is_1024_bytes -- if True (default), use multiples of 1024
                                if False, use multiples of 1000

    Returns: string

    '''

Докстрінґи функції - теж рядки. Їх текст може містити багато рядків, тому він використовує три одинарні лапки для позначення початку і кінця.

    if size < 0:
        raise ValueError('number must be non-negative')

Тут ми бачимо ще один рядок, що передається в функцію як людське пояснення винятку що стався.

    multiple = 1024 if a_kilobyte_is_1024_bytes else 1000
    for suffix in SUFFIXES[multiple]:
        size /= multiple
        if size < multiple:
            return '{0:.1f} {1}'.format(size, suffix)

А це ... Почекайте. Це ще що за хрінь?

Python 3 підтримує форматування значень в рядки. І хоча тут можуть використовуватись дуже складні вирази, але найпростішим використанням є вставка значення в рядок.

>>> username = 'mark' 
>>> password = 'PapayaWhip' 
>>> "{0}'s password is {1}".format(username, password)
"mark's password is PapayaWhip"

Тут відбувається багато всього. По-перше, виклик методу над рядковим літералом. Рядки це об’єкти, а об’єкти мають методи. По-друге, значенням виразу є рядок. По-третє, {0} та {1} - поля заміни, які замінюються аргументами що передаються в метод format().

Складені імена полів

[ред.]

Попередній приклад ілюструє найпростіший випадок, де поля що підставляються - звичайні цілі. Цілі поля підстановки розглядаються як індекси в списку аргументів методу format(). Це означає що {0} замінюється першим аргументом (в даному випадку username), {1} - другим (password), і т.д. Можна використовувати скільки завгодно аргументів. Але поля підстановки набагато потужніші.

>>> import humansize
>>> si_suffixes = humansize.SUFFIXES[1000]      
>>> si_suffixes
['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']

Замість того щоб викликати якусь функцію модуля humansize ми отримуємо описаний в ньому список суфіксів.

>>> '1000{0[0]} = 1{0[1]}'.format(si_suffixes)  
'1000KB = 1MB'

А це виглядає складно, але насправді таким не є. {0} посилається на перший аргумент переданий в метод format(), в нашому випадку це був би si_suffixes. Але si_suffixes - це список. Тому {0[0]} стосується першого елементу в цьому списку: 'KB'. Так само, {0[1]} стосується другого елементу списку переданого першим аргументом: 'MB'. Все ззовні фігурних дужок, включаючи 1000, знак рівності та прогалики залишається неторканим. Кінцевий результат - рядок '1000KB = 1MB'.

{0} замінюється на перший аргумент функції format(), {1} - на другий.

Цей приклад показує, що специфікатори формату можуть отримувати доступ до елементів структур даних використовуючи синтакс мови Python. Це називається складеними іменами полів. Наступні складені імена працюють:

  • Передача списку і отримання елементів за індексом (як в попередньому прикладі)
  • Передача словника і отримання значень за ключами
  • Передача модуля, і отримання його функцій та констант за іменем
  • Передача екземпляра класу і отримання його атрибутів за іменем
  • Будь-яка комбінація попереднього

І щоб вас дещо вразити, ось приклад що використовує все вище перелічене:

>>> import humansize
>>> import sys
>>> '1MB = 1000{0.modules[humansize].SUFFIXES[1000][0]}'.format(sys)
'1MB = 1000KB'

І ось як воно працює:

  • Модуль sys містить інформацію про запущений в даний момент інтерпретатор. Після того, як його імпортували, його можна передавати як аргумент методу format(). Тому поле підстановки {0} посилається на модуль sys.
  • sys.modules - це словник усіх модулів які були імпортованими в даній сесії інтепретатора. Ключами є імена модулів, значеннями - самі об'єкти модулів. Тому поле підстановки {0.modules} посилається на словник відповідних модулів.
  • sys.modules['humansize'] - це модуль humansize який ви недавно імпортували. Поле підстановки {0.modules[humansize]} відповідає модулю humansize. Зауважте невеличку відмінність у синтаксисі. В коді Python ключами словника sys.modules були б рядки, щоб отримати елемент потрібно було б передавати ключ в лапках (наприклад 'humansize'). Але в полі підстановки ви опускаєте лапки навколо ключа словника. Цитуючи PEP 3101: Advanced String Formatting, "Правила розпізнавання ключів словника дуже прості. Якщо він починається з цифри, то розглядається як число, інакше використовується як рядок".
  • sys.modules['humansize'].SUFFIXES[1000] - список суфіксів SI: ['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']. Тому поле підстановки {0.modules[humansize].SUFFIXES[1000]} відповідає цьому списку.
  • sys.modules['humansize'].SUFFIXES[1000][0] - перший елемент списку суфіксів SI: 'KB'. Тому повною заміною поля {0.modules[humansize].SUFFIXES[1000][0]} є двосимвольний рядок 'KB'.

Специфікатори формату

[ред.]

Але зачекайте. Є ще! Давайте глянемо ще разок на дивний рядок коду з humansize.py:

if size < multiple:
    return '{0:.1f} {1}'.format(size, suffix)

{1} замінюється другим аргументом який передається методу format(), який є суфіксом. Але що таке {0:.1f}? Ну, це двоє речей: вже знайома вам {0}, і незнайома :.1f. Друга половина (включаючи двокрапку і все після неї) описує специфікатор формату, який описує те, як змінна що підставляється повинна форматуватись.

Специфікатори формату дозволяють модифікувати текст що підставляється різними корисними способами, як це робить функція printf мови C. Ви можете доповнювати значення зліва нулями чи пропусками, вирівнювати рядки, керувати точністю десяткових дробів, і навіть відображати числа в шістнадцятковій системі числення

Всередині поля підстановки, двокрапка (:) позначає початок специфікатору формату. Специфікатор формату ".1" означає округлення до однієї цифри після коми. Специфікатор формату "f" означає число з плаваючою крапкою. Тому отримавши значення змінної size = 698.24 та suffix = 'GB', відформатований рядок міститиме '698.2 GB':

>>> '{0:.1f} {1}'.format(698.24, 'GB')
'698.2 GB'

На рахунок всіх кривавих деталей щодо специфікаторів формату, звертайтесь до розділу Format Specification Mini-Language в офіційній документації мови Python.


* * *


Інші типові рядкові методи

[ред.]

Окрім форматування, з рядками можна робити кілька інших корисних трюків.

>>> s = '''Finished files are the re- 
... sult of years of scientif- 
... ic study combined with the 
... experience of years.'''

Ви можете вводити багаторядкові рядки прямо в інтерактивній оболонці. Для цього почніть рядок і коли натиснете Enter, оболонка запропонує вам продовжити рядок. Коли ви закриєте потрійні лапки, натиснення Enter виконає команду (в даному випадку присвоєння рядка змінній s).

>>> s.splitlines() 
['Finished files are the re-',
 'sult of years of scientif-',
 'ic study combined with the',
 'experience of years.']

Рядковий метод splitlines() повертає список рядків, в якому кожен рядок відповідає одному рядку даного. Зауважте що символи повернення каретки на кінці рядка не включаються.

>>> print(s.lower()) 
finished files are the re- 
sult of years of scientif- 
ic study combined with the 
experience of years.

Метод lower() переводить весь рядок в нижній регістр. Метод upper() - в верхній.

>>> s.lower().count('f')
6

Метод count() шукає кількість входжень підрядка в рядок. Так, в даному реченні літера 'f' справді зустрічається 6 разів.

Ось інший поширений випадок. Нехай в нас є набір пар ключ-значення в форматі 'key1=value1&key2=value2', і ви хочете розділити їх та створити словник вигляду {key1: value1, key2: value2}.

>>> query = 'user=pilgrim&database=master&password=PapayaWhip' 
>>> a_list = query.split('&') 
>>> a_list
['user=pilgrim', 'database=master', 'password=PapayaWhip']

Метод рядка split(), має один аргумент - розділювач. Метод розбиває рядок на список рядків, якщо вони були розділені розділювачем. В цьому випадку розділювачем є символ амперсанда, але він може бути довільним рядком.

>>> a_list_of_lists = [v.split('=', 1) for v in a_list if '=' in v]
>>> a_list_of_lists 
[['user', 'pilgrim'], ['database', 'master'], ['password', 'PapayaWhip']]

Тепер в нас є список рядків, кожен з яких містить ключ, після якого знак рівності, а потім значення. Ми можемо використати спискові вирази, щоб пройтись по списку і поділити кожен рядок на два, покладаючись на перший знайдений знак рівності. Необов’язковим другим аргументом методу split() є кількість бажаних поділів. 1 означатиме "ділити лише раз", тому метод split() поверне двоелементний список. (В теорії значення теж може містити знаки рівності. Якщо ви просто застосуєте 'key=value=foo'.split('='), ви отримаєте трьохелементрий список ['key', 'value', 'foo'].)

>>> a_dict = dict(a_list_of_lists)
>>> a_dict {'password': 'PapayaWhip', 'user': 'pilgrim', 'database': 'master'}

І нарешті, Python може перетворити список списків у словник, просто за допомогою функції dict().

доробити

Зрізання рядків

[ред.]

Якщо ви маєте заданий рядок, ви можете отримати будь-яку його частину як новий рядок. Це називається зрізом рядка. Зрізи рядків працюють так само як зрізи списків, що досить логічно, бо рядки - всього лише послідовності символів.

>>> a_string = 'My alphabet starts where your alphabet ends.'
>>> a_string[3:11]
'alphabet'

Можна отримати частину рядка ("зріз") задавши два індекси. Новий рядок буде містити всі символи по порядку, починаючи від першого індекса, і не включаючи другий індекс.

>>> a_string[3:-3]
'alphabet starts where your alphabet en'

Як і в списках, індекси можуть бути від’ємними.

>>> a_string[0:2]
'My'

Індексація рядків починається з нуля, тому вираз a_string[0:2] поверне перші два символи рядка від a_string[0], аж до a_string[2] невключно.

>>> a_string[:18]
'My alphabet starts'

Якщо лівий індекс зрізу - нуль, його можна опустити, так як він припускається за замовчуванням. Тому a_string[:18] працює аналогічно a_string[0:18].

>>> a_string[18:]
' where your alphabet ends.'

Аналогічно, якщо правий індекс зрізу дорівнює довжині рядка, його можна опустити. Тому a_string[18:] еквівалентий a_string[18:44], через те що в рядку 44 символи. Тут існує приємна симетрія. В цьому 44-символьному рядку a_string[:18] повертає перші 18 символів, а a_string[18:] повертає все крім перших 18 символів. Взагалі, a_string[:n] завжди поверне n перших символів, а a_string[n:] поверне решту, незалежно від довжини рядка.


* * *


Рядки та байти

[ред.]

Байти це байти, а символи це абстракція. Незмінна послідовність символів Unicode називається рядком. Незмінна послідовність чисел між 0 та 255 називається байтовим об'єктом.

>>> by = b'abcd\x65'
>>> by
b'abcde'

Для запису байтового об’єкта використовують синтаксис b (байтовий літерал). Кожен байт у літералі може бути символом ASCII чи шістнадцятковим кодом від \x00 до \xff (0–255).

>>> type(by)
<class 'bytes'>

Типом байтового об’єкта є bytes

>>> len(by)
5

Як і у випадку списків та рядків довжину байтового об’єкта можна отримати за допомогою вбудованої функції len().

>>> by += b'\xff'
>>> by
b'abcde\xff'

Як і в рядках та списках оператор + здійснює конкатенацію, результатом якої є новий байтовий об’єкт.

>>> len(by)
6

Конкатенація п’ятибайтового об’єкта з однобайтовим дає нам об’єкт з шести байт.

>>> by[0]
97

Як і в рядках та списках можна використовувати індекси для отримання окремих байтів. Елементами рядка є символи, елементи байтового об’єкта - цілі числа. Якщо точніше, цілі числа в діапазоні 0–255.

>>> by[0] = 102
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
TypeError: 'bytes' object does not support item assignment

Об’єкт bytes - незмінний, ми не можемо робити присвоєння окремим байтам. Якщо потрібно змінити окремі байти, можна використати зрізи та конкатенацію (які працюватимуть так само як і в рядках), чи перетворити об’єкт bytes в об’єкт bytearray.

>>> by = b'abcd\x65'
>>> barr = bytearray(by)
>>> barr
bytearray(b'abcde')

Щоб перетворити об’єкт bytes в об’єкт bytearray використайте вбудовану функцію bytearray().

>>> len(barr)
5

Всі методи і операції що можна виконати над об’єктом bytes можна виконати і над об’єктом bytearray.

>>> barr[0] = 102
>>> barr
bytearray(b'fbcde')

Єдиною відмінністю є те що можна присвоювати значення окремим байтам. Присвоюване значення повинно бути цілим в діапазоні 0–255.


Одну річ ніколи не можна робити - змішувати байти і рядки.

>>> by = b'd'
>>> s = 'abcde'
>>> by + s
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
TypeError: can't concat bytes to str

Не можна конкатенувати байти з рядками - це два різні типи.

>>> s.count(by)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
TypeError: Can't convert 'bytes' object to str implicitly

Не можна рахувати кількість входжень байтів в рядок, тому що рядок не містить байтів. Рядок - послідовність символів. Можливо ви мали на увазі "підрахувати кількість входжень рядка що отримується декодуванням цієї послідовності байт таким-то кодуванням"? Ну, тоді потрібно було написати це явно. Python 3 ніколи не перетворить байти в рядки, чи навпаки неявно.

>>> s.count(by.decode('ascii'))
1

Завдяки дивовижному співпадінню цей рядок коду перекладається на українську як "підрахувати кількість входжень рядка що отримується декодуванням цієї послідовності байт таким-то кодуванням".

Ось це і є зв’язком між рядками і байтами: байтові об’єкти мають метод decode() що приймає кодування символів та повертає рядок, а рядки мають метод encode() що теж приймає кодування і повертає байтовий об’єкт. В попередньому прикладі розкодуванням було досить прямолінійним - перетворити послідовність байт що задають рядок в кодуванні ASCII в відповідний рядок. Але такі самі процеси працюють і для будь-якого кодування що підтримує символи в рядку - навіть застарілі (не Unicode) кодування.

>>> a_string = '深入 Python'
>>> len(a_string)
9

a_string - рядок. У ньому 9 символів.

>>> by = a_string.encode('utf-8')
>>> by
b'\xe6\xb7\xb1\xe5\x85\xa5 Python'
>>> len(by)
13

by - байтовий об’єкт. У ньому 13 байт. Таку послідовність байт ми отримаємо коли візьмемо a_string і закодуємо його в UTF-8.

>>> by = a_string.encode('gb18030')
>>> by
b'\xc9\xee\xc8\xeb Python'
>>> len(by)
11

Подібний байтовий об’єкт з 11 байт ми отримаємо якщо закодуємо a_string в кодуванні GB18030.

>>> by = a_string.encode('big5')
>>> by b'\xb2`\xa4J Python'
>>> len(by)
11

Подібний байтовий об’єкт, що складається з зовсім іншої послідовності байт отримаємо якщо візьмемо a_string і закодуємо її в Big5.

>>> roundtrip = by.decode('big5')
>>> roundtrip
'深入 Python'
>>> a_string == roundtrip
True

roundtrip - рядок. Він має 9 символів. Таку послідовність символів можна отримати якщо взяти by і розкодувати її за допомогою алгоритму кодування Big5. Цей рядок збігається з початковим.


* * *


Постскриптум: Кодування коду Python

[ред.]

Python 3 припускає що ваш код - тобто кожен файл .py записаний в кодуванні UTF-8.

В Python 2, кодуванням за замовчуванням для файлів .py було ASCII. В Python 3, кодуванням за замовчуванням є UTF-8.

Якщо ви захочете використати для свого коду інше кодування, то можете вставити оголошення кодування в перший рядок кожного файла. Наприклад такий запис встановлює для файла кодування windows-1252:

# -*- coding: windows-1252 -*-

Технічно, опис кодування файла може розміщуватись також і на другому рядку, якщо перший - UNIX-подібна команда hash-bang.

#!/usr/bin/python3
# -*- coding: windows-1252 -*-

Для детальнішої інформації читайте PEP 263: Defining Python Source Code Encodings.


* * *


Для подальшого читання

[ред.]

Про Unicode в Python:

Про Unicode загалом:

Про кодування символів в форматах файлів:

Про рядки та форматування рядків:

Зноски

[ред.]
  1. Тут автор робить натяк на майстер-перстень з "Володаря Перснів". Хто може глянути в книжці вірш про те як три персні віддали ельфам, ще скількись там людям і гномам, і ... рядок про один перстень має стояти замість цього речення, тільки замість слова перстень - кодування

Регулярні вирази

[ред.]

Деякі люди, коли cтикаються з проблемою, думають: "Я знаю, я використаю регулярні вирази". Після цього вони мають дві проблеми.
Джемі Завінські


Отримання потрібної частки з великого масиву тексту може стати викликом. Рядки в Python мають методи для пошуку та заміни: index(), find(), split(), count(), replace() та подібні. Але ці методи обмежені найпростішими випадками. Наприклад, метод index() шукає єдиний, жорстко зафіксований підрядок, і пошук завжди чутливий до регістру. Щоб здійснити нечутливі до регістру пошуки в рядку s, потрібно застосувати s.lower(), чи s.upper() і переконатись що запит написаний в тому ж регістрі. Методи replace() та split() мають ті ж обмеження.

Якщо вашої цілі можна досягти використовуючи методи для роботи з рядками, ви повинні використовувати їх. Вони швидкі, прості, їх просто читати. Про швидкий, простий і читабельний код можна розказувати багато гарного. Але якщо ви використовуєте багато цих функцій в перемішку з операторами if для обробки особливих випадків, чи якщо ви з'єднуєте виклики split() та join() щоб різати та перемішувати ваші рядки, можливо, вам варто буде перейти до регулярних виразів.

Регулярні вирази - це потужний і (переважно) стандартизований спосіб пошуку, заміни та лексичного аналізу тексту з складними шаблонами символів. Хоча, синтаксис регулярних виразів досить щільний і не схожий на звичайний код, але результат може виявитись більш читабельним ніж саморобне рішення, що використовує велику кількість рядкових функцій. Існує навіть спосіб вставити в регулярний вираз коментарів, тому ви можете включати в них документацію.

Якщо ви користувались регулярними виразами в інших мовах (таких як Perl, JavaScript, чи PHP), їх синтаксис в Python може видатись знайомим. Прочитайте документацію до модуля re, щоб отримати огляд доступних функцій та їх аргументів.


* * *


Приклад: Адреси

[ред.]

Ця серія прикладів викликана проблемою з реального життя, з якою я стикнувся на моїй роботі кілька років тому, коли я хотів отримати та стандартизувати адреси вулиць з старої системи перед тим, як імпортувати її в нову. (Бачте, я не просто даю висмоктані з пальця приклади, вони справді корисні). Цей приклад показує як я підійшов до проблеми.

>>> s = '100 NORTH MAIN ROAD'
>>> s.replace('ROAD', 'RD.')                
'100 NORTH MAIN RD.'

Моєю метою було стандартизувати адреси вулиць так, щоб 'ROAD' завжди записувалось скорочено, як 'RD.'. На перший погляд, це виглядає просто, і я подумав, що можна використати метод для роботи з рядками replace(). В будь-якому випадку дані були завжди в верхньому регістрі, тому чутливість до регістру не мала бути проблемою. І в цьому надзвичайно простому прикладі, s.replace() чудово виконала свою роботу.

>>> s = '100 NORTH BROAD ROAD'
>>> s.replace('ROAD', 'RD.')                
'100 NORTH BRD. RD.'

Життя, на жаль, повне контрприкладів, і я швидко виявив це. Проблемою є те, що 'ROAD' з'являється в адресі двічі, один раз як частина імені вулиці 'BROAD', і один раз як ціле слово. Метод replace() бачить ці два випадки, і сліпо замінює обидвох, тим часом, я бачу як мої адреси псуються.

>>> s[:-4] + s[-4:].replace('ROAD', 'RD.')  
'100 NORTH BROAD RD.'

Щоб розв'язати проблему адрес з більш ніж одним підрядком 'ROAD', можна, наприклад, зробити так: шукати 'ROAD' лише в останніх чотирьох символах адреси (s[-4:1]), і не чіпати решту рядка (s[:-4]). Але ви можете бачити, що це вже досить заплутано. (Якщо б ви замінювали 'STREET' на 'ST.', ви повинні були б використовувати s[:-6] та s[-6:].replace(...).) Ви б хотіли повернутись через пів року щоб зневаджувати це? Я знаю що я б не хотів.

>>> import re

Настав час перейти до регулярних виразів. В мові Python, вся функціональність пов'язана з регулярними виразами міститься в модулі re.

>>> re.sub('ROAD$', 'RD.', s)               
'100 NORTH BROAD RD.'

Погляньте на перший параметр: 'ROAD$'. Це простий регулярний вираз, який співставляється 'ROAD' лише тоді, коли знаходить його в кінці рядка. $ означає "кінець рядка". (Існує також протилежний за значенням символ - ^, який означає "початок рядка"). Використовуючи метод re.sub() шукаємо в рядку s співпадіння з регулярним виразом 'ROAD$' та замінюємо його на 'RD.'. Це спрацьовує для ROAD в кінці рядка s, але не спрацьовує для слова BROAD, тому що воно знаходиться посередині.

^ відповідає початку рядка. $ відповідає кінцю рядка.

Продовжуючи історію з очисткою адрес: я невдовзі відкрив, що в попередньому прикладі, співставляння 'ROAD' кінцю рядка, не достатньо добре, бо деякі адреси просто закінчуються назвою вулиці. Це працювало в більшості випадків, але коли назвою вулиці було 'BROAD', регулярний вираз виявляв співпадіння з кінцівкою 'ROAD', а це не те що мені було потрібно.

>>> s = '100 BROAD'
>>> re.sub('ROAD$', 'RD.', s)
'100 BRD.' 
>>> re.sub('\\bROAD$', 'RD.', s)
'100 BROAD'

Що мені справді було потрібно - це співпадіння з словом ROAD на кінці рядка, але тільки тоді, коли це окреме слово, а не частина якогось іншого слова. Щоб виразити це в регулярному виразі використовують символ \b який означає що в цьому місці повинна знаходитись межа (boundary) слова. В Python, це ускладнюється тим фактом, що символ '\' в рядку сам повинен екрануватись. Це іноді називаються "чумою бекслешів", і це є причиною того що писати регулярні вирази в Perl простіше ніж в Python. З іншого боку, Perl змішує регулярні вирази з рештою синтаксису, тому іноді важко відрізнити синтаксичну помилку від помилки в регулярному виразі.

>>> re.sub(r'\bROAD$', 'RD.', s) 
'100 BROAD'

Щоб позбутись надлишку бекслешів, ви можете використати те, що називається "чистим (raw) рядком", додавши перед рядком символ r. Це повідомляє інтерпретатору Python, що нічого в цьому рядку не повинно екрануватись. '\t' - це рядок з одного символу табуляції, а r'\t' - це рядок з двох символів: бекслеша та букви t. Раджу вам завжди використовувати чисті рядки для опису регулярних виразів, бо інакше все робиться занадто заплутаним занадто швидко (а регулярні вирази з самого початку достатньо заплутані).

>>> s = '100 BROAD ROAD APT. 3'
>>> re.sub(r'\bROAD$', 'RD.', s) 
'100 BROAD ROAD APT. 3'

Ох! На жаль, я пізніше знайшов ще більше прикладів, які суперечать моїм міркуванням. В цьому випадку, адреса вулиці містила слово 'ROAD' як окреме слово, але воно не було на кінці рядка, бо адреса містила номер квартири. Через те що 'ROAD' не в кінці рядка виклик re.sub() не здійснював взагалі ніяких замін, і ви отримували незмінний рядок назад, що не те що вам було потрібно.

>>> re.sub(r'\bROAD\b', 'RD.', s)
'100 BROAD RD. APT 3'

Щоб розв’язати цю проблему, я вилучив симовол $ і додав ще один \b. Тепер регулярний вираз читається як "знайди відповідність шаблону 'ROAD' коли це окреме слово де-небудь в рядку", не залежно від того на початку, в кінці чи десь посередині.


* * *


Приклад: Римські числа

[ред.]

Швидше за все ви вже бачили римські числа, навіть якщо ви їх і не впізнали. Їх можна побачити на фасадах старих будинків (MCMXLVI замість "збудовано в 1946), чи при нумерації розділів в деяких книгах. Така система запису чисел з’явилась ще в римській імперії, звідки й назва.

В римській системі числення існує кілька символів, які повторююься і комбінуються різним чином щоб утворювати числа:

  • I - 1
  • V - 5
  • X - 10
  • L - 50
  • C - 100
  • D - 500
  • M - 1000

А ось деякі загальні правила утворення римських чисел:

  • Іноді символи аддитивні. I це 1, II це 2, і III це 3. VI це 6 (буквально, "5 і 1"), VII це 7, і VIII - 8.
  • Символи що позначають степені десяти (I, X, C, та M) можуть повторюватись до трьох разів. Далі потрібно віднімати від наступного за значенням символа, зазвичай кратного 5. Не можна записувати 4 як IIII, замість цього пишуть IV (на 1 менше ніж 5). 40 записується як XL (на 10 менше ніж 50), 41 як XLI, 42 як XLII, 43 як XLIII, а 44 як XLIV (на 10 менше ніж 50, і ще на одиницю менше ніж 5).
  • Іноді символи ... пряма протилежність аддитивним. Ставлячи деякі символи перед іншими, ви віднімаєте їх від загального значення. Наприклад, щоб записати 9, ви віднімаєте 1 від 10, записуючи це як IX. 8 це VIII, але 9 - це IX, бо не можна писати VIIII (дивись попереднє правило). 90 це XC, 900 - CM.
  • Символи що не є степенями 10 не повторюються двічі підряд. 10 записується як X, і ніколи як VV. 100 - завжди C, і ніколи не LL.
  • Римські цифри читаються зліва направо, і порядок символів має велике значення. DC - це 600, а CD - зовсім інше значення - 400 (на 100 менше ніж 500). CI це 101, а IC навіть не є правильним римським числом, бо не можна віднімати 1 одразу від 100. Число 99 записується як XCIX (на 10 менше ніж 100, і ще на 1 менше ніж 10).

Перевірка тисяч

[ред.]

Що потрібно щоб перевірити що рядок є правильним римським числом? Давайте проаналізуємо це по одній цифрі за раз. Так як римські числа завжди записуються від найбільших до найменших цифр, почнемо з найбільших - тисяч. Для чисел починаючи від значення 1000 і вище, тисячі в них записуються послідовністю символів M.

>>> import re 
>>> pattern = '^M?M?M?$'

Цей шаблон складається з трьох частин. ^ співставляється тільки з тим що знаходиться на початку рядка. Без нього відповідності шаблону знаходились би в довільних місцях рядка, а це не те що вам потрібно. Вам навпаки, потрібно переконатись, що символи M, якщо вони є, знаходяться на початку рядка. M? відповідає або символу M, або відсутності будь-яких символів. Так як воно повторене тричі, то шаблону відповідатиме від нуля до трьох символів M підряд. $ співставляється з кінцем рядка. В поєднанні з символом ^ на початку, він означає, що рядок повинен відповідати шаблону повністю, без жодних невідповідних символів на початку чи в кінці.

>>> re.search(pattern, 'M') 
<_sre.SRE_Match object at 0106FB58>

Основою модуля re є функція search(), яка бере регулярний вираз (pattern), і рядок ('M'), і намагається знайти відповідності. Якщо знаходить, то повертає об’єкт який містить методи що описують знайдену відповідність, інакше повертає None. Нас на поки що цікавить лише наявність чи відсутність співпадіння, а це можна вияснити лише просто за значенням яке повертається. 'M' відповідає шаблону, тому що співпадає з першим символом M в шаблоні, а решта (як і він сам) позначені як не обов’язкові.

>>> re.search(pattern, 'MM') 
<_sre.SRE_Match object at 0106C290>

'MM' теж відповідає шаблону, бо співставляється першим двом M?M?, а третє не враховується.

>>> re.search(pattern, 'MMM') 
<_sre.SRE_Match object at 0106AA38>

'МММ' відповідає всім трьом символам шаблону.

>>> re.search(pattern, 'MMMM')

'MMMM' не відповідає шаблону. Перші три символи M утворюють відповідність з шаблоном, але далі регулярний вираз вимагає закінчення рядка (через символ $), а так як рядок ще не закінчився (є четверте M), то повертається None.

>>> re.search(pattern, '') 
<_sre.SRE_Match object at 0106F4A8>

Що цікаво, порожній рядок теж відповідає шаблону, бо всі три символи в ньому необов’язкові.

Перевірка сотень

[ред.]
? означає що входження шаблону перед ним необов’язкове

Сотні перевіряти дещо складніше ніж тисячі, тому що існує кілька взаємновиключних способів їх запису залежно від значення.

  • 100 = C
  • 200 = CC
  • 300 = CCC
  • 400 = CD
  • 500 = D
  • 600 = DC
  • 700 = DCC
  • 800 = DCCC
  • 900 = CM

Тому, існує чотири можливі шаблони:

  • CM
  • CD
  • Від нуля до трьох символів C
  • D після якого іде від нуля до трьох символів C

Останні два можна поєднати:

  • Необов’язкове D після якого іде від нуля до трьох символів C

Наступний приклад покаже як перевірити сотні в римських числах.

>>> import re 
>>> pattern = '^M?M?M?(CM|CD|D?C?C?C?)$'

Цей шаблон починається як попередній, символом відповідності початку рядка ^, і перевіркою тисяч M?M?M?. Далі в дужках іде нова частина, яка описує набір трьох взаємновиключних шаблонів розділених вертикальними рисками: CM, CD, та D?C?C?C?. Парсер регулярних виразів перевіряє ці три шаблони по порядку, бере перший, який містить відповідність рядку, і ігнорує всі інші.

>>> re.search(pattern, 'MCM') 
<_sre.SRE_Match object at 01070390>

'MCM' відповідає шаблону, тому що перше M відповідає, друге і третє необов’язкові, і CM відповідає (тому частини CD і D?C?C?C? навіть не беруться до розгляду). MCM це римський запис числа 1900.

>>> re.search(pattern, 'MD') 
<_sre.SRE_Match object at 01073A50>

'MD' містить відповідність, тому що перше M відповідає рядку, друге і третє ігноруються, а шаблону D?C?C?C? відповідає символу D (три символи C необов’язкові і ігноруються). MD - це римський запис числа 1500.

>>> re.search(pattern, 'MMMCCC') 
<_sre.SRE_Match object at 010748A8>

'MMMCCC' відповідає шаблону, тому що всі три M знаходять відповідність, і шаблон D?C?C?C? відповідає CCC (D необов’язкове і ігнорується). MMMCCC - римський запис числа 3300.

>>> re.search(pattern, 'MCMC')

'MCMC' не відповідає шаблону. Перше M знаходить відповідність, два наступні ігноруються, потім знаходиться відповідність шаблону CM, проте далі іде символ $ який відповідає кінцю рядка, але ми ще не на кінці рядка (є ще один символ C, який не має відповідності). Він не співставляється шаблону D?C?C?C?, тому що вже був використаний взаємновиключний шаблон CM.

>>> re.search(pattern, '') 
<_sre.SRE_Match object at 01071D98>

Що цікаво, порожній рядок все ще відповідає нашому шаблону, бо всі символи M необов’язкові, а порожній рядок відповідає шаблону D?C?C?C? де всі символи теж необов’язкові.

Хух! Бачите як швидко регулярні вирази стають паскудними? А ми розглянули тільки тисячі і сотні в римських числах. Щоправда якщо ви з цим розібрались, то розібратись з десятками і одиницями буде просто, бо вони працюють за такою ж схемою. Але давайте розглянемо інший спосіб запису шаблону.


* * *


Використання синтаксису {n,m}

[ред.]
{1,4} відповідає від 1 до 4 повторень шаблону що знаходиться перед ним

В попередньому розділі ми розглядали шаблони де символам дозволялось входити в рядок від нуля до трьох разів. Існує інший спосіб запису такого шаблону, який вважається більш читабельним. Але давайте спершу глянемо на метод яким ми користувались в попередньому розділі.

>>> pattern = '^M?M?M?$'

Цей шаблон означає що спочатку рядка може стояти M, а може і не обов’язково, і так само необов’язково можуть стояти ще дві букви M, і далі рядок повинен закінчитись.

Альтернативний спосіб записати цей же шаблон виглядає так:

pattern = '^M{0,3}$'

Він означає: на початку рядка має стояти від 0 до 3 символів M, і потім рядок закінчується. Замість 0 і 3 можна ставити будь-які інші числа.

Перевірка десятків та одиниць

[ред.]

Спочатку додамо перевірку десятків:

>>> pattern = '^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)$' 
>>> re.search(pattern, 'MCMXL')
<_sre.SRE_Match object at 0x008EEB48>

Спробую проілюструвати як відбувається зіставлення шаблона з рядком 'MCMXL' дещо іншим способом ніж в оригінальній книжці. Бо при всій повазі до автора оригіналу, він пише багато слів, але з них важко видобувати сенс. Думаю так як напишу я буде наочніше.

'^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)$'

>>> re.search(pattern, 'MCML')
<_sre.SRE_Match object at 0x008EEB48>

'^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)$'

>>> re.search(pattern, 'MCMLX')
<_sre.SRE_Match object at 0x008EEB48>

'^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)$'

>>> re.search(pattern, 'MCMLXXX')
<_sre.SRE_Match object at 0x008EEB48>

'^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)$'

>>> re.search(pattern, 'MCMLXXXX') 
>>>

'^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)$'

Цей рядок не відповідає шаблону, тому що шаблон очікує кінець рядка (виділено червоним), а рядок натомість містить ще один X

(A|B) відповідає або шаблону A або шаблону B, але не обидвом водночас.

Вираз для одиниць працює так само. Я опущу деталі, і покажу одразу кінцевий результат:

>>> pattern = '^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)(IX|IV|V?I?I?I?)$'

Як би він виглядав, якби ми використали новий синтаксис з фігурними дужками? Так як в прикладі нижче.

>>> pattern = '^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$' 
>>> re.search(pattern, 'MDLV')
<_sre.SRE_Match object at 0x008EEB48>

'^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$'

>>> re.search(pattern, 'MMDCLXVI')
<_sre.SRE_Match object at 0x008EEB48>

'^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$'

>>> re.search(pattern, 'MMMDCCCLXXXVIII')
<_sre.SRE_Match object at 0x008EEB48>

'^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$'

>>> re.search(pattern, 'I')
<_sre.SRE_Match object at 0x008EEB48>

'^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$'

Якщо ви читаючи це зрозуміли кожен вираз з першого разу - ви розумніші за автора цієї книжки. Але регулярні вирази можуть бути ще заплутанішими, і інколи зрозуміти їх призначення в програмі дуже важко.

Тому давайте розглянемо інший спосіб запису регулярних виразів, який спрощує їх прочитання іншими людьми.


* * *


Багатослівні регулярні вирази

[ред.]

Дотепер ми мали справу лише з тим, що я називаю "компактними регулярними виразами". Ви вже могли помітити що їх не просто читати, і навіть якщо ви розумієте як вони працюють, це ще не гарантує що ви зможете зробити це через місяці після їх написання. Тому нам потрібна вбудована документація.

Python дозволяє зробити це за допомогою багатослівних регулярних виразів (verbose regular expressions). Вони відрізняються від звичайних двома особливостями:

  • Розділювальні символи ігноруються. Прогалики, табуляції, символи нового рядка в шаблоні не відповідають прогаликам, табуляціям і символам нового рядка в тексті що порівнюється з шаблоном. Вони взагалі не співставляються з жодними символами в тексті. (Якщо вам потрібний прогалик в багатослівному регулярному виразі, доведеться його екранувати поставивши перед ним зворотній слеш.)
  • Коментарі ігноруються. Коментарі в багатослівних регулярних виразах такі самі як коментарі в коді: це все що знаходиться між символом # і кінцем рядка. В даному випадку це коментарі всередині багаторядкового літералу, а не всередині вашого коду, але принцип їх роботи залишається таким самим.

Спробую пояснити все на прикладі. Давайте повернемось до компактного регулярного виразу з яким ми працювали, і спробуємо зробити його багатослівним. Ось так:

>>> pattern = '''
    ^                   # початок рядка
    M{0,3}              # тисячі - від 0 до 3 M
    (CM|CD|D?C{0,3})    # сотні - 900 (CM), 400 (CD), 0-300 (від 0 до 3 C),
                        #            або 500-800 (D, після якого від 0 до 3 C)
    (XC|XL|L?X{0,3})    # десятки - 90 (XC), 40 (XL), 0-30 (від 0 до 3 X),
                        #        або 50-80 (L, після якого від 0 до 3 X)
    (IX|IV|V?I{0,3})    # одиниці - 9 (IX), 4 (IV), 0-3 (I{0,3}),
                        #        або 5-8 (VI{0-3})
    $                   # кінець рядка
    '''
>>> re.search(pattern, 'M', re.VERBOSE) 
<_sre.SRE_Match object at 0x008EEB48>

Найважливіше про що треба пам’ятати при роботі з багатослівними регулярними виразами - що функціям треба передавати додатковий аргумент: re.VERBOSE. Це константа описана в модулі re для позначення того що регулярний вираз який ми передаємо функції - багатослівний. Як ви можете бачити, такий регулярний вираз містить багато розділювальних символів і коментарів що ігноруються. А якщо видалити всі розділювачі та коментар, це буде точно такий самий регулярний вираз, як ми бачили в попередньому параграфі.

>>> re.search(pattern, 'MCMLXXXIX', re.VERBOSE) 
<_sre.SRE_Match object at 0x008EEB48>
   
   ^                   # початок рядка
   M{0,3}              # тисячі - від 0 до 3 M
   (CM|CD|D?C{0,3})    # сотні - 900 (CM), 400 (CD), 0-300 (від 0 до 3 C),
                       #            або 500-800 (D, після якого від 0 до 3 C)
   (XC|XL|L?X{0,3})    # десятки - 90 (XC), 40 (XL), 0-30 (від 0 до 3 X),
                       #        або 50-80 (L, після якого від 0 до 3 X)
   (IX|IV|V?I{0,3})    # одиниці - 9 (IX), 4 (IV), 0-3 (I{0,3}),
                       #        або 5-8 (VI{0-3})
   $                   # кінець рядка
   
>>> re.search(pattern, 'MMMDCCCLXXXVIII', re.VERBOSE) 
<_sre.SRE_Match object at 0x008EEB48>
   
   ^                   # початок рядка
   M{0,3}              # тисячі - від 0 до 3 M
   (CM|CD|D?C{0,3})    # сотні - 900 (CM), 400 (CD), 0-300 (від 0 до 3 C),
                       #            або 500-800 (D, після якого від 0 до 3 C)
   (XC|XL|L?X{0,3})    # десятки - 90 (XC), 40 (XL), 0-30 (від 0 до 3 X),
                       #        або 50-80 (L, після якого від 0 до 3 X)
   (IX|IV|V?I{0,3})    # одиниці - 9 (IX), 4 (IV), 0-3 (I{0,3}),
                       #        або 5-8 (VI{0-3})
   $                   # кінець рядка
   

MMMDCCCLXXXVIII - римський запис числа 3888, і найдовше римське число яке можна записати не використовуючи розширений синтаксис.

>>> re.search(pattern, 'M') 
>>>

А цей рядок не співставляється з шаблоном. Чому? Бо ми не передали йому параметр re.VERBOSE, тому функція re.search розглядає шаблон як компактний регулярний вираз, з значущими розділювачами, і буквальними символами дієзів. Python не вміє автоматично визначати чи є регулярний вираз багатослівним, тому вважає кожен регулярний вираз компактним, якщо ви явно не вкажете протилежне.


* * *


Приклад: аналіз телефонних номерів

[ред.]
\d відповідає будь-якій цифрі (0–9). \D відповідає будь-чому окрім цифр.

Поки що ми зосереджувались лише на повному співставленні шаблонів. Або рядок відповідає шаблону або ні. Але регулярні вирази мають набагато більше можливостей. Коли рядок відповідає шаблону, ми можемо виділяти певні його частини, і визначати що саме співпало.

Цей приклад з’явився з іншої реальної задачі, яку мені довелось розв’язувати на попередній роботі. Задача: аналіз американських телефонних номерів. Клієнт хоче мати можливість вводити номер в будь-якій формі (в єдиному полі), але хоче мати можливість зберігати код регіону, код міста, номер і необов’язковий додаток в базі даних окремо. Я обнишпорив веб і знайшов багато прикладів регулярних виразів створених з цією метою, але жоден з них не був достатньо гнучким.

Ось номери які я повинен був розбирати:

  • 800-555-1212
  • 800 555 1212
  • 800.555.1212
  • (800) 555-1212
  • 1-800-555-1212
  • 800-555-1212-1234
  • 800-555-1212x1234
  • 800-555-1212 ext. 1234
  • work 1-(800) 555.1212 #1234

Досить різні! В кожному з цих випадків мені потрібно визначити що код регіону - 800, код міста 555, а решта номера - 1212. У випадках наявності додатку мені потрібно знати, що додаток - 1234.

Що ж, давайте почнемо розробку методу аналізу телефонних номерів. Наступний приклад демонструє перший крок:

>>> phonePattern = re.compile(r'^(\d{3})-(\d{3})-(\d{4})$')

Регулярні вирази варто читати зліва направо. Цей регулярний вираз починає співставлення з початку рядка, а потім шукає шаблон (\d{3}). Що таке \d{3}? Ну, \d означає "будь-яка десяткова цифра" (від 0 до 9). {3} означає "застосувати попередній шаблон рівно три рази". Це інший вид синтаксису {n,m} який ви бачили раніше. Поміщення шаблону в дужки, означає "запам’ятати цей шматочок як групу, яку я можу отримати пізніше". Потім іде співставлення з дефісом. Потім співставлення з іншою групою з рівно трьох цифр. Потім знову дефіс. Після цього регулярний вираз співставлятиметься з групою з рівно чотирма цифрами, і далі буде вимагатись кінець рядка.

>>> phonePattern.search('800-555-1212').groups()
('800', '555', '1212')

Щоб отримати доступ до груп які парсер регулярних виразів запам’ятав під час роботи, використовуйте метод groups() об’єкта який повертається методом search(). Він поверне кортеж з стількох груп, скільки їх було задано в регулярному виразі. В нашому випадку задано три групи - дві з трьома цифрами, і одна з чотирма.

>>> phonePattern.search('800-555-1212-1234')

Цей регулярний вираз не є кінцевим, бо як ми бачимо, він не розпізнає телефони з розширенням. Тому нам доведеться його доповнити.

>>> phonePattern.search('800-555-1212-1234').groups()
Traceback (most recent call last):
 File "<stdin>", line 1, in <module> 
AttributeError: 'NoneType' object has no attribute 'groups'

І це є причиною, через яку вам не рекомендується створювати в коді ланцюжки з методів search() та groups(). Якщо метод search() не поверне жодних співпадінь, він поверне None, який не є об’єктом що описує співпадіння з регулярним виразом. Виклик None.groups() поверне цілком очевидне виключення: None не містить методу groups(). (Звичайно, це набагато менш очевидно, коли ви отримуєте таке виключення десь з глибини свого коду. Тут я говорю зі свого досвіду.)

>>> phonePattern = re.compile(r'^(\d{3})-(\d{3})-(\d{4})-(\d+)$')

Цей регулярний вираз майже ідентичний попередньому, за винятком одного. Як і раніше, він співставляється з початком рядка, потім група трьох цифр, дефіс, ще одна група з трьох, дефіс і група з чотирьох цифр. Новим тут є ще один дефіс і ще одна група з чотирьох цифр.

>>> phonePattern.search('800-555-1212-1234').groups()
('800', '555', '1212', '1234')

Оскільки тепер регулярний вираз містить чотири групи, метод groups() повертає кортеж з чотирьох елементів.

>>> phonePattern.search('800 555 1212 1234')
>>>

Та, на жаль, цей регулярний вираз все ще не є кінцевим, бо він припускає що цифри в телефонному номері розділені дефісами. А що якщо їх будуть розділяти прогаликами, комами чи крапками? Нам потрібне більш загальне рішення для співставляння з кількома типами розділювачів.

>>> phonePattern.search('800-555-1212')
>>>

Уупс! Цей регулярний вираз крім того, ще й не розпізнає ті номери, які розпізнавав попередній, а це не те чого ми хочемо. Якщо телефон має розширення, ми хочемо знати яке воно, але, якщо його нема, ми все одно хочемо отримати інші частини номера.

Наступний приклад демонструє регулярний вираз, здатний справитись із розділювачами між різними частинами номера.

>>> phonePattern = re.compile(r'^(\d{3})\D+(\d{3})\D+(\d{4})\D+(\d+)$')

А зараз приготуйтесь. Цей регулярний вираз співставляється з початком рядка, потім групою з трьох цифр, потім \D+. Це ще що за чортівня? Ну, \D відповідає будь-якому символу окрім десяткової цифри, а + означає "один чи більше разів". Тому, \D+ співставляється з одним чи більше символами, які не є цифрами. Його ми поставили замість дефіса, щоб розпізнавати найрізноманітніші форми розділювачів.

>>> phonePattern.search('800 555 1212 1234').groups()
('800', '555', '1212', '1234')

Застосування \D+ замість дефіса означає що ви тепер можете розпізнавати номери з прогаликами замість дефісів.

>>> phonePattern.search('800-555-1212-1234').groups()
('800', '555', '1212', '1234')

Звісно, дефіси також все ще працюють.

>>> phonePattern.search('80055512121234')
>>>

На жаль, це все ще не кінцева відповідь, бо вона припускає, що повинен бути хоч якийсь розділювач. А що, якщо телефонний номер введено взагалі без прогаликів чи дефісів?

>>> phonePattern.search('800-555-1212')
>>>

Уупс! Це також все ще не розв’язує проблему з необов’язковістю розширення. Тепер в нас є дві проблеми, але ми можемо розв’язати кожну з них за допомогою одної техніки.

Наступний приклад демонструє регулярний вираз для обробки телефонних номерів без розділювачів.

>>> phonePattern = re.compile(r'^(\d{3})\D*(\d{3})\D*(\d{4})\D*(\d*)$')

Єдине що ми робимо на цьому кроці - замінюємо всі + на *. Замість \D+ між частинами телефонного номера знаходиться \D*. Пам’ятаєте що + означає "один чи більше"? Так от, * означає "нуль чи більше". Тому зараз ми повинні б розпізнавати навіть телефонні номери, в яких взагалі немає розділювальних символів.

>>> phonePattern.search('80055512121234').groups()
('800', '555', '1212', '1234')

Погляньте-но, воно справді працює. Чому? Регулярний вираз співставляється з початком рядка, потім з групою з трьох цифр (800), потім з нулем нецифрових символів, потім з групою з трьох цифр (555), потім з нулем нецифрових символів, потім з групою з чотирьох цифр (1212), потім з нулем нецифрових символів, потім з групою довільної кількості цифр (1234), і нарешті з кінцем рядка.

>>> phonePattern.search('800.555.1212 x1234').groups()
('800', '555', '1212', '1234')

Інші варіації тепер теж проходять: крапки замість дефісів і прогалик з символом x перед розширенням.

>>> phonePattern.search('800-555-1212').groups()
('800', '555', '1212', '')

Ну, і нарешті, ми розв’язали іншу проблему, яка діставала нас довгий час: розширення знову необов’язкове. Якщо розширення номера не знайдене, метод groups() все ще повертає кортеж з чотирьох елементів, але четвертий елемент є просто порожнім рядком.

>>> phonePattern.search('(800)5551212 x1234')
>>>

Ненавиджу приносити погані новини, але ми все ще не закінчили. І яка проблема цього разу? Перед кодом регіону можуть бути додаткові символи, але регулярний вираз припускає, що код регіону - перше, що стоїть на початку рядка. Жодних проблем, ви можете використовувати ту ж техніку "нуль, чи більше нецифрових символів", щоб пропустити початкові символи перед кодом регіону.


Наступний приклад продемонструє як можна справитись з символами перед кодом регіону.

>>> phonePattern = re.compile(r'^\D*(\d{3})\D*(\d{3})\D*(\d{4})\D*(\d*)$')

Регулярний вираз аналогічний виразу в попередньому прикладі, тільки тепер ми намагаємось знайти відповідність шаблону \D* (нуль чи більше нецифрових символів) перед першою групою що запам’ятовується (код регіону). Зауважте, що регулярний вираз не запам’ятовує ці нецифрові символи (бо вони не в дужках). Якщо вони зустрічаються він просто їх пропускає і починає запам’ятовувати групу, як тільки до неї добереться.

>>> phonePattern.search('(800)5551212 ext. 1234').groups()
('800', '555', '1212', '1234')

Тепер ми можемо розпізнавати код регіону, навіть якщо йому передує відкриваюча дужка. (Закриваюча дужка оброблялась і раніше, тому що вона відповідає шаблону "будь-яка кількість нецифрових символів".

>>> phonePattern.search('800-555-1212').groups()
('800', '555', '1212', '')

Просто перевірка щоб переконатись, що ми не поламали нічого, що працювало раніше. Оскільки перші символи є необов’язковими, шаблон співставляється з рядком так:

^\D*(\d{3})\D*(\d{3})\D*(\d{4})\D*(\d*)$
>>> phonePattern.search('work 1-(800) 555.1212 #1234')
>>>

А ось момент, коли через регулярні вирази мені хочеться виколупати собі очі тупим предметом. Чому цей номер не розпізнається? Тому що перед кодом регіону стоїть цифра 1, але ми припустили що всі символи перед кодом регіону будуть нецифровими (\D*). Агррр!

Давайте на секунду повернемось назад. Досі всі регулярні вирази починали співставлятись з початку рядка. Але зараз ви бачите, що на початку рядка може бути невизначена кількість символів, які ми хочемо проігнорувати. Давайте замість того щоб придумати шаблон який відповідатиме тим символам застосуємо інший підхід: просто перестанемо вимагати, аби співставлення відбувалось з початку рядка. Такий підхід демонструється в наступному прикладі:

>>> phonePattern = re.compile(r'(\d{3})\D*(\d{3})\D*(\d{4})\D*(\d*)$')

Зверніть увагу на відсутність символа ^ на початку шаблону. Ми більше не вимагаємо починати співставлення з початку рядка. Регулярний вираз добре попрацює над тим, аби вияснити звідки починається потрібна нам частина рядка, і почне співставлення звідти.

>>> phonePattern.search('work 1-(800) 555.1212 #1234').groups()
('800', '555', '1212', '1234')

Тепер ви можете успішно парсити телефонний номер що включає довільну кількість довільних символів на початку.

>>> phonePattern.search('800-555-1212').groups()
('800', '555', '1212', '')
>>> phonePattern.search('80055512121234').groups()
('800', '555', '1212', '1234')

І ніщо з старого функціоналу не повинно було поламатись. Ми перевірили - працює.

Бачите, як швидко регулярні вирази виходять з під контролю? Прогляньте попередні етапи створення нашого шаблону. Ви можете описати різницю між двома послідовними етапами?

І хоча ми поки що розуміємо наш кінцевий регулярний вираз (і він таки є кінцевим, якщо ви знайдете випадок, для якого він працює неправильно, я не хочу про нього знати), давайте запишемо регулярний вираз з коментарями, перш ніж ви забудете, чому ви прийняли ті рішення, які ви прийняли.

>>> phonePattern = re.compile(r'''
                # не вимагати початок рядка, номер може початись будь-де
    (\d{3})     # код регіону містить три цифри (наприклад '800')
    \D*         # необов’язковий розділювач - довільна кількість нецифрових символів
    (\d{3})     # ще три цифри (наприклад '555')
    \D*         # необов’язковий розділювач
    (\d{4})     # основна частина номера - чотири цифри (наприклад '1212')
    \D*         # необов’язковий розділювач
    (\d*)       # розширення необов’язкове і може містити будь-яку кількість символів
    $           # кінець рядка
    ''', re.VERBOSE)
>>> phonePattern.search('work 1-(800) 555.1212 #1234').groups()
('800', '555', '1212', '1234')

Окрім того що регулярний вираз тепер розтягнувся на багато рядків, він нічим не відрізняється від попереднього, тому не дивно, що він поводиться аналогічно.

>>> phonePattern.search('800-555-1212')
('800', '555', '1212', '')

На всяк випадок, ще одна перевірка. Все працює. Ми завершили. Ура!


* * *


Підсумок

[ред.]

Це тільки вершина айсберга того, що можуть робити регулярні вирази. Іншими словами: незважаючи на те, що я вас ними щойно сильно загрузив, повірте, ви ще нічого не бачили.

Після прочитання цього розділу ви повинні знати наступне:

  • ^ відповідає початку рядка.
  • $ відповідає кінцю рядка.
  • \b відповідає межі слова.
  • \d відповідає будь-якій десятковій цифрі.
  • \D відповідає будь-чому крім цифр.
  • x? відповідає необов’язковому символу x (іншими словами x нуль чи один раз).
  • x* відповідає нулю чи більше символів x.
  • x+ відповідає одному чи більше символів x.
  • x{n,m} відповідає від n до m включно входжень символа x в рядок.
  • (a|b|c) відповідає одному з виразів a, b чи c.
  • (x) загалом створює запам’ятовувану групу. Пізніше можна отримати рядок, який співставився з шаблоном в групі за допомогою методу groups() об’єкта, який повертається з re.search.

Регулярні вирази є надзвичайно потужними, але вони не є коректним рішенням будь-якої проблеми. Вам потрібно вивчити про них достатньо для того, щоб знати коли їх використання окупається, і вони розв’язують ваші проблеми, і коли вони навпаки створюють більше проблем ніж вирішують.

Для подальшого читання

[ред.]

Замикання та генератори

[ред.]

Мій правопис якийсь кульгавий. Узагалі він гарний правопис, тільки от чогось трохи накульгує, і букви заскакують не на свої місця...
Вінні Пух


Вирісши як син бібліотекаря та вчителя англійської, я завжди був зачарований мовами. Не мовами програмування. Ну, так, і мовами програмування, але і природніми мовами теж. Візьмімо наприклад англійську. Англійська - це шизофренічна мова яка запозичує слова з німецької, французької, іспанської та латинської (і це не всі). Насправді "позичила" - не те слово, "награбувала" підходить більше. Чи можливо "асимілювала" - як Борґ. Так, мені подобається ця аналогія.

Ми Борґ. Ваша лінгвістична та етимологічна відмінність буде додана до нашої. Опір марний.

В цьому розділі ми вивчимо множину іменників в англійській мові. А також, функції що повертають інші функції, генератори та поглибимо знання регулярних виразів. Але спершу, давайте поговоримо про те як створювати множину іменників. (Якщо ви ще не прочитали розділ про Регулярні вирази зараз саме пора. Цей розділ передбачає знання основ регулярних виразів, і швидко переходить до деяких поглиблених речей.)

Якщо ви вчили англійську в школі, ви напевне знайомі з основними правилами:

  • Якщо слово закінчується на S, X, чи Z, потрібно додати ES. Bass стає basses, fax стає faxes, а waltz стає waltzes.
  • Якщо слово закінчується на шумну H, додавайте ES; а якщо на тиху H, - просто S. Що таке шумна H? Така яка в комбінації з іншими літерами дає звук який можна чути. Отож coach стає coaches а rash стає rashes, тому що ми можемо чути CH та SH при вимові. Але cheetah стає cheetahs, тому що тут H - тиха.
  • Якщо слово закінчується на Y що звучить як I, змініть Y на IES; якщо ж Y комбінується з голосним щоб звучати якщо щось інше - просто додайте S. Тому vacancy стає vacancies, але day стає days.
  • Якщо ж жодне правило не підійшло, просто додайте S та сподівайтесь на краще.

(Я знаю, існує багато винятків. Man стає men і woman стає women, але human стає humans. Mouse стає mice і louse стає lice, але house стає houses. Knife стає knives і wife стає wives, але lowlife стає lowlifes. І навіть не дайте мені розпочати про слова що є одночасно в множині і однині, як наприклад sheep, deer, та haiku.)

Інші мови, звичайно, цілком інші.

Давайте створимо бібліотеку мови Python, яка буде автоматично створювати множину іменників в англійській мові. Ми почнемо з вищезгаданих чотирьох правил, але пам'ятайте що неминуче доведеться додати більше.


* * *


Я знаю, давайте використаємо регулярні вирази!

[ред.]

Отож, коли ви дивитесь на слова, принаймі в англійській мові це означає що ви дивитесь на послідовності символів. У нас є правила які кажуть що потрібно знаходити певні комбінації символів, а потім робити з ними різні речі. Це звучить як робота для регулярних виразів!


import re

def plural(noun):          
    if re.search('[sxz]$', noun):

Це регулярний вираз, але він використовує синтаксис який ви не побачите в регулярних виразах. Квадратні дужки означають "співставити з одним з перелічених символів". Отож, [sxz] означає "s, або x, або z", але лише один з них. $ повинен бути знайомим - він співставляється з кінцем рядка. Разом вони перевіряють чи рядок noun закінчується на s, x, або z.

        return re.sub('$', 'es', noun)
    elif re.search('[^aeioudgkprt]h$', noun):
        return re.sub('$', 'es', noun)      
    elif re.search('[^aeiou]y$', noun):      
        return re.sub('y$', 'ies', noun)    
    else:
        return noun + 's'

Функція re.sub() виконує заміну рядків на основі регулярних виразів.


Давайте поглянемо на заміну з регулярними виразами трохи детальніше.

>>> import re
>>> re.search('[abc]', 'Mark')
<_sre.SRE_Match object at 0x001C1FA8>

Рядок 'Mark' містить a, b чи c.

>>> re.sub('[abc]', 'o', 'Mark')
'Mork'

Ок, тепер давайте знайдемо a, b чи c і замінимо на o. Mark перетворюється на Mork.

>>> re.sub('[abc]', 'o', 'rock')
'rook'

Ця ж функція замінює rock на rook.

>>> re.sub('[abc]', 'o', 'caps')
'oops'

Можна було б подумати що вона перетворить caps в oaps, але ні re.sub замінює всі співпадіння, а не тільки перше. Тому цей вираз перетворює caps в oops, тому що і c і a відповідають шаблону і замінюються на o.


А тепер повернімось назад до функції plural()...

def plural(noun):          
    if re.search('[sxz]$', noun):            
        return re.sub('$', 'es', noun)

Тут ми замінюємо кінець рядка (шаблон $) рядком es. Іншими словами, додаємо es до рядка. Можна досягти цього ж, за допомогою конкатенації рядків, наприклад noun + 'es', але я вирішив використати регулярні вирази, з причин які стануть зрозумілими пізніше в розділі.

    elif re.search('[^aeioudgkprt]h$', noun):

Уважно придивіться до цього нового регулярного виразу. Знак ^ розміщений першим всередині квадратних дужок означає дещо особливе: доповнення. [^abc] означає "всі символи крім a, b, чи ". Тому [^aeioudgkprt] означає будь-який символ, окрім a, e, i, o, u, d, g, k, p, r, t. Після одного з таких символів повинна йти h, за якою рядок закінчується. Ми шукаємо слова що закінчуються на H, і вона вимовляється вголос.

        return re.sub('$', 'es', noun)
    elif re.search('[^aeiou]y$', noun):

Аналогічно тут: знайти слова що закінчуються на Y, де символ перед Y не є a, e, i, o, чи u. Шукаємо слова що закінчуються на Y, яке звучить як I.

        return re.sub('y$', 'ies', noun)    
    else:
        return noun + 's'

Давайте розглянемо такі регулярні вирази більш детально.

>>> import re
>>> re.search('[^aeiou]y$', 'vacancy')
<_sre.SRE_Match object at 0x001C1FA8>

vacancy розпізнається цим регулярним виразом тому що закінчується на cy, а c не a, не e, i, o, чи u.

>>> re.search('[^aeiou]y$', 'boy')
>>>
>>> re.search('[^aeiou]y$', 'day')
>>>

boy не розпізнається, тому що закінчується на oy, а ми спеціально наголосили що перед y не повинно йти o. day не розпізнається тому що закінчується на ay.

>>> re.search('[^aeiou]y$', 'pita') >>>

pita не розпізнається тому що взагалі не закінчується на y.

>>> re.sub('y$', 'ies', 'vacancy')
'vacancies'
>>> re.sub('y$', 'ies', 'agency')
'agencies'

Цей регулярний вираз перетворює vacancy в vacancies і agency в agencies, а це якраз те що нам потрібно. Правда варто зауважити що він також перетворить boy на boies, але цього не станеться в функції тому що ми спочатку виконали re.search щоб з’ясувати чи взагалі потрібно викликати re.sub.

>>> re.sub('([^aeiou])y$', r'\1ies', 'vacancy')
'vacancies'

Побічно хочеться вказати на те що можна об’єднати два регулярні вирази (один що перевіряє чи можна застосовувати правило і інший що застосовує правило) в єдиний регулярний вираз. Його можна побачити в коді вище. Більша його частина повинна б бути знайомою, він використовує запам’ятовувану групу, яку ми вивчили в прикладі з аналізом телефонних номерів. Ця група використовується для того щоб запам’ятати символ перед y. Після цього в рядку що підставляється ми використовуємо синтаксис \1, який означає "поклади першу групу яку ми запам’ятали прямо тут". В даному випадку перед y ми запам’ятали c, і коли робиться заміна на місці c так і залишається c, а замість y ставиться ies. (Якщо в нас є більше одної запам’ятовуваної групи, можна використати \2, \3 і так далі.)

Заміни з допомогою регулярних виразів досить гнучкі, і синтаксис \1 робить їх ще гнучкішими. Але об’єднання всієї операції в один регулярний вираз робить його важчим для читання, і не відповідає прямо способу нашого опису правил утворення множини. Ми з самого початку описали правила на зразок "Якщо слово закінчується на S, X, чи Z, потрібно додати ES." А якщо ми подивимось на нашу функцію то ми побачимо два рядки коду в яких написано "Якщо слово закінчується на S, X, чи Z, потрібно додати ES." Це важко виразити більш прямо.


* * *


Список функцій

[ред.]

Зараз ви додасте рівень абстракції. Ви почали з опису списку правил: якщо це, зроби те, інакше переходь до наступного правила. Давайте тимчасово ускладнимо частину програми щоб зробити іншу частину простішою.

import re

def match_sxz(noun):
    return re.search('[sxz]$', noun)

def apply_sxz(noun):
    return re.sub('$', 'es', noun)

def match_h(noun):
    return re.search('[^aeioudgkprt]h$', noun)

def apply_h(noun):
    return re.sub('$', 'es', noun)

def match_y(noun):
    return re.search('[^aeiou]y$', noun)
        
def apply_y(noun):
    return re.sub('y$', 'ies', noun)

def match_default(noun):
    return True

def apply_default(noun):
    return noun + 's'

Тепер кожен регулярний вираз що визначає чи можна застосовувати правило є окремою функцією що викликає метод re.search().

Кожне застосування правила теж окрема функція яка викликає re.sub() для виконання перетворення.

rules = ((match_sxz, apply_sxz),
         (match_h, apply_h),
         (match_y, apply_y),
         (match_default, apply_default)
         )

Замість однієї функції (plural()) з багатьма правилами, ми маємо структуру даних з цими правилами, яка є просто послідовністю пар функцій.

def plural(noun):
    for matches_rule, apply_rule in rules:
        if matches_rule(noun):
            return apply_rule(noun)

Так як правила були винесені в окрему структуру даних, нова функція plural() зменшується аж до кількох рядків коду. Використовуючи цикл for можна вибирати з нашої структури по парі правил за раз (одне для визначення можливості застосування, інше для заміни). На першій ітерації циклу, matches_rule отримає значення match_sxz, а apply_rule отримає apply_sxz. На другій ітерації (якщо припустити що до неї дійде), matches_rule отримає значення match_h, а apply_rule - apply_h. Функція обов’язково колись щось поверне, тому що останнє правило (match_default) просто повертає True, що означає що відповідне правило apply_default обов’язково застосується.

Змінна rules є послідовністю пар функцій

Такий підхід працює тому що все в Python - об’єкти, включно з функціями. Структура даних rules місить функції - не імена функцій, а самі функції. Коли відбувається ітерація циклу for - змінні matches_rule та apply_rule стають функціями які можна викликати. На першій ітерації циклу це евівалентно виклику matches_sxz(noun), і якщо він поверне співпадіння, виклику apply_sxz(noun).

Якщо додатковий рівень абстракції вас заплутав, спробуйте розгорнути функцію, щоб побачити еквівалентність. Ввесь цикл for еквівалентний наступному

def plural(noun):
    if match_sxz(noun):
        return apply_sxz(noun)
    if match_h(noun):
        return apply_h(noun)
    if match_y(noun):
        return apply_y(noun)
    if match_default(noun):
        return apply_default(noun)

Перевагою тут є те, що функція plural() тепер спрощується. Вона бере послідовність правил що описані деінде, і ітерує по них узагальнено:

  1. Візьми наступне правило.
  2. Правило можна застосовувати? Тоді застосуй, і поверни результат.
  3. Не застосовується? Повернись до першого кроку.

Правила можна описувати будь-де. Функції plural() все одно.

Чи додавання додаткового рівня абстракції було того варте? Ну, поки що ні. Давайте подумаємо що потрібно щоб додати нове правило до функції. В першому прикладі це вимагатиме додавання оператора if в функцію plural(). А зараз це вимагатиме додавання двох функцій, і оновлення послідовності rules для того щоб визначити коли відносно інших функцій нові будуть викликатись.

Але це лише сходинка до наступного параграфу. Продовжуємо...


* * *


Список шаблонів

[ред.]

Опис окремих іменованих функцій для кожного правила не обов’язковий, тому що ви ніколи не викликаєте їх напряму, а додаєте в послідовність правил, і викликаєте з цієї послідовності. Більше того, всі функції дуже подібні. Всі функції що визначають чи застосовувати правило викликають re.search(), а всі функції що застосовують правило викликають re.sub(). Давайте виокремимо регулярні вирази аби створення нових правил було простішим.


import re

def build_match_and_apply_functions(pattern, search, replace):
    def matches_rule(word):
        return re.search(pattern, word)

build_match_and_apply_functions() - функція що будує інші функції динамічно. Вона приймає параметри pattern, search та replace, а потім описує функцію matches_rule() яка викликає re.search() з шаблоном що був переданий функції параметром pattern, і word - параметром функції matches_rule() яку ми конструюємо.

    def apply_rule(word):
        return re.sub(search, replace, word)

Функція що застосовує правило будується аналогічно. Вона приймає один параметр, і викликає re.sub() з параметрами search та replace які передаються функції build_match_and_apply_functions(), та параметром функції apply_rule(), яку ми конструююємо - word. Підхід з використанням значень параметрів зовнішньої функції називається замиканням (англ. closure). По суті, ми описуємо константи всередині функції що будується: вона приймає один параметр, проте використовує додаткові два, значення яких задається при описі функції.

    return (matches_rule, apply_rule)

Ну, і насамкінець, функція build_match_and_apply_functions() повертає пару значень: дві функції які щойно були створені. Константи описані всередині цих функцій (pattern в функції matches_rule() та, search і replace в функції apply_rule()) залишаються разом з функціями навіть після того, як їх повернули назовні функції build_match_and_apply_functions(). Це неймовірно круто.


Якщо це вас заплутало (а воно й повинно було, бо це дуже дивні речі), можливо стане зрозуміліше, коли ви побачите як це використовувати.


patterns = \
  (
    ('[sxz]$',           '$',  'es'),
    ('[^aeioudgkprt]h$', '$',  'es'),
    ('(qu|[^aeiou])y$',  'y$', 'ies'),

Наші "правила" утворення множини тепер задаються як кортеж кортежів рядків (не функцій). Перший рядок в кожній групі - це регулярний вираз який буде використовуватись в re.search() для визначення того чи можна застосовувати правило. Другий і третій рядок в кожній групі - регулярні вирази пошуку й заміни що будуть використовуватись в re.sub() для переведення іменника в множину.

    ('$',                '$',  's')
  )

А тут, в останньому правилі є маленька зміна. В попередньому прикладі, функція match_default() просто повертала True, що означало, що якщо жодне з правил не спрацювало, ми просто додамо s в кінець слова. Цей приклад робить дещо функціонально еквівалентне. Останній регулярний вираз перевіряє чи в слова є кінець ($ співставляється з кінцем рядка). Звичайно, кожен рядок має кінець, навіть порожній, тому пошук такого шаблону завжди дає позитивний результат. Тому, він служить тій же цілі що й функція match_default() яка завжди повертала True: гарантує що якщо жодне з попередніх правил не виконалось, то останнє обов’язково виконається, і додасть s в кінець слова.

rules = [build_match_and_apply_functions(pattern, search, replace)
         for (pattern, search, replace) in patterns]

Це магічний рядок. Він бере послідовність рядків в шаблонах, і перетворює їх в послідовність функцій. Як? Передаючи рядки в функцію build_match_and_apply_functions(). Тобто, бере кожну трійку рядків, і викликає build_match_and_apply_functions() з ними як аргументами. Функція build_match_and_apply_functions() повертає кортеж що містить дві функції. Це означає що змінна rules функціонально еквівалентна попередньому прикладу: список кортежів, в якому кожен кортеж це пара функцій. Перша функція це функція що визначає чи можна застосовувати правило, а друга його застосовує.


Закінчуючи описувати теперішню версію скрипта, поглянемо на функцію plural().

def plural(noun):
    for matches_rule, apply_rule in rules:
        if matches_rule(noun):
            return apply_rule(noun)

Так як список rules такий самий як і в попередньому прикладі (насправді, він точно такий самий), нічого дивного в тому що функція plural() зовсім не змінилась. Вона цілком загальна, приймає список функцій - правил, і викликає їх по порядку. Їй все одно як ці функції були описані. В попередньому прикладі вони описані як окремі іменовані функції. Зараз вони будуються динамічно, з результатів застосування функції build_match_and_apply_functions() до елементів списку що містить шаблони. Але це не має значення, бо функція plural() все одно працює так само як і раніше.


* * *


Файл шаблонів

[ред.]

Ви виокремили ввесь код що дублюється, і додали достатньо абстрацій для того щоб правила утворення множини можна було описувати в списку рядків. Наступним логічним кроком було б взяти ці рядки, і винести їх в окремий файл, де їх можна буде редагувати окремо від коду який ними користується.

Спершу, давайте створимо текстовий файл який міститиме потрібні правила. Ніяких вигадливих структур даних, просто рядки розділені пропусками записані в три колонки. Назвемо цей файл plural4-rules.txt.

[sxz]$               $    es
[^aeioudgkprt]h$     $    es
[^aeiou]y$          y$    ies
$                    $    s

Тепер давайте подивимось як можна використати цей файл.


import re

def build_match_and_apply_functions(pattern, search, replace):
    def matches_rule(word):
        return re.search(pattern, word)
    def apply_rule(word):
        return re.sub(search, replace, word)
    return (matches_rule, apply_rule)

Функція build_match_and_apply_functions() не змінилась. Ви все ще користуєтесь замикання щоб динамічно створювати пари функцій які використовують змінні передані ззовні.

rules = []
with open('plural4-rules.txt', encoding='utf-8') as pattern_file:

Глобальна функція open() відкриває файл, та повертає файловий об'єкт. В даному випадку, файл що відкривається містить вирази для переведення іменників в множину. Оператор with створює те що називають контекстом: коли блок закінчується, Python автоматично закриє файл, навіть якщо всередині блоку був згенерований виняток. Ви дізнаєтесь більше про блок with та файлові об'єкти у розділі Файли.

    for line in pattern_file:

Вираз for line in <fileobject> читає дані з відкритого файлу, по рядочку за раз, і присвоює текст рядка змінній line. Ви дізнаєтесь більше про читання з файлів у розділі Файли.

        pattern, search, replace = line.split(None, 3)

Кожен рядок в файлів насправді містить три значення, але вони відокремлені табуляцією чи пропусками. Щоб їх розділити використайте метод рядка split(). Першим аргументом у split() передається None, що означає "розділяти по будь-якому порожньму місці, немає значення з пробілів чи табуляцій”. Другий аргумент - 3, і він означає "розділювати тричі, а потім облишити решту рядка. Рядок на зразок [sxz]$ $ es буде розбито на список рядків ['[sxz]$', '$', 'es'], що означатиме що змінній pattern присвоять '[sxz]$', search присвоять '$', а replace - 'es'. Ось так багато робить такий короткий рядок коду.

        rules.append(build_match_and_apply_functions(
                pattern, search, replace))

Ну і нарешті ми передаємо змінні pattern, search, та replace в функцію build_match_and_apply_functions() яка повертає кортеж функцій. Ми додаємо цей кортеж в список правил, і цей список в кінцевому результаті rules містять список функцій який очікує функція plural().


Покращення тут полягає в тому що ми повністю виокремили правила в зовнішній файл, і він може розроблятись окремо від коду що його використовує. Код це код, дані це дані, і життя чудове.


* * *


Генератори

[ред.]

Хіба б не було чудово мати загальну функцію plural() яка парсить файл правила? Отримати правила, перевірити на співпадіння, застосувати відповідне перетворення, перейти до наступного правила. Це все що що функція plural() повинна б робити, і це все що вона буде робити.

def rules(rules_filename):
    with open(rules_filename, encoding='utf-8') as pattern_file:
        for line in pattern_file:
            pattern, search, replace = line.split(None, 3)
            yield build_match_and_apply_functions(pattern, search, replace)

def plural(noun, rules_filename='plural5-rules.txt'):
    for matches_rule, apply_rule in rules(rules_filename):
        if matches_rule(noun):
            return apply_rule(noun)
    raise ValueError('no matching rule for {0}'.format(noun))

Як вбіса це працює? Давайте спершу розглянемо інтерактивний приклад.


>>> def make_counter(x):
...     print('entering make_counter') 
...     while True: 
...         yield x 
...         print('incrementing x') 
...         x = x + 1 
...

The presence of the yield keyword in make_counter means that this is not a normal function. It is a special kind of function which generates values one at a time. You can think of it as a resumable function. Calling it will return a generator that can be used to generate successive values of x.

Присутність ключового слова yield в make_counter означає що ця функція незвичайна. Це особливий вид функції яка генерує значення по одному за раз. Можете думати про неї як про повторювану функцію. Її виклик повертає генератор який можна використовувати щоб згенерувати послідовні значення x.

>>> counter = make_counter(2)

Щоб створити екземпляр генератора make_counter просто викличте його як звичайну функцію. Зауважте що це насправді не виконує код функції. Ми знаємо про це тому що в першому рядку функції make_counter() ми викликаємо print(), але нічого не надрукувалось.

>>> counter
<generator object at 0x001C9C10>

Функція make_counter() повертає генераторний об'єкт.

>>> next(counter)
entering make_counter
2

Функція next() бере генераторний об'єкт і повертає наступне згенероване значення. Першого разу при виклику next() для генератора, він виконує код в make_counter() аж до першої команди yield, потім повертає значення яке було повернено. В нашому випадку це 2, тому що на самому початку ми створили генератор викликавши make_counter(2).

>>> next(counter)
incrementing x
3

Послідовно викликаючи next() з одним і тим самим генераторним об'єктом продовжує виконання з того місця де воно завершилось і продовжується аж поки не зустрінеться з наступним оператором yield. Всі змінні, локальнимй стан і т.п. зберігаються при виконанні yield і відновлюються при виклику next(). Наступний рядок коду що очікує виконання викликає print(), який друкує збільшуване значення x. Після чого виконується присвоєння x = x + 1. Після чого цикл продовжується, і перший оператор на який ми в ньому натикаємось - це знову yield x, який зберігає стан всього і повертає поточне значення x (тепер вже 3).

>>> next(counter)
incrementing x
4

Коли ми знову викликаємо next(counter), все відбувається так само, але цього разу x має значення 4.


Так як make_counter містить нескінченний цикл, ми можемо в теорії робити це безперервно, і він буде просто збільшувати x та видавати значення. Але давайте замість цього розглянемо більш продуктивне використання генераторів.

Генератор чисел Фібоначчі

[ред.]
yield зупиняє виконання, next() продовжує з того місця на якому зупинились
def fib(max):
    a, b = 0, 1

Послідовність Фібоначі - це послідовність чисел де кожне число є сумою двох попередніх. Вона починається з 0 та 1, починає повільно зростати, а далі зростає все швидше і швидше. Щоб почати послідовність нам потрібно дві змінні зі значеннями 0 та 1.

    while a < max:
        yield a

a - поточне число послідовності, тому ми його повертаємо.

        a, b = b, a + b

b - наступне число послідовності, тому присвоюємо його a, але також обчислюємо наступне значення (a+b) і присвоюємо його b для подальшого використання. Зауважте що це відбувається одночасно: якщо a = 3 а b = 5, тоді a, b = b, a + b присвоїть a значення 5 (попереднє значення b) а b - 8 (сума попередніх значень a та b).

Отож в нас є функція що послідовно випльовує числа Фібоначчі. Звісно, це можна зробити і з рекурсією, але в такий спосіб це простіше читати. Також, це гарно працює з циклами for.


>>> from fibonacci import fib
>>> for n in fib(1000):

Можна використовувати генератори на зразок fib() прямо. Цикл for автоматично буде викликати функцію next() щоб отримувати значення з генератора fib() та присвоювати їх індексу циклу.

...     print(n, end=' ') 
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987

Кожного разу в тілі циклу n отримує нове значення з команди yield всередині функції fib(), і все що вам потрібно зробити це його надрукувати. Як тільки в fib() закінчаться числа (a стане більшим за max, яке в даному випадку дорівнює 1000), і цикл передбачливо закінчується.

>>> list(fib(1000)) 
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987]

Це корисна ідіома: передати генератор в функцію list() і вона проітерується по всьому генератору (як і цикл в попередньому прикладі) та поверне список зі всіма значеннями.

Генератор правил утворення множини

[ред.]

Давайте повернемось і поглянемо як працює функція plural().

def rules(rules_filename):
    with open(rules_filename, encoding='utf-8') as pattern_file:
        for line in pattern_file:
            pattern, search, replace = line.split(None, 3)

Тут ніякої магії. Пам'ятаєте що рядки в файлі з правилами містять по три значення розділені пропусками, тому ми можемо використати line.split(None, 3) щоб отримати ці "колонки" та присвоїти їх локальним змінним.

            yield build_match_and_apply_functions(pattern, search, replace)

І ось тут ми використовуємо yield. Яке значення ми викидаємо? Дві функцкії, створені динамічно за допомогою нашого старого друга, функції build_match_and_apply_functions(). Іншими словами, rules() - це генератор що випльовує функції match та apply за вимогою.

def plural(noun, rules_filename='plural5-rules.txt'):
    for matches_rule, apply_rule in rules(rules_filename):
        if matches_rule(noun):
            return apply_rule(noun)
    raise ValueError('no matching rule for {0}'.format(noun))

Так як rules() - це генератор, його можна використовувати прямо в циклі. При першій ітерації циклу ми викличемо функцію rules(), яка відкриє файл шаблонів, прочитає перший рядок, динамічно створить потрібні функції з шаблонів, і поверне створені функції нам. В наступній ітерації циклу for, ми продовжимо рівно з того місця де ми покинули функцію rules(). Перше що вона зробить - це прочитає наступний рядок з файлу (який все ще відкритий), динамічно збудує наступні функції на основі прочитаних шаблонів, і поверне ці дві функції.


Що ми виграли використовуючи генератори? Час запуску. В попередньому прикладі, коли ми імпортували модуль plural4, він читав ввесь файл шаблонів, і створював список всіх можливих функцій ще тоді коли ми навіть не думали викликати функцію plural(). З генераторами ви можете робити все ліниво: прочитати перше правило, створити функції, спробувати їх, і якщо вони підійдуть не потрібно буде навіть читати решту файла чи створювати будь-які інші функції.

Що ми втратили? Продуктивність! Щоразу як ми викликаємо функцію plural(), генератор rules() починає все з початку, що означає ще одне відкривання файлу з шаблонами, і читання з самого початку, по рядку за раз.

А що якщо б ми могли поєднати краще з обох підходів: мінімальний час запуску (не виконувати ніякий код при імпорті), і максимальну продуктивність (не створювати одні й ті самі функції заново). Ну, і все ще тримати правила в окремому файлі (тому що код це код, а дані це дані), поки звісно не доведеться читати один і той самий рядок вдруге.

Щоб це зробити, нам потрібно буде створити власний ітератор. Але перед цим потрібно буде вивчити класи мови Python.


* * *


Для подальшого читання

[ред.]

Класи та ітератори

[ред.]

Захід є Захід, а Схід є Схід, і їм не зійтися вдвох
Редьярд Кіплінг


Занурення

[ред.]

Ітератори це "таємний соус" мови Pythоn 3. Вони всюди, в основі всього, просто зазвичай невидимі. Вирази над структурами - лише проста форма ітераторів. Генератори - лише проста форма ітераторів. Функції що використовують yield це лише гарний, компактний спосіб побудови ітератора без побудови ітератора. Давайте я покажу що я під цим маю на увазі.

Пам'ятаєте генератор чисел Фібоначчі? Ось побудований з нуля ітератор:

class Fib:
    '''iterator that yields numbers in the Fibonacci sequence'''

    def __init__(self, max):
        self.max = max

    def __iter__(self):
        self.a = 0
        self.b = 1
        return self

    def __next__(self):
        fib = self.a
        if fib > self.max:
            raise StopIteration
        self.a, self.b = self.b, self.a + self.b
        return fib

Щож давайте розбирати по рядку за раз.

class Fib:

class? Що таке клас?


* * *


Опис класів

[ред.]

Python повністю об'єктно-орієнтований: ви можете описувати власні класи, наслідуватись від власних чи вбудованих класів, і створювати екземпляри описаних класів.

Описувати класи в Python просто. Як і з функціями, немає окремого опису інтерфейсу. Просто оголосіть клас і починайте кодити. Клас в Python починається з зарезервованого слова class, за яким іде ім'я класу. Технічно, це все що потрібно, тому що клас не обов'язково повинен наслідуватись від іншого класу.

class PapayaWhip:

Цей клас називається PapayaWhip[1], і не наслідується від інших класів. Імена класів зазвичай пишуться з великої букви, подібно до КожнеСловоЯкУЦьомуОтут, але це лише домовленість, а не вимога.

    pass

Ви можливо вже й самі здогадались, але все в класі пишеться з відступом, так само як і код всередині функцій, умовних операторів, циклів, чи інших блоків коду. Перший рядок без відступа вважається рядком за межами класу.


Наш клас PapayaWhip не описує жодних методів чи атрибутів, але синтаксично необхідно помістити що-небудь в опис, тому ми використали ключове слово pass. Це слово означає "проходьте, тут нема на що дивитись". Це команда яка нічого не робить, і її зручно підставляти коли потрібно зробити заготовку функції.

Інструкція pass в Python аналогічна парі порожніх фігурних дужок ({}) в Java чи C.

Багато класів наслідуються від інших класів, але даний ні. Багато класів описують методи, але цей ні. Взагалі немає нічого що клас в Python обов'язково повинен мати, окрім імені. Наприклад програмісти C++ можуть здивуватись що класи в Python не мають явних конструкторів та деструкторів. Щоправда хоча це й не обов'язково, класи в Python можуть мати щось подібне до конструктора: метод __init__().


Метод __init__()

[ред.]

Цей приклад показує ініціалізацію класу Fib з використанням методу __init__.

class Fib:
    '''iterator that yields numbers in the Fibonacci sequence'''

Класи можуть (і повинні б) мати документацію, так само як модулі та функції.

    def __init__(self, max):

Метод __init__() викликається негайно після того як створюється екземпляр класу. Спокусливо називати цей метод "конструктором" класу, але це технічно невірно. Це спокусливо тому що він виглядає як конструктор в C++ (за домовленістюю, метод __init__() повинен бути першим в списку методів класу), поводиться як конструктор (це перший код що виконується в новоствореному екземплярі класу), і навіть звучить як конструктор. Але це не вірно, тому що об'єкт було створено ще до того як було викликано метод __init__(), і в нас вже є діюче посилання на новостворений екземпляр класу.

Перший аргумент будь-якого методу класу, включаючи метод __init__(), це завжди посилання на поточний екземпляр класу, за домовленістю його називають self. Цей аргумент замінює роль зарезервованого слова this в C++ чи Java, але self не є зарезервованим словом в Python, просто домовленістю з іменування. Тим не менш, будь-ласка не називайте його якось інакше ніж self, це дуже сильна домовленість.

У всіх методах класу, self посилається на екземпляр метод якого викликали. Але в особливому випадку метода __init__() екземпляр чий метод був викликаним також є новоствореним об'єктом. І хоча при описі метода необхідно описувати параметр self явно, його не потрібно передавати при виклику метода, Python передасть його для нас автоматично.


* * *


Створення екземплярів класу

[ред.]

Створення екземплярів класу в Python досить прямолінійне. Щоб створити екземпляр класу, просто викличте клас так наче б він був функцією, передавши як аргументи все що необхідно функції __init__(). Значення яке буде повернутим і буде новоствореним об'єктом.

>>> import fibonacci2
>>> fib = fibonacci2.Fib(100)

Ми створюємо екземпляр класу Fib (описаного в модулі fibonacci2) і присвоюємо новостворений екземпляр змінній fib. Ми передаємо один параметр, 100, який буде переданий як аргумент max в метод __init__().

>>> fib
<fibonacci2.Fib object at 0x00DB8810>

fib тепер екземпляр класу Fib.

>>> fib.__class__
<class 'fibonacci2.Fib'>

Кожен екземпляр класу має вбудований атрибут __class__, який посилається на клас об'єкта. Програмісти Java можливо знайомі з класом Class, який містить методи на зразок getName() таd getSuperclass() щоб отримувати метаінформацію про об'єкт. В Python такі дані доступні через атрибути, але ідея загалом така сама.

>>> fib.__doc__
'iterator that yields numbers in the Fibonacci sequence'

Можна звертатись до документації екземпляра так само як і з функціями чи модулями. Всі екземпляри класу діляться спільним рядком документації.

В Python щоб створити екземпляр, просто викличте клас як функцію. Не потрібно явного оператора new як в C++ чи Java.


* * *


Змінні екземпляра

[ред.]

І перейдемо до наступного рядка класу:

class Fib:
    def __init__(self, max):
        self.max = max

Що таке self.max? Це змінна екземпляра. Це зовсім інша змінна ніж max, яка була передана як аргумент в метод __init__(). self.max "глобальна" для екземпляра. Це означає що доступ до неї можна отримати з інших методів.

class Fib:
    def __init__(self, max):
        self.max = max

self.max створюється в методі __init__()...

    .
    .
    .
    def __next__(self):
        fib = self.a
        if fib > self.max:

... і використовується з метода __next__().

Змінні екземпляра пов'язані лише з одним екземпляром класу. Наприклад якщо ми створимо два екземпляри класу Fib з різними максимальними значеннями, вони пам'ятатимуть кожен про свою змінну.

>>> import fibonacci2
>>> fib1 = fibonacci2.Fib(100)
>>> fib2 = fibonacci2.Fib(200)
>>> fib1.max
100
>>> fib2.max
200


* * *


Ітератор Фібоначчі

[ред.]
Всі три із згадуваних методів класу, __init__, __iter__, та __next__, починаються та закінчуються парою символів підкреслювання (_). Чому так? В цьому немає ніякої магії, але зазвичай це означає що це "спеціальні методи". Єдине що "спеціальне" в спеціальних методах - це те що вони не викликаються напряму, Python викликає їх коли ви використовуєте якийсь інший синтаксис на класі чи на його екземплярі. Більше про спеціальні методи.

Тепер ви готові до того щоб навчитись створювати ітератор. Ітератор це просто клас який описує метод __iter__().

class Fib:

Щоб створити ітератор з нуля, Fib повинен бути класом а не функцією.

    def __init__(self, max):
        self.max = max

"Виклик" Fib(max) це насправді створення екземпляру цього класу і виклик його метода __init__() з аргументом max. Метод __init__() зберігає максимальне значення як змінну екземпляру щоб інші методи могли використати її пізніше.

    def __iter__(self):
        self.a = 0
        self.b = 1
        return self

Метод __iter__() викликається щоразу як хтось викликає iter(fib). (Як ви побачите назабаром, цикл for може викликати це автоматично, але ви також можете зробити це вручну.) Піля виконання ініціалізації перед початком ітерації (в даному випадку встановлення значень лічильників self.a та self.b), метод __iter__() може повернути будь-який об'єкт що реалізує метод __next__(). В цьому (та й у більшості інших) випадку, __iter__() просто повертає self, так як цей клас реалізує власний метод __next__().

def __next__(self):
        fib = self.a

Метод __next__() викликається щоразу як хтось викликає next() на ітераторі чи екземплярі класу. Це стане більш зрозумілим за хвилину.

        if fib > self.max:
            raise StopIteration

Коли метод __next__() генерує виняток StopIteration це сигналізує тому хто його викликав про те що ітерація вичерпалась. На відміну від більшості інших винятків, це не помилка. це нормальна ситуація яка просто означає що ітератор більше не має значень для генерації. Якщо тим хто викликав next() є цикл for, він просто завершить ітерації і передасть потік виконання далі. (Іншими словами він просто проковтне виняток.) Цей маленький шматочок магії насправді є ключем до використання ітератоів в циклах for.

        self.a, self.b = self.b, self.a + self.b
        return fib

Щоб згенерувати наступне значення, метод ітератора __next__() просто повертає його за допомогою return. Він не використовує yield тому що це просто синтаксичний цукор який використовується лише в генераторах. Метод __next__ - метод ітератора а не генератор.

Вже добряче заплутались? Прекрасно. Давайте поглянемо як викликати цей ітератор:

>>> from fibonacci2 import Fib
>>> for n in Fib(1000):
...     print(n, end=' ')
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987

Але ж цей виклик точно такий самий! Байт в байт ідентичний з тим як ми викликали генератор чисел Фібоначчі (за винятком того що тепер він називається з великої букви). Але як?

З циклами for пов'язано трішки магії. Ось що відбувається:

  • Цикл for викликає Fib(1000), як написано. Це дає екземпляр класу Fib. Будемо називати його fib_inst.
  • Таємно, і дуже хитро, цикл for викликає iter(fib_inst), який повертає об'єкт ітератора. Давайте будемо називати його fib_iter. В даному випадку, fib_iter == fib_inst, тому що метод __iter__() повертає self, але цикл for про це не знає (чи взагалі не цікавиться).
  • Щоб "проітеруватись" ітератором, цикл for викликає next(fib_iter), який викликає метод __next__() на об'єкті fib_iter який робить обчислення наступного числа Фібоначчі і повертає значення. Цикл for бере це значення і присвоює його n, після чого виконує тіло циклу з даним значенням n.
  • Звідки цикл for знає коли зупинитись? Радий що ви спитали! Коли next(fib_iter) кидає виняток StopIteration, цикл for ковтає виняток і завершується. (Будь-який інший звичайний виняток прорветься і буде оброблятись як зазвичай.) А де ми бачили виняток StopIteration? Звичайно в методі __next__()!


* * *


Ітератор правил утворення множини

[ред.]
iter(f) викликає f.__iter__
next(f) викликає f.__next__

Тепер час для завершення. Давайте перепишемо генератор правил утворення множини як ітератор.

class LazyRules:
    rules_filename = 'plural6-rules.txt'

    def __init__(self):
        self.pattern_file = open(self.rules_filename, encoding='utf-8')
        self.cache = []

    def __iter__(self):
        self.cache_index = 0
        return self

    def __next__(self):
        self.cache_index += 1
        if len(self.cache) >= self.cache_index:
            return self.cache[self.cache_index - 1]

        if self.pattern_file.closed:
            raise StopIteration

        line = self.pattern_file.readline()
        if not line:
            self.pattern_file.close()
            raise StopIteration

        pattern, search, replace = line.split(None, 3)
        funcs = build_match_and_apply_functions(
            pattern, search, replace)
        self.cache.append(funcs)
        return funcs

rules = LazyRules()

Цей клас реалізує методи __iter__() та __next__(), тому він може використовуватись як ітератор. Потім ми створюємо його екземпляр та відкриваємо для нього файл з правилами. Це відбувається лише раз при імпорті.

Давайте спробуємо розібратись у всьому по кусочку за раз.


class LazyRules:
    rules_filename = 'plural6-rules.txt'

    def __init__(self):
        self.pattern_file = open(self.rules_filename, encoding='utf-8')

Коли ми створюємо екземпляр класу LazyRules відкривається файл з шаблонами, але з нього нічого не читається (це буде потім).

        self.cache = []

Після відкриття файла з шаблонами, ми ініціалізуємо кеш. Ми використаємо його пізніше (в методі __next__()) протягом того як будемо читати рядки з файла з шаблонами.


Перед тим як продовжити давайте поближче глянемо на змінну rules_filename. Вона не описується всередині метода __iter__(). Насправді вона не описується всередині будь-якого метода. Вона описується на рівні класу. Це змінна класу, і хоча доступ до неї подібний на доступ до змінних екземпляра (через self.rules_filename), але вона спільна для всіх екземплярів класу LazyRules.


>>> import plural6
>>> r1 = plural6.LazyRules()
>>> r2 = plural6.LazyRules()
>>> r1.rules_filename 
'plural6-rules.txt'
>>> r2.rules_filename
'plural6-rules.txt'

Кожен екземпляр класу успадковує атрибут rules_filename зі значенням описаним всередині класу.

>>> r2.rules_filename = 'r2-override.txt'
>>> r2.rules_filename
'r2-override.txt'
>>> r1.rules_filename
'plural6-rules.txt'

Зміна значення цього атрибуту не змінює його в інших екземплярах...

>>> r2.__class__.rules_filename
'plural6-rules.txt'

... як і не змінює атрибут класу. Ви можете отримати доступ до атрибуту класу (на противагу атрибутам окремих екземплярів) використовуючи спеціальний атрибут __class__ для доступу до самого класу.

>>> r2.__class__.rules_filename = 'papayawhip.txt'
>>> r1.rules_filename
'papayawhip.txt'

Якщо ми змінюємо атрибут класу, він змінюється для всіх екземплярів які все ще його наслідують (як наприклад тут r1).

>>> r2.rules_filename
'r2-overridetxt'

Екзеплярів які переозначили атрибут (як r2 в цьому прикладі) це не стосується.


Тепер повернемось до наших баранів.


  def __iter__(self):

Метод __iter__() буде викликаним щоразу як хтось, наприклад цикл for викликатиме iter(rules).

        self.cache_index = 0
        return self

Одна річ яку можен метод __iter__() повинен робити - повертати ітератор. В даному випадку він повертає self що сигналізує про те що клас описує метод __next__() який потурбується про повернення значень під час ітерації.


    def __next__(self):

Метод __next__() викликається щоразу, як хтось (наприклад цикл for) викликає next(rules). Цей метод буде зрозумілішим якщо ми почнемо розглядати його з кінця і просуватись до початку, тому давайте так і зробимо.

        .
        .
        .
        pattern, search, replace = line.split(None, 3)
        funcs = build_match_and_apply_functions(        
            pattern, search, replace)
        self.cache.append(funcs)                        
        return funcs

Принаймі остання частина цієї функції повинна виглядати знайомо. Функція build_match_and_apply_functions() не змінилась, вона така ж як і була завжди.

Єдина відмінність в тому, що перед тим як повернути функції (що зберігаються в кортежі func) ми збираємось зберегти їх в self.cache.

Тепер рухаємось назад...

    def __next__(self):
        .
        .
        .
        line = self.pattern_file.readline()
        if not line:
            self.pattern_file.close()
            raise StopIteration
        .
        .
        .

Тут деякі особливі трюки з файлами. Метод readline() (зауважте, назва в однині, а не readlines()) читає рівно один рядок з відкритого файлу. Якщо точніше - наступний рядок. (Файлові об'єкти теж ітератори! Всюди ітератори)

Якщо readline() ще має рядки які можна читати, то це не порожні рядки. Навіть якщо файл і містить порожній рядок, то він буде зберігатись як один символ '\n' (повернення каретки). Якщо змінній line справді присвоїться порожній рядок, це означатиме що рядків в файлі більше нема.

Коли ми досягнемо кінця файла ми повинні його закрити, і згенерувати магічний виняток StopIteration. Ми написали такий код тому що нам потрібно отримати функції для наступного правила. А ці функції утворюються на основі прочитаного рядка. А якщо прочитаного рядка нема, значить і нема значень які потрібно повертати, тому це кінець ітерації. (♫ Кінець бенкету... ♫)

Далі назад...

    def __next__(self):
        self.cache_index += 1
        if len(self.cache) >= self.cache_index:
            return self.cache[self.cache_index - 1]

        if self.pattern_file.closed:
            raise StopIteration
        .
        .
        .

self.cache повинен бути списком функцій які потрібні нам для застосування правил. self.cache_index слідкує за тим який з елементів цього списку потрібно буде повертати наступним. Якщо ми ще не вичерпали кеш (тобто довжина self.cache більша за self.cache_index), то ми можемо взяти значення з кеша! Ура! Ми можемо повернути готові функції замість того щоб створювати їх з нуля.

З іншого боку, якщо значень в кеші нема, і файловий об'єкт закритий (що може трапитись далі в методі, в коді який ми бачили раніше), значить ми нічого більше не зможемо зробити. Якщо файл закритий, це означає що він вичерпався, ми вже прочитали всі його рядки, і побудували всі функції на їх основі. Файл вичерпався, кеш вичерпався, я виснажився. Стояти, що? Зачекайте тут, ми майже закінчили.

Якщо все підсумувати, ось що відбувається коли:

  • Коли модуль імпортується, він створює один екземпляр класу LazyRules названий rules, який відкриває файл шаблонів але не читає з нього.
  • Коли його запитують про першу пару функцій, він перевіряє кеш, але з'ясовує що кеш порожній. Тому він читає рядок з файлу шаблонів, створює функції з шаблонів і записує їх в кеш.
  • Давайте для прикладу скажеом що перші функції підійшли. Якщо так, тоді нові функції не будуються і нові рядки не читаються з файлу.
  • Давайте для прикладу також скажемо що користувач викликає функцію plural() знову для того щоб створити множину для іншого слова. Цикл for в функції plural() викличе iter(rules), який обнулить індекс кешу, але не чіпатиме відкритий файловий об'єкт.
  • Далі цикл for попросить значення з ітератора rules, який застосує свій метод __next__(). Тільки цього разу в кеші вже буде одна пара функцій що відповідають шаблонам в першому рядку файла. Так як вони вже були створені при утворенні множини для попереднього слова, їх буде взято з кешу. Індекс кешу збільшиться, а відкритий файл взагалі не буде зачеплено.
  • Тепер давайте для прикладу скажемо що цього разу перше правило не підійшло. Тоді цикл for спитає про наступне значення з rules. Це викличе метод __next__() вдруге. Цього разу кеш вичерпано - бо в ньому був лише один елемент, а нам потрібен другий, тому метод __next__() продовжить роботу далі, і прочитає наступний рядок з файлу, побудувавши відповідні функції та запам'ятавши їх в кеші.
  • Цей процес читання побудови й запам'ятовування продовжуватиметься доти доки правила що читаються з файла не застосовуватимуться до слова для якого ми намагаємось утворити множину. Якщо ми знайдемо підходяще правило ще до кінця файлу, ми просто його використаємо і зупинимось тримаючи файл відкритим. Посилання на файл залишатиметься, чекаючи наступної команди readline(). Тим часом кількість елементів в кеші буде більшати, і якщо нам буде потрібно утворити множину для ще одного слова, кожен з елементів кешу буде випробуваний до того як буде прочитаний наступний рядок файлу.

От ми й досягли множинної нірвани.

  1. Мінімальна ціна запуску. Єдине що відбувається при імпорті, це створення одного екземпляру класу та відкриття файлу (але не читання з нього).
  2. Максимальна продуктивність. В попередньому прикладі ми читали ввесь файл і будували функції динамічно для кожного слова заново. В цій версії ми запам'ятовуємо функці як тільки вони утворюються, і в найгіршому випадку ми читаємо файл з правилами лише раз, не залежно від того скільки слів нам потрібно буде опрацювати.
  3. Відокремлення коду й даних. Всі шаблони зберігаються в окремому файлі. Код це код, а дані це дані, і їм не зійтися вдвох.

Це справді нірвана? Ну, і так і ні. Є дещо що потрібно мати на увазі при роботі з класом LazyRules: файл шаблонів відкривається (в методі __init__()) і залишається відкритим поки не буде досягнено останнє правило. Python колись закриє файл коли програма завершуватиметься, чи після того як останній екземпляр класу LazyRules буде знищено, але все одно до цього моменту може пройти багато часу. Якщо цей клас є частиною довго працюючого процесу, інтерпретатор Python може ніколи не завершити свою роботу, і об'єкт класу LazyRules може ніколи не бути знищений. Є способи обійти це. Замість того щоб відкривати файл в методі __init__() і залишати його відкритим читаючи по одному правилу за раз, можна відкрити файл, прочитати всі правила і негайно його закрити. Або можна відкрити файл, прочитати одне правило, зберегти позицію за допомогою метода tell(), закрити файл, потім перевідкрити його і використати метод seek() щоб продовжити читати з того місця де завершили. Або можна не хвилюватись про це і просто залишити файл відкритим як ми й зробити в цьому випадку. Програмування це проектування, а суть проектування в компромісах та обмеженнях. Занадто довго відкритий файл може бути проблемою, більш складний код може бути проблемою. Що є більшою проблемою залежить від команди що розробляє вашу програму, вашої програми і середовища в якому вона працюватиме.


* * *


Для подальшого читання

[ред.]

Примітки

[ред.]
  1. Колір що утворюється при змішуванні пюре папаї з ванільним морозивом чи йогуртом. Або світло-оранжевий українською. :)

Детальніше про ітератори

[ред.]

"Великі блохи мають на спинах маленьких блох, щоб їх кусали.
А малі мають ще менших, і так до нескінченності."

Августус Де Морган


Занурення

[ред.]

Так як регулярні вирази - це операції з рядками на стероїдах, так само модуль itertools - ітератори на стероїдах. Але спершу я хочу показати вам одну класичну головоломку:

HAWAII + IDAHO + IOWA + OHIO == STATES
510199 + 98153 + 9301 + 3593 == 621246

H = 5
A = 1
W = 0
I = 9
D = 8
O = 3
S = 6
T = 2
E = 4
Один з найвідоміших математичних ребусів: SEND + MORE = MONEY.

Такі головоломки називаються математичними ребусами. Букви утворюють якісь слова, але якщо замінити кожну букву цифрою від 0 до 9, числа утворять правильну математичну тотожність. Задача в тому, аби вияснити яка цифра відповідає кожній букві. Всі випадки використання букви замінюються на одну й ту ж цифру, жодна цифра не замінює двох різних букв, і жодне "слово" не може починатись з цифри 0.

В цьому розділі ми розберемо неймовірну програму мовою Python, початково написану Реймондом Геттінґером. Ця програма розв’язує математичні ребуси використовуючи всього 14 рядків коду.

import re
import itertools

def solve(puzzle):
    words = re.findall('[A-Z]+', puzzle.upper())
    unique_characters = set(''.join(words))
    assert len(unique_characters) <= 10, 'Too many letters'
    first_letters = {word[0] for word in words}
    n = len(first_letters)
    sorted_characters = ''.join(first_letters) + \
        ''.join(unique_characters - first_letters)
    characters = tuple(ord(c) for c in sorted_characters)
    digits = tuple(ord(c) for c in '0123456789')
    zero = digits[0]
    for guess in itertools.permutations(digits, len(characters)):
        if zero not in guess[:n]:
            equation = puzzle.translate(dict(zip(characters, guess)))
            if eval(equation):
                return equation

if __name__ == '__main__':
    import sys
    for puzzle in sys.argv[1:]:
        print(puzzle)
        solution = solve(puzzle)
        if solution:
            print(solution)

Ви можете запустити програму з командного рядка. Як це виглядатиме в Лінукс продемонстровано нижче. (Пошук розв’язку може зайняти деякий час, тому наберіться терпіння).

you@localhost:~/diveintopython3/examples$ python3 alphametics.py "HAWAII + IDAHO + IOWA + OHIO == STATES"
HAWAII + IDAHO + IOWA + OHIO = STATES
510199 + 98153 + 9301 + 3593 == 621246
you@localhost:~/diveintopython3/examples$ python3 alphametics.py "I + LOVE + YOU == DORA"
I + LOVE + YOU == DORA
1 + 2784 + 975 == 3760
you@localhost:~/diveintopython3/examples$ python3 alphametics.py "SEND + MORE == MONEY"
SEND + MORE == MONEY
9567 + 1085 == 10652


* * *


Пошук всіх входжень шаблону

[ред.]

Перше що робить наш розв’язувач головоломок - знаходить всі великі латинські літери в умові:

>>> import re
>>> re.findall('[0-9]+', '16 2-by-4s in rows of 8')
['16', '2', '4', '8']

Модуль re містить реалізацію регулярних виразів мови Python. Він має вправну функцію findall(), яка отримує регулярний вираз, і рядок, та повертає список зі всіма входженнями шаблону в рядок. В нашому випадку шаблону відповідають послідовності цифр.

>>> re.findall('[A-Z]+', 'SEND + MORE == MONEY')
['SEND', 'MORE', 'MONEY']

Це шаблон якому відповідають послідовності літер. Знову ж таки значенням що повертається - список, кожен елемент якого співставний з регулярним виразом.

А ось ще один приклад, щоб дещо розім’яти ваш мозок:

>>> re.findall(' s.*? s', "The sixth sick sheikh's sixth sheep's sick.")
[' sixth s', " sheikh's s", " sheep's s"]
Це найважча скоромовка в англійській мові.

Здивовані? Регулярний вираз шукає пробіл, s, найкорошту можливу послідовність довільних символів, знову пробіл, а потім іншу s. Але дивлячись на той рядок я бачу аж п’ять відповідностей:

  1. The sixth sick sheikh's sixth sheep's sick.
  2. The sixth sick sheikh's sixth sheep's sick.
  3. The sixth sick sheikh's sixth sheep's sick.
  4. The sixth sick sheikh's sixth sheep's sick.
  5. The sixth sick sheikh's sixth sheep's sick.

А re.findall() повертає список з трьома співпадіннями. Якщо конкретно, то з першим, третім і п’ятим. Чому б це? Тому що вона не повертає співпадіння які перекриваються. Перше співпадіння перекривається з другим, тому друге пропускається. Наступне третє повертається, але воно перетинається з четвертим, тому четверте пропускається, і насамкінець повертається п’яте. Таким чином отримуємо п’ять співпадінь.

Але це не має жодного відношення до розв’язування ребусів. Я просто подумав що це може бути цікаво.


* * *


Пошук унікальних елементів послідовності

[ред.]

Множини роблять пошук унікальних елементів тривіальною справою.

>>> a_list = ['The', 'sixth', 'sick', "sheik's", 'sixth', "sheep's", 'sick'] 
>>> set(a_list) 
{'sixth', 'The', "sheep's", 'sick', "sheik's"}

Отримавши на вхід список елементів, функція set() поверне набір унікальних рядків в списку. Це можна собі уявити як цикл for: беремо перший елемент, додаємо в множину. Потім другий, третій, четвертий, п’ятий... Стоп! П’ятий вже є в множині, тому його пропускаємо. Додаємо ще шостий, і пропускаємо останній. В результаті отримуємо набір унікальних елементів списку. Для цього список навіть не довелось сортувати.

>>> a_string = 'EAST IS EAST' 
>>> set(a_string) 
{'A', ' ', 'E', 'I', 'S', 'T'}

Така ж техніка працює з рядками, тому що рядки - просто послідовності символів.

>>> words = ['SEND', 'MORE', 'MONEY'] 
>>> ''.join(words)
'SENDMOREMONEY'

.join(a_list) склеює список рядків a_list в один рядок.

>>> set(''.join(words))
{'E', 'D', 'M', 'O', 'N', 'S', 'R', 'Y'}

Цей код повертає множину унікальних символів в усіх рядках списку.

Розв’язувач ребусів використовує такий підхід при побудові множини всіх символів головоломки:

unique_characters = set(''.join(words))

Цей набір пізніше використовується щоб присвоювати символам числові значення, в процесі перебору розв’язків.


* * *


Пересвідчення (assert)

[ред.]

Як і багато інших мов, Python містить інструкцію assert. Вона працює так:

>>> assert 1 + 1 == 2

Після інструкції assert іде довільний вираз мови Python. В цьому випадку твердження 1 + 1 == 2 істинне, тому інструкція assert нічого не робить.

>>> assert 1 + 1 == 3 
Traceback (most recent call last): 
 File "<stdin>", line 1, in <module> 
AssertionError

Тим не менш, якщо вираз приймає хибне значення в булевому контексті, assert згенерує виняток AssertionError

>>> assert 2 + 2 == 5, "Only for very large values of 2"
Traceback (most recent call last): 
 File "<stdin>", line 1, in <module>
AssertionError: Only for very large values of 2

Також ви можете додати після виразу пояснення того що сталось для інших людей.

Коротше кажучи, рядок коду:

assert len(unique_characters) <= 10, 'Too many letters'

... є еквівалентним оцьому:

if len(unique_characters) > 10:
    raise AssertionError('Too many letters')

Розв’язувач ребусів використовує таке пересвідчення, щоб не почати розв’язувати головоломку, яка містить більше десяти різних символів. Бо так як цифр всього десять головоломка що має більше символів не може мати розв’язку.


* * *



Генераторні вирази

[ред.]

Генераторні вирази - це як генераторні функції тільки без функцій.

>>> unique_characters = {'E', 'D', 'M', 'O', 'N', 'S', 'R', 'Y'} 
>>> gen = (ord(c) for c in unique_characters)

Генераторний вираз схожий на анонімну функцію що генерує значення. Він схожий на списковий вираз, але замість квадратних дужок використовує круглі.

>>> gen 
<generator object <genexpr> at 0x00BADC10>

Генераторний вираз повертає ітератор.

>>> next(gen) 69 
>>> next(gen) 
68

Виклик функції next повертає наступне значення переданого їй ітератора.

>>> tuple(ord(c) for c in unique_characters) 
(69, 68, 77, 79, 78, 83, 82, 89)

За бажанням ви можете здійснити ітерацію крізь всі значення, та повернути список, кортеж, чи множину, передаючи генераторний вираз як аргумент у функції list(), tuple() чи set(). В цьому випадку вам навіть не потрібна зайва пара дужок - просто передайте "голий" вираз ord(c) for c in unique_characters в функцію tuple(), і Python сам здогадається що це генераторний вираз.

Використання генераторних виразів замість спискових економить як пам’ять так і процесорний час. Якщо ви будуєте список тільки для того аби його невдовзі викинути (тобто передати функціям tuple() чи set()) - краще натомість використовуйте генератори.

Ось інший спосіб досягнути того ж, використовуючи генераторну функцію:

def ord_map(a_string):
    for c in a_string:
        yield ord(c)

gen = ord_map(unique_characters)

Генераторний вираз набагато компактніший, хоча є функціонально еквівалентним.


* * *


Перебір перестановок... Спосіб для лінивих!

[ред.]

Перш за все - що перестановки взагалі таке? Перестановки - це таке математичне поняття. (Взагалі то, існує кілька означень, залежно від того який розділ математики вам потрібен. Тут я розглядаю комбінаторику, але якщо це для вас взагалі нічого не означає - не хвилюйтесь. Як завжди - вікіпедія це ваша помічниця).

Ідея полягає в тому, що ви берете послідовність якихось об’єктів (чисел, рядків чи навіть танцюючих ведмедів) і перебираєте всі можливі способи утворити з неї менші послідовності. Ці менші послідовності повинні бути однакової, проте довільної довжини - від одного до кількості елементів в початковій послідовності. (Примітка: англійське слово permutations в українській комбінаторній термінології означає два поняття: розміщення та перестановки. Перестановки - це розміщення довжини n, де n - довжина початкової послідовності, тому надалі напевне краще буде перекладати термін permutations як розміщення. Не знаю.) Також - два однакові розміщення не повинні повторюватись. Математики кажуть: "давайте знайдемо всі розміщення з трьох елементів по два", що означає: давайте знайдемо всі різні послідовності довжини 2, з трьох можливих елементів.

>>> import itertools

Модуль itertools містить багато цікавих речей, і в тому числі функцію permutations(), яка робить за нас всю важку роботу з генерації розміщень.

>>> perms = itertools.permutations([1, 2, 3], 2)

Функція permutations() приймає на вхід послідовність (в даному випадку список з трьох чисел), і число - розмір розміщень які ми будемо генерувати. Функція повертає ітератор, який можна використовувати в циклах for, чи інших місцях де потрібні послідовності. Тут я перевірю всі значення ітератора вручну.

>>> next(perms)
(1, 2) 
>>> next(perms)
(1, 3) 
>>> next(perms)
(2, 1) 
>>> next(perms)
(2, 3) 
>>> next(perms)
(3, 1) 
>>> next(perms)
(3, 2)

Зауважте що розміщення впорядковані. (2, 1) - це інша не те саме розміщення що й (1, 2).

>>> next(perms)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module> 
StopIteration

І це всі розміщення з елементів [1, 2, 3] взятих поодинці. Такі пари як (1, 1) не з’являються, бо вони містять повторення, тому не є правильними розміщеннями. Коли ітерації закінчуються ітератор генерує виняток StopIteration

Модуль itertools містить ще купу цікавих речей.

Функція permutations() приймає не тільки списки. Вона може приймати довільні послідовності - навіть рядки.

>>> import itertools 
>>> perms = itertools.permutations('ABC', 3)

Рядок - це всього лиш послідовність символів. При генеруванні перестановок рядок 'ABC' еквівалентний списку ['A', 'B', 'C']

>>> next(perms)
('A', 'B', 'C') 
>>> next(perms)
('A', 'C', 'B') 
>>> next(perms)
('B', 'A', 'C') 
>>> next(perms)
('B', 'C', 'A') 
>>> next(perms)
('C', 'A', 'B') 
>>> next(perms)
('C', 'B', 'A') 
>>> next(perms)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
StopIteration

Перше розміщення (тут прийнятний і термін перестановка) з елементів ['A', 'B', 'C'], взятих по три штуки - ('A', 'B', 'C'). Решта п’ять - ці ж три елементи які розміщуються у всіх мислимих порядках.

>>> list(itertools.permutations('ABC', 3))
[('A', 'B', 'C'), ('A', 'C', 'B'),
 ('B', 'A', 'C'), ('B', 'C', 'A'),
 ('C', 'A', 'B'), ('C', 'B', 'A')]

Так як функція permutations() завжди повертає ітератор, то найпростіший спосіб її зневадження - передати цей ітератор функції list(), щоб отримати всі перестановки одразу.


* * *


Інші веселі речі з модуля itertools

[ред.]
>>> import itertools 
>>> list(itertools.product('ABC', '123')) 
[('A', '1'), ('A', '2'), ('A', '3'),
 ('B', '1'), ('B', '2'), ('B', '3'),
 ('C', '1'), ('C', '2'), ('C', '3')]

itertools.product() повертає ітератор що генерує декартовий добуток двох послідовностей.

>>> list(itertools.combinations('ABC', 2)) 
[('A', 'B'), ('A', 'C'), ('B', 'C')]

Функція itertools.combinations() повертає ітератор що містить всі можливі комбінації певної кількості елементів даної послідовності. Це майже так само як itertools.permutations(), тільки до списку комбінацій не входять комбінації які містять такі ж елементи але в іншому порядку. Тобто itertools.permutations('ABC', 2) поверне як і ('A', 'B') так і ('B', 'A') (окрім інших), а itertools.combinations('ABC', 2) не поверне ('B', 'A') тому що це дублікат ('A', 'B') в іншому порядку.


>>> names = list(open('examples/favorite-people.txt', encoding='utf-8')) 
>>> names ['Dora\n', 'Ethan\n', 'Wesley\n', 'John\n', 'Anne\n',
'Mike\n', 'Chris\n', 'Sarah\n', 'Alex\n', 'Lizzie\n']

У такий спосіб можна отримати список рядків файла.

>>> names = [name.rstrip() for name in names] 
>>> names 
['Dora', 'Ethan', 'Wesley', 'John', 'Anne',
'Mike', 'Chris', 'Sarah', 'Alex', 'Lizzie']

На жаль, попередній спосіб також читає з файлу символи переходу на новий рядок, тому ми застосовуємо списковий вираз та рядковий метод rstrip(), щоб видалити зайві розмежовувальні символи з кінця рядка. (Рядки також мають метод lstrip() для видалення їх на початку та метод strip() для очистки невидимих символів з обох кінців).

>>> names = sorted(names) 
>>> names 
['Alex', 'Anne', 'Chris', 'Dora', 'Ethan',
'John', 'Lizzie', 'Mike', 'Sarah', 'Wesley']

Функція sorted() приймає список, і повертає його відсортованим. За замовчуванням, вона сортує за алфавітом. В даному випадку ми передали функцію len(), тому рядки сортуються за довжиною.

>>> names = sorted(names, key=len) 
>>> names ['Alex', 'Anne', 'Dora', 'John', 'Mike',
'Chris', 'Ethan', 'Sarah', 'Lizzie', 'Wesley']

Але також їй в параметр key функцію, і тоді список відсортується за зростанням значень цієї функції.


Але яким чином це пов’язано з модулем itertools? Радий що ви спитали.

продовжуючи попередній сеанс інтерактивної оболонки
>>> import itertools
>>> groups = itertools.groupby(names, len)  
>>> groups
<itertools.groupby object at 0x00BB20C0>
>>> list(groups)
[(4, <itertools._grouper object at 0x00BA8BF0>),
 (5, <itertools._grouper object at 0x00BB4050>),
 (6, <itertools._grouper object at 0x00BB4030>)]

Функція itertools.groupby() приймає два значення - ітератор, і функцію, і повертає ітератор з парами. Першим елементом пари є значення функції, а другим - ітератор що містить всі елементи вхідної послідовності з таким самим значенням функції.

>>> groups = itertools.groupby(names, len)

Виклик функції list() "спорожнив" ітератор, тобто ви вже згенерували кожен елемент в ітераторі для того щоб утворити список, і більше їх там немає. Ніякої кнопки "перезавантажити" на ітераторі немає, і почати все заново неможливо, тому доводиться створювати ітератор спочатку.

>>> for name_length, name_iter in groups:    
...     print('Names with {0:d} letters:'.format(name_length))
...     for name in name_iter:
...         print(name)
... 
Names with 4 letters:
Alex
Anne
Dora
John
Mike
Names with 5 letters:
Chris
Ethan
Sarah
Names with 6 letters:
Lizzie
Wesley

В цьому прикладі, коли функція itertools.groupby() отримує вже відсортований список значень names, і поміщає всі чотирибуквенні імена в один ітератор, всі п’ятибуквенні в інший, і так далі. Функція groupby цілком загальна. Вона може групувати рядки за першою буквою, числа за кількістю дільників, чи за будь-якою іншою функцією яку ви придумаєте.

Функція itertools.groupby() працює лише коли вхідна послідовність вже відсортована за фунцією за якою групується. В прикладі вище ми групували її за функцією len(). Це працювало лише тому, що вхідний список вже був відсортованим за цією функцією.


Ви дивитесь уважно?

>>> list(range(0, 3))
[0, 1, 2] 
>>> list(range(10, 13))
[10, 11, 12] 
>>> list(itertools.chain(range(0, 3), range(10, 13)))
[0, 1, 2, 10, 11, 12]

Функція chain() бере два ітератори, і повертає ітератор, який генерує спочатку всі елементи з першого ітератора, а потім з другого. (Взагалі вона може брати довільне число ітераторів і з’єднувати їх послідовно в тому порядку в якому їх передали).

>>> list(zip(range(0, 3), range(10, 13)))
[(0, 10), (1, 11), (2, 12)]

Функція zip() робить дещо прозаїчне, яке щоправда іноді виявляється надзвичайно корисним: вона бере довільне число послідовностей, і повертає ітератор який генерує кортежі що містять перші елементи всіх послідовностей, потім другі елементи, потім треті, і так далі.

>>> list(zip(range(0, 3), range(10, 14)))
[(0, 10), (1, 11), (2, 12)]

Функція zip() зупиняється на кінці найкоротшої послідовності. range(10, 14) має чотири елементи: (10, 11, 12, 13), а range(0, 3) всього лиш 3, тому функція zip() поверне ітератор з трьох елементів.

>>> list(itertools.zip_longest(range(0, 3), range(10, 14)))
[(0, 10), (1, 11), (2, 12), (None, 13)]

Правда є функція itertools.zip_longest(), яка повертає ітератор що містить стільки елементів, скільки їх є в найдовшій послідовності, заповнюючи нестачу в інших послідовностях значеннями None.

Гаразд, це все було дуже цікаво. Але як воно пов’язане з розв’язувачем ребусів? А ось як:

>>> characters = ('S', 'M', 'E', 'D', 'O', 'N', 'R', 'Y') 
>>> guess = ('1', '2', '0', '3', '4', '5', '6', '7') 
>>> tuple(zip(characters, guess))
(('S', '1'), ('M', '2'), ('E', '0'), ('D', '3'),
 ('O', '4'), ('N', '5'), ('R', '6'), ('Y', '7'))

Отримавши список букв та цифр, функція zip() створює впорядкований список пар.

>>> dict(zip(characters, guess))
{'E': '0', 'D': '3', 'M': '2', 'O': '4',
 'N': '5', 'S': '1', 'R': '6', 'Y': '7'}

І це добре тому що така структура даних підходяща для функції dict() і створення словника, в якому кожній букві відповідає цифра. (Це не єдиний спосіб створити такий словник. Можна також використати словникові вирази, аби створити словник напряму). І хоча як видно з лістингу вище в словнику пари йдуть зовсім в іншому порядку (словники не мають порядку за означенням), але ми можемо бачити що кожному символу відповідає та буква, яка відповідала йому в порядку який був в оригінальних списках characters і guess.

Розв’язувач ребусів використовує такий метод щоб створити словник відповідностей між буквами в головоломці і цифрами в розв’язку.

characters = tuple(ord(c) for c in sorted_characters)
digits = tuple(ord(c) for c in '0123456789')
...
for guess in itertools.permutations(digits, len(characters)):
    ...
    equation = puzzle.translate(dict(zip(characters, guess)))

Але що це за метод translate()? Ах, тут ми нарешті добрались до справді цікавої частини.


* * *


Новий спосіб маніпуляцій над рядками

[ред.]

Рядки в мові Python мають багато методів. Ви дізнались про деякі з них в розділі Текст. Зараз я хочу показати вам зручну, проте маловідому техніку роботи з рядками: метод translate().

>>> translation_table = {ord('A'): ord('O')}

Перетворення рядка починається з створення таблиці перетворення, яка є простим словником який відображає один символ на інший. Щоправда вживати тут слово "символ" не цілком вірно. Таблиця насправді задає відношення між байтами.

>>> translation_table 
{65: 79}

Пам’ятайте, байти в Python 3 - це цілі числа. Функція ord() повертає номер в символа в таблиці ASCII, який, у випадку символів від A до Z, завжди є байтом між 65 і 90.

>>> 'MARK'.translate(translation_table)
'MORK'

Метод рядка translate() отримує таблицю перетворення і пробігається по рядку з нею. Як тільки він значення для якого є ключ в таблиці, він замінює його значенням з таблиці. В даному випадку замінює рядок 'MARK' на 'MORK'.

Нарешті ми добрались до справді цікавого.

І що тут має відношення до розв’язування ребусів? Як виявляється - все.

>>> characters = tuple(ord(c) for c in 'SMEDONRY') 
>>> characters 
(83, 77, 69, 68, 79, 78, 82, 89)

Використовуючи генераторний вираз ми швидко обчислюємо значення байтів для символів в рядку.

>>> guess = tuple(ord(c) for c in '91570682') 
>>> guess 
(57, 49, 53, 55, 48, 54, 56, 50)

Іншим генераторним виразом обчислюємо значення байтів для символів цифр які їм відповідають.

>>> translation_table = dict(zip(characters, guess)) 
>>> translation_table 
{68: 55, 69: 53, 77: 49, 78: 54, 79: 48, 82: 56, 83: 57, 89: 50}

Таблиця перетворення будується за допомогою з’єдання цих двох послідовностей в пари, і конструювання з цих пар словника.

>>> 'SEND + MORE == MONEY'.translate(translation_table)
'9567 + 1085 == 10652'

І нарешті, ми передаємо таблицю перетврення в метод translate() оригінального тексту головоломки. Це перетворює кожну букву головоломки на відповідну цифру. Результатом є правильний вираз мови Python, записаний як рядок.

Це досить вражаюче. Але що робити з рядком який є правильним виразом мови Python?


* * *


Обчислення рядків як виразів Python

[ред.]

Це остання частина головоломки (чи точніше остання частина розв'язувача головоломок). Після всіх тих незвичних маніпуляцій, ми отримуємо рядок на зразок '9567 + 1085 == 10652'. Але це рядок, а що доброго в рядку? Тут з’являється eval() - універсальний інструмент обчислень в Python.

>>> eval('1 + 1 == 2')
True
>>> eval('1 + 1 == 3')
False
>>> eval('9567 + 1085 == 10652')
True

Але зачекайте, є ще! eval() не обмежується булевими виразами. Вона може обробляти будь-який вираз, та повертати будь-який тип даних.

>>> eval('"A" + "B"')
'AB'
>>> eval('"MARK".translate({65: 79})')
'MORK'
>>> eval('"AAAAA".count("A")')
5
>>> eval('["*"] * 5')
['*', '*', '*', '*', '*']

Але зачекайте, це ще не все!

>>> x = 5
>>> eval("x * 5")
25

Вираз який показує, що eval() може використовувати глобальні змінні описані за його межами. А також локальні змінні, якщо виклик відбувається з функції.

>>> eval("pow(x, 2)")
25

Та функції.

>>> import math 
>>> eval("math.sqrt(x)")  
2.2360679774997898

Та модулі.

Ей, зачекайте хвилинку…

>>> import subprocess 
>>> eval("subprocess.getoutput('ls ~')")
'Desktop         Library         Pictures \
 Documents       Movies          Public   \
 Music           Sites'

Модуль subprocess дозволяє запускати консольні команди, та отримувати результат в вигляді рядка.

>>> eval("subprocess.getoutput('rm /some/random/file')")

Деякі консольні команди можуть мати незворотні наслідки.

Ситуація стає ще гіршою від того, що існує глобальна функція __import__(), яка отримує ім'я модуля як рядок, імпортує модуль, та повертає посилання на нього. Якщо це поєднати з силою eval() можна створити один єдиний вираз що знищить всі ваші файли.

>>> eval("__import__('subprocess').getoutput('rm /some/random/file')")

Тепер уявіть вивід 'rm -rf ~'. Хоча насправді не буде ніякого виводу, але також у вас більше не буде ніяких файлів.


eval() - зло


Емм, злою частиною є обчислення довільних виразів з ненадійних джерел. Ви маєте використовувати eval() тільки на довірених джерелах. Звичайно, фокус в тому щоб визначити що є "довіреним". Але є щось, що я знаю точно: ви НЕ маєте брати цей зручний калькулятор, та розміщувати його в інтернет як милий маленький веб-сервіс. Не робіть помилку думаючи, "Ох, функція робить багато маніпуляцій з рядками перед тим як почати їх обчислення; Важко уявити, як хтось може проексплоїтити це." Хтось ВИЯСНИТЬ як протягнути небезпечний код крізь всі маніпуляції з рядками (ставались і дивніші речі), а після цього ви можете попрощатись з сервером.

Чи існує хоч якийсь спосіб безпечного обчислення виразів? Щоб поставити eval() в певні обмеження, за які він не зможе вийти, щоб зашкодити зовнішньому світу? Ну, насправді і так, і ні.

>>> x = 5 
>>> eval("x * 5", {}, {})
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "<string>", line 1, in <module> 
NameError: name 'x' is not defined

Другий та третій параметри, що передаються функції eval(), стають глобальним та локальним просторами імен для виразу. В даному випадку вони обидва порожні. Це означає, що при обчисленні рядка "x * 5" немає посилання на змінну x ні в глобальному ні в локальному просторі імен, тому eval() закінчить роботу і згенерує виняток.

>>> eval("x * 5", {"x": x}, {})

Можна вибірково включати певні змінні в глобальний простір імен, перелічивши кожну індивідуально. Тоді ті, і тільки ті змінні будуть доступними під час обчислення.

>>> import math >>> eval("math.sqrt(x)", {"x": x}, {}) Traceback (most recent call last):

 File "<stdin>", line 1, in <module>
 File "<string>", line 1, in <module> 

NameError: name 'math' is not defined </syntaxhighlight>

Незважаючи на те, що ви імпортували модуль math, ви не додали його в простір імен, що передається функції eval(), тому обчислення не вдається.

Ех, це ж просто! Давайте я тепер зроблю веб-сервіс для розв’язування ребусів!

>>> eval("pow(5, 2)", {}, {})
25

Незважаючи на те, що ви передали порожні словники, як глобальний та локальний простори імен, всі вбудовані функції Python досі доступні в процесі обчислень. Тому pow(5,2), працює, тому що 5 та 2 - літерали, а pow() - вбудована функція.

>>> eval("__import__('math').sqrt(5)", {}, {})  
2.2360679774997898

На жаль (і якщо ви не зрозуміли чому "на жаль" - прочитайте розділ спочатку), функція __import__() - також є вбудованою, та також працює.

Ага, це означає, що ви все ще можете нашкодити, навіть якщо будете передавати в eval() порожні простори імен.

>>> eval("__import__('subprocess').getoutput('rm /some/random/file')", {}, {})

Ойойой, який я радий що ще не зробив веб-сервіс. Так чи існує якийсь спосіб безпечного використання eval()?

>>> eval("__import__('math').sqrt(5)",
...     {"__builtins__":None}, {})
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module> 
NameError: name '__import__' is not defined

Щоб безпечно обчислити вирази яким не довіряєте, ви маєте описати глобальний простір імен, що присвоює "__builtins__" None, тобто відсутність будь-яких значень. Загалом всі вбудовані функції зберігаються всередині псевдо-модуля названого "__builtins__". Цей псевдо-модуль (набір вбудованих функцій) доступний функції eval() за замовчуванням, якщо ви не заміните його явно.

>>> eval("__import__('subprocess').getoutput('rm -rf /')",
...     {"__builtins__":None}, {})
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module> 
NameError: name '__import__' is not defined

Переконайтесь що ви перевантажили __builtins__. Не __builtin__, __built-ins__, чи ще якийсь варіант, що буде працювати прекрасно, але зовсім не зачепить набору вбудованих функцій.

То eval() тепер безпечний? Ну, і так і ні.

>>> eval("2 ** 2147483647",
...     {"__builtins__":None}, {})

Навіть без доступу до __builtins__, все ще можна запустити DoS атаку. Наприклад спроба піднести 2 до великого степеня, повністю завантажить процесор вашого комп'ютера на деякий час. (Якщо ви пробуєте це в консолі інтерпретатора, натисніть Ctrl-C кілька разів, щоб перервати це). Технічно цей вираз швидко поверне значення, проте в той час ваш сервер не зможе робити нічого іншого.

В кінці кінців, можливо безпечно виконувати невідомі вирази Python, для певного визначення "безпеки", яке виявляється не надто корисним в реальному житті. Це нормально, якщо ви просто експериментуєте, і нормально, якщо ви передаєте на обчислення текст якому довіряєте. Але все інше - лиш напрошуватись на зайві неприємності.


* * *


Зібравши все до купи

[ред.]

Нагадаю: ця програма розв’язує математичні ребуси грубою силою, тобто повним перебором всіх можливих розв’язків. Щоб це здійснити, вона:

  1. Знаходить всі букви головоломки за допомогою функції re.findall().
  2. Знаходить множину різних букв за допомогою функції set().
  3. Пересвідчується що букв не більше 10 (що означатиме нерозв’язність головоломки) за допомогою інструкції assert
  4. Генерує ASCII коди букв.
  5. Перебирає всі можливі розв’язки за допомогою функції itertools.permutations()
  6. Перетворює всі можливі розв’язки на вирази мови Python використовуючи метод рядкових об’єктів translate().
  7. Перевіряє кожен розв’язок обчислюючи його вираз функцією eval()
  8. Повертає перший розв’язок вираз для якого є істинним.

... всього лише за 14 рядків коду.


* * *


Що читати далі?

[ред.]

Модульне тестування

[ред.]

Переконаності недостатньо для впевеності. Ми були цілком переконані в багатьох речах, які насправді не були.

Certitude is not the test of certainty. We have been cocksure of many things that were not so.
Олівер Венделл Холмс молодший

(Не) занурення

[ред.]

Ох сьогоднішні діти. Зіпсуті цими швидкими комп'ютерами і модними "динамічними" мовами. Спочатку напиши, потім публікуй, і вже (якщо дійдуть руки) на третьому місці - зневадження. В наші часи в нас була дисципліна. Дисципліна, Я сказав! Ми мусили писати програми від руки, на папері, і згодовувати їх комп'ютеру на пефокартах. І нам це подобалось!

У цьому розділі, ми збираємось написати та зневадити набір допоміжних функцій для перетворення чисел в римську систему та навпаки. Ви бачили механізми створення та перевірки чисел на приналежність до римських в попередньому прикладі. Тепер давайте відійдемо трохи назад і подумаємо над тим що потрібно зробити щоб перетворити це на двосторонній інструмент.

Правила для римських чисел приводять до багатьох цікавих спостережень.

  1. Існує тільки один коректний спосіб представити конкретне число як римське.
  2. Аналогічно і навпаки: якщо рядок символів є правильним римським числом, він представляє собою лише одне число (може бути прочитане однозначно).
  3. Існує обмежений діапазон чисел що можуть бути представлені в римській системі, якщо конкретно - то цілі від 1 до 3999. Римляни мали кілька способів запису більших чисел, наприклад поміщуючи вертикальну риску над числом щоб позначити те що його звичайне значення слід домножити на 1000. Та протягом цього розділу будемо вважати що римські числа простягаються лише від 1 до 3999.
  4. Не існує способу представити 0 в римській системі.
  5. Також римляни не вміли записувати від'ємні числа.
  6. Як і частки дробових чисел.

Давайте почнемо окреслювати те що повинен робити модуль roman.py. Він матиме дві головні функції, to_roman() та from_roman(). Функція to_roman() повинна отримати на вхід ціле число в діапазоні 1..3999 і повернути рядок з записом числа в римській системі...

Зупиніться прямо тут. Тепер давайте зробимо дещо неочікуване: напишемо тест який пеервіряє що функція to_roman() робить те що ми хочемо. Ви це правильно прочитали, ми збираємось написати код що тестує код який ми ще навіть не написали.

Це називається розробкою керованою тестами (англ. test-driven development), чи TDD. Набір двох функцій перетворення - to_roman() та from_roman(), можна написати і тестувати як модуль, окремо від будь-якої більшої програми що їх використовує, і в такому випадку тести називаються модульними. Python має фреймворк для модульного тестування - відповідно названий модуль unittest.

Модульне тестування є важливою частиною стратегії розробки що фокусується на тестуванні. Коли ви пишете модульні тести, важливо писати їх рано, і постійно їх оновлювати протягом того як код та вимоги до нього змінюються. Багато людей пропагують написання тестів перед написанням коду який тестуєтьс, і це підхід який я збираюсь продемонструвати в цьому розділі. Але модульні тести корисні не залежно від того коли ви їх напишете.

  • Написання тестів перед написанням коду змушує вас деталізувати вимоги в досить корисній формі.
  • Написання тестів перед написанням коду запобігає надмірній розробці. Якщо всі тести проходяться значить функцію завершено.
  • При рефакторингу вони допомагають довести що нова версія поводиться так само як і стара.
  • При підтримці коду тести допомагають прикрити власну задницю коли хтось підходить з криком що ваша остання зміна поламала роботу їх старого коду. ("Але пане, всі тести проходяться, я перевірив..")
  • При написанні коду в команді, підтримка вичерпного набору тестів на порядок зменшує шанси того що ваш код поламає якийсь інший код, тому що ви можете спершу запустити їхні модульні тести. (Я спостерігав такі речі під час спринтів розробки. Команда розбиває завдання, кожен бере специфікацію для своєї частини, пише тести для неї, потім ділиться своїми тестами з рештою команди. Таким чином, ніхто не заходить занадто далеко в розробці коду який не надто добре працює з кодом інших).


* * *


Єдине питання

[ред.]

Кожен тест - це острів.

Тест відповідає на єдине запитання про код що тестується. Тест повинен бути здатним...

  • ... працювати цілком самостійно, без жодного людського вводу. Модульне тестування - це автоматизація.
  • ... самостійно визначати чи функція що він тестує пройшла тест чи його завалила, без людини що інтерпретує результат.
  • ... запускатись повністю окремо від інших тестів (навіть якщо вони тестують ту саму функцію). Кожен тест - це острів.

Знаючи це, давайте створимо тест для першої вимоги:

  1. Функція to_roman() повинна повертати римський запис будь-якого цілого числа від 1 до 3999.

Зразу не дуже ясно як код нижче робить..., ну хоч що-небудь. Він описує клас без методу __init__(). Клас має інший метод, але він ніде не викликається. Скрипт має блок __main__, але цей блок не звертається до класу чи його методу. Але дещо він робить, я обіцяю.

import roman1
import unittest

class KnownValues(unittest.TestCase):               
    known_values = ( (1, 'I'),
                     (2, 'II'),
                     (3, 'III'),
                     (4, 'IV'),
                     (5, 'V'),
                     (6, 'VI'),
                     (7, 'VII'),
                     (8, 'VIII'),
                     (9, 'IX'),
                     (10, 'X'),
                     (50, 'L'),
                     (100, 'C'),
                     (500, 'D'),
                     (1000, 'M'),
                     (31, 'XXXI'),
                     (148, 'CXLVIII'),
                     (294, 'CCXCIV'),
                     (312, 'CCCXII'),
                     (421, 'CDXXI'),
                     (528, 'DXXVIII'),
                     (621, 'DCXXI'),
                     (782, 'DCCLXXXII'),
                     (870, 'DCCCLXX'),
                     (941, 'CMXLI'),
                     (1043, 'MXLIII'),
                     (1110, 'MCX'),
                     (1226, 'MCCXXVI'),
                     (1301, 'MCCCI'),
                     (1485, 'MCDLXXXV'),
                     (1509, 'MDIX'),
                     (1607, 'MDCVII'),
                     (1754, 'MDCCLIV'),
                     (1832, 'MDCCCXXXII'),
                     (1993, 'MCMXCIII'),
                     (2074, 'MMLXXIV'),
                     (2152, 'MMCLII'),
                     (2212, 'MMCCXII'),
                     (2343, 'MMCCCXLIII'),
                     (2499, 'MMCDXCIX'),
                     (2574, 'MMDLXXIV'),
                     (2646, 'MMDCXLVI'),
                     (2723, 'MMDCCXXIII'),
                     (2892, 'MMDCCCXCII'),
                     (2975, 'MMCMLXXV'),
                     (3051, 'MMMLI'),
                     (3185, 'MMMCLXXXV'),
                     (3250, 'MMMCCL'),
                     (3313, 'MMMCCCXIII'),
                     (3408, 'MMMCDVIII'),
                     (3501, 'MMMDI'),
                     (3610, 'MMMDCX'),
                     (3743, 'MMMDCCXLIII'),
                     (3844, 'MMMDCCCXLIV'),
                     (3888, 'MMMDCCCLXXXVIII'),
                     (3940, 'MMMCMXL'),
                     (3999, 'MMMCMXCIX'))           

    def test_to_roman_known_values(self):           
        '''to_roman should give known result with known input'''
        for integer, numeral in self.known_values:
            result = roman1.to_roman(integer)       
            self.assertEqual(numeral, result)       

if __name__ == '__main__':
    unittest.main()

① Щоб створити тест, спочатку створіть підклас класу TestCase з модуля unittest. Цей клас надає багато корисних методів які можна використовувати в тестах для перевірки різноманітних умов.

② Це кортеж з пар число/його римський запис, які я перевірив вручну. Він включає десять найменших чисел, найбільше число, кожне римське число що записується одним символом, і випадковий набір інших чисел. Не потрібно тестувати будь-який можливий випадок, але потрібно намагатись протестувати всі очевидні крайні випадки.

③ Кожен тест - це окремий метод. Метод тесту не приймає параметрів, не повертає даних, і повинен мати ім'я що починається з чотирьох символів: test. Якщо виклик методу завершується без винятків, тест вважається пройденим, якщо метод кидає виняток, тест вважається проваленим.

④ Ось тут ми робимо виклик до справжньої функції to_roman(). (Ну, функція все ще не написана, але як тільки вона буде, цей рядок її буде викликати.) Зауважте, що зараз ви описали API функції to_roman(): вона повинна приймати ціле значення (число для перетворення) і повертати рядок (його римський запис). Якщо API буде іншим, тест провалиться. Також зауважте що ми не ловимо жодних винятків при виклику to_roman(). Це навмисне. to_roman не повинен генерувати виняток коли йому передають дозволені значення, а всі перелічені значення дозволені. Якщо to_roman() згенерує виняток тест також провалиться.

⑤ Вважаючи що функція to_roman() була описана вірно, викликана вірно, завершилась успішно, і повернула значення, останнім кроком є перевірка того що повернене значення правильне. Це типова перевірка, і клас TestCase надає метод, assertEqual, для перевірки рівності двох значень. Якщо результат повернений з to_roman() не співпадає з очікуваним значенням, assertEqual, згенерує виняток і тест провалиться. Якщо два значення рівні, assertEqual нічого не зробить. Якщо кожне значення повернуте функцією to_roman() співпадає з очікуваним, assertEqual ніколи не згенерує виняток, тому test_to_roman_known_values з часом завершиться нормально, що означатиме що to_roman() пройшла тест.

Напишіть тест який валиться, тоді пишіть код поки тест не буде пройдено.

Після того як ви написали тест, можна починати кодити функцію to_roman(). Спершу, ви повинні створити заглушку з порожньої функції, та переконатись що тест валиться. Якщо тест успішний до того як ви написати код, ваші тести взагалі не тестують код! Модульне тестування - як танці: тести ведуть, код слідує. Напишіть тест який валиться, тоді програмуйте поки тест не буде пройдено.

# roman1.py

def to_roman(n):
    '''convert integer to Roman numeral'''
    pass

На даному етапі, ми хочемо описати сигнатуру функції to_roman(), але ми ще не хочемо писати її код. (Спершу потрібно щоб тест запустився). Щоб створити заглушку використаємо ключове слово pass, яке вказує на те, що нічого не потрібно робити.

Виконайте romantest1.py в командному рядку щоб запустити тест. Якщо передати при виклику опцію -v, ми отримаємо більш детальний вивід, і зможемо побачити що відбувається при запуску кожного тесту. Наш вивід виглядатиме приблизно так, навіть якщо пощастить:

you@localhost:~/diveintopython3/examples$ python3 romantest1.py -v
test_to_roman_known_values (__main__.KnownValues) ①
to_roman should give known result with known input ... FAIL ②

======================================================================
FAIL: to_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest1.py", line 73, in test_to_roman_known_values
    self.assertEqual(numeral, result)
AssertionError: 'I' != None ③
----------------------------------------------------------------------
Ran 1 test in 0.016s ④

FAILED (failures=1) ⑤

① Запуск скрипта запускає unittest.main(), який виконує кожен тест. Кожен тестовий приклад є окремим методом класу. Обов'язкового формату організації цих класів немає, кожен з них може містити єдиний тестовий метод, чи ви можете мати один клас що містить багато методів. Єдина вимога - кожен клас повинен наслідуватись від unittest.TestCase.

② Для кожного тестового методу модуль unittest роздрукує його рядок документації та результат проходження тесту. Як і очікувалось наш тест валиться.

③ Для кожного провального тесту, unittest виводить трейсбек що показує що саме трапилось. В даному випадку, виклик assertEqual() згенерував AssertionError тому що очікувалось що to_roman(1) поверне 'I', а вона не повернула. (Так як явного виклику return не було, функція повернула None - позначення "нічого" в Python).

④ Після детального опису кожного тесту, unittest показує підсумок того скільки тестів було виконано, і скільки часу це зайняло.

⑤ Загалом, тестування провалено, тому що принаймі один з тестів не пройдено. Коли тест валиться, unittest відрізняє провали та помилки. Провал - це виклик методів assertXYZ, таких як assertEqual чи assertRaises, які провалюютьсяч тому що умова не виконана, наприклад виняток не згенерований. Помилка - будь-який інший виняток згенерований з коду що тестується, чи самого методу що тестує.

Ну, і нарешті, ми можемо написати функцію to_roman().

roman_numeral_map = (('M',  1000),
                     ('CM', 900),
                     ('D',  500),
                     ('CD', 400),
                     ('C',  100),
                     ('XC', 90),
                     ('L',  50),
                     ('XL', 40),
                     ('X',  10),
                     ('IX', 9),
                     ('V',  5),
                     ('IV', 4),
                     ('I',  1))                 

def to_roman(n):
    '''convert integer to Roman numeral'''
    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:                     
            result += numeral
            n -= integer
    return result

roman_numeral_map - кортеж з кортежів який описує три речі: символьне представлення базових римських чисел, їх порядок (спадний, від M аж до I), та значення кожного числа. Кожен внутрішній кортеж - це пара (numeral, value). Це не тільки односимвольні римські числа, тут також описуються двосимвольні пари на зразок CM (“на сотню менше ніж тисяча”). Це дозволяє спростити код функції to_roman(). ② В цьому місці структура даних roman_numeral_map себе окуповує, тому що нам не потрібна жодна спеціальна логіка для опрацювання правила віднімання. Для перетворення числа в римське, просто будемо проходитись по roman_numeral_map шукаючи найбільше ціле число що менше чи рівне за вхідне. Як тільки ми його знайдемо, треба додати його в кінець результату, відняти відповідне ціле від вхідного значення, намилити, змити і повторити :).

Якщо все ще не ясно як працює функція to_roman(), додайте виклик print() до кінця циклу while:

while n >= integer:
    result += numeral
    n -= integer
    print('subtracting {0} from input, adding {1} to output'.format(integer, numeral))

Результат роботи цього коду виглядатиме якось так:

>>> import roman1
>>> roman1.to_roman(1424)
subtracting 1000 from input, adding M to output
subtracting 400 from input, adding CD to output
subtracting 10 from input, adding X to output
subtracting 10 from input, adding X to output
subtracting 4 from input, adding IV to output
'MCDXXIV'

Отож, функція to_roman() ніби працює, щонайменше при цій ручній перевірці. Але чи вона пройде тест що ми написали?

you@localhost:~/diveintopython3/examples$ python3 romantest1.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok               ①

----------------------------------------------------------------------
Ran 1 test in 0.016s

OK

① Ура! Функція to_roman() проходить тест з відомими значеннями. Це звичайно не повний тест, але він прогяняє функцію крізь різноманітні вхідні дані, зокрема вхідні що перетворюються на кожне однолітерне римське число, найбільший можливий ввід, і ввід що створює найдовше можливе римське число (3888). В даний момент ви можете мати цілком обґрунтовану впевненість що функція працює для будь-яких дозволених значень які ви їй передасте.

"Дозволений" вхід? Хммм. А як там з недозволеним?


* * *


Зупинитись та загорітись

[ред.]
Пітонівський спосіб зупинитись і загорітись - кинути виняток.

Недостатньо тестувати те що функція працює успішно при правильному вводі, також потрібно перевірити те що функція відмовляється працювати на недозволених вхідних даних. І це не повинна бути якась випадкова відмова, вони повинні провалюватись в спосіб який ми очікуємо.

>>> import roman1
>>> roman1.to_roman(4000)
'MMMM'
>>> roman1.to_roman(5000)
'MMMMM'
>>> roman1.to_roman(9000) 
'MMMMMMMMM'

① Це явно те не що ми хотіли - таких римських чисел взагалі не існує! Насправді кожне з вхідних неможливо представити в римській системі, але те що повертає функція все одно неприпустимо. Тихо повертати неправильні результати дууууже погааааано. Якщо програма збирається зробити помилку, набагато краще коли вона впаде швидко і шумно. "Halt and catch fire", як то кажуть. В Python аналогом інструкції halt and catch fire є генерація винятку.

Питання яке ми мусимо собі задати "Як виразити це як вимогу яку можна протестувати?". Для початку ось як:

Функція to_roman() повинна згенерувати виняток OutOfRangeError коли отримає на вхід ціле число більше ніж 3999.

Як виглядатиме тест?

import unittest, roman2
class ToRomanBadInput(unittest.TestCase):                                 
    def test_too_large(self):                                             
        '''to_roman should fail with large input'''
        self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)  

① Як і в попередньому тесті, ми створємо клас що наслідується від unittest.TestCase. Ви можете вкладати більш ніж один тест всередину класу (як буде продемонстровано далі в цьому розділі), але я б надав перевагу окремому клас, тому що цей тест перевіряє дещо інше ніж попередній. Ми зберемо всі тести з гарними вхідними даними в одному класі, а всі тести з поганими вхідними даними - в іншому.

② Як і в попередньому тесті, сам тест - це метод класу, ім’я якого починається з префіксу test.

unittest.TestCase має метод assertRaises, який приймає такі аргументи: тип очікуваного винятку, функція що тестується, і аргументи що потрібно передати функції. (Якщо функція приймає багато різних аргументів передавайте їх в такому ж порядку і вони будуть передані далі.)

Добре придивіться до останнього рядка. Замість того щоб викликати to_roman() прямо, і вручну перевірити що вона генерує конкретний виняток (загортанням у блок try...except), ми дозволяємо методу assertRaises зробити все за нас. Єдине що ми робимо - уточняємо якого винятку ми очікуємо (roman2.OutOfRangeError), яку функцію викликати (to_roman()), та з якими аргументами (4000).

Також зверніть увагу що ви передаєте функцію to_roman() як аргумент, ви не викликаєте її, і не передаєте її ім’я як рядок. Я вже згадував як зручно те що в Python все - об’єкт?

Отож, що відбудеться коли ми запустимо тестування разом з новими тестами?

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ERROR ①

======================================================================
ERROR: to_roman should fail with large input
----------------------------------------------------------------------
Traceback (most recent call last):
 File "romantest2.py", line 78, in test_too_large
  self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)
AttributeError: 'module' object has no attribute 'OutOfRangeError' ②

----------------------------------------------------------------------
Ran 2 tests in 0.000s FAILED (errors=1)

① Ви повинні були очікувати що результатом тесту буде "fail" (провал) (так як ми ще не написали код який міг би його пройти), але насправді замість "fail" ми отримали "error". Різниця між ними тонка, але важлива. Тест може завершитись трьома способами: бути пройденим, провалитись, або повернути помилку. Провал тесту означає те що код, що тестується, повернув неправильні дані. Помилка тесту означає що код, що тестується, взагалі не вдалось виконати. ② Чому код не виконався правильно? Трейсбек все пояснює. Модуль який ми тестуємо не містить винятку що називається OutOfRangeError. Пам'ятаєте, ми передали цей виняток в метод assertRaises(), тому що це виняток який ми очікуємо від функції, що отримує дані в недозволеному діапазоні. Але такий виняток не існує, тому нам навіть не вдається викликати assertRaises(), який не отримує можливості протестувати функцію to_roman(), він просто до цього ще не дійшов.

Щоб розв'язати цю проблему потрібно описати OutOfRangeError в roman2.py.

class OutOfRangeError(ValueError):  
    pass                            

① Винятки є класами. Помилка "недозволений діапазон" - різновид помилки значення (ValueError) - значення аргументу не є дозволеним. Тому цей виняток наслідується від вбудованого винятку ValueError. Це не є обов'язковою вимогою (можна просто наслідуватись від базового класу Exception), але це видається правильним. ② Винятки нічого не роблять, але клас повинен мати хоча б один рядок коду. Виклик pass нічого не робить, але це рядок коду, тому клас створюється.

Тепер давайте знову запустимо тести.

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... FAIL ①

======================================================================
FAIL: to_roman should fail with large input
----------------------------------------------------------------------
Traceback (most recent call last):
 File "romantest2.py", line 78, in test_too_large
  self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)
AssertionError: OutOfRangeError not raised by to_roman ②

----------------------------------------------------------------------
Ran 2 tests in 0.016s

FAILED (failures=1)

① Новий тест все ще не проходиться, але принаймі виконується без помилок, і повідомляє про провал. Це прогрес! Це означає що виклик assertRaises() цього разу був успішним, і фреймворк unittest справді тестував функцію to_roman(). ② Звісно, функція to_roman() не генерує щойностворений виняток OutOfRangeError, бо ми поки що не наказували їй це робити. Але це добре! Це означає що це правильний тест, він провалюється до того як ви напишете код, що його проходить.

Тепер можна писати код щоб цей тест проходився.

def to_roman(n):
    '''convert integer to Roman numeral'''
    if n > 3999:
        raise OutOfRangeError('number out of range (must be less than 4000)')

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result

Тут все прямолінійно: якщо даний ввід (n) більший за 3999, згенерувати виняток OutOfRangeError. Тест не перевіряє рядок з поясненням помилки для людей, хоча можна написати інший тест який це перевірятиме (правда потрібно стерегтись проблем з інтернаціоналізацією).

Цей код пройде тест? Давайте перевіримо.

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput) to_roman should fail with large input ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK

Ураа! Обидва тести пройдено. Завдяки тому, що ви працювали ітеративно, перідично займаючись то тестуванням, то кодом, ви можете бути впевненими що два рядки коду які ми щойно написали були причиною того що тест який спочатку провалювався почав проходитись. Цей вид впевненості не приходить дешево, але він окупиться впродовж життя вашого коду.


* * *


Більше зупинок, більше вогню

[ред.]

Поряд з тестуванням чисел які занадто великі, потрібно тестувати числа які занадто малі. Як ми зауважили в функціональних вимогах, римський запис не може виражати 0 чи від'ємні числа.

>>> import roman2
>>> roman2.to_roman(0)
''
>>> roman2.to_roman(-1)
''

Ну, це не є добре. Давайте додамо тести для кожного з цих випадків.

class ToRomanBadInput(unittest.TestCase):
    def test_too_large(self):
        '''to_roman should fail with large input'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 4000)  

    def test_zero(self):
        '''to_roman should fail with 0 input'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 0)     

    def test_negative(self):
        '''to_roman should fail with negative input'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, -1)    

① Метод test_too_large() не змінився з попереднього кроку. Я включаю його тут для того щоб показати куди додається новий код.

② Ось новий тетс: метод test_zero(). Як і метод test_too_large() він викликає метод assertRaises() що описаний в класі unittest.TestCase щоб викликати нашу функію to_roman() з параметром 0, і перевірити що вона генерує відповідний виняток, OutOfRangeError.

③ Метод test_negative() майже ідентичний, окрім того що він передає в функцію to_roman() значення -1. Якщо будь-який з цих методів не згенерує OutOfRangeError (тому що функція поверне якесь значення, чи тому що згенерує якийсь інший виняток), тест буде вважатись проваленим.

Тепер давайте перевіримо що тести валяться:

you@localhost:~/diveintopython3/examples$ python3 romantest3.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... FAIL
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... FAIL

======================================================================
FAIL: to_roman should fail with negative input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest3.py", line 86, in test_negative
    self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, -1)
AssertionError: OutOfRangeError not raised by to_roman

======================================================================
FAIL: to_roman should fail with 0 input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest3.py", line 82, in test_zero
    self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 0)
AssertionError: OutOfRangeError not raised by to_roman

----------------------------------------------------------------------
Ran 4 tests in 0.000s

FAILED (failures=2)

Чудово. Обидва тести провалились, як і очікувалось. Тепер пора переключитись на код, і глянути що можна зробити.

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 4000):                                              
        raise OutOfRangeError('number out of range (must be 1..3999)')  

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result

① Це гарне скорочення Python: кілька порівнянь за раз. Воно еквівалентне if not ((0 < n) and (n < 4000)), але читається набагато простіше. Цей один рядок коду повинен виявляти ввід який завеликий, від'ємний чи нуль.

② Після того як ви змінили умову, змініть також і пояснення помилки. Фреймворку unittest все одно, але через неправильно описані винятки код буде важче зневаджувати.

Я міг би показати багато прикладів що демонструють те що багато порівнянь за раз працюють, але замість цього я просто запущу тести, і доведу це.

you@localhost:~/diveintopython3/examples$ python3 romantest3.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.016s

OK


* * *


І ще одне

[ред.]

Була ще одна функціональна вимога щодо перетворення чисел в римський запис: робота з нецілими числами.

>>> import roman3
>>> roman3.to_roman(0.5) 
''
>>> roman3.to_roman(1.0) 
'I'

① Ой, це погано.

② Ой, це навіть гірше. Обидва випадки повинні були б генерувати виняток, а замість цього вони повертають неприпустимі результати.

Тестуваня на нецілі нескладне. Спершу, давайте опишемо виняток NotIntegerError.

# roman4.py
class OutOfRangeError(ValueError): pass
class NotIntegerError(ValueError): pass

Далі, напишімо тест що перевіряє на виняток NotIntegerError.

class ToRomanBadInput(unittest.TestCase):
    .
    .
    .
    def test_non_integer(self):
        '''to_roman should fail with non-integer input'''
        self.assertRaises(roman4.NotIntegerError, roman4.to_roman, 0.5)

І перевіримо що тест правильно валиться.

you@localhost:~/diveintopython3/examples$ python3 romantest4.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_non_integer (__main__.ToRomanBadInput)
to_roman should fail with non-integer input ... FAIL
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok

======================================================================
FAIL: to_roman should fail with non-integer input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest4.py", line 90, in test_non_integer
    self.assertRaises(roman4.NotIntegerError, roman4.to_roman, 0.5)
AssertionError: NotIntegerError not raised by to_roman

----------------------------------------------------------------------
Ran 5 tests in 0.000s

FAILED (failures=1)

І напишемо код що проходить тест.

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 4000):
        raise OutOfRangeError('number out of range (must be 1..3999)')
    if not isinstance(n, int):                                          ①
        raise NotIntegerError('non-integers can not be converted')      ②

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result

① Вбудована функція isinstance() перевіряє чи належить змінна певному типу (чи, технічно, будь-якому типу нащадку).

② Якщо аргумент n - не типу int, згенерувати наш новий NotIntegerError.

І нарешті, перевірмо що наші зміни знову примусили тести проходитись.

you@localhost:~/diveintopython3/examples$ python3 romantest4.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_non_integer (__main__.ToRomanBadInput)
to_roman should fail with non-integer input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 5 tests in 0.000s

OK

Функція to_roman() проходить всі тести, і я не можу придумати ніяких інших, тому час перейти до функції from_roman().


* * *


Приємна симетрія

[ред.]

Перетворення рядка з Римського запису в арабський звучить складніше ніж перетворення з цілого в Римське. Звичайно є проблема з перевіркою правильності вводу. Простіше перевірити що ціле більше за нуль ніж перевірити що рядок - це дозволене римське число. Але ми вже сконструювали регулярний вираз для перевірки римських чисел, тому ця частина зроблена.

Але це залишає проблему самого перетворення. Як ми побачимо через хвилину, завдяки гарній структурі даних що ми описали для відображення окремих Римських чисел в цілі, дрібні деталі реалізації функції from_roman() такі ж прості як і в функції to_roman().

Але спочатку тести. Для першого тесту нам потрібні "відомі значення". Наш набір тестів, вже має такі значення, давайте використаємо їх повторно.

def test_from_roman_known_values(self):
        '''from_roman should give known result with known input'''
        for integer, numeral in self.known_values:
            result = roman5.from_roman(numeral)
            self.assertEqual(integer, result)

Тут є приємна симетрія. Функції to_roman() та from_roman() є оберненими одна до одної. Перша перетворює цілі числа в спеціальним чином відформатовані рядки, друга перетворює такі рядки в числа. В теорії, ми повинні бути здатними послати число "навкруг", передавши його в функцію to_roman() щоб отримати рядок, потім передавши цей рядок в функцію from_roman() щоб отримати ціле число, яке буде дорівнювати тому з якого ми починали

n = from_roman(to_roman(n)) для всіх n

В цьому випадку "для всіх" означає будь-яке число в межах 1..3999, так як тільки такі дозволено передавати в to_roman(). Цю симетрію можна виразити в тесті, що перебирає всі значення цього діапазону, викликає to_roman(), потім from_roman(), і перевіряє що отримане число збігається з тим яке ми намагались перевірити.

class RoundtripCheck(unittest.TestCase):
    def test_roundtrip(self):
        '''from_roman(to_roman(n))==n for all n'''
        for integer in range(1, 4000):
            numeral = roman5.to_roman(integer)
            result = roman5.from_roman(numeral)
            self.assertEqual(integer, result)

Ці нові тести поки що навіть не валяться. Ми ще взагалі не описували функцію from_roman(), тому вони генерують помилки.

you@localhost:~/diveintopython3/examples$ python3 romantest5.py
E.E....
======================================================================
ERROR: test_from_roman_known_values (__main__.KnownValues)
from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 78, in test_from_roman_known_values
    result = roman5.from_roman(numeral)
AttributeError: 'module' object has no attribute 'from_roman'

======================================================================
ERROR: test_roundtrip (__main__.RoundtripCheck)
from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 103, in test_roundtrip
    result = roman5.from_roman(numeral)
AttributeError: 'module' object has no attribute 'from_roman'

----------------------------------------------------------------------
Ran 7 tests in 0.019s

FAILED (errors=2)

Нашвидкоруч вставлена заглушка вирішить цю проблему.

# roman5.py
def from_roman(s):
    '''convert Roman numeral to integer'''

(Хей, ви помітили? Я щойно описав функцію що не містить нічого крім докстрінґа. Це легальний Python. І деякі програмісти клянуться: "Не створюйте просто заглушки, документуйте!")

Тепер тести впадуть правильно.

you@localhost:~/diveintopython3/examples$ python3 romantest5.py
F.F....
======================================================================
FAIL: test_from_roman_known_values (__main__.KnownValues)
from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 79, in test_from_roman_known_values
    self.assertEqual(integer, result)
AssertionError: 1 != None

======================================================================
FAIL: test_roundtrip (__main__.RoundtripCheck)
from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 104, in test_roundtrip
    self.assertEqual(integer, result)
AssertionError: 1 != None

----------------------------------------------------------------------
Ran 7 tests in 0.002s

FAILED (failures=2)

І прийшов час написати from_roman().

def from_roman(s):
    """convert Roman numeral to integer"""
    result = 0
    index = 0
    for numeral, integer in roman_numeral_map:
        while s[index:index+len(numeral)] == numeral:  ①
            result += integer
            index += len(numeral)
    return result

① Підхід тут такий самий як і в функції to_roman(). Ми ітеруємо крізь структуру даних (кортеж кортежів), але замість того щоб вибирати найбільше можливе ціле число, так часто як можемо, ми вибираємо "найбільшу" послідовність римських цифр, так часто як це можливо.

Якщо вам не ясно як працює from_roman(), додайте команду print в кінець циклу:

def from_roman(s):
    """convert Roman numeral to integer"""
    result = 0
    index = 0
    for numeral, integer in roman_numeral_map:
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
            print('found', numeral, 'of length', len(numeral), ', adding', integer)

>>> import roman5
>>> roman5.from_roman('MCMLXXII')
found M of length 1, adding 1000
found CM of length 2, adding 900
found L of length 1, adding 50
found X of length 1, adding 10
found X of length 1, adding 10
found I of length 1, adding 1
found I of length 1, adding 1
1972

Пора перезапустити тести.

you@localhost:~/diveintopython3/examples$ python3 romantest5.py
.......
----------------------------------------------------------------------
Ran 7 tests in 0.060s

OK

В нас є дві захоплюючі новини. Перша, що функція from_roman() працює для правильного вводу, щонайменше для всіх відомих значень. Друга - що "обернений" тест також проходиться. В комбінації з тестом на відомих значеннях, ви можете бути впевненими що обидві функції to_roman() та from_roman() працюють правильно для всіх можливих дозволених вхідних даних. (Це не гарантується, теоретично можливо що to_roman() містить помилку, через яку генерує неправильні римські числа для деяких конкретних вхідних даних, і що from_roman() містить зворотню помилку, яка дозволяє приймати неправильно згенеровані числа, і перетворювати їх в ті дані які функція to_roman() отримала на вхід. Залежно від застосування і вимог така можливість може вас хвилювати. В такому разі пишіть більше тестів, поки не перестанете хвилюватись.)


* * *


Більше поганого вводу

[ред.]

Тепер коли, функція from_roman() працює правильно з гарним вводом, пора скласти останню частину головоломки: змусити її правильно працювати з поганим вводом. Це означає що потрібно знайти спосіб визначити чи є послідовність символів правильним римським числом. Це по суті складніше ніж перевірка правильності цілого числа в функції to_roman(), але в нас є потужний інструмент: регулярні вирази. (Якщо ви досі не знайомі з регулярними виразами, зараз саме час щоб прочитати відповідний розділ.)

Як ми бачили в прикладі з римськими числами, існує кілька простих правил для того щоб сконструювати римське число використовуючи літери M, D, C, L, X, V, та I. Давайте згадаємо ці правила:

  • Іноді символи аддитивні. I це 1, II це 2, і III це 3. VI це 6 (буквально, "5 і 1"), VII це 7, і VIII - 8.
  • Символи що позначають степені десяти (I, X, C, та M) можуть повторюватись до трьох разів. Далі потрібно віднімати від наступного за значенням символа, зазвичай кратного 5. Не можна записувати 4 як IIII, замість цього пишуть IV (на 1 менше ніж 5). 40 записується як XL (на 10 менше ніж 50), 41 як XLI, 42 як XLII, 43 як XLIII, а 44 як XLIV (на 10 менше ніж 50, і ще на одиницю менше ніж 5).
  • Іноді символи ... пряма протилежність аддитивним. Ставлячи деякі символи перед іншими, ви віднімаєте їх від загального значення. Наприклад щоб записати 9, ви віднімаєте 1 від 10, записуючи це як IX. 8 це VIII, але 9 - це IX, бо не можна писати VIIII (дивись попереднє правило). 90 це XC, 900 - CM.
  • Символи що не є степенями 10 не повторюються двічі підряд. 10 записується як X, і ніколи як VV. 100 - завжди C, і ніколи не LL.
  • Римські цифри читаються зліва направо, і порядок символів має велике значення. DC - це 600, а CD - зовсім інше значення - 400 (на 100 менше ніж 500). CI це 101, а IC навіть не є правильним римським числом, бо не можна віднімати 1 одразу від 100. 99 записується як XCIX (на 10 менше ніж 100, і ще на 1 менше ніж 10).

Тому, одним з корисних тестів було б переконатись що функція from_roman() не спрацьовує коли їй передавати рядок в якому забагато повторюваних символів. Скільке саме буде "забагато" залежить від конкретного символа.

class FromRomanBadInput(unittest.TestCase):
    def test_too_many_repeated_numerals(self):
        '''from_roman should fail with too many repeated numerals'''
        for s in ('MMMM', 'DD', 'CCCC', 'LL', 'XXXX', 'VV', 'IIII'):
            self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)

Іншим корисним тестом було б перевірити що деякі послідовності не повторюються. Наприклад IX це 9, але IXIX ніколи не зустрічається в римських числах.

    def test_repeated_pairs(self):
        '''from_roman should fail with repeated pairs of numerals'''
        for s in ('CMCM', 'CDCD', 'XCXC', 'XLXL', 'IXIX', 'IVIV'):
            self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)

Третій тест міг би перевіряти що символи зустрічаються в правильному порядку, від найбільших до найменших. Наприклад, CL це 150, але LC неправильне, бо символ для позначення 50 ніколи не зустрічається перед символом для 100. Цей тест включає випадковий набір неправильних послідовностей: I перед M, V перед X, і подібні.

    def test_malformed_antecedents(self):
        '''from_roman should fail with malformed antecedents'''
        for s in ('IIMXCC', 'VX', 'DCM', 'CMM', 'IXIV',
                  'MCMC', 'XCX', 'IVI', 'LM', 'LD', 'LC'):
            self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)

Кожен з цих тестів передбачає що функція from_roman() згенерує новий виняток, InvalidRomanNumeralError, який ми навіть поки що не описали.

# roman6.py
class InvalidRomanNumeralError(ValueError): pass

Всі три тести повинні провалитись, так як функція from_roman() поки що не містить жодних перевірок коректності. (Якщо тести зараз не валяться, що вони тоді взагалі тестують?)

you@localhost:~/diveintopython3/examples$ python3 romantest6.py
FFF.......
======================================================================
FAIL: test_malformed_antecedents (__main__.FromRomanBadInput)
from_roman should fail with malformed antecedents
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest6.py", line 113, in test_malformed_antecedents
    self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

======================================================================
FAIL: test_repeated_pairs (__main__.FromRomanBadInput)
from_roman should fail with repeated pairs of numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest6.py", line 107, in test_repeated_pairs
    self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

======================================================================
FAIL: test_too_many_repeated_numerals (__main__.FromRomanBadInput)
from_roman should fail with too many repeated numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest6.py", line 102, in test_too_many_repeated_numerals
    self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

----------------------------------------------------------------------
Ran 10 tests in 0.058s

FAILED (failures=3)

Чудово. Тепер, все що залишилось - це додати регулярний вираз що перевіряє що функції from_roman() передали дозволене римське число.

roman_numeral_pattern = re.compile('''
    ^                   # beginning of string
    M{0,3}              # thousands - 0 to 3 Ms
    (CM|CD|D?C{0,3})    # hundreds - 900 (CM), 400 (CD), 0-300 (0 to 3 Cs),
                        #            or 500-800 (D, followed by 0 to 3 Cs)
    (XC|XL|L?X{0,3})    # tens - 90 (XC), 40 (XL), 0-30 (0 to 3 Xs),
                        #        or 50-80 (L, followed by 0 to 3 Xs)
    (IX|IV|V?I{0,3})    # ones - 9 (IX), 4 (IV), 0-3 (0 to 3 Is),
                        #        or 5-8 (V, followed by 0 to 3 Is)
    $                   # end of string
    ''', re.VERBOSE)

def from_roman(s):
    '''convert Roman numeral to integer'''
    if not roman_numeral_pattern.search(s):
        raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))

    result = 0
    index = 0
    for numeral, integer in roman_numeral_map:
        while s[index : index + len(numeral)] == numeral:
            result += integer
            index += len(numeral)
    return result

І перезапустити тести...

you@localhost:~/diveintopython3/examples$ python3 romantest7.py
..........
----------------------------------------------------------------------
Ran 10 tests in 0.066s

OK

І нагорода "заспокійливий засіб року" присуджується... слову "OK", яке друкується модулем unittest коли всі тести пройдено.


Рефакторинг

[ред.]

Простота є кінцевим досягненням. Після того як хтось зіграє величезну кількість нот, а потім ще більше нот, простота виникне як королівська нагорода мистецтва.
Фредерік Шопен


Подобається вам це чи ні, але помилки трапляються. Незважаючи на ваші найкращі спроби писати вичерпні модульні тести, помилки трапляються. Що я маю на увазі під словом "помилка"? Помилка - це ще не написаний тест.

>>> import roman7
>>> roman7.from_roman('') ①
0

Це помилка. Порожній рядок має призводити до виключення InvalidRomanNumeralError, як і будь-яка інша послідовність символів що не являє собою правильне римське число.

Після відтворення помилки, і перед її виправленням потрібно написати тест який провалиться, таким чином ілюструючи помилку.

class FromRomanBadInput(unittest.TestCase):  
    .
    .
    .
    def testBlank(self):
        '''from_roman should fail with blank string'''
        self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, '')

Тут все досить просто. Потрібно викликати from_roman() з порожнім рядком та переконатись що генерується виняток InvalidRomanNumeralError. Найважчою частиною було знайти помилку, тепер коли ми про неї знаємо, тестування це легша частина.

Так як в коді є помилка, а ми маємо тест що її повинен виловити, набір тестів повинен звалитись:

you@localhost:~/diveintopython3/examples$ python3 romantest8.py -v
from_roman should fail with blank string ... FAIL
from_roman should fail with malformed antecedents ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok

======================================================================
FAIL: from_roman should fail with blank string
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest8.py", line 117, in test_blank
    self.assertRaises(roman8.InvalidRomanNumeralError, roman8.from_roman, )
AssertionError: InvalidRomanNumeralError not raised by from_roman

----------------------------------------------------------------------
Ran 11 tests in 0.171s

FAILED (failures=1)

Тепер ви можете виправити помилку.

def from_roman(s):
    '''convert Roman numeral to integer'''
    if not s:
        raise InvalidRomanNumeralError('Input can not be blank')

Потрібно лише два рядки коду: явна перевірка на те що рядок порожній, і команда raise.

    if not re.search(romanNumeralPattern, s):
        raise InvalidRomanNumeralError('Invalid Roman numeral: {}'.format(s))

    result = 0
    index = 0
    for numeral, integer in romanNumeralMap:
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
    return result

Не думаю що я згадував про це десь в книзі, але нехай це послужить вашим останнім уроком форматування рядків. Починаючи з Python 3.1, можна опускати числа, при використанні позиційних індекстів в специфікаторі формату. Тобто замість того щоб використовувати специфікатор {0} для того щоб послатись на перший аргумент методу format(), ви можете просто написати {} і Python заповнить відповідний позиційний індекс за вас. Це працює для будь-якої кількості аргументів: перший {} відповідає {0}, другий {} - {1}, і так далі.

you@localhost:~/diveintopython3/examples$ python3 romantest8.py -v
from_roman should fail with blank string ... ok
from_roman should fail with malformed antecedents ... ok 
from_roman should fail with repeated pairs of numerals ... ok 
from_roman should fail with too many repeated numerals ... ok 
from_roman should give known result with known input ... ok 
to_roman should give known result with known input ... ok 
from_roman(to_roman(n))==n for all n ... ok 
to_roman should fail with negative input ... ok 
to_roman should fail with non-integer input ... ok 
to_roman should fail with large input ... ok 
to_roman should fail with 0 input ... ok 
----------------------------------------------------------------------
Ran 11 tests in 0.156s


OK

Тест з порожнім рядком тепер проходиться, помилка виправлена. І всі інші тести теж проходяться, що означає що виправляючи цю помилку ми нічого не поламали. Припиняємо кодування.

Кодування в такий спосіб не полегшує виправлення помилок. Прості помилки (як от ця) потребують простих тестів, складніші помилки потребуватимуть складніших тестів. В спрямованому на тестування середовищі може здатись ніби для виправлення помилки потрібно більше часу, так як потрібно точно описати кодом в чому полягає помилка (щоб написати тест), а потім виправити її саму. Тоді, якщо тест не проходиться, потрібно з'ясувати де помилка в виправленні помилки, чи де помилка в самому тесті. Тим не менш, в довгостроковій перспективі такий підхід окупає себе, тому що він збільшує ймовірність того що помилки виправляються правильно з першого разу. Також, так як можна просто перезапустити всі тести разом з новим, зменшується ймовірність того що ви зламаєте старий код поки будете тестувати новий. Сьогоднішній модульний тест є завтрашнім регресивним тестом.


* * *


Справляємось зі змінними вимогами

[ред.]

Незважаючи на ваші найкращі спроби опустити своїх замовників на землю, та видобути з них точні вимоги, погрожуючи болем від жахливих загрозливих речей, що включають ножиці та гарячий віск, вимоги будуть змінюватись. Більшість замовників не знають що їм потрібно поки вони цього не побачать, а навіть якщо знають, то не настільки точно можуть це сформулювати, аби цим можна було б скористатись. А навіть якщо зможуть, то в будь-якому разі, вони захочуть ще більше в наступному випуску. Тому готуйтесь до оновлення набору тестів при зміні вимог.

Припустимо наприклад що ми хочемо розширити діапазон роботи функцій з перетворення римських чисел. Зазвичай в римській системі символ не може бути повтореним більш ніж тричі підряд. Але Римляни захотіли зробити виняток з правила, і писали 4 символи M підряд для того щоб записати число 4000. Якщо й ми зробимо таку зміну, ми зможемо розширити діапазон чисел що можна записати в римській системі до 1..4999. Але спершу потрібно зробити зміни в тестах.

class KnownValues(unittest.TestCase):
    known_values = ( (1, 'I'),
                      .
                      .
                      .
                     (3999, 'MMMCMXCIX'),
                     (4000, 'MMMM'),
                     (4500, 'MMMMD'),
                     (4888, 'MMMMDCCCLXXXVIII'),
                     (4999, 'MMMMCMXCIX') )

Існуючі значення не змінились (на них все ще можна проводити тести), але потрібно додати ще кілька в діапазоні 4000. Тут я додав 4000 (найкоротше), 4500 друге за довжиною, 4888 (найдовше) та 4999 (найбільше).

class ToRomanBadInput(unittest.TestCase):
    def test_too_large(self):
        '''to_roman should fail with large input'''
        self.assertRaises(roman8.OutOfRangeError, roman8.to_roman, 5000)

Означення "завеликого числа" змінилось. Цей тест викликав to_roman() з аргументом 4000 і очікував помилки. Тепер числа від 4000 до 4999 правильні, тому потрібно збільшити цей аргумент до 5000.

    .
    .
    .

class FromRomanBadInput(unittest.TestCase):
    def test_too_many_repeated_numerals(self):
        '''from_roman should fail with too many repeated numerals'''
        for s in ('MMMMM', 'DD', 'CCCC', 'LL', 'XXXX', 'VV', 'IIII'):
            self.assertRaises(roman8.InvalidRomanNumeralError, roman8.from_roman, s)

Означення "забагато повторених символів" також змінилось. Цей тест викликав from_roman() передаючи йому 'MMMM' та очікував помилки. Тепер, коли MMMM вважається правильним римським числом, потрібно збільшити це значення до 'MMMMM'.

    .
    .
    .

class RoundtripCheck(unittest.TestCase):
    def test_roundtrip(self):
        '''from_roman(to_roman(n))==n for all n'''
        for integer in range(1, 5000):
            numeral = roman8.to_roman(integer)
            result = roman8.from_roman(numeral)
            self.assertEqual(integer, result)

Перевірка на здоровий глузд перевіряла кожне число в діапазоні від 1 до 3999. Так як діапазон розширився, цикл також потрібно розширити до 4999.

Тепер ваші тести відповідають новим вимогам. Але код все ще ні, тому ми очікуємо що кілька тестів повинні провалитись.

you@localhost:~/diveintopython3/examples$ python3 romantest9.py -v
from_roman should fail with blank string ... ok
from_roman should fail with malformed antecedents ... ok
from_roman should fail with non-string input ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ERROR ①
to_roman should give known result with known input ... ERROR ②
from_roman(to_roman(n))==n for all n ... ERROR ③
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok


======================================================================
ERROR: from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest9.py", line 82, in test_from_roman_known_values
    result = roman9.from_roman(numeral)
  File "C:\home\diveintopython3\examples\roman9.py", line 60, in from_roman
    raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
roman9.InvalidRomanNumeralError: Invalid Roman numeral: MMMM


======================================================================
ERROR: to_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest9.py", line 76, in test_to_roman_known_values
    result = roman9.to_roman(integer)
  File "C:\home\diveintopython3\examples\roman9.py", line 42, in to_roman
    raise OutOfRangeError('number out of range (must be 0..3999)')
roman9.OutOfRangeError: number out of range (must be 0..3999)


======================================================================
ERROR: from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest9.py", line 131, in testSanity
    numeral = roman9.to_roman(integer)
  File "C:\home\diveintopython3\examples\roman9.py", line 42, in to_roman
    raise OutOfRangeError('number out of range (must be 0..3999)')
roman9.OutOfRangeError: number out of range (must be 0..3999)


----------------------------------------------------------------------
Ran 12 tests in 0.171s


FAILED (errors=3)

① Тест перетворення відомих значень з римської системи провалюється як тільки доходить до 'MMMM' тому що from_roman() досі думає що це недозволене римське число.

② Тест перетворення відомих значень в римську систему провалюється як тільки доходить до 4000, тому що to_roman() досі думає що це число виходить за межі діапазону

③ Кругова перевірка теж провалюється через ніби-то вихід за межі діапазону.

Тепер, коли ми маємо тести що провалюються згідно нових вимог, можна подумати і про те щоб виправити код так аби він відповідав тестам. (Коли ви тільки починаєте програмувати використовуючи тести, вам може бути дивно від того що код який тестується ніколи не "випереджає" тести. Поки він позаду, ви все ще повинні щось зробити, але як тільки код доганяє тести, ви припиняєте програмування. Після того як ви до цього звикнете, ви будете дивуватись як ви взагалі програмували без тестів.)

roman_numeral_pattern = re.compile('''
    ^                   # beginning of string
    M{0,4}              # thousands - 0 to 4 Ms  ①
    (CM|CD|D?C{0,3})    # hundreds - 900 (CM), 400 (CD), 0-300 (0 to 3 Cs),
                        #            or 500-800 (D, followed by 0 to 3 Cs)
    (XC|XL|L?X{0,3})    # tens - 90 (XC), 40 (XL), 0-30 (0 to 3 Xs),
                        #        or 50-80 (L, followed by 0 to 3 Xs)
    (IX|IV|V?I{0,3})    # ones - 9 (IX), 4 (IV), 0-3 (0 to 3 Is),
                        #        or 5-8 (V, followed by 0 to 3 Is)
    $                   # end of string
    ''', re.VERBOSE)

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not isinstance(n, int):
        raise NotIntegerError('non-integers can not be converted')
    if not (0 < n < 5000):                        ②
        raise OutOfRangeError('number out of range (must be 1..4999)')

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result

def from_roman(s):
    .
    .
    .

① Вам взагалі не потрібно робити жодних змін в функції from_roman(). Єдине, що потрібно змінити - регулярний вираз roman_numeral_pattern. Якщо придивитись уважно, зрозуміло що я змінив максимально дозволену кількість символів M в першій частині регулярного виразу з 3 до 4. Ну а сама функція from_roman() досить загальна, вона просто рахує кількості повторюваних римських цифр і додає їх, абсолютно не зважаючи на те скільки разів вони повторюються. Єдина причина через яку вона не опрацьовувала рядок 'MMMM' - тому що ми явно заборонили їй це за допомогою регулярного виразу.

② В функції to_roman() потрібна лише одна маленька зміна - перевірка діапазону. Там де в нас стояла умова 0 < n < 4000, тепер стоїть 0 < n < 5000. А також потрібно змінити повідомлення про помилку, яке тепер повинно вказувати на новий дозволений діапазон. Ніяких змін в решті коду функції робити не потрібно, вона вже і так справляється з новими випадками. (Вона просто радісно додає 'M' для кожної знайденої тисячі, якщо їй передати 4000, вона поверне 'MMMM'. Єдина причина через яку вона не зробила це раніше - ми примусово її зупинили за допомогою перевірки діапазону.)

Ви можете засумніватись що такі невеликі зміни це все що нам необхідно. Але не потрібно вірити мені на слово, переконайтесь самі.

you@localhost:~/diveintopython3/examples$ python3 romantest9.py -v
from_roman should fail with blank string ... ok
from_roman should fail with malformed antecedents ... ok
from_roman should fail with non-string input ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 12 tests in 0.203s

OK

Всі тести пройдено. Припиніть програмування.

Вичерпний набір тестів означає що вам не доведеться покладатись на програміста який каже "повір мені".


* * *


Рефакторинг

[ред.]

Найкраще, що дають вичерпні тести - це не відчуття, яке з'являється коли всі тести нарешті проходяться, і навіть не відчуття, яке з'являється коли хтось звинувачує вас в тому, що ви поламали їх код, а ви можете довести що нічого такого не зробили. Найкраще що дають модульні тести - це повна свобода здійснювати рефакторинг нещадно.

Рефакторинг - це процес зміни працюючого коду на краще. Зазвичай "краще" означає "швидше", але також може означати "використовуючи менше пам'яті", чи "використовуючи менше простору на диску", чи просто "більш елегантно". Що б це для вас, вашого проекту чи оточення не означало, рефакторинг дуже важливий для довготермінового здоров'я програми.

А тут "краще", означає як "швидше", так і "простіше підтримувати". Якщо конкретніше, функція from_roman() повільніша і складніша ніж я б хотів, через великі і заплутані регулярні вирази що використовуюються для перевірки приналежності числа до римських. Зараз ви можете подумати "Правда, регулярні вирази великі і важко читаються, але як інакше можна перевірити що певний рядок є римським числом?"

Відповідь: їх всього лише 5000, чому б не побудувати таблицю? Ця ідея виглядає навіть кращою коли ви усвідомлюєте що використовувати регулярні вирази взагалі не доведеться. Як тільки ви побудуєте таблицю для перетворення цілих чисел в римські, можна побудувати зворотню таблицю для перетворення римських чисел в звичайні. І коли виникне потреба перевірити що певний рядок є римським числом, можна буде просто подивитись в словник.

А що найкраще - в нас вже є повний набір модульних тестів. Можна змінити більше половини коду в модулі, а тести не зміняться. Це означає що ви можете довести - як собі так й іншим - що новий код працює так само як оригінальний.

class OutOfRangeError(ValueError): pass
class NotIntegerError(ValueError): pass
class InvalidRomanNumeralError(ValueError): pass

roman_numeral_map = (('M',  1000),
                     ('CM', 900),
                     ('D',  500),
                     ('CD', 400),
                     ('C',  100),
                     ('XC', 90),
                     ('L',  50),
                     ('XL', 40),
                     ('X',  10),
                     ('IX', 9),
                     ('V',  5),
                     ('IV', 4),
                     ('I',  1))

to_roman_table = [ None ]
from_roman_table = {}

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 5000):
        raise OutOfRangeError('number out of range (must be 1..4999)')
    if int(n) != n:
        raise NotIntegerError('non-integers can not be converted')
    return to_roman_table[n]

def from_roman(s):
    '''convert Roman numeral to integer'''
    if not isinstance(s, str):
        raise InvalidRomanNumeralError('Input must be a string')
    if not s:
        raise InvalidRomanNumeralError('Input can not be blank')
    if s not in from_roman_table:
        raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
    return from_roman_table[s]

def build_lookup_tables():
    def to_roman(n):
        result = ''
        for numeral, integer in roman_numeral_map:
            if n >= integer:
                result = numeral
                n -= integer
                break
        if n > 0:
            result += to_roman_table[n]
        return result

    for integer in range(1, 5000):
        roman_numeral = to_roman(integer)
        to_roman_table.append(roman_numeral)
        from_roman_table[roman_numeral] = integer

build_lookup_tables()

Давайте розіб'ємо це на засвоювані кусочки. Безсумнівно, що найважливіший - останній:

build_lookup_tables()

Ви можете зауважити, що це виклик функції, який не поміщений в блок if. Це не блок if __name__ == '__main__', він викликається коли модуль імпортується. (Важливо розуміти що модулі імпортуються лише раз, потім кешуються.) Якщо ви імпортуєте вже імпортований модуль, нічого не відбувається. Тому цей код буде викликано лише при першому імпорті модуля.)

То що робить функція build_lookup_tables()? Радий що ви спитали.

to_roman_table = [ None ]
from_roman_table = {}
.
.
.
def build_lookup_tables():
    def to_roman(n):                                ①
        result = ''
        for numeral, integer in roman_numeral_map:
            if n >= integer:
                result = numeral
                n -= integer
                break
        if n > 0:
            result += to_roman_table[n]
        return result

    for integer in range(1, 5000):
        roman_numeral = to_roman(integer)          ②
        to_roman_table.append(roman_numeral)       ③
        from_roman_table[roman_numeral] = integer

① Це дуже хитрий кусочок програмування... можливо занадто хитрий. Функція to_roman() описана вище. Вона бере значення з таблиці і їх повертає. Але функція build_lookup_tables() переозначає функцію to_roman() для того щоб справді здійснювати обчислення (як це було в попередньому прикладі, до того як ми додали таблиці). Всередині функції build_lookup_tables() виклик, to_roman() викличе цю переозначену версію. Як тільки ми повернемось з функції build_lookup_tables() переозначена версія зникає, вона визначена лише в локальному просторі імен функції build_lookup_tables().

② Цей рядок коду викликає переозначену функцію to_roman(), яка насправді обчислює римські числа.

③ Щойно ми отримуємо результат, можна додати число і його римське представлення до обох таблиць.

Як тільки обидві таблиці створено, решта коду працює дуже просто і швидко.

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 5000):
        raise OutOfRangeError('number out of range (must be 1..4999)')
    if int(n) != n:
        raise NotIntegerError('non-integers can not be converted')
    return to_roman_table[n]

Після такої ж перевірки діапазону як і раніше, функція to_roman() просто знаходить відповідне значення в таблиці і повертає його.

def from_roman(s):
    '''convert Roman numeral to integer'''
    if not isinstance(s, str):
        raise InvalidRomanNumeralError('Input must be a string')
    if not s:
        raise InvalidRomanNumeralError('Input can not be blank')
    if s not in from_roman_table:
        raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
    return from_roman_table[s]

Аналогічно, функція from_roman() зменшилась до кількох перевірок вхідних даних і одного рядка коду. Більше ніяких регулярних виразів. Ніяких циклів. Перетворення працює за O(1) в обидві сторони.

Але чи правильно воно працює? О, так, так, правильно. І я можу довести.

you@localhost:~/diveintopython3/examples$ python3 romantest10.py -v
from_roman should fail with blank string ... ok
from_roman should fail with malformed antecedents ... ok
from_roman should fail with non-string input ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 12 tests in 0.031s 

OK

Не те щоб ви цікавились, але все також працює швидко! Погляньте, майже вдесятеро швидше. Звичайно це не зовсім чесне порівняння, тому що ця версія довше імпортується (бо мусить побудувати таблиці). Але так як імпорт відбувається всього лише раз, ціна цього запуску поступово компенсується швидшими викликами функцій to_roman() та from_roman(). Так як під час тестів робиться кілька тисячи викликів цих функцій (одна лише перевірка еквівалентності зворотнього перетворення робить 10000), все дуже швидко окупається.

Мораль цієї історії?

  • Простота - це чудова якість.
  • Особливо коли справа стосується регуляних виразів.
  • Модульні тести можуть дати вам впевненість для здійснення великомаштабних рефакторингів.


* * *


Підсумок

[ред.]

Модульне тестування це сильна ідея, яка при правильній реалізації може зменшити витрати на підтримку, та збільшити гнучкість в будь-якому довготерміновому проекті. Також важливо розуміти що модульне тестування не панацея, Магічний Вирішувач Проблем, чи срібна куля. Писати гарні модульні тести важко, підтримка їх актуальності вимагає дисципліни (особливо коли користувачі аж пищать про виправлення критичних помилок). Модульні тести не є заміною інших форм тестування, включаючи функціональне тестування, інтеграційне тестування, та користувацькі тести прийнятності. Але вони можливі, працюють, і якщо ви одного разу побачите як вони працюють, ви дивуватиметесь як колись обходились без них.

Ці кілька розділів покрили багато основного, багато з чого навіть не стосується конкретно мови Python. Існує багато фреймворків для модульного тестування в різних мовах, і всі вони вимагають від вас розуміння наступних базових ідей:

  • Проектування тестів які є конкретними, автоматичними та незалежними.
  • Написання тестів до коду який вони тестуватимуть.
  • Написання тестів що дають функціям правильні дані і очікують правильних результатів.
  • Написання тестів що тестують поведінку функцій даючи тим неправильні дані.
  • Написання та оновлення тестів для того щоб вони відповідали новим вимогам.
  • Нещадний рефакторинг з метод покращення продуктивності, маштабованості, читабельності, підтримуваності чи будь-якої іншої -ності якої йому бракує.


Файли

[ред.]

Дев’ятимильна прогулянка - це не жарт, особливо під час дощу.
Гаррі Кемелман[2]


Мій ноутбук з Windows містив 38493 файли ще до того як я поставив хоч одну програму. Встановлення Python додало майже 3000 файлів до загальної суми. Файли є основною парадигмою зберігання даних в будь-якій поширеній операційній системі, ця ідея настільки вкоренилась, що більшості людей проблематично уявити альтернативу. Ваш комп'ютер, метафорично кажучи, потопає в файлах.

Читання текстових файлів

[ред.]

Перед тим як зчитати щось з файла потрібно його відкрити. Відкриття файлів у Python не може бути простіше:

a_file = open('examples/chinese.txt', encoding='utf-8')

Python має вбудовану функцію open(), яка приймає як параметр ім'я файла. В цьому прикладі це 'examples/chinese.txt'. Назва файла має 5 цікавих особливостей:

  1. Це не просто ім'я файла — це комбінація шляху та назви файла. Гіпотетично функція могла б мати 2 параметри — шлях та назву файла, але open() приймає лише один параметр. За потреби можна включити до імені файла частковий або повний шлях.
  2. Шлях використувує прямий слеш, але я не сказав яку операційну систему використовуємо. Windows використовує зворотні слеші для позначення директорій, а Mac OS X та Linux використовують прямі слеші. Але в Python, прямі слеші завжди Просто Працюють, навіть у Windows.
  3. Шлях не починається зі слешу або літери диску, тобто це відносний шлях. Ви можливо запитаєте відносно чого? Терпіння.
  4. Це рядок. Всі сучасні операційні системи (навіть Windows!) використовують Unicode для зберігання імен файлів та директорій. Python 3 повністю підтримує не-ASCII імена.
  5. Не потрібно щоб файл був на вашому локальному диску. Це може бути змонтований мережевий диск. Файл може бути елементом повністю віртуальної файлової системи. Якщо ваш комп'ютер вважає його файлом і має доступ до нього у вигляді файла, Python може спокійно відкрити його.

Але виклик функції open() не зупиняється на імені файла. Там ще один аргумент — encoding (кодування). Ой мамо, це звучить жахливо знайомо.

Кодування символів показує свою потворну голову

[ред.]

Байти це байти, а символи це абстракція. Рядок — це послідовність символів Unicode. Але файл на диску не є послідовністю символів Unicode. Файл на диску — послідовність байтів. Отже, якщо ви читаєте текстовий файл з диску, як Python конвертує послідовність байтів у послідовність символів? Він декодує байти відповідно до певного алгоритму кодування символів і повертає послідовність символів Unicode (також відому як рядок).

# Цей приклад був створенний на Windows. Інші платформи
# можуть поводити себе інакше (описано нижче)
>>> file = open('examples/chinese.txt')
>>> a_string = file.read()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "C:\Python31\lib\encodings\cp1252.py", line 23, in decode
    return codecs.charmap_decode(input,self.errors,decoding_table)[0]
UnicodeDecodeError: 'charmap' codec can't decode byte 0x8f in position 28: character maps to <undefined>
>>>
Кодування за замовчуванням платформозалежне.

Що трапилось? Ви не вказали кодування символів, тому Python використав кодування за замовчування. Що це за кодування? Якщо придивитись до повідомлення про помилку, то можна побачити, що це cp1252, звідки випливає що Python використовує кодування CP-1252 за замовчуванням (CP-1252 — кодування, яке часто використовується на комп'ютерах з Microsoft Windows). Кодування CP-1252 не підтримує символи, які знаходяться в цьому файлі, тому читання завершується з потворним UnicodeDecodeError.

Але зачекайте, тут ще гірше! Кодування за замовчуванням залежить від платформи, так що цей код може працювати на вашому комп'ютері (якщо за замовчуванням кодування UTF-8), але він не буде виконаний, якщо ви передасте його комусь ще (кодування за замовчуванням може відрізнятись від CP-1252).

Якщо вам потрібно отримати кодування за замовчуванням, імпортуйте модуль locale та зробіть виклик locale.getpreferredencoding(). На моєму ноутбуці Windows, вона повертає 'cp1252', але на моєму комп'ютері з Linux, вона повертає 'utf8'. Я навіть не можу підтримувати консистентність в своєму власному домі! Ваші результати можуть відрізнятися (навіть на Windows) залежно від версії операційної системи, яку ви встановили і як ваш регіон або мови налаштовані. Ось чому так важливо вказувати кодування щоразу, коли ви відкриваєте файл.

Потоковий об'єкт

[ред.]

Всі ми знаємо, що Python має вбудовану функцію open(). Ця функція повертає потоковий об'єкт, який має атрибути та методи для отримання інформації про потік символів файла, а також для маніпуляції над ним.

>>> a_file = open('examples/chinese.txt', encoding='utf-8')
>>> a_file.name
'examples/chinese.txt'

Атрибут name містить ім'я файла, який ми передали як параметр у функцію open(). Ім'я файла не нормалізується до абсолютного шляху.

>>> a_file.encoding
'utf-8'

Крім того, атрибут encoding містить кодування, яке ви передали як параметр у фінкцію open(). Якщо при відкритті файла ви не вказали кодування (поганий розробник!), то атрибут буде містити значення locale.getpreferredencoding().

>>> a_file.mode
'r'

Атрибут mode містить інформацію про те, в якому режимі був відкритий файл. Ви можете передати необов'язковий параметр у функцію open(). Якщо ви не вказали режим, коли відкривали файл, то Python використає значення за замовчуванням 'r', яке означає "відкрити лише для читання, в текстовому режимі". Далі у цьому розділі, ви переконаєтесь, що режим файла служить для декількох цілей. Різні режими дозволяють вам перезаписувати файл, додавати дані до файла, чи відкрити файл у двійковому режимі (в цьому режимі ви маєте справу з байтами, а не рядками)

Документація функції open() містить список всіх можливих режимів роботи з файлами.

Читання даних з текстових файлів

[ред.]

Якщо ви відкрили файл для читання, то можливо в певний момент ви захочете з нього щось прочитати.

>>> a_file = open('examples/chinese.txt', encoding='utf-8')
>>> a_file.read()
'Dive Into Python 是为有经验的程序员编写的一本 Python 书。\n'

Коли ви відкрили файл (з правильним кодуванням), читання з нього здійснюється простим викликом методу read() потокового об'єкту. Результатом є текст.

>>> a_file.read()
''

Можливо дещо несподівано, але повторне прочитання файла не створює винятку. Python не розглядає читання даних після закінчення файла помилкою, він просто повертає порожній рядок.

При відкритті файла завжди передавайте параметр encoding

А що якщо ми хочемо перечитати файл?

>>> a_file.read()
''

Так як ми все ще знаходимось в кінці файла, наступні виклики методу потоку read() просто повернуть порожній рядок.

>>> a_file.seek(0)
0

Метод seek() пененосить нас до вказаної в байтах позиції у файлі.

>>> a_file.read(16)
'Dive Into Python'

Метод read() приймає необов’язковий параметр - кількість символів для читання.

>>> a_file.read(1)
' '

Якщо хочете, можна навіть читати по одному символу за раз.

>>> a_file.read(1)
'是' 
>>> a_file.tell()
20

16 + 1 + 1 = … 20?

Давайте спробуємо спочатку.

>>> a_file.seek(17)
17
>>> a_file.read(1)
'是'
>>> a_file.tell()
20

Переміщуємось на 17-тий байт. Читаємо один символ. Після цього ми опиняємось на 20-тому байті.

Ви бачите? Методи seek() та tell() рахують байти, але тому що ми відкрили файл як текст, метод read() рахує символи. Китайські ієрогліфи потребують кількох байт для того щоб бути закодованими в UTF-8. Кожен з латинських символів потребує лише одного байта в файлі, тому це може переконати вас що seek() та read() рахують одне й те ж, але це вірно лише для деяких символів.

Але зачекайте, буде ще гірше!

>>> a_file.seek(18)
18
>>> a_file.read(1)
Traceback (most recent call last):
  File "<pyshell#12>", line 1, in <module>
    a_file.read(1)
  File "C:\Python31\lib\codecs.py", line 300, in decode
    (result, consumed) = self._buffer_decode(data, self.errors, final)
UnicodeDecodeError: 'utf8' codec can't decode byte 0x98 in position 0: unexpected code byte

Перемістіться на 18-тий байт та спробуйте прочитати один символ. Чому це не працює? Тому що на 18-тому байті немає символа. Найближчий символ починається на 17-тому байті та простягається на три байти. Спроба почати читати символ з його середини призводить до UnicodeDecodeError.

Закривання файлів

[ред.]

Відкриті файли споживають системні ресурси, та залежно від режиму, інші програми можуть не отримати до них доступу. Тому важливо закривати файли, як тільки ви закінчите роботу з ними.

>>> a_file.close()

Потоковий об’єкт a_file досі існує, виклик методу close() не знищує його. Але цей об’єкт не є надто корисним.

>>> a_file.read()
Traceback (most recent call last):
  File "<input>", line 1, in <module>
ValueError: I/O operation on closed file.

Ви не можете читати закритий файл, це призводить до винятку.

>>> a_file.seek(0)
Traceback (most recent call last):
  File "<input>", line 1, in <module>
ValueError: I/O operation on closed file.

Не можна переміщуватись по закритому файла.

>>> a_file.tell()
Traceback (most recent call last):
  File "<input>", line 1, in <module>
ValueError: I/O operation on closed file.

В закритому файлі немає позиції, тому tell() теж не працює.

>>> a_file.close()

Хоча й дещо несподівано, але виклик методу close() над потоковим об’єктом чий файл вже був закритий не створює винятку. Просто нічого не відбувається.

>>> a_file.closed
True

Закритий потоковий об’єкт має один корисний атрибут: closed який підтверджує факт закривання файла.

Автоматичне закривання файлів

[ред.]
try..finally - це добре. А with - ще краще.

Потокові об’єкти мають явний метод close(), але що станеться якщо у вашому коді є помилка, і програма завалюється ще до того як ви викличите close()? Цей файл теоретично може залитатись відкритим набагато довше ніж необхідно. Поки ви тестуєте програму на своєму комп’ютері, це не така вже й проблема. Але на працюючому сервері - може бути проблемою.

Python 2 мав для розв’язок цієї проблеми: блок try..finally. Таке все ще працює в Python 3 і ви могли бачити це в коді інших людей, чи в старому коді який перенесли на Python 3. Але починаючи з Python 2.6 було введено більш акуратний спосіб, якому потрібно віддавати перевагу - конструкція with

with open('examples/chinese.txt', encoding='utf-8') as a_file:
    a_file.seek(17)
    a_character = a_file.read(1)
    print(a_character)

Цей код викликає open(), але ніде не видно a_file.close(). Слово with починає блок, аналогічно до подібного в конструкції if чи for. Всередині цього блоку коду ви можете використовувати змінну a_file в якості потокового об’єкта що повертається функцією open(). Звичайні методи потокового об’єкта все ще доступні — seek(), read(), будь-що що вам потрібно. Коли блок with закінучєеться, Python викликає a_file.close() автоматично.

Ось що цікаво: незважаючи на те як, чи коли ви вийдете з блоку with, Python закриє файл. Навіть якщо ви "вийдете" з блоку за допомогою необробленого винятку. Так, це правда, навіть якщо ваш код згенерує виняток, і вся програма завершить роботу, файл все одно буде закритим. Гарантовано.

В технічних термінах, конструкція with створює контекст виконання. В цих прикладах потоковий об’єкт працює як менеджер контексту. Python створює потоковий об’єкт a_file і каже що ми входимо в контекст виконання. Коли блок коду закінучується, Python каже потоковому об’єкта що ми виходимо з контексту виконання і потоковий об’єкт сам викликає свій метод close(). Для деталей прочитайте про Класи що можуть використовуватись в блоці with.

Немає нічого специфічного для файлів у конструкції with, це просто загальний спосіб створення контекстів виконання та повідомлення об’єктам що вони входять та виходять з цього контексту. Якщо наш об’єкт є потоковим об’єктом, тоді він виконує корисні для файла речі (такі як його автоматичне закривання). Але така поведінка описана в потоковому об’єкті, а не в самій конструкції with. Існує багато інших способів використовувати менеджери контексту які жодним чином не пов’язані з файлами. Ви навіть можете створити свій власний, як буде показано далі в цьому розділі.

Читання даних по рядку за раз

[ред.]

Рядок тексту - це те про що ви подумали - ви набираєте кілька слів, натискаєте ↵ Enter на опиняєтесь на новому рядку. Рядок тексту це послідовність символів, обмежена... чим саме? Ну, це складно сказати, тому що текстові файли можуть використовувати різні символи для позначення кінця рядка. Кожна операційна система має свої власні правила. Одні використовують символ повернення каретки, інші використовують символ нового рядка, а деякі використовують обидва символи для кінця кожного рядка.

Тепер зітхніть з полегшенням, тому що Python автоматично обробляє закінчення рядків. Якщо ви скажете "Я хочу читати цей текстовий файл порядково", Python з’ясує який вид закінчень рядків використовується, і все буде Просто Працювати.

Якщо вам потрібен точний контроль над тим що вважається кінцем рядка, можете передати в функцію open() необов’язковий параметр newline. Дивіться документацію функції open() аби дізнатись всі криваві подробиці.

Отож, як це зробити? Прочитати файл по рядку за раз, це дуже просто і дуже красиво.

line_number = 0
with open('examples/favorite-people.txt', encoding='utf-8') as a_file:  ①
    for a_line in a_file:                                               ②
        line_number += 1
        print('{:>4} {}'.format(line_number, a_line.rstrip()))          ③

① Використовуючи конструкцію with ми безпечно відкриваємо файл, і покладаємо обов’язок його закривання на Python.

② Щоб прочитати файл по рядку за раз, використовуйте цикл for. Це все. Потоковий об’єкт не тільки має явні методи на зразок read(), він також є ітератором що повертає наступний рядок щоразу коли ми про нього просимо.

③ Використовуючи метод format(), ви можете надрукувати номер рядка та сам рядок. Специфікатор формату {:>4} означає "надрукуй цей аргумент вирівняним по правому краю з шириною в чотири символи.” Змінна a_line містить ввесь рядок, символ повернення каретки, та інше. Метод rstrip() видаляє зайві невидимі символи, включаючи символи повернення каретки.


* * *


Запис в текстові файли

[ред.]
Просто відкрийте файл і почніть запис

Ви можете писати в файли майже аналогічно тому як ви з них читаєте. Спершу потрібно відкрити файл та отримати потоковий об’єкт, потім використати методи потокового об’єкта для запису даних, після чого закрити файл.

Для відкривання файла використовується функція open() якій передають режим запису файла. Є два режими відкривання файла на запис:

  • Режим "Write", який повністю перепише файл з початку. Передайте функції open() параметр mode='w'.
  • Режим "Append", який додаватиме дані в кінець файла. Передайте функції open() параметр mode='a'.

Кожен режим створюватиме файли автоматично, якщо вони не існують, тому ніколи не виникає потреба в таких незручностях як "якщо файл ще не існує, створити його, аби його можна було відкрити". Просто відкривайте файл і починайте запис.

Ви повинні завжди закривати файл, як тільки закінчите записувати дані, щоб звільнити файловий дескриптор і гарантувати що дані справді потраплять на диск. Як і при читанні, можна викликати метод потокового об’єкта close(), або використати конструкцію with дозволивши Пайтону зробити це за вас. Закладаюсь що ви знаєте який підхід я рекомендую.

>>> with open('test.log', mode='w', encoding='utf-8') as a_file:
...     a_file.write('test succeeded')                          
>>> with open('test.log', encoding='utf-8') as a_file:
...     print(a_file.read())                              
test succeeded

Ми рішуче починаємо створюючи новий файл test.log (чи стираючи існуючий), та відкриваємо його для запису, на що вказує праметр mode='w'. Так, це настільки небезпечно як звучить. Я сподіваюсь ви не сильно переживаєте за попередній вміст того файла (якщо такий був), тому що зараз дані пропали. Можна додати дані в щойновідкритий файл методом потокового об’єкта write(). Після закінчення блоку Python автоматично закриє файл.

>>> with open('test.log', mode='a', encoding='utf-8') as a_file:
...     a_file.write('and again')
>>> with open('test.log', encoding='utf-8') as a_file:
...     print(a_file.read())                              
test succeededand again

Це було так весело, що я захотів ще. Але цього разу з режимом mode='a' для того аби додавати дані до файла, замість того аби його переписувати. Додавання не повинно шкодити існуючим даним файла.

Як і рядок що ми записали трохи раніше, так і той рядок що ми записали щойно зараз знаходяться в файлі test.log. Також зауважте що не було додано ні символів повернення каретки, ні символів переходу на новий рядок. Так як ми явно не сказали записати їх у файл, файл їх не містить. Можна записувати повернення каретки з допомогою символа \r, а перехід на новий рядок з допомогою \n, але так як ми не вказали жодного з них, все що ми записували опинилось в одному рядку.

Знову кодування символів

[ред.]

Ви зауважили параметр encoding що передається в функцію open() при відкриванні файла на запис? Це важливо, ніколи його не забувайте! Як ви вже побачили на початку цього розділу, файли не містять рядків, вони містять байти. Читання "рядка" з текстового файла працює лише тому, що ви сказати Python яке кодування використовувати аби прочитати послідовність байтів і перетворити її в рядок. Запис тексту у файл являє собою цю ж проблему, тільки з іншого боку. Ви не можете записати символи в файл, символи це абстракція. Щоб записувати в файл, Python повинен знати як перетворити ваш рядок в послідовність байтів. Єдиний спосіб бути впевненим що перетворення відбувається правильно - явно передати параметр encoding при відкриванні файла на запис.


* * *


Двійкові файли

[ред.]
Собака

Не всі файли містять текст. Деякі з них містять зображення собаки.

>>> an_image = open('dog.jpg', mode='rb')

Відкривати двійковий файл так само просто як і текстовий, з однією тонкою відмінністю: параметр mode містить символ 'b'.

>>> an_image.mode
'rb'

Потоковий об’єкт який ми отримуємо при відкриванні файла в двійковому режимі має багато атрибутів що є й у текстовому режимі, включаючи mode, який відповідає параметру mode переданому в функцію open().

>>> an_image.name
'dog.jpg'

Як і в текстових потокових об’єктах, атрибут name містить назву відкритого файла.

>>> an_image.encoding
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: '_io.BufferedReader' object has no attribute 'encoding'

А ось одна відмінність: двійковий потоковий об’єкт не має атрибута encoding. Це й логічно, хіба ні? Ми читаємо (чи пишемо) байти, а не рядки, тому не потрібно ніяких перетворень. Те що ви отримуєте з двійкового файла - точно те саме що ви в нього записуєте, тому перетворення не є обов’язковим.


Я вже згадував що ви читаєте байти? О, так, ви читаєте байти.

>>> an_image.tell()
0
>>> data = an_image.read(3)
>>> data
b'\xff\xd8\xff'

Як і текстові, двійкові файли можна читати по шматочку за раз. Але є одна критична відмінність...

>>> type(data)
<class 'bytes'>

... ми читаємо байти, а не рядки. Так як файл відкрито в двійковому режимі, метод read() приймає кількість байт для прочитання, а не кількість символів.

>>> an_image.tell()
3
>>> an_image.seek(0)
0
>>> data = an_image.read()
>>> len(data)
3150

Це означає що ніколи не виникне неочікуваної невідповідності між числом яке ми передали в метод read() та індексом позиції яку нам після цього повертає метод tell(). Метод read() читає байти, а метод seek() та tell() рахують клількість прочитаних байтів. Для двійкових файлів вони завжди зходяться.


* * *


Потокові об'єкти з нефайлових джерел

[ред.]
Щоб читати з фальшивого файла, просто викличте функцію read().

Уявіть собі що ви пишете бібліотеку, і одна з функцій вашої бібліотеки збирається прочитати деякі дані з файла. Функція може просто отримати назву файла в рядку, відкрити файл для читання, прочитати його, та закрити перед завершенням. Але ви не повинні цього робити. Замість цього ваше API повинно приймати довільний потоковий об’єкт.

В найпростішому випадку, потоковий об’єкт це будь-що з методом read(), який приймає необов’язковий параметр size та повертає рядок. При виклику без параметра size, метод read() повинен прочитати все що ще залишилось на вході і повернути ці дані єдиним значенням. При виклику з параметром size він читає і повертає стільки даних скільки попросили. При повторному виклику він починає читати з того місця де зупинився, і повертає наступний шматок даних.

Звучить майже так само як потоковий об’єкт який ми отримаємо від відкривання справжнього файла. Тільки відмінність в тому, що ми не обмежуємо себе лише файлами. Джерелом даних для читання може бути будь-що: веб-сторінка, рядок в пам’яті, навіть вивід іншої програми. Якщо ваша функція приймає потоковий об’єкт і просто викликає метод read(), ви можете працювати з будь-яким видом даних що поводяться як файл, без написання спеціального коду для кожного виду.

>>> a_string = 'PapayaWhip is the new black.'
>>> import io

Модуль io описує клас StringIO який можна використати аби поводитись з рядком в пам’яті як з файлом.

>>> a_file = io.StringIO(a_string)

Щоб створити потоковий об’єкт з рядка, створіть екземпляр класу io.StringIO() і передайте його конструктору рядок який ви хочете використати як дані вашого "файла". Тепер ми маємо потоковий об’єкт і можемо робити з ним все що дозволяє потоковий об’єкт.

>>> a_file.read()
'PapayaWhip is the new black.'

Виклик методу read() "читає" увесь "файл", що в даному випадку з StringIO просто повертає початковий рядок.

>>> a_file.read()
''

Як і зі справжнім файлом, повторний виклик read() повертає порожній рядок.

>>> a_file.seek(0)
0

Ви можете переміститись на початок рядка, так само як ви робили б це з файлом, використовуючи метод seek() об’єкта класу StringIO.

>>> a_file.read(10)
'PapayaWhip'
>>> a_file.tell()
10
>>> a_file.seek(18)
18
>>> a_file.read()
'new black.'

Також можна читати рядок по шматочках, передавши параметр size методу read().

io.StringIO дозволяє вам поводитись з рядком як з текстовим файлом. Також існує клас io.BytesIO, який дозволяє вам поводитись з масивом байтів як з двійковим файлом.

Обробка стиснутих файлів

[ред.]

Стандартна бібліотека мови Python містить модулі що підтримують читання та запис стиснених файлів. Існує багато різних форматів стискання, два найбільш популярні на системах без Windows - це gzip та bzip2. (Ви могли також зустрічатись з архівами PKZIP та GNU Tar. Python має модулі і для них.)

Модуль gzip дозволяє вам створювати потоковий об’єкт для читання чи запису файла стисненого алгоритмом gzip. Потоковий об’єкт що ним надається підтримує метод read() (якщо ви відкрили його для читання) чи метод write() (якщо ви відкрили його для запису). Тобто ви можете використовувати методи для звичайних файлів, які ви вже вивчили, щоб прямо писати чи читати з файла gzip, без створення тимчасового файла для збереження розархівованих даних.

Як додатковий бонус, він також підтримує конструкцію with, тому ви можете дозволити Python-ну автоматично закривати ваш стиснений файл.

you@localhost:~$ python3

>>> import gzip
>>> with gzip.open('out.gz', mode='wb') as z_file:                                      
...   z_file.write('Дев’ятимильна прогулянка - це не жарт, особливо під час дощу.'.encode('utf-8'))
... 
>>> exit()

Ви повинні завжди відкривати стиснені gzip-файли в двійковому режимі. (Зверніть увагу на символ 'b' в аргументі mode)

you@localhost:~$ ls -l out.gz 
-rw-rw-r-- 1 bunyk bunyk 117 кві  5 19:52 out.gz

Я створив цей приклад на Linux. Якщо ви не знайомі з командним рядком, ця команда показує "розширений лістинг" для стисненого файла який ми щойно створили з директорії Python. Цей лістинг показує що файл існує (це добре), і що він має довжину 117 байт. Це насправді більше ніж рядок з якого ми почали! Формат файла gzip включає заголовок фіксованої довжини, який містить деякі метадані про файл, тому є неефективним для надто малих файлів.

you@localhost:~$ gunzip out.gz

Команда gunzip (читається як "джі-анзіп") розархівовує файл, і зберігає його вміст в новому файлі, який називається так само як стиснений файл, але без розширення .gz.

you@localhost:~$ cat out
Дев’ятимильна прогулянка - це не жарт, особливо під час дощу.

Команда cat показує вміст файла. Цей файл містить рядок який ми раніше записали прямо в стиснений файл out.gz прямо з оболонки Python.


* * *


Стандартні потоки вводу, виводу та помилок

[ред.]
sys.stdin,
sys.stdout,
sys.stderr.

Гуру командного рядка вже знайомі з концепцією стандартного вводу, стандартного виводу, та стандартного виводу помилок. Цей розділ для решти з вас.

Стандартний вивід, та вивід помилок (зазвичай скорочуються до stdout та stderr) - це канали що вбудовані в кожну UNIX-подібну систему, включаючи Mac OS X та Linux. Коли ви викликаєте функцію print(), все що ви друкуєте відправляється у канал stdout. За замовчуванням обидва ці канали просто прив’язані до вікна терміналу, і коли програма завершує роботу з помилкою, ви бачите цю помилку в тому ж терміналі що й вивід програми. В графічній оболонці Python, канали stdout та stderr за замовчуванням прив’язані до "Інтерактивного вікна".

>>> for i in range(3):
...     print('PapayaWhip')
PapayaWhip
PapayaWhip
PapayaWhip

Просто функція print() в циклі. Поки що нічого незвичайного.

>>> import sys
>>> for i in range(3):
...     l = sys.stdout.write('is the')
is theis theis the

stdout описаний в модулі sys, і є потоковим об’єктом. Виклик його методу write() надрукує рядок що йому передали, після чого поверне довжину виводу. Насправді це те що робить функція print() - додає символ нового рядка до кінця рядка який потрібно надрукувати і викликає sys.stdout.write().

>>> for i in range(3):
...     l = sys.stderr.write('new black')
new blacknew blacknew black

В найпростішому випадку sys.stdout та sys.stderr відправляють свій вивід в одне й те ж місце: IDE Python (якщо ви ним користуєтесь), чи термінал (якщо використовуєте Python в командному рядку). Як і стандартний вивід, стандартний вивід помилок не додаватиме символи нового рядка за вас. Якщо вам потрібно закінчити рядок - потрібно додати символ переходу самому.

sys.stdout та sys.stderr - потокові об’єкти, але вони призначені тільки для запису. Якщо ви спробуєте викликати їх метод read() - завжди отримаєте IOError.

>>> import sys
>>> sys.stdout.read()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
IOError: not readable

Перенаправлення стандартного потоку виводу

[ред.]

sys.stdout та sys.stderr є потоковими об’єктами, хоча й такими що підтримують лише запис. Але вони не константи, вони змінні. Це означає що їм можна присвоїти нове значення - будь-який інший потоковий об’єкт, щоб перенаправити їх вивід.

import sys

class RedirectStdoutTo:
    def __init__(self, out_new):
        self.out_new = out_new

    def __enter__(self):
        self.out_old = sys.stdout
        sys.stdout = self.out_new

    def __exit__(self, *args):
        sys.stdout = self.out_old

print('A')
with open('out.log', mode='w', encoding='utf-8') as a_file, RedirectStdoutTo(a_file):
    print('B')
print('C')

Зацініть:

you@localhost:~$ python3 stdout.py
A
C
you@localhost:~$ cat out.log
B

Давайте спершу розберемось з останньою частиною:

print('A')
with open('out.log', mode='w', encoding='utf-8') as a_file, RedirectStdoutTo(a_file):
    print('B')
print('C')

Це складна конструкція with. Давайте я перепишу її в щось більш знайоме:

with open('out.log', mode='w', encoding='utf-8') as a_file:
    with RedirectStdoutTo(a_file):
        print('B')

Як демонструє переписаний варіант, насправді тут є дві вкладені конструкції with. "Зовнішня" конструкція with повинна бути вам вже знайомою: вона відкриває текстовий файл в кодуванні UTF-8, який називається out.log для запису, і присвоює утворений потоковий об’єкт змінній a_file. Але це не настільки дивно, як те що нижче.

with RedirectStdoutTo(a_file):

Де ключове слово as? Насправді, конструкція with не вимагає цього слова. Так само як ми можемо викликати функцію, та ігнорувати її повернене значення, ми можемо мати конструкцію with яка не присвоює свій менеджер контексту змінній. В даному випадку ми зацікавлені лише в побічних ефектах контексту RedirectStdoutTo.

Що це за побічні ефекти? Давайте подивимось всередину класу RedirectStdoutTo. Цей клас є менеджером контексту. Кожен клас може стати менеджером контексту, якщо опише два спеціальні методи: __enter__() та __exit__().

class RedirectStdoutTo:
    def __init__(self, out_new):
        self.out_new = out_new

Метод __init__() викликається одразу після того як створюється екземпляр класу. Він приймає один параметр - потоковий об’єкт який ми хочемо використовувати в якості стандартного потоку виводу протягом часу життя контексту. Цей метод просто зберігає потоковий об’єкт в атрибуті екземпляра, аби потім його могли використовувати інші методи.

    def __enter__(self):
        self.out_old = sys.stdout
        sys.stdout = self.out_new

Метод __enter__() - це спеціальний метод, який викликається коли інтерпретатор входить в контекст (на початку блоку with). Цей метод зберігає поточне значення sys.stdout в self.out_old, після чого перенаправляє стандартний вивід присвоюючи змінній sys.stdout значення змінної self.out_new.

    def __exit__(self, *args):
        sys.stdout = self.out_old

Метод __exit__() - це інший спеціальний метод, який викликається при виході з контексту (в кінці блоку with). Цей метод відновлює початкове значення стандартного потоку виводу, присвоюючи змінній sys.stdout її старе значення збережене у змінній self.out_old.

Тепер подивимось як це все разом працює.

print('A')
with open('out.log', mode='w', encoding='utf-8') as a_file, RedirectStdoutTo(a_file):
    print('B')
print('C')

Перший рядок надрукує щось в інтерактивне вікно Python, чи термінал, якщо ви запускаєте скрипт з командного рядка).

Другий рядок починається ключовим словом with, після якого йде список менеджерів контексту, розділених комами. Цей список працює так само як послідовність вкладених блоків with. Перший з перелічених менеджерів контексту створює "зовнішній" блок, останній "внутрішній". Перший контекст відкриває файл, другий контекст перенаправляє sys.stdout в потоковий об’єкт що був створений в першому контексті.

Тому що функція print() в блоці with знаходиться в контексті що перенаправляє стандартний вивід, вона не виводиться на екран, а записується в файл out.log.

Коли блок with закінчується, Python каже кожному менеджеру контексту робити те що вони повинні робити при виході з контексту. Менеджери контексту формують стек "останній зайшов - перший вийшов". Перед виходом, другий контекст замінює sys.stdout назад в початкове значення, потім перший контекст закриває файл названий out.log. Так як стандартний потік виводу отримав початкове значення, наступний виклик функції print знову друкуватиме на екран.

Перенаправлення стандартного потоку помилок працює так само, просто замість sys.stdout потрібно використати sys.stderr.


* * *


Для подальшого читання

[ред.]

XML

[ред.]

Під час архонства Арістаехмуса Дракон приймав його постанови.
Арістотель


Майже кожен розділ цієї книги обертається навколо шматка демонстраційного коду. Але суть XML не в коді, а в даних. Одним з типових використань XML є "синдикація фідів" які перелічують останні статті блогу, форуму, чи іншого частооновлюваного веб-сайту. Більшість популярних блогових платформ можуть генерувати фід і оновлювати його як тільки нові статті чи обговорення публікуються. Можна читати блог "підписавшись" на його фід, і можна читати кілька блогів за раз, за допомогою спеціальної програми - агрегатора фідів, такої наприклад як Google Reader.

Ось тут дані XML з якими ми будемо працювати в цьому розділі. Це фід, а якщо точніше, то фід Atom.

<?xml version='1.0' encoding='utf-8'?>
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'>
  <title>dive into mark</title>
  <subtitle>currently between addictions</subtitle>
  <id>tag:diveintomark.org,2001-07-29:/</id>
  <updated>2009-03-27T21:56:07Z</updated>
  <link rel='alternate' type='text/html' href='http://diveintomark.org/'/>
  <link rel='self' type='application/atom+xml' href='http://diveintomark.org/feed/'/>
  <entry>
    <author>
      <name>Mark</name>
      <uri>http://diveintomark.org/</uri>
    </author>
    <title>Dive into history, 2009 edition</title>
    <link rel='alternate' type='text/html'
      href='http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition'/>
    <id>tag:diveintomark.org,2009-03-27:/archives/20090327172042</id>
    <updated>2009-03-27T21:56:07Z</updated>
    <published>2009-03-27T17:20:42Z</published>
    <category scheme='http://diveintomark.org' term='diveintopython'/>
    <category scheme='http://diveintomark.org' term='docbook'/>
    <category scheme='http://diveintomark.org' term='html'/>
  <summary type='html'>Putting an entire chapter on one page sounds
    bloated, but consider this &amp;mdash; my longest chapter so far
    would be 75 printed pages, and it loads in under 5 seconds&amp;hellip;
    On dialup.</summary>
  </entry>
  <entry>
    <author>
      <name>Mark</name>
      <uri>http://diveintomark.org/</uri>
    </author>
    <title>Accessibility is a harsh mistress</title>
    <link rel='alternate' type='text/html'
      href='http://diveintomark.org/archives/2009/03/21/accessibility-is-a-harsh-mistress'/>
    <id>tag:diveintomark.org,2009-03-21:/archives/20090321200928</id>
    <updated>2009-03-22T01:05:37Z</updated>
    <published>2009-03-21T20:09:28Z</published>
    <category scheme='http://diveintomark.org' term='accessibility'/>
    <summary type='html'>The accessibility orthodoxy does not permit people to
      question the value of features that are rarely useful and rarely used.</summary>
  </entry>
  <entry>
    <author>
      <name>Mark</name>
    </author>
    <title>A gentle introduction to video encoding, part 1: container formats</title>
    <link rel='alternate' type='text/html'
      href='http://diveintomark.org/archives/2008/12/18/give-part-1-container-formats'/>
    <id>tag:diveintomark.org,2008-12-18:/archives/20081218155422</id>
    <updated>2009-01-11T19:39:22Z</updated>
    <published>2008-12-18T15:54:22Z</published>
    <category scheme='http://diveintomark.org' term='asf'/>
    <category scheme='http://diveintomark.org' term='avi'/>
    <category scheme='http://diveintomark.org' term='encoding'/>
    <category scheme='http://diveintomark.org' term='flv'/>
    <category scheme='http://diveintomark.org' term='GIVE'/>
    <category scheme='http://diveintomark.org' term='mp4'/>
    <category scheme='http://diveintomark.org' term='ogg'/>
    <category scheme='http://diveintomark.org' term='video'/>
    <summary type='html'>These notes will eventually become part of a
      tech talk on video encoding.</summary>
  </entry>
</feed>


* * *


П'ятихвилинний ввідний курс XML

[ред.]

Якщо ви вже знаєте XML, можете пропустити цей розділ.

XML - це узагальнений спосіб опису даних з ієрархічною структурою. Документ XML містить один чи більше елементів, які обмежуються відкриваючим та закриваючим тегами. Ось завершений (хоча й нудний) документ XML:

<foo>
</foo>

В першому рядку відкриваючий тег, а в другому - відповідний йому закриваючий. Як і дужки при написанні математичних виразів чи коду, кожен відкриваючий тег має бути закритим відповідним закриваючим.

Елементи можна вставляти один в одного довільну кількість разів. Елемент bar всередині елементу foo називається піделементом або дитиною тегу foo.

<foo>
  <bar></bar>
</foo>

Перший елемент в кожному XML документі називається кореневим. Документ XML може мати лише один кореневий елемент. Внизу ви бачите текст який не є документом XML, тому що він має два кореневі елементи:

<foo></foo>
<bar></bar>

Елементи можуть мати атрибути, які є парами ключ - значення. Атрибути перелічуються всередині відкриваючого тега елемента і розділяються пропуском. Імена атрибутів не повинні повторюватись в межах одного елемента. Значення атрибутів повинні поміщуватись в лапки. Можна використовувати як одинарні, так і подвійні лапки.

<foo lang='en'>
  <bar id='papayawhip' lang="fr"></bar>
</foo>

Елемент foo має один атрибут який називається lang. Значенням атрибута lang - en. Елемент bar має два атрибути, названі id та lang. Значенням атрибута lang є fr. Це жодним чином не конфліктує з елементом foo, бо кожен елемент має свій власний набір атрибутів.

Порядок атрибутів не має значення. Атрибути елемента утворюють невпорядковану множину пар, на зразок словника мови Python. Немає обмеження на кількість атрибутів які можна описати для кожного елемента.

Елементи можуть мати текстовий вміст.

<foo lang='en'>
  <bar lang='fr'>PapayaWhip</bar>
</foo>

Елементи що не містять тексту і дітей називаються порожніми.

<foo></foo>

Існує скорочення для запису порожніх елементів. Вставляючи символ / наприкінці відкриваючого тегу, ви можете пропустити закриваючий тег. XML з попереднього прикладу може бути записаний наступним чином:

<foo/>

Так як і функції Python можуть бути описаними в різних модулях, елементи XML можуть бути описаними в різних просторах імен. Простори імен зазвичай виглядають як URL. Ви можете використати декларацію xmlns для того щоб задати простір імен за замовчуванням. Ця декларація схожа на атрибут, але вона має іншу мету.

<feed xmlns='http://www.w3.org/2005/Atom'>
  <title>dive into mark</title>
</feed>

Елемент feed знаходиться в просторі імен http://www.w3.org/2005/Atom. Елемент title також знаходиться в просторі імен http://www.w3.org/2005/Atom. Декларація простору імен впливає на елемент в якому вона описується а також на всі дочірні.

Також ви можете використати декларацію xmlns:prefix щоб описати простір імен, і пов'язати його з префіксом prefix. Тепер кожен елемент в тому просторі імен повинен оголошуватись з явним простором імен. Ось так:

<atom:feed xmlns:atom='http://www.w3.org/2005/Atom'>
  <atom:title>dive into mark</atom:title>
</atom:feed>

З точки зору XML парсера попередні два XML документи ідентичні. Простір імен та назва елемента разом дають XML ідентичність. Префікси створені тільки для того щоб посилатись на простори імен, тому саме ім'я префікса (atom:) не має значення. Простори імен співпадають, імена елементів співпадають, атрибути (чи їх відсутність) співпадають, і вміст кожного елемента теж збігається, тому документи XML однакові.

Нарешті, документи XML можуть містити інформацію про кодування в першому рядку, перед кореневим елементом (Якщо вам цікаво як документ може містити інформацію яка повинна бути відомою до того як документ можна буде прочитати, Секція F специфікації XML містить деталі як розв'язати цю пастку 22.)

<?xml version='1.0' encoding='utf-8'?>

І тепер ви знаєте достатньо XML щоб бути небезпечним.


* * *


Структура фіду Atom

[ред.]

Подумайте про блог, чи фактично будь-який сайт з вмістом що часто оновлюється, такий як CNN.com. Сам сайт має заголовок ("CNN.com"), підзаголовки ("Breaking News, U.S., World, Weather, Entertainment & Video News"), дату останнього оновлення ("updated 12:43 p.m. EDT, Sat May 16, 2009") та список статтей опублікованих в різні моменти часу. Кожна стаття також має заголовок, дату початкової публікації (та можливо дату останнього оновлення, якщо вони публікували доповнення чи виправлення), та унікальну адресу URL.

Формат синдикації Atom створений аби зібрати всю цю інформацію в стандартній формі. Мій блог, та CNN.com дуже відрізняються своїм стилем, тематикою та аудиторією, але вони обидва мають подібну базову структуру. CNN.com має заголовок, мій блог має заголовок. CNN.com публікує статті, я публікую статті.

На верхньому рівні є кореневий елемент спільний для всіх фідів Atom: елемент feed в просторі імен http://www.w3.org/2005/Atom.

<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'>

http://www.w3.org/2005/Atom - задає простір імен Atom.

Будь-який елемент може містити атрибут xml:lang, який описує мову що використовуватиметься в елементі та його нащадках. В даному випадку атрибут xml:lang описується лише для кореневого елемента, що означатиме що ввесь фід буде містити вміст англійською мовою.

Кожен фід Atom містить кілька шматочків інформації про самого себе. Вони описуються як діти кореневого елемента feed.

<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'>
  <title>dive into mark</title>                                             ①
  <subtitle>currently between addictions</subtitle>                         ②
  <id>tag:diveintomark.org,2001-07-29:/</id>                                ③
  <updated>2009-03-27T21:56:07Z</updated>                                   ④
  <link rel='alternate' type='text/html' href='http://diveintomark.org/'/>  ⑤

① Заголовок фіду "dive into mark" записується в елементі <title>.

② Підзаголовок, "currently between addictions" - в елементі <subtitle>.

③ Кожен фід повинен мати глобально унікальний ідентифікатор. Дивіться RFC 4151 аби дізнатись як такий створити.

④ Цей фід востаннє був оновлений 27 березня, 2009, в 21:56 за грінвічем. Зазвичай цей час дорівнює часу останнього оновлення найновішої статті.

⑤ Тепер речі стають цікавими. Цей елемент не має текстового вмісту, але має три атрибути: rel, type, and href. Значення rel вказує нам на тип посилання; rel='alternate' означає що це посилання на альтернативне представлення цього фіду. type='text/html' означає що це посилання на HTML-сторінку. Ну, а сама адреса посилання дається нам в атрибуті href.

Таким чином ми знаємо що це фід для сайту з заголовком "dive into mark" який доступний за адресою http://diveintomark.org/ та востаннє оновлювався в березні 27, 2009.

Хоча порядок елементів може мати значення в деяких XML документах, він не має значення в фіді Atom.

Після метаданих фіду йде список недавніх публікацій. Публікація виглядає наступним чином:

<entry>
  <author>                                                                 ①
    <name>Mark</name>
    <uri>http://diveintomark.org/</uri>
  </author>
  <title>Dive into history, 2009 edition</title>                           ②
  <link rel='alternate' type='text/html'                                   ③
    href='http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition'/>
  <id>tag:diveintomark.org,2009-03-27:/archives/20090327172042</id>        ④
  <updated>2009-03-27T21:56:07Z</updated>                                  ⑤
  <published>2009-03-27T17:20:42Z</published>        
  <category scheme='http://diveintomark.org' term='diveintopython'/>       ⑥
  <category scheme='http://diveintomark.org' term='docbook'/>
  <category scheme='http://diveintomark.org' term='html'/>
  <summary type='html'>Putting an entire chapter on one page sounds        ⑦
    bloated, but consider this &amp;mdash; my longest chapter so far
    would be 75 printed pages, and it loads in under 5 seconds&amp;hellip;
    On dialup.</summary>
</entry>                                                                   ⑧

① Елемент author повідомляє хто написав цю статтю: якийсь чувак на ім’я Марк, якого можна знайти за адресою http://diveintomark.org/. (Це така сама адреса як і адреса альтернативного представлення фіду, але не обов’язково повинна бути такою. Багато блогів мають по кілька авторів, кожен з яких може мати власний сайт.)

② Елемент title дає нам заголовок публікації, "Dive into history, 2009 edition".

③ Як і з посиланням на альтернативне представлення фіду, цей елемент link дає нам адресу HTML версії публікації.

④ Публікації, як і фіди, потребують унікального ідентифікатора.

⑤ Публікації мають по дві дати: дату першої публікації (published) та дату останньої зміни (updated).

⑥ Публікації можуть мати довільне число категорій. В цій, наприклад, є категорії: diveintopython, docbook, та html.

⑦ Елемент summary дає нам короткий опис вмісту публікації. (Також існує елемент content, тут не показаний, якщо ви хочете включити повний текст публікації у ваш фід. Цей елемент summary має один специфічний для формату Atom атрибут, type='html', який вказує що даний опис публікації є шматком HTML, а не простим текстом. Це важливо, тому що він містить в собі деякі елементи HTML, такі як (&mdash; та &hellip;) які повинні відображатись як "—" та "…", а не як HTML код.

⑧ Коли ми описали всі дані публікації, потрібно закрити елемент entry.


* * *


Парсинг XML

[ред.]

Python може парсити документи XML кількома способами. Він має традиційні парсери DOM та SAX, але я сфокусуюсь на іншій бібліотеці яка називається ElementTree.

>>> import xml.etree.ElementTree as etree

Бібліотека ElementTree - частина стандартної бібліотеки Python, в пакеті xml.etree.ElementTree.

>>> tree = etree.parse('examples/feed.xml')

Основною точкою входу в бібліотеку ElementTree є функція parse(), яка приймає ім'я файлу, або потоковий об'єкт. Функція парсить ввесь документ за раз. Якщо з пам'яттю туго існують способи парсити XML документ інкрементно.

>>> root = tree.getroot()

Функція parse() повертає об'єкт що являє собою ввесь документ. Це не кореневий елемент. Для того щоб отримати посилання на кореневий елемент - викличте метод getroot().

>>> root
<Element {http://www.w3.org/2005/Atom}feed at cd1eb0>

Як і очікувалось, кореневий елемент - це тег feed у просторі імен http://www.w3.org/2005/Atom. Рядкове представлення цього об'єкта посилює важливу тезу: елемент XML - це комбінація простору імен та назви тегу (також замість тег, іноді кажуть локальне ім'я). Кожен елемент в цьому документі знаходиться у просторі імен Atom, тому кореневий елемент представляється як {http://www.w3.org/2005/Atom}feed.

ElementTree показує елементи XML в форматі {namespace}localname. Ви будете зустрічатись з цим форматом в багатьох місцях в API ElementTree.


Елементи це списки

[ред.]

В API ElementTree, елемент поводиться як список. Елементами списку є його діти.

>>> root.tag
'{http://www.w3.org/2005/Atom}feed'
>>> len(root)
8

Як і в попередньому прикладі, елемент root - це {http://www.w3.org/2005/Atom}feed. "Довжина" елемента - це кількість дочірніх елементів.

>>> for child in root:
...   print(child)
... 
<Element {http://www.w3.org/2005/Atom}title at e2b5d0>
<Element {http://www.w3.org/2005/Atom}subtitle at e2b4e0>
<Element {http://www.w3.org/2005/Atom}id at e2b6c0>
<Element {http://www.w3.org/2005/Atom}updated at e2b6f0>
<Element {http://www.w3.org/2005/Atom}link at e2b4b0>
<Element {http://www.w3.org/2005/Atom}entry at e2b720>
<Element {http://www.w3.org/2005/Atom}entry at e2b510>
<Element {http://www.w3.org/2005/Atom}entry at e2b750>

Можна використати елемент як ітератор, для того щоб отримати всі його дочірні елементи. Як можна бачити з виводу, справді є 8 дочірніх елементів: метадані фіду (title, subtitle, id, updated, та link) за якими йдуть три елементи entry.

Ви могли про це самі здогадатись, але я хочу наголосити про це явно: список дочірніх елементів включає лише прямих дітей. Кожен з елементів entry має власних дітей, але вони не включаються в цей список. Їх нема в списку feed, зате вони входять в список дітей кожного елемента entry. Існують способи знаходити елементи не залежно від того як глибоко вони вкладені, і чиїми дітьми є, ми розглянемо два таких способи пізніше в цьому розділі.

Атрибути це словники

[ред.]

XML це не просто набір елементів, це також набір атрибутів цих елементів. Як тільки ми маємо певний елемент, ми можемо просто отримати його атрибути як словник Python.

>>> root.attrib
{'{http://www.w3.org/XML/1998/namespace}lang': 'en'}

Поле attrib - це словник атрибутів елемента. Початковою розміткою було <feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'>. Префікс xml: вказує на вбудований прострі імен, який кожен XML документ може використовувати без декларування

>>> root[4]
<Element {http://www.w3.org/2005/Atom}link at e181b0>
>>> root[4].attrib
{'href': 'http://diveintomark.org/',
 'type': 'text/html',
 'rel': 'alternate'}

П’ятою дитиною ([4], пам’ятаєте, нумерація в списках починається з нуля) є елемент link. Він має три атрибути: href, type, та rel.

>>> root[3]
<Element {http://www.w3.org/2005/Atom}updated at e2b4e0>
>>> root[3].attrib
{}

Четвертою дитиною є елемент updated. Він не має атрибутів, тому .attrib - порожній словник.


* * *


Пошук вузлів XML документа

[ред.]

Дотепер ми працювали з нашим XML документом "зверху вниз", починаючи з кореневого елемента, отримуючи його дітей, дітей дітей, і так далі в глиб документа. Але в багатьох використаннях XML потрібно знаходити конкретні елементи. Etree може зробити й це.

>>> import xml.etree.ElementTree as etree
>>> tree = etree.parse('examples/feed.xml')
>>> root = tree.getroot()
>>> root.findall('{http://www.w3.org/2005/Atom}entry')
[<Element {http://www.w3.org/2005/Atom}entry at e2b4e0>,
 <Element {http://www.w3.org/2005/Atom}entry at e2b510>,
 <Element {http://www.w3.org/2005/Atom}entry at e2b540>]

Метод findall() знаходить всі дочірні елементи які відповідають певному запиту. (Про формат цього запиту ви дізнаєтесь за хвилину.)

>>> root.tag
'{http://www.w3.org/2005/Atom}feed'
>>> root.findall('{http://www.w3.org/2005/Atom}feed')
[]

Кожен елемент - як кореневий, так і його діти, має метод findall(). Цей метод знаходить всі відповідні запиту елементи серед дітей елемента для якого викликається. Але чому нема жодних результатів? Хоча це й не надто очевидно, даний запит шукає лише серед дітей елемента. А так як кореневий елемент feed не має дітей які називаються feed, запит повертає порожній список.

>>> root.findall('{http://www.w3.org/2005/Atom}author')
[]

Цей результат теж може бути несподіваним для вас. В даному документі є елемент author, навіть три (один в кожному елементі entry). Але ті елементи не є прямими дітьми кореневого, вони є "онуками" (дітьми дітей). Якщо ви хочете знайти елементи author на будь-якому рівні вкладеності, це можливо зробити, проте формат запиту буде дещо іншим.

>>> tree.findall('{http://www.w3.org/2005/Atom}entry')
[<Element {http://www.w3.org/2005/Atom}entry at e2b4e0>,
 <Element {http://www.w3.org/2005/Atom}entry at e2b510>,
 <Element {http://www.w3.org/2005/Atom}entry at e2b540>]

Для зручності, об’єкт tree (повернутий функцією etree.parse()) має кілька методів що відображають методи кореневого елемента. Результати є такими самими, як і при виклику методу tree.getroot().findall().

>>> tree.findall('{http://www.w3.org/2005/Atom}author')
[]

Можливо дещо несподівано, але цей запит не знаходить елементів author в нашому документі. Чому ні? Тому що це просто скорочений запис tree.getroot().findall('{http://www.w3.org/2005/Atom}author'), який означає "знайти всі елементи author які є дітьми елемента root. Елементи author не є дітьми кореневого елементу, вони є дітьми елементів entry. Тому запит не повертає жодних результатів.

Також існує метод find() який повертає перший знайдений елемент. Це корисно у випадках в яких ви очікуєте знайти лише один елемент, або коли хоча елементів може бути кілька, нам потрібен лише перший.

>>> entries = tree.findall('{http://www.w3.org/2005/Atom}entry')
>>> len(entries)
3

Це ви вже бачили в попередньому прикладі. Запит знаходить всі елементи atom:entry.

>>> title_element = entries[0].find('{http://www.w3.org/2005/Atom}title')
>>> title_element.text
'Dive into history, 2009 edition'

Метод find() приймає запит ElementTree та повертає перший елемент що відповідає запиту.

>>> foo_element = entries[0].find('{http://www.w3.org/2005/Atom}foo')
>>> foo_element
>>> type(foo_element)
<class 'NoneType'>

Всередині цього елемента entry немає елемента що називається foo, тому ми отримуємо результат None.

В методі find() існує підводний камінь на який ви можете наткнутися. В булевому контексті, об’єкти елементів ElementTree мають значення False якщо вони не мають дітей (тобто коли len(element) дорівнює нулю). Це означає що if element.find('...') не перевіряє чи метод find() знайшов відповідний елемент, воно перевіряє чи цей елемент має дітей! Щоб перевірити чи повернув метод find() якийсь елемент, користуйтесь умовою if element.find('...') is not None.

Існує спосіб знаходити всіх нащадків, тобто дітей, "онуків", та інших елементів на будь-яких рівнях вкладеності.

>>> all_links = tree.findall('//{http://www.w3.org/2005/Atom}link')
>>> all_links
[<Element {http://www.w3.org/2005/Atom}link at e181b0>,
 <Element {http://www.w3.org/2005/Atom}link at e2b570>,
 <Element {http://www.w3.org/2005/Atom}link at e2b480>,
 <Element {http://www.w3.org/2005/Atom}link at e2b5a0>]

Цей запит - //{http://www.w3.org/2005/Atom}link — дуже подібний до попередніх прикладів, якщо не брати до уваги двох слешів на початку запиту. Ці два слеші означають "не перебирати лише прямих дітей, перевіряти всі елементи не залежно від рівня вкладеності". Тому результатом є список з чотирьох елементів link, а не тільки з одного.

>>> all_links[0].attrib
{'href': 'http://diveintomark.org/',
 'type': 'text/html',
 'rel': 'alternate'}

Перший результат є дитиною кореневого елемента. Як можна побачити з атрибутів - це посилання на альтернативне представленя фіду, яке вказує на HTML версію сайту що фід описує.

>>> all_links[1].attrib
{'href': 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition',
 'type': 'text/html',
 'rel': 'alternate'}
>>> all_links[2].attrib
{'href': 'http://diveintomark.org/archives/2009/03/21/accessibility-is-a-harsh-mistress',
 'type': 'text/html',
 'rel': 'alternate'}
>>> all_links[3].attrib
{'href': 'http://diveintomark.org/archives/2008/12/18/give-part-1-container-formats',
 'type': 'text/html',
 'rel': 'alternate'}

Інші три результати є посиланнями на альтернативне представлення для кожної публікації. Кожна публікація містила одне посилання, і завдяки двом слешам на початку запиту, вони всі потрапили до результатів.

Взагалі, метод findall() бібліотеки ElementTree є дуже потужною функцією, але мова запитів може бути дещо незвичною. Вона описується як "обмежена підтримка виразів XPath". XPath є W3C стандартом здійснення запитів до документів XML. Мова запитів ElementTree достатньо подібна на XPath щоб здійснювати базовий пошук, але достатньо відмінна щоб дратувати вас якщо ви вже знаєте XPath. Зараз давайте поглянемо на сторонню XML бібліотеку яка розширює API ElementTree повною підтримкою XPath.


* * *


Йдемо далі з lxml

[ред.]

lxml - стороння бібліотека з відкритим кодом що базується на популярному парсері libxml2. Вона надає стовідсотково сумісне з ElementTree API, та розширює його до повної підтримки XPath 1.0 додаючи ще деякі приємності. Існують інсталятори для Windows. Користувачі Linux повинні пробувати використовувати інструменти свого дистрибутиву, такі як yum чи apt-get щоб встановити попередньо скомпільовані бінарники з репозиторіїв. В решті випадків доведеться встановлювати lxml вручну.

>>> from lxml import etree

Після імпортування lxml надає таке саме API як і вбудована ElementTree.

>>> tree = etree.parse('examples/feed.xml')
>>> root = tree.getroot()
>>> root.findall('{http://www.w3.org/2005/Atom}entry')
[<Element {http://www.w3.org/2005/Atom}entry at e2b4e0>,
 <Element {http://www.w3.org/2005/Atom}entry at e2b510>,
 <Element {http://www.w3.org/2005/Atom}entry at e2b540>]

Методи parse(), getroot() та findall() - працюють точно так само як і в ElementTree.

Якщо XML документ досить великий, lxml працюватиме з ним значно швидше ніж вбудована бібліотека ElementTree. Якщо ви використовуєте лише API ElementTree і хочете мати найшвидшу з доступних реалізацій, ви можете спробувати імпортувати lxml та при невдачі повернутись до вбудованої ElementTree.

try:
    from lxml import etree
except ImportError:
    import xml.etree.ElementTree as etree

Але lxml - це набагато більше ніж просто швидша ElementTree. Її метод findall() підтримує більш складні вирази.

>>> import lxml.etree
>>> tree = lxml.etree.parse('examples/feed.xml')
>>> tree.findall('//{http://www.w3.org/2005/Atom}*[@href]')
[<Element {http://www.w3.org/2005/Atom}link at eeb8a0>,
 <Element {http://www.w3.org/2005/Atom}link at eeb990>,
 <Element {http://www.w3.org/2005/Atom}link at eeb960>,
 <Element {http://www.w3.org/2005/Atom}link at eeb9c0>]

Цей запит знаходить всі елементи документа в просторі імен Atom, які мають атрибут href. // на початку документа означає "елементи всюди (а не лише діти кореневого елемента)", {http://www.w3.org/2005/Atom} означає "лише елементи в просторі імен Atom", * означає "елементи з будь-яким ім’ям". Ну а [@href] означає "мають атрибут href".

>>> tree.findall("//{http://www.w3.org/2005/Atom}*[@href='http://diveintomark.org/']")
[<Element {http://www.w3.org/2005/Atom}link at eeb930>]

Цей запит знаходить всі елементи Atom, які мають атрибут href значення якого дорівнює http://diveintomark.org/.

>>> NS = '{http://www.w3.org/2005/Atom}'
>>> tree.findall('//{NS}author[{NS}uri]'.format(NS=NS))
[<Element {http://www.w3.org/2005/Atom}author at eeba80>,
 <Element {http://www.w3.org/2005/Atom}author at eebba0>]

Після того як ми використали деяке форматування рядків (без якого запит виглядає занадто довгим), ми написали запит, який шукає елементи Atom author, які мають дочірній елемент Atom uri. Це поверне нам два елементи author, з першої та другої публікації. Елемент author з трерьої публікації містить лише name, а не uri.

Для вас все ще не достатньо? lxml також включає підтримку довільних виразів XPath 1.0. Я не збираюсь тут заглиблюватись в синтаксис XPath, бо про нього можна написати окрему книгу. Але я покажу вам як він інтегрується в lxml.

>>> import lxml.etree
>>> tree = lxml.etree.parse('examples/feed.xml')
>>> NSMAP = {'atom': 'http://www.w3.org/2005/Atom'}

Щоб виконувати запити XPath над елементами з просторами імен, потрібно описати відображення префіксів в простори імен. Це звичайний словник Python.

>>> entries = tree.xpath("//atom:category[@term='accessibility']/..",
...     namespaces=NSMAP)
>>> entries
[<Element {http://www.w3.org/2005/Atom}entry at e2b630>]

Ось тут ми бачимо запит за допомогою XPath. Цей вираз шукає елементи category (у просторі імен Atom), що містять атрибут term зі значенням accessibility. Але це насправді не буде результатом запиту. Подивіться на кінець виразу, ви помітили частину /..? Це означає "а потім поверніть батьківський елемент елемента category який ми щойно знайшли". Тому цей запит XPath знайде всі публікації що містять дочірній елемент <category term='accessibility'>.

Результатом роботи фукнції xpath() є список об’єктів ElementTree. В даному документі є лише одна публікація що відповідає потрібним критеріям.

>>> entry = entries[0]
>>> entry.xpath('./atom:title/text()', namespaces=NSMAP) ④
['Accessibility is a harsh mistress']

Вирази XPath не завжди повертають список елементів. Технічно, DOM-дерево XML-документа не містить елементів, воно містить вузли. Залежно від їх типу, вузли можуть бути елементами, атрибутами, чи навіть текстовим вмістом. Результатом запиту XPath є список вузлів. Цей запит повертає список текстових вузлів: текстовий вміст (text()) елемента title (atom:title) що є дитиною поточного елемента (./).


* * *


Генерація XML

[ред.]

Підтримка XML в Python не обмежується читанням існуючих документів. Ви також можете їх створювати.

>>> import xml.etree.ElementTree as etree
>>> new_feed = etree.Element('{http://www.w3.org/2005/Atom}feed',
...     attrib={'{http://www.w3.org/XML/1998/namespace}lang': 'en'})

Щоб створити новий елемент потрібно створити екземпляр класу Element. Ім’я елемента (простір імен та локальне ім’я) можна передати першим елементом. У видеописаному випадку створюється елемент feed в просторі імен Atom. Це буде кореневим елементом нашого документа. Атрибути при створенні елемента передаються у словнику, за допомогою параметра attrib. Зауважте що імена атрибутів повинні бути в стандартному форматі ElementTree: {простір_імен}локальне_ім’я.

>>> print(etree.tostring(new_feed))
<ns0:feed xmlns:ns0='http://www.w3.org/2005/Atom' xml:lang='en'/>

В будь-який момент можна серіалізувати будь-який елемент (разом з дітьми) за допомогою функції бібліотеки ElementTree tostring().

Результат серіалізації став для вас несподіванкою? Спосіб, яким ElementTree серіалізує XML елементи з простором імен є технічно правильним, але не оптимальним. Приклад XML документа на початку цього розділу задавав простір імен за замовчуванням (xmlns='http://www.w3.org/2005/Atom'). Задання простору імен за замовчуванням є корисним для документів на зразок фідів Atom - де кожен елемент знаходиться в одному й тому ж просторі імен, тому можна описати простір імен лише раз, а далі описувати кожен елемент використовуючи лише його локальне ім’я (<feed>, <link>, <entry>). Не треба використовувати ніяких префіксів, якщо тільки ви не хочете описати елементи з іншого простору імен.

Парсер XML не побачить жодних відмінностей між XML документом з простором імен що заданий за замовчуваням, та простором імен який задається в кожному префіксі. DOM-дерево наступного документа:

<ns0:feed xmlns:ns0='http://www.w3.org/2005/Atom' xml:lang='en'/>

ідентичне DOM-дереву такого:

<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'/>

Єдина практична відмінність в тому, що друга серіалізація на кілька символів коротша. Якщо б ми додали префікс ns0: до кожного відкриваючого та закриваючого тега всього документа який давався як приклад на початку розділу, це б додало чотири символи на тег × 79 тегів + 4 символи для задання самого простору імен, що загалом складає 320 символів. Припускаючи кодування UTF-8, це 320 додаткових байтів. (Після стиснення з Gzip різниця падає до 21-го байта, але все одно, 21 байт, це 21 байт). Можливо для вас це й нічого не означає, але для такої штуки як фід Atom, яку можуть завантажувати кілька тисяч разів при кожній зміні, економія кількох байт на один запит може швидко акумулюватись.

Вбудована бібліотека ElementTree не надає можливості тонкого налаштування серіалізації елементів з просторами імен, але lxml надає.

>>> import lxml.etree
>>> NSMAP = {None: 'http://www.w3.org/2005/Atom'}

Для початку, опишіть словник просторів імен. Значеннями словника будуть простори імен, ключами - префікси. Використання None замість префікса задає простір імен за замовчуванянм.

>>> new_feed = lxml.etree.Element('feed', nsmap=NSMAP)

Тепер, при створенні елемента можна передавати специфічний для lxml аргумент nsmap, і lxml не обділить увагою простори імен що ви задали.

>>> print(lxml.etree.tounicode(new_feed))
<feed xmlns='http://www.w3.org/2005/Atom'/>

Як і очікувалось, ця серіалізація опише простір імен Atom, як простір імен за замовчуванням а елемент feed запише без префікса.

>>> new_feed.set('{http://www.w3.org/XML/1998/namespace}lang', 'en')
>>> print(lxml.etree.tounicode(new_feed))
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'/>

Ой, ми забули додати атрибут xml:lang. Але то нічого, атрибути завжди можна додати за допомогою методу set() будь-якого елемента. Він має два аргументи: назву атрибуту в стандартному форматі ElementTree, та значення атрибуту.

Чекайте, хіба документи XML обмежені одним елементом на документ? Ні, звичайно що ні. Ви також можете просто створити дочірні елементи.

>>> title = lxml.etree.SubElement(new_feed, 'title',
...     attrib={'type':'html'})

Щоб створити дочірній елемент для вже існуючого елемента, створіть екземпляр класу SubElement. Обов’язковими аргументами є батьківський елемент (new_feed в нашому прикладі) та ім’я нового елемента. Так як дочірні елементи успадкують словник просторів імен свого батька, нема необхідності передекларовувати префікси просторів імен в цьому конструкторі. Також можна передати словник атрибутів. Ключами є імена атрибутів, значеннями - значення атрибутів.

>>> print(lxml.etree.tounicode(new_feed))
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'><title type='html'/></feed>

Як і очікувалось, новий елемент title був створений в просторі імен Atom, та був вставлений як дитина елементу feed. Так як елемент title не має власного текстового вмісту, lxml серіалізує його як порожній елемент (зі скороченням />).

>>> title.text = 'dive into &hellip;'

Щоб задати елементу текстовий вміст, просто задайте значення атрибуту .text.

>>> print(lxml.etree.tounicode(new_feed))
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'><title type='html'>dive into &amp;hellip;</title></feed>

Тепер елемент title серіалізується зі своїм вмістом. Будь-який вміст що містить знаки менше, чи амперсанди повинен екрануватись при серіалізації. lxml проводить таку екранізацію автоматично.

>>> print(lxml.etree.tounicode(new_feed, pretty_print=True))
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'>
<title type='html'>dive into&amp;hellip;</title>
</feed>

При серіалізації можна також використати опцію "гарного форматування" (pretty_print), яка додає символи нового рядка після закриваючих тегів, та після відкриваючих тегів елементів які містять дочірні елементи але не текст. В технічних термінах, lxml може додавати незначимі пропуски, щоб зробити вивід більш читабельним.

Можливо ви також захочете оцінити xmlwitch - іншу сторонню бібліотеку для генерації XML. Вона активно використовує конструкцію with, щоб зробити код генерації XML більш читабельним.


* * *


Парсинг пошкодженого XML

[ред.]

Специфікація XML вимагає щоб всі відповідні їй парсери XML застосовували "драконівську обробку помилок". Тобто, вони повинні з шумом припинити роботу, як тільки помітять в XML документі будь-яку помилку оформлення. До помилок оформлення входять неспівпадаючі відкриваючі та закриваючі теги, незадані сутності, недозволені символи юнікоду, та багато інших езотеричних правил. Це різко контрастує з іншими звичними форматами, такими як HTML - ваш браузер не припинить рендеринг веб-сторінки якщо ви забудете закрити HTML-тег, чи екранувати амперсанд в значенні атрибута. (Це типова помилка - вважати що HTML не має описаного алгоритму роботи з помилками. Робота з помилками в HTML насправді добре описана, але є значно складнішою ніж "припинити роботу при першій помилці".)

Деякі люди (і я в тому числі) вважають що це було помилкою винахідників XML - здійснювати драконівську обробку помилок. Не зрозумійте мене неправильно, я безперечно можу побачити привабливість спрощення правил обробки помилок. Але на практиці, ідея "правильного форматування" є складнішою ніж звучить, особливо для документів XML (на зразок фідів Atom) які публікуються у всесвітньому павутинні і передаються через HTTP. Не зважаючи на дозрілість XML, яка ввела драконівську обробку помилок в стандарті 1997-го, дослідження постійно показують, що значна частина фідів Atom в інтернеті страждають помилками форматування.

Тому, я маю як теоретичні, так і практичні причини парсити документи XML "за всяку ціну", тобто не припиняти розбір при першій же помилці форматування. Якщо ви теж захочете робити так, lxml може допомогти.

Ось фрагмент неправильного XML документа. Я виділив рядок з помилкою:

<?xml version='1.0' encoding='utf-8'?>
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'>
  <title>dive into &hellip;</title>
...
</feed>

Це помилка, тому що сутність &hellip; не описана в XML. (Вона описана в HTML.) Якщо ви спробуєте парсити цей помилковий фід з стандартними налаштуваннями, lxml затнеться на цій неописаній сутності.

>>> import lxml.etree
>>> tree = lxml.etree.parse('examples/feed-broken.xml')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "lxml.etree.pyx", line 2693, in lxml.etree.parse (src/lxml/lxml.etree.c:52591)
  File "parser.pxi", line 1478, in lxml.etree._parseDocument (src/lxml/lxml.etree.c:75665)
  File "parser.pxi", line 1507, in lxml.etree._parseDocumentFromURL (src/lxml/lxml.etree.c:75993)
  File "parser.pxi", line 1407, in lxml.etree._parseDocFromFile (src/lxml/lxml.etree.c:75002)
  File "parser.pxi", line 965, in lxml.etree._BaseParser._parseDocFromFile (src/lxml/lxml.etree.c:72023)
  File "parser.pxi", line 539, in lxml.etree._ParserContext._handleParseResultDoc (src/lxml/lxml.etree.c:67830)
  File "parser.pxi", line 625, in lxml.etree._handleParseResult (src/lxml/lxml.etree.c:68877)
  File "parser.pxi", line 565, in lxml.etree._raiseParseError (src/lxml/lxml.etree.c:68125)
lxml.etree.XMLSyntaxError: Entity 'hellip' not defined, line 3, column 28

Щоб парсити цей пошкоджений документ XML, не беручи до уваги помилки в його оформленні, вам потрібно створити свій парсер XML.

>>> parser = lxml.etree.XMLParser(recover=True)

Щоб створити власний парсер створіть екземпляр класу xml.etree.XMLParser. Клас приймає різноманітні іменовані аргументи. Тут ми зацікавлені лише в аргументі recover. Коли йому передати True, парсер XML буде старатись зробити все найкраще аби "відновитись" ("recover") після помилок форматування.

>>> tree = lxml.etree.parse('examples/feed-broken.xml', parser)

Аби відпарсити документ XML вашим парсером, передайте об’єкт parser другим аргументом в функцію parse(). Зауважте що lxml не генерує винятку щодо неописаної сутності &hellip;.

>>> parser.error_log
examples/feed-broken.xml:3:28:FATAL:PARSER:ERR_UNDECLARED_ENTITY: Entity 'hellip' not defined

Парсер зберігає лог помилок форматування з якими він зустрівся. (І робить це незалежно від того чи встановлена опція відновлення після тих помилок чи ні.)

>>> tree.findall('{http://www.w3.org/2005/Atom}title')
[<Element {http://www.w3.org/2005/Atom}title at ead510>]
>>> title = tree.findall('{http://www.w3.org/2005/Atom}title')[0]
>>> title.text
'dive into '

Так як він не знав що робити з неописаною сутністю &hellip;, парсер її просто тихенько пропустив. Текстовий вміст елемента title став 'dive into '.

>>> print(lxml.etree.tounicode(tree.getroot()))
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'>
  <title>dive into </title>
.
. [решта серіалізації пропущена для економії місця]
.

Як ви можете бачити з серіалізації, &hellip; справді видалено з документа.

Важливо повторити що між парсерами які "відновлюються" після помилок нема гарантії сумісності. Інший парсер міг вирішити що він розпізнає сутність &hellip; з HTML, і замінити її на &amp;hellip;. Чи це "краще"? Можливо. Чи це "більш правильно"? Ні, вони обоє однаково неправильні. Правильна поведінка (згідно специфікації XML) - аварійно завершити роботу. Якщо ви вирішили цього не робити - тоді ви самі по собі.


* * *


Для подальшого читання

[ред.]

Серіалізація об'єктів

[ред.]

Щосуботи, відколи ми живемо в цій квартирі, я прокидався в 6:15, насипав собі миску вівсянки, додавав чверть чашки двохпроцентного молока, сідав на цей кінець цього дивану, вмикав BBC Америка, і дивився доктора Хто.
Шелдон, Теорія Великого Вибуху


Згоди ідея серіалізації виглядає простою. В нас є структура даних в пам'яті яку ми хочемо зберегти, перевикористати чи відправити комусь іншому. Як це можна зробити? Ну, все залежить від того як ви хочете її зберігати, як ви хочете її повторно використовувати, і кому ви хочете її відправити. Багато ігор дозволяють вам збегігати свій прогрес при завершенні гри, і продовжувати звідки почали, коли ви запускаєте гру наступного разу. (Ну і звісно так вміють багато програм окрім ігор.) В даному випадку, структура даних що зберігає ваш поточний прогрес повинна записатись на диск коли ви виходите з програми, а потім завантажитись в пам'ять коли ви запускаєте програму знову. Такі дані призначені для використання тою ж програмою що їх створила, не пересилаються через мережу і ніколи не читаються іншими програмами. Тому проблеми сумісності обмежуються гарантуванням того що наступні версії програми будуть здатні читати дані записані попередніми версіями.

Для подібних випадків ідеальним є модуль pickle. Він є частиною стандартної бібліотеки мови Python, тому завжди доступний. Він швидкий, основна його частина написана на C, як і сам інтерпретатор мови. Він може зберігати досить складні структури даних мови Python.

Що може зберігати модуль pickle?

  • Всі стандартні типи даних що підтримує Python: булевий, цілий, числа з плаваючою крапкою, комплексні числа, рядки, байтові об'єкти, та None.
  • Списки, кортежі, словники і множини що містять будь-які комбінації стандартних типів даних.
  • Списки, кортежі, словники і множини що містять будь-яку послідовність списків, кортежів, словників і множин що містять будь-яку послідовність стандартних типів даних (і так далі, до максимального рівня вкладеності що підтримує Python[1]).
  • Функції, класи та екземпляри класів (з кількома застереженнями).

Якщо цього для вас недостатньо, модуль pickle ще й розширюваний. Якщо ви зацікавлені в розширенні, перегляньте посилання в параграфі Для подальшого читання в кінці розділу.

Коротке зауваження про приклади цього розділу

[ред.]

Цей розділ розповідає історію з двома оболонками інтерпретатора. Всі приклади в цьому розділі є частиною єдиної сюжетної арки. Вас проситимуть перемикатись між двома оболонками поки я демонструватиму вам модулі pickle та json.

Щоб не заплутатись, запустіть одну сесію інтерпретатора Python і створіть наступну змінну:

>>> shell = 1

Тримайте це вікно відкритим. Тепер відкрийте ще одне, і опишіть таку змінну:

>>> shell = 2

Протягом цього розділу я використовуватиму змінну shell щоб позначити яка з оболонок використовується в кожному прикладі.


* * *


Збереження даних в файл Piсkle

[ред.]

Модуль pickle працює з структурами даних. Давайте створимо одну.

>>> shell
1
>>> entry = {}
>>> entry['title'] = 'Dive into history, 2009 edition'
>>> entry['article_link'] = 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition'
>>> entry['comments_link'] = None
>>> entry['internal_id'] = b'\xDE\xD5\xB4\xF8'
>>> entry['tags'] = ('diveintopython', 'docbook', 'html')
>>> entry['published'] = True

Ідея тут полягає в тому щоб створити словник що може містити щось корисне, наприклад елемент фіду Atom. Але я також хочу щоб він міститв кілька різних типів даних, щоб показати що може модуль pickle. Не треба вишукувати в цих значеннях забагато змісту.

>>> import time
>>> entry['published_date'] = time.strptime('Fri Mar 27 22:20:42 2009')
>>> entry['published_date']
time.struct_time(tm_year=2009, tm_mon=3, tm_mday=27, tm_hour=22, tm_min=20, tm_sec=42, tm_wday=4, tm_yday=86, tm_isdst=-1)

Модуль time містить структуру даних (struct_time) що являє собою момент в часі (з точністю до одної мілісекунди) і функції для маніпуляції з часовими структурами даних. Функція strptime() приймає форматований рядок і перетворює його в struct_time. Цей рядок записаний в стандартному форматі, але ви можете керувати форматом текстового запису часу. Зверніться до модуля time за додатковими деталями.

Ну, от в нас вийшов симпатичний словничок. Давайте збережемо його в файл.

>>> shell
1
>>> import pickle
>>> with open('entry.pickle', 'wb') as f:
...     pickle.dump(entry, f)
...

Тут ми використовуємо функцію open() щоб відкрити файл. Ми встановлюємо режим файла в 'wb' що означає відкрити файл для запису в двійковому режимі. Загорніть запис в конструкцію with щоб переконатись що файл автоматично закриється коли ми закінчимо роботу з ним.

Функція dump() модуля pickle приймає серіалізовну структуру даних мови Python, серіалізує її в двійковий формат використовуючи останню версію протоколу pickle мови Python, і зберігає її в відкритий файл.

Останнє речення було досить важливим.

  • Модуль pickle приймає структуру даних мови Python і зберігає її в файл.
  • Щоб зробити це він серіалізує структуру даних використовуючи формат який називається "протокол pickle".
  • Протокол pickle специфічний для мови Python, немає гарантії міжмовної сумісності. Скоріш за все ви не зможете взяти щойностворений файл entry.pickle і зробити з ним щось корисне за допомогою Perl, PHP, Java чи іншої мови.
  • Не кожна структура мови Python може бути серіалізованою модулем pickle. Протокол pickle змінювався кілька разів коли в мові Python з'являлись нові типи даних, але досі існують певні обмеження.
  • Через ці зміни немає гарантії сумісності цих файлів між різними версіями мови Python. Новіші версії мови підтримують старі формати серіалізації, але старіші версії не підтримують нові формати (бо в них немає відповідних нових типів даних).
  • Якщо ви не вкажете інше, всі функції модуля pickle використовуватимуть останню версію протоколу. Це гарантує вам максимальну гнучкість при виборі даних які можна серіалізувати, але також означає що файл не читатиметься в старих версіях мови Python, які не підтримують останню версію протоколу.
  • Останньою версією протоколу pickle є двійковий формат. Переконайтесь що ви відкриваєте файли в двійковому режимі, інакше дані будуть пошкоджені при записі.


* * *


Завантаження даних з файлу pickle

[ред.]

Тепере переключіться в другий інтерпретатор Python - в той в якому ми не створювали словник entry.

>>> shell
2
>>> entry
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'entry' is not defined

Як бачимо в інтерпретаторі №2 немає змінної entry. Ми описали змінну entry в першій оболонці, але це зовсім інше середовище з власним станом.

>>> import pickle
>>> with open('entry.pickle', 'rb') as f:
...     entry = pickle.load(f)
...

Відкрийте файл entry.pickle який ми створили в інтерпретаторі №1. Модуль pickle використовує двійковий формат даних, тому ми повинні завжди відкривати файли pickle в двійковому режимі.

Фукнція pickle.load() приймає потоковий об'єкт, читає серіалізовані дані з потоку, створює новий об'єкт мови Python, відтворює серіалізовані дані в цьому об'єкті, і повертає його як результат.

>>> entry
{'comments_link': None,
 'internal_id': b'\xDE\xD5\xB4\xF8',
 'title': 'Dive into history, 2009 edition',
 'tags': ('diveintopython', 'docbook', 'html'),
 'article_link':
 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition',
 'published_date': time.struct_time(tm_year=2009, tm_mon=3, tm_mday=27, tm_hour=22, tm_min=20, tm_sec=42, tm_wday=4, tm_yday=86, tm_isdst=-1),
 'published': True}

Тепер змінна entry - це словник з вже знайомими нам ключами та значеннями.

Послідовність pickle.dump() / pickle.load() в результаті дає нам нову структуру даних яка дорівнює оригінальній структурі.

Тепер давайте знову повернемось в оболонку №1.

>>> shell
1
>>> with open('entry.pickle', 'rb') as f:
...     entry2 = pickle.load(f)
...

Давайте відкриємо файл entry.pickle і завантажимо серіалізовані дані в нову змінну, entry2.

>>> entry2 == entry
True

Python підтверджує що два словники, entry та entry2 рівні. В цьому інтерпретаторі ми створили entry з нуля, почавши з порожнього словника, і вручну присвоюючи значення відповідним ключам. Ми серіалізували цей словник і зберегли його в файлі entry.pickle. Зараз ми прочитали серіалізовані дані з того файлу і створили ідеальну копію оригінальної структури даних.

>>> entry2 is entry
False

Рівність це не те саме що ідентичність. Я сказав що ми створили ідеальну копію оригінальної структури даних, і це правда. Але це все одно копія.

>>> entry2['tags']
('diveintopython', 'docbook', 'html')
>>> entry2['internal_id']
b'\xDE\xD5\xB4\xF8'

З причин які стануть зрозумілими далі в цьому розділі, я хочу вказати на те що занчення ключа 'tags' - кортеж, а значення ключа 'internal_id' - байтовий об'єкт.


* * *


Серіалізація без файлу

[ред.]

Приклади в попередній секції показали нам як серіалізувати об'єкт прямо в файл на диску. Але що якщо ми не хочемо, чи не потребуємо файла? Також можна серіалізувати в байтовий об'єкт в пам'яті.

>>> shell
1
>>> b = pickle.dumps(entry)

Функція pickle.dumps() (зауважте 's' наприкінці імені) виконує таку ж серіалізацію як і pickle.dump(). Але замість того щоб прийняти потоковий об'єкт і записати серіалізовані дані на диск, вона просто повертає серіалізовані дані.

>>> type(b)
<class 'bytes'>

Так як протокол pickle використовує двійковий формат даних, функція pickle.dumps повертає об'єкт типу bytes.

>>> entry3 = pickle.loads(b)

Функція pickle.loads() (знову, зауважте 's' наприкінці імені) виконує таку ж десеріалізацію як і фукнція pickle.load(). Але замість того щоб прайняти потоковий об'єкт і прочитати серіалізовані дані з диску, вона приймає байтовий об'єкт що містить серіалізовані дані, такий як той що повернула функція pickle.dumps().

>>> entry3 == entry
True

Кінцевий результат такий самий - ідеальна копія оригінального словника.


* * *


Байти та рядки показують свою потворну голову знову

[ред.]

Протокол pickle існував протягом багатьох років, і розвивався разом з мовою Python. Зараз існує чотири різні версії протоколу pickle.

  • Python 1.x мав два протоколи, текстовий формат ("версія 0") та двійковий формат ("версія 1").
  • В Python 2.3 з'явився новий протокол pickle ("версія 2") призначений для роботи з новою функціональністю класів в Python. Це двійковий формат.
  • В Python 3.0 з'явився інший протокол pickle ("версія 3") з підтримкою байтових об'єктів та байтових масивів. Він теж двійкового формату.

Ой, гляньте, відмінність між байтами та рядками знову показує свою потворну голову. (Якщо вас це здивувало, значить ви недостатньо уважно читаєте). На практиці це означає що хоча Python 3 може читати дані записані протоколом версії 2, Python 2 не може читати дані записані протоколом версії 3.


* * *



Дослідження файлів Pickle

[ред.]

Як виглядає протокол pickle? Давайте на хвилину вийдемо з інтерпретатора і подивимось на створений нами файл entry.pickle. Для неозброєного ока, це переважно абракадабра.

you@localhost:~/diveintopython3/examples$ ls -l entry.pickle
-rw-r--r-- 1 you you 358 Aug 3 13:34 entry.pickle
you@localhost:~/diveintopython3/examples$ cat entry.pickle
comments_linkqNXtagsqXdiveintopythonqXdocbookqXhtmlq?qX publishedq?
XlinkXJhttp://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition
q Xpublished_dateq
ctime
struct_time
?qRqXtitleqXDive into history, 2009 editionqu.

Це не надто допомагає. Можна бачити рядки, але інші типи даних записані невидимими або нечитабельними символами. Поля не розділяються очевидним чином пропусками чи табуляціями. Це не той формат який хотілось би досліджувати вручну.

>>> shell
1
>>> import pickletools
>>> with open('entry.pickle', 'rb') as f:
...     pickletools.dis(f)

Тому використовуємо для цього вбудовані інструменти які дадуть нам таку інформацію:

   0: \x80 PROTO      3
   2: }    EMPTY_DICT
   3: q    BINPUT     0
   5: (    MARK
   6: X        BINUNICODE 'published_date'
  25: q        BINPUT     1
  27: c        GLOBAL     'time struct_time'
  45: q        BINPUT     2
  47: (        MARK
  48: M            BININT2    2009
  51: K            BININT1    3
  53: K            BININT1    27
  55: K            BININT1    22
  57: K            BININT1    20
  59: K            BININT1    42
  61: K            BININT1    4
  63: K            BININT1    86
  65: J            BININT     -1
  70: t            TUPLE      (MARK at 47)
  71: q        BINPUT     3
  73: }        EMPTY_DICT
  74: q        BINPUT     4
  76: \x86     TUPLE2
  77: q        BINPUT     5
  79: R        REDUCE
  80: q        BINPUT     6
  82: X        BINUNICODE 'comments_link'
 100: q        BINPUT     7
 102: N        NONE
 103: X        BINUNICODE 'internal_id'
 119: q        BINPUT     8
 121: C        SHORT_BINBYTES 'ÞÕ´ø'
 127: q        BINPUT     9
 129: X        BINUNICODE 'tags'
 138: q        BINPUT     10
 140: X        BINUNICODE 'diveintopython'
 159: q        BINPUT     11
 161: X        BINUNICODE 'docbook'
 173: q        BINPUT     12
 175: X        BINUNICODE 'html'
 184: q        BINPUT     13
 186: \x87     TUPLE3
 187: q        BINPUT     14
 189: X        BINUNICODE 'title'
 199: q        BINPUT     15
 201: X        BINUNICODE 'Dive into history, 2009 edition'
 237: q        BINPUT     16
 239: X        BINUNICODE 'article_link'
 256: q        BINPUT     17
 258: X        BINUNICODE 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition'
 337: q        BINPUT     18
 339: X        BINUNICODE 'published'
 353: q        BINPUT     19
 355: \x88     NEWTRUE
 356: u        SETITEMS   (MARK at 5)
 357: .    STOP
highest protocol among opcodes = 3

Найцікавішою інформацією що видав цей дизасемблер є останній рядок, тому що він вказує на версію протоколу в якій зберігався файл. В протоколі pickle немає явного запису версії. Щоб визначити яку версію протоколу використовували для збереження файлу, потрібно подивитись на маркери ("опкоди") в запіклених даних і використати знання про те які опкоди в якій версії протоколу були введені. Функція pickletools.dist() робить саме це, і виводить результат в останньому рядку свого виводу. Ось функція що повертає лише номер версії, не друкуючи нічого:

import pickletools

def protocol_version(file_object):
    maxproto = -1
    for opcode, arg, pos in pickletools.genops(file_object):
        maxproto = max(maxproto, opcode.proto)
    return maxproto

А ось вона в дії:

>>> import pickleversion
>>> with open('entry.pickle', 'rb') as f:
...     v = pickleversion.protocol_version(f)
>>> v 3


* * *


Серіалізація об'єктів Python для читання в інших мовах

[ред.]

Формат даних що використовується модулем pickle специфічний для Python. Не робиться ніяких спроб зробити його сумісним з іншими мовами. Якщо міжмовна сумісність є однією з ваших вимог, вам потрібно розглянути інші формати серіалізації. Одним з таких форматів є JSON. "JSON" означає "JavaScript Object Notation", тобто "запис об'єктів в мові JavaScript", але не дайте цій назві себе обдурити - JSON спеціально створений для того щоб могти використовуватись в різних мовах програмування.

Python3 містить в своїй стандартній бібліотеці модуль json. Як і модуль pickle, модуль json містить функції для серіалізації структур даних, збереження серіалізованих даних на диску, завантаження серіалізованих даних з диску і десеріалізації даних назад в новий об'єкт мови Python. Але також і є деякі важливі відмінності. По-перше, формат даних JSON текстовий а не двійковий. RFC 4627 описує формат JSON, і те як різні типи даних повинні бути закодовані в тексті. Наприклад, булеве значення зберігається або як п'ятисимвольний рядок 'false' або як чотирисимвольний рядок true. Всі значення в JSON чутливі до регістру.

По-друге, як і з будь-яким іншим текстовим форматом, є проблема з вільним місцем. JSON дозволяє вставляти між значеннями довільну кількість прогалин (пропусків, табуляцій, переходів на новий рядок). Це дозволяє вам "красиво роздрукувати" свої дані в JSON, гарно вкладаючи значення всередину значень з різними рівнями відступів, так що це буде зручно читати в браузері чи текстовому редакторі. Модуль json в Python має можливості такого форматування при закодовуванні.

По-третє, існує багаторічна проблема кодування символів. JSON кодує всі значення як простий текст, але як ви знаєте, немає такої штуки як простий текст. JSON повинен зберігатись в кодуванні Unicode (UTF-32, UTF-16, чи за замовчуванням, UTF-8), і третя секція RFC 4627 описує те як визначити яке кодування використовується.


* * *


Збереження даних в файл JSON

[ред.]

JSON виглядає неймовірно подібним до структур даних які ви могли б описати в мові JavaScript. Це не випадковість, і ви навіть можете використовувати функцію eval() в JavaScript щоб "розкодувати" дані серіалізовані в JSON. (Звичайні застереження щодо даних яким не варто довіряти тут теж потрібно брати до уваги, але суть в тому що JSON це правильний код JavaScript). Як такий, JSON повинен здаватись для вас знайомим.

>>> shell
1
>>> basic_entry = {}
>>> basic_entry['id'] = 256
>>> basic_entry['title'] = 'Dive into history, 2009 edition'
>>> basic_entry['tags'] = ('diveintopython', 'docbook', 'html')
>>> basic_entry['published'] = True
>>> basic_entry['comments_link'] = None

Замість того щоб використовувати стару структуру entry ми створимо нову. Далі в цьому розділі ми побачимо що стається коли ми намагаємось закодувати більш складні структури даних в JSON.

>>> import json
>>> with open('basic.json', mode='w', encoding='utf-8') as f:
...     json.dump(basic_entry, f)

JSON - це текстовий формат, і це означає що ми повинні відкрити цей файл в текстовому режимі і задати кодування символів. Ви ніколи не прогадаєте з UTF-8.

Як і модуль pickle модуль json описує функцію dump() яка приймає структуру даних мови Python та потоковий об'єкт в режимі запису. Функція dump серіалізує структуру даних і записує її в потоковий об'єкт. Якщо це робити всередині блока with, можна бути певним що після завершення файл буде правильно закритим.

То як виглядає результат серіалізації в JSON?

you@localhost:~/diveintopython3/examples$ cat basic.json
{"published": true, "tags": ["diveintopython", "docbook", "html"], "comments_link": null,
"id": 256, "title": "Dive into history, 2009 edition"}

Ну, це однозначно читабельніше ніж файл pickle. Але в JSON можна вставляти білі місця між значеннями, і модуль json надає простий спосіб для того щоб цим скористатись, і створити ще читабельніший файл JSON.

>>> shell
1
>>> with open('basic-pretty.json', mode='w', encoding='utf-8') as f:
...     json.dump(basic_entry, f, indent=2)

Якщо передати параметр indent в функцію json.dumps(), вона зробить результуючий JSON файл більш читабельним, за рахунок збільшення його розмірів. Параметр indent це ціле число, де 0 означає "розмістити кожне значення на новому рядку", а будь-яке додатнє ціле число означає "розмістити кожне значення на власному рядку, і використати таку кількість пропусків щоб робити відступи у вкладених структурах даних.

А ось отриманий результат:

you@localhost:~/diveintopython3/examples$ cat basic-pretty.json
{
  "published": true,
  "tags": [
    "diveintopython",
    "docbook",
    "html"
  ],
  "comments_link": null,
  "id": 256,
  "title": "Dive into history, 2009 edition"
}


* * *


Співставлення типів даних в Python і JSON

[ред.]

Так як JSON не є специфічним для Python, існує кілька неспівпадінь з типами мови Python. Деякі з цих неспівпадінь це просто відмінності в іменуванні, але є два важливі типи даних в Python які зовсім відсутні в JSON. Давайте подивимось чи ви їх знайдете:

Примітка JSON Python 3
об'єкт словник
масив список
рядок рядок
ціле ціле
дійсне число з плаваючою комою
* true True
* false False
* null None
* Всі значення JSON чутливі до регістру

Ви зауважили чого бракує? Кортежів і байт! JSON має тип "масив", який модуль json перетворює в список в Python, але не має окремого типу для "заморожених масивів" (кортежів). І хоча JSON досить добре підтримує рядки, він не має підтримки байтових об'єктів чи байтових масивів.


* * *


Серіалізація типів даних непідтримуваних в JSON

[ред.]

Хоча JSON й не має вбудованої підтримки байтових об'єктів, це не означає що ми не можемо серіалізувати об'єкти типу bytes. Модуль json надає засоби розширення кодування й декодування для невідомих типів. (Тут під "невідомими" я маю на увазі не описані в JSON). Очевидно що модуль json знає про масиви байт, але він зв'язаний обмеженнями специфікації JSON). Якщо ви хочете закодувати байти, чи інші типи які не підтримуються чистим JSON, ви повинні надати власні кодери й декодери для тих типів.

>>> shell
1
>>> entry
{'comments_link': None,
 'internal_id': b'\xDE\xD5\xB4\xF8',
 'title': 'Dive into history, 2009 edition',
 'tags': ('diveintopython', 'docbook', 'html'),
 'article_link': 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition',
 'published_date': time.struct_time(tm_year=2009, tm_mon=3, tm_mday=27, tm_hour=22, tm_min=20, tm_sec=42, tm_wday=4, tm_yday=86, tm_isdst=-1),
 'published': True}

Ок, зараз час повернутись до старої структури entry. Вона містить все: булеве значення, значення None, рядок, кортеж рядків, об'єкт bytes, і структуру time.

>>> import json
>>> with open('entry.json', 'w', encoding='utf-8') as f:

Я знаю, що казав це раніше, але це варто повторити: JSON це текстовий формат. Завжди відкривайте файли JSON в текстовому режимі з кодуванням символів UTF-8.


...     json.dump(entry, f)
... 
Traceback (most recent call last):
  File "<stdin>", line 5, in <module>
  File "C:\Python31\lib\json\__init__.py", line 178, in dump
    for chunk in iterable:
  File "C:\Python31\lib\json\encoder.py", line 408, in _iterencode
    for chunk in _iterencode_dict(o, _current_indent_level):
  File "C:\Python31\lib\json\encoder.py", line 382, in _iterencode_dict
    for chunk in chunks:
  File "C:\Python31\lib\json\encoder.py", line 416, in _iterencode
    o = _default(o)
  File "C:\Python31\lib\json\encoder.py", line 170, in default
    raise TypeError(repr(o) + " is not JSON serializable")
TypeError: b'\xDE\xD5\xB4\xF8' is not JSON serializable

Ой це не добре. Що трапилось?

Ось що трапилось: функція json.dump() спробувала серіалізувати об'єкт b'\xDE\xD5\xB4\xF8' типу bytes, але в неї не вийшло, тому що в JSON немає підтримки об'єкту bytes. Тим не менш, якщо зберігання байтів для вас важливе, ви можете описати власний "міні формат серіалізації".


def to_json(python_object):

Щоб описати власний "міні-формат серіалізації" для типу даних який не передбачений в JSON, просто опишіть функцію що приймає об'єкт Python як параметр. Цей об'єкт й буде тим об'єктом який функція json.dumps() не може серіалізувати самостійно. В нашому випадку байтовий об'єкт b'\xDE\xD5\xB4\xF8'.

    if isinstance(python_object, bytes):

Варто перевіряти тип даних який функція json.dumps() передає їй. Це не є обов'язковим якщо ваша функція серіалізує лише один тип даних, але це дуже чітко визначає коли можна застосовувати вашу функцію, і спрошує її розширення якщо пізніше потрібно буде додати можливість серіалізації інших типів.

        return {'__class__': 'bytes',
                '__value__': list(python_object)}

В даному випадку, я вирішив перетворити об'єкт bytes в словник. Ключ __class__ зберігатиме оригінальний тип даних (в вигляді рядка 'bytes'), а ключ __value__ буде містити саме значення. Звісно ци значенням не може бути сам об'єкт bytes, вся суть тут в тому щоб перетворити його на щось що може бути серіалізованим в JSON! Об'єкт bytes - це просто послідовність цілих чисел, кожне з яких приймає значення в діапазоні 0-255. Ми можемо використати функцію list() для того щоб перетворити об'єкт bytes в список цілих. Так b'\xDE\xD5\xB4\xF8' стане [222, 213, 180, 248]. (Порахуйте самі! Воно працює! Байт \xDE це шістнадцятковий запис десяткового числа 222, \xD5 - числа 213 і так далі.)

    raise TypeError(repr(python_object) + ' is not JSON serializable')

Це важливий рядок. Структура даних яку ви серіалізуєте може містити типи які ні вбудований серіалізатор JSON, ні ваш серіалізатор не можуть обробити. В такому випадку ваш серіалізатор повинен згенерувати виняток TypeError, щоб функція json.dumps() могла дізнатись про те що наш серіалізатор не розпізнав тип.


І це все, не потрібно більше нічого робити. Ця наша функція серіалізації повертає словник мови Python а не рядок. Ми не робимо всю серіалізацію самостійно, ми просто перетворюємо тип в підтримуваний JSON. Функція json.dumps() зробить решту.

>>> shell
1
>>> import customserializer

Припустимо що customserializer - це модуль в якому ми нещодавно описали функцію to_json.

>>> with open('entry.json', 'w', encoding='utf-8') as f:

Текстовий режим, кодування UTF-8, бла-бла-бла. (Ви забудете! Я іноді забуваю! І все працюватиме до певного часу, але потім поламається, і поламається в найбільш незручний момент).

...     json.dump(entry, f, default=customserializer.to_json)
...

Це важлива частина, щоб використати нашу фукнцію приведення типу при серіалізації, ми повинні передати її всередину json.dumps() як параметр default. (Ура, все в Python - об'єкт!)

Traceback (most recent call last):
  File "<stdin>", line 9, in <module>
    json.dump(entry, f, default=customserializer.to_json)
  File "C:\Python31\lib\json\__init__.py", line 178, in dump
    for chunk in iterable:
  File "C:\Python31\lib\json\encoder.py", line 408, in _iterencode
    for chunk in _iterencode_dict(o, _current_indent_level):
  File "C:\Python31\lib\json\encoder.py", line 382, in _iterencode_dict
    for chunk in chunks:
  File "C:\Python31\lib\json\encoder.py", line 416, in _iterencode
    o = _default(o)
  File "/Users/pilgrim/diveintopython3/examples/customserializer.py", line 12, in to_json
    raise TypeError(repr(python_object) + ' is not JSON serializable')
TypeError: time.struct_time(tm_year=2009, tm_mon=3, tm_mday=27, tm_hour=22, tm_min=20, tm_sec=42, tm_wday=4, tm_yday=86, tm_isdst=-1) is not JSON serializable

Ну, так, насправді це не працює. Але гляньте на виняток. Функція json.dumps() більше не жаліється на те що не може серіалізувати об'єкт bytes. Тепер вона жаліється на зовсім інший об'єкт: time.struct_time.

І хоча отримання іншого винятку не виглядає прогресом, але насправді це прогрес! Просто потрібно ще дещо підправити щоб це запрацювало.

import time

def to_json(python_object):
    if isinstance(python_object, time.struct_time):
        return {'__class__': 'time.asctime',
                '__value__': time.asctime(python_object)}
    if isinstance(python_object, bytes):
        return {'__class__': 'bytes',
                '__value__': list(python_object)}
    raise TypeError(repr(python_object) + ' is not JSON serializable')

Ми розширимо функцію customserializer.to_json() перевіркою того чи є об'єкт що їй передається (той з яким має проблеми json.dump() об'єктом класу time.struct_time. Якщо так, потрібно зробити щось схоже на те перетворення яке ми робили з об'єктом bytes: перетворити time.struct_time в словник який містить тільки значення що можуть серіалізуватись в JSON. В цьому випадку найпростішим способом це зробити є перетворити його в рядок за допомогою функції time.asctime(). Ця функція перетворить цей страшнуватий time.struct_time в рядок виду Fri Mar 27 22:20:42 2009'.

З цими двома приведеннями типів вся структура даних entry повинна серіалізуватись в JSON без подальших проблем.

>>> shell 1
>>> with open('entry.json', 'w', encoding='utf-8') as f:
...     json.dump(entry, f, default=customserializer.to_json)
...
you@localhost:~/diveintopython3/examples$ cat example.json
{"published_date": {"__class__": "time.asctime", "__value__": "Fri Mar 27 22:20:42 2009"},
"comments_link": null, "internal_id": {"__class__": "bytes", "__value__": [222, 213, 180, 248]},
"tags": ["diveintopython", "docbook", "html"], "title": "Dive into history, 2009 edition",
"article_link": "http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition",
"published": true}


* * *


Завантаження даних з файла JSON

[ред.]

Як і модуль pickle модуль json має функцію load() яка приймає потоковий об'єкт, читає з нього дані закодовані в JSON, і створює новий об'єкт Python що відображає структуру даних JSON.

>>> shell
2
>>> del entry
>>> entry
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'entry' is not defined

З метою демонстрації перемкніться на оболонку №2 та видаліть структуру даних entry що ви створили раніше в цьому розділі за допомогою модуля pickle.

>>> import json
>>> with open('entry.json', 'r', encoding='utf-8') as f:
...     entry = json.load(f)
...

В найпростішому випадку функція json.load() працює так само як і pickle.load(). Ви передаєте потоковий об'єкт і вона повертає новий об'єкт Python.

>>> entry
{'comments_link': None,
 'internal_id': {'__class__': 'bytes', '__value__': [222, 213, 180, 248]},
 'title': 'Dive into history, 2009 edition',
 'tags': ['diveintopython', 'docbook', 'html'],
 'article_link': 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition',
 'published_date': {'__class__': 'time.asctime', '__value__': 'Fri Mar 27 22:20:42 2009'},
 'published': True}

В мене гарна та погана новини. Спершу гарна новина: функція json.load() успішно прочитала файл entry.json який ми створили з оболонки №1 і створила новий об'єкт Python що містить наші дані. Тепер погана новина: вона не відтворила оригінальну структуру entry. Два значення 'internal_id' та 'published_date' були відтворені як словники. А якщо точніше, то словники зі значеннями сумісними з JSON які ми згенерували в функції to_json().

json.load() не знає нічого про жодну функцію перетворення що ми могли передати в json.dumps(). Нам потрібна функція протилежна до to_json() - функція що прийме перетворений JSON-об'єкт і відтворить за ним оригінальний тип даних Python.


# додаємо це до customserializer.py
def from_json(json_object):

Ця функція перетворення також приймає один параметр і повертає одне значення. Але той параметр що вона приймає це не рядок, а об'єкт Python - результат десеріалізації рядка що містить JSON.

    if '__class__' in json_object:
        if json_object['__class__'] == 'time.asctime':
            return time.strptime(json_object['__value__'])
        if json_object['__class__'] == 'bytes':
            return bytes(json_object['__value__'])
    return json_object

Все що потрібно - це перевірити чи цей об'єкт має ключ '__class__' який створила функція to_json(). Якщо має, то його значення підкаже нам як перетворити значення назад в типи Python.

Для того щоб перетворити рядочок з часом повернений функцією time.asctime(), використайте функцію time.strptime(). Ця функція приймає відформатований рядок з датою та часом (формат може налаштовуватись, але значення за замовчуванням таке саме як і значення за замовчуванням функції time.asctime()) та повертає time.struct_time.

А щоб перетворити список цілих в об'єкт bytes можна просто використати функцію bytes().


І це все. В функції to_json() ми обробляли лише два типи і тепер ці типи обробляються в функції from_json(). Ось результат:

>>> shell
2
>>> import customserializer
>>> with open('entry.json', 'r', encoding='utf-8') as f:
...     entry = json.load(f, object_hook=customserializer.from_json)
...

Щоб приєднати функцію from_json() до процесу десеріалізації передайте її як параметр object_hook в функцію json.load(). Функції що приймають функції це так зручно!

>>> entry
{'comments_link': None,
 'internal_id': b'\xDE\xD5\xB4\xF8',
 'title': 'Dive into history, 2009 edition',
 'tags': ['diveintopython', 'docbook', 'html'],
 'article_link': 'http://diveintomark.org/archives/2009/03/27/dive-into-history-2009-edition',
 'published_date': time.struct_time(tm_year=2009, tm_mon=3, tm_mday=27, tm_hour=22, tm_min=20, tm_sec=42, tm_wday=4, tm_yday=86, tm_isdst=-1),
 'published': True}

Структура даних entry тепер містить ключ 'internal_id' значенням якого є об'єкт bytes. А за ключем 'published_date' зберігається об'єкт time.struct_time.

Правда є ще один глюк.

>>> shell
1
>>> import customserializer
>>> with open('entry.json', 'r', encoding='utf-8') as f:
...     entry2 = json.load(f, object_hook=customserializer.from_json)
... 
>>> entry2 == entry
False

Навіть після того як ми застосувати при серіалізації функцію to_json() та при десеріалізації функцію from_json(), ми все ще не повністю відтворили ідеальну копію оригінальної структури даних. Чому ні?

>>> entry['tags']
('diveintopython', 'docbook', 'html')

В оригінальній структурі даних entry значення ключа 'tags' було кортежем з трьох елементів.

>>> entry2['tags']
['diveintopython', 'docbook', 'html']

Але в отриманій структурі даних entry2 значення ключа 'tags' це список з трьох елементів. JSON не відрізняє списки та кортежі, він має лише один спископодібний тип дани - масив, і модуль json при серіалізації тихо перетворює як кортежі так і списки в масиви JSON. В більшості випадків можна ігнорувати відмінності між кортежами та списками, але іноді при роботі з модулем json про це варто пам'ятати.

Для подальшого читання

[ред.]

Багато статтей про модуль pickle згадують модуль cPickle. В Python 2, були дві реалізації модуля pickle одна з яких написана на чистому Python, інша на C (але її можна було використовувати з Python). В Python 3 ці два модулі було об'єднано, тому вам варто завжди імпортувати pickle. Можливо ви знайдете корисні статті, але ви повинні ігнорувати застарілу інформацію про cPickle.

Про серіалізацію та модуль pickle:

Про JSON та модуль json:

Про розширюваність pickle:

Примітки

[ред.]
  1. Насправді рівень вкладеності не обмежується глибиною стеку. Обмежується лише глибина рекурсії функцій що працюють з такими структурами даних але їх не обов'язково робити рекурсивними.[1]

Веб-сервіси HTTP

[ред.]

Збуджений розум робить подушку незручною. (A ruffled mind makes a restless pillow.)
Шарлотта Бронте


Пірнаймо!

[ред.]

Філософськи, веб-сервіси HTTP можна описати як обмін даними з віддаленим сервером використовуючи лише засоби протоколу HTTP. Якщо ви хочете отримати дані з сервера - використовуйте HTTP GET. Якщо хочете відправити дані на сервер - HTTP POST. API деяких передових веб-сервісів також дозволяють створення, модифікацію та видалення даних за допомогою HTTP PUT, та HTTP DELETE. Це все. Ніяких регістрів, конвертів, обгорток, тунелювання. "Дієслова" вбудовані в протокол HTTP (GET, POST, PUT та DELETE) напряму пов’язані з операціями прикладного рівня для отримання, створення, модифікації та видалення даних.

Основна перевага такого підходу - простота, а простота зарекомендувала себе дуже популярною. Дані - зазвичай XML або JSON можуть зберігатись статично, чи генеруватись динамічно скриптом на стороні сервера, і всі основні мови програмування (включаючи Python звісно!) включать бібліотеку для доступу до них через HTTP. Зневадження також набагато простіше, бо так як кожен ресурс веб-сервісу має унікальну адресу в формі URL, ви можете просто відкрити його в веб-браузері щоб одразу побачити чисті дані.

Приклади веб-сервісів:

Python 3 постачається з двома бібліотеками для взаємодії з веб-сервісами HTTP:

  • http.client - низькорівнева бібліотека яка реалізує RFC2616 (протокол HTTP)
  • urllib.request - шар абстракції над http.client. Він надає стандартне API для взаємодії як з HTTP, так і з FTP серверами, автоматично переходить за перенаправленнями, і справляється з деякими типовими видами HTTP аутентифікації.

Так яку з них варто використовувати? Жодну! Натомість, ви повинні використовувати httplib2, сторонню бібліотеку з відкритим кодом, яка реалізує HTTP повніше ніж http.client і надає кращий рівень абстракції ніж urllib.request.

Щоб зрозуміти чому httplib2 - правильний вибір, вам спершу потрібно зрозуміти HTTP.

Насправді httplib2 був таким під час написання цієї книжки. Але в 2011-тому році з’явилась нова, краща бібліотека, requests. Та все одно читання цього розділу може бути корисним, тому що ви дізнаєтесь багато про особливості HTTP.


* * *


Особливості HTTP

[ред.]

Існує п’ять важливих особливостей, які повинні підтримуватись всіма клієнтами.

Кешування

[ред.]

Найважливіше що потрібно розуміти про будь-який веб-сервіс це те, що доступ до мережі неймовірно дорогий. І я не маю на увазі долари й центи, хоча, звісно, трафік теж не безкоштовний. Я маю на увазі, що, щоб відкрити з’єднання, послати запит, отримати відповідь від сервера, потрібно неймовірно багато часу. Навіть на найшвидших з’єднаннях латентність (час який проходить від відправлення запиту до отримання відповіді) може перевищити ваші очікування. Маршрутизатор працює погано, пакети губляться, проміжне проксі перебуває під атакою - завжди знайдеться причина, і ви нічого не зможете з цим зробити.

Cache-Control: max-age означає "не чіпайте мене аж до наступного тижня"

HTTP був створений з думкою про кешування. Існує цілий клас пристроїв (які називаються "кешуючими проксі"), єдине завдання яких - знаходитись між вами і рештою світу та мінімізувати навантаження на мережу. Ваша компанія чи провайдер інтернету майже напевне підтримує кешуючі проксі, хоча ви можете про них і не здогадуватись. Вони працюють, тому що кешування вбудоване в протокол HTTP.

Ви відкриваєте в своєму браузері сторінку яка може містити якісь зображення. Коли браузер просить віддати це зображення сервер додає у відповідь такі заголовки HTTP:

HTTP/1.1 200 OK
Date: Sun, 31 May 2009 17:14:04 GMT
Server: Apache
Last-Modified: Fri, 22 Aug 2008 04:28:16 GMT
ETag: "3075-ddc8d800"
Accept-Ranges: bytes
Content-Length: 12405
Cache-Control: max-age=31536000, public
Expires: Mon, 31 May 2010 17:14:04 GMT
Connection: close
Content-Type: image/jpeg

Такі заголовки Cahce-Control та Expires повідомляють вашому браузеру (і кожному кешуючому проксі перед ним), що зображення може зберігатись в кеші ще рік. Рік! І якщо через рік ви відвідаєте іншу сторінку, яка теж включає це зображення, ваш браузер візьме це зображення з кешу не створюючи ніякого навантаження на мережу взагалі.

І навіть краще. Скажімо ваш браузер видалив це зображення з якоїсь причини. Можливо закінчився вільний простір на диску, можливо ви вручну очистили кеш. Без різниці. Але заголовки HTTP кажуть, що це зображення може кешуватись публічними кешуючими проксі. (Якщо говорити технічно точно, то важливо не те що кажуть заголовки, а те чого вони не кажуть. Заголовок Cache-Control не містить ключового слова private, тому ці дані кешуються за замовчуванням.) В кешуючих проксі передбачені гектари місця для зберігання даних, ймовірно, на порядок більше ніж може собі дозволити ваш локальний браузер.

Якщо ваш інтернет-провайдер підтримує кешуючий проксі-сервер, він може все ще зберігати закешоване зображення. Коли ви відвідуєте сторінку вдруге, браузер подивиться в локальний кеш, не знайде там потрібної картинки і зробить запит до мережі намагаючись завантажити її з віддаленого сервера. Але якщо кешуючий проксі досі зберігає копію картинки, він перехопить запит і віддасть картинку зі свого кешу. Це означає, що ваш запит ніколи не досягне віддаленого сервера і, можливо, навіть не покине мережу вашої компанії. Це пришвидшить завантаження (менша кількість передач пакетів між маршрутизаторами) і зекономить гроші компанії (менше даних буде завантажено з зовнішнього світу).

Кешування HTTP працює лише, коли кожен робить свою справу. З одного боку, сервери повинні відправляти з відповіддю коректні заголовки. З іншого боку, клієнти повинні розуміти і зважати на ті заголовки, перш ніж запитувати ті ж дані вдруге. Проксі посередині не є панацеєю, вони розумні рівно настільки, наскільки їм дозволяють клієнти й сервери.

Стандартні бібліотеки мови Python не підтримують кешування, зате httplib2 підтримує.

Перевірка Last-Modified

[ред.]

Деякі дані не змінюються ніколи, а деякі постійно. Між ними ще є широкий діапазон даних, які можуть змінитись, але не змінились. Фід CNN.com оновлююється щохвилини, а фід мого блогу може не змінюватись днями а то й тижнями. В другому випадку я не хочу казати клієнтам кешувати мій фід цілий тиждень, бо тоді, коли я нарешті щось опублікую, люди не дізнаються про це ще тиждень (тому що вони зважають на заголовки, які кажуть "не перевіряти цей фід ще тиждень"). З іншого боку я не хочу щоб клієнти завантажували ввесь мій фід щогодини аби перевірити чи не з’явилось нічого нового.

304: Not Modified означає "новий день, та сама х*ня"

Протокол HTTP має рішення і для цього. Коли ви запитуєте дані вперше, сервер може повернути заголовок Last-Modified. Він містить інформацію про час останньої зміни даних.

HTTP/1.1 200 OK
Date: Sun, 31 May 2009 17:14:04 GMT
Server: Apache
Last-Modified: Fri, 22 Aug 2008 04:28:16 GMT
ETag: "3075-ddc8d800"
Accept-Ranges: bytes
Content-Length: 12405
Cache-Control: max-age=31536000, public
Expires: Mon, 31 May 2010 17:14:04 GMT
Connection: close
Content-Type: image/jpeg

Коли ви запитуєте ті ж дані другий (чи третій, чи четвертий) раз, ви можете з запитом відправити заголовок If-Modified-Since в якому вказати час, який повернув вам сервер минулого разу. Якщо з того часу дані змінились, сервер поверне вам нові дані, з кодом статусу 200. Але, якщо дані з того часу не змінювались, сервер поверне спеціальний код статусу HTTP 304, який означає "дані не змінились з того моменту коли ви запитували їх востаннє". Можете перевірити це з командного рядка, використовуючи curl:

you@localhost:~$ curl -I -H "If-Modified-Since: Fri, 22 Aug 2008 04:28:16 GMT" http://wearehugh.com/m.jpg
HTTP/1.1 304 Not Modified
Date: Sun, 31 May 2009 18:04:39 GMT
Server: Apache
Connection: close
ETag: "3075-ddc8d800"
Expires: Mon, 31 May 2010 18:04:39 GMT
Cache-Control: max-age=31536000, public

Це дуже добре, бо коли сервер повертає статус 304, він не відсилає заново всі дані. Від відсилає лише код статусу. Навіть якщо термін зберігання копії в кеші закінчився, last-modified гарантує, що ви не будете завантажувати ті самі дані двічі, якщо вони не змінились. (Як додатковий бонус, з відповіддю 304 також приходять заголовки кешування. Проксі зберігатимуть копію даних навіть якщо в них офіційно закінчиться термін зберігання, сподіваючись що якщо дані не змінились, то з наступним запитом повернеться відповідь з кодом 304 і оновленими термінами кешування.)

Стандартні бібліотеки мови Python не підтримують перевірку часу останньої модифікації, зате httplib2 підтримує.

Перевірка ETag

[ред.]

ETag-и це альтернативний спосіб досягти того ж самого, що й за допомогою перевірки last-modified. З Etags, сервер відправляє разом з даними хеш-код в заголовку ETag. (Як точно визначається цей хеш цілком залежить від сервера. Єдина вимога - він повинен змінюватись, коли змінюються дані.) Фонове зображення, що отримується з diveintomark.org має заголовок ETag.

HTTP/1.1 200 OK
Date: Sun, 31 May 2009 17:14:04 GMT
Server: Apache
Last-Modified: Fri, 22 Aug 2008 04:28:16 GMT
ETag: "3075-ddc8d800"
Accept-Ranges: bytes
Content-Length: 12405
Cache-Control: max-age=31536000, public
Expires: Mon, 31 May 2010 17:14:04 GMT
Connection: close
Content-Type: image/jpeg
ETag означає "немає нічого нового під сонцем".

Коли наступного разу ви зробите запит тих самих даних, то включите хеш ETag в заголовок If-None-Match свого запиту. Якщо дані не змінились, сервер відішле нам назад код статусу 304. Так само як і при перевірці last-modified сервер відправить назад лише код 304, він не буде відправляти ті ж дані повторно. Включаючи хеш ETag в свій другий запит, ви повідомляєте сервер, що немає потреби пересилати ті ж самі дані повторно, якщо вони все ще відповідають хешу, так як ми зберегли дані з останнього запиту.

Знову за допомогою curl:

you@localhost:~$ curl -I -H "If-None-Match: \"3075-ddc8d800\"" http://wearehugh.com/m.jpg
HTTP/1.1 304 Not Modified
Date: Sun, 31 May 2009 18:04:39 GMT
Server: Apache
Connection: close
ETag: "3075-ddc8d800"
Expires: Mon, 31 May 2010 18:04:39 GMT
Cache-Control: max-age=31536000, public

ETag-и зазвичай поміщуються в лапки, але лапки є частиною значення. Це означає, що потрібно відправляти й лапки назад на сервер в заголовку If-None-Match.

Стандартні бібліотеки мови Python не підтримують перевірку хешу ETags, зате httplib2 підтримує.

Стиснення

[ред.]

Коли ми говоримо про веб-сервіси HTTP, ми майже завжди говоримо про передачу текстових даних туди-сюди по мережі. Можливо це XML, можливо JSON, можливо це всього лиш простий текст. Незалежно від формату текст чудово стискається. Наприклад фід в розділі про XML має розмір 3070 байт, але займатиме лише 941 байт після gzip стиснення. А це всього лише 30% від початкового розміру!

HTTP підтримує кілька алгоритмів стиснення. Двома найбільш вживаними є gzip та deflate. Коли ви запитуєте ресурс через HTTP, ви можете попросити сервер прислати його в стиснутому форматі. Для цього потрібно включити в запит заголовок Accept-encoding, в якому перелічити підтримувані алгоритми стиснення. Якщо сервер теж підтримує один з тих алгоритмів, він відправить вам стиснені дані (з заголовком Content-encoding який визначає використаний алгоритм). Після цього розкодування даних в ваших руках.

Важлива підказка для тих, хто розробляє на стороні сервера: переконайтесь, що стиснена версія ресурсу має відмінний від нестисненої версії Etag. Інакше кешуючі проксі можуть заплутатись і передати стиснену версію клієнтам, які її не підтримують. Прочитайте обговорення помилки Apache №39727, щоб дізнатись додаткові деталі цієї тонкої проблеми.

Стандартні бібліотеки мови Python не підтримують стиснення, зате httplib2 підтримує.

Перенаправлення

[ред.]

Круті URI не змінюються, але багато URI дуже не круті. Веб-сайти реорганізовуються, сторінки переміщуються на нові адреси. Навіть веб-сервіси переорганізовуються. Синдикований фід за адресою http://example.com/index.xml може переміститись в http://example.com/xml/atom.xml. Чи ввесь домен може переміститись, в процесі росту і перебудови організації. http://www.example.com/index.xml може стати http://server-farm-1.example.com/index.xml.

Location означає “дивись сюди!”

Щоразу як ви запитуєте будь-який ресурс з HTTP сервера, сервер додає код статусу в свою відповідь. Код статусу 200 означає "все нормально, ось сторінка яку ви просили". Код статусу 404 означає "сторінка не знайдена". (Ви напевне вже бачили помилки 404 переглядаючи веб). Коди статусу від 300 і до 400 вказують на якусь форму перенаправлення.

В HTTP є кілька різних способів позначення того що ресурс переміщено. Двома найбільш типовими є коди статусу 302 та 301. Код статусу 302 означає тимчасове перенаправлення, "уупс, ми тимчасово переїхали сюди" (в такому разі заголовок Location містить тимчасову адресу). Код статусу 301 - постійне перенаправлення: "уупс, ми назавжди переїхали" (і теж передає нову адресу в заголовку Location). Якщо ви отримали у відповідь код 302 і нову адресу, специфікація HTTP каже що потрібно використати нову адресу для того щоб отримати те що потрібно, але коли вам знадобиться цей ресурс наступного разу, ви повинні спершу спробувати стару адресу. Але якщо ви отримали код 301 і нову адресу, ви повинні використовувати цю нову адресу й надалі.

Модуль urllib.request автоматично "переходить" за перенаправленнями коли отримує відповідний код статусу з сервера, але не каже вам що він так зробив. В кінцевому результаті ви отримаєте ті дані про які просили, але не будете знати що бібліотека прослідувала по перенаправленню за вас. Тому ви продовжите використовувати стару адресу, і кожного разу urllib.request буде переходити за тим самим перенаправленням. Іншими словами він працює з постійними перенаправленнями так само як з тимчасовими. А це означає два запити замість одного, що погано як для сервера так і для вас.

httplib2 опрацьовує постійні перенаправлення для нас. Він не тільки може повідомити про те що відбулось перенаправлення, він самостійно слідкує за ними, і може автоматично змінити URL перед тим як їх запитувати.


* * *


Як не варто отримувати дані через HTTP

[ред.]

Скажімо нам потрібно завантажити через HTTP наприклад фід Atom. А так як це фід, його зазвичай не завантажують один раз, а часто і регулярно. (Більшість програм що читають RSS перевірятимуть зміни щогодини.) Давайте спочатку зробимо косо-криво аби живо, а потім подивимось як можна зробити краще.

>>> import urllib.request
>>> a_url = 'http://diveintopython3.org/examples/feed.xml'
>>> data = urllib.request.urlopen(a_url).read()

Завантаження будь-чого через HTTP в Python страшенно легко, і робиться одним рядком коду. Модуль urllib.request має гарну функцію urlopen() яка бере адресу потрібної сторінки, і повертає файлоподібний об'єкт з якого можна просто взяти ввесь вміст сторінки за допомогою методу read(). Простіше вже ніяк.

>>> type(data)
<class 'bytes'>
>>> print(data)
<?xml version='1.0' encoding='utf-8'?>
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='en'>
  <title>dive into mark</title>
  <subtitle>currently between addictions</subtitle>
  <id>tag:diveintomark.org,2001-07-29:/</id>
  <updated>2009-03-27T21:56:07Z</updated>
  <link rel='alternate' type='text/html' href='http://diveintomark.org/'/>
  …

Метод urlopen().read() завжди повертає байтовий об'єкт а не рядок. Пам'ятаєте, байти це байти, а символи це абстракція. HTTP сервери не мають справи з абстракціями. Якщо ви запитуєте ресурс, ви отримуєте байти. Якщо ви хочете текст, вам потрібно визначити кодування його символів, і явно зробити розкодування.

То що тут такого поганого? Для швидкого одноразового тесту під час розробки - нічого. Я так постійно роблю. Мені потрібен був вміст фіду, я його отримав. Такий самий підхід працює з будь-якою веб сторінкою. Але як тільки ви починаєте думати в термінах веб-сервісу, ви захочете отримати регулярний доступ (наприклад оновлювати фід щогодини), тоді такий метод буде неефективним, а також невихованим.


* * *


Що на дроті?

[ред.]

Щоб побачити чому це неефективно та невиховано, давайте ввімкнемо зневаджувальні можливості бібліотеки HTTP, і подивимось що відправляється "дротами" (тобто через мережу).

>>> from http.client import HTTPConnection 
>>> HTTPConnection.debuglevel = 1

Як я вже згадував на початку цього розділу, urllib.request працює на основі іншої стандартної бібліотеки мови Python, http.client. Зазвичай вам взагалі не потрібно буде чіпати http.client напряму. (Модуль urllib.request імпортує її автоматично). Але ми імпортуємо її тут, щоб можна було змінити зневаджувальний прапорець класу HTTPConnection, який urllib.request використовує для з'єднання з HTTP сервером.

>>> from urllib.request import urlopen 
>>> response = urlopen('http://diveintopython3.org/examples/feed.xml')

Тепер, коли встановлено зневаджувальний прапорець, інформація запитів та відповідей HTTP буде роздруковуватись в режимі реального часу. Як ви можете бачити, коли ви запитуєте фід Atom, модуль urllib.request відправляє п'ять рядків на сервер.

send: b'GET /examples/feed.xml HTTP/1.1

Перший рядок задає версію HTTP яку ви використовуєте та шлях до ресурсу (без доменного імені).

Host: diveintopython3.org

Другий рядок задає доменне ім'я в якого ви запитуєте цей фід.

Accept-Encoding: identity

Третій рядок задає алгоритми стиснення які підтримує клієнт. Як я згадав раніше, urllib.request за замовчуванням не підтримує стиснення.

User-Agent: Python-urllib/3.1'

Четвертий рядок задає назву бібліотеки що здійснює запит. За замовчуванням там написано Python-urllib та номер версії. urllib.request та httplib2 підтримують зміну значення заголовку User-Agent додаванням його до запиту (що перевстановить значення за замовчуванням).

Connection: close
reply: 'HTTP/1.1 200 OK'
... подальші дані зневадження опущено ...
Ми завантажуємо 3070 байт в той час як могли завантажувати всього лиш 941.

Тепер давайте поглянемо що сервер прислав у відповідь.


# продовжуємо з попереднього прикладу
>>> print(response.headers.as_string()) ① 
Date: Sun, 31 May 2009 19:23:06 GMT ② 
Server: Apache
Last-Modified: Sun, 31 May 2009 06:39:55 GMT ③ 
ETag: "bfe-93d9c4c0" ④ 
Accept-Ranges: bytes
Content-Length: 3070 ⑤ 
Cache-Control: max-age=86400 ⑥ 
Expires: Mon, 01 Jun 2009 19:23:06 GMT
Vary: Accept-Encoding
Connection: close
Content-Type: application/xml

① Значення response повернене функцією urllib.request.urlopen() містить всі заголовки HTTP які сервер надіслав у відповідь. Також response має методи для завантаження самих даних, але ми перейдемо до цього за хвилину.

② Сервер каже нам коли він обробив запит.

③ Ця відповідь включає заголовок Last-Modified.

④ Також ця відповідь включає заголовок ETag.

⑤ Дані мають довжину 3070 байтів. Зауважте що тут відсутнє - заголовок Content-encoding. Ваш запит стверджував що ви можете приймати лише нестиснені дані (Accept-encoding: identity), тому можна бути достатньо впевненим що ця відповідь теж містить нестиснені дані.

⑥ Відповідь включає заголовки що вказують на те що результат запиту можна зберігати в кеші протягом 24 годин (86400 секунд).

>>> data = response.read()
>>> len(data)
3070

Ну й нарешті завантажуємо дані викликаючи response.read(). Як можна бачити з результату функції len() отримано загалом 3070 байт.

Як ви можете бачити, цей код вже неефективний: він запитує (і отримує) нестиснені дані. Я знаю той факт що сервер підтримує стиснення gzip, але в HTTP стиснення лише опціональне. Ми не просили про це, тому ми його й не отримуємо. Це означає що ми пересилаємо мережею 3070 байт, в той час як могли пересилати лише 941. Поганий пес, ніякої кістки.

Але все ще може стати гіршим! Щоб побачити яким неефективним є цей код, давайте попросимо той самий фід вдруге.


# продовжуємо з попереднього прикладу
>>> response2 = urlopen('http://diveintopython3.org/examples/feed.xml')
send: b'GET /examples/feed.xml HTTP/1.1
Host: diveintopython3.org
Accept-Encoding: identity
User-Agent: Python-urllib/3.1'
Connection: close
reply: 'HTTP/1.1 200 OK'
... подальші дані зневадження опущено ...

Зауважили щось особливе в цьому запиті? Він не змінився! Він точно такий самий як попередній. Ніяких слідів заголовків Last-Modified. Ніяких слідів If-None-Match. Ніякої поваги до заголовків кешування. І знову ніякого стиснення.

І що відбувається якщо ви зробите такий самий запит двічі? Ви отримаєте таку саму відповідь. Двічі.

# продовжуємо з попереднього прикладу
>>> print(response2.headers.as_string())
Date: Mon, 01 Jun 2009 03:58:00 GMT
Server: Apache
Last-Modified: Sun, 31 May 2009 22:51:11 GMT
ETag: "bfe-255ef5c0"
Accept-Ranges: bytes
Content-Length: 3070
Cache-Control: max-age=86400
Expires: Tue, 02 Jun 2009 03:58:00 GMT
Vary: Accept-Encoding
Connection: close
Content-Type: application/xml

Сервер досі присилає той самий набір "розумних" заголовків: Cache-Control та Expires для того щоб уможливити кешування, Last-Modified та ETag для того щоб дозволити перевірку того що дані не змінились. Заголовок Vary: Accept-Encoding навіть підказує що сервер підтримує стиснення, якщо б ви тільки попросили про це. Але ви не попросили.

>>> data2 = response2.read() 
>>> len(data2)
3070

І знову ми завантажили всі 3070 байтів...

>>> data2 == data
True

... точно ті самі 3070 байтів що й минулого разу.

HTTP створено щоб працювати краще ніж тут. urllib говорить на HTTP так як я говорю іспанською - достатньо щоб вибратись з пробки, але недостатньо для того щоб підтримувати розмову. Час оновитись до бібліотеки що спілкується на HTTP вільно.


* * *


Представляємо httplib2

[ред.]

Перш ніж ви зможете використовувати httplib2, ви повинні встановити його. Відвідайте code.google.com/p/httplib2/ і завантажте останню версію. httplib2 доступний як для Python 2.x так і Python 3.x; пересвідчіться що ви отримаєте правильну версію, файл якої має назву подібну до httplib2-python3-0.5.0.zip.

Розпакуйте архів, відкрийте термінал, перейдіть в директорію яку щойно створили, і виконайте команду:

c:\Users\you\Downloads\httplib2-python3-0.5.0> c:\python31\python.exe setup.py install
running install
running build
running build_py
...

В Unix-системах аналогічно:

you@localhost:~/Desktop/httplib2-python3-0.5.0$ sudo python3 setup.py install
running install
running build
running build_py

Щоб використовувати httplib2 створіть екземпляр класу httplib2.Http.

>>> import httplib2
>>> h = httplib2.Http('.cache')

Первинним інтерфейсом до httplib2 є об’єкт Http. З причин, які ви зрозумієте в наступній секції, ви повинні при створенні об’єкт завжди передавати назву директорії. Директорія не обов'язково повинна бути існюючою, httplib2 за потреби створить її самостійно.

>>> response, content = h.request('http://diveintopython3.org/examples/feed.xml')

Як тільки в нас є об'єкт класу Http, щоб отримати дані достатньо викликати метод request() з адресою даних що нам потрібні. Це створить для переданого URL запит HTTP GET. (Далі в цьому розділі ви побачите як створювати інші типи запитів HTTP, наприклад POST.)

>>> response.status
200

Метод request() повертає два значення. Перше - це об'єкт httplib2.Response, який містить всі заголовки HTTP що повернув сервер. Наприклад код статусу 200, який вказує на те що запит був успішним.

>>> content[:52] 
b"<?xml version='1.0' encoding='utf-8'?>\r\n<feed xmlns=" 
>>> len(content) 
3070

Змінна content яка прийняла друге значення, містить дані що повернув сервер. Дані повертаються як об'єкт bytes, а не як рядок. Якщо ви хочете отримати їх як рядок, вам потрібно буде з'ясувати кодування і розкодувати байти самостійно.

Вам скоріш за все достатньо одного об'єкта httplib2.Http. Існують причини створювати більш ніж один, але ви повинні робити так лише в тому випадку коли знаєте нащо це потрібно. "Мені потрібно отримати дані з двох різних URL" не є достатньою причиною. Можна двічі викликати метод request() одного об'єкта Http.

Короткий відступ для того щоб пояснити чому httplib2 повертає байти замість рядків

[ред.]

Байти. Рядки. Який біль. Чому httplib2 "просто" не зробить перетворення за вас? Ну, це складно, тому що правила для визначення кодування символів залежать від того який ресурс ми запитуємо. Як httplib2 може знати який ресурс ми запитуємо? Він зазвичай описується в заголовку HTTP Content-Type, але це необов’язковий заголовок HTTP і не всі сервери HTTP його відправляють. Якщо цей заголовок не включається в відповідь HTTP, вгадати кодування повинен клієнт. (Таке вгадування називають "винюхуванням контенту" ("content sniffing"), і воно ніколи не буває ідеальним.)

Якщо ви знаєте який ресурс ви очікуєте (XML документ в нашому випадку), можливо ви можете "просто" передати отриманий об’єкт bytes функції xml.etree.ElementTree.parse(). Це працює лише поки XML документ містить в собі інформацію про своє кодування (як в цьому випадку і є), але це необов’язково, і не всі документи XML включають кодування. Якщо XML документ не описує своє кодування, клієнт повинен подивитись на те в чому його транспортували (на заголовок Content-Type HTTP), в якому може міститись параметр charset.

Але все ще гірше. Тепер інформація про кодування символів може знаходитись в двох місцях: всередині самого XML документа, та всередині заголовку HTTP Content-Type. Якщо ця інформація в двох місцях, то якому надати перевагу? Згідно RFC 3023 (Клянусь що я нічого не вигадую), якщо тип медіа який передається в заголовку HTTP Content-Type є application/xml, application/xml-dtd, application/xml-external-parsed-entity, чи будь-який інший з підтипів application/xml таких як application/atom+xml чи application/rss+xml чи навіть application/rdf+xml, тоді кодування буде:

  1. кодування що задане в параметрі charset заголовку HTTP Content-Type, чи
  2. кодування що задане в атрибуті encoding декларації XML всередині документа, чи
  3. UTF-8

З іншого боку, якщо тип медіа що передається в заголовку HTTP Content-Type є text/xml, text/xml-external-parsed-entity, чи підтип форми text/ЩоЗавгодно+xml, тоді кодування задане всередині XML документа повністю ігнорується, і визначається як

  1. кодування що задане в параметрі charset заголовку HTTP Content-Type, чи
  2. us-ascii

І це тільки для XML документів. Для HTML документів браузери створили такі заплутані правила з’ясування формату документа що ми й досі намагаємось їх всіх з’ясувати.

"Патчі вітаються"

Як httplib2 працює з кешуванням

[ред.]

Пам’ятаєте, в попередньому розділі я сказав що ви завжди повинні створювати об’єкт httplib2.Http з назвою директорії?Кешування є мотивом цього.

# продовжуючи попередній приклад
>>> response2, content2 = h.request('http://diveintopython3.org/examples/feed.xml')
>>> response2.status
200
>>> content2[:52]
b"<?xml version='1.0' encoding='utf-8'?>\r\n<feed xmlns="
>>> len(content2)
3070

Це не повинно бути страшенним сюрпризом. Все так само як ми робили минулого разу, якщо не врахоувати що ми кладемо результат в дві нові змінні. Як і минулого разу статус HTTP 200. Та й розмір завантаженого контенту такий самий як і минулого разу.

Отож, чому нам не все одно? Вийдіть з інтерактивної оболонки Python, зайдіть знову і я вам покажу.


# НЕ продовжуючи попередній приклад
# Вийдіть з інтерактивної оболонки і запустіть нову.
>>> import httplib2
>>> httplib2.debuglevel = 1

Давайте ввімкнемо режим зневадження і подивимось що на дроті.

>>> h = httplib2.Http('.cache')

Створіть об’єкт httplib2.Http з такою самою назвою директорії як і раніше.

>>> response, content = h.request('http://diveintopython3.org/examples/feed.xml')

Запитайте той самий URL що й раніше. Схоже на те, що нічого не відбувається. Якщо точніше, то нічого не відсилається на сервер, і нічого не повертається з сервера. Взагалі відсутня будь-яка мережева активність.

>>> len(content)
3070

Щоправда ми отримали деякі дані. Навіть всі дані.

>>> response.status
200

Ми також "отримали" статус код який вказує що "запит" був успішним.

>>> response.fromcache
True

Ах ось де собака зарита: ця "відповідь" була згенерована з локального кешу httplib2. Ця директорія, ім’я якої ви передали коли створювали об’єкт httplib2.Http, містить локальний кеш всіх будь-коли виконаних операцій httplib2.

Що на дроті? Зовсім нічого.

Якщо ви хочете ввімкнути режим зневадження httplib2, ви повинні встановити константу на рівні модуля (httplib2.debuglevel, а потім створити новий об’єкт httplib2.Http. Якщо ви хочете вимкнути зневадження, ви повинні змінити ту саму константу рівня модуля, і знову створити новий об’єкт httplib2.Http.

Раніше ви запитали дані з цього URL. Запит був успішним (status: 200). Відповідь включала не тільки дані фіду, але також набір заголовків кешування які вказують будь-кому хто слухає, що вони можуть кешувати цей ресурс протягом 24-х годин (Cache-Control: max-age=86400, це 24 години в секундах). httplib2 розуміє і поважає ці заголовки кешування, та зберігає попередню відповідь в директорії .cache (ім’я якої ми задали при створенні об’єкта Http). Цей кеш ще не застарів, тому коли ми вдруге запитали дані за цим URL, httplib2 просто повернув той самий результат, навіть не чіпаючи мережу.

Я кажу "просто", але очевидно за простотою ховається багато складності. httplib2 працює з кешування HTTP автоматично та за замовчуванням. Якщо, з певної причини, вам потрібно знати чи відповідь прийшла з кешу, ви можете перевірити response.fromcache. А в інших випадках, воно Просто Працює.

Тепер, припустимо, ви маєте закешовані дані, але хочете пропустити кеш і перезапитати їх з віддаленого сервера. Браузери іноді роблять це якщо користувач спеціально про таке просить. Ви можете подумати "о, ну я просто видалю дані з локального кешу, а потім зроблю ще один запит." Ви можете так зробити, але пам’ятайте, що може бути задіяно ще багато сторін окрім вас та віддаленого сервера. Як щодо проміжних проксі-серверів? Вони цілком поза вашим контролем, і вони можуть все ще мати дані закешованими, та щасливо повернуть їх вам, тому що (настільки, наскільки вони зацікавлені) їхній кеш все ще дійсний.

Замість маніпуляцій локальним кешем, сподіваючись на краще, ви повинні використовувати можливості HTTP, щоб бути певними що ваш запит справді досягає віддаленого сервера.

# продовжуючи з попереднього прикладу 
>>> response2, content2 = h.request('http://diveintopython3.org/examples/feed.xml',
...     headers={'cache-control':'no-cache'})
connect: (diveintopython3.org, 80)
send: b'GET /examples/feed.xml HTTP/1.1
Host: diveintopython3.org
user-agent: Python-httplib2/$Rev: 259 $
accept-encoding: deflate, gzip
cache-control: no-cache'
reply: 'HTTP/1.1 200 OK'
… подальший вивід опущено …
>>> response2.status
200

httplib2 дозволяє вам додавати довільні заголовки HTTP до будь-якого вихідного запиту. Щоб пропустити всі кеші (не тільки ваш локальний дисковий кеш, а й кешуючі проксі між вами та віддаленим сервером), додайте заголовок no-cache в словник headers.

Після цього ви зможете побачити що httplib2 ініціює мережевий запит. httplib2 розуміє та поважає заголовки кешування в обох напрямках - як частину вхідної відповіді, так і як частину вихідного запиту. Вона помічає, що ви додаєте заголовок no-cache, тому пропускає свій локальний кеш і тоді не має іншого вибору, окрім як звернутись до мережі запитуючи дані.

>>> response2.fromcache
False

Ця відповідь не була згенерованою з вашого локального кешу. Ви, звісно, вже про це знаєте, тому що бачили лог вихідного запиту. Але це добре, що ми можемо перевірити цю інформацію програмно.

>>> print(dict(response2.items()))
{'status': '200',
 'content-length': '3070',
 'content-location': 'http://diveintopython3.org/examples/feed.xml',
 'accept-ranges': 'bytes',
 'expires': 'Wed, 03 Jun 2009 00:40:26 GMT',
 'vary': 'Accept-Encoding',
 'server': 'Apache',
 'last-modified': 'Sun, 31 May 2009 22:51:11 GMT',
 'connection': 'close',
 '-content-encoding': 'gzip',
 'etag': '"bfe-255ef5c0"',
 'cache-control': 'max-age=86400',
 'date': 'Tue, 02 Jun 2009 00:40:26 GMT',
 'content-type': 'application/xml'}

Запит був успішним, ви завантажили увесь фід знову з віддаленого сервера. Звісно, сервер також відправив назад повний набір заголовків HTTP разом з даними фіду. Це включає також заголовки кешування, які httplib2 використовує для оновлення локального кешу, з надією уникнути необхідності доступатись до мережі наступного разу коли ви запитаєте цей фід. Все в кешуванні HTTP продумано для того аби максимізувати звертання до кешу і мінімізувати звертання до мережі. Незважаючи на те що цього разу ви пропустили кеш, віддалений сервер справді оцінить, якщо ви наступного разу кешуватимете результат.

Як httplib2 працює з заголовками Last-Modified та ETag

[ред.]

Заголовки кешування Cache-Control та Expires називаються ``індикаторами свіжості``. Вони вказують кешам в точних термінах що вони можуть повністю уникати доступу до мережі поки кеш не стане недійсним. І це саме та поведінка яку ви бачили в попередньому розділі: маючи індикатор свіжості, httplib2 не генерує жодного байта мережевої активності щоб подати нам закешовані дані (якщо ви, звісно, явно не пропустите кеш).

Але як щодо випадку коли дані могли змінитись, але не змінились? HTTP для цього описує заголовки Last-Modified та Etag. Ці заголовки називаються валідаторами. Якщо локальний кеш більше не свіжий, клієнт може послати валідатори з наступним запитом щоб дізнатись чи дані змінювались. Якщо дані не змінювались, сервер відповідає кодом статусу 304 і не відправляє дані. Тому, хоча використання мережі присутнє, в кінцевому результаті ви завантажуєте менше байтів.

>>> import httplib2
>>> httplib2.debuglevel = 1
>>> h = httplib2.Http('.cache')
>>> response, content = h.request('http://diveintopython3.org/')
connect: (diveintopython3.org, 80)
send: b'GET / HTTP/1.1
Host: diveintopython3.org
accept-encoding: deflate, gzip
user-agent: Python-httplib2/$Rev: 259 $'
reply: 'HTTP/1.1 200 OK'

Замість фіду, цього разу ми будемо завантажувати домашню сторінку сайту, яка написана на HTML. Так як це перший раз коли ми запитуємо цю сторінку, httplib2 має мало з чим працювати, тому відправляє мінімальний набір заголовків разом з запитом.

>>> print(dict(response.items()))
{'-content-encoding': 'gzip',
 'accept-ranges': 'bytes',
 'connection': 'close',
 'content-length': '6657',
 'content-location': 'http://diveintopython3.org/',
 'content-type': 'text/html',
 'date': 'Tue, 02 Jun 2009 03:26:54 GMT',
 'etag': '"7f806d-1a01-9fb97900"',
 'last-modified': 'Tue, 02 Jun 2009 02:51:48 GMT',
 'server': 'Apache',
 'status': '200',
 'vary': 'Accept-Encoding,User-Agent'}

Відповідь містить багато заголовків HTTP..., але жодної інформації про кешування. Щоправда, вона включає як заголовок ETag, так і Last-Modified.

>>> len(content)
6657

Коли я створював цей приклад, сторінка мала розмір 6657 байт. З того часу її розмір міг змінитись, але про це не турбуйтесь.

# продовжуючи попередній приклад
>>> response, content = h.request('http://diveintopython3.org/')  ①
connect: (diveintopython3.org, 80)
send: b'GET / HTTP/1.1
Host: diveintopython3.org
if-none-match: "7f806d-1a01-9fb97900"                             ②
if-modified-since: Tue, 02 Jun 2009 02:51:48 GMT                  ③
accept-encoding: deflate, gzip
user-agent: Python-httplib2/$Rev: 259 $'
reply: 'HTTP/1.1 304 Not Modified'                                ④

① Ми знову запитуємо ту саму сторінку, з тим самим об’єктом Http (та тим самим локальним кешем). ② httplib2 відправляє валідатор ETag назад на сервер в заголовку If-None-Match. ③ httplib2 також відправляє валідатор Last-Modified назад на сервер в заголовку If-Modified-Since. ④ Сервер подивився на ці валідатори, подивився на сторінку яку ви запитуєте і визначив що сторінка не змінювалась з того часу як ви її востаннє запитували, тому відправляє назад статус код 304 та жодних даних.

>>> response.fromcache
True

На клієнті httplib2 помічає статус код 304 і завантажує дані з кешу.

>>> response.status
200

А це може трішки заплутувати. Насправді є два коди статусу - 304 (повернутий сервером цього разу, який змусив httplib2 дивитись в кеш), та 200 (повернений з сервера останнього разу, і збережений в кеші httplib2 поряд з даними сторінки). response.status повертає статус з кешу.

>>> response.dict['status']
'304'

Якщо ви хочете знати чистий код статусу отриманий від сервера, ви можете отримати його дивлячись в response.dict, який є словником заголовків справді повернутих сервером.

>>> len(content)
6657

Щоправда, ви все ще можете отримати дані в змінній content. Зазвичай, ви не маєте потреби знати чому відповідь була віддана з кешу. (Ви можете навіть не турбуватись чи дані взагалі отримуються з кешу, і це теж нормально. httplib2 достатньо розумний, аби дозволити вам поводитись як дурень.) На час, коли метод request() повертає дані, httplib2 вже оновила кеш.

Як httplib2 працює зі стисненням

[ред.]

HTTP підтримує кілька видів стиснення. Двома найбільш поширеними є gzip та deflate. httplib2 підтримує обидва.

Ми маємо обидва жанри музики, кантрі ТА вестерн.
>>> response, content = h.request('http://diveintopython3.org/')
connect: (diveintopython3.org, 80)
send: b'GET / HTTP/1.1
Host: diveintopython3.org
accept-encoding: deflate, gzip
user-agent: Python-httplib2/$Rev: 259 $'
reply: 'HTTP/1.1 200 OK'

Щоразу, коли httplib2 відправляє запит, він включає заголовок Accept-Encoding щоб сказати серверу що він може прочитати дані стиснуті як методом gzip, так і deflate.

>>> print(dict(response.items()))
{'-content-encoding': 'gzip',                          
 'accept-ranges': 'bytes',
 'connection': 'close',
 'content-length': '6657',
 'content-location': 'http://diveintopython3.org/',
 'content-type': 'text/html',
 'date': 'Tue, 02 Jun 2009 03:26:54 GMT',
 'etag': '"7f806d-1a01-9fb97900"',
 'last-modified': 'Tue, 02 Jun 2009 02:51:48 GMT',
 'server': 'Apache',
 'status': '304',
 'vary': 'Accept-Encoding,User-Agent'}

В цьому випадку сервер відповідає даними стисненими за допомогою gzip. До того часу як метод request() поверне дані, httplib2 вже розпакувала тіло відповіді і помістила її в змінну content. Якщо вам цікаво чи відповідь була стиснена, ви можете перевірити response['-content-encoding'], а якщо ні, можете про це взагалі не хвилюватись.

Як httplib2 працює із перенаправленнями

[ред.]

HTTP описує два види перенаправлень: тимчасові та постійні. З тимчасовими перенаправленнями не треба робити нічого особливого, окрім того що йти за ними, що httplib2 робить автоматично.

>>> import httplib2
>>> httplib2.debuglevel = 1
>>> h = httplib2.Http('.cache')
>>> response, content = h.request('http://diveintopython3.org/examples/feed-302.xml') ①
connect: (diveintopython3.org, 80)
send: b'GET /examples/feed-302.xml HTTP/1.1 ②
Host: diveintopython3.org
accept-encoding: deflate, gzip
user-agent: Python-httplib2/$Rev: 259 $'
reply: 'HTTP/1.1 302 Found' ③
send: b'GET /examples/feed.xml HTTP/1.1 ④
Host: diveintopython3.org
accept-encoding: deflate, gzip
user-agent: Python-httplib2/$Rev: 259 $'
reply: 'HTTP/1.1 200 OK'

① За цим URL немає фіда. Я налаштував мій сервер віддавати тимчасове перенаправлення на правильну адресу.

② Ось запит.

③ А ось відповідь: 302 Found. Тут не показано, але ця відповідь також містить заголовок Location, який вказує на справжній URL.

httplib2 негайно розвертається і "йде за" перенаправленням, створючи інший запит до URL переданого в загловку Location: http://diveintopython3.org/examples/feed.xml

"Слідування" за перенаправленням не є чимось більшим ніж показує цей приклад. httplib2 посилає запит про URL який ви попросили. Сервер дає відповідь яка каже "Ні ні, краще подивіться сюди". httplib посилає інший запит за новим URL.

# продовжуючи попередній приклад
>>> response                                                          ①
{'status': '200',
 'content-length': '3070',
 'content-location': 'http://diveintopython3.org/examples/feed.xml',  ②
 'accept-ranges': 'bytes',
 'expires': 'Thu, 04 Jun 2009 02:21:41 GMT',
 'vary': 'Accept-Encoding',
 'server': 'Apache',
 'last-modified': 'Wed, 03 Jun 2009 02:20:15 GMT',
 'connection': 'close',
 '-content-encoding': 'gzip',                                         ③
 'etag': '"bfe-4cbbf5c0"',
 'cache-control': 'max-age=86400',                                    ④
 'date': 'Wed, 03 Jun 2009 02:21:41 GMT',
 'content-type': 'application/xml'}

① Відповідь яку ми отримаємо від цього єдиного виклику методу request() - це відповідь від кінцевого URL.

httplib2 додає кінцевий URL до словника response, як content-location. Це не заголовок що прийшов з сервера, це поле специфічне для httplib2.

③ Без певної на те причини, цей фід стиснений.

④ І може бути закешованим. (Це важливо, як ви зможете побачити за хвилину.)

Словник response який ми отримуємо, дає вам інформацію про кінцевий URL. А що якщо ви захочете отримати більше інформації про проміжні URL-и, ті які в кінцевому результаті перенаправили нас до фінального URL? httplib2 дозволяє вам дізнатись і про це.

# продовжуючи попередній приклад
>>> response.previous
{'status': '302',
 'content-length': '228',
 'content-location': 'http://diveintopython3.org/examples/feed-302.xml',
 'expires': 'Thu, 04 Jun 2009 02:21:41 GMT',
 'server': 'Apache',
 'connection': 'close',
 'location': 'http://diveintopython3.org/examples/feed.xml',
 'cache-control': 'max-age=86400',
 'date': 'Wed, 03 Jun 2009 02:21:41 GMT',
 'content-type': 'text/html; charset=iso-8859-1'}

Атрибут response.previous містить посилання на попередній об’єкт відповіді, який httplib2 використала щоб дістатись до поточної відповіді.

>>> type(response)
<class 'httplib2.Response'>
>>> type(response.previous)
<class 'httplib2.Response'>

Як response так і response.previous - це об’єкти класу httplib2.Response.

>>> response.previous.previous
>>>

Це означає що ви можете перевірити response.previous.previous аби прослідкувати за ланцюжком перенаправлень ще далі. (Сценарій: один URL перенаправляє на інший, який перенаправляє на третій. Таке може трапитись!). В даному випадку, ми вже досягли початку ланцюга перенаправлень, тому атрибут має значення None.

Що трапиться якщо ви запитаєте той самий URL знову?

# продовжуючи попередній приклад
>>> response2, content2 = h.request('http://diveintopython3.org/examples/feed-302.xml')  ①
connect: (diveintopython3.org, 80)
send: b'GET /examples/feed-302.xml HTTP/1.1                                              ②
Host: diveintopython3.org
accept-encoding: deflate, gzip
user-agent: Python-httplib2/$Rev: 259 $'
reply: 'HTTP/1.1 302 Found'                                                              ③
>>> content2 == content                                                                  ④
True

① Той самий URL, той самий об’єкт httplib2.Http (відповідно той самий кеш).

② Відповідь 302 не була закешованою, тому httplib2 посилає інший запит до того ж URL.

③ І знову, сервер відповідає з 302. Але зауважте, що не сталося: не було наступного запиту до кінцевого URL, http://diveintopython3.org/examples/feed.xml. Ця відповідь була закешованою (пам’ятаєте заголовок Cache-Control, який ми бачили в попередньому прикладі?) Як тільки httplib2 отримала код 302 Found, вона перевірила його кеш перед створенням наступного запиту. Цей кеш містив свіжу копію http://diveintopython3.org/examples/feed.xml, тому не було потреби її перезапитувати.

④ Коли метод request() повертає дані, він бере їх з кешу. І звичайно це ті самі дані що ви отримали минулого разу.


Іншими словами, ви не повинні робити нічого особливого для обробки тимчасових перенаправлень. httplib2 буде слідувати за ними автоматично, і факт того що один URL посилається на інший, не має ніякого відношення до підтримки httplib2 стиснення, кешування, чи будь-яких інших особливостей HTTP.

Постійні перенаправлення так само прості.

# продовжуючи попередній приклад
>>> response, content = h.request('http://diveintopython3.org/examples/feed-301.xml')  ①
connect: (diveintopython3.org, 80)
send: b'GET /examples/feed-301.xml HTTP/1.1
Host: diveintopython3.org
accept-encoding: deflate, gzip
user-agent: Python-httplib2/$Rev: 259 $'
reply: 'HTTP/1.1 301 Moved Permanently'                                                ②
>>> response.fromcache                                                                 ③
True

① Знову ж таки, цього URL насправді не існує. Я додав його на свій сервер щоб створювати постійне перенаправлення на http://diveintopython3.org/examples/feed.xml.

② І ось він тут: код статусу 301. Але знову, зауважте чого не відбулось: не було запиту до URL на який нас перенаправляли. Чому? Тому що він вже закешований локально.

httplib2 "пішла" за перенаправленням прямо в свій кеш.


Але зачекайте, є ще!

# продовжуючи попередній приклад
>>> response2, content2 = h.request('http://diveintopython3.org/examples/feed-301.xml')  ①
>>> response2.fromcache                                                                  ②
True
>>> content2 == content                                                                  ③
True

① Ось різниця між тимчасовим та постійними перенаправленнями: як тільки httplib2 переходить за постійним перенаправленням, всі подальші запити за тим URL будуть очевидно перекидатись на цільовий URL без звертань по мережі до оригінального URL. Пам’ятайте, режим зневадження все ще ввімкнений, і при цьому нема жодного виводу про якусь мережеву активність.

② Ага, ця відповідь була отримана з локального кешу.

③ Ага, ви отримали ввесь фід (з кешу).


HTTP. Воно працює!


* * *


Поза HTTP GET

[ред.]

Веб-сервіси HTTP не обмежуються GET запитами. Що якщо ви захочете створити щось нове? Щоразу коли ви публікуєте коментар на форумі, оновлюєте свій блог, публікуєте статус на сервісі мікроблогів на зразок Twitter чи Google+, чи публікуєте gist на GitHub[1], ви скоріш за все вже використовуєте HTTP POST.

GitHub та Twitter надають просте API на основі HTTP для публікації статусів та коду відповідно. Давайте подивимось на документацію API GitHub щодо публікації нового gist.

Як це працює? Щоб створити новий gist, нам потрібно здійснити POST запит до https://api.github.com/gists. В запиті потрібно передати JSON об’єкт що містить дані нашого gist-та. І запит повинен бути автентифікованим.

Автентифікованим? Звісно. Щоб розмістити щось від свого імені на gist, потрібно довести що це саме ви. httplib2 підтримує як автентифікацію по SSL, так і базову HTTP автентифікацію, але в даному випадку ми використовуватимемо токен. Токен для авторизації в GitHub можна отримати на сторінці Applications налаштувань профілю, на панелі Personal Access Tokens:


Цей токен варто зберегти в якийсь, не доступний стороннім файл на зразок gist_token.txt і передавати його в заголовку наступним чином:

token = open('gist_token.txt').read().strip()
headers = {'Authorization': 'token ' + token}

POST запит відрізняється від GET запиту тим, що він містить корисне навантаження. Корисне навантаження - це дані які ви хочете відправити на сервер. GitHub приймає дані закодовані як JSON:

>>> import json
>>> data = json.dumps({
...     'description': 'test gist created from Python',
...     'public': True,
...     'files': {
...         'file1.txt': { 'content': 'hello world!'},
...     }
... })
>>> data
'{"files": {"file1.txt": {"content": "hello world!"}}, "description": "test gist created from Python", "public": true}'

Деякі сервіси приймають дані закодовані в форматі URL. Закодувати їх так можна за допомогою функції urlencode() з модуля urllib.parse, яка приймає словник пар ключ-значення, та перетворює його в рядок:

>>> from urllib.parse import urlencode
>>> data = {'greeting': 'Hello from Python 3'}
>>> urlencode(data)
'greeting=Hello+from+Python+3'

І нарешті, відправляємо запит разом з вмістом та заголовками авторизації серверу:

>>> http.request('https://api.github.com/gists', method='POST', body=data, headers=headers)
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "/usr/local/lib/python2.7/dist-packages/httplib2/__init__.py", line 1570, in request
    (response, content) = self._request(conn, authority, uri, request_uri, method, body, headers, redirections, cachekey)
  File "/usr/local/lib/python2.7/dist-packages/httplib2/__init__.py", line 1317, in _request
    (response, content) = self._conn_request(conn, request_uri, method, body, headers)
  File "/usr/local/lib/python2.7/dist-packages/httplib2/__init__.py", line 1252, in _conn_request
    conn.connect()
  File "/usr/local/lib/python2.7/dist-packages/httplib2/__init__.py", line 1044, in connect
    raise SSLHandshakeError(e)
SSLHandshakeError: [Errno 1] _ssl.c:504: error:14090086:SSL routines:SSL3_GET_SERVER_CERTIFICATE:certificate verify failed

Ого, це було неочікувано, правда?

Про SSL

[ред.]

httplib2 сказав нам що при встановленні захищеного з’єднання (https), сервер надав сертифікат, який підтверджує що це справді сервер GitHub, а не якогось хакера, але ми не можемо його перевірити, тому що у нас немає відповідного публічного сертифікату. З цієї ситуації є два виходи.

Найпростіший - вимкнути перевірку сертифікатів взагалі:

http = httplib2.Http(disable_ssl_certificate_validation=True)
headers, responce = http.request('https://api.github.com/gists', method='POST', body=data, headers=headers)

Але є ще й правильний вихід. Переглянемо для GitHub в браузері інформацію про сторінку.

Ми бачимо що факт того що ми на сайті github.com засвідчує якась DigiCert Inc. Натискаємо Переглянути сертифікат, далі відкриваємо вкладку подробиці і шукаємо внизу кнопку Експорт.... Серед типів файлу вибираємо "Сертифікат X.509 з ланцюжком (PEM)" і зберігаємо його кудись на диск, наприклад у файл digicert.pem.

Тепер, ми можемо передати цей файл об’єкту Http:

http = httplib2.Http(ca_certs='digicert.pem')

Якщо сертифікат правильний, вищеописана помилка більше з’являтись не повинна, і ми повинні отримати успішний результат в responce:

{'comments': 0,
 'comments_url': 'https://api.github.com/gists/6666887/comments',
 'commits_url': 'https://api.github.com/gists/6666887/commits',
 'created_at': '2013-09-23T05:56:19Z',
 'description': 'test gist created from Python',
 'files': {'file1.txt': {'content': 'hello world!',
                           'filename': 'file1.txt',
                           'language': None,
                           'raw_url': 'https://gist.github.com/raw/6666887/bc7774a7b18deb1d7bd0212d34246a9b1260ae17/file1.txt',
                           'size': 12,
                           'type': 'text/plain'}},
 'forks': [],
 'forks_url': 'https://api.github.com/gists/6666887/forks',
 'git_pull_url': 'https://gist.github.com/6666887.git',
 'git_push_url': 'https://gist.github.com/6666887.git',
 'history': [{'change_status': {'additions': 1,
                                  'deletions': 0,
                                  'total': 1},
               'committed_at': '2013-09-23T05:56:19Z',
               'url': 'https://api.github.com/gists/6666887/60f8dbda1d08edd9c466f0cf359690d534e5348c',
               'user': {... багато даних про користувача ... },
               'version': '60f8dbda1d08edd9c466f0cf359690d534e5348c'}],
 'html_url': 'https://gist.github.com/6666887',
 'id': '6666887',
 'public': True,
 'updated_at': '2013-09-23T05:56:19Z',
 'url': 'https://api.github.com/gists/6666887',
 'user': {... знову багато даних про користувача ...}}
  1. В оригіналі книжки не йшлося про ніякий GitHub, чи Google+, але сервіс identi.ca закрив реєстрацію, тому перекладач вирішив цю книжку трохи осучаснити

А якщо ми відкриємо Gits з id, який нам передали з відповіддю (6666887) у браузері, то побачимо щось таке:


* * *


Поза HTTP POST

[ред.]

HTTP не обмежується лише методами GET та POST. Це, звісно, найбільш поширені види запитів, особливо в браузерах. Але API веб-сервісів може поширюватись далі за GET та POST, і httplib2 готовий до цього.

В responce ми прийняли текст з даними закодованими як JSON. Ми можемо розкодувати їх і витягнути звідки id нашого Gist-та:

# продовжуючи попередній приклад 
>>> gist_id = json.loads(responce)['id']
>>> gist_id
'6666887'

Тепер давайте спробуємо видалити наш Gist, пославши запит DELETE:

>>> http.request('https://api.github.com/gists/%s' %id, method='DELETE', headers=headers)
({'status': '204', 'x-ratelimit-remaining': '4999', 'x-github-media-type': 'github.beta; format=json', 'x-content-type-options': 'nosn
iff', 'access-control-expose-headers': 'ETag, Link, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Acc
epted-OAuth-Scopes', 'x-github-request-id': '5F85A6C1:5248:1DB517D:5247DA46', 'vary': 'Accept-Encoding', 'server': 'GitHub.com', 'acce
ss-control-allow-origin': '*', 'x-ratelimit-limit': '5000', 'access-control-allow-credentials': 'true', 'date': 'Sun, 29 Sep 2013 07:4
4:06 GMT', 'x-oauth-scopes': 'user, public_repo, repo, gist', 'x-accepted-oauth-scopes': 'gist', 'x-ratelimit-reset': '1380444246'}, b
'')

У відповідь ми отримуємо статус-код HTTP 204. 204 означає "No content", тобто те що запит виконаний успішно, але сервер не вважає необхідним повертати у відповідь ще якісь дані. Що й логічно, для запиту видалення. Тому що якщо ми знову захочемо подивитись на сторінку в Github, вона вже буде відсутньою:


* * *


Для подальшого читання

[ред.]

httplib2:

Кешування HTTP:

RFC:

Приклад: Перенесення chardet на Python 3

[ред.]

Слова, слова. Це все що у нас є щоб продовжувати.
Розенкранц та Гільденстерн мертві


Питання: що є причиною №1 кракозябрів в інтернеті, в вашій поштовій скринці, і в кожній комп’ютерній системі яку ви писали? Це кодування символів. В розділі Текст, я розповідав про історію кодувань та створення Юнікоду, "одного кодування щоб керувати ними всіма". Я б був щасливий ніколи більше не бачити кракозябрів на екрані, тому що всі системи створення тексту зберігали б точну інформацію про кодування, всі протоколи передачі мали уявлення про Юнікод, і кожна система що працює з текстом зберігала б ідеальну якість при перетвореннях між різними кодуваннями.

Мені також сподобалось би поні.

Юнікодове поні.

Юніпоні, так би мовити.

Але я задовольнюсь автовизначенням кодування символів.


* * *


Що таке автовизначення кодування символів?

[ред.]

Це означає взяти послідовність байт в невідомому кодуванні, та спробувати визначити кодування, аби мати змогу прочитати текст. Це як зламування шифру, коли у вас немає ключа.

Хіба це не неможливо?

[ред.]

Взагалі, так. Щоправда, деякі кодування оптимізовані для деяких мов, а мови не випадкові. Деякі послідовності символів з’являються постійно, в той час як інші не мають жодного сенсу. Людина яка вільно читає англійською, відкривши газету і побачивши там "txzqJv 2!dasd0a QqdKjvz" одразу зрозуміє що це не англійська (хоча рядок і складений цілком з латинських літер). Вивчаючи багато "типового" тексту, комп’ютерний алгоритм може імітувати подібний рівень "вільного володіння" і вгадувати мову тексту.

Іншими словами, визначення кодування, це насправді визначення мови, комбіноване з знанням того які мови використовують які кодування.

А такий алгоритм існує?

[ред.]

Як виявилось, так. Всі основні браузери мають автовизначення кодування символів, тому що в інтернеті повно сторінок які взагалі не містять інформації про кодування. Mozilla Firefox містить бібліотеку автовизначення кодування яка поширюється з відкритим кодом. Я портував ту бібліотеку на Python 2, як модуль chardet. В цьому розділі ми крок за кроком пройдемось кріз процес портування модуля chardet з Python 2 на Python 3.


* * *


Представляєю модуль chardet

[ред.]

Перед тим як ми почнемо переносити код, було б добре розібратись у тому як він працює. Це корокта довідка щодо того як читати код. Бібліотека chardet занадто велика щоб вставити її код прямо сюди, але ви можете прочитати її код на GitHub.

Головною вхідною точкою алгоритму визначення є universaldetector.py, який має один клас, UniversalDetector. (Ви можете думати що головною вхідною точкою є функція detect в файлі chardet/__init__.py, але насправді це просто функція для зручності, яка створює об’єкт UniversalDetector, викликає його, та повертає результат виклику.)

Визначення кодування це насправді переодягнене визначення мови.

UniversalDetector працює з 5 видами кодувань:

  1. UTF-N з маркером порядку байтів (BOM). Це включає UTF-8, як Big-Endian так і Little-Endian варіанти UTF-16, та всі чотирибайтові варіанти UTF-32.
  2. Екрановані кодування, які повністю сумісні з семибітним ASCII, де не-ASCII символи починаються з символу екранування. Приклади: ISO-2022-JP (Японська) та HZ-GB-2312 (Китайська).
  3. Багатобайтові кодування, в яких кожен символ представлено різною кількістю байтів. Приклади: BIG5 (Китайська), SHIFT_JIS (Японська), EUC-KR (Корейська), та UTF-8 без BOM.
  4. Однобайтові кодування, в яких кожен символ представлений одним байтом. Приклади: KOI8-R (Російська), WINDOWS-1255 (Єврейська), та TIS-620 (Тайська).
  5. WINDOWS-1251, яка використовується переважно на Microsoft Windows менеджерами середньої ланки, які не відрізнять кодування символів від власної дупи.

UTF-8 з BOM

[ред.]

Якщо текст починається з BOM, ми маємо причину припускати що текст закодований в UTF-8, UTF-16, чи UTF-32. (BOM вкаже нам точно яка з них, вона якраз для цього призначена.) Цей випадок опрацьовується прямо в UniversalDetector, який негайно поверне результат, без подальшої обробки.

Екрановані кодування

[ред.]

Якщо текст містить впізнавану ескейп-послідовність, це може вказувати що використано екрановане кодування. UniversalDetector створить EscCharSetProber (описаний в escprober.py) та передасть йому текст.

EscCharSetProber створює послідовність скінченних автоматів, які базуються на основі моделей кодувань HZ-GB-2312, ISO-2022-CN, ISO-2022-JP, та ISO-2022-KR (описаних в escsm.py). EscCharSetProber передає текст в кожен з цих скінченних автоматів, по одному байту за раз. Якщо будь-який з цих скінченних автоматів зупиниться, унікально ідентифікуючи кодування, EscCharSetProber негайно поверне позитивний результат в UniversalDetector, який поверне результат тому хто викликав цей об’єкт. Якщо будь-який з скінченних автоматів зустрічає недозволену послідовність, вона викидається і обробка продовжується з іншими скінченними автоматами.


Багатобайтові кодування

[ред.]

Припускаючи відсутність BOM, UniversalDetector перевіряє чи містить текст будь-які символи, з найбільш значущим бітом в байті. Якщо так, він створює послідовність "проберів" для визначення багатобайтових кодувань, однобайтових кодувань, і як крайній вихід - windows-1252.

Пробер багатобайтових кодувань, MBCSGroupProber (описаний в mbcsgroupprober.py), є просто оболонкою яка керує групою інших проберів, по одному на кожне багатобайтове кодування: Big5, GB2312, EUC-TW, EUC-KR, EUC-JP, SHIFT_JIS, та UTF-8. MBCSGroupProber передає текст кожному з цих проберів та перевіряє результати. Якщо пробер звітує про те що він знайшов недозволену послідовність байтів, він видаляється з подальшої обробки (так, що наприклад, будь-які подальші виклики UniversalDetector.feed() пропустять той пробер. Якщо пробер звітує що він достатньо впевнений в тому що визначив кодування, MBCSGroupProber звітує про цей позитивний результат в UniversalDetector, а той передає його далі.

Більшість проберів багатобайтових кодувань наслідуються від MultiByteCharSetProber (описаний в mbcharsetprober.py), і просто підтягують відповідний скінченний автомат, та аналізатор розподілу і дозволяють MultiByteCharSetProber зробити решту роботи. MultiByteCharSetProber пропускає текст через відповідні кодуванням скінченні автомати, по байту за раз, щоб дізнатись про послідовності байтів, які вкажуть на заключний позитивний чи негативний результат. В той же час, MultiByteCharSetProber передасть текст відповідному до кодування аналізатору розподілу.

Аналізатори розподілу (визначені в chardistribution.py) використовують мовно-специфічні моделі того які символи використовуються найчастіше. Як тільки MultiByteCharSetProber передасть достатньо тексту аналізатору розпоідлу, він обчислює коефіцієнт впевненості, який базується на кількостях часто використовуваних символів, загальному числу символів, та залежній від мови ймовірності розподілу символів. Якщо рівень впевненості достатньо високий, MultiByteCharSetProber поверне результат в MBCSGroupProber, який поверне його в UniversalDetector а той вже передасть його далі.

Випадок Японської більш складний. Односимвольний аналіз розподілу не завжди є достатнім для того аби відрізнити EUC-JP та SHIFT_JIS, тому SJISProber (описаний в sjisprober.py) також використовує аналіз розподілу пар символів. SJISContextAnalysis та EUCJPContextAnalysis (обоє описані в jpcntx.py та обоє наслідуються від спільного класу JapaneseContextAnalysis), перевіряють частоту складових символів Хірагани в тексті. Як тільки текст буде опрацьовано, вони повертають рівень впевненості в SJISProber, який перевіряє обидва аналізатори і повертає результат того, чий рівень впевненості буде вищим в MBCSGroupProber.

Однобайтові кодування

[ред.]
Серйозно, де моє Юнікодове поні?

Пробер однобайтових кодувань, SBCSGroupProber (описаний в sbcsgroupprober.py), також є лише оболонкою яка керує групою інших проберів, по одному для кожної комбінації однобайтових кодувань та мов: windows-1251, KOI8-R, ISO-8859-5, MacCyrillic, IBM855, та IBM866 (Російська); ISO-8859-7 та windows-1253 (Грецька); ISO-8859-5 та windows-1251 (Болгарська); ISO-8859-2 та windows-1250 (Угорська); TIS-620 (Тайська); windows-1255 та ISO-8859-8 (Іврит).

SBCSGroupProber передає текст кожному з цих специфічних для мови та кодування проберів і перевіряє результати. Всі ці пробери реалізовані в єдиному класі, SingleByteCharSetProber (описаному в sbcharsetprober.py), який приймає модель мови як аргумент. Модель мови описує як часто різні двосимвольні послідовності з’являються в типовому тексті. SingleByteCharSetProber опрацьовує текст, і відмічає найчастіше вживані двосимвольні послідовності. Як тільки текст було опрацьовано, він обчислює рівень впевненості, базуючись на кількостях частовживаних послідовностей, загальній кількості символів та унікальному для мови розподілу частоти.

Іврит опрацьовується окремим випадком. Якщо текст схожий на іврит, за даними аналізу розподілу двосимвольних послідовностей, HebrewProber (описаний в hebrewprober.py) спробує відрізнити між візуальним івритом (де текст справді зберігається "задом наперед" рядок за рядком, а потім так і відображається аби його можна було читати справа наліво) та логічним івритом (де текст зберігається в порядку читання, а потім відображається з права наліво програмою). Тому що деякі символи кодуються по різному залежно від того чи знаходяться вони в середині чи наприкінці слова, ми можемо обґрунтовано вгадати напрямок тексту, і повернути відповідне кодування (windows-1255 для логічного івриту, та ISO-8859-8 для візуального івриту).

windows-1252

[ред.]

Якщо UniversalDetector виявляє в тексті символ з останнім значущим бітом, але жодне з інших багатобайтових кодувань не підійшло, він створює Latin1Prober (описаний в latin1prober.py) щоб спробувати виявити англомовний текст в кодуванні windows-1252. Ця перевірка страшенно ненадійна, тому що латинські букви кодуються однаково в багатьох кодуваннях. Єдиний спосіб відрізнити windows-1252 - за допомогою часто використовуваних символів, таких як лапки, апострофи, символ копірайту, і т.п. Latin1Prober автоматично зменшує коефіцієнт впевненості, аби дати можливість більш точним проберам виграти, якщо це взагалі можливо.


* * *


Запуск 2to3

[ред.]

Ми збираємось портувати модуль chardet з Python 2 на Python 3. Python 3 поставляється з корисним скриптом, який називається 2to3, який бере на вхід код на Python 2 та автоматично перетворює його на Python 3. В багатьох випадках це легко - коли функція була перейменована чи переміщена в інший модуль, але в інших випадках все може ставати досить складним. Щоб отримати відчуття того всього що він може зробити, зверніться до додатку Poring code to Python 3 with 2to3. В цьому розділі ми почнемо з запуску 2to3 над пакетом chardet, але як ви побачите, після того як автоматичні інструменти здійснять свою магію, залишиться все ще досить роботи.

Головний пакунок chardet поділений між кількома різними файлами, що знаходяться в одній директорії. Скрипт 2to3 досить просто конвертує багато файлів за раз: просто передайте в якості аргумента командного рядка директорію, і 2to3 опрацює кожен файл всередині.

C:\home\chardet> python c:\Python30\Tools\Scripts\2to3.py -w chardet\
RefactoringTool: Skipping implicit fixer: buffer
RefactoringTool: Skipping implicit fixer: idioms
RefactoringTool: Skipping implicit fixer: set_literal
RefactoringTool: Skipping implicit fixer: ws_comma
--- chardet\__init__.py (original)
+++ chardet\__init__.py (refactored)
@@ -18,7 +18,7 @@
 __version__ = "1.0.1"

 def detect(aBuf):
-    import universaldetector
+    from . import universaldetector
     u = universaldetector.UniversalDetector()
     u.reset()
     u.feed(aBuf)
--- chardet\big5prober.py (original)
+++ chardet\big5prober.py (refactored)
@@ -25,10 +25,10 @@
 # 02110-1301  USA
 ######################### END LICENSE BLOCK #########################

-from mbcharsetprober import MultiByteCharSetProber
-from codingstatemachine import CodingStateMachine
-from chardistribution import Big5DistributionAnalysis
-from mbcssm import Big5SMModel
+from .mbcharsetprober import MultiByteCharSetProber
+from .codingstatemachine import CodingStateMachine
+from .chardistribution import Big5DistributionAnalysis
+from .mbcssm import Big5SMModel

 class Big5Prober(MultiByteCharSetProber):
     def __init__(self):
--- chardet\chardistribution.py (original)
+++ chardet\chardistribution.py (refactored)
@@ -25,12 +25,12 @@
 # 02110-1301  USA
 ######################### END LICENSE BLOCK #########################

-import constants
-from euctwfreq import EUCTWCharToFreqOrder, EUCTW_TABLE_SIZE, EUCTW_TYPICAL_DISTRIBUTION_RATIO
-from euckrfreq import EUCKRCharToFreqOrder, EUCKR_TABLE_SIZE, EUCKR_TYPICAL_DISTRIBUTION_RATIO
-from gb2312freq import GB2312CharToFreqOrder, GB2312_TABLE_SIZE, GB2312_TYPICAL_DISTRIBUTION_RATIO
-from big5freq import Big5CharToFreqOrder, BIG5_TABLE_SIZE, BIG5_TYPICAL_DISTRIBUTION_RATIO
-from jisfreq import JISCharToFreqOrder, JIS_TABLE_SIZE, JIS_TYPICAL_DISTRIBUTION_RATIO
+from . import constants
+from .euctwfreq import EUCTWCharToFreqOrder, EUCTW_TABLE_SIZE, EUCTW_TYPICAL_DISTRIBUTION_RATIO
+from .euckrfreq import EUCKRCharToFreqOrder, EUCKR_TABLE_SIZE, EUCKR_TYPICAL_DISTRIBUTION_RATIO
+from .gb2312freq import GB2312CharToFreqOrder, GB2312_TABLE_SIZE, GB2312_TYPICAL_DISTRIBUTION_RATIO
+from .big5freq import Big5CharToFreqOrder, BIG5_TABLE_SIZE, BIG5_TYPICAL_DISTRIBUTION_RATIO
+from .jisfreq import JISCharToFreqOrder, JIS_TABLE_SIZE, JIS_TYPICAL_DISTRIBUTION_RATIO

 ENOUGH_DATA_THRESHOLD = 1024
 SURE_YES = 0.99
.
.
. (воно деякий час продовжується подібним чином)
.
.
RefactoringTool: Files that were modified:
RefactoringTool: chardet\__init__.py
RefactoringTool: chardet\big5prober.py
RefactoringTool: chardet\chardistribution.py
RefactoringTool: chardet\charsetgroupprober.py
RefactoringTool: chardet\codingstatemachine.py
RefactoringTool: chardet\constants.py
RefactoringTool: chardet\escprober.py
RefactoringTool: chardet\escsm.py
RefactoringTool: chardet\eucjpprober.py
RefactoringTool: chardet\euckrprober.py
RefactoringTool: chardet\euctwprober.py
RefactoringTool: chardet\gb2312prober.py
RefactoringTool: chardet\hebrewprober.py
RefactoringTool: chardet\jpcntx.py
RefactoringTool: chardet\langbulgarianmodel.py
RefactoringTool: chardet\langcyrillicmodel.py
RefactoringTool: chardet\langgreekmodel.py
RefactoringTool: chardet\langhebrewmodel.py
RefactoringTool: chardet\langhungarianmodel.py
RefactoringTool: chardet\langthaimodel.py
RefactoringTool: chardet\latin1prober.py
RefactoringTool: chardet\mbcharsetprober.py
RefactoringTool: chardet\mbcsgroupprober.py
RefactoringTool: chardet\mbcssm.py
RefactoringTool: chardet\sbcharsetprober.py
RefactoringTool: chardet\sbcsgroupprober.py
RefactoringTool: chardet\sjisprober.py
RefactoringTool: chardet\universaldetector.py
RefactoringTool: chardet\utf8prober.py

Тепер, давайте запустимо скрипт 2to3 на наших страховочних тестах, test.py.

C:\home\chardet> python c:\Python30\Tools\Scripts\2to3.py -w test.py
RefactoringTool: Skipping implicit fixer: buffer
RefactoringTool: Skipping implicit fixer: idioms
RefactoringTool: Skipping implicit fixer: set_literal
RefactoringTool: Skipping implicit fixer: ws_comma
--- test.py (original)
+++ test.py (refactored)
@@ -4,7 +4,7 @@
 count = 0
 u = UniversalDetector()
 for f in glob.glob(sys.argv[1]):
-    print f.ljust(60),
+    print(f.ljust(60), end=' ')
     u.reset()
     for line in file(f, 'rb'):
         u.feed(line)
@@ -12,8 +12,8 @@
     u.close()
     result = u.result
     if result['encoding']:
-        print result['encoding'], 'with confidence', result['confidence']
+        print(result['encoding'], 'with confidence', result['confidence'])
     else:
-        print '******** no result'
+        print('******** no result')
     count += 1
-print count, 'tests'
+print(count, 'tests')
RefactoringTool: Files that were modified:
RefactoringTool: test.py

Ну, це не було аж так складно. Просто переписати кілька імпортів та команд print. І раз ми вже про це заговорили, а що то була за проблема з import</code? Щоб дати відповідь на це запитання потрібно розуміти як модуль chardet розбитий на файли.


* * *


Короткий відступ про багатофайлові модулі

[ред.]

chardet це багатофайловий модуль. Я міг би вирішити покласти ввесь код у один файл (названий chardet.py), але не зробив цього. Натомість, я створив каталог (названий chardet), а тоді створив в ньому файл __init__.py. Якщо Python бачить в директорії файл __init__.py, він припускає що всі файли в тій директорії є частиною одного модуля. Назвою модуля є назва директорії. Файли всередині директорії можуть посилатись на інші файли в директорії чи навіть в піддиректоріях. (За хвилину про це більше). Але ввесь набір файлів виглядає для решти коду як єдиний модуль - так ніби всі функції та класи є в єдиному .py файлі.

Що відбувається в файлі __init__.py? Нічого. Все. Щось середнє. Файл __init__.py не повинен нічого описувати, він може бути буквально порожнім. Або ви можете використати його аби описати там функції що є точками входу. Чи описати там всі функції. Чи всі крім однієї.

Каталог з файлом __init__.py завжди розлядається як багатофайловий модуль. Без файлу __init__.py каталог є просто каталогом з непов’язаними .py файлами.

Давайте подивимось як це працює на практиці:

>>> import chardet
>>> dir(chardet)
['__builtins__', '__doc__', '__file__', '__name__',
 '__package__', '__path__', '__version__', 'detect']

Окрім звичайних атрибутів, єдине що міститься в модулі chardet - це функція detect().

 >>> chardet 
<module 'chardet' from 'C:\Python31\lib\site-packages\chardet\__init__.py'>

А ось наша перша підказка про те що модуль chardet це не просто файл: цей модуль описується як файл __init__.py всередині директорії chardet/.

Давайте заглянемо в той файл __init__.py:

def detect(aBuf):
    from . import universaldetector
    u = universaldetector.UniversalDetector()
    u.reset()
    u.feed(aBuf)
    u.close()
    return u.result

__init__.py описує функцію detect(), яка є головною точкою входу в бібліотеку chardet. Але ця функція майже не містить коду, Все що вона насправді робить - це імпортує модуль universaldetector та починає його використовувати. Але де описується universaldetector?

Відповідь дає ця дивна команда імпорту:

from . import universaldetector

Що в перекладі на українську означає "імпортуй модуль universaldetector, він знаходиться в тій самій директорії що і я", де "я" - це chardet/__init__.py. Це називається відносним імпортом. Це спосіб для файлів всередині багатофайлового модуля посилатись один на одного, не хвилюючись про конфлікти імен з іншими модулями, які ви могли встановити в одну з директорій в яких Python шукає модулі для імпорту. Дана команда import буде шукати модуль universaldetector лише в директорії chardet/.

Ці два поняття - __init__.py, та відносні імпорти, означають що ви можете розбити ваш модуль на так багато шматочків як захочете. Модуль chardet містить 36 файлів .py. 36! І все одно, все що вам потрібно зробити аби його використати - це import chardet, а потім викликати основну функцію chardet.detect(). Без відома вашого коду, функція detect() описується в файлі chardet/__init__.py. Також, без вашого відома, функція detect() використовує відносний імпорт щоб звернутись до класу описаного в файлі chardet/universaldetector.py, який далі використовує відносні імпорти на п’яти інших файлах, кожен з яких міститься в каталозі chardet/.

Якщо ви будете писати велику бібліотеку на Python (чи, що більш ймовірно, коли побачите що ваша маленька бібліотека виросла і стала великою), знайдіть час для рефакторингу її в багатофайловий модуль. Це одна з багатьох речей з якими Python справляється добре, тому варто використати її як перевагу.


* * *


Виправлення того що не зміг 2to3

[ред.]

False is invalid syntax

[ред.]
У вас ж є тести, правда?

Тепер справжнє випробування: запуст набору тестів. Так як тести створені таким чином щоб покривати всі можливі шляхи в коді, це гарний спосіб перевірити наш портований код, щоб переконатись що ніде не заповзли ніякі баги.

C:\home\chardet> python test.py tests\*\*
Traceback (most recent call last):
  File "test.py", line 1, in <module>
    from chardet.universaldetector import UniversalDetector
  File "C:\home\chardet\chardet\universaldetector.py", line 51
    self.done = constants.False
                              ^
SyntaxError: invalid syntax

Хмм, невеликий сучок. В Python 3, False - зарезервоване слово, тому його не можна використовувати як ім’я змінної. Давайте подивимось на constants.py, і знайдемо де воно описане. Ось оригінальна версія коду з constants.py, до того як скрипт 2to3 його змінив:

import __builtin__
if not hasattr(__builtin__, 'False'):
    False = 0
    True = 1
else:
    False = __builtin__.False
    True = __builtin__.True

Цей шматок коду створений щоб дозволити бібліотеці працювати на старіших версіях Python 2. До Python 2.3, в Python не було вбудованого типу bool. Цей код виявляє відсутність вбудованих констант True та False і описує їх за потреби.

Проте, Python 3 завжди матиме тип bool, тому ввесь той шматок коду не є обов’язковим. Найпростішим рішенням є замінити всі входження constants.True та constants.False на True та False відповідно, а потім видалити цей мертвий код з constants.py.

Тоді цей рядок в universaldetector.py:

self.done = constants.False

Стає цим:

self.done = False

Ох, хіба це не задоволення? Код став коротший і більш читабельний вже зараз.

No module named constants

[ред.]

Час запустити test.py знову, і подивитись як далеко він зможе дійти.

C:\home\chardet> python test.py tests\*\*
Traceback (most recent call last):
  File "test.py", line 1, in <module>
    from chardet.universaldetector import UniversalDetector
  File "C:\home\chardet\chardet\universaldetector.py", line 29, in <module>
    import constants, sys
ImportError: No module named constants

Що ти сказав? Нема модуля названого constants? Звичайно є модуль з назвою constants. Він ось тут, в chardet/constants.py.

Пам’ятаєте, коли скрипт 2to3 виправив всі ті команди імпорту? Ця бібліотека має багато відносних імпортів, тобто модулів що імпортують інші модулі з своєї бібліотеки, але логіка відносних імпортів змінилась в Python 3. В Python 2, ви могли просто написати import constants і він шукав би спершу в директорії chardet/. У Python 3, всі імпорти за замовчуванням абсолютні. Якщо ви хочете здійснити відносний імпорт в Python 3, треба про це написати явно:


from . import constants

Але зачекайте. Хіба скрипт 2to3 не повинен був виправити це за нас? Ну, він так і зробив, але ця конкретна команда імпорту поєднує два типи імпорту на одному рядку: відносний імпорт модуля constants та абсолютний імпорт модуля sys, який входить в стандартну бібліотеку. В Python 2, ви можете комбінувати це в одній команді. В Python 3 - не можете, а скрипт 2to3 не настільки розумний щоб розбити команду імпорту на дві.

Єдиним виходом буде зробити це вручну. Тому цей однорядковий імпорт:

import constants, sys

Повинен стати двома окремими імпортами:

from . import constants
import sys

Існують варіації цієї проблеми розкидані по всій бібліотеці chardet. В деяких місцях це "import constants, sys", в деяких "import constants, re". Рішення всюди однакове, розбити імпорт на два, один відносний, один абсолютний.

Вперед!

Name 'file' is not defined

[ред.]
open() - це новий file().

І ось ми знову запускаємо test.py, щоб пройтись по тестах:

C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml
Traceback (most recent call last):
  File "test.py", line 9, in <module>
    for line in file(f, 'rb'):
NameError: name 'file' is not defined

Це мене здивувало, тому що я звик використовувати цю ідіому відколи себе пам`ятаю. В Python 2, глобальна функція file() була псевдонімом для функції open(), яка була стандартним способом відкривання текстових файлів для читання. В Python 3 глобальної функції file() вже не існує, але функція open() все ще залишається.

Тому, найпростішим вирішенням проблеми відсутньї функції file(), буде викликати функцію open() натомість:

for line in open(f, 'rb'):

І це все що я про це мав сказати.

Can’t use a string pattern on a bytes-like object

[ред.]

А тепер речі стають цікавими. І під "цікавими" я маю на увазі "до чорта заплутаними".

C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml
Traceback (most recent call last):
  File "test.py", line 10, in <module>
    u.feed(line)
  File "C:\home\chardet\chardet\universaldetector.py", line 98, in feed
    if self._highBitDetector.search(aBuf):
TypeError: can't use a string pattern on a bytes-like object

Щоб це зневадити, давайте подивимось що таке self._highBitDetector. Воно описане в методі __init__ класу UniversalDetector:

class UniversalDetector:
    def __init__(self):
        self._highBitDetector = re.compile(r'[\x80-\xFF]')

Це попередньо відкомпільований регулярний вираз створений для знаходження не ASCII символів в діапазоні 128-255 (Ox80-0xFF). Але зачекайте, це не цілком правильно, я повинен бути точнішим з моєю термінологією. Цей паттерн створений для пошуку не ASCII байтів в діапазоні 128-255.

І ось тут лежить наша проблема.

В Python 2, рядок був масивом байтів, кодування символів яких відстежувалось окремо. Якщо ви хотіли щоб Python 2 слідкував за кодуванням символів, потрібно було використовувати натомість рядок Юнікод (u). Але в Python 3, рядки - завжди те що в Python 2 називалось Юнікодовими рядками, тобто масив символів Юнікоду (можливо з різними кількостями байтів на символ). Так як даний регулярний вираз описаний рядком, він може використовуватись лише для пошуку рядків - тобто повторюсь, масивів символів. Але те що ми шукаємо - це не рядок, це масив байтів. Якщо подивитись на трейсбек, ця помилка виникла в universaldetector.py:

def feed(self, aBuf):
    .
    .
    .
    if self._mInputState == ePureAscii:
        if self._highBitDetector.search(aBuf):

А що таке aBuf? Давайте перейдемо до місця з якого викликається UniversalDetector.feed(). Одне з тих місць де цей метод викликається - тестова страховка в test.py.

u = UniversalDetector()
.
.
.
for line in open(f, 'rb'):
    u.feed(line)
Не масив символів, а масив байтів

І тут ми знаходимо нашу відповідь: в методі UniversalDetector.feed(), aBuf - це рядок що читається з файлу на диску. Подивіться уважно на параметри використані для відкривання файлу: 'rb'. 'r' означає "read". Ну гаразд, подумаєш, ми читаємо файл. Ах, але 'b' означає "binary". Без прапора 'b', цей цикл for прочитав би файл, рядок за рядком, і перетворив би кожен рядок в масив символів Юнікоду - згідно з системним кодуванням за замовчуванням. Але з прапором 'b', цей цикл for читає файл, рядок за рядком, і і зберігає кожен рядок точно так як він записаний в файлі, як масив байтів. Цей масив байтів передається до UniversalDetector.feed(), та в кінцевому результаті передається в попередньо відкомпільований регулярний вираз, self._highBitDetector, для пошуку високобітових... символів. Але в нас немає символів, в нас є байти. Ой...

Що нам потрібно в цьому регулярному виразі - це шукати не масив символів, а масив байтів.

Як тільки ми це усвідомимо, рішення не буде складним. Регулярні вирази описані рядками можуть шукати рядки. Регулярні вирази описані масивами байтів можуть шукати масиви байтів. Щоб описати шаблон над байтовим масивом, ми просто змінюємо тип аргументу, який ми використовуємо для опису регулярних виразів, на масив байт. (Також наступний приклад покаже виправлення іншої аналогічної проблеми):

  class UniversalDetector:
      def __init__(self):
-         self._highBitDetector = re.compile(r'[\x80-\xFF]')
-         self._escDetector = re.compile(r'(\033|~{)')
+         self._highBitDetector = re.compile(b'[\x80-\xFF]')
+         self._escDetector = re.compile(b'(\033|~{)')
          self._mEscCharSetProber = None
          self._mCharSetProbers = []
          self.reset()

Пошук по всьому коду інших використань модуля re дає ще два випадки, в charsetprober.py. І знову, код що описує регулярні вирази - це рядки, але виконуються вони над aBuf, який є масивом байтів. Розв’язок такий самий: описувати шаблони регулярних виразів як масиви байтів.

 class CharSetProber:
      .
      .
      .
      def filter_high_bit_only(self, aBuf):
-         aBuf = re.sub(r'([\x00-\x7F])+', ' ', aBuf)
+         aBuf = re.sub(b'([\x00-\x7F])+', b' ', aBuf)
          return aBuf
    
      def filter_without_english_letters(self, aBuf):
-         aBuf = re.sub(r'([A-Za-z])+', ' ', aBuf)
+         aBuf = re.sub(b'([A-Za-z])+', b' ', aBuf)
          return aBuf

Can't convert 'bytes' object to str implicitly

[ред.]

Цікавіше і цікавіше...

C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml
Traceback (most recent call last):
  File "test.py", line 10, in <module>
    u.feed(line)
  File "C:\home\chardet\chardet\universaldetector.py", line 100, in feed
    elif (self._mInputState == ePureAscii) and self._escDetector.search(self._mLastChar + aBuf):
TypeError: Can't convert 'bytes' object to str implicitly

Тут ми бачимо невдале зіткнення стилю кодування з інтерпретатором Python. TypeError може виникати будь-де в тому рядку, але інтерпретатор не вкаже точно де саме. Виняток може виникати в першій умові, або в другій, і трейсбек виглядатиме так само. Щоб його звузити, поле пошуку, варто розділити рядок надвоє, якось так:

elif (self._mInputState == ePureAscii) and \
    self._escDetector.search(self._mLastChar + aBuf):

І перезапустити тест:

C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml
Traceback (most recent call last):
  File "test.py", line 10, in <module>
    u.feed(line)
  File "C:\home\chardet\chardet\universaldetector.py", line 101, in feed
    self._escDetector.search(self._mLastChar + aBuf):
TypeError: Can't convert 'bytes' object to str implicitly

Ага! Проблема була не в першій умові (self._mInputState == ePureAscii), а в другій. То що в ній могло спричинити TypeError? Можливо ви думаєте що метод search() очікує значення іншого типу, але це не згенерувало б такого трейсбеку. Функції в Python можуть приймати будь-яке значення, якщо ви передасте потрібну кількість аргументів, функція почне виконуватись. Вона може аварійно зупинитись, якщо ви передасте значення типу на який вона не очікувала, але якщо це трапиться, трейсбек буде показувати кудись всередину функції. Але цей трейсбек каже що виконання не перейшло всередину метода search(). Отож, проблема повинна бути в операторі +, який намагається сконструювати значення що буде передано в метод search().

Ми з попереднього зневадження знаємо що aBuf - це масив байтів. Тоді що таке self._mLastChar? Це екземпляра, описана в методі reset(), який викликається в методі __init__().

class UniversalDetector:
    def __init__(self):
        self._highBitDetector = re.compile(b'[\x80-\xFF]')
        self._escDetector = re.compile(b'(\033|~{)')
        self._mEscCharSetProber = None
        self._mCharSetProbers = []
        self.reset()

    def reset(self):
        self.result = {'encoding': None, 'confidence': 0.0}
        self.done = False
        self._mStart = True
        self._mGotData = False
        self._mInputState = ePureAscii
        self._mLastChar = ''

І тепер ми маємо нашу відповідь. Ви її бачите? self._mLastChar - це рядок, але aBuf - масив байтів. І ви не можете конкатенувати рядок з масивом байтів - навіть порожній рядок.

А що self._mLastChar взагалі таке? В методі feed(), лише на кілька рядків нижче від того місця де трапився трейсбек.

if self._mInputState == ePureAscii:
    if self._highBitDetector.search(aBuf):
        self._mInputState = eHighbyte
    elif (self._mInputState == ePureAscii) and \
            self._escDetector.search(self._mLastChar + aBuf):
        self._mInputState = eEscAscii

self._mLastChar = aBuf[-1]

Функція викликає цей метод feed() знову і знову з кількома байтами за раз. Метод обробляє байти які йому були дані (в параметрі aBuf), потім зберігає останній байт в self._mLastChar на випадок якщо він буде потрібним при наступному виклику. (В багатобайтових кодуваннях метод feed() може бути викликаним з половиною символа, а потім викликаним знову з іншою половиною. Але через те що aBuf тепер байтовий масив а не рядок, self._mLastChar теж повинен бути байтовим масивом. Тому:

  def reset(self):
      .
      .
      .
-     self._mLastChar = ''
+     self._mLastChar = b''

Пошук "mLastChar" по всьому коду знаходить подібну проблему в mbcharsetprober.py, але замість останнього символа, він відслідковує останні два символи. Клас MultiByteCharSetProber використовує список з односимвольних рядків щоб слідкувати за останніми двома символами. В Python 3, він повинен використати список цілих, тому що він насправді запам'ятовує не символи, він запам'ятовує байти. (Байти - це просто символи від 0 до 255).

  class MultiByteCharSetProber(CharSetProber):
      def __init__(self):
          CharSetProber.__init__(self)
          self._mDistributionAnalyzer = None
          self._mCodingSM = None
-         self._mLastChar = ['\x00', '\x00']
+         self._mLastChar = [0, 0]

      def reset(self):
          CharSetProber.reset(self)
          if self._mCodingSM:
              self._mCodingSM.reset()
          if self._mDistributionAnalyzer:
              self._mDistributionAnalyzer.reset()
-         self._mLastChar = ['\x00', '\x00']
+         self._mLastChar = [0, 0]

Unsupported operand type(s) for +: 'int' and 'bytes'

[ред.]

В мене є гарні новини і погані новини. Гарні новини полягають в тому що ми здійснюємо прогрес...

C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml
Traceback (most recent call last):
  File "test.py", line 10, in <module>
    u.feed(line)
  File "C:\home\chardet\chardet\universaldetector.py", line 101, in feed
    self._escDetector.search(self._mLastChar + aBuf):
TypeError: unsupported operand type(s) for +: 'int' and 'bytes'

... А погані новини в тому, що цей прогрес не завжди відчутно.

Але це прогрес! Справді! Навіть незважаючи на те що трейсбек показує на той самий рядок коду, це вже інша помилка. Прогрес! То яка прооблема в нас тепер? Останнього разу коли я перевіряв, цей рядок коду не намагався конкатенувати ціле число (int</code) з байтовим масивом (bytes). Насправді, ви щойно витратили багато часу переконуючись що self._mLastChar буде байтовим масивом. Як він перетворився на int?

Відповідь лежить не в попередніх рядках коду, а в оцих:

if self._mInputState == ePureAscii:
    if self._highBitDetector.search(aBuf):
        self._mInputState = eHighbyte
    elif (self._mInputState == ePureAscii) and \
            self._escDetector.search(self._mLastChar + aBuf):
        self._mInputState = eEscAscii

self._mLastChar = aBuf[-1]
Кожен елемент рядка - це рядок. Кожен елемент байтового масиву - це ціле число.

Ця помилка не виникає при першому виклику методу feed(), вона виникає при другому, після того як self._mLastChar отримає значення останнього байта aBuf. Ну добре, а з цим яка проблема? А така, що якщо взяти елемент байтового масиву, то це буде ціле число, а не байтовий масив. Щоб побачити різницю, давайте разом відкриємо інтерактивну оболонку:

>>> aBuf = b'\xEF\xBB\xBF' ①
>>> len(aBuf)
3 
>>> mLastChar = aBuf[-1] 
>>> mLastChar ② 
191 
>>> type(mLastChar) ③ 
<class 'int'> 
>>> mLastChar + aBuf ④ 
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'int' and 'bytes' 
>>> mLastChar = aBuf[-1:] ⑤ 
>>> mLastChar
b'\xbf' 
>>> mLastChar + aBuf ⑥ 
b'\xbf\xef\xbb\xbf'
① Опишемо байтовий масив довжиною 3.
② Останнім елементом цього байтового масиву є 191.
③ Це ціле число.
④ Конкатенація цілого числа з байтовим масивом не працює. Ми щойно відтворили помилку на яку натрапили в universaldetector.py.
⑤ Ах, ось і вирішення. Замість того аби отримувати останній елемент байтового масиву, використайте зрізання списків, аби створити новий байтовий масив що містить лише останній елемент. Тобто, почніть з останнього елементу і продовжуйте зріз до кінця байтового масиву. Тепер mLastChar - це байтовий масив довжини 1.
⑥ Конкатенація байтового масиву довжини 1 з байтовим масивом довжини 3 дає новий байтовий масив довжини 4.

Отож, щоб переконатись що метод feed() в universaldetector.py продовжує працювати не зважаючи на те як часто його викликають, ви повинні ініціалізувати self._mLastChar як байтовий масив нульової довжини а тоді пересвідчитись що він залишатиметься байтовим масивом.

              self._escDetector.search(self._mLastChar + aBuf):
          self._mInputState = eEscAscii

- self._mLastChar = aBuf[-1]
+ self._mLastChar = aBuf[-1:]

ord() expected string of length 1, but int found

[ред.]

Ще не втомились? Ми вже майже там...

C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml                       ascii with confidence 1.0
tests\Big5\0804.blogspot.com.xml
Traceback (most recent call last):
  File "test.py", line 10, in <module>
    u.feed(line)
  File "C:\home\chardet\chardet\universaldetector.py", line 116, in feed
    if prober.feed(aBuf) == constants.eFoundIt:
  File "C:\home\chardet\chardet\charsetgroupprober.py", line 60, in feed
    st = prober.feed(aBuf)
  File "C:\home\chardet\chardet\utf8prober.py", line 53, in feed
    codingState = self._mCodingSM.next_state(c)
  File "C:\home\chardet\chardet\codingstatemachine.py", line 43, in next_state
    byteCls = self._mModel['classTable'][ord(c)]
TypeError: ord() expected string of length 1, but int found

Гаразд, c - int, але функція ord() очікувала односимвольного рядка. Це справедливо. Де описаний c?

# codingstatemachine.py
def next_state(self, c):
    # for each byte we get its class
    # if it is first byte, we also get byte length
    byteCls = self._mModel['classTable'][ord(c)]

Явно не тут, сюди він просто передається. Давайте подивимось вище в стеку.

# utf8prober.py
def feed(self, aBuf):
    for c in aBuf:
        codingState = self._mCodingSM.next_state(c)

Ви це бачите? В Python 2, aBuf був рядком, тому c був односимвольним рядком. (Це те що ви отримаєте при ітерації по рядку - всі символи, один за одним.) Але зараз, aBuf - байтовий масив, тому c - це int, а не односимвольний рядок. Іншими словами, немає потреби викликати функцію ord(), тому що c - це вже int!

Тому:

  def next_state(self, c):
      # for each byte we get its class
      # if it is first byte, we also get byte length
-     byteCls = self._mModel['classTable'][ord(c)]
+     byteCls = self._mModel['classTable'][c]

Шукаючи в коді входження "ord(c)" можна виявити подібні проблеми в sbcharsetprober.py...

# sbcharsetprober.py
def feed(self, aBuf):
    if not self._mModel['keepEnglishLetter']:
        aBuf = self.filter_without_english_letters(aBuf)
    aLen = len(aBuf)
    if not aLen:
        return self.get_state()
    for c in aBuf:
        order = self._mModel['charToOrderMap'][ord(c)]

... та в latin1prober.py ...

# latin1prober.py
def feed(self, aBuf):
    aBuf = self.filter_with_english_letters(aBuf)
    for c in aBuf:
        charClass = Latin1_CharToClass[ord(c)]

c ітерується крізь aBuf, що означає що це ціле число, а не односимвольний рядок. Рішення є таким самим - замінити ord(c) на просто c.

  # sbcharsetprober.py
  def feed(self, aBuf):
      if not self._mModel['keepEnglishLetter']:
          aBuf = self.filter_without_english_letters(aBuf)
      aLen = len(aBuf)
      if not aLen:
          return self.get_state()
      for c in aBuf:
-         order = self._mModel['charToOrderMap'][ord(c)]
+         order = self._mModel['charToOrderMap'][c]

  # latin1prober.py
  def feed(self, aBuf):
      aBuf = self.filter_with_english_letters(aBuf)
      for c in aBuf:
-         charClass = Latin1_CharToClass[ord(c)]
+         charClass = Latin1_CharToClass[c]

Unorderable types: int() >= str()

[ред.]

Давайте ще раз.

C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml                       ascii with confidence 1.0
tests\Big5\0804.blogspot.com.xml
Traceback (most recent call last):
  File "test.py", line 10, in <module>
    u.feed(line)
  File "C:\home\chardet\chardet\universaldetector.py", line 116, in feed
    if prober.feed(aBuf) == constants.eFoundIt:
  File "C:\home\chardet\chardet\charsetgroupprober.py", line 60, in feed
    st = prober.feed(aBuf)
  File "C:\home\chardet\chardet\sjisprober.py", line 68, in feed
    self._mContextAnalyzer.feed(self._mLastChar[2 - charLen :], charLen)
  File "C:\home\chardet\chardet\jpcntx.py", line 145, in feed
    order, charLen = self.get_order(aBuf[i:i+2])
  File "C:\home\chardet\chardet\jpcntx.py", line 176, in get_order
    if ((aStr[0] >= '\x81') and (aStr[0] <= '\x9F')) or \
TypeError: unorderable types: int() >= str()

Через що це? "Unorderable types"? Знову, різниця між байтовими масивами і рядками показує свою огидну голову. Подивіться на код:

class SJISContextAnalysis(JapaneseContextAnalysis):
    def get_order(self, aStr):
        if not aStr: return -1, 1
        # find out current char's byte length
        if ((aStr[0] >= '\x81') and (aStr[0] <= '\x9F')) or \
           ((aStr[0] >= '\xE0') and (aStr[0] <= '\xFC')):
            charLen = 2
        else:
            charLen = 1

А звідки з'явився aStr? Давайте піднімемось стеком:

def feed(self, aBuf, aLen):
    .
    .
    .
    i = self._mNeedToSkipCharNum
    while i < aLen:
        order, charLen = self.get_order(aBuf[i:i+2])

О, дивіться, це наш старий друг, aBuf. Як ви вже могли дізнатись при вирішенні інших проблем в цьому розділі, aBuf це масив байтів. Тут, метод feed() не просто передає його оптом, він його зрізає. Але, як ви бачили раніше в цьому розділі, зрізання байтового масиву повертає нам байтовий масив, тому параметр aStr, який передається до метода get_order() все ще є байтовим масивом.

І що цей код намагається зробити з aStr? Він бере перший елемент байтового масиву та порівнює його з рядком довжини 1. В Python 2 таке працювало, тому що aStr та aBuf були рядками, і aStr[0] був рядком, і ви могли порівняти рядки на нерівність. Але в Python 3, aStr та aBuf є байтовими рядками, aStr[0] є цілим, і ви не можете порівнювати рядки та цілі числа не привівши тип одного з них до іншого явно.

В цьому випадку немає потреби робити код більш складним додаючи явне перетворення тпиів. aStr[0] дає ціле число, а всі змінні з якими ми це число порівнюємо є константами. Давайте змінимо їх значення з односимвольних рядків на цілі числа. І поки ми тут, давайте перейменуємо aStr на aBuf, тому що вона насправді містить не рядок.

  class SJISContextAnalysis(JapaneseContextAnalysis):
-     def get_order(self, aStr):
-      if not aStr: return -1, 1
+     def get_order(self, aBuf):
+      if not aBuf: return -1, 1
          # find out current char's byte length
-         if ((aStr[0] >= '\x81') and (aStr[0] <= '\x9F')) or \
-            ((aBuf[0] >= '\xE0') and (aBuf[0] <= '\xFC')):
+         if ((aBuf[0] >= 0x81) and (aBuf[0] <= 0x9F)) or \
+            ((aBuf[0] >= 0xE0) and (aBuf[0] <= 0xFC)):
              charLen = 2
          else:
              charLen = 1

          # return its order if it is hiragana
-      if len(aStr) > 1:
-             if (aStr[0] == '\202') and \
-                (aStr[1] >= '\x9F') and \
-                (aStr[1] <= '\xF1'):
-                return ord(aStr[1]) - 0x9F, charLen
+      if len(aBuf) > 1:
+             if (aBuf[0] == 202) and \
+                (aBuf[1] >= 0x9F) and \
+                (aBuf[1] <= 0xF1):
+                return aBuf[1] - 0x9F, charLen

          return -1, charLen

  class EUCJPContextAnalysis(JapaneseContextAnalysis):
-     def get_order(self, aStr):
-      if not aStr: return -1, 1
+     def get_order(self, aBuf):
+      if not aBuf: return -1, 1
          # find out current char's byte length
-         if (aStr[0] == '\x8E') or \
-           ((aStr[0] >= '\xA1') and (aStr[0] <= '\xFE')):
+         if (aBuf[0] == 0x8E) or \
+           ((aBuf[0] >= 0xA1) and (aBuf[0] <= 0xFE)):
              charLen = 2
-         elif aStr[0] == '\x8F':
+         elif aBuf[0] == 0x8F:
              charLen = 3
          else:
              charLen = 1

        # return its order if it is hiragana
-    if len(aStr) > 1:
-           if (aStr[0] == '\xA4') and \
-              (aStr[1] >= '\xA1') and \
-              (aStr[1] <= '\xF3'):
-                 return ord(aStr[1]) - 0xA1, charLen
+    if len(aBuf) > 1:
+           if (aBuf[0] == 0xA4) and \
+              (aBuf[1] >= 0xA1) and \
+              (aBuf[1] <= 0xF3):
+               return aBuf[1] - 0xA1, charLen

        return -1, charLen

Пошук викликів функції ord() по всьому коду виявляє цю проблему в chardistribution.py (якщо точніше, то в класах EUCTWDistributionAnalysis, EUCKRDistributionAnalysis, GB2312DistributionAnalysis, Big5DistributionAnalysis, SJISDistributionAnalysis, та EUCJPDistributionAnalysis. В кожному випадку, виправлення аналогічне до зміни яку ми зробили для класів EUCJPContextAnalysis та SJISContextAnalysi в jpcntx.py.

Global name 'reduce' is not defined

[ред.]

Ще раз в пролом...

C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml                       ascii with confidence 1.0
tests\Big5\0804.blogspot.com.xml
Traceback (most recent call last):
  File "test.py", line 12, in <module>
    u.close()
  File "C:\home\chardet\chardet\universaldetector.py", line 141, in close
    proberConfidence = prober.get_confidence()
  File "C:\home\chardet\chardet\latin1prober.py", line 126, in get_confidence
    total = reduce(operator.add, self._mFreqCounter)
NameError: global name 'reduce' is not defined

Згідно з офіційним повідомленням Що нового в Python 3.0, функція reduce() була переміщена з головного простору імен в модуль functools. Цитуючи повідомлення: "Використовуйте functools.reduce() якщо вам справді це потрібно; щоправда в 99 відсотках випадків явний цикл for є більш читабельним". Ви можете прочитати більше про це рішення на блозі Ґвідо ван Россума: The fate of reduce() in Python 3000.

def get_confidence(self):
    if self.get_state() == constants.eNotMe:
        return 0.01
  
    total = reduce(operator.add, self._mFreqCounter)

Функція reduce() приймає два аргументи - функцію та список (якщо говорити точно, будь-який ітеровний об'єкт) - та застосовує функцію кумулятивно до кожного елемента списку. Іншими словами, це екстравагантний обхідний шлях додавання (чи іншої операції) всіх елементів списку та повернення результату.

Виділений вище рядок коду був настільки типовим, що в Python ввели глобальну функцію sum().


  def get_confidence(self):
      if self.get_state() == constants.eNotMe:
          return 0.01
  
-     total = reduce(operator.add, self._mFreqCounter)
+     total = sum(self._mFreqCounter)

Так як ми більше не використовуємо модуль operator, можна також видалити відповідний import на початку файлу.

  from .charsetprober import CharSetProber
  from . import constants
- import operator

А МОНА МНУ ТЕСТИ?

C:\home\chardet> python test.py tests\*\*
tests\ascii\howto.diveintomark.org.xml                       ascii with confidence 1.0
tests\Big5\0804.blogspot.com.xml                             Big5 with confidence 0.99
tests\Big5\blog.worren.net.xml                               Big5 with confidence 0.99
tests\Big5\carbonxiv.blogspot.com.xml                        Big5 with confidence 0.99
tests\Big5\catshadow.blogspot.com.xml                        Big5 with confidence 0.99
tests\Big5\coolloud.org.tw.xml                               Big5 with confidence 0.99
tests\Big5\digitalwall.com.xml                               Big5 with confidence 0.99
tests\Big5\ebao.us.xml                                       Big5 with confidence 0.99
tests\Big5\fudesign.blogspot.com.xml                         Big5 with confidence 0.99
tests\Big5\kafkatseng.blogspot.com.xml                       Big5 with confidence 0.99
tests\Big5\ke207.blogspot.com.xml                            Big5 with confidence 0.99
tests\Big5\leavesth.blogspot.com.xml                         Big5 with confidence 0.99
tests\Big5\letterlego.blogspot.com.xml                       Big5 with confidence 0.99
tests\Big5\linyijen.blogspot.com.xml                         Big5 with confidence 0.99
tests\Big5\marilynwu.blogspot.com.xml                        Big5 with confidence 0.99
tests\Big5\myblog.pchome.com.tw.xml                          Big5 with confidence 0.99
tests\Big5\oui-design.com.xml                                Big5 with confidence 0.99
tests\Big5\sanwenji.blogspot.com.xml                         Big5 with confidence 0.99
tests\Big5\sinica.edu.tw.xml                                 Big5 with confidence 0.99
tests\Big5\sylvia1976.blogspot.com.xml                       Big5 with confidence 0.99
tests\Big5\tlkkuo.blogspot.com.xml                           Big5 with confidence 0.99
tests\Big5\tw.blog.xubg.com.xml                              Big5 with confidence 0.99
tests\Big5\unoriginalblog.com.xml                            Big5 with confidence 0.99
tests\Big5\upsaid.com.xml                                    Big5 with confidence 0.99
tests\Big5\willythecop.blogspot.com.xml                      Big5 with confidence 0.99
tests\Big5\ytc.blogspot.com.xml                              Big5 with confidence 0.99
tests\EUC-JP\aivy.co.jp.xml                                  EUC-JP with confidence 0.99
tests\EUC-JP\akaname.main.jp.xml                             EUC-JP with confidence 0.99
tests\EUC-JP\arclamp.jp.xml                                  EUC-JP with confidence 0.99
.
.
.
316 tests

Чорт забирай, справді працює! Я танцюю.


* * *


Підсумок

[ред.]

Чого ми навчились?

  1. Портувати будь-який нетривіальний код з Python 2 на Python 3 буде боляче. І цього ніяк не уникнути. Це важко.
  2. Автоматизований інструмент 2to3 допомагає чим може, але він опрацьовує лише легкі частини - перейменування функцій, перейменування модулів, зміни синтаксису. Це вражаюче інженерне творіння, хоча з іншого боку - всього лише розумний інструмент пошуку та заміни.
  3. Проблемою №1 в цій бібліотеці була різниця між рядками та байтами. В даному випадку це виглядає очевидним, так як ввесь сенс бібліотеки chardet - в перетворенні потоку байтів у рядок. Але "рядок байтів" зустрічається набагато частіше ніж ви можете подумати. Читаєте файл в "двійковому" режимі? Отримаєте потік байтів. Завантажуєте веб-сторінку? Викликаєте веб API? Вони теж повертають потік байтів.
  4. Ви повинні розуміти свою програму. В деталях. Бажано - тому що ви її написали, але щонайменше - просто комфортно почуватись з всіма її примхами та затхлими кутами. Помилки повсюди.
  5. Тести суттєві. Не портуйте нічого без них. Єдине через що я впевнений що chardet працює в Python 3 є те що я почав з набору тестів які виконали всі основні частини коду. Якщо у вас немає тестів, напишіть хоч якісь перед тим як почати портувати на Python3. Якщо у вас є кілька тестів, напишіть ще. Якщо у вас є багато тестів, тоді можуть початись справжні веселощі.


Пакування бібліотек

[ред.]

Ви з'ясуєте що сором - це як біль, його відчуваєш лише раз
Маркіз де Мертеуль, Небезпечні зв'язки


Справжні художники продають. Принаймі так каже Стів Джобс. Хочете випустити скрипт, бібліотеку чи програму? Чудово. Світ потребує більше коду Python. Python 3 розповсюджується разом з фреймворком для створення пакетів який називається Distutils. Distutils це багато речей: інструмент для побудови проекту (для вас), інструмент для встановлення (для ваших користувачів), формат метаданих пакету (для пошукових програм), і багато іншого. Він інтегрується з Python Package Index ("PyPI") - центральним репозиторієм для бібліотек Python з відкритим кодом.

Всі ці грані Distutils зосереджуються навколо скрипта встановлення, який традиційно називається setup.py. Насправді ви вже могли бачити кілька скриптів встановлення Distutils протягом цієї книги. Ви використовували Distutils для того щоб встановити httplib2 в розділі веб-сервіси HTTP а також щоб встановити chardet в розділі Перенесення chardet на Python3.

В цьому розділі ви дізнаєтесь як працює скрипт встановлення для chardet та httplib2, та пройдемо процес випуску власного програмного забезпечення на мові Python.

# chardet's setup.py
from distutils.core import setup
setup(
    name = "chardet",
    packages = ["chardet"],
    version = "1.0.2",
    description = "Universal encoding detector",
    author = "Mark Pilgrim",
    author_email = "mark@diveintomark.org",
    url = "http://chardet.feedparser.org/",
    download_url = "http://chardet.feedparser.org/download/python3-chardet-1.0.1.tgz",
    keywords = ["encoding", "i18n", "xml"],
    classifiers = [
        "Programming Language :: Python",
        "Programming Language :: Python :: 3",
        "Development Status :: 4 - Beta",
        "Environment :: Other Environment",
        "Intended Audience :: Developers",
        "License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)",
        "Operating System :: OS Independent",
        "Topic :: Software Development :: Libraries :: Python Modules",
        "Topic :: Text Processing :: Linguistic",
        ],
    long_description = """\
Universal character encoding detector
-------------------------------------

Detects
 - ASCII, UTF-8, UTF-16 (2 variants), UTF-32 (4 variants)
 - Big5, GB2312, EUC-TW, HZ-GB-2312, ISO-2022-CN (Traditional and Simplified Chinese)
 - EUC-JP, SHIFT_JIS, ISO-2022-JP (Japanese)
 - EUC-KR, ISO-2022-KR (Korean)
 - KOI8-R, MacCyrillic, IBM855, IBM866, ISO-8859-5, windows-1251 (Cyrillic)
 - ISO-8859-2, windows-1250 (Hungarian)
 - ISO-8859-5, windows-1251 (Bulgarian)
 - windows-1252 (English)
 - ISO-8859-7, windows-1253 (Greek)
 - ISO-8859-8, windows-1255 (Visual and Logical Hebrew)
 - TIS-620 (Thai)

This version requires Python 3 or later; a Python 2 version is available separately.
"""
)

chardet та httplib2 поширюються з відкритим кодом, проте немає вимоги поширювати ваше програмне забезпечення під певною ліцензією. Процес описаний в даному розділі працюватиме для будь-якого програмного забезпечення, незалежно від ліцензії.


* * *


Речі які Distutils не зможе зробити за вас

[ред.]

Випуск вашого пакета мови Python - це гнітючий процес. (Випуск другого трішки легший.) Distutils намагається автоматизувати стільки скільки може, але є деякі речі які ви просто повинні зробити самостійно.

  • Обрати ліцензію. Це складна тема, насичена політикою та небезпеками. Якщо бажаєте випустити своє програмне забезпечення з відкритим кодом, я скромно дам вам п'ять порад:
    1. Не пишіть власну ліцензію.
    2. Не пишіть власну ліцензію.
    3. Не пишіть власну ліцензію.
    4. Це не обов'язково повинна бути GPL, але обов'язково повинна бути GPL-сумісна
    5. Не пишіть власну ліцензію.
  • Класифікуйте своє програмне забезпечення використавши систему класифікації PyPi. Я поясню що це означає пізніше в даному розділі.
  • Напишіть файл "README". Не пропускайте це. Щонайменше, він повинен давати вашим користувачам огляд того що ваше програмне забезпечення робить, і як його встановити.


* * *


Структура каталогів

[ред.]

Щоб почати пакувати своє програмне забезпечення, потрібно впорядкувати свої файли та каталоги. Каталог з httplib виглядає так:

httplib2/                 ①
|
+--README.txt             ②
|
+--setup.py               ③
|
+--httplib2/              ④
   |
   +--__init__.py
   |
   +--iri2uri.py

① Створіть кореневий каталог що міститиме все решта. Дайте йому таке ж ім'я як ім'я вашого модуля.

② Щоб пристосуватись до користувачів Windows, ваш файл "read me" повинен мати розширення .txt, та використовувати символи повернення каретки в стилі Windows. Тільки тому що ви використовуєте вишуканий текстовий редактор що запускається з командного рядка та має власну мову макросів, не варто робити життя складним для ваших користувачів. (Ваші користувачі використовутимуть "Блокнот". Сумно, але правда). Навіть якщо ви користуєтесь Linux чи Mac OS, ваш сучасний текстовий редактор повинен мати опцію для зберігання текстових файлів з розділювачами рядків в Windows-стилі.

③ Ваш скрипт Distutils повинен називатись setup.py, якщо звісно ви не маєте вагомої причини називати його інакше. Але в вас немає вагомої причини.

④ Якщо ваше програмне забезпечення поміщується в єдиному .py файлі, потрібно помістити його в кореневу директорію разом з файлом "read me" та скриптом встановлення. Але httplib2 - не єдиний файл .py, це багатофайловий модуль. Але це нормально! Просто покладіть в кореневий каталог каталог httplib2, так щоб файл __init__.py містився в каталозі httplib2 який сам міститься в каталозі httplib2. Це не проблема, насправді це навіть спростить ваш процес пакування.


Директорія chardet виглядає дещо по іншому. Як і в httplib2 це багатофайловий модуль, тому всередині каталогу chardet є ще одна директорія chardet. На додачу до файлу README.txt, chardet містить HTML-документацію в каталозі docs/. Директорія docs/ містить кілька .html та .css та піддиректорію images/, з кількома файлами .png та .gif. (Це буде важливо пізніше). Також, для дотримання правил (L)GPL-ліцензованого програмного забезпечення, в окремому файлі що називається COPYING.txt міститься повний текст ліцензії.

chardet/
|
+--COPYING.txt
|
+--setup.py
|
+--README.txt
|
+--docs/
|  |
|  +--index.html
|  |
|  +--usage.html
|  |
|  +--images/ ...
|
+--chardet/
   |
   +--__init__.py
   |
   +--big5freq.py
   |
   +--...


* * *


Написання власного встановлювального скрипта

[ред.]

Скрипт Distutils є Python-скриптом. В теорії, він може все що може Python. На практиці, він повинен робити настільки мало, настільки можливо, в настільки стандартний спосіб наскільки можливо. Встановлювальні скрипти повинні бути нудними. Чим екзотичнішим є ваш процес інсталяції, тим більш екзотичними будуть повідомлення про помилки.

Перший рядок кожного скрипта Distutils завжди однаковий:

from distutils.core import setup

Це імпортує функцію setup(), яка є головною точкою входу в Distutils. 95% всіх скриптів Distutils складаються з виклику функції setup() і нічого більше. (Я просто вигадав статистику, але якщо ваш встановлювальний скрипт робить щось більше ніж викликає функцію setup(), ви повинні мати гарний привід. У вас є гарний привід? Я так не думаю.)

Функція setup може приймати десятки параметрів. Для того щоб ті хто його читатиме не з'їхали з глузду, ви повинні використовувати іменовані аргументи для кожного параметру. Це не звичай, це жорстка вимога. Ваш встановлювальний скрипт поламається якщо ви спробуєте викликати функцію setup() з неіменованими аргументами.

Наступні іменовані аргументи є обов'язковими:

  • name - назва пакета
  • version - номер версії пакета
  • author - ваше ім'я
  • author_email - адреса вашої електронної поштової скриньки
  • url - домашня сторінка проекту. Це може бути сторінка пакету на PyPI якщо в вас немає окремого сайту проекту.

Я також рекомендую додати в свій встановлювальний скрипт наступне:

  • description - однорядковий опис проекту.
  • long_description - багаторядковий рядок в форматі reStructuredText. PyPI перетворить його в HTML, та відобразить на сторінці пакету.
  • classifiers - список по особливому відформатованих рядків описаних в наступному розділі.

метадані встановлювального скрипта описуються в PEP 314.

Тепер давайте глянемо на скрипт встановлення chardet. Він має всі обов'язкові і рекомендовані параметри, плюс один який я поки що не згадував: packages.

from distutils.core import setup
setup(
    name = 'chardet',
    packages = ['chardet'],
    version = '1.0.2',
    description = 'Universal encoding detector',
    author='Mark Pilgrim',
    ...
)

Параметр packages показує невдале перевикористання термінів в системі поширення коду. Ми говоримо про пакет як про штуку яку ви створюєте (і потенційно публікуєте в індексі пакетів Python). Але це не те що описує параметр packages. Він стосується того факту що chardet це багатофайловий модуль, часом відомий як... "пакет". Параметр packages каже Distutils включити в пакет директорію chardet/, її файл __init__.py та інші файли .py що складають модуль chardet. Це дещо важливо, так як всі ці веселі розмови про метадані та документацію не мають сенсу якщо ви забудете додати сам код!


* * *


Класифікація вашого пакета

[ред.]

Індекс пакетів Python ("PyPI") містить тисячі бібліотек Python. Правильні метадані класифікації дозволять людям знайти ваш пакет швидше. PyPI дозволяє вам переглядати пакети за класами. Можна навіть вибрати кілька класифікаторів для того щоб звузити пошук. Класифікатори не є невидимими метаданими які ви можете просто проігнорувати!

Щоб класифікувати ваше програмне забезпечення передайте параметр classify у функцію setup(). Цей параметр є списком рядків. Ці рядки не довільні. Всі рядки класифікації повинні братись із цього списку на PyPI.

Класифікатори не обов'язкові. Можна написати скрипт встановлення Distutils без жодних класифікаторів взагалі. Не робіть цього. Ви завжди повинні включати принаймі наступні класифікатори:

  • Мова програмування. Особливо, ви повинні включати як і "Programming language :: Python" так і "Programming language :: Python :: 3". Якщо ви не включите їх, ваш пакет не буде поміщено в цьому списку бібліотек сумісних з Python 3, на який посилається бічне меню кожної сторінки на pypi.python.org.
  • Ліцензія. Це найперше на що я дивлюсь оцінюючи сторонню бібліотеку. Не змушуйте мене полювати за цією життєвоважливою інформацією. Не включайте більш ніж один класифікатор ліцензії, якщо ваш код не є явно доступним під кількома ліцензіями одночасно. (І не випускайте програмне забезпечення під кількома ліцензіями, якщо вас не змушують цього робити. І не змушуйте інших людей так робити. Ліцензування це й без того геморой, не робіть його гіршим.)
  • Операційна система. Якщо ваше програмне забезпечення працює тільки під Windows (чи MacOS чи Linux), мені краще знати про це якомога раніше. Якщо ваше програмне забезпечення повинно працювати всюди і не покладається на який-небудь платформо-залежний код, використайте класифікатор "Operating System :: OS Independent". Кілька класифікаторів Operating System необхідні лише тоді коли ваше програмне забезпечення вимагатиме підтримки для кожної платформи. (Це не типово.)

Також рекомендую додати наступні класифікатори:

  • Development Status. Ваше програмне забезпечення знаходиться в бета версії? Альфа версії? Гірше за альфу? Виберіть. Будьте чесними.
  • Intended Audience. Кому варто завантажувати ваше програмне забезпечення? Типовими варіантами є Developers, End Users/Desktop, Science/Research та System Administrators.
  • Topic. Є купа різних тематик, виберіть ту яка підходить.

Приклади гарних класифікаторів пакетів

[ред.]

Заради прикладу наведу тут класифікатори для Django - готового до використання, кросплатформенного веб-фреймворка ліцензованого під BSD. (Django поки що не сумісний з Python 3, тому класифікатор Programming Language :: Python :: 3 не перелічується.)

Programming Language :: Python
License :: OSI Approved :: BSD License
Operating System :: OS Independent
Development Status :: 5 - Production/Stable
Environment :: Web Environment
Framework :: Django
Intended Audience :: Developers
Topic :: Internet :: WWW/HTTP
Topic :: Internet :: WWW/HTTP :: Dynamic Content
Topic :: Internet :: WWW/HTTP :: WSGI
Topic :: Software Development :: Libraries :: Python Modules

А ось класифікатори для chardet, бібліотека для визначення кодування символів, яка описується в розділі Перенесення chardet на Python 3. chardet це бета-версія, кросплатформенного, сумісного з Python 3, LGPL-ліцензованого, коду для використання розробниками в своїх власних продуктах.

Programming Language :: Python
Programming Language :: Python :: 3
License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)
Operating System :: OS Independent
Development Status :: 4 - Beta
Environment :: Other Environment
Intended Audience :: Developers
Topic :: Text Processing :: Linguistic
Topic :: Software Development :: Libraries :: Python Modules

А ось класифікатори для httplib2, бібліотеки описаної в розділі Веб-сервіси HTTP. httplib2 це бета-версія кросплатформенної бібліотеки під ліцензією MIT, яка призначена для розробників.

Programming Language :: Python
Programming Language :: Python :: 3
License :: OSI Approved :: MIT License
Operating System :: OS Independent
Development Status :: 4 - Beta
Environment :: Web Environment
Intended Audience :: Developers
Topic :: Internet :: WWW/HTTP
Topic :: Software Development :: Libraries :: Python Modules

Опис додаткових файлів маніфестом

[ред.]

За замовчуванням, Distutils додасть наступні файли до вихідного пакету:

  • README.txt
  • setup.py
  • Файли .py які потрібні багатофайловим модулям переліченим в параметрі packages.
  • Окремі файли .py перелічені в параметрі py_modules.

Це включить всі файли в проекті httplib2. Але в проекті chardet ми також хочемо включити файл COPYING.txt та ввесь каталог docs/ що містить зображення та файли HTML. Для того щоб сказати Distutils включити ці додаткові файли та директорії коли він створить готовий пакет chardet, вам потрібен файл-маніфест.

Файл-маніфест це текстовий файл що називається MANIFEST.in. Покладіть його в кореневий каталог пакета, поруч з README.txt та setup.py. Файли маніфесту не є скриптами Python, це звичайні текстові файли які містять послідовності "команд" в форматі описаному Distutils. Команди маніфесту дозволяють вам включати чи виключати певні файли та каталоги.

Це ввесь маніфест для проекту chardet:

include COPYING.txt
recursive-include docs *.html *.css *.png *.gif

Перший рядок самозрозумілий: включити файл COPYING.txt з кореневого каталогу.

Другий рядок дещо складніший. Команда recursive-include приймає ім'я каталогу та одне чи більше імен файлів. Імена файлів не обмежуються іменами конкретних файлів, це можуть бути шаблони. Цей рядок означає "Бачиш каталог docs/ в корені проекту? Переглянь його (рекурсивно) на наявність файлів .html, .css, .png та .gif. Я хочу щоб вони всі попали в кінцевий пакет".

Всі команди маніфесту зберігають структуру каталогів яку ви створили всередині проекту. Команда recursive-include не буде копіювати всі файли .html та .png в одну купу всередині одного каталогу в пакеті. Вона збереже існуюче всередині docs/ дерево каталогів, просто скопіює лише ті файли, які відповідають іменам переліченим у переданих параметрах. (Я цього раніше не згадував, але документація до chardet насправді написана в XML та перетворюється в HTML окремим скриптом. Я не хочу включати файли XML у вміст пакету, лише результуючий HTML та зображення.

Файли маніфести мають свій власний формат. Дивіться Specifying the files to distribute та the manifest template commands для деталей.

Для повторення: вам потрібно створити файл маніфесту лише якщо хочете включити файли які Distutils не включив за замовчуванням. Якщо вам не потрібен файл маніфесту, він повинен включати лише файли і директорії які Distutils інакше не знайде самостійно.

Перевірка вашого скрипта встановлення на помилки

[ред.]

Є багато речей за якими потрібно стежити. Distutils розповсюджується з вбудованою командою валідації яка перевіряє що всі необхідні метадані присутні в вашому скрипті встановлення. Наприклад якщо ви забудете включити параметр version, Distutils вам нагадає.

c:\Users\pilgrim\chardet> c:\python31\python.exe setup.py check
running check
warning: check: missing required meta-data: version

Як тільки ви додасте параметр version (і всі інші потрібні частини метаданих), команда check працюватиме так:

c:\Users\pilgrim\chardet> c:\python31\python.exe setup.py check
running check


* * *


Створення дистрибутиву з кодом

[ред.]

Distutils підтримують побудову різних типів вихідних пакетів. Як мінімум ви повинні побудувати "дистрибутив з кодом" який міститиме ваш код, ваш скрипт встановлення, ваш файл "read me", і будь-які додаткові файли які ви захочете включити. Для того щоб створити дистрибутив з кодом передайте команду sdist у ваш скрипт встановлення Distutils.

c:\Users\pilgrim\chardet> c:\python31\python.exe setup.py sdist
running sdist
running check
reading manifest template 'MANIFEST.in'
writing manifest file 'MANIFEST'
creating chardet-1.0.2
creating chardet-1.0.2\chardet
creating chardet-1.0.2\docs
creating chardet-1.0.2\docs\images
copying files to chardet-1.0.2...
copying COPYING -> chardet-1.0.2
copying README.txt -> chardet-1.0.2
copying setup.py -> chardet-1.0.2
copying chardet\__init__.py -> chardet-1.0.2\chardet
copying chardet\big5freq.py -> chardet-1.0.2\chardet
...
copying chardet\universaldetector.py -> chardet-1.0.2\chardet
copying chardet\utf8prober.py -> chardet-1.0.2\chardet
copying docs\faq.html -> chardet-1.0.2\docs
copying docs\history.html -> chardet-1.0.2\docs
copying docs\how-it-works.html -> chardet-1.0.2\docs
copying docs\index.html -> chardet-1.0.2\docs
copying docs\license.html -> chardet-1.0.2\docs
copying docs\supported-encodings.html -> chardet-1.0.2\docs
copying docs\usage.html -> chardet-1.0.2\docs
copying docs\images\caution.png -> chardet-1.0.2\docs\images
copying docs\images\important.png -> chardet-1.0.2\docs\images
copying docs\images\note.png -> chardet-1.0.2\docs\images
copying docs\images\permalink.gif -> chardet-1.0.2\docs\images
copying docs\images\tip.png -> chardet-1.0.2\docs\images
copying docs\images\warning.png -> chardet-1.0.2\docs\images
creating dist
creating 'dist\chardet-1.0.2.zip' and
adding 'chardet-1.0.2' to it
adding 'chardet-1.0.2\COPYING'
adding 'chardet-1.0.2\PKG-INFO'
adding 'chardet-1.0.2\README.txt'
adding 'chardet-1.0.2\setup.py'
adding 'chardet-1.0.2\chardet\big5freq.py'
adding 'chardet-1.0.2\chardet\big5prober.py' ...
adding 'chardet-1.0.2\chardet\universaldetector.py'
adding 'chardet-1.0.2\chardet\utf8prober.py'
adding 'chardet-1.0.2\chardet\__init__.py'
adding 'chardet-1.0.2\docs\faq.html'
adding 'chardet-1.0.2\docs\history.html'
adding 'chardet-1.0.2\docs\how-it-works.html'
adding 'chardet-1.0.2\docs\index.html'
adding 'chardet-1.0.2\docs\license.html'
adding 'chardet-1.0.2\docs\supported-encodings.html'
adding 'chardet-1.0.2\docs\usage.html'
adding 'chardet-1.0.2\docs\images\caution.png'
adding 'chardet-1.0.2\docs\images\important.png'
adding 'chardet-1.0.2\docs\images\note.png'
adding 'chardet-1.0.2\docs\images\permalink.gif'
adding 'chardet-1.0.2\docs\images\tip.png'
adding 'chardet-1.0.2\docs\images\warning.png'
removing 'chardet-1.0.2' (and everything under it)

Кілька речей які потрібно зауважити:

  • Distutils побачили маніфест (MANIFEST.in).
  • Distutils успішно прочитали маніфест і додали файли які ми хотіли: COPYING.txt та HTML і картинки з каталогу docs/.
  • Якщо ви подивитесь в каталог свого проекту ви побачите що Distutils створила директорію dist/. Всередині dist/ знаходиться .zip-файл який ви можете розповсюджувати.


* * *


Створення графічного інсталятора

[ред.]

На мою думку, кожна бібліотека мови Python заслуговує на графічний інсталятор для користувачів Windows. Його просто зробити (навіть якщо ви самі не використовуєте Windows), і користувачі Windows це цінуватимуть.

Distutils можуть створити графічний інсталятор для вас, якщо їм передати команду bdist_wininst через скрипт setup.py.

c:\Users\pilgrim\chardet> c:\python31\python.exe setup.py bdist_wininst
running bdist_wininst
running build
running build_py
creating build
creating build\lib
creating build\lib\chardet
copying chardet\big5freq.py -> build\lib\chardet
copying chardet\big5prober.py -> build\lib\chardet
...
copying chardet\universaldetector.py -> build\lib\chardet
copying chardet\utf8prober.py -> build\lib\chardet
copying chardet\__init__.py -> build\lib\chardet
installing to build\bdist.win32\wininst
running install_lib
creating build\bdist.win32
creating build\bdist.win32\wininst
creating build\bdist.win32\wininst\PURELIB
creating build\bdist.win32\wininst\PURELIB\chardet
copying build\lib\chardet\big5freq.py -> build\bdist.win32\wininst\PURELIB\chardet
copying build\lib\chardet\big5prober.py -> build\bdist.win32\wininst\PURELIB\chardet
...
copying build\lib\chardet\universaldetector.py -> build\bdist.win32\wininst\PURELIB\chardet
copying build\lib\chardet\utf8prober.py -> build\bdist.win32\wininst\PURELIB\chardet
copying build\lib\chardet\__init__.py -> build\bdist.win32\wininst\PURELIB\chardet
running install_egg_info
Writing build\bdist.win32\wininst\PURELIB\chardet-1.0.2-py3.1.egg-info
creating 'c:\users\pilgrim\appdata\local\temp\tmp2f4h7e.zip' and adding '.' to it
adding 'PURELIB\chardet-1.0.2-py3.1.egg-info'
adding 'PURELIB\chardet\big5freq.py'
adding 'PURELIB\chardet\big5prober.py'
...
adding 'PURELIB\chardet\universaldetector.py'
adding 'PURELIB\chardet\utf8prober.py'
adding 'PURELIB\chardet\__init__.py'
removing 'build\bdist.win32\wininst' (and everything under it)

Побудова самовстановлювальних пакетів для інших операційних систем

[ред.]

Distutils можуть допомогти побудувати встановлювальні пакети для користувачів Linux. На мою думку, це напевне не варто вашого часу. Якщо ви хочете щоб ваше програмне забезпечення поширювалось на Linux, краще використати ваш час працюючи з членами спільноти яка спеціалізується в пакуванні програмного забезпечення для основних дистрибутивів Linux.

Наприклад, моя бібліотека chardet присутня в репозиторіях Debian GNU/Linux (а тому і в репозиторіях Ubuntu також). Я ніяк з цим не пов'язаний, одного дня ці пакети там просто лише з'явились. Спільнота Debian має власні правила для пакування бібліотек Python, і пакет python-chardet зроблений у відповідності до цих правил. А так як пакет знаходиться в репозиторіях Debian, користувачі цього дистрибутиву будуть отримувати оновлення безпеки та нові версії залежно від обраних ними налаштувань системи.

Пакети Linux які створюються за допомогою Distutils не мають таких переваг. Краще витратити свій час де-інде.


* * *



Додавання вашого програмного забезпечення до Python Package Index

[ред.]

Завантаження програмного забезпечення в Python Package Index це трикроковий процес.

  1. Зареєструйтесь самі.
  2. Зареєструйте своє програмне забезпечення.
  3. Завантажте пакет який ви створили з setup.py sdist та setup.py bdist_*.

Для того щоб зареєструватись, перейдіть на сторінку реєстрації PyPI. Введіть обраний логін та пароль, надайте дійсну адресу email, і натисніть кнопку Register. (Якщо в вас є ключ PGP чи GPG, можете також надати їх. Якщо ви їх не маєте, чи не знаєте що це означає, не переживайте). Перевірте свій email, через кілька хвилин ви повинні отримати повідомлення від PyPI з посиланням для підтвердження. Натисніть посилання для того щоб завершити процес реєстрації.

Тепер потрібно зареєструвати своє програмне забезпечення і завантажити його на PyPI. Це все можна зробити за один крок.

c:\Users\pilgrim\chardet> c:\python31\python.exe setup.py register sdist bdist_wininst upload ①
running register
We need to know who you are, so please choose either:
1. use your existing login,
2. register as a new user,
3. have the server generate a new password for you (and email it to you), or
4. quit
Your selection [default 1]: 1 ②
Username: MarkPilgrim ③
Password:
Registering chardet to http://pypi.python.org/pypi ④
Server response (200): OK
running sdist ⑤
... вивід обрізаний ...
running bdist_wininst ⑥
... output trimmed for brevity ...
running upload ⑦
Submitting dist\chardet-1.0.2.zip to http://pypi.python.org/pypi
Server response (200): OK
Submitting dist\chardet-1.0.2.win32.exe to http://pypi.python.org/pypi
Server response (200):
OK I can store your PyPI login so future submissions will be faster.
(the login will be stored in c:\home\.pypirc)
Save your login (y/N)?n ⑧

① Коли ви вперше випускаєте свій проект, Distutils додасть ваше програмне забезпечення до індексу пакетів, і дасть йому власне URL. Щоразу після цього, він просто оновлюватиме метадані проекту з будь-якими змінами що ви зробите в параметрах у setup.py. Потім він створює дистрибутив з кодом (sdist) та інсталятор для Windows (bdist_wininst), після чого завантажує їх до PyPI (upload).


② Натисніть 1, чи просто натисність ENTER щоб вибрати "використати існуючий логін".

③ Введіть логін і пароль який ви вибрати на сторінці регістрації PyPI. Distutils не буде виводити ваш пароль, і навіть не буде виводити зірочки замість символів. Просто введіть пароль і натисніть Enter.

④ Distutils зареєструє ваш пакет у Python Package Index

⑤ ... збудує дистрибутив з кодом ...

⑥ ... а також інсталятор для Windows ...

⑦ ... та завантажить їх обох в індекс пакетів Python ...

⑧ якщо ви хочете автоматизувати процес випуску нових версій, потрібно зберегти свій логін та пароль до PyPI в локальному файлі. Це дуже небезпечно, і зовсім необов'язково.

Вітання, тепер у вас є власна сторінка на Python Package Index! Адреса має вигляд http://pypi.python.org/pypi/NAME, де NAME - рядок який ви передали в параметр name у вашому файлі setup.py.

Якщо ви хочете випустити нову версію, просто оновіть номер версії в setup.py і знову запустіть команду завантаження:

c:\Users\pilgrim\chardet> c:\python31\python.exe setup.py register sdist bdist_wininst upload


* * *


Різні варіанти майбутнього системи пакетів Python

[ред.]

Distutils це не останнє і не все що є у світі керування пакетами в Python, але на час написання цього (Серпень 2009), це єдиний фреймворк пакетів який працює з Python 3. Існує кілька інших фреймворків для Python 2; деякі фокусуються на інсталяції, інші на тестуванні і розгортанні. Деякі з них можливо будуть перенесеними на Python 3 в майбутньому.

Ось ці фреймворки фокусуються на інсталяції:

А ці фокусуються на тестуванні та розгортанні:

Для подальшого читання

[ред.]


Імена магічних методів

[ред.]

Моя професія - бути правим, коли всі інші помиляються.
Бернард Шоу


Іноді в цій книжці ви бачили приклади "спеціальних методів" - деяких "магічних методів" які Python викликає коли ви використовуєте спеціальний синтаксис. Використовуючи ці спеціальні методи ваші класи можуть поводитись як словники, як функції, як ітератори, чи навіть як числа. Цей додаток слугує одночасно і як довідник по тих методах які ми вже розглядали, і як короткий вступ до більш езотеричних.

Базові

[ред.]

Якщо ви читали вступ до класів, ви вже знайомі з найбільш поширеним магічним методом - __init__(). Більшість класів які я писав потребували якоїсь ініціалізації. Також є кілька базових магічних методів які особливо корисні при зневадженні ваших класів.

Ви хочете... Тому ви пишете... І Python виконує...
Ініціалізувати екземпляр
x = MyClass()
x.__init__()

Метод __init__() викликається після створення екземпляра. Якщо ви хочете конролювати сам процес створення екземпляра, використайте метод __new__().

Ви хочете... Тому ви пишете... І Python виконує...
"офіційне" представлення у вигляді рядка
repr(x)
x.__repr__()

За домовленістю, __repr__() повинен повертати рядок, який є правильним виразом Python.

Ви хочете... Тому ви пишете... І Python виконує...
"неофіційне" представлення у вигляді рядка
str(x)
x.__str__()

Метод __str__() також викликається коли ви виконуєте print(x).

Ви хочете... Тому ви пишете... І Python виконує...
"неофіційне" представлення у вигляді масиву байт
bytes(x)
x.__bytes__()

А цей магічний метод з’явився в Python 3 разом з типом bytes.

Ви хочете... Тому ви пишете... І Python виконує...
значення як форматований рядок
format(x, format_spec)
x.__format__(format_spec)

За замовчуванням, format_spec повинен відповідати мінімові специфікації формату. decimal.py в стандартній бібліотеці мови Python надає власний метод __format__().

Класи що поводяться як ітератори

[ред.]

У розділі про ітератори ви читали про те як побудувати ітератор з нуля за допомогою методів __iter__() та __next__().

Ви хочете... Тому ви пишете... І Python виконує...
Ітеруватись через послідовність
iter(seq)
seq.__iter__()

Метод __iter__ викликається щоразу як ви створюєте новий ітератор. Це гарне місце для того, щоб ініціалізувати ітератор з початковими значеннями.

Ви хочете... Тому ви пишете... І Python виконує...
отримати наступне значення з ітератора
next(seq)
seq.__next__()

Метод __next__() викликається щоразу як ви отримуєте наступне значення з ітератора.

Ви хочете... Тому ви пишете... І Python виконує...
Створити обернений ітератор
reversed(seq)
seq.__reversed__()

Метод __reversed__ не дуже часто зустрічається. Він приймає існуючу послідовність та повертає ітератор, який видає її елементи в зворотньому порядку, від останнього до першого.

Як ви бачили в розділі про ітератори, цикл for може працювати над ітератором. В такому циклі:

for x in seq:
    print(x)

Python 3 викличе seq.__iter__() щоб створити ітератор, потім метод __next__() щоб отримати кожне із значень. Коли метод __next__() згенерує виняток StopIteration, цикл for елегантно завершиться.

Обчислювані атрибути

[ред.]
Ви хочете... Тому ви пишете... І Python виконує...
Отримати обчислений атрибут (безумовно)
x.my_attr
x.__getattribute__('my_attr')

Якщо ваш клас описує метод __getattribute__() Python викликатиме його при кожному звертанні до будь-якого атрибуту чи методу (окрім спеціальних методів, тому що це може створити негарний нескінченний цикл).

Ви хочете... Тому ви пишете... І Python виконує...
Отримати обчислений атрибут (якщо інших не знайдеться)
x.my_attr
x.__getattr__('my_attr')

Якщо клас описує метод __getattr__(), Python викличе його лише після пошуку атрибута в усіх нормальних місцях. Якщо екземпляр x описує атрибут color, x.color не викличе x.__getattr__('color'); він просто поверне вже задане значення x.color.

Ви хочете... Тому ви пишете... І Python виконує...
Задати значення атрибута
x.my_attr = value
x.__setattr__('my_attr', value)

Метод __setattr__ викликається щоразу як ви пробуєте задати значення атрибута.

Ви хочете... Тому ви пишете... І Python виконує...
Видалити атрибут
del x.my_attr
x.__delattr__('my_attr')

Метод __delattr__() викликається щоразу як ви видаляєте атрибут.

Ви хочете... Тому ви пишете... І Python виконує...
Перелічити всі атрибути та методи
dir(x)
x.__dir__()

Метод __dir__() корисний коли ви описуєте методи __getattr__() чи __getattribute__(). Зазвичай, виклик dir(x) дає перелік лише звичайних атрибутів та методів. Якщо ваш метод __getattr__() працює з атрибутом color динамічно, dir(x) не перелічуватиме color серед доступних атрибутів. Перезадання методу __dir__() дозволяє вам додати color до списку доступних атрибутів, що буде корисно для інших людей, які схочуть користуватись вашим класом не вникаючи в його нутрощі.

Різниця між методами __getattr__() та __getattribute__() тонка але важлива. Я можу пояснити її двома прикладами:

class Dynamo:
    def __getattr__(self, key):
        if key == 'color':
            return 'PapayaWhip'
        else:
            raise AttributeError

Ім’я атрибуту передається в метод __getattr__() як рядок. Якщо ім’я - 'color', метод повертає значення. (В даному випадку це жорстко заданий рядок, але ви зазвичайн зробите певні обчислення та повернете результат.)

Якщо ім’я атрибуту невідоме, метод __getattr__() повинен згенерувати виняток AttributeError, інакше ваш код працюватиме неправильно. Технічно, якщо метод не генерує виняток, чи явно повертає якесь значення, він повертає None. Це означає що всі атрибути які не задані явно матимуть значення None, а це майже напевне не те що вам потрібно.

>>> dyn = Dynamo()
>>> dyn.color
'PapayaWhip'

Екземпляр dyn не має атрибуту з назвою color, тому викликається метод __getattr__() який обчислює необхідне значення атрибуту.

>>> dyn.color = 'LemonChiffon'
>>> dyn.color
'LemonChiffon'

Після явного задання dyn.color, метод __getattr__() вже не викликається щоб надати значення dyn.color, тому що dyn.color вже заданий в екземплярі.

А метод __getattribute__() - навпаки, абсолютний та безумовний.

class SuperDynamo:
    def __getattribute__(self, key):
        if key == 'color':
            return 'PapayaWhip'
        else:
            raise AttributeError

>>> dyn = SuperDynamo()
>>> dyn.color
'PapayaWhip'

Метод __getattribute__() викликається щоб повернути значення dyn.color.

>>> dyn.color = 'LemonChiffon'
>>> dyn.color
'PapayaWhip'

І навіть після того як ми явно задали значення dyn.color, метод __getattribute__() все ще викликається щоб надати значення для dyn.color. Якщо присутній, метод __getattribute__() викликається безумовно для кожного атрибута, навіть якщо ми явно задали його після створення екземпляра.

Якщо ваш клас описує метод __getattribute__(), вам напевне варто також описати метод __setattr__(), та скоординувати їх роботу так аби стежити за значеннями атрибутів. Інакше, будь-які атрибути які ви задаєте після створення екземпляру пропадуть в чорній дірі.

Потрібно бути особливо обережним з методом __getattribute__(), тому що він також викликається коли Python шукає методи вашого класу.

class Rastan:
    def __getattribute__(self, key):
        raise AttributeError
    def swim(self):
        pass

Цей клас описує метод __getattribute__(), який завжди генерує виняток AttributeError. Жоден пошук атрибуту чи методу не буде успішним.

>>> hero = Rastan()
>>> hero.swim()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in __getattribute__
AttributeError

Коли ви викликаєте hero.swim(), Python шукає метод swim() в класі Rastan. Цей пошук йде крізь метод __getattribute__(), тому що всі атрибути та методи шукаються з його допомогою. В нашому випадку __getattribute__() генерує виняток AttributeError тому пошук метода не вдається, і його виклик відповідно теж.

Класи що поводяться як функції

[ред.]

Можна зробити так щоб екземпляр класу можна було викликати як функцію - описавши метод __call__

Ви хочете... Тому ви пишете... І Python виконує...
викликати об’єкт як функцію
my_instance()
my_instance.__call__()

Модуль zipfile використовує це щоб описати клас що може розшифрувати зашифрований zip-файл переданим йому паролем. Алгоритм шифрування в файлах zip потребує зберігати стан протягом розшифрування. Опис розшифровщика як класу дозволяє зберігати стан в екземплярах цього класу. Стан ініціалізується в методі __init__() і змінюється протягом розшифровки. Але так як такий клас можна викликати як функцію, можна передавати його екземпляр першим аргументом в функцію map(), наприклад так як в наступному прикладі:

# шматочок файла zipfile.py
class _ZipDecrypter:
.
.
.
    def __init__(self, pwd):
        self.key0 = 305419896
        self.key1 = 591751049
        self.key2 = 878082192
        for p in pwd:
            self._UpdateKeys(p)

Клас _ZipDecryptor зберігає стан в формі трьох ключів, які пізніше оновлюються в методі _UpdateKeys() (тут не показаний).

    def __call__(self, c):
        assert isinstance(c, int)
        k = self.key2 | 2
        c = c ^ (((k * (k^1)) >> 8) & 255)
        self._UpdateKeys(c)
        return c

Клас описує метод __call__(), який дозволяє викликати екземпляри класу як функції. В нашому випадку метод __call__() розшифровує один байт zip-файлу, після чого оновлює ключі способом що залежить від розшифрованого байта.

zd = _ZipDecrypter(pwd)
bytes = zef_file.read(12)

zd - екземпляр класу _ZipDecryptor. Змінна pwd передається в метод __init__(), де вона зберігається та використовується для першого оновлення ключів.

h = list(map(zd, bytes[0:12]))

Маючи перших 12 байт zip-файла, ми розшифровуємо їх map-лячи байти на zd, що дає ефект дванадцятиразового викликання zd, що викликає метод __call__() 12 разів, що оновлює внутрішній стан та повертає результуючий байт 12 разів.

Класи що поводяться як множини

[ред.]

Якщо ваш клас працює як контейнер для множини значень, тобто, якщо є сенс перевіряти чи "містить" клас значення, тоді напевне варто описувати наступні спеціальні методи і змусити їх працювати як множину.

Ви хочете... Тому ви пишете... І Python виконує...
дізнатись кількість елементів
len(s)
s.__len__()
знати чи містить клас певне значення
x in s
s.__contains__(x)

Модуль cgi використовує ці методи в класі FieldStorage, який представляє всі поля форми, чи параметри запиту які були відправлені динамічній веб-сторінці.

# Скрипт який відповідає на запит http://example.com/search?q=cgi
import cgi
fs = cgi.FieldStorage()
if 'q' in fs:
  do_search()

Як тільки ви створите екземпляр класу cgi.FieldStorage, ви можете використати оператор in щоб перевірити чи певний параметр був переданий в рядку запиту.

# Витяг з cgi.py який пояснює як це працює
class FieldStorage:
.
.
.
    def __contains__(self, key):
        if self.list is None:
            raise TypeError('not indexable')
        return any(item.name == key for item in self.list)

Метод __contains__() - це магія яка приводить все це в дію. Коли ви пишете if 'q' in fs, Python шукає в об’єкті fs метод __contains__(), який описаний в cgi.py. Значення 'q' передається в цей метод як аргумент key.

А функція any(), приймає генераторний вираз та повертає True якщо генератор повертає хоча б одне істинне значення. Вона досить розумна щоб одразу після цього зупинитись.

    def __len__(self):
        return len(self.keys())

Цей же клас FieldStorage також підтримує повернення своєї довжини, тому ви можете написати len(fs), що викличе метод __len__() для FieldStorage і поверне кількість параметрів запиту які були знайдені. Метод self.keys() перевіряє чи self.list is None, тому метод __len__ не повинен проводити цю перевірку на помилки повторно.

Класи що поводяться як словники

[ред.]

Трішки розширюючи інформацію попереднього параграфа, ви можете описувати класи що відповідають не тільки на оператор in та функцію len(), а й працюють як повноцінні словники, повертаючи значення за ключами.

Ви хочете... Тому ви пишете... І Python виконує...
отримати значення за ключем
x[key]
x.__getitem__(key)
записати значення за ключем
x[key] = value
x.__setitem__(key, value)
видалити значення за ключем
del x[key]
x.__delitem__(key)
надати значення за замовчуванням для неіснуючих ключів
x[nonexistent_key]
x.__missing__(nonexistent_key)

Клас FieldStorage з модуля cgi також описує ці спеціальні методи, що означає, що ви можете робити речі на зразок наступних:

# Скрипт який відповідає на запит http://example.com/search?q=cgi
import cgi
fs = cgi.FieldStorage()
if 'q' in fs:
  do_search(fs['q'])

Об’єкт fs є екземпляром cgi.FieldStorage, але ви все одно можете отримати значення виразу на зразок fs['q'].

# Шматок з cgi.py який показує як це працює
class FieldStorage:
.
.
.
    def __getitem__(self, key):
        if self.list is None:
            raise TypeError('not indexable')
        found = []
        for item in self.list:
            if item.name == key: found.append(item)
        if not found:
            raise KeyError(key)
        if len(found) == 1:
            return found[0]
        else:
            return found

fs['q'] задіює метод __getitem__() передаючи йому 'q' в параметр key. Після чого він шукає у власному списку параметрів запиту (self.list) елемент, .name якого відповідає даному ключу.

Класи що поводяться як числа

[ред.]

Використовуючи відповідні спеціальні методи, ви можете описати свої власні класи що поводяться як числа. Тобто, ви можете додавати їх, віднімати та виконувати інші математичні операції. Таким чином реалізовані дроби - клас Fraction містить реалізацію цих методів, і тому ми можемо робити речі на зразок таких:

>>> from fractions import Fraction
>>> x = Fraction(1, 3)
>>> x / 3
Fraction(1, 9)

Далі вичерпний список спеціальних методів які потрібні щоб створити числоподібний клас.

Ви хочете... Тому ви пишете... І Python виконує...
додавання
x + y
x.__add__(y)
віднімання
x - y
x.__sub__(y)
множення
x * y
x.__mul__(y)
ділення
x / y
x.__truediv__(y)
цілочисельне ділення
x // y
x.__floordiv__(y)
остачу від ділення
x % y
x.__mod__(y)
цілочисельне ділення з остачею
divmod(x, y)
x.__divmod__(y)
піднесення до степеня
x ** y
x.__pow__(y)
побітовий зсув вліво
x << y
x.__lshift__(y)
побітовий зсув право
x >> y
x.__rshift__(y)
побітове "і"
x & y
x.__and__(y)
побітове виключне "або"
x ^ y
x.__xor__(y)
побітове або
x | y
x.__or__(y)

Це все чудово і добре, якщо x - екземпляр класу що реалізує ці методи. Але що якщо він не реалізує жодного? Чи гірше, що якщо він їх реалізує, але не може обробляти певні види аргументів? Наприклад:

>>> from fractions import Fraction
>>> x = Fraction(1, 3)
>>> 1 / x
Fraction(3, 1)

Це як в попередньому прикладі, коли ми брали Fraction та ділили його на ціле. Цей випадок був прямолінійним, x / 3 викликає x.__truediv__(3), а метод __truediv__() класу Fraction обробляє всю математику. Але цілі не знають як робити арифметичні операції з дробами. То чому цей приклад працює?

Існує інший набір спеціальних арифметичних методів, з оберненими операндами. Маючи арифметичну операцію яка бере два операнди (наприклад x / y), існує два способи її здійснити:

  1. Попросити x поділити себе на y, або
  2. Попросити y поділити на себе x

Множина спеціальних методів вище обирає перший підхід: маючи x / y, вони описують те як x може поділити себе на y. Наступний набір спеціальних методів обирає інший підхід: вони надають спосіб за допомогою якого y може сказати "Я знаю як бути знаменником і поділити на себе x".

Ви хочете... Тому ви пишете... І Python виконує...
додавання
x + y
y.__radd__(x)
віднімання
x - y
y.__rsub__(x)
множення
x * y
y.__rmul__(x)
ділення
x / y
y.__rtruediv__(x)
цілочисельне ділення
x // y
y.__rfloordiv__(x)
остачу від ділення
x % y
y.__rmod__(x)
цілочисельне ділення з остачею
divmod(x, y)
y.__rdivmod__(x)
піднесення до степеня
x ** y
y.__rpow__(x)
побітовий зсув вліво
x << y
y.__rlshift__(x)
побітовий зсув право
x >> y
y.__rrshift__(x)
побітове "і"
x & y
y.__rand__(x)
побітове виключне "або"
x ^ y
y.__rxor__(x)
побітове або
x | y
y.__ror__(x)

Але зачекайте, є ще! Якщо ви використовуєте оператори з присвоєнням ("in-place operator" - оператор на місці), такі як x /= 3, існує ще більше спеціальних методів які ви можете описати.

Ви хочете... Тому ви пишете... І Python виконує...
додавання з присвоєнням
x += y
x.__iadd__(y)
віднімання з присвоєнням
x -= y
x.__isub__(y)
множення з присвоєнням
x *= y
x.__imul__(y)
ділення з присвоєнням
x /= y
x.__itruediv__(y)
цілочисельне ділення з присвоєнням
x //= y
x.__ifloordiv__(y)
остача від ділення з присвоєнням
x %= y
x.__imod__(y)
піднесення до степеня з присвоєнням
x **= y
x.__ipow__(y)
побітовий зсув вліво з присвоєнням
x <<= y
x.__ilshift__(y)
побітовий зсув право з присвоєнням
x >>= y
x.__irshift__(y)
побітове "і" з присвоєнням
x &= y
x.__iand__(y)
побітове виключне "або" з присвоєнням
x ^= y
x.__ixor__(y)
побітове або з присвоєнням
x |= y
x.__ior__(y)

Зауваження: у більшості випадків перевантажувати оператори з присвоєнням не є необхідним. Якщо ви не задасте метод для певної операції з присвоєнням, Python спробує інші методи. Наприклад, щоб виконати вираз x /= y, Python спробує:

  1. Спробувати викликати x.__itruediv__(y). Якщо цей метод описаний та повертає будь-яке значення окрім NotImplemented, то на цьому все.
  2. Спробувати викликати x.__truediv__(y). Якщо цей метод описаний та повертає будь-яке значення окрім NotImplemented, то старе значення x відкидається та замінюється повернутим значенням, так ніби ми написали x = x / y.
  3. Спробувати викликати y.__rtruediv__(x). Якщо цей метод описаний та повертає будь-яке значення окрім NotImplemented, то старе значення x відкидається та замінюється повернутим значенням.

Тому ви справді потребуєте задавати методи для операторів з присвоєнням на зразок __itruediv__(), якщо ви хочете зробити спеціальні оптимізації для таких операторів. Інакше Python переформулює ці оператори в звичайні з наступним присвоєнням.

Існує також кілька "унарних" математичних операцій, які можна виконувати на числоподібних об’єктах.

Ви хочете... Тому ви пишете... І Python виконує...
від’ємне число
-x
x.__neg__()
додатнє число
+x
x.__pos__()
побітову інверсію ("не")
~x
x.__invert__()
комплексне число
complex(x)
x.__complex__()
ціле число
int(x)
x.__int__()
число з плаваючою крапкою
float(x)
x.__float__()
число округлене до найближчого цілого
round(x)
x.__round__()
число округлене до найближчого значення з n значущими цифрами
round(x, n)
x.__round__(n)
найменше ціле >= x
math.ceil(x)
x.__ceil__()
найбільше ціле <= x
math.floor(x)
x.__round__()
обрізати x до найближчого цілого в сторону 0
math.trunc(x)
x.__trunc__()
використати як індекс списку (PEP 357)
a_list[x]
a_list[x.__index__()]

Класи які можна порівнювати

[ред.]

Я виокремив цей параграф від попереднього, тому що порівняння не є строгою компетенцією чисел. Багато типів даних можна порівнювати - рядки, списки, навіть словники. Якщо ви створюєте власний клас і є сенс в порівнянні ваших об’єктів з іншими, ви можете використати наступні спеціальні методи для реалізації порівнянь.

Ви хочете... Тому ви пишете... І Python виконує...
рівність
x == y
x.__eq__(y)
нерівність
x != y
x.__ne__(y)
менше
x < y
x.__lt__(y)
менше рівно
x <= y
x.__le__(y)
більше
x > y
x.__gt__(y)
більше рівно
x >= y
x.__ge__(y)
значення істинності в булевому контексті
if x:
x.__bool__()

Якщо ви опишете метод __lt__() але не __gt__(), Python використає метод __lt__() з переставленими аргументами. Щоправда, Python не комбінуватиме методи. Наприклад, якщо ви опишете методи __lt__() та __eq__() та спробуєте перевірити чи x <= y, Python не викликатиме __lt__() та __eq__() один за одним. Він лише викличе метод __le__().

Класи які можна серіалізувати

[ред.]

Python підтримує серіалізацію та розсеріалізацію довільних об’єктів. (Також цей процес називають pickling та unpickling відповідно). Це може бути корисним для зберігання стану в файл, та відновлення його пізніше. Всі cтандартні типи даних вже підтримують серіалізацію. Якщо ви створюєте власний клас, та хочете його серіалізувати, прочитайте про протокол pickle щоб побачити коли і як викликаються наступні спеціальні методи.

Ви хочете... Тому ви пишете... І Python виконує...
копію власного об’єкта
copy.copy(x)
x.__copy__()
"глибоку" копію власного об’єкта
copy.deepcopy(x)
x.__deepcopy__()
отримати стан об’єкта перед серіалізацією*
pickle.dump(x, file)
x.__getstate__()
серіалізувати об’єкт*
pickle.dump(x, file)
x.__reduce__()
серіалізувати об’єкт (новий протокол pickle)*
pickle.dump(x, file, protocol_version)
x.__reduce_ex__(protocol_version)
контролювати створення об’єкта під час розсеріалізації*
x = pickle.load(file)
x.__getnewargs__()
відновити стан об’єкта після розсеріалізації*
x = pickle.load(file)
x.__setstate__()
* Щоб відтворити серіалізований об’єкт, Python повинен створити новий об’єкт який виглядає як серіалізований, після чого встановити значення всіх атрибутів нового об’єкта. Метод __getnewargs__() контролює процес створення об’єкта, а метод __setstate__() - процес відновлення значень атрибутів.

Класи що можуть використовуватись в блоці with

[ред.]

Блок with утворює контекст виконання; ви входите в контекст при виконанні оператора with і виходите з нього після виконання останнього оператора з блоку.

Ви хочете... Тому ви пишете... І Python виконує...
зробити певну дію при вході в блок with
with x:
x.__enter__()
зробити певну дію при виході з блоку with
with x:
x.__exit__(exc_type, exc_value, traceback)

Ось як це працює з файлами:

# витяг з io.py:
def _checkClosed(self, msg=None):
    '''Internal: raise an ValueError if file is closed
    '''
    if self.closed:
        raise ValueError('I/O operation on closed file.'
                         if msg is None else msg)

def __enter__(self):
    '''Context management protocol.  Returns self.'''
    self._checkClosed()                                
    return self

Файловий об’єкт описує як метод __enter__() так і метод __exit__(). Метод __enter__() перевіряє чи файл є відкритим, і якщо ні, метод _checkClosed() кидає виняток.

Метод __enter__() завжди повинен повертати self - це об’єкт який блок with використовує для доступу до атрибутів об’єкта.

def __exit__(self, *args):
    '''Context management protocol.  Calls close()'''
    self.close()

Після виходу з блоку with файловий об’єкт автоматично закривається, завдяки коду в методі __exit__().

Метод __exit__() виконується завжди, навіть якщо всередині блоку with трапився виняток. Інформація про цей виняток передається в параметри __exit__(). Щоб дізнатись деталі дивіться документацію.

Щоб дізнатись більше про керування контекстом читайте розділи Автоматичне закривання файлів та Перенаправлення стандартного потоку виводу

Зовсім езотеричні штуки

[ред.]

Якщо ви знаєте що робите, ви можете отримати майже повний контроль над тим як класи порівнюються, як визначаються їх атрибути, та які види класів вважаються підкласами вашого.


Ви хочете... Тому ви пишете... І Python виконує...
конструктор класу
x = MyClass()
x.__new__()
деструктор класу*
del x
x.__del__()
задати певну множину атрибутів
x.__slots__()
задати власне значення хешу
hash(x)
x.__hash__()
отримати значення властивості
x.color
type(x).__dict__['color'].__get__(x, type(x))
встановити значення властивості
x.color = 'PapayaWhip'
type(x).__dict__['color'].__set__(x, 'PapayaWhip')
видалити властивість
del x.color
type(x).__dict__['color'].__del__(x)
встановити чи є об’єкт екземпляром класу
isinstance(x, MyClass)
MyClass.__instancecheck__(x)
встановити чи є клас підкласом класу
issubclass(C, MyClass)
MyClass.__subclasscheck__(C)
встановити чи є клас підкласом абстрактного базового класу
issubclass(C, MyABC)
MyABC.__subclasshook__(C)
* Точний момент коли Python викликає метод __del__ надзвичайно складно визначити. Щоб повністю це зрозуміти, потрібно знати як Python зберігає об’єкти в пам’яті. Ось гарна стаття про збирання сміття в Python та деструктори класів. Також ви повинні прочитати про слабкі посилання, модуль weakref та, для певності, модуль gc.

Для подальшого читання

[ред.]

Модулі згадані в цьому додатку:

Інше неважке читання:

Що читати далі?

[ред.]

Йди далі своїм шляхом, тому що він існує лише завдяки тому що ти йдеш.
Августин Аврелій


Ще почитати (англійською)

[ред.]

На жаль, в цій книзі ми не зможемо описати всі аспекти мови Python 3. На щастя, є багато чудових, вільно доступних уроків в інших місцях.

Індекс: http://jessenoller.com/good-to-great-python-reads/

Декоратори:

Властивості:

Дескриптори:

Потоки і багатопроцесорність:

Метакласи:

  • Посилання на уроки по метакласах від IBM пропали. Треба знайти кращі. І може українською.
  • Python class advisors

Ну, і на додачу серія публікацій Дуга Хелмана Python Module of the Week є чудовим путівником до багатьох модулів в стандартній бібліотеці Python.

Де шукати код сумісний з Python3

[ред.]

Так як Python 3 - відносно молода мова, існує певна нестача сумісних бібліотек. Тут перелічено кілька місць де можна пошукати код що працює з Python 3.

Українською

[ред.]