Файли й pathlib

Програма забуває все, щойно завершилась. Диск — це її памʼять на завтра, і розмова з диском має свої правила: режим відкриття, кодування, гарантоване закриття. Порушиш одне — і український текст перетвориться на мотлох, а прайс за півроку зникне від одного зайвого символу.

Усі дані, з якими ми досі працювали, ми набирали руками. Список покупок у темі 07, словник цін у темі 09, зіпсовані рядки прайсу в темі 19 — усе це літерали, записані просто в коді. Порахували, надрукували, закрили вікно. Наступного разу все починається з тих самих літералів, бо більше нічого й не лишилось.

Це нормально, поки ти вчишся. Але жодна справжня програма так не працює: дані приходять ззовні й мають пережити завершення програми. Прайс вивантажили з бухгалтерії, результат треба віддати колезі, журнал подій має існувати завтра вранці. Усе це — файли, і сьогоднішня тема відкриває блок «Робота з реальністю»: там, де дані вже не ми вигадуємо.

Наскрізний приклад лишається той самий, що в темі 19, — прайс із трьох рядків. Тільки тепер він не змінна в коді, а файл prays.txt на диску:

хліб;28.5 молоко;32.0 яблука;19.0

МотивЧому програма все забуває

Коли ти запускаєш скрипт, операційна система виділяє йому шматок оперативної памʼяті. Там живуть усі обʼєкти: числа, рядки, списки, словники. Коли скрипт завершується, система забирає цей шматок назад і віддає наступній програмі. Нічого не «зберігається саме собою»: памʼять для того й потрібна, щоб її переприділяти.

Побутова аналогія проста. Оперативна памʼять — це стіл: широкий, швидкий, але ввечері прибиральниця змітає з нього все. Диск — це шафа: повільніша, зате вранці все на місці.

Файл — це названа послідовність байтів, яку зберігає операційна система. Байт — число від 0 до 255, і сама система не знає, що ті байти означають: текст прайсу, фотографію чи музику. Тлумачення — цілком наша справа, і саме звідси візьметься половина сьогоднішніх пасток.

У файлів є друга роль, не менш важлива: це спосіб обміну між програмами. Бухгалтерка вивантажила таблицю, ти прочитав її Python-ом, записав результат, вона відкрила його в Excel. Жодна з трьох програм нічого не знає про дві інші — домовились лише про формат файлу.

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

Основаopen(): файл як обʼєкт

Уся робота починається з однієї вбудованої функції:

f = open("prays.txt", "r", encoding="utf-8") print(f) <_io.TextIOWrapper name='prays.txt' mode='r' encoding='utf-8'>

Зверни увагу на те, чого тут немає: вмісту файлу. open не читає нічого — вона повертає обʼєкт, через який читати можна. Це принципово: якби open одразу читала файл, відкрити фільм на чотири гігабайти було б неможливо.

Точніша аналогія — не «прочитати книжку», а «покласти закладку». Файловий обʼєкт памʼятає дві речі: який файл відкрито і на якій позиції ми зараз стоїмо. Кожне читання зсуває позицію вперед.

Аргументів у open три: шлях (рядок або обʼєкт Path із розділу 08), режим — що саме ми збираємось робити, за замовчуванням "r", тобто «читати», — і encoding, тобто за якою таблицею перекладати байти в символи. Останній формально не обовʼязковий, а фактично обовʼязковий завжди: чому — у розділі 05.

Текстовий режим і двійковий

Літера b в режимі ("rb", "wb") перемикає файл у двійковий режим, і різниця більша, ніж здається:

текстовий ("r")двійковий ("rb")
що віддаєstr — символиbytes — числа 0…255
кодуваннядекодує за encodingне чіпає взагалі
кінці рядківбудь-які перетворює на \nлишає як є
для чоготекст, CSV, JSON, кодкартинки, архіви, бази, мережа

Правило вибору без винятків: текст для людини — текстовий режим з encoding, усе інше — двійковий. Спроба відкрити картинку в текстовому режимі майже напевно дасть UnicodeDecodeError, і це ще щасливий кінець.

НаміриРежими r, w, a, x

Другий аргумент open — це оголошення намірів. Ти кажеш системі, що збираєшся робити, а вона на основі цього вирішує, чи створювати файл і що зробити з наявним вмістом. Чотири базові літери:

