مقارنة مجلدين باستخدام بايثون واكتشاف الملفات المختلفة | بايثون بالمثال 30

مقارنة مجلدين باستخدام بايثون واكتشاف الملفات المضافة والمحذوفة والمعدلة والمتطابقة باستخدام filecmp

هل لديك نسختان من مجلد مشروع، واحدة قديمة وأخرى جديدة، وتريد معرفة الملفات المضافة والمفقودة والمعدلة بينهما؟ يمكنك مقارنة مجلدين باستخدام بايثون بدل فتح كل ملف يدويًا، حتى لو احتويا على مجلدات فرعية كثيرة.

في هذا الدرس من سلسلة بايثون بالمثال 30 سنبني برنامجًا عمليًا يقارن مسارات الملفات ومحتوياتها الفعلية، ويحدد العناصر التي تغيرت، ويحفظ تقريرًا باسم comparison-report.csv. سنبدأ بكود قصير يستخدم filecmp، ثم ننتقل إلى المشروع الكامل بالمخرجات التي تم التحقق منها بالتشغيل.

{alertInfo}المتطلبات: Python 3.10 أو أحدث. جميع الأدوات التي نستخدمها (filecmp وpathlib وos وcsv) مدمجة في بايثون؛ لا تحتاج إلى pip install. المشروع يقارن المجلدين ولا يغيّر ملفاتهما، لكنه ينشئ تقرير CSV في مسار خارجي تختاره.

تحميل مشروع مقارنة المجلدات ببايثون

كود جاهز، ومجلدان تجريبيان، وتقرير CSV فعلي، واختبارات باستخدام unittest، ودليل التشغيل.

تحميل ملفات المشروع ZIP

{getToc} $title={محتوى المقال}


متى تحتاج مقارنة مجلدين باستخدام بايثون؟

لنفرض أن لديك نسخة احتياطية من ملفات موقعك، ونسخة أحدث منها. قبل الاعتماد على النسخة الجديدة، تريد الإجابة عن أسئلة بسيطة: ما الملفات الجديدة؟ وما الملفات التي حُذفت؟ وهل تغيّر محتوى الملفات التي تحمل الأسماء نفسها؟

هذا مختلف عن مقارنة ملفَي Excel أو حساب SHA-256 لملف واحد. هنا نقارن بنية مجلدين كاملين بالاعتماد على المسار النسبي لكل ملف؛ مثل reports/january.csv. وإذا أردت تعلم حساب بصمة ملف منفرد، يمكنك مراجعة حساب SHA-256 والتحقق من سلامة الملفات باستخدام بايثون | بايثون بالمثال 26.

الحالة في التقريرمعناها نسبةً للنسخة القديمة
ADDEDملف أو مجلد يوجد في النسخة الجديدة فقط.
REMOVEDملف أو مجلد يوجد في النسخة القديمة فقط.
MODIFIEDملف يحمل المسار نفسه في الجهتين لكن محتواه يختلف.
UNCHANGEDملف مطابق، أو مجلد موجود في الجهتين بالنوع نفسه؛ ملفات المجلد الداخلية تُقارن كلٌّ على حدة.
TYPE_CHANGEDمسار كان ملفًا وأصبح مجلدًا، أو العكس.
SKIPPED / ERRORعنصر خاص لم يُفحص أو تعذرت قراءته؛ لا يصح اعتباره مطابقًا.

تجهيز مجلدين للاختبار دون المخاطرة بملفاتك

حمّل المشروع، وفك ضغطه، وافتح الطرفية داخل مجلده. اكتب الأمر التالي لإنشاء مجلد demo يحتوي نسختين تجريبيتين آمنتين:

python create_demo.py --dest demo

ستنشئ الأداة الملفات التالية. وهي ترفض استبدال المجلدين إذا كانا موجودين مسبقًا لحماية أي بيانات قد تكون أنشأتها:

demo/
├── old_folder/
│   ├── reports/
│   │   ├── january.csv
│   │   └── february.csv
│   └── old-note.txt
└── new_folder/
    ├── reports/
    │   ├── january.csv
    │   └── february.csv
    └── new-note.txt

