Практика · Файли й pathlib¶

Лекція: lecture.html · Тест: quiz.html · ДЗ: homework.md

Тема 20, перша в блоці «Робота з реальністю». Тут ми не читатимемо про файли — ми їх створимо, зіпсуємо, відновимо й приберемо за собою.

Що зробимо по кроках:

  1. Створимо тимчасову теку — усе, що нижче, живе тільки в ній.
  2. Запишемо прайс і прочитаємо його трьома способами, довівши, що результат однаковий.
  3. Побачимо на власні очі, що "w" стирає файл у мить відкриття.
  4. Прогонимо режими r, a, x і зловимо їхні винятки.
  5. Порівняємо наївний close() із with на коді, що падає.
  6. Відтворимо мотлох з інтерактиву 3 байт у байт і зловимо UnicodeDecodeError.
  7. Розберемо Path на частини й побудуємо дерево тек через mkdir(parents=True).
  8. Перевіримо всі шість шаблонів glob з інтерактиву 6 на справжніх файлах.
  9. Подивимось, скільки байтів насправді на диску до flush().
  10. Приберемо теку повністю — зошит не має лишати сміття.

Мережа не потрібна. Усі дані ми створюємо самі, нічого не завантажується.

1 · Тимчасова тека: пісочниця, яку не шкода¶

Перше правило експериментів із файлами — не експериментуй у робочій теці. Модуль tempfile створює теку з унікальною назвою в системному місці для тимчасових файлів, а shutil.rmtree наприкінці прибере її разом із усім вмістом.

In [1]:
import shutil
import tempfile
from pathlib import Path

# mkdtemp повертає РЯДОК зі шляхом; загортаємо його в Path, щоб далі користуватись
# оператором «/» і не клеїти шляхи вручну
робоча_тека = Path(tempfile.mkdtemp(prefix="praktyka-20-"))

print("працюємо в:", робоча_тека)
print("тека існує:", робоча_тека.exists(), "· це саме тека:", робоча_тека.is_dir())
print("усередині зараз:", list(робоча_тека.iterdir()))
працюємо в: /tmp/praktyka-20-s26s5cdz
тека існує: True · це саме тека: True
усередині зараз: []

2 · Перший запис: with, encoding і той самий \n¶

Три речі в одному рядку open: шлях (складений оператором /), режим "w" і обовʼязкове encoding="utf-8". І одразу нагадування з лекції: write не додає перехід на новий рядок, тому ми дописуємо \n самі.

In [2]:
прайс = робоча_тека / "prays.txt"        # «/» ставить правильний роздільник на будь-якій системі

рядки_прайсу = ["хліб;28.5", "молоко;32.0", "яблука;19.0"]

with open(прайс, "w", encoding="utf-8") as f:
    for рядок in рядки_прайсу:
        f.write(рядок + "\n")            # без цього «\n» усі три рядки склеїлись би в один

print("створено файл:", прайс.name)
print("байтів на диску:", прайс.stat().st_size)
# змінна f живе й після блоку, але сам файл уже закритий — це і є робота with
print("файл закрито після виходу з with:", f.closed)
створено файл: prays.txt
байтів на диску: 50
файл закрито після виходу з with: True

Пʼятдесят байтів на дванадцять українських літер і кілька цифр — це не помилка. Кирилична літера в UTF-8 коштує два байти, латиниця й цифри — один. Перевіримо руками, щоб число перестало бути магічним.

In [3]:
# рахуємо очікуваний розмір самі: кожен рядок плюс один байт на «\n»
очікуваних_байтів = sum(len(рядок.encode("utf-8")) + 1 for рядок in рядки_прайсу)

print("порахували вручну:", очікуваних_байтів)
print("операційна система каже:", прайс.stat().st_size)
assert очікуваних_байтів == прайс.stat().st_size, "розрахунок розійшовся з диском!"
print("✅ збігається — жодної магії, просто сума довжин у байтах")
порахували вручну: 50
операційна система каже: 50
✅ збігається — жодної магії, просто сума довжин у байтах

