منابع ترجمه · Think Python · فصل ۱۳

فایل‌ها و پایگاه‌های داده (فصل ۱۳ Think Python)

۲۴ دقیقه مطالعه · به‌روزرسانی ۲ آبان ۱۴۰۵

منبع اصلی: Think Python, Chapter 13: Files and Databases — Allen B. Downey

💡 این صفحه مثل یک نوت‌بوک Jupyter است: متغیر و تابعی که در یک سلول تعریف شود، در سلول‌های بعدی همین صفحه در دسترس است. سلول‌ها را به ترتیب اجرا کنید، یا با «⏮ تا اینجا» همهٔ سلول‌های قبلیِ اجرانشده را یک‌جا اجرا کنید.

چند نکته درباره‌ی این ترجمه: (۱) در کتاب اصلی، نام کاربری در مسیر پوشه‌ی خانگی نمونه به یک شخصیت طنز اشاره دارد؛ من آن را با نام ساده‌ی student جایگزین کرده‌ام. (۲) عکس‌های این فصل از Lorem Picsum، سرویسی برای عکس‌های جانگهدار، گرفته شده‌اند و در فایل photos.zip کتاب هستند. (۳) خروجی‌های نمایش‌داده‌شده‌ی زیر کدها را با اجرای همین کدها روی فایل‌های کتاب به دست آورده‌ام. ترتیب فهرست فایل‌ها و نام فایل‌هایی که shelve می‌سازد به سیستم‌عامل و پیاده‌سازی dbm بستگی دارد؛ این موارد را در متن توضیح داده‌ام.

سلول‌های زیر در نوت‌بوک اصلی فایل‌های مثال این فصل را آماده می‌کنند:

# This cell downloads an archive file that contains the the files we'll
# use for the examples in this chapter.

download('https://github.com/AllenDowney/ThinkPython/raw/v3/photos.zip');
# WARNING: This cell removes the photos/ directory if it already exists.
# Any files already in the photos/ directory will be deleted.

# !rm -rf photos/
!unzip -o photos.zip

بیشتر برنامه‌هایی که تا الان دیده‌ایم زودگذر (ephemeral) هستند، به این معنا که مدت کوتاهی اجرا می‌شوند و خروجی تولید می‌کنند، اما وقتی تمام می‌شوند، داده‌هایشان از بین می‌رود. هر بار که یک برنامه‌ی زودگذر را اجرا می‌کنید، از صفر شروع می‌شود.

برنامه‌های دیگر پایدار (persistent) هستند: مدت طولانی (یا همیشه) اجرا می‌شوند؛ دست‌کم بخشی از داده‌هایشان را در ذخیره‌سازی بلندمدت نگه می‌دارند؛ و اگر خاموش و دوباره راه‌اندازی شوند، از همان جایی که بودند ادامه می‌دهند.

یک راه ساده برای اینکه برنامه‌ها داده‌هایشان را نگه دارند خواندن و نوشتن فایل‌های متنی است. جایگزین همه‌کاره‌تر، ذخیره‌ی داده در یک پایگاه داده است. پایگاه‌های داده فایل‌های تخصصی‌ای هستند که کارآمدتر از فایل‌های متنی خوانده و نوشته می‌شوند و قابلیت‌های اضافی دارند.

در این فصل برنامه‌هایی می‌نویسیم که فایل‌های متنی و پایگاه‌های داده را می‌خوانند و می‌نویسند، و به‌عنوان تمرین برنامه‌ای می‌نویسید که مجموعه‌ای از عکس‌ها را برای پیدا کردن تکراری‌ها جست‌وجو می‌کند. اما قبل از اینکه با یک فایل کار کنید، باید آن را پیدا کنید، پس با نام فایل‌ها، مسیرها و دایرکتوری‌ها شروع می‌کنیم.

۱۳.۱. نام فایل‌ها و مسیرها

فایل‌ها در دایرکتوری‌ها (directories) که «پوشه» (folder) هم نامیده می‌شوند سازمان‌دهی می‌شوند. هر برنامه‌ی در حال اجرا یک دایرکتوری کاری فعلی (current working directory) دارد که دایرکتوری پیش‌فرض بیشتر عملیات است. برای مثال، وقتی یک فایل را باز می‌کنید، پایتون آن را در دایرکتوری کاری فعلی جست‌وجو می‌کند.

ماژول os توابعی برای کار با فایل‌ها و دایرکتوری‌ها دارد («os» مخفف «operating system»، یعنی سیستم‌عامل است). تابعی به نام getcwd دارد که نام دایرکتوری کاری فعلی را می‌گیرد.

# this cell replaces `os.cwd` with a function that returns a fake path

import os

def getcwd():
    return "/home/student"

os.getcwd = getcwd
import os

os.getcwd()
'/home/student'

نتیجه در این مثال دایرکتوری خانگی کاربری به نام student است. رشته‌ای مثل '/home/student' که یک فایل یا دایرکتوری را مشخص می‌کند مسیر (path) نامیده می‌شود.

نام ساده‌ی فایل مثل 'memo.txt' هم یک مسیر به حساب می‌آید، اما مسیر نسبی (relative path) است چون نام فایل را نسبت به دایرکتوری فعلی مشخص می‌کند. در این مثال، دایرکتوری فعلی /home/student است، پس 'memo.txt' معادل مسیر کامل '/home/student/memo.txt' است.

