Metadata-Version: 2.4
Name: iffy
Version: 1.0.0
Summary: Tiny conditional helpers for dict, list, and set — skip None, merge nested dicts, pick keys
Project-URL: Homepage, https://github.com/miriada-io/iffy
Project-URL: Repository, https://github.com/miriada-io/iffy
Project-URL: Issues, https://github.com/miriada-io/iffy/issues
Project-URL: Changelog, https://github.com/miriada-io/iffy/blob/master/CHANGELOG.md
Author-email: Miriada <info@miriada.io>
License-Expression: MIT
License-File: LICENSE
Keywords: conditional,dict,list,merge,pick,recursive,set,update,utilities
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: typing-extensions>=4.5.0
Provides-Extra: dev
Requires-Dist: coverage>=7.0; extra == 'dev'
Requires-Dist: pre-commit>=3.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# iffy

[![PyPI](https://img.shields.io/pypi/v/iffy.svg?label=PyPI)](https://pypi.org/project/iffy/)
[![Python](https://img.shields.io/pypi/pyversions/iffy.svg?label=Python)](https://pypi.org/project/iffy/)
[![Tests](https://github.com/miriada-io/iffy/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/miriada-io/iffy/actions/workflows/tests.yml)
[![License](https://img.shields.io/pypi/l/iffy.svg?label=License)](https://github.com/miriada-io/iffy/blob/master/LICENSE)

Small utilities for conditional `dict`, `list`, and `set` operations — skip `None`, merge nested dicts, pick keys, collect or raise exceptions.

## Installation

```bash
pip install iffy
```

Requires Python 3.11+.

## Why iffy?

Assembling a dict or list from optional values is a surprisingly verbose corner of Python. The stdlib makes you choose between two bad options: a noisy chain of `if`-statements, or a comprehension that inlines the predicate into every call site.

- Building a request payload from optional form fields typically looks like `if x is not None: d["x"] = x` repeated five times. `conditional_setitem(d, **optional_fields)` collapses it to one line.
- `dict.update` clobbers nested values — `{"a": {"x": 1}}.update({"a": {"y": 2}})` gives `{"a": {"y": 2}}`, not `{"a": {"x": 1, "y": 2}}`. `update_dict_recursive` merges deeply instead.
- Extracting a subset of keys from a dict is usually a comprehension or `operator.itemgetter`. `PickableDict(...).pick([...])` reads as plain English.
- In validation or batch-processing code you often want to either raise on the first error *or* collect errors per-item, depending on context. `write_to_dict_or_raise` gives you both modes behind a single call.

Plain functions on plain `dict`, `list`, and `set`. Each helper does one thing, so there is nothing to learn beyond its signature.

## Quick Start

```python
from iffy import PickableDict, conditional_setitem, update_dict_recursive

# Build a PATCH payload — only send fields that actually changed.
patch: dict = {}
conditional_setitem(patch, name="alice", email=None, age=30, bio=None)
assert patch == {"name": "alice", "age": 30}  # None fields are dropped, not sent as null

# Merge user overrides on top of defaults, preserving nested keys.
config = {"timeouts": {"connect": 5, "read": 30}, "retries": 3}
overrides = {"timeouts": {"read": 60}}
update_dict_recursive(config, overrides)
assert config == {"timeouts": {"connect": 5, "read": 60}, "retries": 3}

# Expose only a whitelisted subset of a record.
user = PickableDict({"id": 1, "name": "alice", "password_hash": "...", "internal_id": "xyz"})
assert user.pick(["id", "name"]) == {"id": 1, "name": "alice"}
```

## Overview

**Conditional operations:** [`add_if`](#add_if) | [`append_if`](#append_if) | [`setitem_if`](#setitem_if) | [`conditional_add`](#conditional_add) | [`conditional_append`](#conditional_append) | [`conditional_setitem`](#conditional_setitem)

**Dict utilities:** [`PickableDict`](#pickabledict) | [`update_dict_recursive`](#update_dict_recursive)

**Error handling:** [`write_to_dict_or_raise`](#write_to_dict_or_raise)

**Predicates:** [`is_none`](#is_none--is_not_none) | [`is_not_none`](#is_none--is_not_none)

---

## Conditional Operations

Two families of helpers for writing into a container when a condition holds.

The `*_if` functions take a **single boolean** and apply it to *all* values — "if this flag is set, write everything". The `conditional_*` functions take a **per-value predicate** (default: `is_not_none`) and apply it to each value individually — "write each value that passes the check". Pick whichever matches your branching style.

### `add_if`

Add all given values to a `set` when `condition` is truthy.

```python
from iffy import add_if

tags = {"read"}
add_if(user.is_admin, tags, "write", "delete")
# if user.is_admin: tags == {"read", "write", "delete"}
# else:             tags == {"read"}
```

Equivalent to `if condition: set_.update(args)`, but reads more naturally inline.

### `append_if`

Append all given values to a `list` when `condition` is truthy.

```python
from iffy import append_if

args = ["--verbose"]
append_if(debug_mode, args, "--trace", "--dump-on-error")
# if debug_mode: args == ["--verbose", "--trace", "--dump-on-error"]
# else:          args == ["--verbose"]
```

### `setitem_if`

Set all given items in a `MutableMapping` when `condition` is truthy.

```python
from iffy import setitem_if

request_body = {"limit": 10}
setitem_if(is_auth_required, request_body, user="guest", password="secret")
# if is_auth_required: request_body == {"limit": 10, "user": "guest", "password": "secret"}
# else:                request_body == {"limit": 10}
```

*Use when:* a whole block of fields shares a single gate — a feature flag, a role check, a privacy toggle, a dev-vs-prod switch.

### `conditional_add`

Add each value to a `set` that passes a per-value predicate. Default predicate is `is_not_none`.

```python
from iffy import conditional_add

# Default predicate drops None:
roles = {"user"}
conditional_add(roles, "admin", None, "editor", None)
# roles == {"user", "admin", "editor"}

# Custom predicate — e.g. skip empty strings with the builtin `bool`:
tags: set[str] = set()
conditional_add(tags, "", "python", "", "typing", _condition=bool)
# tags == {"python", "typing"}
```

### `conditional_append`

Append each value to a `list` that passes a per-value predicate. Default predicate is `is_not_none`.

```python
from iffy import conditional_append

def parse_int(s: str) -> int | None:
    try:
        return int(s)
    except ValueError:
        return None

numbers: list[int] = []
conditional_append(numbers, parse_int("42"), parse_int("abc"), parse_int("7"))
# numbers == [42, 7]  — "abc" failed to parse and was skipped
```

*Use when:* you are collecting results from lookups or parsers that may return `None`, and you want the successes in order without a separate `if ... is not None` per call.

### `conditional_setitem`

Set each keyword argument in a `MutableMapping` whose value passes a per-value predicate. Default predicate is `is_not_none`.

```python
from iffy import conditional_setitem

def build_query(name=None, status=None, created_after=None):
    q = {}
    conditional_setitem(q, name=name, status=status, created_after=created_after)
    return q

build_query(name="alice")
# {"name": "alice"} — status and created_after dropped because they're None
```

*Use when:* building API request bodies, query parameters, or partial update payloads where absent fields must not appear as `null`.

All three `conditional_*` functions accept a `_condition` keyword to override the default `is_not_none` check:

```python
def is_positive(x) -> bool:
    return isinstance(x, (int, float)) and x > 0

metrics: dict[str, float] = {}
conditional_setitem(metrics, requests=0, latency_ms=42, errors=-1, _condition=is_positive)
# metrics == {"latency_ms": 42}  — zero and negative values filtered out
```

---

## Dict Utilities

### `PickableDict`

A `dict` subclass with a single extra method: `.pick(keys)`. Returns a plain `dict` containing only the listed keys that exist in the source.

```python
from iffy import PickableDict

user = PickableDict({"id": 1, "name": "alice", "password_hash": "...", "email": "a@b.c"})

user.pick(["id", "name"])
# {"id": 1, "name": "alice"}

# Missing keys are silently skipped — no KeyError:
user.pick(["id", "does_not_exist"])
# {"id": 1}
```

*Use when:* you have a bag-of-fields dict (ORM row, form payload, config) and you want to emit a whitelisted projection of it. The alternatives (`{k: d[k] for k in keys if k in d}`, `operator.itemgetter` with try/except) are either noisy or brittle.

### `update_dict_recursive`

Like `dict.update`, but merges nested dicts instead of replacing them. Modifies `target_dict` in place.

```python
from iffy import update_dict_recursive

defaults = {
    "db": {"host": "localhost", "port": 5432, "pool": {"min": 1, "max": 10}},
    "debug": False,
}
overrides = {
    "db": {"host": "prod.db", "pool": {"max": 50}},
    "debug": True,
}

update_dict_recursive(defaults, overrides)
# defaults == {
#     "db": {"host": "prod.db", "port": 5432, "pool": {"min": 1, "max": 50}},
#     "debug": True,
# }
```

Non-dict values replace in full. A dict replacing a non-dict (or vice versa) also replaces — the merge only recurses when both sides are dicts.

```python
update_dict_recursive({"x": 1}, {"x": {"nested": True}})
# {"x": {"nested": True}}  — plain value replaced by a dict
```

*Use when:* layering configuration sources (defaults → file → env → CLI), merging partial patches into a document, or any time `dict.update`'s shallow behavior silently drops fields.

---

## Error Handling

### `write_to_dict_or_raise`

Either records an exception in a dict under the given key, or re-raises it — depending on whether the dict is `None`.

```python
from iffy import write_to_dict_or_raise

def validate_all(items, *, collect=True):
    errors: dict[str, Exception] | None = {} if collect else None
    for name, item in items.items():
        try:
            validate(item)
        except ValueError as e:
            write_to_dict_or_raise(errors, name, e)
    return errors

# Collect errors from every item, return them together:
validate_all(items, collect=True)
# {"item_3": ValueError(...), "item_7": ValueError(...)}

# Fail fast on the first error:
validate_all(items, collect=False)
# raises ValueError on the first bad item
```

*Use when:* the same validation or processing loop needs to run in two modes — aggregate-then-report in batch jobs, fail-fast in interactive use — without duplicating the loop.

---

## Predicates

### `is_none` / `is_not_none`

Thin wrappers around `x is None` / `x is not None`, useful as first-class callables.

```python
from iffy import is_none, is_not_none

is_none(None)     # True
is_none(0)        # False
is_none("")       # False

is_not_none(None) # False
is_not_none(0)    # True
```

They exist because `is` is a keyword, not a function — so you can't pass it to `filter`, `map`, or use it as a default argument. `is_not_none` is the default `_condition` for all three `conditional_*` helpers.

```python
list(filter(is_not_none, [1, None, 2, None, 3]))
# [1, 2, 3]
```

---

## Deprecated Aliases

The following names are kept for backward compatibility. They emit a `DeprecationWarning` and simply forward their arguments — prefer the canonical names in new code. They will be removed in a future major release.

| Deprecated                                           | Use instead                                                                      |
| ---------------------------------------------------- | -------------------------------------------------------------------------------- |
| `set_if(condition, mapping, **kwargs)`               | [`setitem_if(condition, mapping, **kwargs)`](#setitem_if)                        |
| `conditional_set(mapping, _condition=..., **kwargs)` | [`conditional_setitem(mapping, _condition=..., **kwargs)`](#conditional_setitem) |

## License

[MIT](LICENSE)