الملف january.csv متطابق. أما february.csv فيحتوي على مبلغ مختلف: 200 في القديم مقابل 250 في الجديد. ويوجد ملف جديد وملف اختفى من النسخة الجديدة.

مقارنة محتويات old_folder وnew_folder في بايثون وتحديد الملفات المتطابقة والمعدلة والمضافة والمحذوفة

أبسط طريقة لمقارنة ملفين باستخدام filecmp

توفر مكتبة بايثون القياسية filecmp دالة cmp(). نستخدم shallow=False عند الحاجة إلى فحص المحتوى بدل الاعتماد على معلومات الحجم وتاريخ التعديل وحدها.

from filecmp import cmp

same = cmp(
    "samples/old_folder/reports/january.csv",
    "samples/new_folder/reports/january.csv",
    shallow=False,
)
print(same)

إذا شغّلت المثال من مجلد المشروع المرفق، الذي يحتوي على المجلدين تحت samples، فستظهر النتيجة التالية:

True

جرّب الآن الملف المعدّل february.csv بنفس الطريقة:

print(cmp(
    "samples/old_folder/reports/february.csv",
    "samples/new_folder/reports/february.csv",
    shallow=False,
))
False

بهذه الخطوة قارنّا ملفين فقط. نحتاج بعد ذلك إلى اكتشاف الملفات المشتركة والمضافة والمفقودة داخل شجرة المجلدين.

لماذا لا يكفي تطابق حجم الملف وتاريخ تعديله؟

بشكل افتراضي، تستخدم filecmp.cmp() الوسيط shallow=True. إذا تطابقت بيانات نوع الملف والحجم ووقت التعديل، فقد تُرجع True دون قراءة البايتات. ولذلك لا ينبغي استخدام هذه النتيجة بوصفها فحصًا قاطعًا لمحتوى ملف.

حتى عند استخدام shallow=False توجد ذاكرة تخزين مؤقت في filecmp تعتمد على معلومات الملف؛ ويمكن مسحها باستخدام filecmp.clear_cache() عند إعادة المقارنة فور تعديل الملفات. لذلك يستخدم مشروعنا النهائي مقارنة مباشرة للبايتات على دفعات، ويتجنب الاعتماد على بيانات الملفات الوصفية للحكم بالتطابق.

import filecmp

# المقارنة المباشرة للمحتوى
print(filecmp.cmp(
    "samples/old_folder/reports/january.csv",
    "samples/new_folder/reports/january.csv",
    shallow=False,
))

# امسح الذاكرة المؤقتة عند الحاجة لإعادة المقارنة بعد تغييرات سريعة
filecmp.clear_cache()
{alertWarning}فرق مهم: فحص المحتوى في هذا الدرس يعني اكتشاف اختلاف البايتات. إذا تغيّر اسم ملف دون تغيير محتواه، فسيُسجَّل المسار القديم REMOVED والمسار الجديد ADDED، وليس «إعادة تسمية» تلقائية.

استخدام dircmp لمعرفة الملفات المشتركة والمختلفة

تقدم filecmp.dircmp() خصائص مثل left_only وright_only وcommon_files وdiff_files. توضح هذه الخصائص عناصر المجلد في المستوى الحالي، بينما تصل إلى المجلدات المتقابلة الداخلية عبر subdirs.

from filecmp import dircmp

diff = dircmp("samples/old_folder", "samples/new_folder")
print("Old only:", diff.left_only)
print("New only:", diff.right_only)
print("Common directories:", diff.common_dirs)
Old only: ['old-note.txt']
New only: ['new-note.txt']
Common directories: ['reports']

يمكن أيضًا استخدام diff.report_full_closure() لإظهار مقارنة نصية متكررة. ولكن انتبه إلى أن dircmp تستخدم مقارنة سطحية افتراضيًا، وبعض الأسماء تُستثنى تلقائيًا. كما أن معامل shallow الخاص بمنشئ dircmp لم يُضف إلا في Python 3.13. لذلك سننشئ تقرير المشروع الكامل بطريقة متوافقة مع Python 3.10+، مع مقارنة البايتات مباشرة.

لمزيد من أساسيات الوصول إلى الملفات، راجع شرح مكتبة os للتعامل مع الملفات والمجلدات | أساسيات بايثون 25.