3 · Три способи прочитати той самий файл¶

read() віддає весь текст одним рядком, readlines() — список рядків разом із \n, обхід циклом — по одному рядку за раз. Подивимось на кожен окремо.

In [4]:
with open(прайс, encoding="utf-8") as f:
    увесь_текст = f.read()

print("тип:", type(увесь_текст).__name__)
print("символів:", len(увесь_текст))
print(repr(увесь_текст))        # repr показує «\n» явно — саме тому він тут, а не print
тип: str
символів: 34
'хліб;28.5\nмолоко;32.0\nяблука;19.0\n'
In [5]:
with open(прайс, encoding="utf-8") as f:
    список_рядків = f.readlines()

print("тип:", type(список_рядків).__name__, "· елементів:", len(список_рядків))
for номер, рядок in enumerate(список_рядків, 1):
    print(номер, repr(рядок))   # хвостовий «\n» лишився в кожному елементі
тип: list · елементів: 3
1 'хліб;28.5\n'
2 'молоко;32.0\n'
3 'яблука;19.0\n'
In [6]:
зібрані_циклом = []

with open(прайс, encoding="utf-8") as f:
    for рядок in f:             # файловий обʼєкт сам є послідовністю рядків
        зібрані_циклом.append(рядок)

print("зібрано рядків:", len(зібрані_циклом))
print("останній:", repr(зібрані_циклом[-1]))
зібрано рядків: 3
останній: 'яблука;19.0\n'

А тепер головне: три різні способи мають дати однаковий результат. Це наша перша перевірка через assert — і вона доводить, що обхід циклом не «спрощений» варіант, а рівноцінний.

In [7]:
assert увесь_текст == "".join(список_рядків), "read() і readlines() розійшлись!"
assert список_рядків == зібрані_циклом, "readlines() і цикл розійшлись!"

print("read()      → один рядок на", len(увесь_текст), "символів")
print("readlines() → список із", len(список_рядків), "елементів")
print("цикл        → зібрано", len(зібрані_циклом), "елементів, по одному за раз")
print("✅ усі три способи дали той самий вміст — різниця лише у витраті памʼяті")
read()      → один рядок на 34 символів
readlines() → список із 3 елементів
цикл        → зібрано 3 елементів, по одному за раз
✅ усі три способи дали той самий вміст — різниця лише у витраті памʼяті

Розібрати рядок на назву й ціну — те, заради чого ми файл і читали. Зверни увагу на rstrip("\n"): без нього ціна останнього товару була б рядком "19.0\n".

In [8]:
товари = {}

with open(прайс, encoding="utf-8") as f:
    for рядок in f:
        назва, ціна = рядок.rstrip("\n").split(";")   # зрізаємо перехід рядка ПЕРЕД розбором
        товари[назва] = float(ціна)

print(товари)
assert товари == {"хліб": 28.5, "молоко": 32.0, "яблука": 19.0}
print("✅ прайс розібрано у словник")
{'хліб': 28.5, 'молоко': 32.0, 'яблука': 19.0}
✅ прайс розібрано у словник

4 · "w" стирає файл у мить відкриття¶

Найдорожча пастка теми. Відкриємо файл на запис без with — щоб зазирнути в проміжок між open і write, коли ще нічого не записано, а вміст уже зник.

In [9]:
розмір_до = прайс.stat().st_size

# навмисно без with: нам треба подивитись на файл ДО того, як щось записали
f = open(прайс, "w", encoding="utf-8")
розмір_після_open = прайс.stat().st_size      # жодного write ще не було
f.close()

