FP-Ops: Composable Async Pipelines for Python
FP-Ops turns regular Python functions into small, type-safe operations that are easy to compose, run, and test.
Use it when a task has several steps—fetching data, validating it, transforming it, handling failures—and you want the whole pipeline to remain readable.
Why FP-Ops?
- Readable composition: connect operations from left to right with
>>. - Sync and async together: compose either kind of function in one pipeline.
- Explicit errors: every run returns
Ok(value)orError(exception). - Strong typing: pipeline inputs and outputs are checked by mypy and Pyright.
- Safe concurrency: choose fail-fast, all-settled, or lossy behavior by name.
- Immutable building blocks: configuring or composing an operation never mutates the original.
Installation
pip install fp-ops
FP-Ops supports Python 3.10 and newer.
Quick start
import asyncio
from fp_ops import Ok, operation
@operation
def parse_number(value: str) -> int:
return int(value)
@operation
async def double(value: int) -> int:
return value * 2
pipeline = parse_number >> double
async def main() -> None:
assert await pipeline.run("21") == Ok(42)
invalid = await pipeline.run("not a number")
assert invalid.is_error()
assert isinstance(invalid.error, ValueError)
asyncio.run(main())
An Operation[A, B] accepts one value of type A and produces a
Result[B, Exception]. Ordinary exceptions become Error values, so a
failed step stops the pipeline without hiding the reason.
Compose and transform
Use >> (or .then()) when the next step is another operation. Use
.map() for a small value transformation.
from fp_ops import operation
@operation
def username(user: dict[str, str]) -> str:
return user["name"]
display_name = username.map(str.strip).map(str.title)
Pipelines are immutable and reusable:
raw_name = username
clean_name = username.map(str.strip)
display_name = clean_name.map(str.title)
Creating clean_name or display_name does not change username.
Configure reusable operations
Operation templates let you configure a multi-argument function while leaving
one _ slot for the pipeline value:
from fp_ops import _, operation_template
@operation_template
def format_money(symbol: str, amount: float, *, precision: int = 2) -> str:
return f"{symbol}{amount:.{precision}f}"
usd = format_money("$", _, precision=2)
# await usd.run(12.5) == Ok("$12.50")
The template immediately creates a normal unary operation. Configuration is validated and captured when the operation is built—not later when it runs.
Handle failures
Choose the behavior that matches your application:
from fp_ops import Ok, default_on_error, operation, retry
@operation
def parse_number(value: str) -> int:
return int(value)
safe_parse = default_on_error(parse_number, 0)
resilient_parse = retry(parse_number, attempts=3, backoff=0.1)
# await safe_parse.run("unknown") == Ok(0)
recoverturns an exception into a value.recover_withruns another operation after a failure.default_on_errorsupplies a fixed fallback value.first | secondandfallback(...)try alternatives with the original input.retryretries an operation with a fixed or calculated backoff.
Work with collections
Collection helpers preserve list order and mapping keys:
from fp_ops import Ok, filter_each, map_each, operation
@operation
def scores(record: dict[str, list[int]]) -> list[int]:
return record["scores"]
normalize_scores = (
scores
>> filter_each(lambda score: score >= 0)
>> map_each(lambda score: score / 100)
)
# await normalize_scores.run({"scores": [80, -1, 95]})
# == Ok([0.8, 0.95])
Use raw callbacks with map_each, filter_each, and fold. Use nested
operations with traverse, filter_operation, and fold_operation.
traverse_parallel adds bounded concurrency when each item performs async
work.
Build structured output
Create dictionaries or typed models from the same input:
from dataclasses import dataclass
from fp_ops import Ok, build, get_path
@dataclass
class User:
name: str
age: int
to_user = build(
{
"name": get_path("profile.name"),
"age": get_path("profile.age"),
},
User,
)
data = {"profile": {"name": "Ada", "age": 36}}
# await to_user.run(data) == Ok(User(name="Ada", age=36))
get_path works with nested mappings, sequence indexes, and attributes.
assign, assign_fields, and merge_shallow cover common mapping
transformations.
Supply shared capabilities
An Environment provides typed dependencies such as configuration, clients,
or sessions without mixing them into pipeline data:
from dataclasses import dataclass
from fp_ops import EnvKey, Environment, Ok, environment_operation
@dataclass(frozen=True)
class Settings:
base_url: str
SETTINGS = EnvKey("settings", Settings)
@environment_operation(SETTINGS)
def user_url(user_id: int, settings: Settings) -> str:
return f"{settings.base_url}/users/{user_id}"
environment = Environment().with_value(
SETTINGS,
Settings(base_url="https://api.example.com"),
)
# await user_url.run(7, environment=environment)
# == Ok("https://api.example.com/users/7")
Environments are read-only and shared by every stage in a run.
Choose an execution policy
Failure behavior is explicit in each helper's name:
| Work | Fail fast | Keep every result | Keep successes |
|---|---|---|---|
| Parallel branches | fanout_parallel |
fanout_all_settled |
— |
| Collection items | traverse / traverse_parallel |
traverse_all_settled |
traverse_lossy |
| Object fields | build |
build_all_settled |
build_lossy |
| Predicates | filter_operation |
— | filter_best_effort |
Fail-fast concurrent work cancels unfinished siblings. Concurrent collection
helpers require a limit, and results retain input order rather than
completion order.
Learn more
Version 0.3 is a breaking redesign. If you are upgrading from 0.2, start with
the migration guide; old .execute(), callable operations, binary &, and
runtime argument binding have been replaced by explicit APIs.
Development
poetry install
poetry run pytest
poetry run mypy -p fp_ops
poetry run pyright
Contributing
Contributions are welcome. Open an issue to discuss a larger change, or submit a pull request with tests for the behavior you are changing.
License
FP-Ops is available under the MIT License.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file fp_ops-0.3.1.tar.gz.
File metadata
- Download URL: fp_ops-0.3.1.tar.gz
- Upload date:
- Size: 24.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
533dfa7bc24b5fe4f1eaad001fa282811ca943d37db2be57aa2c981da7c82cbe
|
|
| MD5 |
4d6c2a0362756a13533177e885706a02
|
|
| BLAKE2b-256 |
8cc7b52e21776464dd8a1499d58eb83c43dbabf4576e651f34ec4b01b1c40c24
|
File details
Details for the file fp_ops-0.3.1-py3-none-any.whl.
File metadata
- Download URL: fp_ops-0.3.1-py3-none-any.whl
- Upload date:
- Size: 26.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e342e63d7b9ee2b6d3de77070726ca4e95c85f8453b138a853fc286769b3ffaa
|
|
| MD5 |
3666b65518e50dc0de422f8ece5e119d
|
|
| BLAKE2b-256 |
34620781933e76c6918b04cb6630e292599dbd2785147a7b3e7a8a31d9adaffb
|