كيف نقارن المجلدات الفرعية مع الاحتفاظ بمسارات الملفات؟

وجود ملف باسم data.csv في مجلدين مختلفين داخل المشروع لا يعني أنهما الملف نفسه. لذلك نستخدم المسار النسبي، مثل sales/data.csv أو reports/data.csv. وبذلك نقارن كل عنصر بنظيره في الموضع نفسه داخل النسختين.

هذا مثال مبسّط باستخدام pathlib لعرض المسارات الموجودة تحت مجلد واحد:

from pathlib import Path

root = Path("samples/old_folder")
for file in sorted(root.rglob("*")):
    if file.is_file():
        print(file.relative_to(root).as_posix())
old-note.txt
reports/february.csv
reports/january.csv

هذا المثال التعليمي مناسب للمجلدات التجريبية الموثوقة. أما مشروعنا النهائي فيستخدم os.scandir() حتى يتجنب تتبّع الروابط الرمزية، ويُسجل أخطاء الصلاحيات والعناصر الخاصة بوضوح بدل إخفائها.

اكتشاف الملفات المضافة والمفقودة والمعدلة

الفكرة العامة بسيطة: نجمع المسارات النسبية في كل جهة، ثم نقارن اتحادها. إذا وجدنا مسارًا في الجديدة فقط فهو ADDED، وإذا ظهر في القديمة فقط فهو REMOVED، وإذا كان ملفًا في الجهتين نقرأ محتواه ونحدد MODIFIED أو UNCHANGED.

لمنع استهلاك ذاكرة كبيرة عند مقارنة ملفات ضخمة، تُقرأ الملفات على دفعات ثابتة بدل read() دون حجم:

from pathlib import Path


def same_file_bytes(first: Path, second: Path) -> bool:
    if first.stat().st_size != second.stat().st_size:
        return False

    with first.open("rb") as a, second.open("rb") as b:
        while True:
            left_chunk = a.read(128 * 1024)
            right_chunk = b.read(128 * 1024)
            if left_chunk != right_chunk:
                return False
            if not left_chunk:
                return True

وضع rb يجعل القراءة ثنائية فلا يتغير تفسير المحتوى بسبب ترميز النص. لا نقول إن الملفات متطابقة بمجرد تساوي الحجم؛ بل نواصل المقارنة حتى نهاية البايتات أو أول اختلاف.

مشروع مقارنة المجلدات الكامل باستخدام بايثون

الآن ننفذ النسخة الأكثر أمانًا. يحتوي compare_folders.py على وظائف لفحص الشجرتين، وتصنيف اختلافاتهما، ثم كتابة تقرير CSV. يقبل البرنامج مسارات تحتوي مسافات أو أسماء عربية، ويمنع وضع التقرير داخل أحد المجلدين حتى لا يتسبب التقرير نفسه في تغيير نتيجة المقارنة.

هذا هو الكود الكامل للملف كما اختُبر ضمن المشروع المرفق، دون حذف وظائف مهمة:

"""Compare two directory trees without changing their contents.

Python 3.10+; standard library only. Symbolic links are explicitly skipped.
"""
from __future__ import annotations

import argparse
import csv
import os
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable

CHUNK_SIZE = 128 * 1024
FIELDS = ("path", "old_type", "new_type", "status", "note")
STATUSES = ("ADDED", "REMOVED", "MODIFIED", "UNCHANGED", "TYPE_CHANGED", "SKIPPED", "ERROR")


@dataclass(frozen=True)
class Entry:
    kind: str
    location: Path
    error: str = ""


def inspect_tree(root: Path) -> dict[str, Entry]:
    """Collect files, directories and unsupported items, without following symlinks."""
    found: dict[str, Entry] = {}
    pending = [(root, Path("."))]
    while pending:
        current_dir, prefix = pending.pop()
        try:
            with os.scandir(current_dir) as handle:
                children = sorted(handle, key=lambda e: e.name)
        except OSError as exc:
            # A subdirectory may exist but be unreadable. Do not call it unchanged.
            if prefix != Path("."):
                key = prefix.as_posix()
                found[key] = Entry("error", current_dir, str(exc))
            else:
                raise OSError(f"تعذر فحص المجلد الرئيسي: {root}: {exc}") from exc
            continue
        for child in children:
            relative = prefix / child.name
            key = relative.as_posix()
            child_path = Path(child.path)
            try:
                if child.is_symlink():
                    found[key] = Entry("symlink", child_path)
                elif child.is_dir(follow_symlinks=False):
                    found[key] = Entry("dir", child_path)
                    pending.append((child_path, relative))
                elif child.is_file(follow_symlinks=False):
                    found[key] = Entry("file", child_path)
                else:
                    found[key] = Entry("other", child_path)
            except OSError as exc:
                found[key] = Entry("error", child_path, str(exc))
    return found