print("байтів до open :", розмір_до)
print("байтів одразу після open:", розмір_після_open)
assert розмір_до == 50 and розмір_після_open == 0, "«w» мав обрізати файл ще до write"
print("✅ підтверджено: три рядки прайсу зникли від самого факту відкриття")
байтів до open : 50
байтів одразу після open: 0
✅ підтверджено: три рядки прайсу зникли від самого факту відкриття

Прайс ми щойно знищили — відновимо його. Path.write_text робить те саме, що with open(..., "w") плюс один write, тільки коротше.

In [10]:
відновлено_символів = прайс.write_text("\n".join(рядки_прайсу) + "\n", encoding="utf-8")

print("write_text повернув кількість записаних СИМВОЛІВ:", відновлено_символів)
print("а на диску БАЙТІВ:", прайс.stat().st_size)
assert прайс.stat().st_size == розмір_до, "відновлений файл має бути таким самим"
print("✅ прайс на місці; символів менше, ніж байтів, — бо кирилиця в UTF-8 двобайтова")
write_text повернув кількість записаних СИМВОЛІВ: 34
а на диску БАЙТІВ: 50
✅ прайс на місці; символів менше, ніж байтів, — бо кирилиця в UTF-8 двобайтова

5 · Режими r, a, x і їхні винятки¶

Три ситуації з таблиці режимів, кожна зі своїм винятком. Ловимо їх через try/except з теми 18, щоб зошит не падав, а показував.

In [11]:
немає_такого = робоча_тека / "nemaye.txt"

try:
    with open(немає_такого, "r", encoding="utf-8") as f:
        f.read()
except FileNotFoundError as e:
    print("режим «r» на відсутньому файлі →", type(e).__name__)
    print("повідомлення:", e)

print("чи створив «r» файл?", немає_такого.exists())
assert not немає_такого.exists(), "«r» не має нічого створювати"
режим «r» на відсутньому файлі → FileNotFoundError
повідомлення: [Errno 2] No such file or directory: '/tmp/praktyka-20-s26s5cdz/nemaye.txt'
чи створив «r» файл? False
In [12]:
try:
    with open(прайс, "x", encoding="utf-8") as f:
        f.write("це ніколи не запишеться\n")
except FileExistsError as e:
    print("режим «x» на наявному файлі →", type(e).__name__)

print("байтів у прайсі після невдалої спроби:", прайс.stat().st_size)
assert прайс.stat().st_size == 50, "«x» не має чіпати наявний файл"
print("✅ саме за це «x» і люблять: помилка замість тихого знищення")
режим «x» на наявному файлі → FileExistsError
байтів у прайсі після невдалої спроби: 50
✅ саме за це «x» і люблять: помилка замість тихого знищення
In [13]:
with open(прайс, "a", encoding="utf-8") as f:
    f.write("сіль;12.0\n")          # «a» завжди пише в кінець, хай там що

рядки_після_дозапису = прайс.read_text(encoding="utf-8").splitlines()

print("рядків стало:", len(рядки_після_дозапису))
for рядок in рядки_після_дозапису:
    print("  ", рядок)
assert len(рядки_після_дозапису) == 4 and рядки_після_дозапису[0] == "хліб;28.5"
print("✅ старі три рядки цілі, четвертий дописано в хвіст")
рядків стало: 4
   хліб;28.5
   молоко;32.0
   яблука;19.0
   сіль;12.0
✅ старі три рядки цілі, четвертий дописано в хвіст

6 · with проти наївного close() на коді, що падає¶

Той самий код із інтерактиву 2. Спершу наївний варіант: close() стоїть після рядка, який кине ZeroDivisionError.

In [14]:
звіт_наївно = робоча_тека / "zvit-naivno.txt"

f = open(звіт_наївно, "w", encoding="utf-8")
try:
    f.write("молоко;32.0\n")
    ціна_за_літр = 32.0 / 0        # ось тут усе й обірветься
    f.write(f"за літр: {ціна_за_літр}\n")
    f.close()                      # цей рядок недосяжний
