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
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ Added
accept keys that are not in their signature, which are forwarded to the model
on instantiation (`#732
<https://github.com/mauvilsa/jsonargparse/pull/732>`__).
- Support for methods and ``__init__`` defined with ``functools.partialmethod``,
both for adding their parameters and as import paths of callables (`#665
<https://github.com/mauvilsa/jsonargparse/pull/665>`__).

Changed
^^^^^^^
Expand Down
5 changes: 3 additions & 2 deletions DOCUMENTATION.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2255,8 +2255,9 @@ it. Modules commonly import others, e.g. ``import os``, so without this
it prevents the object from being used, unlike the check on the given path,
which prevents the import from happening at all. An object that has no defining
path of its own is denied by the callable it reaches, i.e. the bound function
for a ``functools.partial`` and the defining class for an instance, e.g.
``builtins.help`` is an instance of the ``_sitebuiltins._Helper`` class.
for a ``functools.partial`` or ``partialmethod`` and the defining class for an
instance, e.g. ``builtins.help`` is an instance of the ``_sitebuiltins._Helper``
class.

Entries given are added to the ones denied by default, they don't replace them.
For configs that are entirely untrusted, prefer denying everything and allowing
Expand Down
8 changes: 8 additions & 0 deletions jsonargparse/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from collections.abc import Callable
from contextlib import contextmanager
from contextvars import ContextVar
from functools import partialmethod
from typing import ( # type: ignore[attr-defined]
Generic,
TypeVar,
Expand Down Expand Up @@ -581,6 +582,13 @@ def get_generic_origins(class_or_tuple):
return get_generic_origin(class_or_tuple)


def get_partial_method(value) -> partialmethod | None:
"""The partialmethod given, or the one that created a method obtained from a class, e.g. ``Class.partial``."""
if inspect.isfunction(value):
value = getattr(value, "__partialmethod__", getattr(value, "_partialmethod", None)) # python<3.13
return value if isinstance(value, partialmethod) else None


def get_unsubscripted_alias_origin(typehint):
"""Origin class of an unsubscripted typing alias, e.g. typing.List -> list, else None."""
if isinstance(typehint, type) or hasattr(typehint, "__args__"):
Expand Down
9 changes: 7 additions & 2 deletions jsonargparse/_optionals.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,13 @@ def get_docstring_parse_options():


def parse_docstring(component, params=False, logger=None):
from ._common import get_partial_method

dp = import_docstring_parser("parse_docstring")
options = get_docstring_parse_options()
partial_method = get_partial_method(component)
if partial_method:
component = partial_method.func # documented by the function that it binds
try:
if params and options["attribute_docstrings"]:
return dp.parse_from_object(component, style=options["style"])
Expand Down Expand Up @@ -276,7 +281,7 @@ def parse_docs(component, parent, logger):


def get_doc_short_description(function_or_class, method_name=None, logger=None):
from ._common import get_generic_origin
from ._common import get_generic_origin, get_partial_method

function_or_class = get_generic_origin(function_or_class) # e.g. Strategy[int] documented by Strategy
if docstring_parser_support:
Expand All @@ -287,7 +292,7 @@ def get_doc_short_description(function_or_class, method_name=None, logger=None):
if docstring and docstring.short_description:
return docstring.short_description
init = cls.__dict__.get("__init__")
if init is not None:
if init is not None and not get_partial_method(init):
# the class defines its own constructor, so base classes don't describe it
docstring = parse_docstring(init, params=False, logger=logger)
return docstring.short_description if docstring else None
Expand Down
42 changes: 34 additions & 8 deletions jsonargparse/_parameter_resolvers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import ast
import dataclasses
import functools
import inspect
import logging
import textwrap
Expand All @@ -8,7 +9,7 @@
from contextlib import contextmanager, suppress
from contextvars import ContextVar
from copy import deepcopy
from functools import partial
from functools import partial, partialmethod
from importlib import import_module
from types import MethodType
from typing import Any, Union
Expand Down Expand Up @@ -105,6 +106,10 @@ def is_method(attr) -> bool:
)


def is_partial_method(attr) -> bool:
return isinstance(attr, partialmethod)


def is_property(attr) -> bool:
return isinstance(attr, property)

