Ingestion scripts¶
A task with HANDLER = 'PYTHON' runs one of your team's Python scripts to bring data in: it reads
its source from where the last run left off and writes a warehouse table. The engine runs it,
captures everything it prints and logs, records its counts, and keeps its offset.
The task¶
| Parameter | Value |
|---|---|
SCRIPT_NAME |
the script, a path inside the project's ingestion_scripts/ folder, such as crm/customers.py |
INPUT_PARAMS |
optional: a JSON object the script receives as a dictionary, such as {"region": "eu", "days": 30} |
TARGET_OBJECT, SOURCE_OBJECT |
optional: the table the script writes and where it reads from, for the catalog's lineage graphs |
The script¶
The script defines run, which takes a ScriptTask and returns a ScriptResult:
from etl_craft.scripting import Offset, ScriptResult, ScriptTask
def run(task: ScriptTask) -> ScriptResult:
region, days = task.input_params["region"], task.input_params["days"]
since = task.offset.value if task.offset else 0 # None on the first run
rows = fetch_orders(region, after_id=since, days=days) # your own code
task.logger.info("fetched %d orders for %s", len(rows), region)
with task.warehouse() as engine, engine.begin() as conn:
written = insert_orders(
conn,
rows,
pipeline_id=task.pipeline_id,
pipeline_run_id=task.pipeline_run_id,
task_run_id=task.task_run_id,
)
return ScriptResult(
row_count=written,
offset=Offset.number(max(r.id for r in rows)) if rows else None,
)
ScriptTask gives the script:
| Attribute | Holds |
|---|---|
offset |
where the last successful run left off, an Offset with its value and datatype, or None on the first run |
input_params |
INPUT_PARAMS as a dictionary, empty when unset |
pipeline_id |
the pipeline definition's id; stamp it on every row you write |
pipeline_run_id |
the pipeline execution's id; stamp it on every row you write |
task_run_id |
this task execution's id, shared by its retries; stamp it on every row you write |
refresh_type |
the pipeline's FULL or INCREMENTAL |
force |
true when the task was run with --force |
run_date |
the date the run runs as of: the day it started (UTC), the --run-date it was given, or the date of a backfill run |
backfill |
true in a backfill run: offset is then None, and an offset the script returns is not stored, so read the source for run_date |
warehouse() |
a connection to the warehouse, queued behind other writers where the warehouse allows only one (a DuckDB file) |
table(name) |
schema.table as its full name in the active warehouse database, such as analytics.raw.orders, so the same script writes to each environment's database |
logger |
a logger named after the task |
task_params, config |
the task's parameters and the loaded craft-connector.yml |
ScriptResult holds row_count, the rows the script wrote, a required whole number that the
engine records as the task's source, target and insert counts; offset, the new offset, or None
to keep the stored one; and variables, a mapping of names to any values of your own, listed
in the task log. The engine validates the whole result before storing its offset.
A script that needs neither the offset nor INPUT_PARAMS can define run() without a parameter;
it still returns a ScriptResult.
An offset is a value and its datatype, and the datatype is always declared: Offset(1042,
"NUMBER"), Offset("cursor-9", "TEXT") or Offset(latest, "TIMESTAMP"), or the shorthands
Offset.number(1042), Offset.text("cursor-9") and Offset.timestamp(latest). The value is cast
to its datatype, as CAST(value AS type) would in SQL: Offset("1042", "NUMBER") holds the number
1042, and Offset("abc", "NUMBER") fails. The type is never guessed from the value.
The offset is stored as text beside its datatype, so one column holds every kind. The next run
gets it back cast to that datatype: task.offset.value is an int or Decimal for NUMBER, a
str for TEXT and a datetime for TIMESTAMP, and task.offset.datatype names it.
Timestamp offsets keep microsecond precision. A datetime with nonzero nanoseconds, such as a
pandas Timestamp, is refused; round it to microseconds before building the offset.
The offset is stored in AUD_TASK_OFFSET_TRACKER if and only if the attempt is recorded
SUCCESS. The attempt outcome, task summary, new offset and admitted task-dependency consumption
commit in one Engine DB transaction. A crash before that commit preserves the previous offset;
a crash after it preserves the complete success. A failed attempt is retried from the same place,
and the offset keeps its datatype from one run to the next. Warehouse writes made by the script
commit separately; make them safe to repeat when an attempt must be retried.
Scripts may import helper modules kept beside them in ingestion_scripts/.
Each script is compiled from its current source and registered as a module named
etl_craft_script_ followed by its path relative to ingestion_scripts/, with / and .
replaced by _: crm/customers.py becomes etl_craft_script_crm_customers_py. The script's
own future imports apply; dataclasses, enums, pickling and typing.get_type_hints work as in
other modules. A script may use ProcessPoolExecutor with the fork start method on platforms
that support it. The spawn start method cannot re-import the script by its generated module
name; put functions needed by spawned workers in a normally importable helper module.
Output and logs¶
The script runs inside the task's own process. Everything it prints, writes to stderr or logs
through logging goes to the attempt's log file, and log records carry the task's context.
HTTP and SDK libraries (urllib3, httpx, httpcore, requests, botocore, boto3 and
s3transfer) are captured at WARNING or above unless etl-craft's log level is DEBUG, to keep
request URLs and credential query strings out of routine logs. DEBUG can include those details.
A script must never print or log secrets, including in its own request URLs or exception messages.
For example:
2026-09-25 10:15:02,114 INFO etl_craft_script.load_customers [task_run_id=412 pipeline=CRM task=load_customers pipeline_run_id=97 attempt=1]: fetched 120 orders for eu
The engine adds its own lines around it: the offset and inputs the script started from, and what
it returned. AUD_TASK_RUN_LOG records the row count as the source, target and insert counts, and
TASK_LOG lists them with the new offset and the script's variables:
When something is wrong¶
The task fails with a message that names the script and the problem when:
SCRIPT_NAMEis missing, points outsideingestion_scripts/, or names no.pyfile there;INPUT_PARAMSis not a JSON object;- the script cannot be imported, or defines no
run, or arunthat takes more than the task; runraises: the message has the exception, and the traceback is in the attempt's log;runcallssys.exit: raise an exception to fail, return aScriptResultto succeed;runreturns something other than aScriptResult, arow_countthat is not a whole number of 0 or more, an offset of a different type from the stored one, orvariablesthat is not a mapping;- a timestamp offset has nanoseconds the offset store cannot keep.
The task's time limit covers the script: a script still running when it passes is stopped, and the
task is recorded FAILED.