Skip to main content

Basic monads for implementing pipe-styled processing

Project description

wird

wird is a library that provides basic monads in python. Core idea is to provide mechanics for writing purely python pipeline-styled code.

Why wird? Wird is a misspelling of Anglo-Saxon / Old North word "wyrd". It means fate, but not totally predefined, more like a consequence of previous deeds.

Pipelines

Before getting into wird API it's worth explaining concept of pipeline-styled code. Mainly our code is imperative - we describe what we do to achieve some result in steps, one by one. It's not worth to reject imperative code in favor of declarative one (where we describe the result instead of steps for getting it), as most languages are generally imperative and it's more convenient to provide better ways to write it.

Different languages provide pipelines in different forms. For example in C# or Java it is provided with so called Fluent API (sometimes method chaining). Example:

int[] numbers = [ 5, 10, 8, 3, 6, 12 ];

IEnumerable<int> evenNumbersSorted = numbers
    .Where(num => num % 2 == 0)
    .OrderBy(num => num);

There we write some class that allows us to chain method execution in order to perform some action. This is quite nice approach, however it's not really extensible and does not suit to most of the business cases where we want to separate bits of logic into different entities.

Mostly this kind of syntax is used for builder pattern:

var host = new WebHostBuilder() 
    .UseKestrel() 
    .UseContentRoot(Directory.GetCurrentDirectory()) 
    .UseStartup<Startup>() 
    .Build(); 

host.Run(); 

In functional languages you can find so called "pipe operator" - |>. Let's take a look at simple case - we want to put to square some number, that convert that to string and reverse it. In F# you might write that like:

let result = rev (toStr (square 512))

Problem of this piece of code is that despite or algorithm is simple and direct, when we write code it steps are written in reverse order and we need to "unwrap" function calls.

With pipe operator same code becomes much more elegant:

let result = 512
    |> square
    |> toStr
    |> rev

All actions are written one-by-one in the same order as they executed. This is much more readable code.

Basically wird is written to provide this mechanic to python language in some opinionated form inspired by Rust language.

Monads

Value

Container for sync value that provides pipe-styled execution of arbitrary functions. Let's look at the example:

import operator

from wird import Value


res = (
    Value(3)
    .map(operator.add, 1)       # 3 + 1 -> 4
    .map(operator.mul, 3)       # 4 * 3 -> 12
    .map(operator.truediv, 2)   # 12 / 2 -> 6
    .inspect(print)             # print 6.0 & pass next
    .unwrap(as_type=int)        # extract 6.0 from container
)

Value is a simple wrapper around passed value with special methods (map / map_async / inspect / inspect_async) that bind passed function to container value (read as invoke / apply). Thus it is basically is a simplest monad.

Value provides the following interface:

  • Value.unwrap - method for extracting internally stored value with optional type casting (only for type checker, not actual casting happens)
  • Value.map - binding method for sync functions
  • Value.map_async - binding method for async functions
  • Value.inspect - binding method for sync side-effect functions
  • Value.inspect_async - binding method for async side-effect functions

Main different between map and inspect is that map wraps the result of the executed function into Value container and inspect just invokes function passing stored value next. If stored value is mutable, inspect can be used to modify it via side effect.

Future

Container for async values. It is similar to Value and provides nearly the same interface. When we invoke any of async methods in Value we actually return Future container, as now stored value is computed asynchronously and requires await.

import operator

from wird import Value

async def mul_async(x: int, y: int) -> int:
  return x * y

async def truediv_async(x: int, y: int) -> float:
  return x / y

async def main():
  res = await (
    Value(3)
    .map(operator.add, 1)         # 3 + 1 -> 4 (Value)
    .map_async(mul_async, 3)      # 4 * 3 -> 12 (Future)
    .map_async(truediv_async, 2)  # 12 / 2 -> 6.0 (Future)
    .inspect(print)               # print 6.0 & pass next (Future)
    .unwrap()                     # extract awaitable 6.0 from container
  )

if __name__ == "__main__":
  import asyncio
  asyncio.run(main())

Future provides the following interface:

  • Future.unwrap - extract internally stored awaitable value
  • Future.map - binding method for sync functions
  • Future.map_async - binding method for async functions
  • Future.inspect - binding method for sync side-effect functions
  • Future.inspect_async - binding method for async side-effect functions
  • Future.from_ - static method for creating awaitable object from sync value