except ZeroDivisionError:
    print("виняток спіймано зовні")

print("файл закрито?", f.closed)
print("байтів на диску:", звіт_наївно.stat().st_size)
assert not f.closed, "close() не викликано — файл мусить лишитись відкритим"
assert звіт_наївно.stat().st_size == 0, "буфер не скинуто — на диску має бути порожньо"
print("✅ файл відкритий, записаний рядок висить у буфері, на диску нуль байтів")

f.close()   # прибираємо за собою руками, бо автоматика тут не спрацювала
виняток спіймано зовні
файл закрито? False
байтів на диску: 0
✅ файл відкритий, записаний рядок висить у буфері, на диску нуль байтів

Тепер той самий код у with. Виняток нікуди не подівся — він так само летить далі, але файл закрито, а буфер скинуто.

In [15]:
звіт_with = робоча_тека / "zvit-with.txt"

try:
    with open(звіт_with, "w", encoding="utf-8") as g:
        g.write("молоко;32.0\n")
        ціна_за_літр = 32.0 / 0
        g.write(f"за літр: {ціна_за_літр}\n")
except ZeroDivisionError:
    print("виняток спіймано зовні — with його НЕ проковтнув")

print("файл закрито?", g.closed)
print("байтів на диску:", звіт_with.stat().st_size)
assert g.closed, "with мусить закрити файл навіть після винятку"
assert звіт_with.stat().st_size == len("молоко;32.0\n".encode("utf-8"))
print("✅ 18 байтів долетіли до диска, файл закрито — і все це без жодного close() у коді")
виняток спіймано зовні — with його НЕ проковтнув
файл закрито? True
байтів на диску: 18
✅ 18 байтів долетіли до диска, файл закрито — і все це без жодного close() у коді

7 · Кодування: рахуємо байти й відтворюємо мотлох¶

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

In [16]:
слово = "хліб;28.5"

у_utf8 = слово.encode("utf-8")
у_cp1251 = слово.encode("cp1251")

print("символів у рядку:", len(слово))
print("utf-8 :", у_utf8.hex(" ").upper(), "→", len(у_utf8), "байтів")
print("cp1251:", у_cp1251.hex(" ").upper(), "→", len(у_cp1251), "байтів")
assert len(у_utf8) == 13 and len(у_cp1251) == 9
print("✅ ті самі 13 і 9 байтів, що в інтерактиві 3")
символів у рядку: 9
utf-8 : D1 85 D0 BB D1 96 D0 B1 3B 32 38 2E 35 → 13 байтів
cp1251: F5 EB B3 E1 3B 32 38 2E 35 → 9 байтів
✅ ті самі 13 і 9 байтів, що в інтерактиві 3

Тепер тиха біда: записали UTF-8, прочитали cp1251. Винятку не буде — буде мотлох, і програма спокійно поїде далі.

In [17]:
файл_utf8 = робоча_тека / "utf8.txt"
файл_utf8.write_text(слово, encoding="utf-8")

мотлох = файл_utf8.read_text(encoding="cp1251")

print("оригінал:", слово, "→", len(слово), "символів")
print("прочитане:", мотлох, "→", len(мотлох), "символів")
assert мотлох == "хліб;28.5", "мотлох має бути саме таким, як у лекції"
print("✅ 13 байтів витлумачено як 13 однобайтових символів — і жодної скарги")
оригінал: хліб;28.5 → 9 символів
прочитане: хліб;28.5 → 13 символів
✅ 13 байтів витлумачено як 13 однобайтових символів — і жодної скарги

А тепер гучна біда: записали cp1251, читаємо UTF-8. Байт 0xF5 не може починати символ у UTF-8, тому Python зупиняється на найпершому байті.

In [18]:
файл_cp1251 = робоча_тека / "cp1251.txt"
файл_cp1251.write_text(слово, encoding="cp1251")