режимфайлу немаєфайл уже єдозволяє
rFileNotFoundErrorвідкрити з початкулише читати
wстворити порожнійстерти вмістлише писати
aстворити порожнійстати в кінецьлише дописувати
xстворити порожнійFileExistsErrorлише писати

Найважливіший рядок таблиці — другий. "w" стирає файл у мить відкриття, ще до першого write. Не після, не «якщо запис вдався» — одразу. Рядок open("prays.txt", "w"), виконаний випадково, знищує прайс навіть тоді, коли наступний рядок програми падає з винятком. Відновити немає звідки.

Що робить кожен режим із твоїм файлом

Обери режим і те, чи файл уже існує. Дивись на середню колонку: вона показує стан одразу після open(), ще до запису.

рядків до open—
рядків після open—
рядків після запису—
курсор на—
Прогони w на наявному файлі й подивись на середню колонку. Було три рядки — стало нуль, і жодного попередження. Порівняй із x: той самий намір, але замість тихого знищення — FileExistsError і жоден байт не втрачено.

Ще дві деталі. "a" пише тільки в кінець: навіть якщо перемотати позицію на початок, наступний write усе одно опиниться у хвості — так працює прапорець O_APPEND в операційній системі. І плюс відкриває друге вміння: "r+" — читати й писати в наявному файлі, "w+" — писати й читати, попередньо стерши. Новачкові вони не потрібні майже ніколи, і це добре: змішувати читання із записом в одному обʼєкті — надійний спосіб заплутатись у позиції.

Гарантіяwith: обіцянка з теми 19

У темі 19 ми домовились, що with — це try/finally, спакований у два слова, і що докладно буде тут. Час виконувати обіцянку.

Чому взагалі треба закривати файл? Три причини, і всі практичні:

  1. Дані не на диску, доки файл не закритий. Запис іде через буфер у памʼяті — так швидше. close() скидає буфер на диск.
  2. Файловий дескриптор — обмежений ресурс. Система дає процесу скінченну кількість відкритих файлів (типово тисячу-дві), і цикл, що відкриває й не закриває, впаде з OSError: Too many open files.
  3. Файл заблокований. У Windows відкритий файл часто не можна ані перейменувати, ані видалити — і незрозуміло, хто його тримає.

Найпростіший спосіб — дописати f.close() у кінці. Він працює рівно доти, доки між open і close нічого не падає. А падає, як ми знаємо з теми 19, регулярно: виняток пропускає решту блоку, і рядок close() просто не виконується.

# наївно: close() не виконається, якщо всередині виняток f = open("zvit.txt", "w", encoding="utf-8") f.write(f"разом: {сума / кількість}\n") # кількість == 0 → ZeroDivisionError f.close() # сюди вже не дійдемо # чесно, але багатослівно f = open("zvit.txt", "w", encoding="utf-8") try: f.write(f"разом: {сума / кількість}\n") finally: f.close() # виконається завжди # те саме, коротше — і саме так пишуть with open("zvit.txt", "w", encoding="utf-8") as f: f.write(f"разом: {сума / кількість}\n")

Той самий код трьома способами

Перемкни сценарій на «виняток усередині» й порівняй три колонки: чи закрито файл і скільки рядків справді долетіло до диска.

close: наївний—
close: finally—
close: with—
рядків на диску (A)—
Сценарій «усе гаразд» — це і є причина, чому пастку не помічають. Усі три колонки однакові: файл закрито, два рядки на диску. Різниця зʼявляється рівно тоді, коли щось пішло не так, — тобто в найгіршу мить, коли ти й так розбираєшся з винятком, а тут ще й файл порожній.

Обʼєкт, який уміє стояти після with, називають менеджером контексту (context manager). Він знає дві дії: що зробити на вході в блок і що зробити на виході. Файловий обʼєкт на вході віддає сам себе (тому as f — це той самий обʼєкт, що повернула open), а на виході викликає close(). Вихід відбувається завжди: через кінець блоку, return, break чи виняток.

Тепер видно, чому with кращий за try/finally, а не просто коротший. Різниця в тому, хто знає, як прибирати. У варіанті з finally це знання лежить у тебе в коді, і ти повторюєш його на кожному місці, де відкриваєш файл, — а отже маєш шанс щоразу забути, написати не той метод або поставити close() не в ту гілку. У варіанті з with прибирання описав сам обʼєкт, один раз і назавжди; тобі лишилось позначити межу блоку. Тому те саме слово with однаково правильно закриває файл, звільняє блокування чи фіксує транзакцію бази — забути «правильну» дію неможливо, бо її вибирає не той, хто пише блок.

