Git Product home page Git Product logo

tibia's Introduction

tibia

Simple library that provides some monad-like containers for "pipeline"-based code style. It is developed with simple idea in mind: important parts of code base (specifically those that contain domain-specific logic) must be implemented in human-readable manner as text that describes non-technical (or at least not too) details.

Pipeline & AsyncPipeline

Pipeline & AsyncPipeline are basic building blocks for applying function to data which is opposite to invoking function with data:

from typing import Any

from tibia.pipeline import Pipeline


def set_admin_status(user: dict[str, Any]) -> dict[str, Any]:
    user['role'] = 'admin'
    return user

# invoke function with data
user_1 = set_admin_status(
    {
        'name': 'John Doe',
        'role': 'member'
    }
)

# apply function to data
user_2 = Pipeline({
        'name': 'John Doe',
        'role': 'member'
    }).then(set_admin_status)

With this approach we can build pipelines that process some data performing different actions in more declarative manner.

Direct analogue of Pipeline and AsyncPipeline is so-called functional "pipe" operator which is usually written as |>:

let result = data |> function // same as `function data`

As a general reference to API methods I used rust Option and Result interfaces. As a general rule:

  • map unwraps contained value, passes it to the function and returns back wrapped result of function invocation
  • then unwraps contained value, passes it to the function and returns result
flowchart LR
    result[TResult]
    c_value["Container[TValue]"]
    c_result["Container[TResult]"]

    subgraph map
        map_func[function]
        map_value[TValue] --apply--> map_func
    end

    subgraph then
        then_func[function]
        then_value[TValue] --apply--> then_func
    end

    c_value --unwrap--> map_value
    c_value --unwrap--> then_value

    map_func --return--> c_result
    then_func --return--> result
Loading

In case one needs to invoke some async functions there are map_async and then_async methods, that transform Pipeline container to AsyncPipeline container, which allows to invoke async functions in non-async context like JavaScript Promise or more widely known Future. For naming consistency reasons AsyncPipeline is called as it called instead of being Future (also python has some other builtin packages with Future name).

Maybe & AsyncMaybe

Monadic container that replaces logic for Optional values. Consists of 2 containers: Some & Empty where Some represents actual value and Empty represents absence of data.

Some might question: do we need additional abstraction for typing.Optional? What is the purpose of Empty?

This is small real-life example: one has a table in database with some data, where some columns are nullable and one wishes to perform update on this data with single structure.

Structure:

from datetime import datetime
from typing import Optional


class User:
    name: str
    age: int
    crated_at: datetime
    deleted_at Optional[datetime]

For field name, age and created_at it seems to be good solution to use Optional as indication of 2 cases:

  • one wants to update field (value is not optional)
  • one does not want to update field (value is optional)

But for deleted_at Optional is one of the possible states for update, so how we identify that in one request None means "update with NULL" and in some other request it means "do not update"?

This is where Maybe as additional abstraction comes in handy:

  • Some(value) even if this value is None means that we want to update and set new field to value wrapped around container
  • Empty means that we do not want to update

So UpdateUser structure can be implemented as:

from datetime import datetime
from typing import Optional

from tibia.maybe import Maybe


class UpdateUser:
    name: Maybe[str]
    age: Maybe[int]
    created_at: Maybe[datetime]
    deleted_at: Maybe[Optional[datetime]]

With this approach we do not have any doubts on what action we actually want to perform.

Simple example of working with Maybe:

value = ( # type of str
    Some(3)
    .as_maybe()  # as_maybe performs upper-cast to Maybe[T]
    .map(lambda x: str(x))  # Maybe[int] -> int -> func -> str -> Maybe[str]
    .then_or(lambda x: x * 3, '')  # Maybe[str] -> str -> func -> str
)

Result & AsyncResult

Python exception handling lacks one very important feature - it is hard to oversee whether some function raises Exception or not. In order to make exception more reliable and predictable we can return Exceptions or any other error states.

It can be achieved in multiple ways:

  1. Using product type (like in Golang, tuple[_TValue, _TException] for python)
  2. Using sum type (python union _TValue | _TException)

Result monad is indirectly a sum type of Ok and Err containers, where Ok represents success state of operation and Err container represents failure.

In order to make existing sync and async function support Result one can use result_returns and result_returns_async decorators, that catch any exception inside function and based on this condition wrap returned result to Result monad.

@result_returns  # converts (Path) -> str to (Path) -> Result[str, Exception]
def read_file(path: Path):
    with open(path, "r") as tio:
        return tio.read()

result = (
    read_file(some_path)
    .recover("")  # if result is Err replace it with Ok with passed value
    .unwrap()  # extract contained value (as we recovered we are sure that
               # Result is Ok)
)

Many

Container for iterables, that provides some common methods of working with arrays of data like:

  • value mapping (map_values and map_values_lazy)
  • value filtering (filter_values and filter_values_lazy)
  • value skip/take (skip_values, skip_values_lazy, take_values and take_values_lazy)
  • ordering values (order_values_by)
  • aggregation (reduce and reduce_to)

Also supports Pipeline operations map and then.

Methods named as lazy instead of performing computation in-place (with python list) make generators and should be evaluated lazily (for example with compute method):

result = (
    Many(path.rglob("*"))  # recursively read all files
    .filter_values_lazy(lambda p: p.is_file() and p.suffix == ".py")
    .map_values_lazy(read_file)  # iterable of Results
    .filter_values_lazy(result_is_ok)  # take only Ok results
    .map_values_lazy(result_unwrap)  # unwrap results to get str
    .compute()  # forcefully evaluate generator
    .unwrap()  # extract Iterable[str], but actually list[str]
)