def same_file_bytes(left: Path, right: Path) -> bool:
    """Compare full file contents; don't trust equal sizes or modification times."""
    if left.stat().st_size != right.stat().st_size:
        return False
    with left.open("rb") as stream_left, right.open("rb") as stream_right:
        while True:
            a = stream_left.read(CHUNK_SIZE)
            b = stream_right.read(CHUNK_SIZE)
            if a != b:
                return False
            if not a:
                return True


def compare_folders(old: Path, new: Path) -> list[dict[str, str]]:
    """Return sorted report rows. Added/Removed are relative to old -> new."""
    old_items = inspect_tree(old)
    new_items = inspect_tree(new)
    report: list[dict[str, str]] = []

    for relative_path in sorted(old_items.keys() | new_items.keys()):
        left = old_items.get(relative_path)
        right = new_items.get(relative_path)
        old_type = left.kind if left else ""
        new_type = right.kind if right else ""
        note = ""
        if (left and left.kind == "error") or (right and right.kind == "error"):
            status = "ERROR"
            note = "; ".join(x.error for x in (left, right) if x and x.error)
        elif (left and left.kind in ("symlink", "other")) or (right and right.kind in ("symlink", "other")):
            status = "SKIPPED"
            note = "رابط رمزي أو عنصر خاص: لم تتم مقارنته أو اتباعه"
        elif left is None:
            status = "ADDED"
        elif right is None:
            status = "REMOVED"
        elif left.kind != right.kind:
            status = "TYPE_CHANGED"
            note = "تحول المسار من ملف إلى مجلد أو العكس"
        elif left.kind == "dir":
            status = "UNCHANGED"
            # Only directory presence/type is compared; the children have their own rows.
        else:
            try:
                status = "UNCHANGED" if same_file_bytes(left.location, right.location) else "MODIFIED"
            except OSError as exc:
                status = "ERROR"
                note = f"تعذرت قراءة الملف: {exc}"
        report.append(dict(path=relative_path, old_type=old_type,
                           new_type=new_type, status=status, note=note))
    return report


def validate_roots(old: Path, new: Path, output: Path) -> tuple[Path, Path, Path]:
    # Reject symlink roots before resolving them.
    for root in (old, new):
        if root.is_symlink():
            raise ValueError(f"المجلد الرئيسي رابط رمزي، غير مسموح: {root}")
        if not root.is_dir():
            raise ValueError(f"المجلد غير موجود أو ليس مجلدًا: {root}")
    old = old.resolve(strict=True)
    new = new.resolve(strict=True)
    output = output.expanduser().resolve(strict=False)
    if old == new:
        raise ValueError("اختر مجلدين مختلفين للمقارنة")
    if old in new.parents or new in old.parents:
        raise ValueError("لا تستخدم مجلدًا رئيسيًا يقع داخل المجلد الآخر")
    if output.is_relative_to(old) or output.is_relative_to(new):
        raise ValueError("يجب حفظ ملف التقرير خارج المجلدين حتى لا يغيّر نتيجة المقارنة")
    if output.is_dir():
        raise ValueError("مسار التقرير يشير إلى مجلد وليس ملف CSV")
    return old, new, output


def safe_spreadsheet_cell(value: str) -> str:
    """Avoid spreadsheet formula execution from user-controlled filenames."""
    if value and value[0] in ("=", "+", "-", "@", "\t", "\r", "\n"):
        return "'" + value
    return value