Expand Down Expand Up @@ -449,6 +454,22 @@ def replace_args_and_kwargs(params: ParamList, args: ParamList, kwargs: ParamLis
return params


def apply_partial_method(params: ParamList, partial_method: partialmethod) -> ParamList:
"""Applies to the parameters the arguments given in a partialmethod, as inspect.signature does."""
placeholder = getattr(functools, "Placeholder", object()) # python>=3.14
positionals = [p for p in params if p.kind in {kinds.POSITIONAL_ONLY, kinds.POSITIONAL_OR_KEYWORD}]
given = {p.name for p, arg in zip(positionals, partial_method.args) if arg is not placeholder}
params = [p for p in params if p.name not in given]
to_keyword_only = False
for param in params:
if param.name in partial_method.keywords:
param.default = partial_method.keywords[param.name]
to_keyword_only = True
if to_keyword_only and param.kind == kinds.POSITIONAL_OR_KEYWORD:
param.kind = kinds.KEYWORD_ONLY
return params


def group_parameters(params_list: list[ParamList]) -> ParamList:
if len(params_list) == 1:
for param in params_list[0]:
Expand Down Expand Up @@ -522,13 +543,13 @@ def get_mro_parameters(method_name, get_parameters_fn, logger):
remainder = classes[num + 1 :] + [object]
if method and not any(method is getattr(c, method_name, None) for c in remainder):
current_mro.set((classes, num))
return get_parameters_fn(cls, method, logger=logger)
return get_parameters_fn(cls, method_name, logger=logger)
return []


def get_component_and_parent(
function_or_class: Callable | type,
method_or_property: str | Callable | None = None,
method_or_property: str | None = None,
):
if is_subclass(function_or_class, ClassFromFunctionBase) and method_or_property in {None, "__init__"}:
function_or_class = function_or_class.wrapped_function # type: ignore[union-attr]
Expand All @@ -539,8 +560,6 @@ def get_component_and_parent(
method_or_property = None
elif inspect.isclass(get_generic_origin(function_or_class)) and method_or_property is None:
method_or_property = "__init__"
elif method_or_property and not isinstance(method_or_property, str):
method_or_property = method_or_property.__name__
parent = component = None
if method_or_property:
try:
Expand All @@ -555,6 +574,8 @@ def get_component_and_parent(
component = getattr(function_or_class, "__new__")
elif is_method(attr):
component = attr
elif is_partial_method(attr) and is_method(attr.func):
component = attr.func
elif is_property(attr):
component = attr.fget
elif isinstance(attr, classmethod):
Expand All @@ -574,7 +595,7 @@ class ParametersVisitor(LoggerProperty, ast.NodeVisitor):
def __init__(
self,
function_or_class: Callable | type,
method_or_property: str | Callable | None = None,
method_or_property: str | None = None,
**kwargs,
):
super().__init__(**kwargs)
Expand Down Expand Up @@ -1248,7 +1269,7 @@ def get_signature_parameters(
# the generated __new__ of a named tuple doesn't keep the annotations as written, and a
# subscripted generic one has no signature, so its parameters come from its fields
return get_namedtuple_params(function_or_class, logger, component=function_or_class)
get_component_and_parent(function_or_class, method_or_property) # verify input
component, parent, method_name = get_component_and_parent(function_or_class, method_or_property)
params = None
for get_parameters in [
get_parameters_from_pydantic_or_attrs,
Expand All @@ -1269,4 +1290,9 @@ def get_signature_parameters(
)
if params is not None:
break
return params or []
params = params or []
if parent:
attr = inspect.getattr_static(get_generic_origin(parent), method_name)
if is_partial_method(attr) and component is attr.func:
params = apply_partial_method(params, attr)
return params
19 changes: 18 additions & 1 deletion jsonargparse/_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from ._common import (
check_import_path,
get_generic_origin,
get_partial_method,
parser_capture,
parser_context,
)
Expand Down Expand Up @@ -248,6 +249,9 @@ def canonical_import_paths(obj) -> set:
stack = [obj]
while stack:
current = stack.pop()
partial_method = get_partial_method(current)
if partial_method:
current = partial_method.func # a method from a partialmethod is denied by the callable it binds
canonical = canonical_import_path(current)
if canonical:
paths.add(canonical)
Expand Down Expand Up @@ -276,6 +280,16 @@ def register_unresolvable_import_paths(*modules: ModuleType):
unresolvable_import_paths[val] = f"{module.__name__}.{val.__name__}"


def get_partial_method_path(partial_method: functools.partialmethod) -> str:
"""Import path of a partialmethod, found in the classes of the module that defines the function it binds."""
module = import_module(partial_method.func.__module__)
for cls in [v for v in vars(module).values() if inspect.isclass(v)]:
name = next((k for k, v in vars(cls).items() if v is partial_method), None)
if name:
return f"{get_import_path(cls)}.{name}"
raise ValueError(f"Not possible to determine the import path for partialmethod {partial_method}.")


def get_module_var_path(module_path: str, value: Any) -> str | None:
module = import_module(module_path)
for name, var in vars(module).items():
Expand Down Expand Up @@ -320,6 +334,9 @@ def get_import_path(value: Any) -> str | None:
remembered = resolved_import_paths.get(value)
if remembered:
return remembered
partial_method = get_partial_method(value)
if partial_method:
return get_partial_method_path(partial_method)
path = None
value = get_generic_origin(value)
if hasattr(value, "__self__") and inspect.isclass(value.__self__) and inspect.ismethod(value):
Expand Down Expand Up @@ -367,7 +384,7 @@ def object_path_serializer(value):
try:
path = get_import_path(value)
reimported = import_object(path, check_path=False)
if value is not reimported:
if (get_partial_method(value) or value) is not (get_partial_method(reimported) or reimported):
raise ValueError
return path
except Exception as ex:
Expand Down
23 changes: 23 additions & 0 deletions jsonargparse_tests/test_import_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,18 @@ def test_denied_callable_bound_by_a_partial():
import_object(f"{__name__}.system_partial") # partial bound to os.system, defined in posix, nt on Windows


def test_denied_callable_bound_by_a_partialmethod():
set_parsing_settings(import_path_denylist=[])
with pytest.raises(ImportDenied, match=f"'{os.system.__module__}'"):
import_object(f"{__name__}.WithSystemPartialMethod.system") # partialmethod of os.system


def test_partialmethod_allowed():
set_parsing_settings(import_path_denylist=[])
method = import_object(f"{__name__}.WithPartialMethod.partial_method")
assert method(WithPartialMethod()) == "given"


def test_denied_callable_exposed_by_an_instance():
set_parsing_settings(import_path_denylist=[])
with pytest.raises(ImportDenied, match="'operator'"):
Expand Down Expand Up @@ -297,6 +309,17 @@ def test_import_object_unaffected_when_allowed():
attr_getter = operator.attrgetter("__globals__") # instance of the denied operator.attrgetter


class WithSystemPartialMethod:
system = functools.partialmethod(os.system, "echo test") # binds a denied callable, defined in posix


class WithPartialMethod:
def method(self, value: str):
return value

partial_method = functools.partialmethod(method, "given")


def no_module_function():
"""Mimics extension functions that have __module__ set to None."""

Expand Down
64 changes: 64 additions & 0 deletions jsonargparse_tests/test_parameter_resolvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import calendar
import inspect
import sys
import xml.dom
from functools import partialmethod
from random import shuffle
from typing import Any, Callable, Dict, List, Optional, Protocol, Union
from unittest.mock import patch
Expand Down Expand Up @@ -37,6 +39,8 @@ def method_a(self, pma1: int, pma2: float, kma1: str = "x"):
kma1: help for kma1
"""

partial_method_a = partialmethod(method_a, pma1=1, pma2=0.5)


class ClassB(ClassA):
def __init__(self, pkb1: str, kb1: int = 3, kb2: str = "4", **kwargs):
Expand Down Expand Up @@ -96,6 +100,8 @@ def method_d(self, pmd1: int, *args, kmd1: int = 2, **kws):
"""
return super().method_a(*args, **kws) # pragma: no cover

partial_method_d = partialmethod(method_d, 3, kma1="y")

@staticmethod
def staticmethod_d(ksmd1: str = "z", **kw):
"""
Expand Down Expand Up @@ -836,6 +842,64 @@ def test_get_params_classmethod_instantiate_from_cls():
assert_params(get_params(ClassS1, "classmethod_s"), [])


# partialmethod parameters tests


def test_get_params_partialmethod_keywords():
params = get_params(ClassA, "partial_method_a")
assert_params(params, ["pma1", "pma2", "kma1"])
assert [p.default for p in params] == [1, 0.5, "x"]
signature = list(inspect.signature(ClassA.partial_method_a).parameters.values())[1:]
assert [(p.name, p.kind, p.default) for p in params] == [(p.name, p.kind, p.default) for p in signature]
with source_unavailable():
assert params == get_params(ClassA, "partial_method_a")


def test_get_params_partialmethod_positional_and_forwarded_kwargs():
params = get_params(ClassD, "partial_method_d")
assert_params(params, ["kmd1", "pma1", "pma2", "kma1"])
assert params[-1].default == "y"
with source_unavailable():
assert_params(get_params(ClassD, "partial_method_d"), ["kmd1"])


class ClassPartialInit(ClassB):
__init__ = partialmethod(ClassB.__init__, "p", kb1=5)


def test_get_params_partialmethod_init():
params = get_params(ClassPartialInit)
assert_params(params, ["kb1", "kb2", "ka1"])
assert [p.default for p in params] == [5, "4", 1.2]
with source_unavailable():
assert_params(get_params(ClassPartialInit), ["kb1", "kb2", "ka1", "ka2"])


class ClassPartialInitChild(ClassPartialInit):
def __init__(self, kpc1: int = 0, **kwargs):
"""
Args:
kpc1: help for kpc1
"""
super().__init__(**kwargs) # pragma: no cover


def test_get_params_partialmethod_init_from_super():
params = get_params(ClassPartialInitChild)
assert_params(params, ["kpc1", "kb1", "kb2", "ka1"])
assert [p.default for p in params] == [0, 5, "4", 1.2]


@pytest.mark.skipif(sys.version_info < (3, 14), reason="functools.Placeholder introduced in python 3.14")
def test_get_params_partialmethod_placeholder():
from functools import Placeholder

class ClassPlaceholder(ClassA):
placeholder_method = partialmethod(ClassA.method_a, Placeholder, 0.5)

assert_params(get_params(ClassPlaceholder, "placeholder_method"), ["pma1", "kma1"])


# function method parameters tests


Expand Down
Loading