مسیری که با / شروع می‌شود به دایرکتوری فعلی بستگی ندارد – مسیر مطلق (absolute path) نامیده می‌شود. برای پیدا کردن مسیر مطلق یک فایل، می‌توانید از abspath استفاده کنید.

os.path.abspath('memo.txt')
'/home/student/memo.txt'

ماژول os توابع دیگری برای کار با نام فایل‌ها و مسیرها دارد. listdir فهرستی از محتویات دایرکتوری داده‌شده را، شامل فایل‌ها و دایرکتوری‌های دیگر، برمی‌گرداند. این مثالی است که محتویات دایرکتوری‌ای به نام photos را فهرست می‌کند.

os.listdir('photos')
['feb-2023', 'jan-2023', 'notes.txt', 'mar-2023']

(ترتیب عنصرها به سیستم‌عامل بستگی دارد و ممکن است در رایانه‌ی شما فرق کند.)

این دایرکتوری یک فایل متنی به نام notes.txt و سه دایرکتوری دارد. دایرکتوری‌ها فایل‌های تصویری با قالب JPEG دارند.

os.listdir('photos/jan-2023')
['photo2.jpg', 'photo3.jpg', 'photo1.jpg']

برای بررسی اینکه فایل یا دایرکتوری وجود دارد یا نه، می‌توانیم از os.path.exists استفاده کنیم.

os.path.exists('photos')
True
os.path.exists('photos/apr-2023')
False

برای بررسی اینکه مسیر به یک فایل اشاره می‌کند یا دایرکتوری، می‌توانیم از isdir استفاده کنیم که اگر مسیر به دایرکتوری اشاره کند True برمی‌گرداند.

os.path.isdir('photos')
True

و isfile که اگر مسیر به فایل اشاره کند True برمی‌گرداند.

os.path.isfile('photos/notes.txt')
True

یک چالش کار با مسیرها این است که در سیستم‌عامل‌های مختلف شکل متفاوتی دارند. در macOS و سیستم‌های یونیکس مثل لینوکس، نام دایرکتوری‌ها و فایل‌های یک مسیر با اسلش رو به جلو، /، از هم جدا می‌شوند. ویندوز از اسلش رو به عقب، \، استفاده می‌کند. پس اگر این مثال‌ها را در ویندوز اجرا کنید، در مسیرها اسلش رو به عقب می‌بینید و باید اسلش‌های رو به جلوی مثال‌ها را جایگزین کنید.

یا، برای نوشتن کدی که روی هر دو سیستم کار کند، می‌توانید از os.path.join استفاده کنید که نام دایرکتوری‌ها و فایل‌ها را بسته به سیستم‌عاملی که استفاده می‌کنید با اسلش رو به جلو یا رو به عقب به یک مسیر تبدیل می‌کند.

os.path.join('photos', 'jan-2023', 'photo1.jpg')
'photos/jan-2023/photo1.jpg'

در ادامه‌ی این فصل از این توابع برای جست‌وجوی مجموعه‌ای از دایرکتوری‌ها و پیدا کردن همه‌ی فایل‌های تصویری استفاده می‌کنیم.

۱۳.۲. f-string ها

یک راه برای اینکه برنامه‌ها داده‌ها را ذخیره کنند نوشتن آن‌ها در یک فایل متنی است. برای مثال، فرض کنید شما یک «شتردیده‌بان» هستید و می‌خواهید تعداد شترهایی را که در یک دوره‌ی مشاهده دیده‌اید ثبت کنید. و فرض کنید در یک سال و نیم 23 شتر دیده‌اید. داده‌ها در دفتر شترشماری شما ممکن است این‌طور باشد.

num_years = 1.5
num_camels = 23

برای نوشتن این داده‌ها در یک فایل، می‌توانید از متد write که در فصل ۸ دیدیم استفاده کنید. آرگومان write باید رشته باشد، پس اگر بخواهیم مقدارهای دیگر را در فایل بگذاریم، باید آن‌ها را به رشته تبدیل کنیم. آسان‌ترین راه برای این کار تابع داخلی str است.

این‌طور به نظر می‌رسد:

writer = open('camel-spotting-book.txt', 'w')
writer.write(str(num_years))
writer.write(str(num_camels))
writer.close()

این کار می‌کند، اما write فاصله یا خط جدید اضافه نمی‌کند مگر اینکه صریحاً بگذارید. اگر فایل را دوباره بخوانیم، می‌بینیم دو عدد به هم چسبیده‌اند.

open('camel-spotting-book.txt').read()
'1.523'

دست‌کم باید بین عددها فاصله بگذاریم. و حالا که این کار را می‌کنیم، متن توضیحی هم اضافه کنیم.

برای نوشتن ترکیبی از رشته‌ها و مقدارهای دیگر، می‌توانیم از f-string استفاده کنیم، که رشته‌ای است که حرف f قبل از علامت نقل‌قول بازکننده دارد و یک یا چند عبارت پایتون در آکولاد دارد. f-string زیر یک عبارت دارد، که یک نام متغیر است.

f'I have spotted {num_camels} camels'
'I have spotted 23 camels'

نتیجه رشته‌ای است که در آن عبارت ارزیابی شده و با نتیجه جایگزین شده است. می‌تواند بیش از یک عبارت وجود داشته باشد.

f'In {num_years} years I have spotted {num_camels} camels'
'In 1.5 years I have spotted 23 camels'

و عبارت‌ها می‌توانند عملگر و فراخوانی تابع داشته باشند.