def write_csv(rows: Iterable[dict[str, str]], output: Path) -> None:
    with output.open("w", newline="", encoding="utf-8-sig") as stream:
        writer = csv.DictWriter(stream, fieldnames=FIELDS)
        writer.writeheader()
        for row in rows:
            writer.writerow({key: safe_spreadsheet_cell(row[key]) for key in FIELDS})


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Compare two folders recursively; read-only, CSV report")
    parser.add_argument("--old", required=True, type=Path, help="المجلد القديم")
    parser.add_argument("--new", required=True, type=Path, help="المجلد الجديد")
    parser.add_argument("--output", type=Path, default=Path("comparison-report.csv"), help="ملف CSV خارج المجلدين")
    parser.add_argument("--differences-only", action="store_true", help="استبعد حالات UNCHANGED من CSV")
    parser.add_argument("--force", action="store_true", help="اسمح باستبدال تقرير موجود")
    args = parser.parse_args(argv)
    try:
        old, new, output = validate_roots(args.old.expanduser(), args.new.expanduser(), args.output)
        if output.exists() and not args.force:
            raise ValueError("تقرير الإخراج موجود بالفعل؛ اختر اسمًا آخر أو أضف --force")
        rows = compare_folders(old, new)
        selected = [row for row in rows if row["status"] != "UNCHANGED"] if args.differences_only else rows
        output.parent.mkdir(parents=True, exist_ok=True)
        write_csv(selected, output)
    except (OSError, ValueError) as exc:
        parser.exit(status=1, message=f"خطأ: {exc}\n")
    counts = Counter(row["status"] for row in rows)
    print(f"OLD: {old}")
    print(f"NEW: {new}")
    print(f"REPORT: {output}")
    print(" | ".join(f"{status}={counts[status]}" for status in STATUSES))
    print(f"عدد أسطر التقرير: {len(selected)}")
    if counts["ERROR"]:
        print("تحذير: بعض الملفات تعذر فحصها؛ راجع الصفوف ERROR في التقرير.")
        return 2
    if counts["SKIPPED"]:
        print("تنبيه: الروابط الرمزية والعناصر الخاصة تظهر SKIPPED؛ لم تُتبع.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

يتعمد البرنامج عدم تتبع روابط symlink؛ فهي تُسجل بالحالة SKIPPED. وإذا تعذرت قراءة ملف أو مجلد، تظهر الحالة ERROR ويعيد البرنامج رمز خروج 2 عند وجود أخطاء داخل التقرير. وهذا أفضل من وصف الملفات غير المقروءة بأنها متطابقة.

تشغيل الأداة على مجلدات الاختبار

من داخل مجلد المشروع المضغوط شغّل الأمر التالي. المجلدات التجريبية موجودة مسبقًا في samples، ويمكنك بدلها إنشاء مجلدات جديدة باستخدام create_demo.py:

python compare_folders.py --old samples/old_folder --new samples/new_folder --output comparison-report.csv

في التجربة التي أجريتها على المجلدين المرفقين، ظهرت إحصاءات الحالات الآتية. حذفت فقط الأسطر التي تحمل المسارات المطلقة لأنها تختلف من جهاز لآخر:

ADDED=1 | REMOVED=1 | MODIFIED=1 | UNCHANGED=2 | TYPE_CHANGED=0 | SKIPPED=0 | ERROR=0
عدد أسطر التقرير: 5

لاحظ أن UNCHANGED=2 تشمل ملف january.csv ومجلد reports. حالة المجلد تعني وجوده في الجهتين، بينما تفاصيل الملفات الداخلية لها أسطر منفصلة.

ما الذي ستجده في تقرير comparison-report.csv؟

يحفظ البرنامج الأعمدة path وold_type وnew_type وstatus وnote. هذا جزء من التقرير الفعلي الناتج عن تشغيل المثال:

path,old_type,new_type,status,note
new-note.txt,,file,ADDED,
old-note.txt,file,,REMOVED,
reports,dir,dir,UNCHANGED,
reports/february.csv,file,file,MODIFIED,
reports/january.csv,file,file,UNCHANGED,

يُحفظ الملف بترميز utf-8-sig للمساعدة في عرضه بالعربية في Excel. وعند وجود اسم ملف يبدأ برمز قد يُفسَّر كصيغة جداول، يضيف البرنامج علامة أمان في بداية الخلية؛ لذلك قد تظهر علامة اقتباس مفردة قبل بعض الأسماء غير الموثوقة في CSV.

تقرير CSV يعرض نتائج مقارنة مجلدين في بايثون مع تصنيف الملفات إلى ADDED وREMOVED وMODIFIED وUNCHANGED

كيف تعرض الملفات المختلفة فقط؟

إذا كنت ترغب في رؤية الإضافات والحذف والتعديلات فقط، أضف الوسيط --differences-only. وستظل الإحصاءات المعروضة في الطرفية مبنية على جميع العناصر، بينما يستبعد CSV أسطر UNCHANGED:

python compare_folders.py --old samples/old_folder --new samples/new_folder --output changes.csv --differences-only

إذا أنشأت تقريرًا بالاسم نفسه من قبل، يرفض البرنامج الكتابة فوقه افتراضيًا. يمكنك اختيار اسم جديد، أو إضافة --force فقط عندما تريد عمدًا استبدال التقرير القديم. لا تضع التقرير داخل old_folder أو new_folder.

مقارنة مجلد النسخة الاحتياطية بالمجلد الأصلي

يمكنك استخدام الأداة للتأكد من أن ملفات نسخة احتياطية قابلة للقراءة ومطابقة للمجلد المصدر. اعتبر المجلد المصدر --old والنسخة الاحتياطية --new حتى تُفسّر الحالات من منظور المصدر:

python compare_folders.py --old "C:/Projects/MySite" --new "D:/Backups/MySite" --output "C:/Reports/backup-check.csv"

هذا مثال لمسارات Windows، ويجب تعديلها حسب جهازك. عند مقارنة مجلدات حقيقية كبيرة، قد تستغرق القراءة وقتًا أطول لأن الأداة تفحص المحتوى الكامل للملفات المشتركة. واحرص على عدم تعديل البيانات أثناء المقارنة، لأن أي تغيّر متزامن قد يجعل التقرير غير متسق.

{alertSuccess}إجراء آمن: استخدم التقرير أولًا لمعرفة الاختلافات، ثم قرر يدويًا أي الملفات ينبغي نسخها. برنامج هذا الدرس لا يحذف الملفات ولا يزامنها تلقائيًا.

التعامل مع المجلدات الفارغة وتغيّر نوع العنصر

يفحص المشروع المجلدات نفسها أيضًا، وليس الملفات وحدها. فإذا وجدت مجلدًا فارغًا في النسخة الجديدة فقط، يُسجَّل ADDED. وإذا كان المسار assets ملفًا في جهة ومجلدًا في الأخرى، تُسجَّل الحالة TYPE_CHANGED.

أما الرابط الرمزي الذي يشير إلى مكان خارج مجلد المشروع، فلن يتبعه البرنامج. سيُصنّف SKIPPED حتى لا ينتهي الفحص إلى قراءة مجلدات لا يقصد المستخدم إدخالها في المقارنة. والعناصر الخاصة أو غير القابلة للقراءة لا تُعتبر متطابقة بصمت.

مشكلات شائعة أثناء مقارنة الملفات والمجلدات

المشكلةالسببما الحل؟
ظهور ملف جديد داخل التقرير كل مرةحفظ التقرير ضمن أحد المجلدين الجاري فحصهمااحفظ CSV خارجهما؛ المشروع يمنع الخطأ تلقائيًا.
البرنامج يرفض مسار المجلدالمسار غير موجود، أو المجلدان متطابقان/متداخلانتحقق من المسارات ومن استقلال جذرَي المقارنة.
ظهور SKIPPEDعنصر رمزي أو خاص لم يُفحصافحص العنصر يدويًا إن أردت مقارنته؛ لا تفترض تطابقه.
ظهور ERRORتعذر فتح ملف أو قراءة مجلدراجع الصلاحيات، وأغلق البرامج التي تحتجز الملف، ثم أعد التجربة.
مقارنة ملفين متطابقين اسمًا لكن مختلفين محتوىًاسم الملف لا يضمن تطابق البايتاتاستخدم مقارنة المحتوى وليس الاسم أو الحجم فقط.
المجلد UNCHANGED رغم وجود ملفات معدلة داخلهالحالة تخص وجود المجلد ونوعه، بينما الملفات تحلل في أسطر منفصلةافحص صفوف الملفات داخل المجلد، لا صف المجلد وحده.
خطأ أن التقرير موجود بالفعلالحماية من استبدال ملف CSV قديماختر اسمًا جديدًا أو استخدم --force عمدًا.
شرح مشكلة الاعتماد على حجم الملف وتاريخ تعديله عند مقارنة الملفات في بايثون وأهمية مقارنة المحتوى الفعلي

اختبار كود مقارنة المجلدات باستخدام unittest

تتضمن حزمة المشروع مجلد tests يحوي اختبارات للحالات المختلفة، منها المجلدات المتداخلة، وتساوي الحجم ووقت التعديل مع اختلاف البايتات، والمجلدات الفارغة، والأسماء العربية، والروابط الرمزية عندما يدعمها النظام، والتأكد من عدم تغيير ملفات الإدخال.

من داخل مجلد المشروع، شغّل:

python -m unittest discover -s tests -v

كانت نتيجة التشغيل الفعلي على Python 3.13.5:

Ran 23 tests in 0.038s

OK

مدة الاختبارات هنا تخص بيئة التجربة فقط، وليست قياسًا لأداء البرنامج على مجلدات كبيرة. والأهم أن الاختبارات حققت حالات الإضافة والحذف والتعديل والتطابق دون الاعتماد على مكتبات خارجية.

مراجع رسمية ودروس مرتبطة

الأسئلة الشائعة حول مقارنة مجلدين باستخدام بايثون

هل يمكن مقارنة مجلدين في Python دون تثبيت أي مكتبة؟

نعم. يوفر Python مكتبات قياسية منها filecmp وpathlib وcsv. يعمل المشروع المرفق دون تثبيت مكتبات طرف ثالث.

كيف أعرف الملفات المضافة والمحذوفة بين مجلدين؟

قارن مجموعات المسارات النسبية بين المجلد القديم والجديد. يسجّل المشروع المسارات التي توجد في الجديدة فقط بالحالة ADDED، والموجودة في القديمة فقط بالحالة REMOVED.

هل يكتشف البرنامج اختلاف المحتوى إذا تساوى حجم الملفين ووقت تعديله؟

نعم في المشروع المرفق؛ فهو يقارن بايتات الملفات المشتركة مباشرة على دفعات. أما filecmp.cmp() عند استخدامها افتراضيًا مع shallow=True فقد تعتمد على بيانات الحجم ووقت التعديل.

هل يقارن البرنامج المجلدات الفرعية أيضًا؟

نعم. يفحص جميع المجلدات الفرعية العادية، ويعرض الملفات بمساراتها النسبية. ولا يتبع الروابط الرمزية تجنبًا للدخول إلى مسارات خارج النطاق المتوقع.

هل يعني UNCHANGED أمام مجلد أن كل ملفاته متطابقة؟

لا. أمام المجلد تعني وجود المجلد نفسه بالنوع ذاته في الجهتين فقط؛ كل ملف بداخله له سطر حالة مستقل. تحقّق من صفوف الملفات ومجموع الإحصاءات.

هل ينسخ البرنامج الملفات الناقصة أو يحذفها؟

لا. وظيفته المقارنة وإنشاء تقرير CSV فقط. وهذا مقصود لتقليل خطر تغيير النسخ الأصلية أو حذف ملف بالخطأ.

هل يمكن مقارنة مجلدات كبيرة أو نسخة احتياطية كاملة؟

يمكن ذلك بشرط توفر صلاحيات القراءة ووقت كافٍ للفحص. قراءة البايتات كاملة تحقق دقة أعلى من مقارنة المعلومات الوصفية وحدها، لكنها تستغرق وقتًا أكبر، خصوصًا مع الملفات الضخمة أو الأقراص الشبكية. تجنب تعديل الملفات أثناء المقارنة.

لماذا يُرفض حفظ CSV داخل أحد المجلدين؟

لأن إنشاء التقرير أثناء المقارنة قد يغير محتويات المجلد الجاري فحصه، فتظهر نتيجة إضافية غير مقصودة. يحفظ المشروع التقرير خارجهما ويرفض أي مسار داخلي.

إرسال تعليق

أحدث أقدم