# `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:
```python
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 `|>`:
```fsharp
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
```mermaid
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
```
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:
```python
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:
```python
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`:
```py
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.
```python
@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):
```python
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.
```python
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:
```fsharp
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:
```python
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
<!-- markdownlint-disable MD033 -->
<!-- markdownlint-disable MD046 -->
<details>
<summary>
HTTPS
</summary>
```sh
git clone https://github.com/katunilya/tibia.git
```
</details>
<details>
<summary>
SSH
</summary>
```sh
git clone git@github.com:katunilya/tibia.git
```
</details>
<details>
<summary>
GitHub CLI
</summary>
```sh
gh repo clone katunilya/tibia
```
</details>
Then run:
```shell
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:
```txt
<issue-number>-<title>
```
As example: `101-how-to-start`
### Making Commit
To make a commit use `commitizen`:
```shell
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`:
```sh
poetry version <SemVer>
```
When release branch is merged to `main` new release tag and GitHub release are made (via
web UI or GitHub CLI).
Raw data
{
"_id": null,
"home_page": "https://github.com/katunilya/tibia",
"name": "tibia",
"maintainer": null,
"docs_url": null,
"requires_python": "<4.0,>=3.12",
"maintainer_email": null,
"keywords": "monad, functional, pipeline, rust",
"author": "Ilya Katun",
"author_email": "katun.ilya@gmail.com",
"download_url": "https://files.pythonhosted.org/packages/30/02/79d8bd9d3a1daee2f67c090a930526deeef6c34ebdde4ea0ef659429f21f/tibia-1.3.0.tar.gz",
"platform": null,
"description": "# `tibia`\n\nSimple library that provides some monad-like containers for \"pipeline\"-based code style.\nIt is developed with simple idea in mind: important parts of code base (specifically\nthose that contain domain-specific logic) must be implemented in human-readable manner\nas text that describes non-technical (or at least not too) details.\n\n## `Pipeline` & `AsyncPipeline`\n\n`Pipeline` & `AsyncPipeline` are basic building blocks for applying function to data\nwhich is opposite to invoking function with data:\n\n```python\nfrom typing import Any\n\nfrom tibia.pipeline import Pipeline\n\n\ndef set_admin_status(user: dict[str, Any]) -> dict[str, Any]:\n user['role'] = 'admin'\n return user\n\n# invoke function with data\nuser_1 = set_admin_status(\n {\n 'name': 'John Doe',\n 'role': 'member'\n }\n)\n\n# apply function to data\nuser_2 = Pipeline({\n 'name': 'John Doe',\n 'role': 'member'\n }).then(set_admin_status)\n```\n\nWith this approach we can build pipelines that process some data performing different\nactions in more declarative manner.\n\nDirect analogue of Pipeline and AsyncPipeline is so-called functional \"pipe\" operator\nwhich is usually written as `|>`:\n\n```fsharp\nlet result = data |> function // same as `function data`\n```\n\nAs a general reference to API methods I used rust Option and Result interfaces. As a\ngeneral rule:\n\n- `map` unwraps contained value, passes it to the function and returns back wrapped\n result of function invocation\n- `then` unwraps contained value, passes it to the function and returns result\n\n```mermaid\nflowchart LR\n result[TResult]\n c_value[\"Container[TValue]\"]\n c_result[\"Container[TResult]\"]\n\n subgraph map\n map_func[function]\n map_value[TValue] --apply--> map_func\n end\n\n subgraph then\n then_func[function]\n then_value[TValue] --apply--> then_func\n end\n\n c_value --unwrap--> map_value\n c_value --unwrap--> then_value\n\n map_func --return--> c_result\n then_func --return--> result\n```\n\nIn case one needs to invoke some async functions there are `map_async` and `then_async`\nmethods, that transform `Pipeline` container to `AsyncPipeline` container, which allows\nto invoke async functions in non-async context like JavaScript `Promise` or more widely\nknown `Future`. For naming consistency reasons `AsyncPipeline` is called as it called\ninstead of being `Future` (also python has some other builtin packages with `Future`\nname).\n\n## `Maybe` & `AsyncMaybe`\n\nMonadic container that replaces logic for `Optional` values. Consists of 2 containers:\n`Some` & `Empty` where `Some` represents actual value and `Empty` represents absence of\ndata.\n\nSome might question: do we need additional abstraction for `typing.Optional`? What is\nthe purpose of `Empty`?\n\nThis is small real-life example: one has a table in database with some data, where some\ncolumns are nullable and one wishes to perform update on this data with single\nstructure.\n\nStructure:\n\n```python\nfrom datetime import datetime\nfrom typing import Optional\n\n\nclass User:\n name: str\n age: int\n crated_at: datetime\n deleted_at Optional[datetime]\n```\n\nFor field `name`, `age` and `created_at` it seems to be good solution to use `Optional`\nas indication of 2 cases:\n\n- one wants to update field (value is not optional)\n- one does not want to update field (value is optional)\n\nBut for deleted_at `Optional` is one of the possible states for update, so how we\nidentify that in one request `None` means \"update with NULL\" and in some other request\nit means \"do not update\"?\n\nThis is where `Maybe` as additional abstraction comes in handy:\n\n- `Some(value)` even if this value is `None` means that we want to update and set new\n field to `value` wrapped around container\n- `Empty` means that we do not want to update\n\nSo `UpdateUser` structure can be implemented as:\n\n```python\nfrom datetime import datetime\nfrom typing import Optional\n\nfrom tibia.maybe import Maybe\n\n\nclass UpdateUser:\n name: Maybe[str]\n age: Maybe[int]\n created_at: Maybe[datetime]\n deleted_at: Maybe[Optional[datetime]]\n```\n\nWith this approach we do not have any doubts on what action we actually want to perform.\n\nSimple example of working with `Maybe`:\n\n```py\nvalue = ( # type of str\n Some(3)\n .as_maybe() # as_maybe performs upper-cast to Maybe[T]\n .map(lambda x: str(x)) # Maybe[int] -> int -> func -> str -> Maybe[str]\n .then_or(lambda x: x * 3, '') # Maybe[str] -> str -> func -> str\n)\n```\n\n## `Result` & `AsyncResult`\n\nPython exception handling lacks one very important feature - it is hard to oversee\nwhether some function raises Exception or not. In order to make exception more reliable\nand predictable we can return Exceptions or any other error states.\n\nIt can be achieved in multiple ways:\n\n1. Using product type (like in Golang, `tuple[_TValue, _TException]` for python)\n2. Using sum type (python union `_TValue | _TException`)\n\n`Result` monad is indirectly a sum type of `Ok` and `Err` containers, where `Ok`\nrepresents success state of operation and `Err` container represents failure.\n\nIn order to make existing sync and async function support `Result` one can use\n`result_returns` and `result_returns_async` decorators, that catch any exception inside\nfunction and based on this condition wrap returned result to `Result` monad.\n\n```python\n@result_returns # converts (Path) -> str to (Path) -> Result[str, Exception]\ndef read_file(path: Path):\n with open(path, \"r\") as tio:\n return tio.read()\n\nresult = (\n read_file(some_path)\n .recover(\"\") # if result is Err replace it with Ok with passed value\n .unwrap() # extract contained value (as we recovered we are sure that\n # Result is Ok)\n)\n```\n\n## `Many`\n\nContainer for iterables, that provides some common methods of working with arrays of\ndata like:\n\n- value mapping (`map_values` and `map_values_lazy`)\n- value filtering (`filter_values` and `filter_values_lazy`)\n- value skip/take (`skip_values`, `skip_values_lazy`, `take_values` and\n `take_values_lazy`)\n- ordering values (`order_values_by`)\n- aggregation (`reduce` and `reduce_to`)\n\nAlso supports `Pipeline` operations `map` and `then`.\n\nMethods named as lazy instead of performing computation in-place (with python `list`)\nmake generators and should be evaluated lazily (for example with `compute` method):\n\n```python\nresult = (\n Many(path.rglob(\"*\")) # recursively read all files\n .filter_values_lazy(lambda p: p.is_file() and p.suffix == \".py\")\n .map_values_lazy(read_file) # iterable of Results\n .filter_values_lazy(result_is_ok) # take only Ok results\n .map_values_lazy(result_unwrap) # unwrap results to get str\n .compute() # forcefully evaluate generator\n .unwrap() # extract Iterable[str], but actually list[str]\n)\n```\n\n## `Pairs`\n\nSame as `Many` but for key-value mappings (`dict`). Also allows to perform map/filter\noperations on both keys and values. Values and keys can be extracted lazily.\n\n```python\nresult = ( # dict[str, dict[str, Any]]\n # imagine more data\n Pairs({\"Jane\": {\"age\": 34, \"sex\": \"F\"}, \"Adam\": {\"age\": 15, \"sex\": \"M\"}})\n .filter_by_value(lambda v: v[\"age\"] > 18 and v[\"sex\"] == \"M\")\n .map_keys(lambda k: k.lower())\n .unwrap()\n)\n```\n\n## Curring\n\nIn order to properly use `Pipeline` and other monad binding function we need to be able\nto partially apply function: pass some arguments and some leave unassigned, but instead\nof invoking function get new one, that accepts left arguments.\n\nSome programming languages (functional mostly, like F#) support curring out of the box:\n\n```fsharp\nlet addTwoParameters x y = // number -> number -> number\n x + y\n\n// this is curring/partial/argument baking - name it\nlet addOne = addTwoParameters 1 // number -> number\n\nlet result = addOne 3 // 4\nlet anotherResult = addTwoParameters 1 3 // 4\n```\n\nPython has built-in `partial`, but it lacks typing, for this reason `tibia` provides\nspecial `curried` decorator, that extracts first argument and leave it for later\nassignment:\n\n```python\ndef add_two_parameters(x: int, y: int) -> int:\n return x + y\n\nadd_one = curried(add_two_parameters)(1) # int -> int\n\nprint(add_one(3)) # 4\n```\n\n## Development Guide\n\n### Starting Development\n\nIn order to use `Makefile` scripts one would need:\n\n- `pyenv`\n- `python>=3.12` (installed via `pyenv`)\n- `poetry>=1.2`\n\nClone repository\n\n<!-- markdownlint-disable MD033 -->\n<!-- markdownlint-disable MD046 -->\n<details>\n <summary>\n HTTPS\n </summary>\n\n ```sh\n git clone https://github.com/katunilya/tibia.git\n ```\n</details>\n\n<details>\n <summary>\n SSH\n </summary>\n\n ```sh\n git clone git@github.com:katunilya/tibia.git\n ```\n</details>\n\n<details>\n <summary>\n GitHub CLI\n </summary>\n\n ```sh\n gh repo clone katunilya/tibia\n ```\n</details>\n\nThen run:\n\n```shell\nmake setup\n```\n\nWith this command python3.12 will be chosen as local python, new python virtual\nenvironment would be created via `poetry` and dependencies will be install via `poetry`\nand also `pre-commit` hooks will be installed.\n\nOther commands in `Makefile` are pretty self-explanatory.\n\n### Making And Developing Issue\n\nUsing web UI or GitHub CLI create new Issue in repository. If Issue title provides clean\ninformation about changes one can leave it as is, but we encourage providing details in\nIssue body.\n\nIn order to start developing new issue create branch with the following naming\nconvention:\n\n```txt\n<issue-number>-<title>\n```\n\nAs example: `101-how-to-start`\n\n### Making Commit\n\nTo make a commit use `commitizen`:\n\n```shell\ncz c\n```\n\nThis would invoke a set of prompts that one should follow in order to make correct\nconventional commits.\n\n### Preparing Release\n\nWhen new release is coming firstly observe changes that are going to become a part of\nthis release in order to understand what SemVer should be provided. Than create Issue on\npreparing release with title `Release v<X>.<Y>.<Z>` and develop it as any other issue.\n\nDeveloping release Issue might include some additions to documentation and anything that\ndoes not change code base crucially (better not changes in code). Only **required**\nthing to do in release Issue is change version of project in `pyproject.toml` via\n`poetry`:\n\n```sh\npoetry version <SemVer>\n```\n\nWhen release branch is merged to `main` new release tag and GitHub release are made (via\nweb UI or GitHub CLI).\n",
"bugtrack_url": null,
"license": "MIT",
"summary": "Monads in python for pipeline-based development with Rust-like interface",
"version": "1.3.0",
"project_urls": {
"Homepage": "https://github.com/katunilya/tibia",
"Repository": "https://github.com/katunilya/tibia"
},
"split_keywords": [
"monad",
" functional",
" pipeline",
" rust"
],
"urls": [
{
"comment_text": "",
"digests": {
"blake2b_256": "a2996a677aa96dbc2d0416536bae5164b816cd8cd1a702b876c4b7b21f223397",
"md5": "5ff615a2f30c860d18fd90781c788bd9",
"sha256": "9cf51fab3fa6c567962658d89f3f80814f99ec278075ef3294ce9a9c9584fed7"
},
"downloads": -1,
"filename": "tibia-1.3.0-py3-none-any.whl",
"has_sig": false,
"md5_digest": "5ff615a2f30c860d18fd90781c788bd9",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": "<4.0,>=3.12",
"size": 15339,
"upload_time": "2024-10-19T09:30:12",
"upload_time_iso_8601": "2024-10-19T09:30:12.802498Z",
"url": "https://files.pythonhosted.org/packages/a2/99/6a677aa96dbc2d0416536bae5164b816cd8cd1a702b876c4b7b21f223397/tibia-1.3.0-py3-none-any.whl",
"yanked": false,
"yanked_reason": null
},
{
"comment_text": "",
"digests": {
"blake2b_256": "300279d8bd9d3a1daee2f67c090a930526deeef6c34ebdde4ea0ef659429f21f",
"md5": "85f4ff01ba102357523fdf0a7cb512d5",
"sha256": "ea261f824857c10c3ba6d1127057c2c96114a11b7b03ad909e9ddcc1d98ff8bf"
},
"downloads": -1,
"filename": "tibia-1.3.0.tar.gz",
"has_sig": false,
"md5_digest": "85f4ff01ba102357523fdf0a7cb512d5",
"packagetype": "sdist",
"python_version": "source",
"requires_python": "<4.0,>=3.12",
"size": 16313,
"upload_time": "2024-10-19T09:30:14",
"upload_time_iso_8601": "2024-10-19T09:30:14.133603Z",
"url": "https://files.pythonhosted.org/packages/30/02/79d8bd9d3a1daee2f67c090a930526deeef6c34ebdde4ea0ef659429f21f/tibia-1.3.0.tar.gz",
"yanked": false,
"yanked_reason": null
}
],
"upload_time": "2024-10-19 09:30:14",
"github": true,
"gitlab": false,
"bitbucket": false,
"codeberg": false,
"github_user": "katunilya",
"github_project": "tibia",
"travis_ci": false,
"coveralls": false,
"github_actions": true,
"lcname": "tibia"
}