line = f'In {round(num_years * 12)} months I have spotted {num_camels} camels'
line
'In 18 months I have spotted 23 camels'

پس می‌توانیم داده‌ها را این‌طور در یک فایل متنی بنویسیم.

writer = open('camel-spotting-book.txt', 'w')
writer.write(f'Years of observation: {num_years}\n')
writer.write(f'Camels spotted: {num_camels}\n')
writer.close()

هر دو f-string به دنباله‌ی \n ختم می‌شوند که یک نویسه‌ی خط جدید اضافه می‌کند.

می‌توانیم فایل را این‌طور دوباره بخوانیم:

data = open('camel-spotting-book.txt').read()
print(data)
Years of observation: 1.5
Camels spotted: 23

در f-string، عبارت داخل آکولاد به رشته تبدیل می‌شود، پس می‌توانید فهرست، دیکشنری و نوع‌های دیگر را هم بگنجانید.

t = [1, 2, 3]
d = {'one': 1}
f'Here is a list {t} and a dictionary {d}'
"Here is a list [1, 2, 3] and a dictionary {'one': 1}"

اگر f-string عبارت نامعتبری داشته باشد، نتیجه خطاست.

f'This is not a valid expression {t + 2}'
TypeError: can only concatenate list (not "int") to list

۱۳.۳. YAML

یکی از دلایل خواندن و نوشتن فایل در برنامه‌ها ذخیره‌ی داده‌های پیکربندی (configuration data) است، یعنی اطلاعاتی که مشخص می‌کند برنامه چه کاری و چگونه باید انجام دهد.

برای مثال، در برنامه‌ای که عکس‌های تکراری را جست‌وجو می‌کند، ممکن است دیکشنری‌ای به نام config داشته باشیم که نام دایرکتوری‌ای که باید جست‌وجو شود، نام دایرکتوری دیگری که باید نتیجه‌ها را در آن ذخیره کند و فهرستی از پسوندهای فایلی که باید برای شناسایی فایل‌های تصویری به کار ببرد را در بر دارد.

ممکن است این‌طور باشد:

config = {
    'photo_dir': 'photos',
    'data_dir': 'photo_info',
    'extensions': ['jpg', 'jpeg'],
}

برای نوشتن این داده‌ها در یک فایل متنی، می‌توانیم مثل بخش قبل از f-string ها استفاده کنیم. اما آسان‌تر است که از ماژولی به نام yaml استفاده کنیم که دقیقاً برای همین کار طراحی شده است.

ماژول yaml توابعی برای کار با فایل‌های YAML فراهم می‌کند، که فایل‌های متنی‌ای هستند که طوری قالب‌بندی شده‌اند که هم برای انسان‌ها و هم برای برنامه‌ها خواندن و نوشتنشان آسان باشد.

این مثالی است که از تابع dump برای نوشتن دیکشنری config در یک فایل YAML استفاده می‌کند.

# this cell installs the pyyaml package, which provides the yaml module

try:
    import yaml
except ImportError:
    !pip install pyyaml
import yaml

config_filename = 'config.yaml'
writer = open(config_filename, 'w')
yaml.dump(config, writer)
writer.close()

اگر محتویات فایل را دوباره بخوانیم، می‌توانیم ببینیم قالب YAML چه شکلی است.

readback = open(config_filename).read()
print(readback)
data_dir: photo_info
extensions:
- jpg
- jpeg
photo_dir: photos

حالا می‌توانیم از safe_load برای خواندن دوباره‌ی فایل YAML استفاده کنیم.

reader = open(config_filename)
config_readback = yaml.safe_load(reader)
config_readback
{'data_dir': 'photo_info',
 'extensions': ['jpg', 'jpeg'],
 'photo_dir': 'photos'}

نتیجه دیکشنری جدیدی است که اطلاعات یکسانی با دیکشنری اصلی دارد، اما همان دیکشنری نیست.

config is config_readback
False

تبدیل یک شیء مثل دیکشنری به رشته سریال‌سازی (serialization) نامیده می‌شود. تبدیل رشته به شیء سریال‌زدایی (deserialization) نام دارد. اگر یک شیء را سریال کنید و بعد سریال‌زدایی کنید، نتیجه باید هم‌ارز با شیء اصلی باشد.

۱۳.۴. Shelve

تا اینجا فایل‌های متنی را می‌خواندیم و می‌نوشتیم – حالا پایگاه‌های داده را بررسی کنیم. پایگاه داده (database) فایلی است که برای ذخیره‌ی داده سازمان‌دهی شده است. بعضی پایگاه‌های داده مثل جدولی با سطرها و ستون‌های اطلاعات سازمان‌دهی شده‌اند. بعضی دیگر مثل دیکشنری هستند که کلیدها را به مقدارها نگاشت می‌کنند؛ این‌ها گاهی ذخیره‌گاه کلید-مقدار (key-value store) نامیده می‌شوند.

ماژول shelve توابعی برای ساختن و به‌روزرسانی یک ذخیره‌گاه کلید-مقدار به نام «قفسه» (shelf) فراهم می‌کند. به‌عنوان مثال، قفسه‌ای می‌سازیم که توضیح‌های (caption) تصویرهای دایرکتوری photos را نگه دارد. از دیکشنری config برای گرفتن نام دایرکتوری‌ای که باید قفسه را در آن بگذاریم استفاده می‌کنیم.

