openfoodfacts_ingestion.py 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. #ident "@(#)$Format:LocalFoodAI_lanfr144:openfoodfacts_ingestion.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
  2. from airflow import DAG
  3. from airflow.operators.python import PythonOperator
  4. from airflow.providers.docker.operators.docker import DockerOperator
  5. from airflow.exceptions import AirflowSkipException
  6. from datetime import datetime, timedelta
  7. import os
  8. import hashlib
  9. import requests
  10. import urllib.request
  11. DATA_DIR = "/opt/airflow/data"
  12. INGEST_FILE = "en.openfoodfacts.org.products.csv"
  13. URL = "https://static.openfoodfacts.org/data/en.openfoodfacts.org.products.csv"
  14. default_args = {
  15. 'owner': 'airflow',
  16. 'depends_on_past': False,
  17. 'start_date': datetime(2026, 1, 1),
  18. 'email_on_failure': False,
  19. 'email_on_retry': False,
  20. 'retries': 1,
  21. 'retry_delay': timedelta(minutes=5),
  22. }
  23. dag = DAG(
  24. 'openfoodfacts_ingestion',
  25. default_args=default_args,
  26. description='Automated Data Freshness Pipeline for OpenFoodFacts',
  27. schedule_interval='0 4 * * *', # Daily at 04:00
  28. catchup=False,
  29. )
  30. def download_and_validate(**kwargs):
  31. os.makedirs(DATA_DIR, exist_ok=True)
  32. file_path = os.path.join(DATA_DIR, INGEST_FILE)
  33. print("Downloading dataset...")
  34. # Downloading stream to handle large files
  35. try:
  36. urllib.request.urlretrieve(URL, file_path)
  37. except Exception as e:
  38. print(f"Failed to download: {e}")
  39. raise
  40. print("Calculating checksum...")
  41. md5_hash = hashlib.md5()
  42. with open(file_path, "rb") as f:
  43. for byte_block in iter(lambda: f.read(4096), b""):
  44. md5_hash.update(byte_block)
  45. new_checksum = md5_hash.hexdigest()
  46. checksum_file = os.path.join(DATA_DIR, "checksum.md5")
  47. old_checksum = ""
  48. if os.path.exists(checksum_file):
  49. with open(checksum_file, "r") as f:
  50. old_checksum = f.read().strip()
  51. if new_checksum == old_checksum:
  52. print("Checksum matches previously processed file. Skipping ingestion.")
  53. raise AirflowSkipException("Dataset is already up to date.")
  54. print("Checksum mismatch: File is new or modified. Ingestion required.")
  55. # Push new checksum to XCom so the next task can save it upon success
  56. kwargs['ti'].xcom_push(key='new_checksum', value=new_checksum)
  57. return True
  58. def save_checksum(**kwargs):
  59. new_checksum = kwargs['ti'].xcom_pull(key='new_checksum', task_ids='validate_freshness')
  60. checksum_file = os.path.join(DATA_DIR, "checksum.md5")
  61. with open(checksum_file, "w") as f:
  62. f.write(new_checksum)
  63. print("Checksum saved successfully.")
  64. t1_validate = PythonOperator(
  65. task_id='validate_freshness',
  66. python_callable=download_and_validate,
  67. provide_context=True,
  68. dag=dag,
  69. )
  70. # DockerOperator requires the docker socket to be mounted to the airflow container
  71. # It will spawn a container using the same image as our ingest service
  72. t2_ingest = DockerOperator(
  73. task_id='trigger_ingestion_container',
  74. image='food_project-ingest',
  75. api_version='auto',
  76. auto_remove=True,
  77. command='./ingest_csv.py /data/en.openfoodfacts.org.products.csv',
  78. docker_url='unix://var/run/docker.sock',
  79. network_mode='food_project_default',
  80. # We must mount the local data dir into the ingest container so it can see the CSV
  81. # We use the relative host path since the docker socket resolves from the host's perspective!
  82. # Airflow runs in Docker, but the socket is the Host's socket.
  83. mounts=[
  84. # Host path -> Container path
  85. # Assuming the host project is in /home/francois/food_project
  86. # Note: This hardcoding is necessary when triggering sibling containers via socket
  87. # unless using complex volume bindings.
  88. ],
  89. environment={
  90. 'DB_HOST': 'mysql',
  91. 'DB_USER': 'food_loader',
  92. 'DB_PASS': 'your_db_password_here'
  93. },
  94. mount_tmp_dir=False,
  95. dag=dag,
  96. )
  97. # Because host paths can vary, it's safer to use the named volume or rely on the fact
  98. # that docker-compose already created the image.
  99. # Wait, the ingest image COPY . /app. So the script is already inside.
  100. # But the CSV is in the host's ./data directory.
  101. # To fix the host path mount dynamically without hardcoding /home/francois/food_project:
  102. # The DockerOperator can mount volumes like this: "food_project_data:/data" but we don't have a named volume for data.
  103. # Let's map it via volumes argument.
  104. t2_ingest.volumes = ['/home/francois/food_project/data:/data']
  105. t3_save_checksum = PythonOperator(
  106. task_id='save_checksum',
  107. python_callable=save_checksum,
  108. provide_context=True,
  109. dag=dag,
  110. )
  111. t1_validate >> t2_ingest >> t3_save_checksum