Tuple code with trcks.fp
See also
The object-oriented tuple page covers the same operations using method chaining.
Synchronous single-track code with trcks.fp.monads.tuple_
If we want to apply a pipeline of functions to each element
in a tuple,
the module trcks.fp.monads.tuple_ provides
some higher-order functions named map* and tap*
that turn element-wise functions into functions
operating on entire tuples.
Example
>>> import asyncio
>>> from typing import Literal
>>> from trcks import AwaitableResultTuple, AwaitableTuple, Result, ResultTuple
>>> from trcks.fp.composition import (
... Pipeline0,
... Pipeline1,
... Pipeline2,
... Pipeline3,
... Pipeline4,
... pipe,
... )
>>> from trcks.fp.monads import tuple_ as t
>>>
>>> def normalize_email(email: str) -> str:
... return email.strip().lower()
>>>
>>> def to_domain(email: str) -> str:
... return email.split("@")[1]
>>>
>>> def get_domains(emails: tuple[str, ...]) -> tuple[str, ...]:
... return pipe(
... emails,
... t.map_(normalize_email),
... t.map_(to_domain),
... )
>>>
>>> get_domains((" Erika.Mustermann@Domain.ORG ", "JOHN_DOE@Provider.COM "))
('domain.org', 'provider.com')
To understand what is going on here, let us have a look at the individual steps of the chain:
Step by step
>>> emails: tuple[str, ...] = (
... " Erika.Mustermann@Domain.ORG ",
... "JOHN_DOE@Provider.COM ",
... )
>>>
>>> p0: Pipeline0[tuple[str, ...]] = (emails,)
>>> pipe(*p0)
(' Erika.Mustermann@Domain.ORG ', 'JOHN_DOE@Provider.COM ')
>>>
>>> p1: Pipeline1[tuple[str, ...], tuple[str, ...]] = (
... emails,
... t.map_(normalize_email),
... )
>>> pipe(*p1)
('erika.mustermann@domain.org', 'john_doe@provider.com')
>>>
>>> p2: Pipeline2[tuple[str, ...], tuple[str, ...], tuple[str, ...]] = (
... emails,
... t.map_(normalize_email),
... t.map_(to_domain),
... )
>>> pipe(*p2)
('domain.org', 'provider.com')
Note
The higher-order function trcks.fp.monads.tuple_.map_to_iterable applies a function that returns a collections.abc.Iterable to each element and flattens the results (like a "flat map"):
>>> pipe(
... ("ab", "cd"),
... t.map_to_iterable(tuple),
... )
('a', 'b', 'c', 'd')
The higher-order function trcks.fp.monads.tuple_.tap allows us to execute side effects for each element:
Example
>>> def get_domains(emails: tuple[str, ...]) -> tuple[str, ...]:
... return pipe(
... emails,
... t.map_(normalize_email),
... t.tap(lambda e: print(f"LOG: Processing '{e}'.")),
... t.map_(to_domain),
... )
>>>
>>> result = get_domains((" Erika.Mustermann@Domain.ORG ", "JOHN_DOE@Provider.COM "))
LOG: Processing 'erika.mustermann@domain.org'.
LOG: Processing 'john_doe@provider.com'.
>>> result
('domain.org', 'provider.com')
Synchronous double-track code with trcks.fp.monads.result_tuple
If one of the functions in a trcks.fp.composition
pipeline returns a trcks.ResultTuple[F, S] type,
the module trcks.fp.monads.result_tuple provides
some higher-order functions named map_successes* and tap_successes*
that turn element-wise functions into functions
operating on trcks.ResultTuple values.
The success track functions use the plural map_successes (instead of map_success)
because they operate on each element in the trcks.SuccessTuple individually.
Processing short-circuits on the first trcks.Failure.
Example
>>> from trcks import ResultTuple, SuccessTuple
>>> from trcks.fp.monads import result_tuple as rt
>>>
>>> UserDoesNotExist = Literal["User does not exist"]
>>> UserDoesNotHaveASubscription = Literal["User does not have a subscription"]
>>> FailureDescription = UserDoesNotExist | UserDoesNotHaveASubscription
>>>
>>> def get_user_id(user_email: str) -> Result[UserDoesNotExist, int]:
... if user_email == "erika.mustermann@domain.org":
... return "success", 1
... if user_email == "john_doe@provider.com":
... return "success", 2
... return "failure", "User does not exist"
>>>
>>> def get_subscription_id(user_id: int) -> Result[UserDoesNotHaveASubscription, int]:
... if user_id == 1:
... return "success", 42
... return "failure", "User does not have a subscription"
>>>
>>> def get_subscription_fee(subscription_id: int) -> float:
... return subscription_id * 0.1
>>>
>>> def get_subscription_fees_by_email(
... user_emails: tuple[str, ...],
... ) -> ResultTuple[FailureDescription, float]:
... return pipe(
... user_emails,
... rt.construct_successes_from_iterable,
... rt.map_successes_to_result(get_user_id),
... rt.map_successes_to_result(get_subscription_id),
... rt.map_successes(get_subscription_fee),
... )
>>>
>>> get_subscription_fees_by_email(("erika.mustermann@domain.org",))
('success', (4.2,))
>>> get_subscription_fees_by_email(("erika.mustermann@domain.org", "john_doe@provider.com"))
('failure', 'User does not have a subscription')
>>> get_subscription_fees_by_email(("jane_doe@provider.com",))
('failure', 'User does not exist')
Note
If you type-check your code with mypy, mypy may fail to infer the
type arguments of trcks.fp.composition.pipe calls that pass results
of generic map* or tap* helper functions.
In this case, gather all arguments for trcks.fp.composition.pipe
in a variable annotated with
an appropriate trcks.fp.composition.Pipeline* type, and pass that
variable to trcks.fp.composition.pipe with unpacking.
The following step-by-step breakdown demonstrates this approach.
To understand what is going on here, let us have a look at the individual steps of the chain:
Step by step
>>> from trcks.fp.composition import Pipeline4
>>>
>>> p0: Pipeline0[tuple[str, ...]] = (("erika.mustermann@domain.org",),)
>>> pipe(*p0)
('erika.mustermann@domain.org',)
>>>
>>> p1: Pipeline1[
... tuple[str, ...],
... SuccessTuple[str],
... ] = (
... ("erika.mustermann@domain.org",),
... rt.construct_successes_from_iterable,
... )
>>> pipe(*p1)
('success', ('erika.mustermann@domain.org',))
>>>
>>> p2: Pipeline2[
... tuple[str, ...],
... SuccessTuple[str],
... ResultTuple[UserDoesNotExist, int],
... ] = (
... ("erika.mustermann@domain.org",),
... rt.construct_successes_from_iterable,
... rt.map_successes_to_result(get_user_id),
... )
>>> pipe(*p2)
('success', (1,))
>>>
>>> p3: Pipeline3[
... tuple[str, ...],
... SuccessTuple[str],
... ResultTuple[UserDoesNotExist, int],
... ResultTuple[FailureDescription, int],
... ] = (
... ("erika.mustermann@domain.org",),
... rt.construct_successes_from_iterable,
... rt.map_successes_to_result(get_user_id),
... rt.map_successes_to_result(get_subscription_id),
... )
>>> pipe(*p3)
('success', (42,))
>>>
>>> p4: Pipeline4[
... tuple[str, ...],
... SuccessTuple[str],
... ResultTuple[UserDoesNotExist, int],
... ResultTuple[FailureDescription, int],
... ResultTuple[FailureDescription, float],
... ] = (
... ("erika.mustermann@domain.org",),
... rt.construct_successes_from_iterable,
... rt.map_successes_to_result(get_user_id),
... rt.map_successes_to_result(get_subscription_id),
... rt.map_successes(get_subscription_fee),
... )
>>> pipe(*p4)
('success', (4.2,))
Note
The function trcks.fp.monads.result_tuple.construct_successes_from_iterable converts a collections.abc.Iterable into a tuple and wraps it into a trcks.SuccessTuple, which can then be used with the higher-order functions from trcks.fp.monads.result_tuple.
While trcks.fp.monads.result.map_failure and trcks.fp.monads.result.map_success operate on single-value trcks.Result types, trcks.fp.monads.result_tuple.map_successes (plural) operates on each element in the tuple.
The higher-order functions trcks.fp.monads.result_tuple.tap_successes and trcks.fp.monads.result_tuple.tap_failure allow us to execute side effects in the success case (for each element) or in the failure case, respectively.
Example
>>> def get_subscription_fees_by_email(
... user_emails: tuple[str, ...],
... ) -> ResultTuple[FailureDescription, float]:
... return pipe(
... user_emails,
... rt.construct_successes_from_iterable,
... rt.map_successes_to_result(get_user_id),
... rt.tap_successes(lambda n: print(f"LOG: User ID: {n}.")),
... rt.map_successes_to_result(get_subscription_id),
... rt.map_successes(get_subscription_fee),
... rt.tap_successes(lambda x: print(f"LOG: Subscription fee: {x}.")),
... rt.tap_failure(lambda fd: print(f"LOG: Failure: {fd}.")),
... )
>>>
>>> fees_erika = get_subscription_fees_by_email(("erika.mustermann@domain.org",))
LOG: User ID: 1.
LOG: Subscription fee: 4.2.
>>> fees_erika
('success', (4.2,))
>>>
>>> fees_john = get_subscription_fees_by_email(("john_doe@provider.com",))
LOG: User ID: 2.
LOG: Failure: User does not have a subscription.
>>> fees_john
('failure', 'User does not have a subscription')
>>>
>>> fees_jane = get_subscription_fees_by_email(("jane_doe@provider.com",))
LOG: Failure: User does not exist.
>>> fees_jane
('failure', 'User does not exist')
Sometimes, side effects themselves can fail and need to return a trcks.Result type. The higher-order function trcks.fp.monads.result_tuple.tap_successes_to_result allows us to execute such side effects for each element in the success case. If the side effect returns a trcks.Failure for any element, that failure is propagated. If the side effect returns a trcks.Success for all elements, the original success values are preserved.
Example
>>> OutOfDiskSpace = Literal["Out of disk space"]
>>>
>>> def write_to_disk(n: int) -> Result[OutOfDiskSpace, None]:
... if n > 1:
... return "failure", "Out of disk space"
... return "success", print(f"LOG: Wrote {n} to disk.")
>>>
>>> def get_and_persist_user_ids(
... user_emails: tuple[str, ...],
... ) -> ResultTuple[UserDoesNotExist | OutOfDiskSpace, int]:
... return pipe(
... user_emails,
... rt.construct_successes_from_iterable,
... rt.map_successes_to_result(get_user_id),
... rt.tap_successes_to_result(write_to_disk),
... )
>>>
>>> ids_erika = get_and_persist_user_ids(("erika.mustermann@domain.org",))
LOG: Wrote 1 to disk.
>>> ids_erika
('success', (1,))
>>>
>>> ids_john = get_and_persist_user_ids(("john_doe@provider.com",))
>>> ids_john
('failure', 'Out of disk space')
>>>
>>> ids_jane = get_and_persist_user_ids(("jane_doe@provider.com",))
>>> ids_jane
('failure', 'User does not exist')
To demonstrate that processing truly short-circuits on the first
trcks.Failure, we can add logging to get_user_id.
When the second element fails, the third element is never evaluated:
Example
>>> def get_user_id_logged(user_email: str) -> Result[UserDoesNotExist, int]:
... print(f"LOG: Looking up '{user_email}'.")
... return get_user_id(user_email)
>>>
>>> pipe(
... (
... "erika.mustermann@domain.org",
... "jane_doe@provider.com",
... "john_doe@provider.com",
... ),
... rt.construct_successes_from_iterable,
... rt.map_successes_to_result(get_user_id_logged),
... )
LOG: Looking up 'erika.mustermann@domain.org'.
LOG: Looking up 'jane_doe@provider.com'.
('failure', 'User does not exist')
"john_doe@provider.com" is never looked up:
as soon as "jane_doe@provider.com" returns a trcks.Failure,
the remaining elements are skipped.
Asynchronous single-track code with trcks.fp.monads.awaitable_tuple
If one of the functions in a trcks.fp.composition
pipeline returns
a trcks.AwaitableTuple[T] type,
the following function must accept this trcks.AwaitableTuple[T] type
as its input.
The module trcks.fp.monads.awaitable_tuple provides
some higher-order functions named map*
that turn element-wise functions
into functions operating on trcks.AwaitableTuple values.
Example
>>> from trcks import AwaitableTuple
>>> from trcks.fp.monads import awaitable_tuple as at
>>>
>>> async def read_from_disk(path: str) -> str:
... await asyncio.sleep(0.001)
... contents = {"a.txt": "Hello", "b.txt": "World"}
... return contents[path]
>>>
>>> def transform(s: str) -> str:
... return f"Length: {len(s)}"
>>>
>>> async def read_and_transform(
... input_paths: tuple[str, ...],
... ) -> tuple[str, ...]:
... return await pipe(
... input_paths,
... at.construct_from_iterable,
... at.map_to_awaitable(read_from_disk),
... at.map_(transform),
... )
>>>
>>> asyncio.run(read_and_transform(("a.txt", "b.txt")))
('Length: 5', 'Length: 5')
To understand what is going on here, let us have a look at the individual steps of the chain:
Step by step
>>> from trcks import AwaitableTuple
>>>
>>> p1: Pipeline1[tuple[str, ...], AwaitableTuple[str]] = (
... ("a.txt", "b.txt"),
... at.construct_from_iterable,
... )
>>> asyncio.run(at.to_coroutine_tuple(pipe(*p1)))
('a.txt', 'b.txt')
>>>
>>> p2: Pipeline2[
... tuple[str, ...],
... AwaitableTuple[str],
... AwaitableTuple[str],
... ] = (
... ("a.txt", "b.txt"),
... at.construct_from_iterable,
... at.map_to_awaitable(read_from_disk),
... )
>>> asyncio.run(at.to_coroutine_tuple(pipe(*p2)))
('Hello', 'World')
>>>
>>> p3: Pipeline3[
... tuple[str, ...],
... AwaitableTuple[str],
... AwaitableTuple[str],
... AwaitableTuple[str],
... ] = (
... ("a.txt", "b.txt"),
... at.construct_from_iterable,
... at.map_to_awaitable(read_from_disk),
... at.map_(transform),
... )
>>> asyncio.run(at.to_coroutine_tuple(pipe(*p3)))
('Length: 5', 'Length: 5')
Note
The function trcks.fp.monads.awaitable_tuple.construct_from_iterable converts a collections.abc.Iterable into a tuple and wraps it into a trcks.AwaitableTuple, which can then be used with the higher-order functions from trcks.fp.monads.awaitable_tuple.
The values pipe(*p1), pipe(*p2), and pipe(*p3) are all
of type trcks.AwaitableTuple.
Since asyncio.run expects the input type collections.abc.Coroutine,
we use the function
trcks.fp.monads.awaitable_tuple.to_coroutine_tuple
to convert the trcks.AwaitableTuples
to collections.abc.Coroutines.
The higher-order function trcks.fp.monads.awaitable_tuple.tap allows us to execute synchronous side effects for each element. Similarly, the higher-order function trcks.fp.monads.awaitable_tuple.tap_to_awaitable allows us to execute asynchronous side effects for each element.
Example
>>> async def read_from_disk(path: str) -> str:
... await asyncio.sleep(0.001)
... contents = {"a.txt": "Hello", "b.txt": "World"}
... return contents[path]
>>>
>>> async def read_and_transform(
... input_paths: tuple[str, ...],
... ) -> tuple[str, ...]:
... return await pipe(
... input_paths,
... at.construct_from_iterable,
... at.map_to_awaitable(read_from_disk),
... at.tap(lambda s: print(f"Read '{s}' from disk.")),
... at.map_(transform),
... at.tap(lambda s: print(f"Transformed to '{s}'.")),
... )
>>>
>>> asyncio.run(read_and_transform(("a.txt", "b.txt")))
Read 'Hello' from disk.
Read 'World' from disk.
Transformed to 'Length: 5'.
Transformed to 'Length: 5'.
('Length: 5', 'Length: 5')
The widening functions
trcks.fp.monads.awaitable_tuple.map_to_result,
trcks.fp.monads.awaitable_tuple.map_to_result_iterable,
trcks.fp.monads.awaitable_tuple.map_to_awaitable_result, and
trcks.fp.monads.awaitable_tuple.map_to_awaitable_result_iterable
of the module trcks.fp.monads.awaitable_tuple
apply failable functions to each element individually
and turn the trcks.AwaitableTuple into
a trcks.AwaitableResultTuple
(see the glossary).
Similarly, the functions tap_to_result, tap_to_result_iterable,
tap_to_awaitable_result, and tap_to_awaitable_result_iterable
widen the pipeline based on the outcome of failable side effects.
Processing short-circuits on the first trcks.Failure:
Example
>>> from trcks import ResultTuple
>>> from trcks.fp.monads import awaitable_result_tuple as art
>>>
>>> async def get_subscription_fees_slowly(
... user_emails: tuple[str, ...],
... ) -> ResultTuple[FailureDescription, float]:
... return await pipe(
... user_emails,
... at.construct_from_iterable,
... at.map_to_result(get_user_id),
... art.map_successes_to_result(get_subscription_id),
... art.map_successes(get_subscription_fee),
... )
>>>
>>> asyncio.run(get_subscription_fees_slowly(("erika.mustermann@domain.org",)))
('success', (4.2,))
>>> asyncio.run(get_subscription_fees_slowly(("john_doe@provider.com",)))
('failure', 'User does not have a subscription')
>>> asyncio.run(get_subscription_fees_slowly(("jane_doe@provider.com",)))
('failure', 'User does not exist')
Asynchronous double-track code with trcks.fp.monads.awaitable_result_tuple
If one of the functions in a trcks.fp.composition
pipeline returns
a trcks.AwaitableResultTuple[F, S] type,
the module trcks.fp.monads.awaitable_result_tuple provides
some higher-order functions named map_successes* and tap_successes*
that turn element-wise functions into functions
operating on trcks.AwaitableResultTuple values.
The success track functions use the plural map_successes (instead of map_success)
because they operate on each element individually.
Processing short-circuits on the first trcks.Failure,
just as in the synchronous case above.
Example
>>> from trcks import AwaitableResultTuple, AwaitableSuccessTuple
>>> from trcks.fp.monads import awaitable_result_tuple as art
>>>
>>> ReadErrorLiteral = Literal["read error"]
>>> WriteErrorLiteral = Literal["write error"]
>>> async def read_from_disk(path: str) -> Result[ReadErrorLiteral, str]:
... if path != "a.txt" and path != "b.txt":
... return "failure", "read error"
... await asyncio.sleep(0.001)
... contents = {"a.txt": "Hello", "b.txt": "World"}
... return "success", contents[path]
>>>
>>> def transform(s: str) -> str:
... return f"Length: {len(s)}"
>>>
>>> async def write_to_disk(s: str, path: str) -> Result[WriteErrorLiteral, None]:
... if path != "output.txt":
... return "failure", "write error"
... await asyncio.sleep(0.001)
... print(f"Wrote '{s}' to file {path}.")
... return "success", None
>>>
>>> async def read_and_transform_and_write(
... input_paths: tuple[str, ...], output_path: str
... ) -> ResultTuple[ReadErrorLiteral | WriteErrorLiteral, str]:
... return await pipe(
... input_paths,
... art.construct_successes_from_iterable,
... art.map_successes_to_awaitable_result(read_from_disk),
... art.map_successes(transform),
... art.tap_successes_to_awaitable_result(lambda s: write_to_disk(s, output_path)),
... )
>>>
>>> asyncio.run(read_and_transform_and_write(("a.txt", "b.txt"), "output.txt"))
Wrote 'Length: 5' to file output.txt.
Wrote 'Length: 5' to file output.txt.
('success', ('Length: 5', 'Length: 5'))
To understand what is going on here, let us have a look at the individual steps of the chain:
Step by step
>>> p1: Pipeline1[
... tuple[str, ...],
... AwaitableSuccessTuple[str],
... ] = (
... ("a.txt", "b.txt"),
... art.construct_successes_from_iterable,
... )
>>> asyncio.run(art.to_coroutine_result_tuple(pipe(*p1)))
('success', ('a.txt', 'b.txt'))
>>>
>>> p2: Pipeline2[
... tuple[str, ...],
... AwaitableSuccessTuple[str],
... AwaitableResultTuple[ReadErrorLiteral, str],
... ] = (
... ("a.txt", "b.txt"),
... art.construct_successes_from_iterable,
... art.map_successes_to_awaitable_result(read_from_disk),
... )
>>> asyncio.run(art.to_coroutine_result_tuple(pipe(*p2)))
('success', ('Hello', 'World'))
>>>
>>> p3: Pipeline3[
... tuple[str, ...],
... AwaitableSuccessTuple[str],
... AwaitableResultTuple[ReadErrorLiteral, str],
... AwaitableResultTuple[ReadErrorLiteral, str],
... ] = (
... ("a.txt", "b.txt"),
... art.construct_successes_from_iterable,
... art.map_successes_to_awaitable_result(read_from_disk),
... art.map_successes(transform),
... )
>>> asyncio.run(art.to_coroutine_result_tuple(pipe(*p3)))
('success', ('Length: 5', 'Length: 5'))
>>>
>>> p4: Pipeline4[
... tuple[str, ...],
... AwaitableSuccessTuple[str],
... AwaitableResultTuple[ReadErrorLiteral, str],
... AwaitableResultTuple[ReadErrorLiteral, str],
... AwaitableResultTuple[ReadErrorLiteral | WriteErrorLiteral, str],
... ] = (
... ("a.txt", "b.txt"),
... art.construct_successes_from_iterable,
... art.map_successes_to_awaitable_result(read_from_disk),
... art.map_successes(transform),
... art.tap_successes_to_awaitable_result(lambda s: write_to_disk(s, "output.txt")),
... )
>>> asyncio.run(art.to_coroutine_result_tuple(pipe(*p4)))
Wrote 'Length: 5' to file output.txt.
Wrote 'Length: 5' to file output.txt.
('success', ('Length: 5', 'Length: 5'))
Note
The function trcks.fp.monads.awaitable_result_tuple.construct_successes_from_iterable converts a collections.abc.Iterable into a tuple and wraps it into a trcks.AwaitableSuccessTuple, which can then be used with the higher-order functions from trcks.fp.monads.awaitable_result_tuple.
The values pipe(*p1), pipe(*p2), pipe(*p3), and pipe(*p4) are all
of type trcks.AwaitableResultTuple.
Since asyncio.run expects the input type collections.abc.Coroutine,
we use the function
trcks.fp.monads.awaitable_result_tuple.to_coroutine_result_tuple
to convert the trcks.AwaitableResultTuples
to collections.abc.Coroutines.
The higher-order functions trcks.fp.monads.awaitable_result_tuple.tap_failure and trcks.fp.monads.awaitable_result_tuple.tap_successes allow us to execute synchronous side effects in the failure case or in the success case (for each element), respectively:
Example
>>> async def read_from_disk(path: str) -> Result[ReadErrorLiteral, str]:
... if path != "a.txt" and path != "b.txt":
... return "failure", "read error"
... await asyncio.sleep(0.001)
... contents = {"a.txt": "Hello", "b.txt": "World"}
... return "success", contents[path]
>>>
>>> async def write_to_disk(s: str, path: str) -> Result[WriteErrorLiteral, None]:
... if path != "output.txt":
... return "failure", "write error"
... await asyncio.sleep(0.001)
... return "success", None
>>>
>>> async def read_and_transform_and_write(
... input_paths: tuple[str, ...], output_path: str
... ) -> ResultTuple[ReadErrorLiteral | WriteErrorLiteral, str]:
... return await pipe(
... input_paths,
... art.construct_successes_from_iterable,
... art.map_successes_to_awaitable_result(read_from_disk),
... art.tap_successes(lambda s: print(f"LOG: Read '{s}' from disk.")),
... art.map_successes(transform),
... art.tap_successes_to_awaitable_result(lambda s: write_to_disk(s, output_path)),
... art.tap_successes(lambda _: print("LOG: Successfully wrote to disk.")),
... art.tap_failure(lambda err: print(f"LOG: Failed with error: {err}")),
... )
>>>
>>> result_1 = asyncio.run(read_and_transform_and_write(("a.txt", "b.txt"), "output.txt"))
LOG: Read 'Hello' from disk.
LOG: Read 'World' from disk.
LOG: Successfully wrote to disk.
LOG: Successfully wrote to disk.
>>> result_1
('success', ('Length: 5', 'Length: 5'))
>>>
>>> result_2 = asyncio.run(read_and_transform_and_write(("missing.txt",), "output.txt"))
LOG: Failed with error: read error
>>> result_2
('failure', 'read error')
Sometimes, side effects themselves can fail and need to return a trcks.AwaitableResult type. The higher-order function trcks.fp.monads.awaitable_result_tuple.tap_successes_to_awaitable_result allows us to execute such asynchronous side effects for each element in the success case. If the side effect returns a trcks.Failure for any element, that failure is propagated. If the side effect returns a trcks.Success for all elements, the original success values are preserved:
Example
>>> async def write_to_disk(s: str) -> Result[OutOfDiskSpace, None]:
... await asyncio.sleep(0.001)
... if len(s) > 10:
... return "failure", "Out of disk space"
... return "success", None
>>>
>>> async def read_from_disk(path: str) -> Result[ReadErrorLiteral, str]:
... if path != "a.txt" and path != "b.txt":
... return "failure", "read error"
... await asyncio.sleep(0.001)
... contents = {"a.txt": "Hi", "b.txt": "Hello, world!"}
... return "success", contents[path]
>>>
>>> async def read_and_persist(
... input_paths: tuple[str, ...],
... ) -> ResultTuple[ReadErrorLiteral | OutOfDiskSpace, str]:
... return await pipe(
... input_paths,
... art.construct_successes_from_iterable,
... art.map_successes_to_awaitable_result(read_from_disk),
... art.tap_successes(lambda s: print(f"LOG: Persisting '{s}'.")),
... art.tap_successes_to_awaitable_result(write_to_disk),
... )
>>>
>>> result = asyncio.run(read_and_persist(("a.txt", "b.txt")))
LOG: Persisting 'Hi'.
LOG: Persisting 'Hello, world!'.
>>> result
('failure', 'Out of disk space')