Airflow DAGs skill for Claude Code

Airflow 3 DAGs that parse fast and rerun cleanly. Gives Claude the Airflow 3 rules it tends to miss: airflow.sdk imports, removed SLAs and execution_date, and the cron default that makes data_interval_start equal data_interval_end. It writes tasks that read their window from the run, keeps DAG files free of parse-time I/O, makes waits deferrable with explicit timeouts, and ships a stdlib checker that flags Airflow 2 leftovers before they reach the scheduler.

  • nameairflow-dags
  • size318 lines · ≈3,700 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Writes, reviews and upgrades Apache Airflow 3.x DAGs - TaskFlow API with airflow.sdk imports, timetables and the CronTriggerTimetable data-interval trap, asset-aware scheduling, idempotent tasks, retries, execution timeouts and Deadline Alerts, deferrable operators and sensors, dynamic task mapping, XCom size limits, pools, backfills, parse-time hygiene and DAG tests. Use when writing or debugging a DAG file, migrating from Airflow 2, a DAG is slow to parse or missing in the UI, sensors hog worker slots, or choosing between Airflow, Dagster and Prefect.

Try asking

  • Migrate our dags/ folder from Airflow 2 to Airflow 3
  • This DAG takes 40 seconds to parse and sometimes disappears from the UI, fix it
  • Write an Airflow DAG that loads yesterday's orders from S3 into Snowflake and waits for the file

Use it when

  • Writing or reviewing an Airflow DAG file
  • Upgrading DAGs from Airflow 2.x to 3.x
  • Sensors or long waits are consuming worker slots
  • A DAG is slow to parse, missing in the UI, or reruns create duplicates
  • Planning a backfill over a date range

Skip it when

  • You are designing a pipeline independent of the orchestrator, or using Dagster or Prefect - use data-pipelines
  • You only need one script on a cron schedule - use scheduled-jobs

How to check the result

scripts/check_dag_files.py reports no errors, the DagBag integrity test passes, and airflow dags test for one logical date produces the same row counts when run twice.

The common mistake it prevents

Keeping a bare cron schedule after upgrading to Airflow 3: it becomes a trigger timetable where data_interval_start equals data_interval_end, so tasks that query the interval process an empty window.

~/.claude/skills/airflow-dags/SKILL.md

--- name: airflow-dags description: Writes, reviews and upgrades Apache Airflow 3.x DAGs - TaskFlow API with airflow.sdk imports, timetables and the CronTriggerTimetable… license: MIT ---

A DAG is done when the file parses in well under a second with no I/O, every task reads its window from the run (never the wall clock) and can be rerun without duplicates, waiting happens in the triggerer instead of a worker slot, every task and run has a timeout, and an integrity test plus one dags test run pass in CI.

First, identify the setup

airflow version                                  # 3.x? which minor?
pip list 2>/dev/null | grep -E "apache-airflow(-providers-[a-z-]+)? "
airflow config get-value core executor
airflow config get-value scheduler create_cron_data_intervals
airflow dags list-import-errors

Managed services (MWAA, Cloud Composer, Astronomer) pin their own Airflow and provider versions; check the environment’s version before using a 3.x-only feature.

Airflow 3 facts that change how you write DAGs

  • Author with airflow.sdk: dag, task, DAG, Asset, AssetAlias, Variable, Param, TaskGroup, get_current_context. Core operators and sensors moved to airflow.providers.standard (for example airflow.providers.standard.operators.python). Old paths warn in 3.1 and will be removed.
  • Task code runs through the Task SDK and cannot open the metadata database (no settings.Session, no ORM models in tasks). Use the REST API client instead.
  • Removed: schedule_interval (use schedule), SLAs (sla=, sla_miss_callback; use Deadline Alerts, 3.1+), SubDAGs (use TaskGroups or assets), and context keys execution_date, next_ds, prev_ds, yesterday_ds, tomorrow_ds and their variants. Use logical_date and data_interval_start / data_interval_end.
  • catchup defaults to False.
  • Bare cron strings and presets now resolve to CronTriggerTimetable because [scheduler] create_cron_data_intervals defaults to False. For those runs data_interval_start == data_interval_end. Code that computed “yesterday” from the interval silently processes a zero-length window. See Scheduling.
  • xcom_pull() without task_ids reads only the current task.
  • Datasets are Assets; @asset defines a one-task DAG producing an asset.
  • Airflow 3.2 added asset partitioning (CronPartitionTimetable, PartitionedAssetTimetable, dag_run.partition_key); check your minor version before relying on it.

