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
1 change: 1 addition & 0 deletions i2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
include_exclude,
rm_params,
partialx,
curried,
ch_names,
arg_val_converter,
ignore_extra_args, # Wrap a function to accept (and ignore) extra arguments
Expand Down
56 changes: 44 additions & 12 deletions i2/doc_mint.py
Original file line number Diff line number Diff line change
Expand Up @@ -427,9 +427,9 @@ def convert_string(s: str, converters: list[dict | Callable]) -> object:

DocStyle = Literal["numpy", "google", "rest"]

# Each style's tell-tale marker, checked in this order (most specific first): a numpy
# section header underlined with dashes, a reST field (``:param x:``), a google
# section header ending with a colon.
# Each style's tell-tale marker: a numpy section header underlined with dashes, a reST
# field (``:param x:``), a google section header ending with a colon. The style whose
# marker comes first in the docstring wins (ties go to the earlier style here).
_doc_style_markers = (
(
"numpy",
Expand Down Expand Up @@ -481,6 +481,18 @@ def detect_doc_style(docstring: str | Callable) -> DocStyle | None:
... :type x: int
... ''')
'rest'

When several styles' markers appear (e.g. a google docstring whose example
shows reST fields), the one that comes first wins:

>>> detect_doc_style('''
... Args:
... x (int): An integer.
...
... Example:
... :param y: shown as text, not a field.
... ''')
'google'
>>> detect_doc_style('Just a summary line.') is None
True
>>> detect_doc_style(None) is None
Expand All @@ -501,10 +513,23 @@ def detect_doc_style(docstring: str | Callable) -> DocStyle | None:
docstring = docstring.__doc__ or ""
if not isinstance(docstring, str):
return None
docstring = _normalize_newlines(docstring)
first_match_positions = {}
for style, marker in _doc_style_markers:
if marker.search(docstring):
return style
return None
if match := marker.search(docstring):
first_match_positions[style] = match.start()
if not first_match_positions:
return None
return min(first_match_positions, key=first_match_positions.get)


def _normalize_newlines(text: str) -> str:
r'''Turn CRLF (and lone CR) line endings into LF.

>>> _normalize_newlines('a\r\nb\rc')
'a\nb\nc'
'''
return text.replace("\r\n", "\n").replace("\r", "\n")


def docstring_to_params(
Expand Down Expand Up @@ -584,11 +609,19 @@ def docstring_to_params(
... '''
>>> [p['name'] for p in docstring_to_params(f, doc_style='auto')]
['x']

The Sphinx typed-field form (``:param int x:``) and CRLF line endings are handled:

>>> docstring_to_params(':param int x: X.\\r\\n:param y: Note: Y.', doc_style='auto') == [
... {"name": "x", "default": None, "annotation": int, "description": "X."},
... {"name": "y", "default": None, "annotation": None, "description": "Note: Y."},
... ]
True
"""
if not isinstance(docstring, str) and callable(docstring):
docstring = docstring.__doc__ or ""
# dedent, so the section headers of an indented ``__doc__`` are found
docstring = cleandoc(docstring or "")
docstring = cleandoc(_normalize_newlines(docstring or ""))
if doc_style == "auto":
doc_style = detect_doc_style(docstring) or "numpy"

Expand Down Expand Up @@ -747,11 +780,10 @@ def _parse_rest_param_lines(param_lines: list[str]) -> list[dict]:
i = 0
while i < len(param_lines):
line = param_lines[i].strip()
# Match ':param name: description'
param_match = re.match(r"^:param (\w+):\s*(.+)", line)
# Match ':param name: description' or ':param type name: description'
param_match = re.match(r"^:param\s+(?:([^:]+?)\s+)?(\w+):\s*(.*)", line)
if param_match:
name = param_match.group(1)
description = param_match.group(2)
annotation, name, description = param_match.groups()
# Check for default value in description
default_match = re.search(r"\(Default:\s*(.+)\)", description)
default = default_match.group(1).strip() if default_match else None
Expand All @@ -761,7 +793,7 @@ def _parse_rest_param_lines(param_lines: list[str]) -> list[dict]:
params_dict[name] = {
"name": name,
"default": default,
"annotation": None,
"annotation": annotation,
"description": description,
}
i += 1
Expand Down
115 changes: 115 additions & 0 deletions i2/tests/test_partials_and_methodize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Tests for partialx(_rm_partialize), curried, methodize(default_from) and the
docstring parser fixes of i2mint/i2#111, #112, #113 and #116."""

import asyncio
import inspect
from dataclasses import dataclass
from functools import partial

import pytest

from i2 import Sig, curried, methodize, partialx
from i2.doc_mint import detect_doc_style, docstring_to_params


def test_rm_partialize_keeps_a_partial_and_binds_positionals_once():
def h(a, *args, **kw):
return a, args, kw

g = partialx(h, 1, 2, _rm_partialize=True)
assert isinstance(g, partial) and g.func is h
assert g(3) == (1, (2, 3), {})


def test_rm_partialize_makes_params_after_a_keyword_binding_keyword_only():
def f(a, b, c=3):
return a, b, c

g = partialx(f, b=2, _rm_partialize=True)
assert str(Sig(g)) == "(a, *, c=3)"
assert g(1, c=5) == (1, 2, 5)


def test_rm_partialize_keeps_var_params_named_by_a_keyword():
def h(a, *args, **kw):
return a, args, kw

g = partialx(h, kw=3, _rm_partialize=True)
assert str(Sig(g)) == "(a, *args, **kw)"
assert g(1) == (1, (), {"kw": 3})


def test_rm_partialize_keeps_coroutine_functions():
async def af(a, b):
return a + b

g = partialx(af, b=2, _rm_partialize=True)
assert inspect.iscoroutinefunction(g)
assert asyncio.run(g(1)) == 3


def test_curried():
def f(a, b, *, c):
return a, b, c

g = curried(f)
assert g(1)(2)(c=3) == g(1, 2, c=3) == g(c=3)(1)(2) == (1, 2, 3)
with pytest.raises(TypeError):
g(1, d=4)


def test_curried_async_awaits_on_the_full_call_only():
async def af(a, b):
return a + b

ga = curried(af)
assert not inspect.iscoroutinefunction(ga)
assert asyncio.run(ga(1)(2)) == 3


def test_methodize_default_from():
def foo(x, y, z):
return x + y * z

@dataclass
class K:
x: int
zoo: int = 2
bar = methodize(foo, bind="x", default_from=[("z", "zoo")])

k = K(1)
assert str(Sig(k.bar)) == "(y, z=self.zoo)"
assert (k.bar(3), k.bar(3, 10), k.bar(3, z=0)) == (7, 31, 1)


def test_methodize_default_from_rejects_variadic_and_unknown_params():
def foo(x, *args, **kwargs):
return x

with pytest.raises(ValueError, match="args"):
methodize(foo, default_from="args")
with pytest.raises(ValueError, match="not in the method"):
methodize(foo, default_from="nope")


@pytest.mark.parametrize(
"line, name, annotation, description",
[
(":param x: Note: foo", "x", None, "Note: foo"),
(":param int x: X.", "x", int, "X."),
(":param dict[str, int] x: X.", "x", "dict[str, int]", "X."),
],
)
def test_rest_param_lines(line, name, annotation, description):
[param] = docstring_to_params(line, doc_style="rest")
assert (param["name"], param["annotation"], param["description"]) == (
name,
annotation,
description,
)


def test_crlf_docstrings():
doc = "Do it.\r\n\r\nArgs:\r\n x (int): An integer.\r\n"
assert detect_doc_style(doc) == "google"
assert [p["name"] for p in docstring_to_params(doc, doc_style="auto")] == ["x"]
Loading
Loading