config['data_dir']
'photo_info'

اگر این دایرکتوری از قبل وجود نداشته باشد، می‌توانیم با os.makedirs آن را بسازیم.

os.makedirs(config['data_dir'], exist_ok=True)

و از os.path.join برای ساختن مسیری که نام دایرکتوری و نام فایل قفسه، یعنی captions، را دارد.

db_file = os.path.join(config['data_dir'], 'captions')
db_file
'photo_info/captions'

حالا می‌توانیم از shelve.open برای باز کردن فایل قفسه استفاده کنیم. آرگومان c نشان می‌دهد که اگر لازم است فایل باید ساخته شود.

import shelve

db = shelve.open(db_file, 'c')
db
<shelve.DbfilenameShelf at 0x7f6c57af5550>

اگر خطایی مثل db type could not be determined گرفتید، محتمل‌ترین علت این است که فایلی با همین نام از قبل وجود دارد اما پایگاه داده‌ی shelve معتبری نیست (مثلاً ممکن است خراب شده باشد یا چیز دیگری آن را ساخته باشد).

در آن صورت، ساده‌ترین راه‌حل حذف فایل موجود و اجرای دوباره‌ی کد است تا shelve.open بتواند پایگاه داده‌ی جدیدی بسازد.

مقدار بازگشتی رسماً یک شیء DbfilenameShelf است که غیررسمی‌تر شیء قفسه نامیده می‌شود.

شیء قفسه از جهات زیادی مثل دیکشنری رفتار می‌کند. برای مثال، می‌توانیم از عملگر کروشه برای اضافه کردن یک مورد، که نگاشتی از یک کلید به یک مقدار است، استفاده کنیم.

key = 'jan-2023/photo1.jpg' 
db[key] = 'Cat nose'

در این مثال، کلید مسیر یک فایل تصویری و مقدار رشته‌ای است که تصویر را توصیف می‌کند.

همچنین از عملگر کروشه برای جست‌وجوی یک کلید و گرفتن مقدار متناظر استفاده می‌کنیم.

value = db[key]
value
'Cat nose'

اگر نسبت‌دهی دیگری به یک کلید موجود انجام دهید، shelve مقدار قدیمی را جایگزین می‌کند.

db[key] = 'Close up view of a cat nose'
db[key]
'Close up view of a cat nose'

بعضی متدهای دیکشنری، مثل keys، values و items، روی شیءهای قفسه هم کار می‌کنند.

list(db.keys())
['jan-2023/photo1.jpg']
list(db.values())
['Close up view of a cat nose']

می‌توانیم از عملگر in برای بررسی اینکه کلیدی در قفسه هست یا نه استفاده کنیم.

key in db
True

و می‌توانیم از دستور for برای پیمایش کلیدها استفاده کنیم.

for key in db:
    print(key, ':', db[key])
jan-2023/photo1.jpg : Close up view of a cat nose

مثل فایل‌های دیگر، وقتی کارتان تمام شد باید پایگاه داده را ببندید.

db.close()

حالا اگر محتویات دایرکتوری داده را فهرست کنیم، دو فایل می‌بینیم.

# When you open a shelve file, a backup file is created that has the suffix `.bak`.
# If you run this notebook more than once, you might see that file left behind.
# This cell removes it so the output shown in the book is correct.

!rm -f photo_info/captions.bak
os.listdir(config['data_dir'])
['captions.dat', 'captions.dir']

(این خروجی همان است که در کتاب آمده. نام و تعداد فایل‌هایی که shelve می‌سازد به پیاده‌سازی dbm در سیستم شما بستگی دارد؛ روی بعضی سیستم‌ها فقط یک فایل با نام captions می‌بینید، که در اجرای من این‌طور بود.)

captions.dat داده‌ای را که همین الان ذخیره کردیم دارد. captions.dir اطلاعاتی درباره‌ی سازمان‌دهی پایگاه داده دارد که دسترسی به آن را کارآمدتر می‌کند. پسوند dir مخفف «directory» است، اما ربطی به دایرکتوری‌هایی که فایل‌ها را در خود دارند و تا الان با آن‌ها کار می‌کردیم ندارد.

۱۳.۵. ذخیره‌ی ساختارهای داده

در مثال قبل، کلیدها و مقدارهای قفسه رشته بودند. اما می‌توانیم از قفسه برای نگه داشتن ساختارهای داده مثل فهرست‌ها و دیکشنری‌ها هم استفاده کنیم.

به‌عنوان مثال، مثال آناگرام را از یکی از تمرین‌های فصل ۱۱ دوباره بررسی می‌کنیم. به یاد بیاورید که دیکشنری‌ای ساختیم که رشته‌ی مرتب‌شده‌ی حرف‌ها را به فهرست کلمه‌هایی که می‌توان با آن حرف‌ها نوشت نگاشت می‌کرد. برای مثال، کلید 'opst' به فهرست ['opts', 'post', 'pots', 'spot', 'stop', 'tops'] نگاشت می‌شود.

از تابع زیر برای مرتب کردن حرف‌های یک کلمه استفاده می‌کنیم.

def sort_word(word):
    return ''.join(sorted(word))

و این یک مثال است.

word = 'pots'
key = sort_word(word)
key
'opst'

حالا قفسه‌ای به نام anagram_map باز می‌کنیم. آرگومان 'n' یعنی همیشه باید یک قفسه‌ی جدید و خالی بسازیم، حتی اگر از قبل وجود داشته باشد.