Pairs

Same as Many but for key-value mappings (dict). Also allows to perform map/filter operations on both keys and values. Values and keys can be extracted lazily.

result = (  # dict[str, dict[str, Any]]
    # imagine more data
    Pairs({"Jane": {"age": 34, "sex": "F"}, "Adam": {"age": 15, "sex": "M"}})
    .filter_by_value(lambda v: v["age"] > 18 and v["sex"] == "M")
    .map_keys(lambda k: k.lower())
    .unwrap()
)

Curring

In order to properly use Pipeline and other monad binding function we need to be able to partially apply function: pass some arguments and some leave unassigned, but instead of invoking function get new one, that accepts left arguments.

Some programming languages (functional mostly, like F#) support curring out of the box:

let addTwoParameters x y =  // number -> number -> number
   x + y

// this is curring/partial/argument baking - name it
let addOne = addTwoParameters 1  // number -> number

let result = addOne 3 // 4
let anotherResult = addTwoParameters 1 3 // 4

Python has built-in partial, but it lacks typing, for this reason tibia provides special curried decorator, that extracts first argument and leave it for later assignment:

def add_two_parameters(x: int, y: int) -> int:
    return x + y

add_one = curried(add_two_parameters)(1)  # int -> int

print(add_one(3))  # 4

Development Guide

Starting Development

In order to use Makefile scripts one would need:

  • pyenv
  • python>=3.12 (installed via pyenv)
  • poetry>=1.2

Clone repository

HTTPS
```sh
git clone https://github.com/katunilya/tibia.git
```
SSH
```sh
git clone [email protected]:katunilya/tibia.git
```
GitHub CLI
```sh
gh repo clone katunilya/tibia
```

Then run:

make setup

With this command python3.12 will be chosen as local python, new python virtual environment would be created via poetry and dependencies will be install via poetry and also pre-commit hooks will be installed.

Other commands in Makefile are pretty self-explanatory.

Making And Developing Issue

Using web UI or GitHub CLI create new Issue in repository. If Issue title provides clean information about changes one can leave it as is, but we encourage providing details in Issue body.

In order to start developing new issue create branch with the following naming convention:

<issue-number>-<title>

As example: 101-how-to-start

Making Commit

To make a commit use commitizen:

cz c

This would invoke a set of prompts that one should follow in order to make correct conventional commits.

Preparing Release

When new release is coming firstly observe changes that are going to become a part of this release in order to understand what SemVer should be provided. Than create Issue on preparing release with title Release v<X>.<Y>.<Z> and develop it as any other issue.

Developing release Issue might include some additions to documentation and anything that does not change code base crucially (better not changes in code). Only required thing to do in release Issue is change version of project in pyproject.toml via poetry:

poetry version <SemVer>

When release branch is merged to main new release tag and GitHub release are made (via web UI or GitHub CLI).

tibia's People

Contributors

dependabot[bot] avatar katunilya avatar

Watchers

 avatar

tibia's Issues

add Many.unwrap_as_pair

Many.unwrap_as_pair accepts function that converts contained value to key-value pairs which is used to build Pair mapping.

add code quality tools and automation

  • pre-commit for formatter + linter (ruff all-in-one)
  • Forbid commit directly to main
  • GitHub Action for linting and formatting on PR
  • add commitizen for better and easier commits
  • write dev quick start documentation

rework types for Maybe and Result

Currently implementation of Maybe & Result monads is contained in base class for actual containers.

We can provide implementation via some private class (ABC/Protocol/??) and make this base class just type aliases:

class __BaseMaybe[_TValue]:
    ...  # implementation of Maybe for both containers

class Some[_TValue](__BaseMaybe[_TValue]):
    ...

class Empty(__BaseMaybe[Any]):
    ...

type Maybe[_TValue] = Some[_TValue] | Empty

This would provide much easier and better typing without overhead like:

Maybe[SomeVeryLongTypeName].some(value)

Many rework

  • rename map to map_value
  • add map that works like Pipeline.map (maybe inherit Many from Pypeline)
  • add then that works like Pipeline.then
  • add generation of Pair from (x) -> [(k, v)] function

add Grouper

Grouper is a class for building function that groups Iterable if values to some labels.

Interface:

  1. contructor - takes default value, when no predicate for provided groups works
  2. add_group - method for adding new group (group label + predicte, where label is value in result Mapping)
  3. match - method for matching single value to group label
  4. group_by - method for tranforming Iterable to Mapping based on grouping function
  5. group_by_as_pairs - same as group_by but result value is Pairs

add method for thenning error state to Result

  • Result.otherwise: ((_TErr) -> (_TNewErr)) -> Result[_TOk, _TNewErr]
  • Result.otherwise_async
  • AsyncResult.othersise
  • AsyncResult.otherwise_async
  • Result.recover
    Can be invoked only on Err converting it to Ok
  • AsyncResult.recover

add point-free functions

  • Result

    • unwrap function
    • is_ok function
    • is_err function
    • as_err function
    • as_ok function
  • Maybe

    • unwrap function
    • is_some function
    • is_empty function
    • as_some function
    • as_empty function
  • type castings

    • as_type dumb casting function (curried by default)
    • some_as_type_of some only when isinstance function (curried by default)
    • is_type_of function (curried by default)
  • Many

    • fold - join Many[Iterable[T]] to Many[T]

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.