الفصل 25

ملفات JSON و CSV

قراءة الجداول المحاطة بعلامات اقتباس وكتابتها باستخدام وحدة csv، لماذا نحتاج `newline=""`، حفظ البيانات المهيكلة باستخدام json، وأي الأنواع البرمجية تتغير أثناء التحويل.

34 دقيقةPython 3.12
  1. 1المشكلة
  2. 2الفهم
  3. 3أمثلة محلولة
  4. 4التوقع
  5. 5التطبيق
  6. 6التحدي

المشكلة التي نقوم بحلها

منذ الفصل الثالث والعشرين ونحن نقسم الأسطر باستخدام .split(",")، وكان ذلك يفي بالغرض بنجاح. لكن ماذا لو كان أحد الحقول في الملف يحتوي على فاصلة في داخله؟

text
name,note,price
pen,blue ink,15.0
bag,"large, sturdy",850.0
python
with open("items.csv", "r", encoding="utf-8") as fh:
    for line in fh:
        print(line.strip().split(","))
text
['name', 'note', 'price']
['pen', 'blue ink', '15.0']
['bag', '"large', ' sturdy"', '850.0']

السطر الأخير خرج بأربعة حقول بدلاً من ثلاثة، وأصبحت علامات الاقتباس جزءاً من البيانات نفسها!

وضع علامات الاقتباس ليس خطأ — بل هو القاعدة الرسمية لملفات CSV، وقد وُجدت تحديداً للتعامل مع هذا الموقف. الخطأ يكمن في طريقة قراءتنا اليدوية. وكتابة كود يعالج هذه القواعد بنفسك تعني معالجة الاقتباسات داخل الاقتباسات ونزول الأسطر داخل الحقول؛ وهي مهمة تبدأ صغيرة وتتعقد سريعاً.

ولكن لحسن الحظ، هذه المهمة مُنجزة ومحلولة بالفعل.

python
import csv

with open("items.csv", "r", encoding="utf-8", newline="") as fh:
    for row in csv.reader(fh):
        print(row)
text
['name', 'note', 'price']
['pen', 'blue ink', '15.0']
['bag', 'large, sturdy', '850.0']

يتناول هذا الفصل تنسيقين أساسيين — CSV للجداول المسطحة، و JSON للبيانات ذات البنية الهيكلية — وتأتي بايثون مجهزة بوحدة قياسية لكل منهما.

في نهاية هذا الدرس ستكون قادراً على

  • قراءة ملفات CSV باستخدام csv.reader و csv.DictReader، والكتابة إليها باستخدام csv.writer
  • شرح سبب كتابة المعامل newline="" دائماً
  • التمييز بين json.dumps/loads و json.dump/load
  • معرفة أنواع بايثون التي تعود كما هي من JSON وتلك التي تتغير أو تفشل
  • اتخاذ القرار الصحيح بالمفاضلة بين CSV و JSON
  • قراءة وفهم خطأ JSONDecodeError وتحديد موقعه

المتطلبات السابقة: الاستثناءات — التعامل مع الأخطاء والانهيارات.


قراءة ملفات CSV

تُرجع الدالة csv.reader كل سطر على هيئة قائمة (List). لكن هذا يتطلب منك حفظ مواقع الأعمدة في الذاكرة — ماذا كان يمثل row[2] مجدداً؟

أما csv.DictReader، فإنه يعتبر السطر الأول رؤوساً للأعمدة (Headers)، ويحوّل كل سطر بيانات إلى قاموس (Dictionary):

python
import csv

with open("items.csv", "r", encoding="utf-8", newline="") as fh:
    for row in csv.DictReader(fh):
        print(row["name"], "-", row["price"], type(row["price"]))
text
pen - 15.0 <class 'str'>
bag - 850.0 <class 'str'>

لاحظ أمرين مهمين:

الوصول إلى البيانات يتم بالاسم، لذا فإن تغيير ترتيب الأعمدة في الملف لن يفسد الكود. يُعد DictReader الخيار الأفضل والأنسب في الغالبية العظمى من الحالات.