db = shelve.open('anagram_map', 'n')

حالا می‌توانیم یک مورد را این‌طور به قفسه اضافه کنیم.

db[key] = [word]
db[key]
['pots']

در این مورد، کلید رشته و مقدار فهرستی از رشته‌هاست.

حالا فرض کنید کلمه‌ی دیگری پیدا کنیم که همان حرف‌ها را دارد، مثل tops.

word = 'tops'
key = sort_word(word)
key
'opst'

کلید همان کلید مثال قبلی است، پس می‌خواهیم کلمه‌ی دومی را به همان فهرست رشته‌ها اضافه کنیم. اگر db دیکشنری بود، این‌طور انجامش می‌دادیم.

db[key].append(word)          # INCORRECT

اما اگر این را اجرا کنیم و بعد کلید را در قفسه جست‌وجو کنیم، به نظر می‌رسد به‌روزرسانی نشده است.

db[key]
['pots']

مشکل این است: وقتی کلید را جست‌وجو می‌کنیم، فهرستی از رشته‌ها می‌گیریم، اما اگر فهرست رشته‌ها را تغییر دهیم، بر قفسه اثری ندارد. اگر بخواهیم قفسه را به‌روزرسانی کنیم، باید مقدار قدیمی را بخوانیم، به‌روزش کنیم و بعد مقدار جدید را دوباره در قفسه بنویسیم.

anagram_list = db[key]
anagram_list.append(word)
db[key] = anagram_list

حالا مقدار قفسه به‌روزرسانی شده است.

db[key]
['pots', 'tops']

به‌عنوان تمرین، می‌توانید این مثال را با خواندن فهرست کلمه‌ها و ذخیره‌ی همه‌ی آناگرام‌ها در یک قفسه کامل کنید.

db.close()

۱۳.۶. بررسی فایل‌های هم‌ارز

حالا به هدف این فصل برگردیم: جست‌وجوی فایل‌های متفاوتی که داده‌ی یکسانی دارند. یک راه برای بررسی این است که محتویات هر دو فایل را بخوانیم و مقایسه کنیم.

اگر فایل‌ها تصویر داشته باشند، باید آن‌ها را با حالت 'rb' باز کنیم، که 'r' یعنی می‌خواهیم محتویات را بخوانیم و 'b' حالت دودویی (binary mode) را نشان می‌دهد. در حالت دودویی، محتویات به‌عنوان متن تفسیر نمی‌شوند – با آن‌ها مثل دنباله‌ای از بایت‌ها رفتار می‌شود.

این مثالی است که یک فایل تصویری را باز می‌کند و می‌خواند.

path1 = 'photos/jan-2023/photo1.jpg'
data1 = open(path1, 'rb').read()
type(data1)
bytes

نتیجه‌ی read یک شیء bytes است – همان‌طور که از نامش پیداست، دنباله‌ای از بایت‌ها دارد.

به‌طور کلی محتویات یک فایل تصویری برای انسان خواناست. اما اگر محتویات فایل دومی را بخوانیم، می‌توانیم از عملگر == برای مقایسه استفاده کنیم. (در کتاب اصلی اینجا جمله‌ی «برای انسان خوانا نیست» آمده است؛ منظور این است که محتویات فایل تصویری را نمی‌توان به‌صورت متن خواند.)

path2 = 'photos/jan-2023/photo2.jpg'
data2 = open(path2, 'rb').read()
data1 == data2
False

این دو فایل هم‌ارز نیستند.

آنچه تا اینجا داریم را در یک تابع کپسوله می‌کنیم.

def same_contents(path1, path2):
    data1 = open(path1, 'rb').read()
    data2 = open(path2, 'rb').read()
    return data1 == data2

اگر فقط دو فایل داشته باشیم، این تابع گزینه‌ی خوبی است. اما فرض کنید تعداد زیادی فایل داریم و می‌خواهیم بدانیم آیا دو تا از آن‌ها داده‌ی یکسانی دارند. ناکارآمد است که هر جفت فایل را مقایسه کنیم.

یک جایگزین استفاده از تابع درهم‌ساز (hash function) است که محتویات یک فایل را می‌گیرد و یک چکیده (digest) حساب می‌کند، که معمولاً یک عدد صحیح بزرگ است. اگر دو فایل داده‌ی یکسانی داشته باشند، چکیده‌ی یکسانی خواهند داشت. اگر دو فایل فرق داشته باشند، تقریباً همیشه چکیده‌های متفاوتی دارند.

ماژول hashlib چند تابع درهم‌ساز فراهم می‌کند – آنی که استفاده می‌کنیم md5 نام دارد. با استفاده از hashlib.md5 یک شیء HASH می‌سازیم.

import hashlib

md5_hash = hashlib.md5()
type(md5_hash)
_hashlib.HASH

شیء HASH متدی به نام update دارد که محتویات فایل را به‌عنوان آرگومان می‌گیرد.

md5_hash.update(data1)

حالا می‌توانیم از hexdigest برای گرفتن چکیده به‌صورت رشته‌ای از ارقام شانزده‌شانزدهی (hexadecimal) استفاده کنیم که یک عدد صحیح را در مبنای ۱۶ نشان می‌دهند.

digest = md5_hash.hexdigest()
digest
'aa1d2fc25b7ae247b2931f5a0882fa37'

تابع زیر این مراحل را کپسوله می‌کند.