with закриває, але не рятує. Виняток, що стався всередині блоку, летить далі вгору рівно так само, як без with: менеджер контексту гарантує прибирання, а не тишу. Хочеш ще й обробити виняток — потрібен try/except навколо блоку.

Після виходу з блоку обʼєкт лишається, але вже закритий, і спроба скористатись ним дає впізнаваний виняток:

with open("prays.txt", encoding="utf-8") as f: вміст = f.read() print(f.closed) # True f.read() # ValueError: I/O operation on closed file.

Кілька файлів в одному with пишуть через кому — обидва закриються, навіть якщо впаде посередині:

with open("prays.txt", encoding="utf-8") as вхід, \ open("zvit.txt", "w", encoding="utf-8") as вихід: for рядок in вхід: вихід.write(рядок.upper())

ПасткаКодування: чому utf-8 обовʼязково

Тепер головна практична пастка теми. На диску немає символів — там байти. Щоб з байтів вийшов текст, потрібна таблиця відповідності, і називають її кодуванням (encoding).

Таблиць історично багато. ASCII описує 128 символів: латиниця, цифри, розділові знаки — і жодної кириличної літери. cp1251 (вона ж Windows-1251) розширює ASCII до 256 і додає туди кирилицю — але лише її, і в кожної мовної групи була така своя таблиця. UTF-8 вміщає всі писемності світу: ASCII в ній лишився однобайтовим, а решта займає два-чотири байти.

Порахуймо руками на слові хліб. У cp1251 кожна літера — один байт: F5 EB B3 E1, разом чотири. У UTF-8 кирилична літера коштує два байти: D1 85 D0 BB D1 96 D0 B1, разом вісім. Той самий текст, різна довжина — і жодного натяку всередині файлу на те, за якою таблицею його писали. Кодування у файлі не зберігається. Його треба знати наперед.

Записали одним кодуванням, читаємо іншим

Той самий український рядок. Обери, чим писали й чим читаємо, — і подивись на нижній ряд.

байтів на диску—
символів вийшло—
збіглося з оригіналом—
як дізнаєшся—
Порівняй дві біди. «Пишемо utf-8, читаємо cp1251» — це 13 байтів, витлумачених як 13 однобайтових символів: хліб;28.5. Помилки немає, програма спокійно працює далі, і мотлох їде в базу даних. «Пишемо cp1251, читаємо utf-8» — це UnicodeDecodeError на першому ж байті. Гучна біда краща за тиху: про неї ти дізнаєшся в ту саму секунду.

Тепер найголовніше. Якщо не передати encoding, Python не вгадує кодування файлу — він бере налаштування системи. На сучасному Linux і macOS це UTF-8, а на Windows донедавна була кирилична cp1251. Ось звідки береться класична історія: у тебе все читається, у колеги той самий скрипт на тому самому файлі падає. Код однаковий, система різна.

Правило без винятків. Кожен open у текстовому режимі отримує encoding="utf-8". Завжди. Це чотирнадцять символів, які роблять поведінку програми однаковою на всіх машинах світу. Пропущений encoding — не «дрібна недбалість», а зламаність, яка проявиться не в тебе.

Що робити, коли файл таки прийшов у cp1251? Не вгадувати, а спитати того, хто його дав, і передати кодування явно: open("staryi.txt", encoding="cp1251"). Автовизначення — це евристика, і короткий файл вона вгадує особливо погано.

Якщо джерело брудне й кілька байтів псують усе читання, є аварійний вихід — аргумент errors:

open("brudno.txt", encoding="utf-8", errors="replace") # зіпсоване → «�» open("brudno.txt", encoding="utf-8", errors="ignore") # зіпсоване викидається

Обидва варіанти — втрата даних, і ставити їх «щоб не падало» не можна. Вони доречні там, де прочитати 99% тексту цінніше, ніж зупинитись: пошук по логах, груба статистика. У розборі платіжок такому місця немає.

Звідки береться utf-8-sig. Excel і низка редакторів Windows ставлять на початок файлу три службові байти EF BB BF — «мітку порядку байтів» (BOM). Прочитаний як звичайний utf-8, такий файл дасть невидимий символ \ufeff на початку, і перший заголовок стовпця раптом не збігається ні з чим. Кодування utf-8-sig зʼїдає цю мітку при читанні. Дивно поводиться перше поле CSV — підозра саме на BOM.