لكن price ما زال نصاً (String). وحدة csv تفصل الحقول عن بعضها، لكنها لا تحوّل أنواع البيانات — فلا يوجد شيء داخل ملف CSV يخبر بايثون أن هذا العمود عبارة عن رقم. لذا فإن استدعاء float() و int() يقع على عاتقك أنت، تماماً كما في الفصل الثالث والعشرين.

كتابة ملفات CSV

python
import csv

rows = [["pen", "blue ink", 15.0], ["bag", "large, sturdy", 850.0]]

with open("out.csv", "w", encoding="utf-8", newline="") as fh:
    writer = csv.writer(fh)
    writer.writerow(["name", "note", "price"])
    writer.writerows(rows)
text
name,note,price
pen,blue ink,15.0
bag,"large, sturdy",850.0

لاحظ أن النص large, sturdy وُضع بين علامتي اقتباس تلقائياً — بينما لم يُحط blue ink باقتباس، لعدم الحاجة لذلك. ما كان يصعب علينا قراءته يدوياً، لم نعد مضطرين للقلق بشأن كتابته أيضاً.

ولكتابة قائمة من القواميس، نستخدم DictWriter:

python
import csv

rows = [
    {"name": "pen", "price": 15.0},
    {"name": "bag", "price": 850.0},
]

with open("out.csv", "w", encoding="utf-8", newline="") as fh:
    writer = csv.DictWriter(fh, fieldnames=["name", "price"])
    writer.writeheader()
    writer.writerows(rows)
text
name,price
pen,15.0
bag,850.0

يحدد المعامل fieldnames ترتيب الأعمدة أيضاً، ودون استدعاء writeheader() لن يُكتب سطر العناوين الرئيسي.

لماذا نكتب newline=""؟ تتكفل وحدة csv بنفسها بتحديد رمز نهاية السطر. وفتح الملف بالطريقة العادية يجعل بايثون يترجم نهايات الأسطر مرة ثانية في بعض أنظمة التشغيل (مثل ويندوز)، مما يترك سطراً فارغاً بعد كل صف. تمرير newline="" يوقف هذه الترجمة الإضافية. احرص على كتابتها عند القراءة والكتابة على السواء.

JSON — بنية هيكلية تتجاوز حدود الجداول

ملفات CSV تشبه الجداول: صفوف وأعمدة، وكل شيء فيها مسطح. ولكن ماذا لو كان الطلب يحتوي على قائمة عناصر، وكل عنصر يحتوي على قائمة وسوم (Tags)؟ لا يمكن للجدول البسيط استيعاب ذلك.

python
import json

order = {"name": "pen", "price": 15.0, "tags": ["ink", "blue"], "stocked": True, "note": None}

print(json.dumps(order))
text
{"name": "pen", "price": 15.0, "tags": ["ink", "blue"], "stocked": true, "note": null}

تحوّل الدالة json.dumps كائن بايثون إلى نص (String). يبدو الشكل قريباً جداً من قواميس بايثون، مع اختلافين: تحولت True إلى true، وتحولت None إلى null. فـ JSON لغة مستقلة مشتركة بين العديد من لغات البرمجة، ولها قواعدها الإملائية الخاصة.

ولإنتاج نص منسق يسهل على الإنسان قراءته:

python
import json

order = {"name": "pen", "tags": ["ink", "blue"]}

print(json.dumps(order, indent=2))
text
{
  "name": "pen",
  "tags": [
    "ink",
    "blue"
  ]
}

ولإعادة تحويل النص إلى كائن بايثون:

python
import json

text = '{"name": "pen", "price": 15.0, "stocked": true, "note": null}'
order = json.loads(text)

print(order)
print(type(order["price"]), type(order["stocked"]), order["note"] is None)
text
{'name': 'pen', 'price': 15.0, 'stocked': True, 'note': None}
<class 'float'> <class 'bool'> True

هذا هو الفارق الجوهري الأكبر بينه وبين CSV. عادت القيمة price كرقم وعادت stocked كـ True بوليني، دون الحاجة لأي دوال تحويل يدوية على الإطلاق. يحمل JSON أنواع البيانات الأصلية معه، بينما لا يفعل CSV ذلك.

يسهل حفظ الفروق بين الدوال الأربع: dumps و loads تتعاملان مع النصوص، بينما dump و load تتعاملان مع الملفات مباشرة. حرف s الأخير يرمز إلى string.