def md5_digest(filename):
    data = open(filename, 'rb').read()
    md5_hash = hashlib.md5()
    md5_hash.update(data)
    digest = md5_hash.hexdigest()
    return digest

اگر محتویات یک فایل دیگر را درهم‌سازی کنیم، می‌توانیم تأیید کنیم که چکیده‌ی متفاوتی می‌گیریم.

filename2 = 'photos/feb-2023/photo2.jpg'
md5_digest(filename2)
'6a501b11b01f89af9c3f6591d7f02c49'

حالا تقریباً هر چیزی را که برای پیدا کردن فایل‌های هم‌ارز لازم داریم داریم. قدم آخر جست‌وجوی یک دایرکتوری و پیدا کردن همه‌ی فایل‌های تصویری است.

۱۳.۷. پیمایش دایرکتوری‌ها

تابع زیر دایرکتوری‌ای را که می‌خواهیم جست‌وجو کنیم به‌عنوان آرگومان می‌گیرد. از listdir برای پیمایش محتویات دایرکتوری استفاده می‌کند. وقتی فایلی پیدا کند، مسیر کاملش را چاپ می‌کند. وقتی دایرکتوری‌ای پیدا کند، خودش را به‌صورت بازگشتی برای جست‌وجوی زیردایرکتوری فراخوانی می‌کند.

def walk(dirname):
    for name in os.listdir(dirname):
        path = os.path.join(dirname, name)

        if os.path.isfile(path):
            print(path)
        elif os.path.isdir(path):
            walk(path)

می‌توانیم آن را این‌طور به کار ببریم:

walk('photos')
photos/feb-2023/photo2.jpg
photos/feb-2023/photo1.jpg
photos/jan-2023/photo2.jpg
photos/jan-2023/photo3.jpg
photos/jan-2023/photo1.jpg
photos/notes.txt
photos/mar-2023/photo2.jpg
photos/mar-2023/photo1.jpg

ترتیب نتیجه‌ها به جزئیات سیستم‌عامل بستگی دارد.

۱۳.۸. اشکال‌زدایی

وقتی فایل می‌خوانید و می‌نویسید، ممکن است به مشکل فاصله‌ها (whitespace) بخورید. اشکال‌زدایی این خطاها دشوار است چون نویسه‌های فاصله معمولاً نامرئی هستند. برای مثال، این رشته‌ای است که فاصله، یک تب که با دنباله‌ی \t نشان داده شده و یک خط جدید که با دنباله‌ی \n نشان داده شده دارد. وقتی آن را چاپ می‌کنیم، نویسه‌های فاصله را نمی‌بینیم.

s = '1 2\t 3\n 4'
print(s)
1 2	 3
 4

تابع داخلی repr می‌تواند کمک کند. هر شیء را به‌عنوان آرگومان می‌گیرد و نمایش رشته‌ای شیء را برمی‌گرداند. برای رشته‌ها، نویسه‌های فاصله را با دنباله‌های بک‌اسلش نشان می‌دهد.

print(repr(s))
'1 2\t 3\n 4'

این می‌تواند برای اشکال‌زدایی مفید باشد.

مشکل دیگری که ممکن است به آن بخورید این است که سیستم‌های مختلف از نویسه‌های متفاوتی برای نشان دادن پایان یک خط استفاده می‌کنند. بعضی سیستم‌ها خط جدید، که با \n نشان داده می‌شود، به کار می‌برند. بعضی دیگر نویسه‌ی بازگشت (return)، که با \r نشان داده می‌شود. بعضی هر دو را. اگر فایل‌ها را بین سیستم‌های مختلف جابه‌جا کنید، این ناسازگاری‌ها می‌توانند مشکل ایجاد کنند.

بزرگی و کوچکی حروف نام فایل‌ها مسئله‌ی دیگری است که اگر با سیستم‌عامل‌های مختلف کار کنید ممکن است با آن مواجه شوید. در macOS و یونیکس، نام فایل‌ها می‌توانند حروف کوچک و بزرگ، رقم‌ها و بیشتر نمادها را داشته باشند. اما بسیاری از برنامه‌های ویندوز تفاوت حروف کوچک و بزرگ را نادیده می‌گیرند و چند نماد که در macOS و یونیکس مجازند در ویندوز مجاز نیستند.

۱۳.۹. واژه‌نامه

زودگذر (ephemeral): برنامه‌ی زودگذر معمولاً مدت کوتاهی اجرا می‌شود و وقتی تمام می‌شود داده‌هایش از بین می‌رود.

پایدار (persistent): برنامه‌ی پایدار نامحدود اجرا می‌شود و دست‌کم بخشی از داده‌هایش را در ذخیره‌سازی دائمی نگه می‌دارد.

دایرکتوری (directory): مجموعه‌ای از فایل‌ها و دایرکتوری‌های دیگر.

دایرکتوری کاری فعلی (current working directory): دایرکتوری پیش‌فرضی که یک برنامه استفاده می‌کند مگر اینکه دایرکتوری دیگری مشخص شود.

مسیر (path): رشته‌ای که دنباله‌ای از دایرکتوری‌ها را مشخص می‌کند، اغلب منتهی به یک فایل.

مسیر نسبی (relative path): مسیری که از دایرکتوری کاری فعلی، یا دایرکتوری مشخص‌شده‌ی دیگری، شروع می‌شود.

مسیر مطلق (absolute path): مسیری که به دایرکتوری فعلی بستگی ندارد.

