sfnx
Write the intent of an AWS Step Functions workflow in Python, and sfnx compiles it to the Amazon States Language (ASL) you might write by hand, in JSONata mode. It aims to express what you want the workflow to do in natural, readable states and JSONata expressions, not to reproduce every detail of Python execution. When a construct cannot be translated sensibly, the compiler says what to write instead.
Save this as app.py (it is also examples/orders.py):
from sfnx import Timeout, state_machine, task
class OutOfStock(Exception):
pass
class DynamoDb:
class ConditionalCheckFailedException(Exception):
pass
@state_machine(timeout=300)
def fulfill(input):
"""Reserve every item of an order, then charge for it."""
items: list = input["items"]
for item in items:
try:
task(
"arn:aws:states:::aws-sdk:dynamodb:updateItem",
{
"TableName": "stock",
"Key": {"sku": {"S": item["sku"]}},
"UpdateExpression": "SET quantity = quantity - :n",
"ConditionExpression": "quantity >= :n",
"ExpressionAttributeValues": {":n": {"N": str(item["quantity"])}},
},
retry=[{"ErrorEquals": [Timeout], "MaxAttempts": 3}],
)
except DynamoDb.ConditionalCheckFailedException:
raise OutOfStock(f"{item['sku']} is out of stock") from None
receipt = task(
"arn:aws:states:::lambda:invoke",
{"FunctionName": "charge", "Payload": input},
)
return {"order": input["id"], "receipt": receipt["Payload"]}
With sfnx installed (uv add sfnx), compile it:
uv run sfnx compile app.py -o fulfill.asl.json
The definition has the states a person would write by hand, named after what they do:
{
"Comment": "Reserve every item of an order, then charge for it.",
"QueryLanguage": "JSONata",
"TimeoutSeconds": 300,
"StartAt": "items",
"States": {
"items": {
"Type": "Pass",
"Assign": {
"items": "{% $states.context.Execution.Input.items %}",
"item_index": 0
},
"Next": "for"
},
"for": {
"Type": "Choice",
"Choices": [
{
"Condition": "{% $item_index < $count($items) %}",
"Next": "updateItem"
}
],
"Default": "receipt"
},
"updateItem": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:dynamodb:updateItem",
"Arguments": {
"TableName": "stock",
"Key": {
"sku": {
"S": "{% $items[$item_index].sku %}"
}
},
"UpdateExpression": "SET quantity = quantity - :n",
"ConditionExpression": "quantity >= :n",
"ExpressionAttributeValues": {
":n": {
"N": "{% $string($items[$item_index].quantity) %}"
}
}
},
"Retry": [
{
"ErrorEquals": [
"States.Timeout"
],
"MaxAttempts": 3
}
],
"Catch": [
{
"ErrorEquals": [
"DynamoDb.ConditionalCheckFailedException"
],
"Next": "raise"
}
],
"Next": "item_index"
},
"raise": {
"Type": "Fail",
"Error": "OutOfStock",
"Cause": "{% $string($items[$item_index].sku) & ' is out of stock' %}"
},
"item_index": {
"Type": "Pass",
"Assign": {
"item_index": "{% $item_index + 1 %}"
},
"Next": "for"
},
"receipt": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Arguments": {
"FunctionName": "charge",
"Payload": "{% $states.context.Execution.Input %}"
},
"Assign": {
"receipt": "{% $states.result %}"
},
"Next": "return"
},
"return": {
"Type": "Succeed",
"Output": {
"order": "{% $states.context.Execution.Input.id %}",
"receipt": "{% $receipt.Payload %}"
}
}
}
}
Why
ASL is a JSON document of states that name each other, with the logic in JSONata strings. Writing it means choosing the right spelling for every operation (+, & or $append), wiring Next by hand, and repeating Retry and Catch on every Task. sfnx lets you write the flow as Python and does that part.
- The output is ASL you can read. States split only where ASL needs them, independent assignments share one Pass, and each state is named after its variable,
return,if,foror the API it calls, so execution histories and the console read like the source. - Mistakes surface at compile time. Every rejected line comes with what to write instead. SDK integration ARNs and their argument names are checked against the botocore service models (whether Step Functions integrates the action is not checked).
- Python control flow with a few workflow primitives. The names sfnx exports make states (
task,wait,parallel,inline_map,distributed_map) or name what ASL names (context, error classes,jsonatafor an expression written out). Everything else is Python syntax, compiled to the JSONata you would write for it. Where results differ from Python lists the values known to come out otherwise.
sfnx compiles; it does not run workflows or mock tasks, and it does not deploy. The Python module stays importable, but the definition is the contract, not what CPython computes.
Setup
uv add sfnx
The workflow module imports sfnx, so it is a dependency of the project; uv run sfnx compile app.py runs the compiler.
What you write
- The machine is a function marked
@state_machineor@state_machine(timeout=300). Its parameter is the execution input, read as$states.context.Execution.Input; its return value is the output. - Assignments,
if/elif/else,for,while,break,continue,return,raise,try/exceptbecome Pass, Choice, loops through Choice, Succeed, Fail and Catch. task(resource, arguments, timeout=, heartbeat=, role=, retry=)is a Task for any integration: SDK (arn:aws:states:::aws-sdk:dynamodb:getItem), optimized (arn:aws:states:::lambda:invoke, with.syncor.waitForTaskToken), HTTP, activities, or a${Placeholder}filled in at deploy time.parallel(f, g)runs functions without parameters as branches.inline_map(f, items)anddistributed_map(f, items or source=, args=, batch=, result=)run a function per item.wait(10)andwait(until=timestamp)are Wait states.context["Execution"]["Id"]reads the Context Object.jsonata("$pad($s, -5, '0')", s=code)writes a JSONata expression out, for what has no Python spelling, with each value bound to the variable of its name.- Exceptions are your own classes derived from
Exception, nested classes for dotted names (Lambda.ServiceException), or the Step Functions errors sfnx exports (Timeout,TaskFailed, ...).except ExceptionisStates.ALL. - Names assigned outside the machine (
RETRIES = [{"ErrorEquals": [Timeout], "MaxAttempts": 3}]) hold JSON data and exception classes, and are written into the definition where they are read, so what ASL repeats state by state is written once. - Expressions are Python operators, conditional expressions, list comprehensions, f-strings, slices and dicts with
**, and the functions and methods JSONata has a counterpart for:- built-in functions
len,float,int,str,bool,list,isinstance,abs,round,sum,max,min,sorted,reversedandrange, andsetandzipinlist()(sum(xs) / len(xs)is$average, andsorted,maxandmintakekey=lambda item: ...) math.floor,math.ceil,math.sqrt,random.random,time.time,json.loads,itertools.batchedinlist(),str(uuid.uuid4())andhashlib.sha256(s.encode()).hexdigest()- a datetime from
datetime.now(),datetime.fromisoformat(text)ordatetime.fromtimestamp(seconds), converted where it is made:str()or an f-string for the timestamp text,.timestamp()for the seconds - the string methods
split,replace,lower,upper,join,startswith,endswith,ljust,rjustandstrip, and the dict methodskeys,valuesandget
- built-in functions
- Types are written where an operator depends on them, as annotations:
+is+,&or$appenddepending on the operands, andlenis$count,$lengthor$count($keys(...)). Literals, operator results and AWS API responses carry their types already. - Comments go into the definition: a function's docstring is the
Commentof the machine, a Parallel branch or a Map processor, and the comment lines right above a statement are theCommentof the first state it makes. - Anything else (
with, other methods, alambdaoutsidekey=, ...) is rejected with what to write instead; the reference lists it.
docs/language.md is the reference, and docs/design.md explains the design and the Step Functions behavior it relies on.
Rejected lines
Success prints the definition on stdout and exits 0. A rejected line exits 1 with the location and what to write instead:
$ sfnx compile app.py
app.py:6:12: + adds numbers, joins strings or lists, so the type of input['price'] must be known; assign it to an annotated variable first: value: float = input['price']
$ sfnx compile app.py
app.py:6:63: getItem has no argument Tablename; did you mean TableName?
$ sfnx compile app.py
app.py:6:12: calling any() is not supported; write it with operators or jsonata(), or compute it in a Lambda task
$ sfnx compile app.py
app.py:6:9: loop over one variable: for item in items (unpack inside the loop)
A call that cannot proceed exits 2:
$ sfnx compile missing.py
missing.py: No such file or directory
$ sfnx compile app.py
app.py defines 2 state machines (pay, refund); pass -o out/ to write one file each
Reference
usage: sfnx [-h] [--version] [--instructions] command ...
sfnx - write Step Functions workflows as Python functions and compile them to Amazon States Language.
positional arguments:
command
compile compile the @state_machine functions of a file to ASL
options:
-h, --help show this help message and exit
--version show program's version number and exit
--instructions print the paragraph for an agent instruction file and exit
Examples:
sfnx compile app.py print the ASL of the only @state_machine
sfnx compile app.py -o out/ write one <function>.asl.json per @state_machine
Exit codes:
0 success
1 the source is not accepted; the message names the line and what to write instead
2 the call is wrong or a file cannot be read or written
3 internal error; report it with the source that caused it
compileparses the file and never imports or runs it.-oending in.jsonwrites the only machine to that file; any other path is a directory that receives<function>.asl.jsonper machine. Missing directories are created.- The first error stops the compilation, so one run reports one line.
Output
| Stream | Shape | Stable |
|---|---|---|
| stdout (exit 0) | the definition as indented JSON in UTF-8, with text as written | valid JSON of a definition |
| stderr (exit 1) | <path>:<line>:<column>: <message> |
the location before the message |
| stderr (exit 2) | <path>: <reason>, or a message naming the path |
the path |
The message text, including ; <what to write instead>, is prose and may change between releases. So may state names when the source changes above them in the same scope (serial numbers such as amount_2).
Before 1.0, the definition compiled from the same source, and what the language accepts, may change between releases; the release notes say so.
Deploying the definition
sfnx stops at the definition. Write ${Name} where a value comes from the deployment, as a resource ARN or inside an argument string, and fill it with CDK definition_substitutions, SAM or CloudFormation DefinitionSubstitutions. docs/deployment.md has the snippets, how to check a definition before deploying it, and the IAM actions each kind of task needs.
Development
env -u VIRTUAL_ENV ./validate.sh
validate.sh runs lint, formatting, type checking, the tests and a build. The tests evaluate the generated JSONata with jsonata-python and run whole definitions through a small interpreter, including random programs whose results must match CPython's.
License
MIT
Release files for sfnx 0.8.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| sfnx-0.8.1.tar.gz | 151.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sfnx-0.8.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 227.1 kB
Release files / sfnx-0.8.1.tar.gz
| Download URL | sfnx-0.8.1.tar.gz |
|---|---|
| Size | 151.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5abe5e1e48a38ced234aa14013fbc28579119b184c058086186f228c283fefdd
|
|
BLAKE2b-256 checksum How to use checksums |
e33131a32920364e10655adf5ec85fb8f9e15ecf374c920de19708a4143e0058
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.16 {"installer":{"name":"uv","version":"0.12.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / sfnx-0.8.1-py3-none-any.whl
| Download URL | sfnx-0.8.1-py3-none-any.whl |
|---|---|
| Size | 75.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
9976a885d827efe1f0bbaa7000bb718d1889e210f127ca0d6a4d44b6432f46a4
|
|
BLAKE2b-256 checksum How to use checksums |
dea1c7c742d3e71e4d6822ae1c7a9b6ffe0d86da354fa451e48455c810d253f6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.16 {"installer":{"name":"uv","version":"0.12.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|