try:
    файл_cp1251.read_text(encoding="utf-8")
except UnicodeDecodeError as e:
    print("тип:", type(e).__name__)
    print("повідомлення:", e)
    # у винятку лежать самі байти й точна позиція — розбирати текст помилки не треба
    print("позиція:", e.start, "· проблемний байт:", hex(e.object[e.start]))
    assert e.start == 0 and e.object[e.start] == 0xF5

print("✅ гучна біда краща за тиху: ми дізнались про неї одразу")
тип: UnicodeDecodeError
повідомлення: 'utf-8' codec can't decode byte 0xf5 in position 0: invalid start byte
позиція: 0 · проблемний байт: 0xf5
✅ гучна біда краща за тиху: ми дізнались про неї одразу

І чесний аварійний вихід: errors="replace". Він рятує від падіння, але це втрата даних — переконаймось у цьому числами.

In [19]:
з_заміною = файл_cp1251.read_text(encoding="utf-8", errors="replace")

print("що вийшло:", repr(з_заміною))
print("символів:", len(з_заміною), "· в оригіналі:", len(слово))
assert з_заміною != слово, "після заміни це вже не той самий текст"
print("⚠️ падіння немає, але кириличні літери зникли безповоротно")
що вийшло: '���;28.5'
символів: 8 · в оригіналі: 9
⚠️ падіння немає, але кириличні літери зникли безповоротно

8 · Path розібраний на частини¶

Той самий шлях, що в інтерактиві 5. Головна перевірка — .stem і .suffixes на назві з двома крапками.

In [20]:
шлях = Path("/home/olena") / "dani" / "prays.2026.csv"

print("повний шлях :", шлях)
print(".parent     :", шлях.parent)
print(".name       :", шлях.name)
print(".stem       :", шлях.stem)
print(".suffix     :", шлях.suffix)
print(".suffixes   :", шлях.suffixes)
print(".parts      :", шлях.parts)

assert шлях.stem == "prays.2026", "«2026» — частина назви, а не розширення"
assert шлях.suffix == ".csv"
assert шлях.suffixes == [".2026", ".csv"]
assert len(шлях.parts) == 5
print("✅ усе збіглося з інтерактивом 5")
повний шлях : /home/olena/dani/prays.2026.csv
.parent     : /home/olena/dani
.name       : prays.2026.csv
.stem       : prays.2026
.suffix     : .csv
.suffixes   : ['.2026', '.csv']
.parts      : ('/', 'home', 'olena', 'dani', 'prays.2026.csv')
✅ усе збіглося з інтерактивом 5

І доказ того, що Path не торкається диска, доки ти не попросиш: обʼєкт для неіснуючого файлу створюється без жодної скарги.

In [21]:
вигаданий = Path("/nemaye/takoyi/teky/zvit.csv")

print("обʼєкт створено:", вигаданий)
print("розширення:", вигаданий.suffix, "· тека:", вигаданий.parent.name)
print("а файл існує?", вигаданий.exists())
assert not вигаданий.exists()
print("✅ Path — це текст із розумом, а не запит до файлової системи")
обʼєкт створено: /nemaye/takoyi/teky/zvit.csv
розширення: .csv · тека: teky
а файл існує? False
✅ Path — це текст із розумом, а не запит до файлової системи

9 · Дерево тек через mkdir(parents=True, exist_ok=True)¶

Відтворимо дерево з інтерактиву 6 — рівно ті самі девʼять файлів у трьох теках.

In [22]:
projekt = робоча_тека / "projekt"

# parents=True створює й «projekt», і «dani», і «stari» одним викликом
(projekt / "dani" / "stari").mkdir(parents=True, exist_ok=True)
(projekt / "utils").mkdir(parents=True, exist_ok=True)