f-string: رشته‌ای که حرف f قبل از علامت نقل‌قول بازکننده دارد و یک یا چند عبارت در آکولاد دارد.

داده‌های پیکربندی (configuration data): داده‌ای، اغلب ذخیره‌شده در یک فایل، که مشخص می‌کند برنامه چه کاری و چگونه باید انجام دهد.

سریال‌سازی (serialization): تبدیل یک شیء به رشته.

سریال‌زدایی (deserialization): تبدیل یک رشته به شیء.

پایگاه داده (database): فایلی که محتویاتش طوری سازمان‌دهی شده تا عملیات خاصی را کارآمد انجام دهد.

ذخیره‌گاه کلید-مقدار (key-value store): پایگاه داده‌ای که محتویاتش مثل دیکشنری سازمان‌دهی شده، با کلیدهایی که با مقدارها متناظرند.

حالت دودویی (binary mode): شیوه‌ای برای باز کردن فایل که محتویات را به‌جای دنباله‌ای از نویسه‌ها، به‌صورت دنباله‌ای از بایت‌ها تفسیر می‌کند.

تابع درهم‌ساز (hash function): تابعی که شیئی را می‌گیرد و عدد صحیحی حساب می‌کند که گاهی چکیده نامیده می‌شود.

چکیده (digest): نتیجه‌ی یک تابع درهم‌ساز، به‌ویژه وقتی برای بررسی یکسان بودن دو شیء به کار می‌رود.

۱۳.۱۰. تمرین‌ها

# This cell tells Jupyter to provide detailed debugging information
# when a runtime error occurs. Run it before working on the exercises.

%xmode Verbose

۱۳.۱۰.۱. از یک دستیار مجازی بپرسید

چند موضوع در این فصل مطرح شد که به تفصیل توضیح ندادم. این‌ها پرسش‌هایی هستند که می‌توانید از یک دستیار مجازی بپرسید تا اطلاعات بیشتری بگیرید.

  • “What are the differences between ephemeral and persistent programs?” (تفاوت برنامه‌های زودگذر و پایدار چیست؟)

  • “What are some examples of persistent programs?” (چند مثال از برنامه‌های پایدار کدام‌اند؟)

  • “What’s the difference between a relative path and an absolute path?” (تفاوت مسیر نسبی و مسیر مطلق چیست؟)

  • “Why does the yaml module have functions called load and safe_load?” (چرا ماژول yaml توابعی به نام load و safe_load دارد؟)

  • “When I write a Python shelf, what are the files with suffixes dat and dir?” (وقتی یک قفسه‌ی پایتون می‌نویسم، فایل‌هایی با پسوند dat و dir چیستند؟)

  • “Other than key-values stores, what other kinds of databases are there?” (غیر از ذخیره‌گاه‌های کلید-مقدار، چه نوع پایگاه داده‌های دیگری وجود دارد؟)

  • “When I read a file, what’s the difference between binary mode and text mode?” (وقتی فایلی را می‌خوانم، تفاوت حالت دودویی و حالت متنی چیست؟)

  • “What are the differences between a bytes object and a string?” (تفاوت شیء bytes و رشته چیست؟)

  • “What is a hash function?” (تابع درهم‌ساز چیست؟)

  • “What is an MD5 digest?” (چکیده‌ی MD5 چیست؟)

مثل همیشه، اگر در هر یک از تمرین‌های زیر گیر کردید، از یک دستیار مجازی کمک بگیرید. همراه با پرسش‌تان، ممکن است بخواهید توابع مربوط از این فصل را هم در آن بچسبانید.

۱۳.۱۰.۲. تمرین

تابعی به نام replace_all بنویسید که به‌عنوان آرگومان یک رشته‌ی الگو، یک رشته‌ی جایگزین و دو نام فایل بگیرد. باید فایل اول را بخواند و محتویاتش را در فایل دوم بنویسد (و اگر لازم است آن را بسازد). اگر رشته‌ی الگو در هر جای محتویات ظاهر شد، باید با رشته‌ی جایگزین عوض شود.

این یک طرح کلی از تابع برای شروع است.

def replace_all(old, new, source_path, dest_path):
    # read the contents of the source file
    reader = open(source_path)

    # replace the old string with the new
    
    # write the result into the destination file
# Solution goes here

برای آزمودن تابع‌تان، فایل photos/notes.txt را بخوانید، 'photos' را با 'images' جایگزین کنید و نتیجه را در فایل photos/new_notes.txt بنویسید.

source_path = 'photos/notes.txt'
open(source_path).read()
'These photos are from Lorem Picsum at https://picsum.photos\n'
dest_path = 'photos/new_notes.txt'
old = 'photos'
new = 'images'
replace_all(old, new, source_path, dest_path)
open(dest_path).read()

۱۳.۱۰.۳. تمرین

در بخش قبل از ماژول shelve برای ساختن یک ذخیره‌گاه کلید-مقدار استفاده کردیم که رشته‌ی مرتب‌شده‌ی حرف‌ها را به فهرستی از آناگرام‌ها نگاشت می‌کند. برای کامل کردن مثال، تابعی به نام add_word بنویسید که یک رشته و یک شیء قفسه را به‌عنوان آرگومان بگیرد.

