{
  "source_file": "0930",
  "source_platform": "Dev.to",
  "author": "Bala Priya C",
  "title_listed": "5 DIY Python Decorators for Building Cleaner Data Pipelines",
  "keywords": "",
  "domain": "文本处理",
  "content_type": "深度技术解析",
  "is_paid_flag": "否",
  "url": "https://dev.to/balapriya/5-diy-python-decorators-for-building-cleaner-data-pipelines-1f24",
  "publish_date": "2026-02-23",
  "corpus_stage": "",
  "notes": "",
  "id": "055aefebc54f",
  "collected_at": "2026-10-02T14:01:17",
  "title": "5 DIY Python Decorators for Building Cleaner Data Pipelines - DEV Community",
  "body_markdown": "Data pipelines often tend to accumulate the same boilerplate in a lot of places. You'll likely have retry logic copy-pasted across functions, timing code scattered through the codebase, validation checks buried inside business logic, and more. Decorators are a clean way to pull that stuff out.\nThey let you separate\nwhat\na function does from\nhow\nit behaves, and in data engineering, that's a useful separation to make.\nThis article walks through five practical decorators you can use to keep your data pipelines maintainable.\nYou can get the code on GitHub\n.\nPrerequisites\nBefore we get started, you should be comfortable with:\nPython functions as first-class objects\n— functions can be passed as arguments and returned from other functions\n*args\nand\n**kwargs\n— used throughout to make decorators flexible enough to wrap any function signature\nBasic exception handling\n—\ntry/except/raise\nblocks appear in several decorators\nfunctools\nmodule\n— specifically\nfunctools.wraps\n, explained in the next section\nYou don't need to have written a decorator for data pipelines before. That's what this article is for.\nHow Python Decorators Work\nA decorator is a function that takes another function as input and returns a new function as output. The\n@\nsymbol is syntactic sugar to make it read cleanly.\nHere's the simplest possible example:\ndef\nshout\n(\nfunc\n):\ndef\nwrapper\n(\n*\nargs\n,\n**\nkwargs\n):\nresult\n=\nfunc\n(\n*\nargs\n,\n**\nkwargs\n)\nreturn\nstr\n(\nresult\n).\nupper\n()\nreturn\nwrapper\n@shout\ndef\ngreet\n(\nname\n):\nreturn\nf\n\"\nhello,\n{\nname\n}\n\"\nprint\n(\ngreet\n(\n\"\nworld\n\"\n))\n# HELLO, WORLD\nEnter fullscreen mode\nExit fullscreen mode\nWhen Python sees\n@shout\nabove\ngreet\n, it does this behind the scenes:\ngreet\n=\nshout\n(\ngreet\n)\nEnter fullscreen mode\nExit fullscreen mode\nSo calling\ngreet(\"world\")\ncalls\nwrapper(\"world\")\n, which calls the original\ngreet\n, gets\n\"hello, world\"\n, and returns\n\"HELLO, WORLD\"\n. The original function is unchanged; it's just been wrapped.\nThe wrapper uses\n*args\nand\n**kwargs\nso it can forward any arguments without knowing them in advance. This is what makes decorators reusable across functions with different signatures.\nNote\n: without\n@functools.wraps(func)\n, your decorated function loses its original name and docstring.\ngreet.__name__\nwould show\nwrapper\ninstead of\ngreet\n, which makes debugging and log output harder. Every decorator in this article includes\nfunctools.wraps\nfor this reason.\n1.\n@retry\n— Handle Transient Failures\nIn many projects, database connections and API calls often fail, a file is briefly locked by another process, and more. The common fix is wrapping every external call in a\nfor\nloop with a\ntry/except\n. But copy-pasting that pattern across your codebase gets messy fast.\nHere's a reusable\n@retry\ndecorator:\nimport\ntime\nimport\nfunctools\ndef\nretry\n(\nmax_attempts\n=\n3\n,\ndelay\n=\n2\n,\nexceptions\n=\n(\nException\n,)):\ndef\ndecorator\n(\nfunc\n):\n@functools.wraps\n(\nfunc\n)\ndef\nwrapper\n(\n*\nargs\n,\n**\nkwargs\n):\nfor\nattempt\nin\nrange\n(\n1\n,\nmax_attempts\n+\n1\n):\ntry\n:\nreturn\nfunc\n(\n*\nargs\n,\n**\nkwargs\n)\nexcept\nexceptions\nas\ne\n:\nif\nattempt\n==\nmax_attempts\n:\nraise\nprint\n(\nf\n\"\nAttempt\n{\nattempt\n}\nfailed:\n{\ne\n}\n. Retrying in\n{\ndelay\n}\ns...\n\"\n)\ntime\n.\nsleep\n(\ndelay\n)\nreturn\nwrapper\nreturn\ndecorator\nEnter fullscreen mode\nExit fullscreen mode\nHere we simulate a flaky SQLite read that fails the first two times before succeeding on the third attempt:\nimport\nsqlite3\n# Setup: create an in-memory DB with some orders\nconn\n=\nsqlite3\n.\nconnect\n(\n\"\n:memory:\n\"\n)\nconn\n.\nexecute\n(\n\"\nCREATE TABLE orders (order_id, customer_id, amount, status)\n\"\n)\nconn\n.\nexecutemany\n(\n\"\nINSERT INTO orders VALUES (?, ?, ?, ?)\n\"\n,\n[\n(\n1\n,\n101\n,\n59.99\n,\n\"\ncompleted\n\"\n),\n(\n2\n,\n102\n,\n120.00\n,\n\"\ncompleted\n\"\n),\n(\n3\n,\n101\n,\n34.50\n,\n\"\nrefunded\n\"\n),\n(\n4\n,\n103\n,\n89.00\n,\n\"\ncompleted\n\"\n),\n])\nconn\n.\ncommit\n()\n# Simulate a connection that fails twice before succeeding\nattempt_count\n=\n0\n@retry\n(\nmax_attempts\n=\n3\n,\ndelay\n=\n1\n,\nexceptions\n=\n(\nsqlite3\n.\nOperationalError\n,))\ndef\nfetch_completed_orders\n(\nconn\n):\nglobal\nattempt_count\nattempt_count\n+=\n1\nif\nattempt_count\n<\n3\n:\nraise\nsqlite3\n.\nOperationalError\n(\n\"\ndatabase is locked\n\"\n)\ncursor\n=\nconn\n.\nexecute\n(\n\"\nSELECT * FROM orders WHERE status =\n'\ncompleted\n'\"\n)\nreturn\ncursor\n.\nfetchall\n()\nrows\n=\nfetch_completed_orders\n(\nconn\n)\nprint\n(\nrows\n)\nEnter fullscreen mode\nExit fullscreen mode\nOutput:\nAttempt 1 failed: database is locked. Retrying in 1s...\nAttempt 2 failed: database is locked. Retrying in 1s...\n[(1, 101, 59.99, 'completed'), (2, 102, 120.0, 'completed'), (4, 103, 89.0, 'completed')]\nEnter fullscreen mode\nExit fullscreen mode\nWe're specific about\nsqlite3.OperationalError\nthat covers locks and connection issues. A\nsqlite3.ProgrammingError\nfrom a malformed query is not worth retrying; it'll fail the same way every time.\nThere are three nested functions here because\nretry\ntakes configuration arguments.\nretry(...)\nreturns\ndecorator\n, which is the actual decorator.\ndecorator\nreceives the function and returns\nwrapper\n.\n2.\n@timer\n— Measure Execution Time\nWhen a pipeline run is slower than expected, the naive approach is sprinkling\ntime.time()\ncalls through your code. It works, but timing logic ends up tangled with business logic everywhere, and you have to clean it all up when you're done. Let's write a\n@timer\ndecorator:\nimport\ntime\nimport\nfunctools\ndef\ntimer\n(\nfunc\n):\n@functools.wraps\n(\nfunc\n)\ndef\nwrapper\n(\n*\nargs\n,\n**\nkwargs\n):\nstart\n=\ntime\n.\nperf_counter\n()\nresult\n=\nfunc\n(\n*\nargs\n,\n**\nkwargs\n)\nelapsed\n=\ntime\n.\nperf_counter\n()\n-\nstart\nprint\n(\nf\n\"\n[TIMER]\n{\nfunc\n.\n__name__\n}\ncompleted in\n{\nelapsed\n:\n.\n4\nf\n}\ns\n\"\n)\nreturn\nresult\nreturn\nwrapper\nEnter fullscreen mode\nExit fullscreen mode\nHere we apply it to a transformation step that computes customer lifetime value and assigns spend tiers across a list of orders:\norders\n=\n[\n{\n\"\norder_id\n\"\n:\n1\n,\n\"\ncustomer_id\n\"\n:\n101\n,\n\"\namount\n\"\n:\n59.99\n,\n\"\nstatus\n\"\n:\n\"\ncompleted\n\"\n},\n{\n\"\norder_id\n\"\n:\n2\n,\n\"\ncustomer_id\n\"\n:\n102\n,\n\"\namount\n\"\n:\n120.00\n,\n\"\nstatus\n\"\n:\n\"\ncompleted\n\"\n},\n{\n\"\norder_id\n\"\n:\n3\n,\n\"\ncustomer_id\n\"\n:\n101\n,\n\"\namount\n\"\n:\n34.50\n,\n\"\nstatus\n\"\n:\n\"\nrefunded\n\"\n},\n{\n\"\norder_id\n\"\n:\n4\n,\n\"\ncustomer_id\n\"\n:\n103\n,\n\"\namount\n\"\n:\n89.00\n,\n\"\nstatus\n\"\n:\n\"\ncompleted\n\"\n},\n{\n\"\norder_id\n\"\n:\n5\n,\n\"\ncustomer_id\n\"\n:\n102\n,\n\"\namount\n\"\n:\n45.00\n,\n\"\nstatus\n\"\n:\n\"\ncompleted\n\"\n},\n]\n@timer\ndef\ntransform_orders\n(\norders\n:\nlist\n[\ndict\n])\n->\nlist\n[\ndict\n]:\n# Compute lifetime value per customer\nltv\n=\n{}\nfor\no\nin\norders\n:\nif\no\n[\n\"\nstatus\n\"\n]\n==\n\"\ncompleted\n\"\n:\nltv\n[\no\n[\n\"\ncustomer_id\n\"\n]]\n=\nltv\n.\nget\n(\no\n[\n\"\ncustomer_id\n\"\n],\n0\n)\n+\no\n[\n\"\namount\n\"\n]\n# Enrich each order with LTV and spend tier\ndef\nspend_tier\n(\nvalue\n):\nif\nvalue\n>=\n200\n:\nreturn\n\"\nhigh\n\"\nif\nvalue\n>=\n100\n:\nreturn\n\"\nmid\n\"\nreturn\n\"\nlow\n\"\nreturn\n[\n{\n**\no\n,\n\"\ncustomer_ltv\n\"\n:\nltv\n.\nget\n(\no\n[\n\"\ncustomer_id\n\"\n],\n0\n),\n\"\nspend_tier\n\"\n:\nspend_tier\n(\nltv\n.\nget\n(\no\n[\n\"\ncustomer_id\n\"\n],\n0\n))}\nfor\no\nin\norders\n]\nresult\n=\ntransform_orders\n(\norders\n)\nfor\nrow\nin\nresult\n:\nprint\n(\nrow\n)\nEnter fullscreen mode\nExit fullscreen mode\nOutput:\n[TIMER] transform_orders completed in 0.0001s\n{'order_id': 1, 'customer_id': 101, 'amount': 59.99, 'status': 'completed', 'customer_ltv': 59.99, 'spend_tier': 'low'}\n{'order_id': 2, 'customer_id': 102, 'amount': 120.0, 'status': 'completed', 'customer_ltv': 165.0, 'spend_tier': 'mid'}\n{'order_id': 3, 'customer_id': 101, 'amount': 34.5, 'status': 'refunded', 'customer_ltv': 59.99, 'spend_tier': 'low'}\n{'order_id': 4, 'customer_id': 103, 'amount': 89.0, 'status': 'completed', 'customer_ltv': 89.0, 'spend_tier': 'low'}\n{'order_id': 5, 'customer_id': 102, 'amount': 45.0, 'status': 'completed', 'customer_ltv': 165.0, 'spend_tier': 'mid'}\nEnter fullscreen mode\nExit fullscreen mode\nInside\nwrapper\n, we store the result before printing the time and return it after, so the decorator is invisible to whatever called the function. We use\ntime.perf_counter()\ninstead of\ntime.time()\nbecause it has higher resolution and isn't affected by system clock adjustments.\n3.\n@validate_schema\n— Catch Bad Data Early\nData from upstream sources isn't always consistently shaped. A bug in a producer might start omitting a field. A schema change might rename a key. If a required field is missing, you want to know at the point the record enters your pipeline — not several transformations later when something raises a\nKeyError\nwith no indication of which record caused it.\nimport\nfunctools\ndef\nvalidate_schema\n(\nrequired_keys\n):\ndef\ndecorator\n(\nfunc\n):\n@functools.wraps\n(\nfunc\n)\ndef\nwrapper\n(\ndata\n,\n*\nargs\n,\n**\nkwargs\n):\nif\nnot\nisinstance\n(\ndata\n,\ndict\n):\nraise\nTypeError\n(\nf\n\"\nExpected dict, got\n{\ntype\n(\ndata\n).\n__name__\n}\n\"\n)\nmissing\n=\nrequired_keys\n-\ndata\n.\nkeys\n()\nif\nmissing\n:\nraise\nValueError\n(\nf\n\"\nMissing required keys:\n{\nmissing\n}\n\"\n)\nreturn\nfunc\n(\ndata\n,\n*\nargs\n,\n**\nkwargs\n)\nreturn\nwrapper\nreturn\ndecorator\nEnter fullscreen mode\nExit fullscreen mode\nWe apply it to\nnormalize_order\n, which standardizes each raw order record before it's passed to the transformation step:\n@validate_schema\n(\nrequired_keys\n=\n{\n\"\norder_id\n\"\n,\n\"\ncustomer_id\n\"\n,\n\"\namount\n\"\n,\n\"\nstatus\n\"\n})\ndef\nnormalize_order\n(\norder\n:\ndict\n)\n->\ndict\n:\nreturn\n{\n\"\norder_id\n\"\n:\norder\n[\n\"\norder_id\n\"\n],\n\"\ncustomer_id\n\"\n:\norder\n[\n\"\ncustomer_id\n\"\n],\n\"\namount\n\"\n:\nround\n(\nfloat\n(\norder\n[\n\"\namount\n\"\n]),\n2\n),\n\"\nstatus\n\"\n:\norder\n[\n\"\nstatus\n\"\n].\nlower\n().\nstrip\n(),\n\"\nnote\n\"\n:\norder\n.\nget\n(\n\"\nnote\n\"\n,\n\"\"\n),\n# optional — fine to be absent\n}\n# Valid record — passes through\nnormalize_order\n({\n\"\norder_id\n\"\n:\n1\n,\n\"\ncustomer_id\n\"\n:\n101\n,\n\"\namount\n\"\n:\n\"\n59.99\n\"\n,\n\"\nstatus\n\"\n:\n\"\nCompleted\n\"\n})\n# {\"order_id\": 1, \"customer_id\": 101, \"amount\": 59.99, \"status\": \"completed\", \"note\": \"\"}\n# Missing amount — caught immediately\nnormalize_order\n({\n\"\norder_id\n\"\n:\n2\n,\n\"\ncustomer_id\n\"\n:\n102\n,\n\"\nstatus\n\"\n:\n\"\nCompleted\n\"\n})\n# ValueError: Missing required keys: {'amount'}\n# Wrong type entirely — also caught\nnormalize_order\n([\n1\n,\n101\n,\n59.99\n,\n\"\ncompleted\n\"\n])\n# TypeError: Expected dict, got list\nEnter fullscreen mode\nExit fullscreen mode\nTwo checks run before the function body executes. First, we confirm the input is a\ndict\n. Second, we use set subtraction to find any missing required keys and raise a\nValueError\nthat names them exactly.\nNote that\nnote\nis not in\nrequired_keys\n. It's optional and we use\n.get()\nfor it. The decorator only enforces what's truly required.\n4.\n@cache_result\n— Skip Redundant Computation\nSome pipeline steps are expensive and return the same result for the same inputs. A lookup table loaded from a database, a CSV read from disk, a slow aggregation — if you're calling these multiple times with the same arguments, there's no reason to recompute it.\nPython has\nfunctools.lru_cache\nbuilt in, but it only works with hashable arguments. Dicts and lists — common in data pipelines — won't work with it. This decorator handles those cases by hashing the arguments:\nimport\nfunctools\nimport\nhashlib\nimport\njson\ndef\ncache_result\n(\nfunc\n):\ncache\n=\n{}\n@functools.wraps\n(\nfunc\n)\ndef\nwrapper\n(\n*\nargs\n,\n**\nkwargs\n):\nkey\n=\nhashlib\n.\nmd5\n(\njson\n.\ndumps\n((\nargs\n,\nkwargs\n),\nsort_keys\n=\nTrue\n,\ndefault\n=\nstr\n).\nencode\n()\n).\nhexdigest\n()\nif\nkey\nnot\nin\ncache\n:\ncache\n[\nkey\n]\n=\nfunc\n(\n*\nargs\n,\n**\nkwargs\n)\nprint\n(\nf\n\"\n[CACHE] Computed result for\n{\nfunc\n.\n__name__\n}\n\"\n)\nelse\n:\nprint\n(\nf\n\"\n[CACHE] Cache hit for\n{\nfunc\n.\n__name__\n}\n\"\n)\nreturn\ncache\n[\nkey\n]\nwrapper\n.\ncache_clear\n=\nlambda\n:\ncache\n.\nclear\n()\nreturn\nwrapper\nEnter fullscreen mode\nExit fullscreen mode\nWe apply it to\nload_discount_codes\n, which reads a reference table from our SQLite database. This table is queried repeatedly during enrichment but rarely changes within a run:\nimport\nsqlite3\nconn\n=\nsqlite3\n.\nconnect\n(\n\"\n:memory:\n\"\n)\nconn\n.\nexecute\n(\n\"\nCREATE TABLE discount_codes (code TEXT, discount_pct REAL, active INTEGER)\n\"\n)\nconn\n.\nexecutemany\n(\n\"\nINSERT INTO discount_codes VALUES (?, ?, ?)\n\"\n,\n[\n(\n\"\nSAVE10\n\"\n,\n10.0\n,\n1\n),\n(\n\"\nSAVE20\n\"\n,\n20.0\n,\n1\n),\n(\n\"\nOLD5\n\"\n,\n5.0\n,\n0\n),\n])\nconn\n.\ncommit\n()\n@cache_result\ndef\nload_discount_codes\n(\nactive_only\n:\nbool\n=\nTrue\n)\n->\ndict\n:\nquery\n=\n\"\nSELECT code, discount_pct FROM discount_codes\n\"\nif\nactive_only\n:\nquery\n+=\n\"\nWHERE active = 1\n\"\nrows\n=\nconn\n.\nexecute\n(\nquery\n).\nfetchall\n()\nreturn\n{\ncode\n:\npct\nfor\ncode\n,\npct\nin\nrows\n}\n# First call hits the database\ncodes\n=\nload_discount_codes\n(\nactive_only\n=\nTrue\n)\n# Second call is instant\ncodes\n=\nload_discount_codes\n(\nactive_only\n=\nTrue\n)\nprint\n(\ncodes\n)\n# Different argument = separate cache entry\nall_codes\n=\nload_discount_codes\n(\nactive_only\n=\nFalse\n)\nEnter fullscreen mode\nExit fullscreen mode\nOutput:\n[CACHE] Computed result for load_discount_codes\n[CACHE] Cache hit for load_discount_codes\n{'SAVE10': 10.0, 'SAVE20': 20.0}\n[CACHE] Computed result for load_discount_codes\nEnter fullscreen mode\nExit fullscreen mode\nThe cache key is generated by serializing\nargs\nand\nkwargs\nto JSON, then MD5-hashing the result.\nsort_keys=True\nensures consistent output regardless of keyword argument order;\ndefault=str\nhandles types that JSON can't serialize natively. The\ncache\ndict lives in the closure and persists for the program's lifetime.\ncache_clear\nis attached to the wrapper as an escape hatch if you need to force a fresh load.\n5.\n@log_step\n— Build a Trail\nWithout structured logging, debugging a failed pipeline run means guesswork. You have no record of what ran, what succeeded, or where things went sideways.\nimport\nfunctools\nimport\nlogging\nlogging\n.\nbasicConfig\n(\nlevel\n=\nlogging\n.\nINFO\n,\nformat\n=\n\"\n%(asctime)s — %(message)s\n\"\n)\nlogger\n=\nlogging\n.\ngetLogger\n(\n__name__\n)\ndef\nlog_step\n(\nfunc\n):\n@functools.wraps\n(\nfunc\n)\ndef\nwrapper\n(\n*\nargs\n,\n**\nkwargs\n):\nlogger\n.\ninfo\n(\nf\n\"\nSTART\n{\nfunc\n.\n__name__\n}\n\"\n)\ntry\n:\nresult\n=\nfunc\n(\n*\nargs\n,\n**\nkwargs\n)\nlogger\n.\ninfo\n(\nf\n\"\nEND\n{\nfunc\n.\n__name__\n}\n— OK\n\"\n)\nreturn\nresult\nexcept\nException\nas\ne\n:\nlogger\n.\nerror\n(\nf\n\"\nFAIL\n{\nfunc\n.\n__name__\n}\n—\n{\ntype\n(\ne\n).\n__name__\n}\n:\n{\ne\n}\n\"\n)\nraise\nreturn\nwrapper\nEnter fullscreen mode\nExit fullscreen mode\nWe apply it across the main stages of our pipeline:\n@log_step\ndef\nfetch_completed_orders\n(\nconn\n)\n->\nlist\n[\ntuple\n]:\ncursor\n=\nconn\n.\nexecute\n(\n\"\nSELECT * FROM orders WHERE status =\n'\ncompleted\n'\"\n)\nreturn\ncursor\n.\nfetchall\n()\n@log_step\ndef\ntransform_orders\n(\norders\n:\nlist\n[\ndict\n])\n->\nlist\n[\ndict\n]:\n...\n@log_step\ndef\nwrite_results\n(\nconn\n,\nrows\n:\nlist\n[\ndict\n])\n->\nint\n:\nconn\n.\nexecute\n(\n\"\nCREATE TABLE IF NOT EXISTS orders_transformed\n\"\n\"\n(order_id, customer_id, amount, status, customer_ltv, spend_tier)\n\"\n)\nconn\n.\nexecutemany\n(\n\"\nINSERT INTO orders_transformed VALUES (:order_id, :customer_id, :amount,\n\"\n\"\n:status, :customer_ltv, :spend_tier)\n\"\n,\nrows\n,\n)\nconn\n.\ncommit\n()\nreturn\nlen\n(\nrows\n)\nEnter fullscreen mode\nExit fullscreen mode\nAfter logging the error, we call bare\nraise\n. This re-raises the original exception so the pipeline still fails. Without it, the decorator would silently swallow every error and execution would continue. Always re-raise in a logging decorator.\nWrapping Up\nDecorators let you write that logic once and apply it anywhere. The five patterns here — retry, timer, schema validation, caching, and logging — cover the most common cross-cutting concerns in data pipelines.\nA good starting point is\n@log_step\nand\n@retry\n. They address the most immediate pain points in data pipelines with minimal setup. Add the others as your pipeline grows and the needs become clearer.\nHappy coding!",
  "metadata": {
    "title": "5 DIY Python Decorators for Building Cleaner Data Pipelines - DEV Community",
    "description": "Data pipelines often tend to accumulate the same boilerplate in a lot of places. You'll likely have... Tagged with python.",
    "keywords": "python, software, coding, development, engineering, inclusive, community",
    "author": null,
    "og:type": "article",
    "og:url": "https://dev.to/balapriya/5-diy-python-decorators-for-building-cleaner-data-pipelines-1f24",
    "og:title": "5 DIY Python Decorators for Building Cleaner Data Pipelines",
    "og:description": "Data pipelines often tend to accumulate the same boilerplate in a lot of places. You'll likely have...",
    "og:site_name": "DEV Community",
    "og:image": "https://media2.dev.to/dynamic/image/width=1000,height=500,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk2y18yt0h2ksd6yzvihp.png",
    "twitter:site": "@thepracticaldev",
    "twitter:creator": "@",
    "twitter:title": "5 DIY Python Decorators for Building Cleaner Data Pipelines",
    "twitter:description": "Data pipelines often tend to accumulate the same boilerplate in a lot of places. You'll likely have...",
    "twitter:card": "summary_large_image",
    "twitter:widgets:new-embed-design": "on",
    "twitter:image:src": "https://media2.dev.to/dynamic/image/width=1000,height=500,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk2y18yt0h2ksd6yzvihp.png"
  },
  "paywall": {
    "listed_paid": false,
    "detected": false,
    "marker": "",
    "public_only": false,
    "policy": "付费/会员内容只保留页面公开可见部分，不绕过付费墙"
  },
  "crawl": {
    "success": true,
    "status_code": 200,
    "error": ""
  },
  "code_blocks": [
    {
      "file": "attachments/055aefebc54f/code/body-000.txt",
      "chars": 235,
      "language": "text"
    },
    {
      "file": "attachments/055aefebc54f/code/body-001.txt",
      "chars": 21,
      "language": "text"
    },
    {
      "file": "attachments/055aefebc54f/code/body-002.txt",
      "chars": 595,
      "language": "text"
    },
    {
      "file": "attachments/055aefebc54f/code/body-003.txt",
      "chars": 870,
      "language": "text"
    },
    {
      "file": "attachments/055aefebc54f/code/body-004.txt",
      "chars": 202,
      "language": "text"
    },
    {
      "file": "attachments/055aefebc54f/code/body-005.txt",
      "chars": 340,
      "language": "text"
    },
    {
      "file": "attachments/055aefebc54f/code/body-006.txt",
      "chars": 1099,
      "language": "text"
    },
    {
      "file": "attachments/055aefebc54f/code/body-007.txt",
      "chars": 641,
      "language": "text"
    },
    {
      "file": "attachments/055aefebc54f/code/body-008.txt",
      "chars": 509,
      "language": "text"
    },
    {
      "file": "attachments/055aefebc54f/code/body-009.txt",
      "chars": 902,
      "language": "text"
    },
    {
      "file": "attachments/055aefebc54f/code/body-010.txt",
      "chars": 584,
      "language": "text"
    },
    {
      "file": "attachments/055aefebc54f/code/body-011.txt",
      "chars": 839,
      "language": "text"
    },
    {
      "file": "attachments/055aefebc54f/code/body-012.txt",
      "chars": 171,
      "language": "text"
    },
    {
      "file": "attachments/055aefebc54f/code/body-013.txt",
      "chars": 559,
      "language": "text"
    },
    {
      "file": "attachments/055aefebc54f/code/body-014.txt",
      "chars": 666,
      "language": "text"
    }
  ],
  "images": [],
  "audio": [],
  "videos": [],
  "links": [
    {
      "href": "https://github.com/balapriyac/data-science-tutorials/tree/main/data-pipeline-decorators",
      "label": "You can get the code on GitHub",
      "type": "a"
    }
  ],
  "repair_version": 2,
  "repaired_at": "2026-10-14T10:49:07Z",
  "source_url": "https://dev.to/balapriya/5-diy-python-decorators-for-building-cleaner-data-pipelines-1f24",
  "papers": [],
  "actual_video_files": [],
  "body_chars": 15304,
  "expected_body_images": 0,
  "discovery_status": "ok",
  "delivery_status": "ok",
  "missing_reason": "",
  "raw_html": "attachments/055aefebc54f/pages/source.html",
  "local_html": "attachments/055aefebc54f/pages/article.html",
  "body_base_path": "attachments/055aefebc54f/pages/",
  "body_markdown_path": "attachments/055aefebc54f/pages/article.md"
}