файли_дерева = [
    "main.py", "README.md", "prays.txt",
    "dani/sichen.csv", "dani/lyutyi.csv",
    "dani/stari/2024.csv", "dani/stari/nazvy.txt",
    "utils/__init__.py", "utils/chytach.py",
]
for відносний in файли_дерева:
    (projekt / відносний).write_text("# заглушка\n", encoding="utf-8")

усе_дерево = list(projekt.rglob("*"))
print("файлів створено:", len(файли_дерева))
print("усього записів у дереві (файли + теки):", len(усе_дерево))
assert len(усе_дерево) == 12, "у лекції в дереві рівно 12 записів"
print("✅ дерево таке саме, як в інтерактиві 6")
файлів створено: 9
усього записів у дереві (файли + теки): 12
✅ дерево таке саме, як в інтерактиві 6

Навіщо потрібен exist_ok=True? Щоб повторний запуск програми не падав. Покажемо обидві поведінки поруч.

In [23]:
(projekt / "dani" / "stari").mkdir(parents=True, exist_ok=True)
print("повторний mkdir з exist_ok=True: тиша, як і має бути")

try:
    (projekt / "dani" / "stari").mkdir(parents=True)   # без exist_ok
except FileExistsError as e:
    print("той самий рядок без exist_ok →", type(e).__name__)

print("✅ саме тому exist_ok=True пишуть майже завжди: повторний запуск — не аварія")
повторний mkdir з exist_ok=True: тиша, як і має бути
той самий рядок без exist_ok → FileExistsError
✅ саме тому exist_ok=True пишуть майже завжди: повторний запуск — не аварія

10 · Усі шість шаблонів glob на справжніх файлах¶

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

In [24]:
очікувана_кількість = {"*.py": 1, "*": 5, "*/*.csv": 2, "**/*.csv": 3, "**/*.py": 3, "**/*": 12}

for шаблон, очікуємо in очікувана_кількість.items():
    # relative_to робить вивід читабельним: без довгого шляху тимчасової теки
    знайдено = sorted(п.relative_to(projekt).as_posix() for п in projekt.glob(шаблон))
    print(f"{шаблон:<9} → {len(знайдено):>2}  {знайдено}")
    assert len(знайдено) == очікуємо, f"{шаблон}: очікували {очікуємо}, знайшли {len(знайдено)}"

print("\n✅ усі шість шаблонів дали рівно ті числа, що й інтерактив 6")
*.py      →  1  ['main.py']
*         →  5  ['README.md', 'dani', 'main.py', 'prays.txt', 'utils']
*/*.csv   →  2  ['dani/lyutyi.csv', 'dani/sichen.csv']
**/*.csv  →  3  ['dani/lyutyi.csv', 'dani/sichen.csv', 'dani/stari/2024.csv']
**/*.py   →  3  ['main.py', 'utils/__init__.py', 'utils/chytach.py']
**/*      → 12  ['README.md', 'dani', 'dani/lyutyi.csv', 'dani/sichen.csv', 'dani/stari', 'dani/stari/2024.csv', 'dani/stari/nazvy.txt', 'main.py', 'prays.txt', 'utils', 'utils/__init__.py', 'utils/chytach.py']

✅ усі шість шаблонів дали рівно ті числа, що й інтерактив 6

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

In [25]:
результат = projekt.glob("*")

print("тип того, що повернув glob:", type(результат).__name__)
print("надрукувати напряму марно:", результат)

усі_знахідки = list(результат)
теки = [п.name for п in усі_знахідки if п.is_dir()]
файли = [п.name for п in усі_знахідки if п.is_file()]

print("теки :", sorted(теки))
print("файли:", sorted(файли))
assert len(теки) == 2, "серед пʼяти знахідок дві мають бути теками"
print("✅ glob не фільтрує за типом — це робота .is_file() і .is_dir()")
тип того, що повернув glob:
 generator
надрукувати напряму марно: <generator object Path.glob at 0x7fb4d44cabd0>
теки : ['dani', 'utils']
файли: ['README.md', 'main.py', 'prays.txt']
✅ glob не фільтрує за типом — це робота .is_file() і .is_dir()