باید حرف‌های کلمه را مرتب کند تا کلید بسازد، بعد بررسی کند که آیا کلید از قبل در قفسه هست یا نه. اگر نبود، باید فهرستی شامل کلمه‌ی جدید بسازد و به قفسه اضافه کند. اگر بود، باید کلمه‌ی جدید را به مقدار موجود اضافه کند.

# Solution goes here

می‌توانید از این حلقه برای آزمودن تابع‌تان استفاده کنید.

download('https://raw.githubusercontent.com/AllenDowney/ThinkPython/v3/words.txt');
word_list = open('words.txt').read().split()

db = shelve.open('anagram_map', 'n')
for word in word_list:
    add_word(word, db)

اگر همه‌چیز درست کار کند، باید بتوانید کلیدی مثل 'opst' را جست‌وجو کنید و فهرستی از کلمه‌هایی که می‌توان با آن حرف‌ها نوشت بگیرید.

db['opst']
['opts', 'post', 'pots', 'spot', 'stop', 'tops']
for key, value in db.items():
    if len(value) > 8:
        print(value)
['capers', 'crapes', 'escarp', 'pacers', 'parsec', 'recaps', 'scrape', 'secpar', 'spacer']
['alerts', 'alters', 'artels', 'estral', 'laster', 'ratels', 'salter', 'slater', 'staler', 'stelar', 'talers']
['least', 'setal', 'slate', 'stale', 'steal', 'stela', 'taels', 'tales', 'teals', 'tesla']
['apers', 'asper', 'pares', 'parse', 'pears', 'prase', 'presa', 'rapes', 'reaps', 'spare', 'spear']
['estrin', 'inerts', 'insert', 'inters', 'niters', 'nitres', 'sinter', 'triens', 'trines']

(ترتیب چاپ این فهرست‌ها به پیاده‌سازی پایگاه داده بستگی دارد.)

db.close()

۱۳.۱۰.۴. تمرین

در مجموعه‌ی بزرگی از فایل‌ها، ممکن است بیش از یک نسخه از یک فایل یکسان وجود داشته باشد، که در دایرکتوری‌های مختلف یا با نام‌های مختلف ذخیره شده‌اند. هدف این تمرین جست‌وجوی تکراری‌هاست. به‌عنوان مثال، با فایل‌های تصویری دایرکتوری photos کار می‌کنیم.

این‌طور کار می‌کند:

  • از تابع walk بخش ۱۳.۷ برای جست‌وجوی این دایرکتوری برای فایل‌هایی که به یکی از پسوندهای config['extensions'] ختم می‌شوند استفاده می‌کنیم.

  • برای هر فایل، از md5_digest بخش ۱۳.۶ برای محاسبه‌ی چکیده‌ی محتویات استفاده می‌کنیم.

  • با یک قفسه، نگاشتی از هر چکیده به فهرستی از مسیرهایی با آن چکیده می‌سازیم.

  • در پایان، قفسه را برای چکیده‌هایی که به چند فایل نگاشت می‌شوند جست‌وجو می‌کنیم.

  • اگر پیدا کردیم، از same_contents برای تأیید اینکه فایل‌ها داده‌ی یکسانی دارند استفاده می‌کنیم.

ابتدا چند تابع پیشنهاد می‌کنم که بنویسید، بعد همه را کنار هم می‌گذاریم.

۱. برای شناسایی فایل‌های تصویری، تابعی به نام is_image بنویسید که یک مسیر و فهرستی از پسوندهای فایل را بگیرد و اگر مسیر به یکی از پسوندهای فهرست ختم می‌شود True برگرداند. راهنمایی: از os.path.splitext استفاده کنید – یا از یک دستیار مجازی بخواهید این تابع را برایتان بنویسد.

# Solution goes here

می‌توانید از doctest برای آزمودن تابع‌تان استفاده کنید.

from doctest import run_docstring_examples

def run_doctests(func):
    run_docstring_examples(func, globals(), name=func.__name__)

run_doctests(is_image)

۲. تابعی به نام add_path بنویسید که یک مسیر و یک قفسه را به‌عنوان آرگومان بگیرد. باید از md5_digest برای محاسبه‌ی چکیده‌ی محتویات فایل استفاده کند. بعد باید قفسه را به‌روزرسانی کند: یا موردی جدید بسازد که چکیده را به فهرستی شامل مسیر نگاشت می‌کند، یا اگر مورد وجود دارد مسیر را به فهرست اضافه کند.

# Solution goes here

۳. نسخه‌ای از walk به نام walk_images بنویسید که یک دایرکتوری را بگیرد و فایل‌های دایرکتوری و زیردایرکتوری‌هایش را پیمایش کند. برای هر فایل، باید با is_image بررسی کند که فایل تصویری است یا نه و با add_path آن را به قفسه اضافه کند.

# Solution goes here

وقتی همه‌چیز کار کرد، می‌توانید از برنامه‌ی زیر برای ساختن قفسه، جست‌وجوی دایرکتوری photos و افزودن مسیرها به قفسه، و بعد بررسی اینکه آیا چند فایل با چکیده‌ی یکسان وجود دارد استفاده کنید.

db = shelve.open('photos/digests', 'n')
walk_images('photos')

for digest, paths in db.items():
    if len(paths) > 1:
        print(paths)
['photos/jan-2023/photo1.jpg', 'photos/mar-2023/photo2.jpg']

باید یک جفت فایل با چکیده‌ی یکسان پیدا کنید. از same_contents برای بررسی اینکه آیا داده‌ی یکسانی دارند استفاده کنید.

# Solution goes here