Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

The middleware toolbox: meta-programming tools for building declarative frameworks
— function signatures as data, decorators, wrapping/routing, multi-object
composition. Legacy-packaged (`setup.cfg`/`setup.py`, no `pyproject.toml`) but
heavily depended upon across the fleet — see Dependents below.
composition. Packaged with `pyproject.toml` (hatchling) and heavily depended upon
across the fleet — see Dependents below.

## Module map (`i2/`)

Expand Down Expand Up @@ -31,15 +31,20 @@ heavily depended upon across the fleet — see Dependents below.
## Tests (verified)

```bash
uv venv .venv && uv pip install -e . pytest
.venv/bin/pytest i2/ --ignore=i2/examples --ignore=i2/scrap --doctest-modules -q
# 756 passed, 2 xfailed
uv venv .venv && uv pip install -e ".[dev]"
.venv/bin/python -m pytest
# 782 passed, 2 xfailed
```
No `ruff`/lint gate in CI — `.github/workflows/ci.yml` uses the legacy
`i2mint/isee` actions (`install-packages`, `format-source-code`,
`pytest-validation`), not the `i2mint/wads` reusable workflow other repos use.
`ruff check i2/` reports hundreds of pre-existing findings; it is not what gates
merges here.
`[tool.pytest.ini_options]` supplies `--doctest-modules` and the `i2/examples`,
`i2/scrap` ignores, so plain `pytest` matches CI. `i2/tests/test_readme.py` runs
every python block of `README.md` in order, so README examples must run.

CI is the wads **inline** uv workflow (`.github/workflows/ci.yml`), configured by
`[tool.wads.ci]`. It is inline rather than the reusable-workflow stub only because
the stub's Pages job can't pass the epythet v2 pilot pin (`epythet-spec`). The
lint gate is `ruff check i2` with only `D100` (module docstrings) selected; don't
widen it without fixing what it finds. A merge to `master` bumps the version and
publishes to PyPI, so never edit the version by hand.

## Invariant: this package has no safety net for its own breaking changes

Expand Down
405 changes: 350 additions & 55 deletions .github/workflows/ci.yml

Large diffs are not rendered by default.