python
import json
from pathlib import Path

order = {"name": "pen", "price": 15.0}

Path("order.json").write_text(json.dumps(order, indent=2) + "\n", encoding="utf-8")

with open("order.json", "r", encoding="utf-8") as fh:
    back = json.load(fh)

print(back)
text
{'name': 'pen', 'price': 15.0}

ما لا يعيده JSON كما كان

لا يمكن لكل كائن في بايثون أن يتحول إلى JSON، وما يدخل فيه لا يعود بالضرورة متطابقاً تماماً.

python
import json

data = {"pair": (1, 2), "numbers": {3, 4}}

print(json.dumps({"pair": data["pair"]}))
print(json.dumps(data))
text
{"pair": [1, 2]}
TypeError: Object of type set is not JSON serializable

يتحول التوبل (Tuple) بصمت إلى قائمة (List). لا يوجد مفهوم التوبل في JSON، لذا فالكتابة لا تطلق أي خطأ — ولكن ما يعود إليك هو قائمة عادية. فوعد الفصل الرابع عشر بأن التوبل غير قابل للتعديل يضيع أثناء رحلة التحويل.

المجموعات (Sets) ترفض التحويل تماماً، وهذا في الواقع سلوك أفضل — فالتوقف المباشر بخطأ أفضل بكثير من التحويل الصامت الخاطئ. وإذا أردت حفظها، مررها كقائمة مرتبة عبر sorted(...).

وهناك فخ إضافي أكثر تأثيراً:

python
import json

data = {1: "pen", 2: "bag"}
text = json.dumps(data)

print(text)
print(json.loads(text))
text
{"1": "pen", "2": "bag"}
{'1': 'pen', '2': 'bag'}

مفاتيح JSON نصية دائماً وأبداً. إذا مررت مفاتيح عددية، فإنها تتحول بهدوء إلى نصوص، وتعود إليك كنصوص. لذا إن كان data[1] يعمل سابقاً، فستحتاج إلى data["1"] بعد القراءة من JSON.

القائمة التي يجدر بك حفظها قصيرة: القواميس، والقوائم، والنصوص، والأرقام، و True/False و None آمنة تماماً. التوبلات تتغير إلى قوائم. أما المجموعات، والتواريخ، والكائنات المخصصة التي تنشئها بنفسك فلا تُحوّل مباشرة.

JSON صارم ودقيق في قواعده

python
import json

print(json.loads("{'name': 'pen'}"))
text
json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)

في بايثون، علامات الاقتباس المفردة والمزدوجة متكافئة تماماً. أما في JSON فليست كذلك — علامات الاقتباس المزدوجة حصراً هي المقبولة، ويُمنع وضع فاصلة زائدة بعد العنصر الأخير.

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

أيهما تختار ومتى؟

اختر CSV عندما تكون البيانات على شكل جدول مسطح بسيط ويحتاج مستخدم إلى فتحه في برامج الجداول الحسابية (مثل Excel). ومع كثرة الصفوف، يبقى حجم الملف أصغر نسبياً.

اختر JSON عندما تحتوي البيانات على بنية هيكلية — قوائم متداخلة، قواميس داخل قواميس — أو عندما تحتاج إلى استعادة أنواع البيانات دون تحويل يدوي. ملفات الإعدادات وواجهات برمجة التطبيقات على الويب (APIs) تعتمد على JSON في كل مكان تقريباً.


مثال متكامل

ملف items.csv:

text
name,note,price,quantity
pen,blue ink,15.0,3
bag,"large, sturdy",850.0,1
ink,,120.0,2
clip,broken,,4

ملف main.py:

python
"""Read a CSV of items, total it, and write the result as JSON."""

import csv
import json
from pathlib import Path

TAX_RATE = 0.15


def read_items(path):
    """Returns the usable rows and the complaints. csv handles the quoting."""
    items = []
    problems = []

    with open(path, "r", encoding="utf-8", newline="") as fh:
        for number, row in enumerate(csv.DictReader(fh), start=2):
            try:
                items.append({
                    "name": row["name"],
                    "note": row["note"],
                    "amount": round(
                        float(row["price"]) * int(row["quantity"]) * (1 + TAX_RATE), 2),
                })
            except ValueError as err:
                problems.append(f"line {number}: {err}")

    return items, problems