Migrating a 2.x DAG folder: ruff check dags/ --select AIR301,AIR302 (breaking) and AIR311,AIR312 (recommended), ruff 0.13.1 or newer; add --fix after reviewing.

Workflow

- [ ] 1. Define the run unit: what one DAG run processes (a day, an hour, a partition)
- [ ] 2. Pick the timetable or asset trigger that matches that unit
- [ ] 3. Write tasks as idempotent functions of the run's window
- [ ] 4. Set retries, execution_timeout, dagrun_timeout, max_active_runs, pools
- [ ] 5. Make waits deferrable; give every sensor a timeout
- [ ] 6. Keep the file parse-cheap; run scripts/check_dag_files.py
- [ ] 7. Integrity test + `airflow dags test` for one logical date
- [ ] 8. Backfill a small range, verify, then the full range (user confirms range)

Scheduling

NeedSchedule
Process the previous day/hour as a windowCronDataIntervalTimetable("0 2 * * *", timezone="UTC") (interval = previous cron period)
Fire at a time, window not needed (reports, pings)Cron string or CronTriggerTimetable(...)
Fire at a time but process a fixed lookbackCronTriggerTimetable("0 2 * * *", timezone="UTC", interval=timedelta(days=1))
Fixed spacing regardless of clockDeltaTriggerTimetable(timedelta(hours=6))
Run when upstream data landsschedule=[orders_asset], or (a & b), `(a
Both asset updates and a clockAssetOrTimeSchedule(...)
Only manual or API triggeredschedule=None
  • Pass a timetable object rather than relying on the create_cron_data_intervals setting, so a config change cannot alter logical_date semantics.
  • Switching an existing DAG from a trigger to a data-interval timetable skips one run. For big schedule changes, create a new dag_id.
  • Asset events are emitted only when the producing task succeeds. Cross-DAG timing by guessing (“run at 03:00, upstream usually finishes by 02:45”) is a bug; use assets.
  • Schedules, start_date, and intervals in UTC. Use a fixed pendulum.datetime(2026, 1, 1, tz="UTC"), never datetime.now() or days_ago().

A reference DAG

from datetime import timedelta
import pendulum
from airflow.sdk import Asset, CronDataIntervalTimetable, dag, task

raw_orders = Asset("s3://lake/raw/orders")

@dag(
    schedule=CronDataIntervalTimetable("0 2 * * *", timezone="UTC"),
    start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
    catchup=False,
    max_active_runs=1,
    dagrun_timeout=timedelta(hours=3),
    default_args={
        "owner": "data-eng",
        "retries": 3,
        "retry_delay": timedelta(minutes=5),
        "retry_exponential_backoff": True,
        "execution_timeout": timedelta(minutes=45),
    },
    tags=["orders", "tier-1"],
)
def orders_daily():
    @task(outlets=[raw_orders])
    def extract(data_interval_start=None, data_interval_end=None) -> str:
        from my_pkg.orders import export_orders          # heavy imports inside the task
        day = data_interval_start.strftime("%Y-%m-%d")
        path = f"s3://lake/raw/orders/dt={day}/part.parquet"
        export_orders(start=data_interval_start, end=data_interval_end, dest=path)  # overwrite
        return path                                       # small reference, not data

    @task(pool="warehouse", retries=1)
    def load(path: str, data_interval_start=None) -> int:
        from my_pkg.warehouse import replace_partition
        return replace_partition("raw.orders", path, dt=data_interval_start.date())

    @task(retries=0)
    def validate(rows: int) -> None:
        if rows == 0:
            raise ValueError("0 rows loaded")             # fail fast; retrying will not help

    validate(load(extract()))

orders_daily()

Idempotency patterns (overwrite partition, MERGE on key, delete+insert in one transaction) are covered tool-agnostically in data-pipelines; in Airflow the key is that every task derives its window from data_interval_* or logical_date.

Parse-time hygiene

The DAG processor re-executes every file’s top-level code continuously.

  • No network, database, Variable.get(), or cloud SDK calls at module level. Read Variables inside tasks or via templates: "{{ var.value.bucket }}".
  • Import pandas, Spark clients, and SDKs inside the task function.
  • Measure: time python dags/orders_daily.py minus time python -c ''. Aim for well under 1 s per file; [core] dagbag_import_timeout (default 30 s) kills slow files and the DAG disappears from the UI.
  • Dynamic DAG generation from a config file in the repo is fine; generating from an API call at parse time is not.
  • Run the bundled checker on every change:
python3 scripts/check_dag_files.py dags/            # errors exit 1
python3 scripts/check_dag_files.py dags/ --strict   # warnings also fail (CI)

It flags removed Airflow 2 parameters and context keys, dynamic start_date, legacy imports, top-level I/O, wall-clock calls inside tasks, sensors without timeout, and xcom_pull() without task_ids. It parses with ast and never imports the DAG.

Data passing and fan-out

  • XCom (including TaskFlow return values) is for small metadata: paths, IDs, row counts. Large values bloat the metadata DB. For bigger payloads write to object storage and pass the URI, or configure the object storage XCom backend: [core] xcom_backend = airflow.providers.common.io.xcom.backend.XComObjectStorageBackend with [common.io] xcom_objectstorage_path and xcom_objectstorage_threshold (bytes; smaller values stay in the DB).
  • Dynamic task mapping: process.expand(path=list_files()), constants via .partial(...), several args via .expand_kwargs(...). [core] max_map_length (default 1024) caps the fan-out; an empty input marks the mapped task skipped. Limit parallelism with max_active_tis_per_dag on the mapped task, and set map_index_template so UI rows are readable.
  • Group related tasks with @task_group; do not nest DAGs.

Retries, timeouts, and deadlines

SettingRule
retries + retry_exponential_backoff2-3 for tasks hitting networks or warehouses; retries=0 for validation and non-idempotent side effects
execution_timeoutOn every task (via default_args); a hung task otherwise holds a slot forever
dagrun_timeoutUpper bound for the whole run, so a stuck run cannot block max_active_runs=1
Sensor timeoutAlways explicit; the default is 7 days
on_failure_callbackAlert after retries are exhausted, with DAG, task, logical date, and log URL
Deadline Alerts (3.1+)Replace SLAs: alert when a run is not done N minutes after it was queued or scheduled
from datetime import timedelta
from airflow.sdk import DAG, AsyncCallback, DeadlineAlert, DeadlineReference
from airflow.providers.slack.notifications.slack_webhook import SlackWebhookNotifier

with DAG(
    dag_id="orders_daily",
    deadline=DeadlineAlert(
        reference=DeadlineReference.DAGRUN_QUEUED_AT,      # or DAGRUN_LOGICAL_DATE, FIXED_DATETIME(...)
        interval=timedelta(minutes=90),
        callback=AsyncCallback(SlackWebhookNotifier,
                               kwargs={"text": "{{ dag_run.dag_id }} missed its deadline"}),
    ),
    ...
):
    ...

AsyncCallback runs in the triggerer (restart it when callback code changes); SyncCallback (3.2+) runs on an executor.

Waiting: sensors and deferrable operators

Wait typeUse
External job, file, or API state for minutes to hoursDeferrable operator or sensor (deferrable=True): frees the worker slot, waits in the triggerer
Short check (seconds)Plain operator with retries
Sensor without a deferrable variantmode="reschedule" with poke_interval of minutes, plus timeout
Upstream DAG’s dataAsset schedule, not an ExternalTaskSensor
  • Deferrable requires at least one running airflow triggerer. Without it, deferred tasks sit in the deferred state forever.
  • [operators] default_deferrable = True flips the default for providers that support it.
  • Custom triggers: subclass BaseTrigger, implement serialize() and an async run() that yields TriggerEvent; no blocking calls and no side effects, because a trigger can run more than once.

Concurrency

  • max_active_runs=1 when runs share mutable state or must apply in order.
  • Pools (pool="warehouse", pool_slots=) protect shared systems across DAGs; size the pool to what the downstream system tolerates.
  • max_active_tasks caps running task instances across all active runs of the DAG; priority_weight orders queued tasks when slots are scarce.

Backfills

airflow backfill create --dag-id orders_daily \
  --from-date 2026-09-01 --to-date 2026-09-07 \
  --reprocess-behavior failed --max-active-runs 2
  • --reprocess-behavior: none (only missing runs), failed (missing and failed), completed (rerun everything in range). Use the dry-run option first to list dates.
  • Ask the user to confirm the exact range and reprocess behavior before any backfill that rewrites existing partitions in production.
  • Procedure: one date, verify output and row counts, a small range, check warehouse load and cost, then the full range with a low --max-active-runs.
  • Confirm which code version the backfill runs (DAG versioning records the bundle version per run) and that old dates should be processed with that logic.

Testing

# tests/test_dag_integrity.py
import pytest
from airflow.dag_processing.dagbag import DagBag

@pytest.fixture(scope="session")
def dagbag():
    return DagBag(dag_folder="dags", include_examples=False)

def test_no_import_errors(dagbag):
    assert dagbag.import_errors == {}

def test_dag_conventions(dagbag):
    for dag_id, dag in dagbag.dags.items():
        assert dag.tags, f"{dag_id}: add tags"
        for t in dag.tasks:
            assert t.owner != "airflow", f"{dag_id}.{t.task_id}: set a real owner"
            assert t.execution_timeout is not None, f"{dag_id}.{t.task_id}: no execution_timeout"
airflow dags test orders_daily 2026-10-18           # full run in one process, real connections
airflow tasks test orders_daily load 2026-10-18     # one task, no state recorded
pytest tests/                                       # integrity + unit tests of task logic

Unit-test the business logic as plain functions (pass the window explicitly); the DAG should be thin wiring. dag.test() from __main__ gives the same full run inside an IDE debugger.

When not to use Airflow

SituationBetter fit
Asset/partition-first modeling, strong local testing, lineage as the core UIDagster
Python-native dynamic flows, little infrastructure, event-driven runsPrefect
One script on a schedule with no dependenciesA scheduler (cron, cloud scheduler); see scheduled-jobs
Continuous event processingA streaming system; see kafka-streaming
Only dbt models on a scheduledbt’s own scheduler or a minimal job runner

Stay on Airflow when the team already runs it and the need is covered; migration cost is real. Tool-agnostic pipeline design is in data-pipelines.

Gotchas

  • Mixing airflow.decorators and airflow.sdk imports in one repo hides which DAGs are migrated; move a folder at a time and enforce with ruff AIR rules.
  • {{ ds }} is the logical date. With CronDataIntervalTimetable that is the interval start; with trigger timetables it is the trigger time. Prefer explicit data_interval_start in code.
  • start_date in the future means no runs and no error.
  • Retrying a task that appends rows or sends emails multiplies the side effect.
  • trigger_rule="all_done" cleanup tasks still need their own timeout.
  • Top-level Variable.get() turns every parse into a metadata or secrets-backend call.
  • Changing dag_id creates a new DAG with no history; the old one stays until deleted.

Output

**DAG:** orders_daily (Airflow 3.3), CronDataIntervalTimetable 02:00 UTC, window = previous day
**Tasks:** extract (overwrite S3 partition) -> load (pool=warehouse, replace partition) -> validate (retries=0)
**Reliability:** retries 3 exp backoff, execution_timeout 45m, dagrun_timeout 3h, DeadlineAlert 90m after queue
**Waits:** S3 sensor deferrable=True, timeout 2h
**Checks:** check_dag_files.py clean (--strict); DagBag test green; parse 0.21 s
**Verified:** `airflow dags test orders_daily 2026-10-18` loaded 48,211 rows; rerun left count unchanged

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/check_dag_files.py 213 lines · 9.1 KB
#!/usr/bin/env python3
"""Static checks for Airflow DAG files (Airflow 3.x rules). Standard library only.

Usage:
    python3 scripts/check_dag_files.py <dags_dir_or_file> [...] [--strict]

Parses each .py file with `ast` (never imports or executes it) and reports:
  ERROR  AF001  schedule_interval= (removed in Airflow 3; use schedule=)
  ERROR  AF002  sla= / sla_miss_callback= (SLAs removed in Airflow 3; use Deadline Alerts)
  ERROR  AF003  removed context variable (execution_date, next_ds, prev_ds, ...)
  ERROR  AF004  dynamic start_date (datetime.now(), utcnow(), pendulum.now(), days_ago())
  WARN   AF101  legacy import path (airflow.decorators, airflow.models.dag, airflow.datasets,
                airflow.operators.*, airflow.sensors.*) - move to airflow.sdk / providers.standard
  WARN   AF102  top-level I/O call that runs on every DAG parse (Variable.get, HTTP, DB, boto3 ...)
  WARN   AF103  wall-clock time inside a task (datetime.now()/utcnow()/pendulum.now()/today())
  WARN   AF104  sensor without an explicit timeout= (default is 7 days)
  WARN   AF105  xcom_pull() without task_ids (Airflow 3 pulls only from the current task)

Exit codes: 0 = clean (warnings allowed unless --strict), 1 = findings, 2 = bad input.
"""
from __future__ import annotations

import ast
import re
import sys
from pathlib import Path

REMOVED_CONTEXT = {
    "execution_date", "next_execution_date", "prev_execution_date",
    "prev_execution_date_success", "next_ds", "next_ds_nodash", "prev_ds",
    "prev_ds_nodash", "yesterday_ds", "yesterday_ds_nodash", "tomorrow_ds",
    "tomorrow_ds_nodash",
}
CONTEXT_RE = re.compile(r"\{\{[^}]*\b(" + "|".join(sorted(REMOVED_CONTEXT)) + r")\b[^}]*\}\}")
LEGACY_IMPORTS = (
    "airflow.decorators", "airflow.models.dag", "airflow.datasets",
    "airflow.operators.", "airflow.sensors.", "airflow.models.baseoperator",
)
LEGACY_EXACT = {"airflow.operators", "airflow.sensors"}
TOP_LEVEL_IO = {
    ("Variable", "get"), ("requests", "get"), ("requests", "post"), ("requests", "request"),
    ("boto3", "client"), ("boto3", "resource"), ("psycopg", "connect"), ("psycopg2", "connect"),
    ("sqlite3", "connect"), ("pd", "read_sql"), ("pd", "read_csv"), ("pandas", "read_sql"),
    ("urllib.request", "urlopen"), ("httpx", "get"), ("httpx", "post"),
}
TOP_LEVEL_IO_NAMES = {"urlopen", "create_engine", "BaseHook.get_connection"}
NOW_ATTRS = {"now", "utcnow", "today"}


def dotted(node: ast.AST) -> str:
    parts = []
    while isinstance(node, ast.Attribute):
        parts.append(node.attr)
        node = node.value
    if isinstance(node, ast.Name):
        parts.append(node.id)
    return ".".join(reversed(parts))


def is_now_call(node: ast.AST) -> bool:
    if not isinstance(node, ast.Call):
        return False
    name = dotted(node.func)
    if name.endswith("days_ago"):
        return True
    last = name.rsplit(".", 1)[-1]
    head = name.rsplit(".", 1)[0] if "." in name else ""
    return last in NOW_ATTRS and head.split(".")[-1] in {"datetime", "pendulum", "date", "timezone"}


class Checker(ast.NodeVisitor):
    def __init__(self, path: Path):
        self.path = path
        self.findings: list[tuple[int, str, str, str]] = []
        self.func_depth = 0

    def add(self, node: ast.AST, level: str, code: str, msg: str) -> None:
        self.findings.append((getattr(node, "lineno", 0), level, code, msg))

    # imports
    def visit_Import(self, node: ast.Import) -> None:
        for alias in node.names:
            self._check_module(node, alias.name)
        self.generic_visit(node)

    def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
        if node.module:
            self._check_module(node, node.module)
        self.generic_visit(node)

    def _check_module(self, node: ast.AST, module: str) -> None:
        if module in LEGACY_EXACT or module.startswith(LEGACY_IMPORTS):
            self.add(node, "WARN", "AF101",
                     f"legacy import '{module}': use airflow.sdk or airflow.providers.standard.*")

    # functions (task bodies)
    def _visit_func(self, node):
        for arg in node.args.args + node.args.kwonlyargs:
            if arg.arg in REMOVED_CONTEXT:
                self.add(arg, "ERROR", "AF003",
                         f"parameter '{arg.arg}' is a removed context variable; use logical_date or data_interval_start/end")
        # decorators and defaults are evaluated when the module is parsed, not in the task
        for dec in node.decorator_list:
            self.visit(dec)
        for default in node.args.defaults + [d for d in node.args.kw_defaults if d is not None]:
            self.visit(default)
        self.func_depth += 1
        for stmt in node.body:
            self.visit(stmt)
        self.func_depth -= 1

    visit_FunctionDef = _visit_func
    visit_AsyncFunctionDef = _visit_func

    def visit_Lambda(self, node: ast.Lambda) -> None:
        self.func_depth += 1
        self.generic_visit(node)
        self.func_depth -= 1

    def visit_Constant(self, node: ast.Constant) -> None:
        if isinstance(node.value, str):
            m = CONTEXT_RE.search(node.value)
            if m:
                self.add(node, "ERROR", "AF003",
                         f"template uses removed context variable '{m.group(1)}'")
        self.generic_visit(node)

    def visit_Subscript(self, node: ast.Subscript) -> None:
        sl = node.slice
        if isinstance(sl, ast.Constant) and sl.value in REMOVED_CONTEXT and dotted(node.value).endswith("context"):
            self.add(node, "ERROR", "AF003", f"context['{sl.value}'] was removed in Airflow 3")
        self.generic_visit(node)

    def visit_Call(self, node: ast.Call) -> None:
        name = dotted(node.func)
        short = name.rsplit(".", 1)[-1]
        kwargs = {k.arg: k.value for k in node.keywords if k.arg}

        if "schedule_interval" in kwargs:
            self.add(node, "ERROR", "AF001", "schedule_interval= was removed in Airflow 3; use schedule=")
        for key in ("sla", "sla_miss_callback"):
            if key in kwargs:
                self.add(node, "ERROR", "AF002", f"{key}= was removed in Airflow 3; use a DeadlineAlert on the DAG")
        if "start_date" in kwargs and is_now_call(kwargs["start_date"]):
            self.add(node, "ERROR", "AF004", "start_date must be a fixed datetime, not now()/days_ago()")
        if isinstance(kwargs.get("default_args"), ast.Dict):
            d = kwargs["default_args"]
            for k, v in zip(d.keys, d.values):
                if isinstance(k, ast.Constant) and k.value == "start_date" and is_now_call(v):
                    self.add(node, "ERROR", "AF004", "default_args start_date must be fixed, not now()")
                if isinstance(k, ast.Constant) and k.value in ("sla", "sla_miss_callback"):
                    self.add(node, "ERROR", "AF002", f"default_args['{k.value}'] was removed in Airflow 3")

        if self.func_depth == 0:
            parts = name.rsplit(".", 1)
            pair = (parts[0], parts[1]) if len(parts) == 2 else ("", name)
            if pair in TOP_LEVEL_IO or name in TOP_LEVEL_IO_NAMES or short in {"urlopen", "create_engine"}:
                self.add(node, "WARN", "AF102",
                         f"top-level call {name}() runs on every DAG parse; move it into a task or use a Jinja template")
        elif is_now_call(node):
            self.add(node, "WARN", "AF103",
                     f"{name}() inside a task breaks reruns/backfills; use data_interval_start/end or logical_date")

        if short.endswith("Sensor") and short[0].isupper() and "timeout" not in kwargs:
            self.add(node, "WARN", "AF104", f"{short} has no timeout= (default 7 days); set one and prefer deferrable=True")
        if short == "xcom_pull" and "task_ids" not in kwargs and not node.args:
            self.add(node, "WARN", "AF105", "xcom_pull() without task_ids reads only the current task in Airflow 3")
        self.generic_visit(node)


def iter_files(args: list[str]):
    for a in args:
        p = Path(a)
        if p.is_dir():
            yield from sorted(x for x in p.rglob("*.py") if "__pycache__" not in x.parts)
        elif p.is_file():
            yield p
        else:
            print(f"error: {a} not found", file=sys.stderr)
            sys.exit(2)


def main(argv: list[str]) -> int:
    strict = "--strict" in argv
    paths = [a for a in argv if a != "--strict"]
    if not paths or any(a in ("-h", "--help") for a in paths):
        print(__doc__)
        return 2
    errors = warns = files = 0
    for path in iter_files(paths):
        files += 1
        try:
            tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
        except SyntaxError as exc:
            print(f"{path}:{exc.lineno}: ERROR AF000 syntax error: {exc.msg}")
            errors += 1
            continue
        checker = Checker(path)
        checker.visit(tree)
        for line, level, code, msg in sorted(checker.findings):
            print(f"{path}:{line}: {level} {code} {msg}")
            if level == "ERROR":
                errors += 1
            else:
                warns += 1
    print(f"\n{files} file(s) checked: {errors} error(s), {warns} warning(s)")
    if errors or (strict and warns):
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc