Git Product home page Git Product logo

fundom's Introduction

▶️ fundom

fundom is an API for writing functional pipelines with Python3.10+. It is developed with Domain Driven Design in mind and highly inspired by the concepts of functional domain modelling.

Features

pipe and future

pipe and future are not really monads, but some abstractions that provides functionality for creating pipelines of sync and async functions. Inspired by |> F# operator.

In F# one can write something like this to execute multiple functions sequentially:

3 |> ifSome add1
  |> ifSome prod2
  |> ifNothing (fun _ -> 0)

Which basically means "Add 1 to value if it is some, than if result of previous operation is some multiply it by 2, than if result of previous operation is nothing return 0"

Python does not have such operator. In this way I've attempted to provide 2 abstraction compatible with each other - pipe and future.

pipe is for calling synchronous functions one-by-one. Example:

result = (
  pipe(3)
  << (lambda x: x + 1)
  << (lambda x: x * 2)
).finish()

finish method is needed to return wrapped into pipe container value, as on each << step value returned by passed function is wrapped into pipe container for further chaining.

If your function returns pipe object that to unpack that one can use @pipe.returns decorator.

@pipe.returns
def parse_http_query(query: bytes) -> dict:
  return (
    pipe(query)
    << some_when(is_not_empty)
    << if_some(bytes_decode("UTF-8"))
    << if_some(str_split("&"))
    << if_some(cmap(str_split("=")))
    << if_some(dict)
    << if_none(returns({}))
  )

However this limits us to working with synchronous functions only. What if we want to work with asynchronous functions (and event in synchronous context)? For that case we have future container.

future is some awaitable container that wraps some awaitable value and can evaluate next awaitable Future in synchronous context. It's easier to see once in action than to listen twice how it works.

result = await (
  pipe(3)
  >> this_async  # returns Future
  << (lambda x: x + 1)
  << (lambda x: x * 2)
)

future does not have finish method like pipe as it is awaitable and in some sense it has built-in unpacking keyword - await.

Also sometimes it might be useful to wrap value returns by async function into future. For that one can use @future.returns decorator:

@future.returns
async def some_future_function(arg: int) -> bool:
  ...

Basically the way this containers map to each other looks like this. While we work with pipe and sync functions we use << and remain in pipe context. But right at the moment we need to apply some async function future comes out and replaces pipe.

graph LR;
  pipe(Pipe)
  future(Future)

  pipe --"<<"--> pipe
  pipe --">>"--> future
  future --"<<"--> future
  future --">>"--> future
Loading

This scheme describes how pipe and future convert to each other during pipeline execution.

pipe and future are main building blocks for functional pipelines.

Composition

Function composition is important feature that allows us to build functions on the fly. For that fundom provides special compose function (actually it is class). It has the same interface as pipe/future but does not take input argument:

parse_http_query: Callable[[bytes], dict] = (
  compose()
  << some_when(is_not_empty)
  << if_some(bytes_decode("UTF-8"))
  << if_some(str_split("&"))
  << if_some(cmap(str_split("=")))
  << if_some(dict)
  << if_none(returns({}))
)

Sync functions are composed with << and async with >>. I suggest providing type annotation for functions created via compose, especially it is important for domain modelling.

Maybe monad

fundom does not provide dedicated Maybe monad with dedicates containers like Just/Some and Nothing, but provides multiple functions for handling None:

  • if_some
  • if_none
  • if_some_returns
  • if_none_returns
  • some_when
  • some_when_future
  • choose_some
  • choose_some_future

Result monad

Result monad also known as Either is not provided by fundom too. But there is interface for handling Exception objects:

  • if_ok
  • if_error
  • if_ok_returns
  • if_error_returns
  • ok_when
  • ok_when_future
  • choose_ok
  • choose_ok_future
  • safe
  • safe_future

Predicates

Often we have some expressions that return boolean values (logical). To provide composition for such functions - predicates - there are 2 combinators: each and one.

each performs logical AND operation across predicates and stands for mathematical conjunction. one on the other hand performs logical OR and implements disjunction.

p = (
  one()
  << (each() << (lambda x: x > 3) << (lambda x: x < 10))
  << (each() << (lambda x: x > 23) << (lambda x: x < 55))
)

FAQ

But why only one argument functions are supported?

  • Pipeline can be imagined as a tube - there is exactly one input and one output.
  • Any function in functional programming is one-argument function. This concept is called curring.

Second point is actually the one that makes most problems. I see 3 significantly different ways of doing curring in Python:

  • Python partial from functools standard package.
  • Decorator like @curry from toolz package.
  • Writing Higher-Order Functions by yourself.

There are drawbacks of each of the method:

Method 👍 👎
partial no additional dependencies; type hints are lost; bad-looking syntax;
@curry easy syntax for any function; type hints are lost;
HOFs type hints remain; might seem verbose;

I consider it is a matter of personal preference which way to stick to, but I prefer the last option. In many cases it is not that difficult and hard to write a few more lines of code somewhere outside.

Also as some incomplete curring shortcut several decorators provided - hof1, hof2 and hof3. This decorators separate first X (1, 2 or 3 correspondingly) arguments of function with other.

@hof1
def split(separator: str, data: str) -> list[str]:
  return data.split(separator)

@hof1
def encode(encoding: str, data: str) -> bytes:
  return data.encode(encoding)

result = (
  pipe("Hello, world!")
  << split(" ")
  << cmap(encode("UTF-8"))
  << list
).finish()

# same as
result = list(map(lambda s: s.encode("UTF-8"), "Hello, World!".split(" ")))

In this way actually any function with multiple arguments can become single argument function without losing type hints.

Why 3 is max number of arguments for function to put in HOF?

I consider that if you have more than 3 arguments for your function than this function is bad and data structures you use are bad. They are complex and make it hard to write truly declarative code.

Why not to use tuple as single argument?

Valid suggestion, however this makes args projections between chained functions much more complex and you can'y easily convert function to HOF.

Some common HOFs

There are multiple common HOF composable functions:

  • foldl - curried reduce left
  • foldr - curried reduce right
  • cmap - curried map
  • cfilter - curried filter

Some common point-free utilities

Point-free means that function is not used with "dot notation" (like method).

  • for str
    • str_center - point-free str.center
    • str_count - point-free str.count
    • str_encode - point-free str.encode
    • str_endswith - point-free str.endswith
    • str_find - point-free str.find
    • str_index - point-free str.index
    • str_removeprefix - point-free str.removeprefix
    • str_removesuffix - point-free str.removesuffix
    • str_replace - point-free str.replace
    • str_split - point-free str.split
    • str_startswith - point-free str.startswith
    • str_strip - point-free str.strip
  • for bytes
    • bytes_center - point-free bytes.center
    • bytes_count - point-free bytes.count
    • bytes_decode - point-free bytes.decode
    • bytes_endswith - point-free bytes.endswith
    • bytes_find - point-free bytes.find
    • bytes_index - point-free bytes.index
    • bytes_removeprefix - point-free bytes.removeprefix
    • bytes_removesuffix - point-free bytes.removesuffix
    • bytes_replace - point-free bytes.replace
    • bytes_split - point-free bytes.split
    • bytes_startswith - point-free bytes.startswith
    • bytes_strip - point-free bytes.strip
  • for dict
    • dict_maybe_get - point-free dict.get(key, None)
    • dict_try_get - point-free dict[key]

fundom's People

Contributors

katunilya avatar

Stargazers

 avatar  avatar  avatar  avatar

Watchers

 avatar

fundom's Issues

Add `pipeline` decorator

pipeline decorator should automatically unpack result of some sync pipeline executed inside the function.

# before

def get_body(ctx) -> Maybe[str]:
   return (Pipe(ctx) << get_bytes << if_some(_maybe_decode_utf_8)).value

# after

@pipeline
def get_body(ctx):
   return Pipe(ctx) << get_bytes << if_some(_maybe_decode_utf_8) # Pipe[Maybe[str]]

# with decorator actual type is Maybe[str]

Improve documentation

  • examples for each function
  • DDD examples and motivation
  • Improve handsdown generated docs
  • Remove docstring for _compose_future utility class
  • Fix pipe name in line 136 of README.md

Provide general-purpose `hof` decorator

hof decorator should except number - how many arguments we want to leave for the function. Example:

@hof(1)
def encode(encoding: str, data: str) -> bytes:  # (str) -> ((str) -> (bytes))
   return data.encode(encoding)

encode_UTF_8 = encode("UTF-8")  # (str) -> (bytes)

Rework `choose_ok` function

choose_ok function provides switch-case-like syntax for sync functions that might returns some ok value or Exception.

choose_ok provides composition via | operator (__or__):