def main():
    items, problems = read_items(Path("items.csv"))

    summary = {
        "items": items,
        "total": round(sum(item["amount"] for item in items), 2),
        "skipped": problems,
    }

    Path("summary.json").write_text(
        json.dumps(summary, indent=2) + "\n", encoding="utf-8")

    print(Path("summary.json").read_text(encoding="utf-8"), end="")


if __name__ == "__main__":
    main()
text
{
  "items": [
    {
      "name": "pen",
      "note": "blue ink",
      "amount": 51.75
    },
    {
      "name": "bag",
      "note": "large, sturdy",
      "amount": 977.5
    },
    {
      "name": "ink",
      "note": "",
      "amount": 276.0
    }
  ],
  "total": 1305.25,
  "skipped": [
    "line 5: could not convert string to float: ''"
  ]
}

أربعة أمور جديرة بالدراسة:

ملف CSV دخل ومدخلات JSON خرجت، وهذا نابع من طبيعة كل تنسيق. المدخلات عبارة عن جدول مسطح يسهل فتحه في الجداول الحسابية. أما المخرجات فليست مسطحة — بل هي قائمة عناصر يمثل كل منها قاموساً، وبجوارها قائمة ثانية بالملاحظات والأخطاء. بنية كهذه كان يستحيل تمثيلها بنقاء في ملف CSV.

النص large, sturdy اجتاز الرحلة بنجاح تام. قرأته وحدة csv بشكل صحيح وكتبته json باقتباس دقيق. والمشكلة التي بدأ بها هذا الفصل لم يعد لها أي أثر.

الحقل الفارغ note والحقل الفارغ price كلاهما نصوص فارغة، لكن مصيرهما اختلف تماماً. الحقل الفارغ في CSV هو "" وليس None. الحقل note ظل نصاً ولم يسبب أي مشكلة، بينما float("") أطلقت استثناء ValueError، فتخطى البرنامج سطر clip وأضافه إلى قائمة الملاحظات. لا يوجد في CSV مفهوم "قيمة مفقودة"، بل "نص فارغ" فقط — وتفسير معنى هذا الفراغ هو قرارك أنت.

بدء الترقيم بـ start=2 لم يكن عشوائياً. يستهلك DictReader السطر الأول كرؤوس للأعمدة، لذا فإن أول سطر بيانات يقدمه هو السطر الثاني في الملف الحقيقي. والرسالة التي سيتحقق منها إنسان في الملف الأصلي يجب أن تعد الأسطر بنفس طريقة الملف.


حالات الخطأ الشائعة

سطر فارغ إضافي بعد كل صف في ملف CSV نسيت تمرير newline="". احرص على إضافتها عند فتح الملف للكتابة.

KeyError: 'price' — مع أن العمود موجود في الملف بوضوح اسم رأس العمود غير مطابق، سواء في التهجئة أو بوجود مسافات بيضاء زائدة. اطبع reader.fieldnames لترى ما قرأه بايثون فعلياً.

TypeError عند إجراء عمليات حسابية على الأرقام وحدة csv تُرجع كل شيء كنصوص. يجب عليك استدعاء float() أو int() يدوياً.

معاملة السطر الأول في الملف كبيانات عادية أنت تستخدم csv.reader الذي لا يعلم شيئاً عن رؤوس الأعمدة. استخدم DictReader بدلاً منه، أو احذف السطر الأول بنفسك.

json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes استخدام علامات اقتباس مفردة، أو وجود فاصلة إضافية بعد آخر عنصر. لغة JSON ليست كود بايثون.

TypeError: Object of type set is not JSON serializable مررت مجموعة أو تاريخاً أو كائناً مخصصاً. حوّله إلى قائمة أو نص أولاً قبل التحويل.

عادت التوبلات كقوائم عادية هذا هو السلوك الطبيعي المتوقع — فلا توجد توبلات في JSON. استدعِ tuple(...) بنفسك بعد القراءة إذا كنت بحاجة لتوبل.

كان data[1] يعمل، وبعد حفظه وقراءته ألقى KeyError مفاتيح JSON نصية دائماً. أصبح المفتاح الآن data["1"].