Also Future is awaitable by itself, so one can just await Future itself instead of calling Future.unwrap, but to stay uniform it is recommended to use Future.unwrap.

Maybe

Despite Value and Future, Maybe is not a single container, but rather a pair of containers - Some and Empty. Each resembles additional property of data - its presence.

Some indicates that data is present allowing it to be processed. Empty on the other hand marks that there is not data and we can't perform execution ignoring that. Basically it hides explicit is None checks, taking it as internal rule of function mapping.

Possible relevant case of usage is database patch / update operations, when we intentionally want to provide some abstract interface that allows optional column update. For example we store in SQL database following data structure:

from dataclasses import dataclass
from datetime import date


@dataclass
class Customer:
  uid: int
  first_name: str
  second_name: str
  birthdate: date | None = None

We provide HTTP route to update this entity in DB. If we've provided a field in request body, then this field must be updated. Commonly one will make each field in DTO (except for ID) optional with default None value, but what to do with birthdate? When parsing we will propagate default None so we do not know if this None was passed explicitly or we've implicitly set it via DTO default.

Maybe allows to explicitly separate this cases, allowing us to have None as present value:

from dataclasses import dataclass
from datetime import date

from wird import Empty, Maybe


@dataclass
class CustomerUpdate:
  uid: int
  first_name: Maybe[str] = Empty()
  second_name: Maybe[str] = Empty()
  birthdate: Maybe[date | None] = Empty()

Thus when birthdate is Empty we know that we do not have to update this column at all, and when it is Some we can safely set None as desired value.

Maybe provides the following interface:

  • Maybe.unwrap - extract internally stored value on Some, raise EmptyUnwrapError on Empty
  • Maybe.unwrap_or - extract internally stored value on Some or return passed replacement value on Empty
  • Maybe.unwrap_or_none - extract internally stored value on Some or return None on Empty
  • Maybe.unwrap_or_else - extract internally stored value on Some or return result of execution of factory function for replacement value on Empty
  • Maybe.unwrap_or_else_async - same as Maybe.unwrap_or_else, but for async factory function
  • Maybe.map - binding method for sync functions, applies only on Some
  • Maybe.map_async - same as Maybe.map, but for async functions
  • Maybe.inspect - binding method for sync side-effect functions, applies only on Some
  • Maybe.inspect_async - same as Maybe.inspect, but for async functions
  • Maybe.and_ - logical AND for 2 Maybe values, replaces self Maybe with passed Maybe if first one is Some
  • Maybe.and_then - same as Maybe.map, but for sync functions that return Maybe
  • Maybe.and_then_async - same as Maybe.and_then, but for async functions
  • Maybe.or_ - logical OR for 2 Maybe values, replaces self Maybe with passed Maybe if first one is Empty
  • Maybe.or_else - replaces Empty with Maybe result of passed sync function
  • Maybe.or_else_async - same as Maybe.or_else, but for async functions
  • Maybe.is_some - True on Some container
  • Maybe.is_some_and - True on Some container and passed predicate being True
  • Maybe.is_some_and_async - same as Maybe.is_some_and, but for async predicates
  • Maybe.is_empty - True on Empty container
  • Maybe.is_empty_or - True on Empty container or passed predicate being True
  • Maybe.is_empty_or_async - same as Maybe.is_empty_or, but for async predicates
  • Maybe.filter - if predicate is False replaces Maybe with Empty
  • Maybe.filter_async - same as Maybe.filter, but for async predicates

In order to provide seamless experience, instead of making developer to work with Future[Maybe[T]] we provide FutureMaybe container that provides exactly the same interface as sync Maybe. Worth noting that FutureMaybe is awaitable, like Future, and returns internally stored Maybe instance.

Also in some cases one might need point-free versions of Maybe interface methods, so one can access them via maybe module. For FutureMaybe point-free functions one can use future_maybe module.

Result

Exception handling is one of the most important tasks in development. We often face cases when invocation of some logic can lead to Exception raise. In python default handling mechanism is try - except - finally block, which is actually just another for of if statement.

Worst thing about this approach is that commonly in python the only way to know that function can raise an exception is documentation (which is not always written or written good). There is no explicit mechanism to tell LSP / type checker / linter, that this specific function needs exception handling.

Result monad provides another approach, which is common for Rust and Go developers - let's return exceptions instead of raising them. Thus we can explicitly tell that soma action can fail and requires edge-case handling.