11 · Скільки байтів насправді на диску¶

Повторимо інтерактив 7 на справжньому файлі: запишемо 100 рядків і подивимось на розмір файлу до flush() і після.

In [26]:
журнал = робоча_тека / "zhurnal.txt"
рядок_журналу = "хліб;28.5\n"

f = open(журнал, "w", encoding="utf-8")
for _ in range(100):
    f.write(рядок_журналу)

розмір_до_flush = журнал.stat().st_size      # файл ще відкритий, буфер не скинуто
f.flush()
розмір_після_flush = журнал.stat().st_size
f.close()

записано_байтів = 100 * len(рядок_журналу.encode("utf-8"))
print("ми записали байтів:", записано_байтів)
print("на диску ДО flush() :", розмір_до_flush)
print("на диску ПІСЛЯ flush():", розмір_після_flush)
assert розмір_до_flush == 0, "1400 байтів менші за буфер — на диску має бути нуль"
assert розмір_після_flush == записано_байтів
print("✅ доки буфер не скинуто, файл на диску порожній — саме про це інтерактив 7")
ми записали байтів: 1400
на диску ДО flush() : 0
на диску ПІСЛЯ flush(): 1400
✅ доки буфер не скинуто, файл на диску порожній — саме про це інтерактив 7

12 · Прибирання: зошит не лишає сміття¶

shutil.rmtree видаляє теку разом із усім вмістом. Перед таким викликом варто подивитись на змінну зі шляхом двічі — у нас це гарантовано тимчасова тека.

In [27]:
print("прибираємо:", робоча_тека)
print("усередині було записів:", len(list(робоча_тека.rglob("*"))))

shutil.rmtree(робоча_тека)

print("тека ще існує?", робоча_тека.exists())
assert not робоча_тека.exists(), "тимчасова тека мала зникнути"
print("✅ зошит не лишив по собі жодного файлу")
прибираємо: /tmp/praktyka-20-s26s5cdz
усередині було записів: 19
тека ще існує? False
✅ зошит не лишив по собі жодного файлу

Завдання¶

Роби їх у новій тимчасовій теці — рецепт той самий, що в клітинці 1, і не забудь shutil.rmtree наприкінці.

🟢 Рівень 1 — База¶

Напиши функцію зберегти_прайс(шлях, товари), яка приймає словник {назва: ціна} і записує його у файл у форматі назва;ціна, по рядку на товар. Друга функція прочитати_прайс(шлях) має повернути такий самий словник.

Зроблено, якщо: assert прочитати_прайс(p) == товари проходить для словника з щонайменше пʼяти товарів з українськими назвами, а open у обох функціях має явний encoding="utf-8".

🟡 Рівень 2 — Плюс¶

Зроби так, щоб зберегти_прайс відмовлялась затирати наявний файл: якщо файл існує, вона має кинути FileExistsError із зрозумілим повідомленням, а не мовчки перезаписати. Додай аргумент перезаписати=False, який цю поведінку вимикає.

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

🔴 Рівень 3 — Виклик¶

Напиши функцію звести_прайси(тека), яка знаходить усі файли *.csv у теці й усіх вкладених теках, читає їх і повертає один спільний словник товарів. Файли, записані в cp1251, теж мають прочитатись: якщо utf-8 дає UnicodeDecodeError, спробуй cp1251, а якщо і він не спрацював — пропусти файл і додай його імʼя в список проблемних.

Зроблено, якщо: ти будуєш дерево щонайменше з трьох рівнів, кладеш туди один файл у cp1251, один у utf-8 і один навмисно зіпсований (запиши в нього випадкові байти через write_bytes), а функція повертає правильний словник і рівно одне проблемне імʼя. Порядок обходу зафіксуй через sorted, інакше тест буде плаваючим.