52 changes: 38 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ For human readers: [Documentation here.](https://i2mint.github.io/i2/)
If you identify as a dinosaur, the rest of this README is written for you, starting at [Key Modules Overview](#key-modules-overview).
<!-- epythet:agentic-readme:end -->

**Working on this repo as an agent?** Read [`.claude/CLAUDE.md`](.claude/CLAUDE.md) first: module map, test command, and the rule that any change to a public signature, default or return type needs the dependents' tests, not just these. The skills above live in [`.claude/skills/`](.claude/skills), so Claude Code picks them up in this repo automatically. To install one elsewhere, use `gh skill install i2mint/i2 i2-signatures --allow-hidden-dirs --agent claude-code` (swap in any skill name). Contributors who still type every character themselves: see [For carbon-based contributors](#for-carbon-based-contributors).

## Install

```
Expand Down Expand Up @@ -180,7 +182,7 @@ def add(x, y):
# Transform inputs before function, outputs after
wrapped = Wrap(
add,
ingress=lambda x, y: (x * 2, y * 2), # Double inputs
ingress=lambda x, y: ((x * 2, y * 2), {}), # Double inputs; returns (args, kwargs)
egress=lambda result: result / 2 # Halve output
)

Expand All @@ -191,19 +193,19 @@ assert result == 7
**Signature Transformation:**

```python
from i2.wrapper import Ingress
from i2.wrapper import Ingress, wrap

def process(data: dict):
return data['value']

# Change signature: accept 'x' instead of 'data'
ingress = Ingress(
inner_sig=process,
outer_sig='x',
inner_sig='data',
kwargs_trans=lambda x: {'data': {'value': x}}
kwargs_trans=lambda outer_kwargs: {'data': {'value': outer_kwargs['x']}},
)

new_func = ingress(process)
new_func = wrap(process, ingress=ingress)
result = new_func(42) # Calls process({'value': 42})
assert result == 42
```
Expand Down Expand Up @@ -281,7 +283,7 @@ router = RoutingForest([

# Can get all matches or just first
list(router(15)) # ['≥ 10', 'Odd number']
next(router(8)) # None (no matches)
next(router(8), None) # None (no matches)
```

**Pattern Matching Example:**
Expand Down Expand Up @@ -331,7 +333,7 @@ assert asis(42) == 42
assert asis([1, 2, 3]) == [1, 2, 3]

# Constant functions (useful as defaults)
assert return_true(anything, goes="here") is True
assert return_true("anything", goes="here") is True
assert return_false("doesn't", "matter") is False
assert return_none(1, 2, 3) is None
```
Expand All @@ -350,15 +352,19 @@ from functools import partial
assert name_of_obj(partial(print, sep=",")) == 'print'
```

**Attribute/Item Access:**
**Immutable dict:**

```python
from i2.util import imdict

# Flexible dict-like access
# An immutable dict: reads work, mutations raise TypeError
data = imdict({'a': 1, 'b': 2})
assert data.a == 1 # Attribute access
assert data['b'] == 2 # Item access
assert data['b'] == 2
try:
data['c'] = 3
raise AssertionError("imdict should not be mutable")
except TypeError:
pass
```

**Laziness Utilities:**
Expand Down Expand Up @@ -434,8 +440,8 @@ def process(**kwargs):
def typed_process(**kwargs):
return process(**kwargs)

# Now can call with clear parameters
result = typed_process(1, 2, 3)
# Now can call with clear (keyword) parameters
result = typed_process(a=1, b=2, c=3)
assert result == 6
```

Expand All @@ -457,7 +463,10 @@ def validate_input(value):
),
CondNode(
cond=lambda x: isinstance(x, int),
then=FinalNode(x >= 0)
then=RoutingForest([
CondNode(lambda x: x >= 0, FinalNode(True)),
CondNode(lambda x: x < 0, FinalNode(False))
])
)
])
return next(router(value), False)
Expand All @@ -470,6 +479,21 @@ assert validate_input(-1) is False



## For carbon-based contributors

Development setup and the full test run (doctests are most of the suite):

```bash
uv venv .venv && uv pip install -e ".[dev]"
.venv/bin/python -m pytest
```

Packaging lives in `pyproject.toml`. CI is the wads uv workflow in `.github/workflows/ci.yml`, configured by `[tool.wads.ci]`. A merge to `master` publishes to PyPI and bumps the version, so don't edit the version by hand.

About 50 packages depend on `i2` (`dol`, `meshed`, `config2py`, `front`, `py2http` and more). Before changing a public signature, default or return type in `signatures.py`, `deco.py` or `wrapper.py`, run the tests of the heaviest dependents against your branch. i2's own suite has missed breakages there before.

Questions and design discussion go to [GitHub issues](https://github.com/i2mint/i2/issues) and [discussions](https://github.com/i2mint/i2/discussions).

## What's mint?

Mint stands for "Meta-INTerface".
Expand Down
137 changes: 102 additions & 35 deletions i2/signatures.py
Original file line number Diff line number Diff line change
Expand Up @@ -4047,48 +4047,115 @@ def ch_func_to_all_pk(func):
>>> gg = ch_func_to_all_pk(g)
>>> print(Sig(gg))
(x, y=1, args=(), **kwargs)
"""
# Not yet handled (not doctests):
# >>> def h(x, *y, z):
# ... print(f"{x=}, {y=}, {z=}")
# >>> h(1, 2, 3, z=4)
#
# x=1, y=(2, 3), z=4
#
# >>> hh = ch_func_to_all_pk(h)
# >>> hh(1, (2, 3), z=4)
#
# x=1, y=(2, 3), z=4

# _func = tuple_the_args(func)
# sig = Sig(_func)
#
# @wraps(func)
# def __func(*args, **kwargs):
# # b = Sig(_func).bind_partial(*args, **kwargs)
# # return _func(*b.args, **b.kwargs)
# args, kwargs = Sig(_func).extract_args_and_kwargs(
# *args, **kwargs, _ignore_kind=False
# )
# return _func(*args, **kwargs)
#
The variadic positional is given as a tuple, and the variadic keywords are still
given as extra keyword arguments:

>>> def h(x, *y, z=0, **kwargs):
... return f"{x=}, {y=}, {z=}, {kwargs=}"
>>> hh = ch_func_to_all_pk(h)
>>> print(Sig(hh))
(x, y=(), z=0, **kwargs)
>>> hh(1, (2, 3), z=4, extra=5)
"x=1, y=(2, 3), z=4, kwargs={'extra': 5}"
>>> assert hh(1, (2, 3), z=4, extra=5) == h(1, 2, 3, z=4, extra=5)
"""
# Not yet handled: a required keyword-only param after the variadic positional,
# e.g. ``def h(x, *y, z)``, since ``(x, y=(), z)`` isn't a valid signature.
func_sig = Sig(func)
_func = tuple_the_args(func)
sig = Sig(_func)
all_pk_sig = all_pk_signature(Sig(_func)) # a Sig, keeping the name of func

@wraps(func)
def __func(*args, **kwargs):
args, kwargs = Sig(_func).extract_args_and_kwargs(
*args,
**kwargs,
# _ignore_kind=False,
# _allow_partial=True
)
return _func(*args, **kwargs)
if not func_sig.has_var_positional:
# Unchanged behavior (dependents rely on it): the variadic keyword is given
# by name, as a dict (``kwargs={...}``), and other excess arguments are
# ignored.
@wraps(func)
def __func(*args, **kwargs):
args, kwargs = Sig(_func).extract_args_and_kwargs(*args, **kwargs)
return _func(*args, **kwargs)

else:
# With a variadic positional, the path above can't work: it mangles the
# tupled ``*args`` value and drops the variadic keywords (#12). So we bind
# to the all-PK signature and rebuild the call to ``func`` ourselves.
var_keyword_name = func_sig.var_keyword_name

@wraps(func)
def __func(*args, **kwargs):
if var_keyword_name and isinstance(kwargs.get(var_keyword_name), Mapping):
# As in the no-variadic-positional case, accept the variadic keywords
# given by name, as a dict (and merge any other extras with them)
kwargs = dict(kwargs)
kwargs = {**kwargs.pop(var_keyword_name), **kwargs}
arguments = all_pk_sig.map_arguments(
args, kwargs, allow_excess=True, ignore_kind=False
)
_args, _kwargs = _args_and_kwargs_from_all_pk_arguments(
func_sig, arguments
)
return func(*_args, **_kwargs)

__func.__signature__ = all_pk_signature(sig)
__func.__signature__ = all_pk_sig
return __func


def _args_and_kwargs_from_all_pk_arguments(sig, arguments):
"""Make the ``(args, kwargs)`` to call a function of signature ``sig`` from
``arguments`` bound to its ``ch_func_to_all_pk`` signature (where the value of a
``*args`` param is a tuple and the value of a ``**kwargs`` param is a dict).

Positional(-or-keyword) params are given positionally when they have to be (i.e.
when a later positional-only param or a non-empty ``*args`` is given), using their
defaults to fill any gaps; the others are given by keyword.

>>> def f(a, /, b=2, c=3, *args, d, **kwargs):
... ...
>>> _args_and_kwargs_from_all_pk_arguments(
... Sig(f), dict(a=1, c=4, args=(5, 6), d=7, kwargs={'e': 8})
... )
((1, 2, 4, 5, 6), {'d': 7, 'e': 8})
>>> _args_and_kwargs_from_all_pk_arguments(Sig(f), dict(a=1, c=4, d=7))
((1,), {'c': 4, 'd': 7})
"""
positional_params = [p for p in sig.params if p.kind in (PO, PK)]
vp_name = sig.var_positional_name
vp_values = tuple(arguments.get(vp_name, ())) if vp_name else ()

# How many of the positional params must be given positionally
if vp_values:
n_positional = len(positional_params)
else:
n_positional = max(
(
i + 1
for i, p in enumerate(positional_params)
if p.kind == PO and p.name in arguments
),
default=0,
)

args, kwargs = [], {}
for i, p in enumerate(positional_params):
if p.name in arguments:
if i < n_positional:
args.append(arguments[p.name])
else:
kwargs[p.name] = arguments[p.name]
elif i < n_positional:
if p.default is Parameter.empty:
raise TypeError(f"missing a required argument: '{p.name}'")
args.append(p.default)
args.extend(vp_values)

for p in sig.params:
if p.kind == KO and p.name in arguments:
kwargs[p.name] = arguments[p.name]
if sig.var_keyword_name:
kwargs.update(arguments.get(sig.var_keyword_name, {}))
return tuple(args), kwargs


def copy_func(f):
"""Copy a function (not sure it works with all types of callables).

Expand Down
25 changes: 25 additions & 0 deletions i2/tests/test_readme.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Run the README's python examples, in order, in one shared namespace.

The README's examples build on each other (later blocks reuse names defined in
earlier ones), so they are executed cumulatively, as a reader would run them.
"""

import re
from pathlib import Path

import pytest

README = Path(__file__).resolve().parents[2] / "README.md"


def _python_blocks():
if not README.is_file(): # e.g. running from an installed wheel
return []
return re.findall(r"```python\n(.*?)```", README.read_text(), re.S)


@pytest.mark.skipif(not README.is_file(), reason="README.md not found")
def test_readme_python_examples_run():
namespace = {}
for i, block in enumerate(_python_blocks()):
exec(compile(block, f"README.md python block {i}", "exec"), namespace)
2 changes: 0 additions & 2 deletions i2/tests/test_requirements.txt

This file was deleted.

Loading
Loading