ЧитанняТри способи прочитати

Файл відкрито. Далі є кілька способів дістати з нього текст, і вибір між ними — не питання смаку.

with open("prays.txt", encoding="utf-8") as f: for рядок in f: назва, ціна = рядок.rstrip("\n").split(";") print(назва, "→", float(ціна))

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

Скільки памʼяті просить кожен спосіб

Повзунок задає розмір файлу. Смуги показують памʼять у пікові, шкала — від найбільшого з трьох.

файл на диску—
read()—
readlines()—
цикл—
Зсунь повзунок у крайнє ліве положення. На файлі з десяти рядків найдорожчий спосіб — саме обхід циклом: вісім кілобайтів буфера проти кілобайта в readlines(). А тепер у крайнє праве: readlines() просить майже десять гігабайтів, а цикл — ті самі вісім кілобайтів. Перевага обходу не в тому, що він завжди економніший, а в тому, що він не росте. Модель приблизна: 40 байтів тексту на рядок і ще близько 62 службових на кожен обʼєкт-рядок у списку.
Символ \n лишається в рядку. І readline(), і обхід циклом віддають рядок разом із переходом на новий рядок, тому далі майже завжди йде .rstrip("\n"). Забудеш — і остання ціна буде "28.5\n". float її, до речі, проковтне без скарг (пробільні символи по краях він ігнорує), а от порівняння назва == "хліб" тихо стане хибним.

ЗаписЗапис і дозапис

Метод write приймає рядок і повертає кількість записаних символів. Головне про нього — те, чого він не робить:

with open("zvit.txt", "w", encoding="utf-8") as f: f.write("хліб;28.5") f.write("молоко;32.0") # у файлі один рядок: хліб;28.5молоко;32.0

write не додає перехід на новий рядок. Ніколи. Це прямий наслідок того, що файл — послідовність символів, а не список рядків: якщо потрібен \n, пиши його сам.

writelines теж не додає \n. Назва обіцяє «записати рядки», а метод просто склеює елементи списку без жодного роздільника. Хочеш рядки — додай переходи сам: f.writelines(р + "\n" for р in назви). Часто читабельніший другий варіант — print("хліб;28.5", file=f): print додає \n сам, як і на екрані.

Режим "a" перетворює файл на журнал: кожен запуск програми дописує свій рядок у кінець, нічого не стираючи. Класичне застосування — протокол обробки:

with open("zhurnal.txt", "a", encoding="utf-8") as f: f.write(f"оброблено рядків: {len(рядки)}\n")

Межа тут одна: "a" дописує, але не прибирає. Файл журналу росте, доки хтось його не оберне, — і в маленькому скрипті достатньо про це памʼятати.

Шляхиpathlib: шлях як обʼєкт

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

# так робити не варто тека = "dani" шлях = тека + "/" + "prays.txt" # на Windows роздільник \ шлях = тека + "prays.txt" # «daniprays.txt» — і жодної помилки

Клеїти шляхи рядками погано з двох причин. Перша: роздільник різний — / на Linux і macOS, \ у Windows. Друга, важливіша: рядок не перевіряє себе. Забув риску — програма спокійно створить файл daniprays.txt замість того, щоб поскаржитись.

Модуль pathlib розвʼязує це не функціями, а типом: шлях стає обʼєктом, який знає власну будову.

from pathlib import Path шлях = Path("dani") / "2026" / "prays.txt" print(шлях) # dani/2026/prays.txt (у Windows: dani\2026\prays.txt) print(type(шлях)) # <class 'pathlib.PosixPath'>

Оператор / тут — не хак і не гра слів: для Path ділення перевизначене й означає «додати складову шляху», а правильний роздільник система підставить сама. Клас теж обирається автоматично — PosixPath на Linux і macOS, WindowsPath у Windows, — і саме тому той самий код працює скрізь.

З чого складається Path

Обери властивість — підсвітиться те, що вона поверне. Перемикач стилю показує той самий шлях очима Windows.

що поверне—
тип—
складових у .parts—
Найкорисніша кнопка тут — .suffixes. У назві prays.2026.csv дві крапки, і .suffix бере лише останню частину — .csv. «2026» для pathlib — не розширення, а шматок назви, тому .stem дає prays.2026. Хто цього не знає, той пише імʼя.split(".")[0] і одного дня втрачає половину назви файлу.

Найкорисніші властивості одразу списком, на прикладі Path("/home/olena/dani/prays.2026.csv"):

