Metadata-Version: 2.4
Name: pyfun-zeromode
Version: 1.0.0
Summary: Functional programming constructs (Maybe, Pipe) for Python
Author: Avilay Parekh
Author-email: Avilay Parekh <avilay@gmail.com>
License-Expression: MIT
Requires-Dist: fire>=0.7.1
Requires-Python: >=3.14
Project-URL: Repository, https://github.com/avilay/pyfun
Description-Content-Type: text/markdown

# PyFun Design Doc

PyFun implements the [`Monad`](https://wiki.haskell.org/Monad) typeclass from Haskell in Python. Because Python does not have higher-kinded types, I cannot implement the Monad functions for all types. For this reason PyFun also implements the [`Maybe`](https://wiki.haskell.org/Maybe) type in Python, and implements Monad functions on this type.  

Here is some non-standard terminology that I use in this design doc to make things unambiguous:
  * **raw**: This is used to indicate a value that is not inside a `Maybe`. When used in context of functions, it indicates a function that takes in raw values and returns raw values.
  * **wrapped**: This is used to indicate a value that is inside a `Maybe`, it could be `None` or have an actual value. We don't know that at compile time. When used in context of functions, it means a function that takes in a wrapped value and returns a wrapped value.
  * **partially-wrapped**: This is only used in context of a function, where the function either takes in a raw value and returns a wrapped value, or takes in a wrapped value and returns a raw value. It will be clear from context which of these I mean.

## Main Functions

  * `map`: This is a higher-order function that takes in a wrapped value and applies a raw function to it.
  * `bind`
  * `then`

There is no point implementing `pure` for a single type, so it is omitted. I have also omitted `<*>` because there are simpler ways to achieve what it does by using the `match` statement.

## Helper Functions

* `maybe`: Converts a value to `Some[T]` or `Nothing` depending on whether it exists or is `None`.
* `value_or`: Unwraps the `Maybe` value if it exists otherwise returns the default passed in with this function.
* `to_optional`: Unwraps the `Maybe` value if it exists, otherwise returns `None`.

## Use Cases

These use cases use the following three helper functions:

* `lookup(mapping: dict[K, V], key: K) → Maybe[V]`: Returns the wrapped value if `key` exists, otherwise `Nothing`.
* `parse_int(raw: str) → Maybe[int]`: Returns wrapped integer if `raw` can be parsed, otherwise `Nothing`.
* `ensure(condition: Bool) → Maybe[None]`: Returns `Some(None)` if `condition` is `True`, otherwise `Nothing.

#### 1. Normalize an optional field

We have a dictionary `profile` which may or may not contain the `email` field. If it does, we want to normalize it.

```python
profile = {
  "email": "   COOKIE@Monster.com   ",
  "name": "Cookie Monster",
  "last_activity_at": "2026-10-01T22:54:14.627670"
}
```

Without PyFun:

```python
# Without PyFun
email = profile.get("email")
if email is not None:
	email = email.strip().lower()

# With PyFun
email = lookup(profile, "email").map(str.strip).map(str.lower)

# email is "cookie@monster.com" or None/Nothing
```

#### 2. Process an optional field

Calculate whether this is a DAU user or a MAU user, but only if the "last_activity_at" field is present.

```python
# Without PyFun
activity_at = profile.get("last_activity_at")
delta = (date.today() - datetime.fromisoformat(activity_at).date()).days if activity_at else None
if delta is not None:
    dau, mau = update_metric(delta)
    
# With PyFun
dau, mau = lookup(profile, "last_activity_at")
    .map(datetime.fromisoformat)
    .map(date)
    .map(lambda dt: (date.today() - dt).days)
    .map(update_metric)
    .unwrap()
    
# OR
dau, mau = lookup(profile, "last_activity_at")
    .map(lambda dt: (date.today() - datetime.fromisoformat(dt).date()).days)
    .map(update_metric)
    .unwrap()
```

#### 3. Parse then Validate

```python
# Without PyFun
# parse_int :: str -> int | None
# valid_port :: int -> int | None
port: int | None = None
if (requested_port := parse_int("8080")) is not None:
  port = valid_port()
  
# With PyFun
# parse_int :: str -> Maybe[int]
# valid_port :: int -> Maybe[int]
port = parse_int("8080").bind(valid_port)
```

#### 4. Walk nested data where any level might be missing

```python
# Without PyFun
# lookup :: (Iterable[User], int) -> User | None
# get_address :: User -> Address | None
# get_city :: Address -> City | None
city: City | None = None
if (user := lookup(users, 1)) is not None and (addr := get_address(user)) is not None:
    city = get_city(addr)
  
# With PyFun
# lookup :: (Iterable[User], int) -> Maybe[User]
# get_address :: User -> Maybe[Address]
# get_city :: Address -> Maybe[City]
city = lookup(users, 1).bind(get_address).bind(get_city)
```

#### 5. A later step needs an earlier value

```python
# Without PyFun
# checked_div :: (int, int) -> int | None
speed: float | None = None
if (d := parse_int(distance)) is not None and (h := parse_int(hours)) is not None:
    speed = checked_div(d, h)
    
# With PyFun
# checked_div :: (int, int) -> Maybe[float]
speed = parse_int(distance).bind(lambda d: parse_int(hours).bind(lambda h: checked_div(d, h)))

# OR
def speed(distance: str, hours: str) -> Maybe[float]:
  match parse_int(distance), parse_int(hours):
    case Some(d), Some(h):
      return checked_div(d, h)
    case _:
      return Nothing()
```

#### 6. Guard a computation with preconditions

```python
# Without PyFun
newbal: int | None = None
if amount > 0 and amount <= balance:
  newbal = balance - amount
  
# With PyFun
newbal = ensure(amount > 0)
	.then(lambda: ensure(amount <= balance))
  .then(lambda: Some(balance - amount))
```

#### 7. Stop at the first failure

```python
# Without PyFun
# validate :: () -> Bool
# connect :: () -> Connection | None
# fetch :: Connection -> Cookie
cookie: Cookie | None = None
if validate():
  if (conn := connect()):
    cookie = fetch(conn)
    
# With PyFun
# validate :: () -> Maybe[None]
# connect :: () -> Maybe[Connection]
# fetch :: Connection -> Cookie
cookie = validate().then(connect).bind(fetch)
```

