From 872fa0180f10f35d156a263845ed41a5f424fcbe Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Sat, 26 Sep 2026 09:31:06 +0000 Subject: [PATCH 1/4] fix: make ch_func_to_all_pk work for functions with *args ch_func_to_all_pk routed calls through Sig.extract_args_and_kwargs, whose default ignore_kind=True turns every param keyword-only. For a function with *args, that mangled the tupled *args value and dropped the **kwargs extras, so every call failed or lost arguments. For functions with *args, the wrapper now binds to its own all-PK signature and rebuilds the call to the original function (tuple expanded as *args, dict as **kwargs). The **kwargs can be given as extra keywords or by name as a dict. Functions without *args keep master's code path unchanged: meshed's hook_up and FlexFuncFanout rely on its convention (**kwargs given by name as a dict, other extras ignored), and tests now pin it. The signature ch_func_to_all_pk produces is unchanged. Fixes #12 --- i2/signatures.py | 137 +++++++++++++++++++++++++++--------- i2/tests/test_signatures.py | 96 +++++++++++++++++++++++++ 2 files changed, 198 insertions(+), 35 deletions(-) diff --git a/i2/signatures.py b/i2/signatures.py index 5cf6aaca..e73407a3 100644 --- a/i2/signatures.py +++ b/i2/signatures.py @@ -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). diff --git a/i2/tests/test_signatures.py b/i2/tests/test_signatures.py index 7162a471..c7e5536c 100644 --- a/i2/tests/test_signatures.py +++ b/i2/tests/test_signatures.py @@ -2446,3 +2446,99 @@ def test_sigless_builtins_still_get_their_curated_signatures(): from operator import itemgetter assert str(_robust_signature_of_callable(itemgetter(1))) != "(*args, **kwargs)" + + +# -------------------------------------------------------------------------------------- +# ch_func_to_all_pk (i2mint/i2#12) + + +def _pos_and_kws(*pos, **kws): + return {"pos": pos, "kws": kws} + + +def _x_y_args_kwargs(x, y=1, *args, **kwargs): + return x, y, args, kwargs + + +def _x_vp_z(x, *y, z=0): + return x, y, z + + +def _po_pk_ko(a, /, b, *, c=None): + return a, b, c + + +@pytest.mark.parametrize( + "func, args, kwargs, expected", + [ + # The exact repro of i2#12: variadic keywords must not be nested or dropped + (_pos_and_kws, ((1, 2),), dict(a=3, b=4), _pos_and_kws(1, 2, a=3, b=4)), + (_pos_and_kws, (), dict(pos=(1, 2), a=3), _pos_and_kws(1, 2, a=3)), + (_pos_and_kws, (), dict(a=3), _pos_and_kws(a=3)), + (_pos_and_kws, (), {}, _pos_and_kws()), + # The case left as "not yet handled" in ch_func_to_all_pk's docstring (with a + # default for ``z``: a required keyword-only param after ``*y`` can't be made PK) + (_x_vp_z, (1, (2, 3)), dict(z=4), _x_vp_z(1, 2, 3, z=4)), + (_x_vp_z, (1,), dict(y=(2, 3), z=4), _x_vp_z(1, 2, 3, z=4)), + (_x_vp_z, (1, (2, 3), 4), {}, _x_vp_z(1, 2, 3, z=4)), + (_x_vp_z, (1,), {}, _x_vp_z(1)), + # Positional params before a non-empty *args, some left at their default + (_x_y_args_kwargs, (1,), {}, _x_y_args_kwargs(1)), + (_x_y_args_kwargs, (1, 2, (3, 4)), {}, _x_y_args_kwargs(1, 2, 3, 4)), + (_x_y_args_kwargs, (1,), dict(args=(3, 4)), _x_y_args_kwargs(1, 1, 3, 4)), + (_x_y_args_kwargs, (1, 2, (3,)), dict(k=5), _x_y_args_kwargs(1, 2, 3, k=5)), + (_x_y_args_kwargs, (), dict(x=1, k=5), _x_y_args_kwargs(1, k=5)), + # Functions without variadics keep working, including being forgiving of + # excess arguments (as they were before the fix) + (_po_pk_ko, (1, 2, 3), {}, (1, 2, 3)), + (_po_pk_ko, (), dict(a=1, b=2, c=3), (1, 2, 3)), + (_po_pk_ko, (1, 2, 3, 4), {}, (1, 2, 3)), + (_po_pk_ko, (1, 2), dict(d=5), (1, 2, None)), + ], +) +def test_ch_func_to_all_pk_calls(func, args, kwargs, expected): + assert ch_func_to_all_pk(func)(*args, **kwargs) == expected + + +def test_ch_func_to_all_pk_signature_unchanged(): + assert str(Sig(ch_func_to_all_pk(_pos_and_kws))) == "(pos=(), **kws)" + assert str(Sig(ch_func_to_all_pk(_x_vp_z))) == "(x, y=(), z=0)" + assert str(Sig(ch_func_to_all_pk(_x_y_args_kwargs))) == ( + "(x, y=1, args=(), **kwargs)" + ) + assert str(Sig(ch_func_to_all_pk(_po_pk_ko))) == "(a, b, c=None)" + + +def test_ch_func_to_all_pk_missing_required_argument_raises(): + with pytest.raises(TypeError): + ch_func_to_all_pk(_po_pk_ko)(1) + with pytest.raises(TypeError): + ch_func_to_all_pk(_x_y_args_kwargs)(y=2) # x is required + + +def _a_b_kw(a, b=2, **kw): + return a, b, kw + + +def test_ch_func_to_all_pk_without_var_positional_keeps_legacy_kwargs_semantics(): + """Without ``*args``, the behavior dependents rely on (meshed's ``hook_up``, + ``FlexFuncFanout``) is kept: the variadic keywords are given by name, as a dict, + and other excess keyword arguments are ignored.""" + ff = ch_func_to_all_pk(_a_b_kw) + assert str(Sig(ff)) == "(a, b=2, **kw)" + assert ff(1, kw={"c": 3}) == (1, 2, {"c": 3}) + assert ff(1, c=3) == (1, 2, {}) + assert ff(1, 5) == (1, 5, {}) + + +def test_ch_func_to_all_pk_with_var_positional_accepts_kwargs_by_name(): + """With ``*args``, the variadic keywords can be given as extra keyword arguments + (i2#12) or, consistently with the case without ``*args``, by name as a dict.""" + ff = ch_func_to_all_pk(_pos_and_kws) + assert ff((1, 2), kws={"a": 3}) == _pos_and_kws(1, 2, a=3) + assert ff((1, 2), kws={"a": 3}, b=4) == _pos_and_kws(1, 2, a=3, b=4) + + +def test_ch_func_to_all_pk_signature_keeps_function_name(): + assert ch_func_to_all_pk(_a_b_kw).__signature__.name == "_a_b_kw" + assert ch_func_to_all_pk(_pos_and_kws).__signature__.name == "_pos_and_kws" From d572784393c1c346a05b0f422509e34012ee78b7 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Sat, 26 Sep 2026 09:31:06 +0000 Subject: [PATCH 2/4] fix: keep parameter kinds in order when partialx reorders params move_params_to_the_end appended the moved names at the very end, which could put a keyword-only param after **kwargs or before a positional one. partialx(partialx, x=2, _allow_reordering=True) hit this and raised "wrong parameter order". The reordered names are now stable-sorted by parameter kind. Every valid signature is already sorted by kind, so only orders that used to raise change; the order within each kind is kept. Fixes #17 --- i2/tests/test_wrapper.py | 49 ++++++++++++++++++++++++++++++++++++++++ i2/wrapper.py | 17 ++++++++++++-- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/i2/tests/test_wrapper.py b/i2/tests/test_wrapper.py index 60ccdca3..b0559b67 100644 --- a/i2/tests/test_wrapper.py +++ b/i2/tests/test_wrapper.py @@ -598,3 +598,52 @@ def g(x: str, y: str) -> str: assert str(Sig(wrapped_g)) == "(x: str, y: str) -> str" assert str(Sig(wrapped_f)) == "(a: int) -> int" + + +# -------------------------------------------------------------------------------------- +# partialx / move_params_to_the_end reordering (i2mint/i2#17) + + +def test_partialx_of_partialx_with_reordering(): + """The exact repro of i2#17: reordering must keep keyword-only params before + ``**kwargs`` instead of raising ``ValueError: wrong parameter order``.""" + from i2.wrapper import partialx + + @partialx(partialx, x=2, _allow_reordering=True) + def foo(x, y, z=0): + return x + y * z + + # The outer partialx's `_allow_reordering` applies to partialx's own params, so + # `foo` itself is just partialized (no reordering), as with `partialx(foo, x=2)` + assert str(Sig(foo)) == "(*, x=2, y, z=0)" + assert foo(y=3, z=4) == 14 + assert foo(y=3) == 2 + + +def test_move_params_to_the_end_keeps_variadic_keyword_last(): + from i2.wrapper import move_params_to_the_end + + def g(a, b=1, *args, c=2, **kwargs): + return a, b, args, c, kwargs + + h = move_params_to_the_end(g, ["b", "c"]) + assert str(Sig(h)) == "(a, b=1, *args, c=2, **kwargs)" + assert h(0, 1, 2, c=3, d=4) == g(0, 1, 2, c=3, d=4) + + # Moving keyword-only params behind others of their kind still works + def k(*, a=1, b, c=3, **kwargs): + return a, b, c, kwargs + + kk = move_params_to_the_end(k, ["a"]) + assert str(Sig(kk)) == "(*, b, c=3, a=1, **kwargs)" + assert kk(b=2, d=4) == (1, 2, 3, {"d": 4}) + + +def test_move_params_to_the_end_keeps_keyword_only_after_positional(): + from i2.wrapper import move_params_to_the_end + + def f(a=1, *, b, c=3): + return a, b, c + + h = move_params_to_the_end(f, Sig(f).defaults) + assert str(Sig(h)) == "(a=1, *, b, c=3)" diff --git a/i2/wrapper.py b/i2/wrapper.py index 56a8383c..29782b4c 100644 --- a/i2/wrapper.py +++ b/i2/wrapper.py @@ -2455,6 +2455,14 @@ def move_params_to_the_end(func: Callable, names_to_move: Callable | Iterable[st >>> h = move_params_to_the_end(g, Sig(g).defaults) >>> assert str(Sig(g)) == '(a, *, b=4, c)' >>> assert str(Sig(h)) == '(a, *, c, b=4)' + + Names are only moved to the end of their own kind: a keyword-only param always + stays after the positional ones, and ``**kwargs`` stays last. + + >>> def bar(a, b=1, *, c=2, d, **kwargs): + ... ... + >>> str(Sig(move_params_to_the_end(bar, ['b', 'c']))) + '(a, b=1, *, d, c=2, **kwargs)' """ if callable(names_to_move): names_to_move = names_to_move(func) @@ -2463,8 +2471,13 @@ def move_params_to_the_end(func: Callable, names_to_move: Callable | Iterable[st f"or a callable producing one from a function. Was {names_to_move}" ) - names = Sig(func).names - reordered = move_names_to_the_end(names, names_to_move) + sig = Sig(func) + reordered = move_names_to_the_end(sig.names, names_to_move) + # Moving names to the end can put them after params of a later kind (e.g. a + # keyword-only param after ``**kwargs``), which is not a valid signature. A stable + # sort by kind only changes such invalid orders, keeping the order within each kind + # (see i2mint/i2#17). + reordered = sorted(reordered, key=lambda name: sig.kinds[name]) wrapped_func = include_exclude(func, include=reordered) return wrapped_func From a6b9eeb53813c11c9a7ee5dd51b8c1b2a2ad422b Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Sat, 26 Sep 2026 09:31:06 +0000 Subject: [PATCH 3/4] build: migrate packaging to pyproject.toml and CI to wads uv Legacy setup.cfg/setup.py become a hatchling pyproject.toml, and the isee-based workflow becomes the wads inline uv workflow. This commit is self-contained so it can be dropped without touching the fixes before it. - Metadata: SPDX license string, description, keywords, classifiers, URLs, requires-python >=3.10 (master already fails to import on 3.9). - Wheel contents are file-for-file identical to the setuptools build. - [tool.wads.ci] mirrors today's CI: Linux only, Python 3.10 and 3.12, doctests, i2/examples and i2/scrap excluded. Metrics off (no config). - Ruff limited to D100, which the package already passes. - Inline workflow rather than the stub, because the stub's Pages job cannot pass the epythet v2 pilot pin, which is kept here. - Dropped i2/tests/test_requirements.txt (read only by the old workflow; the suite never imported its pins) and the unused SCRIPTS_REPOSITORY_URL. Publishing now uses secrets.PYPI_PASSWORD as a PyPI API token. --- .github/workflows/ci.yml | 405 ++++++++++++++++++++++++++++----- i2/tests/test_requirements.txt | 2 - pyproject.toml | 131 +++++++++++ setup.cfg | 22 -- setup.py | 3 - 5 files changed, 481 insertions(+), 82 deletions(-) delete mode 100644 i2/tests/test_requirements.txt create mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cba411f7..0ba7d486 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,110 +1,405 @@ -name: Continuous Integration +name: Continuous Integration (uv) +# Inline uv CI (wads `github_ci_uv.yml`), not the reusable-workflow stub: the stub's +# github-pages job can't pass `epythet-spec`, and this repo pilots epythet v2 (see the +# Pages step). Once the pin is no longer needed, `wads-migrate ci-to-stub` applies. +# PyPI auth: publish uses secrets.PYPI_PASSWORD as an API token (uv publish). on: [push, pull_request] + +# Workflow-level env vars from [tool.wads.ci.env] in pyproject.toml. +# Populated by wads-migrate / wads init via template substitution. +# Contains ONLY non-secret values: PROJECT_NAME and literal defaults from +# env.defaults. Secret-backed vars (required/test/extra_envvars) are +# deliberately scoped to the jobs that run tests — see the validation and +# windows-validation jobs below. A workflow-level secret would be in scope +# for the setup job too, and GitHub then refuses to emit any job OUTPUT +# containing its value: a short value (e.g. a test level of "3") silently +# blanks python-versions, the matrix expands to nothing, and the run fails +# with no failing step (i2mint/wads#61). env: PROJECT_NAME: i2 - SCRIPTS_REPOSITORY_URL: http://${{ secrets.SCRIPTS_USERNAME }}:${{ secrets.SCRIPTS_TOKEN }}@git.otosense.ai/vferon/ci-scripts.git + jobs: + # First job: Read configuration from pyproject.toml + setup: + name: Read Configuration + runs-on: ubuntu-latest + outputs: + project-name: ${{ steps.config.outputs.project-name }} + python-versions: ${{ steps.config.outputs.python-versions }} + pytest-args: ${{ steps.config.outputs.pytest-args }} + coverage-enabled: ${{ steps.config.outputs.coverage-enabled }} + exclude-paths: ${{ steps.config.outputs.exclude-paths }} + test-on-windows: ${{ steps.config.outputs.test-on-windows }} + windows-blocking: ${{ steps.config.outputs.windows-blocking }} + tests-enabled: ${{ steps.config.outputs.tests-enabled }} + build-sdist: ${{ steps.config.outputs.build-sdist }} + build-wheel: ${{ steps.config.outputs.build-wheel }} + metrics-enabled: ${{ steps.config.outputs.metrics-enabled }} + metrics-config-path: ${{ steps.config.outputs.metrics-config-path }} + metrics-storage-branch: ${{ steps.config.outputs.metrics-storage-branch }} + metrics-python-version: ${{ steps.config.outputs.metrics-python-version }} + metrics-force-run: ${{ steps.config.outputs.metrics-force-run }} + ruff-enabled: ${{ steps.config.outputs.ruff-enabled }} + black-enabled: ${{ steps.config.outputs.black-enabled }} + mypy-enabled: ${{ steps.config.outputs.mypy-enabled }} + docs-enabled: ${{ steps.config.outputs.docs-enabled }} + licence-enabled: ${{ steps.config.outputs.licence-enabled }} + publish-enabled: ${{ steps.config.outputs.publish-enabled }} + skip-ci-marker: ${{ steps.config.outputs.skip-ci-marker }} + publish-marker: ${{ steps.config.outputs.publish-marker }} + trigger-mode: ${{ steps.config.outputs.trigger-mode }} + run-ci-marker: ${{ steps.config.outputs.run-ci-marker }} + commit-subject: ${{ steps.commit.outputs.subject }} + + steps: + # ------------------------------------------------------------------ + # The marker gates below (skip-ci, publish) match against the commit + # SUBJECT — its first line — and never against the whole message. + # + # Why: a squash-merge folds the ENTIRE PR BODY into the squash commit + # message. A `contains()` over the full message therefore fires on a + # PR that merely WRITES ABOUT a marker. That is not hypothetical: a PR + # body quoting the publish marker forced a publish on a repo that had + # publishing disabled, and turned a default branch that had been green + # for the first time in a year red. + # + # Why not `startsWith()`: this house's marker convention is TRAILING + # ("cw v1: an MIT replacement for argh ... [bump minor]") and GitHub + # appends " (#N)" to every squash subject. A prefix match would turn a + # gate that fires too often into one that SILENTLY NEVER FIRES — the + # same defect, in the direction nobody notices. + # + # Shell safety: the commit message is untrusted, attacker-influenced, + # multi-line text. It reaches bash ONLY through the environment, never + # spliced into a command line, so quotes, newlines and backticks in it + # cannot become shell syntax. The value is written with the + # $GITHUB_OUTPUT heredoc form under a per-run random delimiter. + # + # On events with no head commit (e.g. pull_request) the expression + # renders empty, the subject is empty, and every `contains()` gate is + # false — the same verdict the full-message form gave. + # ------------------------------------------------------------------ + - name: Extract commit subject + id: commit + env: + HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + run: | + subject="${HEAD_COMMIT_MESSAGE%%$'\n'*}" + subject="${subject%$'\r'}" + delimiter="wads-subject-${RANDOM}${RANDOM}${RANDOM}" + { + printf 'subject<<%s\n' "$delimiter" + printf '%s\n' "$subject" + printf '%s\n' "$delimiter" + } >> "$GITHUB_OUTPUT" + printf 'commit subject: %s\n' "$subject" + + - uses: actions/checkout@v6 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + + - name: Set up Python + run: uv python install 3.11 + + - name: Read CI Config + id: config + uses: i2mint/wads/actions/read-ci-config@master + with: + pyproject-path: . + + # TRIGGER GATE. Every job after `setup` carries the same clause: + # (trigger-mode != 'on-demand' || github.event_name == 'workflow_dispatch' + # || contains(commit-subject, run-ci-marker)) + # [tool.wads.ci.trigger].mode = "auto" (the default) makes it always true, so + # auto repos behave exactly as before. "on-demand" means NOTHING RUNS UNLESS + # ASKED: the commit SUBJECT must carry run_ci_marker (default "[run ci]"), or + # the run must be a manual workflow_dispatch (always allowed). Publishing and + # Pages obey the same gate. `!= 'on-demand'` fails OPEN to auto when + # read-ci-config installed a wads too old to emit the output: a repo that + # never asked for on-demand must not lose its CI to a version skew. + # + # An on-demand caller stub also pre-filters at zero cost (no runner is + # scheduled for an ordinary push). That pre-filter can only test the whole + # message (expressions have no split), so THIS clause, over the extracted + # subject, is the decision: a marker quoted only in a squash-merged PR body + # costs the setup job and runs nothing else. + + # Second job: Validation using the config validation: name: Validation - if: "!contains(github.event.head_commit.message, '[skip ci]')" + if: "!contains(needs.setup.outputs.commit-subject, '[skip ci]') && (needs.setup.outputs.trigger-mode != 'on-demand' || github.event_name == 'workflow_dispatch' || contains(needs.setup.outputs.commit-subject, needs.setup.outputs.run-ci-marker))" + needs: setup runs-on: ubuntu-latest + # Secret-backed env vars from [tool.wads.ci.env] land here (test jobs + # only, never workflow level — see the note on the top-level env block), + # rendered as `KEY: ${{ secrets.NAME || '' }}` (an unset secret renders + # as an empty string). The block is omitted entirely when none are + # declared. Names also present in env.defaults are not re-emitted here: + # the committed default at workflow level stays authoritative (matching + # the reusable workflow's export-ci-env). The publish and github-pages + # jobs deliberately do not receive these vars either — parity with + # uv-ci.yml, where only the test jobs export them. + strategy: matrix: - python-version: ["3.10", "3.12"] + python-version: ${{ fromJson(needs.setup.outputs.python-versions) }} + steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: i2mint/wads/actions/setup-python-uv@master with: python-version: ${{ matrix.python-version }} - # Necessary for 3.12, since setuptools was removed there - - name: Install setuptools - run: python -m pip install setuptools + - name: Install System Dependencies + uses: i2mint/wads/actions/install-system-deps@master + with: + pyproject-path: . - name: Install Dependencies - uses: i2mint/isee/actions/install-packages@master - with: - dependency-files: setup.cfg - test-requirements-file: ${{ env.PROJECT_NAME }}/tests/test_requirements.txt + uses: i2mint/wads/actions/install-deps-uv@master - name: Format Source Code - uses: i2mint/isee/actions/format-source-code@master + if: needs.setup.outputs.ruff-enabled != 'false' + run: uvx ruff format . + + - name: Format Source Code (black) + if: needs.setup.outputs.black-enabled == 'true' + run: uvx black . + + - name: Lint Validation + if: needs.setup.outputs.ruff-enabled != 'false' + run: uvx ruff check --output-format=github ${{ needs.setup.outputs.project-name }} + + # Licence perimeter: fail the build if the INSTALLED dependency closure + # carries a copyleft / non-commercial licence the project's policy + # forbids. Opt-in via [tool.wads.licence].enabled = true, so a repo that + # declares nothing sees no change in CI behaviour at all. + # + # Run once, on the FIRST python-versions entry only: the answer does not + # vary by interpreter, and N identical failures across the matrix is + # noise. (The Windows job is a separate job and never runs this.) + # + # `--python` is load-bearing. `uvx` runs the tool in its OWN isolated + # environment, which contains wads and nothing of the project; without + # pointing it at the project's .venv the check would read the wrong + # closure and report a confident, wrong green. + - name: Licence Perimeter + if: needs.setup.outputs.licence-enabled == 'true' && matrix.python-version == fromJson(needs.setup.outputs.python-versions)[0] + run: | + if [ ! -f ".venv/bin/activate" ]; then + echo "::error::no .venv found; the licence gate must read the project's own installed closure, not uvx's isolated one" + exit 1 + fi + source .venv/bin/activate + uvx --from wads wads-licence-check . --python "$VIRTUAL_ENV/bin/python" + + - name: Type Check (mypy) + if: needs.setup.outputs.mypy-enabled == 'true' + run: uvx mypy ${{ needs.setup.outputs.project-name }} + + - name: Run Tests + if: needs.setup.outputs.tests-enabled != 'false' + uses: i2mint/wads/actions/run-tests-uv@master + with: + root-dir: ${{ needs.setup.outputs.project-name }} + pytest-args: ${{ needs.setup.outputs.pytest-args }} + exclude-paths: ${{ needs.setup.outputs.exclude-paths }} + coverage: ${{ needs.setup.outputs.coverage-enabled }} + + - name: Track Code Metrics + if: needs.setup.outputs.metrics-enabled == 'true' + uses: i2mint/umpyre/actions/track-metrics@master + continue-on-error: true + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + config-path: ${{ needs.setup.outputs.metrics-config-path }} + storage-branch: ${{ needs.setup.outputs.metrics-storage-branch }} + python-version: ${{ needs.setup.outputs.metrics-python-version }} + force-run: ${{ needs.setup.outputs.metrics-force-run }} - # Documentation on "enable" codes: - # http://pylint.pycqa.org/en/latest/technical_reference/features.html#basic-checker-messages - # - name: Pylint Validation - # uses: i2mint/isee/actions/pylint-validation@master - # with: - # root-dir: ${{ env.PROJECT_NAME }} - # enable: missing-module-docstring - # ignore: tests,examples,scrap - # ignore-patterns: '.*tests.*|.*scrap.*|.examples.*' + # Optional Windows testing (if enabled in config) + windows-validation: + name: Windows Tests + if: "!contains(needs.setup.outputs.commit-subject, '[skip ci]') && needs.setup.outputs.test-on-windows == 'true' && needs.setup.outputs.tests-enabled != 'false' && (needs.setup.outputs.trigger-mode != 'on-demand' || github.event_name == 'workflow_dispatch' || contains(needs.setup.outputs.commit-subject, needs.setup.outputs.run-ci-marker))" + needs: setup + runs-on: windows-latest + # Fail-closed opt-in ([tool.wads.ci.testing].windows_blocking): only the + # literal 'true' makes the Windows leg block. An unset output - an older + # read-ci-config that does not emit it - yields '' != 'true', i.e. the + # historical informational behaviour. Blocking reddens the RUN; publish + # still does not depend on this job. + continue-on-error: ${{ needs.setup.outputs.windows-blocking != 'true' }} + env: + # PEP 540 UTF-8 mode: avoid cp1252 UnicodeDecode/EncodeError when test + # code reads source files or scripts print non-ASCII characters. + PYTHONUTF8: "1" + PYTHONIOENCODING: "utf-8" + # Secret-backed env vars from [tool.wads.ci.env] (see the note on the + # validation job). Empty when none are declared. Names colliding with + # the literals above are skipped — a duplicate key in one mapping + # would fail the whole workflow at parse time. - - name: Pytest Validation - uses: i2mint/isee/actions/pytest-validation@master + steps: + - uses: actions/checkout@v6 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 with: - root-dir: ${{ env.PROJECT_NAME }} - paths-to-ignore: examples,scrap + enable-cache: true + - name: Set up Python + uses: i2mint/wads/actions/setup-python-uv@master + with: + python-version: ${{ fromJson(needs.setup.outputs.python-versions)[0] }} + + - name: Install System Dependencies + uses: i2mint/wads/actions/install-system-deps@master + with: + pyproject-path: . + + - name: Install Dependencies + uses: i2mint/wads/actions/install-deps-uv@master + + - name: Run Tests + uses: i2mint/wads/actions/run-tests-uv@master + with: + root-dir: ${{ needs.setup.outputs.project-name }} + pytest-args: ${{ needs.setup.outputs.pytest-args }} + exclude-paths: ${{ needs.setup.outputs.exclude-paths }} + + # Publishing job + # + # Gated by [tool.wads.ci.publish] in pyproject.toml (read via the setup job): + # - skip-ci-marker : when publishing is enabled, a commit SUBJECT LINE + # containing this substring skips the publish job + # (default "[skip ci]"). + # - publish-enabled : whether publishing runs at all (default true). + # - publish-marker : when publishing is disabled, a commit SUBJECT LINE + # containing this substring forces the publish job + # (default "[publish]"). + # - trigger-mode : in on-demand mode, publish also needs the run-ci + # marker in the subject or a workflow_dispatch — + # the TRIGGER GATE above `validation`. + # The publish-enabled check uses `== 'true'` (fail-closed): if an older wads + # without these outputs is installed by read-ci-config, publishing is skipped + # rather than run unintentionally. + # + # SUBJECT, not message: both markers are matched against the setup job's + # `commit-subject` output (the first line), never the full message, because + # a squash-merge folds the whole PR BODY into the squash commit message — + # see the note on the extraction step in the setup job. publish: name: Publish - if: "!contains(github.event.head_commit.message, '[skip ci]') && github.ref == 'refs/heads/master'" - needs: validation + permissions: + contents: write + if: "!contains(needs.setup.outputs.commit-subject, needs.setup.outputs.skip-ci-marker) && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && (needs.setup.outputs.publish-enabled == 'true' || contains(needs.setup.outputs.commit-subject, needs.setup.outputs.publish-marker)) && (needs.setup.outputs.trigger-mode != 'on-demand' || github.event_name == 'workflow_dispatch' || contains(needs.setup.outputs.commit-subject, needs.setup.outputs.run-ci-marker))" + needs: [setup, validation] runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.12"] + steps: - - uses: actions/checkout@v3 + # `actions/checkout@v6` defaults to persist-credentials: true, configuring + # HTTPS auth in .git/config using `secrets.GITHUB_TOKEN`. Together with the + # job-level `permissions: contents: write`, this is exactly what the + # post-publish push-back needs — no per-repo SSH deploy key required. Do + # NOT re-add a "Force SSH for git remote" step: rewriting origin to an + # SSH URL overrides these credentials and reintroduces the push-back + # failure on every repo lacking an SSH_PRIVATE_KEY deploy key. + - uses: actions/checkout@v6 with: fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} + - name: Set up uv + uses: astral-sh/setup-uv@v7 - # Necessary for 3.12, since setuptools was removed there - - name: Install setuptools - run: python -m pip install setuptools + - name: Set up Python + uses: i2mint/wads/actions/setup-python-uv@master + with: + python-version: ${{ fromJson(needs.setup.outputs.python-versions)[0] }} + create-venv: "false" - name: Format Source Code - uses: i2mint/isee/actions/format-source-code@master + if: needs.setup.outputs.ruff-enabled != 'false' + run: uvx ruff format . + + - name: Format Source Code (black) + if: needs.setup.outputs.black-enabled == 'true' + run: uvx black . - name: Update Version Number + id: version uses: i2mint/isee/actions/bump-version-number@master - - name: Package - uses: i2mint/isee/actions/package@master + - name: Build Distribution + uses: i2mint/wads/actions/build-dist-uv@master + with: + sdist: ${{ needs.setup.outputs.build-sdist }} + wheel: ${{ needs.setup.outputs.build-wheel }} - - name: Publish - uses: i2mint/isee/actions/publish@master + - name: Publish to PyPI + uses: i2mint/wads/actions/pypi-publish-uv@master with: - pypi-username: ${{ secrets.PYPI_USERNAME }} - pypi-password: ${{ secrets.PYPI_PASSWORD }} + pypi-token: ${{ secrets.PYPI_PASSWORD }} - - name: Check In - uses: i2mint/isee/actions/check-in@master + # A second merge landing on the default branch mid-run used to make this + # push-back fail as non-fast-forward: PyPI had the release, but the bump + # commit and tag never landed and the run went red (i2mint/wads#81). The + # git-commit action replays the bump onto the moved branch and retries; + # see its `push-rebase-retries` input, and the note in + # i2mint/wads .github/workflows/uv-ci.yml on why no `concurrency` group. + - name: Commit Changes + uses: i2mint/wads/actions/git-commit@master with: - commit-message: "**CI** Formatted code + Updated version number and documentation. [skip ci]" - ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + commit-message: "**CI** Formatted code + Updated version to ${{ env.VERSION }} [skip ci]" + push: true - name: Tag Repository - uses: i2mint/isee/actions/tag-repository@master + uses: i2mint/wads/actions/git-tag@master with: - tag: $VERSION + tag: ${{ env.VERSION }} + message: "Release version ${{ env.VERSION }}" + push: true + # Optional GitHub Pages (skipped when [tool.wads.ci.docs].enabled = false) + # Depends on validation (not publish) so docs still publish when the publish + # job is disabled via [tool.wads.ci.publish].enabled = false. github-pages: name: Publish GitHub Pages - if: "!contains(github.event.head_commit.message, '[skip ci]') && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)" - needs: publish + permissions: + contents: write + pages: write + id-token: write + if: "!contains(needs.setup.outputs.commit-subject, '[skip ci]') && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && needs.setup.outputs.docs-enabled != 'false' && (needs.setup.outputs.trigger-mode != 'on-demand' || github.event_name == 'workflow_dispatch' || contains(needs.setup.outputs.commit-subject, needs.setup.outputs.run-ci-marker))" + needs: [setup, validation] runs-on: ubuntu-latest + steps: + # Check out first so install-system-deps can read [tool.wads.ops.*] from + # pyproject.toml. The epythet action self-checks-out again internally; + # apt-installed system deps persist across that re-checkout. + - uses: actions/checkout@v6 + + # Install [tool.wads.ops.*] system deps (e.g. portaudio for pyaudio) so + # the epythet docs build, which pip-installs this package to extract + # docstrings, doesn't fail on a missing native library at import time. + # No-op when the package declares no system deps. + - name: Install System Dependencies + uses: i2mint/wads/actions/install-system-deps@master + with: + pyproject-path: . + - uses: i2mint/epythet/actions/publish-github-pages@master with: github-token: ${{ secrets.GITHUB_TOKEN }} + ignore: "tests/,scrap/,examples/" # WP5 pilot: opt into epythet v2 ahead of the fleet default (i2mint/epythet#16) epythet-spec: "epythet>=0.2,<0.3" diff --git a/i2/tests/test_requirements.txt b/i2/tests/test_requirements.txt deleted file mode 100644 index 3bd5a521..00000000 --- a/i2/tests/test_requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -py2store==0.0.10 -py2http==0.1.24 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..a978f40c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,131 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "i2" +version = "0.1.74" +description = "The middleware toolbox: signatures as data, decorators, wrappers and multi-object composition" +readme = "README.md" +requires-python = ">=3.10" +license = "Apache-2.0" +license-files = ["LICENSE"] +authors = [{ name = "Thor Whalen" }] +keywords = [ + "signature", + "inspect", + "decorator", + "wrapper", + "middleware", + "meta-programming", + "function composition", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [] + +[project.urls] +Homepage = "https://github.com/i2mint/i2" +Documentation = "https://i2mint.github.io/i2" +Repository = "https://github.com/i2mint/i2" +Issues = "https://github.com/i2mint/i2/issues" + +[project.optional-dependencies] +dev = ["pytest", "pytest-cov"] + +[tool.hatch.build] +skip-excluded-dirs = true + +[tool.hatch.build.targets.sdist] +exclude = [".claude", ".github", "misc", "*.ipynb"] + +[tool.hatch.build.targets.wheel] +packages = ["i2"] +exclude = ["*.ipynb"] + +[tool.ruff] +line-length = 88 +target-version = "py310" +exclude = [ + "**/*.ipynb", + "**/*.md", + ".git", + ".venv", + "build", + "dist", + "tests", + "examples", + "scrap", +] + +[tool.ruff.lint] +select = ["D100"] +ignore = ["D203", "E501", "B905"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.per-file-ignores] +"**/tests/*" = ["D"] +"**/examples/*" = ["D"] +"**/scrap/*" = ["D"] + +[tool.pytest.ini_options] +minversion = "6.0" +testpaths = ["i2"] +addopts = "--doctest-modules --ignore=i2/examples --ignore=i2/scrap" +# Same flags as the CI test action (wads run-tests-uv), so local runs match CI +doctest_optionflags = ["ELLIPSIS", "IGNORE_EXCEPTION_DETAIL"] + +[tool.wads.ci] +project_name = "i2" + +[tool.wads.ci.install] +extras = "dev" + +[tool.wads.ci.env] +required_envvars = [] +test_envvars = [] +extra_envvars = [] + +[tool.wads.ci.quality.ruff] +enabled = true + +[tool.wads.ci.quality.black] +enabled = false + +[tool.wads.ci.quality.mypy] +enabled = false + +[tool.wads.ci.testing] +enabled = true +python_versions = ["3.10", "3.12"] +pytest_args = ["-q", "--tb=short"] +coverage_enabled = true +coverage_threshold = 0 +coverage_report_format = ["term", "xml"] +exclude_paths = ["i2/examples", "i2/scrap"] +test_on_windows = false + +[tool.wads.ci.metrics] +enabled = false + +[tool.wads.ci.build] +sdist = true +wheel = true + +[tool.wads.ci.publish] +enabled = true + +[tool.wads.ci.docs] +enabled = true +builder = "epythet" +ignore_paths = ["tests/", "scrap/", "examples/"] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 53f72f93..00000000 --- a/setup.cfg +++ /dev/null @@ -1,22 +0,0 @@ -[metadata] -name = i2 -version = 0.1.74 -url = https://github.com/i2mint/i2 -platforms = any -description_file = README.md -root_url = https://github.com/i2mint -description = The middleware toolbox -long_description = file: README.md -long_description_content_type = text/markdown -license = Apache Software License -description-file = README.md - -[options] -packages = find: -include_package_data = True -zip_safe = False -install_requires = - -[options.extras_require] -testing = - diff --git a/setup.py b/setup.py deleted file mode 100644 index 201cd4c2..00000000 --- a/setup.py +++ /dev/null @@ -1,3 +0,0 @@ -from setuptools import setup - -setup() # Note: Everything should be in the local setup.cfg From ce64cc6f6956d6e37fb1eaced474a5250faa2f80 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Sat, 26 Sep 2026 09:31:07 +0000 Subject: [PATCH 4/4] docs: fix broken README examples and guard them with a test Seven README python examples failed when run: wrong Wrap ingress return shape, Ingress used as a decorator, next() without a default, undefined names, and attribute access on imdict. They now run, and i2/tests/test_readme.py executes every README python block in order so they can't silently break again. Also: a README pointer for agents to .claude/CLAUDE.md and the skills, a "For carbon-based contributors" section with dev setup and the dependents rule, and CLAUDE.md updated for the pyproject/uv CI setup and test count. Fixes #94 --- .claude/CLAUDE.md | 25 ++++++++++++-------- README.md | 52 ++++++++++++++++++++++++++++++----------- i2/tests/test_readme.py | 25 ++++++++++++++++++++ 3 files changed, 78 insertions(+), 24 deletions(-) create mode 100644 i2/tests/test_readme.py diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index ac90fc6e..a5aab619 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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/`) @@ -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 diff --git a/README.md b/README.md index de8f4843..2287c789 100644 --- a/README.md +++ b/README.md @@ -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). +**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 ``` @@ -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 ) @@ -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 ``` @@ -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:** @@ -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 ``` @@ -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:** @@ -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 ``` @@ -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) @@ -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". diff --git a/i2/tests/test_readme.py b/i2/tests/test_readme.py new file mode 100644 index 00000000..725f69f6 --- /dev/null +++ b/i2/tests/test_readme.py @@ -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)