виразщо дає
.nameprays.2026.csv — імʼя з розширенням
.stemprays.2026 — імʼя без останнього розширення
.suffix.csv — останнє розширення, з крапкою
.parent/home/olena/dani — тека, у якій лежить
.partsкортеж усіх складових, від кореня
.with_suffix(".json")той самий шлях з іншим розширенням
Path.cwd()поточна тека — від неї рахуються відносні шляхи
Path(__file__).parentтека, у якій лежить сам скрипт
Path нічого не читає з диска. Path("немає-такого.txt") створюється без найменшої скарги — це просто обʼєкт із текстом усередині. Диска він торкається лише тоді, коли ти прямо просиш: .exists(), .read_text(), .glob(). Це зручно (шлях можна складати й передавати куди завгодно) і водночас підступно: сам факт, що обʼєкт створився, нічого не доводить про існування файлу.
Класична пастка «у мене працювало». Скрипт читає open("dani/prays.txt"): із теки проєкту все гаразд, з іншої теки — FileNotFoundError, бо відносний шлях рахується від поточної теки, а не від теки зі скриптом. Ліки одні: Path(__file__).parent / "dani" / "prays.txt". Змінна __file__ знайома нам із теми 17.

ТекаТека: створити й обійти

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

тека = Path("zvity") / "2026" тека.mkdir(parents=True, exist_ok=True) print(тека.exists(), тека.is_dir()) # True True

Обидва прапорці в mkdir потрібні, бо без них рядок падає через раз. parents=True створює всі проміжні теки — без нього zvity/2026 не зʼявиться, якщо немає zvity (це аналог mkdir -p з терміналу). exist_ok=True велить не скаржитись, якщо тека вже є: без нього повторний запуск програми впаде з FileExistsError, а повторний запуск — нормальний сценарій, не аварія.

Обійти вміст теки можна трьома способами. .iterdir() віддає все, що лежить безпосередньо всередині. .glob(шаблон) фільтрує за маскою. .rglob(шаблон) — те саме, що .glob("**/" + шаблон), тобто з заходом у всі вкладені теки.

Що саме знайде твій glob

Одне дерево файлів, шість шаблонів. Знайдене підсвічується зліва й перелічується справа.

знайдено—
з них теки—
найглибший рівень—
усього в дереві12
Порівняй */*.csv і **/*.csv. Одна зірочка — рівно один рівень углиб, тому dani/stari/2024.csv лишається невидимим. Дві зірочки означають «ця тека і всі вкладені» — знаходяться всі три файли. І окремо подивись на *: серед пʼяти знахідок дві — теки, бо glob не відрізняє файл від теки, доки не спитаєш .is_file().

Дві властивості glob, про які легко забути. Він повертає генератор, а не список: диск читається поступово, у міру обходу, тому print(тека.glob("*.csv")) надрукує <generator object ...>, а не файли. І порядок не гарантований — він залежить від файлової системи, тому тест, який мовчки покладається на алфавіт, одного дня почервоніє на чужому компʼютері. Треба порядок — sorted(...).

for файл in sorted(Path("dani").glob("*.csv")): print(файл.name, файл.stat().st_size, "байтів")

Видалення теж на місці: файл.unlink(missing_ok=True) прибирає файл, тека.rmdir() — порожню теку. Для теки з вмістом потрібен shutil.rmtree(), і це та функція, перед викликом якої варто двічі подивитись на змінну зі шляхом.

СкороченняЯрлики read_text і write_text

Для найчастішого випадку — «прочитати невеликий файл цілком» — pathlib має готові методи, і вони самі відкривають та закривають файл:

шлях = Path("prays.txt") текст = шлях.read_text(encoding="utf-8") # замість with open(...) as f: f.read() шлях.write_text("хліб;28.5\n", encoding="utf-8") # повертає кількість записаних символів дані = шлях.read_bytes() # двійковий варіант

Аргумент encoding тут потрібен рівно так само, як в open: це скорочення запису, а не скасування правил. Не варто ними користуватись у двох випадках. Великий файл — read_text тягне все в памʼять, точно як read(), і інтерактив 4 показав, чим це закінчується. Дозапис — write_text завжди перезаписує файл з нуля, це "w", а не "a".

ГлибшеДля тих, хто вже пише код

Кілька речей, які на робочому проєкті трапляються щотижня.

Буферизація й коли потрібен flush()

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

Де насправді твої дані