f = (
   choose_ok()
   | (compose() 
      << check(lambda x: x > 10, lambda _: Exception("More than 10")
      << if_ok(lambda x: x + 3))
   | (lambda x: x + 1)
)

Requirements:

  • if no functions passed to choose_ok it returns Exception on call (EmptyChooseOkError)
  • if no function returned ok result that Exception is returned (FailedChooseOkError(value))

Fix docstrings

  • bytes count
  • bytes find
  • bytes removesuffix
  • dict maybe_get
  • str count
  • str endswith
  • str join_by

Rework `Maybe` monad to work directly with `None`

With this maybe becomes some kind of built-in monad. We just need:

Core functions

  • if_some to run function only when not-None value is passed
  • if_none to run function only when None is passed to recover

General-purpose utility functions

  • if_none_return to return some value instead of None

Common domain models

  • Entity - identifiable
  • ValueObject - just marker
  • Signed - has created_by and updated_by fields
  • Auditable - inherits from Signed and also has created_at and updated_at fields
  • Event - immutable

`choose_some` function

Proposal: #74

Example:

f: Callable[[int], int] = (
   choose_some() 
   | (lambda x: None if x > 3 else x)
   | (lambda x: 5 if x == 3 else None)
   | (lambda x: x + 3)
)

Requirements:

  • Only for sync functions;
  • Returns None if no functions succeeded or no option function had been provided

Remove `@func` usage in `hof`

This is wrong behavior as the most inner function where func is actually called can be both sync and async and we can't be sure if it is sync to use @func decorator instead of @future_func

Predicate composition

We can provide Predicate type (val -> bool) that implements operators or and and for Predicate composition.

Deprecate `Composable` concept

Composable and AsyncComposable are not really needed in the world of Pipe as any Composable can be written as @pipeline function (see #6).

Example:

# ✅ short function declaration
# ❌ no type hinting
# ❌ no documentation
# ❌ harder to debug
func = composable(lambda x: x + 1) << (lambda x: x ** 2)

# ✅ has type hinting
# ✅ can be documented
# ✅ easier to debug
# ❌ longer
@pipeline
def func(x: int) -> Pipe[int]:
   return Pipe(x) << (lambda x: x + 1) << (lambda x: x ** 2)

Abstraction type over functions

  • Func for both sync and async functions
  • << for composition with sync function
  • >> for composition with async function
  • @func decorator

General purpose `curry`

Possible implementation

T = ParamSpec("T")
R = TypeVar("R")
A1 = TypeVar("A1")

def curry(number_of_params: int, filled_params:int = 0) :
    def _curry(f: Callable[Concatenate[A1, T], R]) -> Callable[Concatenate[A1, T], R]:
        def wrapper(
            arg1: A1, *args: T.args, kwargs: T.kwargs
        ) -> R:
            if number_of_params <= filled_params +len(args) + 1 + len(kwargs):
                return f(arg1, *args, **kwargs)

            def inner(*nargs: T.args, **nkwargs: T.kwargs) -> R:
                return f(arg1, *(args+nargs), (kwargs|nkwargs))
            

            return curry(number_of_params, filled_params+len(args) + 1 + len(kwargs))(inner)

        return wrapper
    return _curry

@curry(4)
def a(w: int, q: int, e: int, r: int) -> int:
    """magic."""
    return w + q + e + r

Requirements

  • should inherit docstring at least after first currying

Improve `safe` and `safe_async` typing

We can wrap currently existing safe and safe_async into decorator with args.

Thus we can specify what exceptions we really await. Possible solution (draft, not sure if it works):

TError = TypeVar('TError', bound=Exception)

def safe(error_type: Type[TError] = Exception):
    def _decorator(func: Callable[P, T]) -> Callable[P, T | error_type]:
        @wraps(func)
        def _wrapper(*args: P.args, **kwargs: P.kwargs) -> T | error_type:
            try:
                return func(*args, **kwargs)
            except Exception as err:
                return err
        return _wrapper
    return _decorator

@safe
def some_unsafe(x: int) -> int:  # final type must be (x: int) -> (int | Exception)
    ...

@safe(UnicodeError)
def encode(s: str) -> bytes:  # final type must be (s: str) -> (bytes | UnicodeError)
    ...

`policy` function with `choose` combinator

check is close to ok_when and some_when, but on fail returns preset Exception

  • PolicyViolationError exception class
  • check function: predicate -> (value -> value | PolicyViolationError)
  • choose function: *funcs -> (value -> result| PolicyViolationError) where funcs return result or PolicyViolationError

Rework `Predicate` and `FuturePredicate`

General Predicate and FuturePredicate cannot describe the true nature of predicates. They are actually needed to provide the concept of composition of functions that return True/False. In mathematics and programming there are 2 common composition functions for that case - conjunction and disjunction ("and" and "or" correspondingly). Thus we implement this 2 functions to work in the same way as composition.

Example:

p = (
   one() 
   << (each() << (lambda x: x > 3) << (lambda x: x < 10))
   << (each() << (lambda x: x > 23) << (lambda x: x < 55))
)

Requirements:

  • empty each returns True (like all)
  • empty one returns False (like any)
  • << for sync predicates
  • >> for async predicates

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.