Like Maybe, Result is just a protocol and has 2 implementations:

  • Ok - container indicating that calculation succeeded
  • Err - container indicating that calculation failed

Simplest case of using Result is division:

from wird import Result, Ok, Err

def try_div(a: int, b: int) -> Result[float, ZeroDivisionError]:
  if b == 0:
    return Err(ZeroDivisionError())
  
  return Ok(a / b)

There we explicitly tell that division operation can lead to failure and even pinpoint specific type of error.

Result provides the following interface:

  • Result.unwrap - extract internally stored value of Ok or raise ErrUnwrapError
  • Result.unwrap_or - extract internally stored value of Ok or return other
  • Result.unwrap_or_else - extract internally stored value of Ok or return closure result
  • Result.unwrap_or_else_async - same as Result.unwrap_or_else, but for async closures
  • Result.unwrap_err - same as Result.unwrap, but for Err
  • Result.unwrap_err_or - same as Result.unwrap_err_or, but for Err
  • Result.unwrap_err_or_else - same as Result.unwrap_err_or_else, but for Err
  • Result.unwrap_err_or_else_async - same as Result.unwrap_err_or_else_async, but for Err
  • Result.map - binding method for Ok
  • Result.map_async - same as Result.map, but for async functions
  • Result.inspect - binding side-effect method for Ok
  • Result.inspect_async- same as Result.inspect_async, but for async functions
  • Result.map_err - same as Result.map, but for Err
  • Result.map_err_async - same as Result.map_async, but for Err
  • Result.inspect_err - same as Result.inspect, but for Err
  • Result.inspect_err_async - same as Result.inspect_async, but for Err
  • Result.and_ - logical AND, replaces current Result with passed on Ok
  • Result.and_then - same as Result.map, but for functions returning Result
  • Result.and_then_async - same as Result.and_then, but for async functions
  • Result.or_ - logical OR, replaces current Result with passed on Err
  • Result.or_else - same as Result.map_err, but for functions returning Result
  • Result.or_else_async - same as Result.or_else, but for async functions
  • Result.is_ok - True on Ok
  • Result.is_ok_and - True on Ok and predicate True
  • Result.is_ok_and_async - same as Result.is_ok_and, but for async predicate
  • Result.is_ok_or - True on Ok or Err predicate True
  • Result.is_ok_or_async - same as Result.is_ok_or, but for async predicate
  • Result.is_err - True on Err
  • Result.is_err_and - True on Err and predicate True
  • Result.is_err_and_async - same as Result.is_err_and, but for async predicate
  • Result.is_err_or - True on Err or Ok predicate `True
  • Result.is_err_or_async - same as result.is_err_or, but for async predicate

In the same manner as with Maybe we wird provides:

  • FutureResult as seamless adapter for Future[Result]
  • point-free Result API in wird.result module
  • point-free FutureResult API in wird.future_result module

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

wird-1.2.0.tar.gz (36.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

wird-1.2.0-py3-none-any.whl (20.9 kB view details)

Uploaded Python 3

File details

Details for the file wird-1.2.0.tar.gz.

File metadata

  • Download URL: wird-1.2.0.tar.gz
  • Upload date:
  • Size: 36.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.10.8 {"installer":{"name":"uv","version":"0.10.8","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}

File hashes

Hashes for wird-1.2.0.tar.gz
Algorithm Hash digest
SHA256 b8e6c80c819d33f4ccaf80d52cc1556520e682b153c1ad1d52e3d287a739017e
MD5 fc5dcfaef64f36ec5760fdb689df88e5
BLAKE2b-256 3bea2a00492365c430184c2eac5ec00e7affe1d527c61fc97b009ea3c891ac16

See more details on using hashes here.

File details

Details for the file wird-1.2.0-py3-none-any.whl.

File metadata

  • Download URL: wird-1.2.0-py3-none-any.whl
  • Upload date:
  • Size: 20.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.10.8 {"installer":{"name":"uv","version":"0.10.8","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}

File hashes

Hashes for wird-1.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 39bf7962f9251e635ad8242c1a6ff55a98bf539780d34384b93435cc722c77f7
MD5 dfece35b091eb5f53c0fb60b3c160af3
BLAKE2b-256 fe2d77b26fefd5b7f943c5820ed6a037c39706b8d9eba8b88a607951f78d23c7

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page