Повзунок — скільки рядків уже записано. Буфер — 8 192 байти; дивись, скільки з них дійшло до диска.

усього байтів—
на диску—
у буфері—
звернень до ОС—
Найцікавіше — режим «просто пишемо» на малій кількості рядків. Записано 100 рядків = 1 400 байтів, а на диску нуль: усе в буфері, жодного звернення до ОС ще не було. «flush() щоразу» кладе все на диск негайно, але платить системним викликом за кожен рядок. Вихід із with робить те саме одним скиданням у кінці — і це та причина, чому with не просто охайніший, а правильніший.

Отже, f.flush() потрібен там, де файл читає хтось інший одночасно з записом: журнал під tail -f або файл прогресу довгої обробки. У решті випадків with скине буфер сам. І уточнення, яке рятує від хибної впевненості: flush() віддає дані операційній системі, а не кладе їх на пластину диска — в ОС теж є свій кеш. Гарантія переживання вимкнення живлення — це os.fsync(f.fileno()), і коштує вона відповідно.

newline="" для CSV

У текстовому режимі Python нормалізує кінці рядків: при читанні перетворює \r\n на \n, а при записі у Windows робить навпаки. Для звичайного тексту це благо. Для модуля csv — біда, бо він керує роздільниками рядків сам, і два шари нормалізації дають зайві порожні рядки через один. Тому файл під csv відкривають так:

with open("zvit.csv", "w", encoding="utf-8", newline="") as f: ...

Це не забобон: newline="" вимикає нормалізацію, лишаючи роботу з \r\n самому модулю csv. Знадобиться вже в темі 22.

Чому os.path досі трапляється

pathlib зʼявився в Python 3.4, але os.path старший на два десятиліття, і код із ним нікуди не подівся. Відповідники прямі: os.path.join(a, b) — це Path(a) / b, os.path.basename — .name, os.path.splitext(x)[1] — .suffix. Читай як інший запис тієї самої думки; у новому коді пиши pathlib. Сумісність повна: майже будь-яка функція, що чекає шлях-рядок, приймає й Path — через протокол os.PathLike. Навпаки теж просто: str(шлях).

Ціна запитання до диска й атомарний запис

Path нічого не кешує: кожен .exists(), .is_file(), .stat() — окремий системний виклик. У циклі по сотні тисяч файлів це помітно, і тоді беруть os.scandir(): він віддає записи, у яких тип уже відомий з одного читання теки.

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

Образ тут простий. Пряма перезаписуванням — це стирати надпис на дверях кабінету й писати новий поверх: поки пишеш, на дверях мотлох, і кожен, хто прийшов у цю секунду, прочитає його. Атомарна заміна — це виготовити нову табличку окремо, а потім одним рухом зняти стару й повісити нову. Хтось бачить стару, хтось нову; напівготової не бачить ніхто. Слово атомарний означає рівно це: операція, у якої немає видимої середини. Уточнення про «в межах однієї файлової системи» теж не формальність: якщо тимчасовий файл лежить на іншому диску, перейменування перетворюється на копіювання, і середина в нього зʼявляється.

тимчасовий = шлях.with_suffix(".tmp") тимчасовий.write_text(текст, encoding="utf-8") тимчасовий.replace(шлях) # атомарна заміна

Знайти пропущений encoding автоматично

З Python 3.10 запуск python3 -X warn_default_encoding скрипт.py друкує EncodingWarning на кожен open без явного кодування. Прогнати цим прапорцем чужий проєкт — найшвидший спосіб знайти майбутні проблеми з кирилицею. Довгострокове рішення обговорюють у PEP 686: зробити UTF-8 кодуванням за замовчуванням, після чого пастка зникне сама. Доти — пиши encoding явно.

ПідсумокЩо забрати з теми

Тепер програма вміє читати з диска й писати на диск. Але подивись, чим ми досі займались із прочитаним: рядок.rstrip("\n").split(";") — розбираємо текст руками. Це працює для трьох рядків, а щойно в назві товару трапиться сама крапка з комою або лапки, розбір розсиплеться. Домовленостей про те, як пакувати дані в текст, люди придумали чимало, і дві перемогли решту: CSV для таблиць і JSON для вкладених структур. Обидві мають готові модулі в стандартній бібліотеці, обидві правильно розбирають лапки й екранування — і про них тема 22. Файл ми відкривати вже вміємо; лишилось навчитись розуміти, що в ньому написано.