Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Star-args attribute? #110

Closed
dstufft opened this issue Nov 8, 2016 · 11 comments
Closed

Star-args attribute? #110

dstufft opened this issue Nov 8, 2016 · 11 comments
Labels

Comments

@dstufft
Copy link
Member

dstufft commented Nov 8, 2016

I don't see a way to have this right now, but it'd be great to be able to make a particular attribute a star-args, something like:

import attr

@attr.s
class MyClass:
    foobars = attr.ib(init="starargs")

MyClass(1, 2, 3).foobars # [1, 2, 3]
@hynek
Copy link
Member

hynek commented Nov 8, 2016

Hm this seems related to #106 & al. It seems really bad WRT sub-classing et al. People are already surprised about things like #38. I mean we could just say “lol you can’t subclass and add more attributes”…

@hynek hynek added the Feature label Nov 13, 2016
@innov8ian
Copy link

innov8ian commented Jul 18, 2017

Currently, an init does not have *args, or **kwargs. But when an __attrs_post_init__ method is provided, why not then allow *args and **kwargs for the init and pass these to __attrs_post_init__?

The result would be the same for unexpected init parameters except it would be __attrs_post_init__ raising the error.

However cases like the above example would be handled, plus classes that have a need to store kwargs in dictionary would also be possible.

import attr

@attr.s
class MyClass:
    def __attrs_post_init__(self, *foobars):
         self.foobars = foobars

MyClass(1, 2, 3).foobars # [1, 2, 3]

I am new to attrs, so please forgive if I am missing something, but I have a specific need for the **kwargs case. This solution is one extra line than proposed, but perhaps more flexible?

@wsanchez
Copy link

@dstufft Perhaps #393 would be sufficient?

@nickwilliams-eventbrite
Copy link

I envision something like this:

@attr.s
class MyClass:
    contents = attr.ib(all_args=True)

    # all further attr.ibs must be keyword args because `contents` has consumed all positional args
    description = attr.ib(default=None)

This would be especially useful in our Conformity, which makes heavy use of Attrs but has to skip Attrs in certain places because we need to support star-args.

@oakkitten
Copy link

oakkitten commented Jun 24, 2020

So because writing Python decorators is my idea of fun, I made this proof of concept:

@star_attrs(auto_attribs=True)
class Foo:
    foo: str
    args: Tuple[str, ...] = star_attrib()
    bar: int = attrib(kw_only=True, factory=int)
    kwargs: Mapping[str, int] = star_attrib(kw_only=True)

Which can be used like this:

>>> Foo?
Init signature: Foo(foo: str, *args: str, bar: int = NOTHING, **kwargs: int) -> None
...
>>> Foo("foo", "bar", "baz", bar=1, baz=2, qux=3)
Foo(foo='foo', args=('bar', 'baz'), bar=1, kwargs={'baz': 2, 'qux': 3})

Note the correct signature and types! Also the errors are sensible:

>>> Foo(bar=2)
Traceback (most recent call last):
  ...
TypeError: __init__() missing 1 required positional argument: 'foo'

Both star_attrs and star_attrib behave exactly like their original conterparts, except ValueError is raised if you pass a default value (default=... or factory=...) or init=False to star_attrib. These arguments make no sense here and this is why I think that it might be better to have another marker such as star_attrib, instead of cramming it all into attrib.

Another question is how converters and validators should work. In my code nothing special happens:

>>> @star_attrs
... class Foo:
...     args = star_attrib(converter=set)
...
>>> Foo(1, 2, 3, 2)
Foo(args={1, 2, 3})

But an argument can be made for applying converters & friends individually.

Yet another question is typing. Can we convert between “collected” argument type and init signature annotations? If you look at regular Python functions, it appears that args and kwargs in def foo(*args: str, **kwargs: int) are Tuple[str, ...] and Dict[str, int]. My approach here is to detect only these two types and “supertypes” and convert if present, and strip annotations otherwise. For instance, for star_attrib(), these annotations will work and will produce *args: str:

  • Tuple[str, ...]
  • Sequence[str]
  • Iterable[str]

And these won't and produce *args:

  • Tuple (duh)
  • Tuple[str]
  • Tuple[str, str]
  • MutableSequence[str]
  • str

Since attrs strip annotations from attrib-s that have converters, this should work without surprises. Although, some useful converters such as list could be detected.

Another small issue is, what to do given something like this:

@star_attrs
class Foo:
    args = star_attrib()
    bar = attrib()
    kwargs = star_attrib(kw_only=True)

At first glance, it looks like the signature of this would be (*args, bar, **kwargs) -> None, which is reasonable. *args, however, will eat up all positional arguments, which makes bar keyword-only. I think it's reasonable to require kw_only=True here. In my code, i don't do anything special and let inspect produce an error when the above code is run, which is pretty reasonable:

Traceback (most recent call last)
  ...
ValueError: wrong parameter order: variadic positional parameter before positional or keyword parameter

A case can be made for adding kw_only=True automatically, however, as if you are using auto_attrib=True, you will have to slap on = attrib(kw_only=True) on all attributes followin star_attrib().

This also works with subclasses without surprises.

I'm not sure what to do if the type is something along Tuple['Zoo', ...] (or perhaps 'Sequence[Zoo]'), where Zoo can't be accessed directly. It might be reasonable to expect Zoo to be defined at the time of “starrifying” or it might not. get_type_hints() will attempt to resolve Zoo. It's possible to write something along Tuple['"Zoo"', ...] (or 'Tuple["Zoo", ...]'); this will get evaluated to typing.Tuple[ForwardRef('Zoo')] from which signature (*args: 'Zoo') -> None can be constructed. But this sounds a bit crazy.


Full code and tests

P.S. It's not possible to typecheck this in a nice way, but a workaround is possible.

P.P.S. it would be nice if the team decided on the API, then someone perhaps could write a PR~

@graingert
Copy link
Contributor

I'd also like to see something like this:

import attr

@attr.s
class MyClass:
    ham = attr.ib()
    spam = attr.ib()
    _= attr.ib(init="/")
    eggs = attr.ib()
    _ = attr.ib(init="*")
    bacon = attr.ib()
    kwargs = attr.ib(init="**")

would produce a class like:

class MyClass:
    def __init__(self, ham, spam, /, eggs, *, bacon, **kwargs):

@graingert
Copy link
Contributor

graingert commented Jul 23, 2020

I've received an anonymous suggestion to add:

isk = functools.partial(ib, init="*")

To allow _ = attr.isk()

@oakkitten
Copy link

oakkitten commented Jul 24, 2020

this seems to be possible; this:

class PrintingDict(dict):
    def __setitem__(self, key, value):
        print(f"setting {key} to {value}")
        super().__setitem__(key, value)

class DefPrinter(type):
    @classmethod
    def __prepare__(metacls, name, bases, **kwargs):
        return PrintingDict()

    def __new__(metacls, name, bases, sad):
        return super().__new__(metacls, name, bases, dict(sad))

def asterisk():
    import inspect
    frame = inspect.currentframe()
    frame.f_back.f_locals["asterisk"] = True

def slash():
    import inspect
    frame = inspect.currentframe()
    frame.f_back.f_locals["slash"] = True

class Foo(metaclass=DefPrinter):
    a = attrib()
    asterisk()
    c = attrib()
    slash()
    d = attrib(kw_only=True)

will print

setting __module__ to __main__
setting __qualname__ to Foo
setting a to _CountingAttr(...)
setting asterisk to True
setting c to _CountingAttr(...)
setting slash to True
setting d to _CountingAttr(...)

but i would perhaps prefer something like...

from attr import attr, attrs
from attr import positional_only, keyword_only
from star_attr import star_attrib

@attrs
class Foo:
    with positional_only:
        ham = attrib()
        spam = attrib()

    eggs = attrib()

    with keyword_only:
        bacon = attrib()
        kwargs = star_attrib()

this probably would also require a metaclass locals trickery but i'm not sure

@hynek
Copy link
Member

hynek commented Jan 26, 2021

The original issue is now easy enough and doesn't require extra syntax:

In [1]: import attr

In [2]: @attr.define
   ...: class C:
   ...:     foobars = attr.ib()
   ...:
   ...:     def __init__(self, *args):
   ...:         self.__attrs_init__(args)
   ...:

In [3]: C(1,2,3)
Out[3]: C(foobars=(1, 2, 3))

Writing a helper for that should be trivial.

@hynek hynek closed this as completed Jan 26, 2021
@oakkitten
Copy link

this has always been easy enough with classmethods:

class Foo:
    @classmethod
    def from_list(cls, *list_of_data):
        return cls(list_of_data)

what advantage does __attrs_init__ have over this?

this issue was about not having to write methods like this. also, a big point of star-arg-aware classes is that they can be subclassed without having having to override constructors, and i don't think __attrs_init__ helps there

@gwerbin
Copy link

gwerbin commented Aug 17, 2021

Another use case where direct support for star-args and star-kwargs would be really elegant:

from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from collections.abc import Sequence
    from typing import AnyStr, Generic, TypeVar
    from typing_extensions import ParamSpec, Concatenate as ParamConcat
    _P = ParamSpec("_P")
    _A = TypeVar("_A")


class LogConverter(Generic[_A]):
    value: _A
    converter: Callable[ParamConcat[_A, P], str]
    converter_args: Sequence[object]
    converter_kwargs: dict[str, object]

    def __init__(
        self,
        value: _A,
        converter: Callable[ParamConcat[_A, _P], str],
        *converter_args: _P.args,
        **converter_kwargs: _P.kwargs,
    ):
        self.value = value
        self.converter = converter
        self.converter_args = converter_args
        self.converter_kwargs = converter_kwargs

    def __str__(self) -> str:
        return self.converter(value, *self.converter_args, **self.converter_kwargs)

A hypothetical Attrs version could be:

from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from collections.abc import Sequence
    from typing import AnyStr, Generic, TypeVar
    from typing_extensions import ParamSpec, Concatenate as ParamConcat
    _P = ParamSpec("_P")
    _A = TypeVar("_A")

import attr


@attr.define
class LogConverter(Generic[_A]):
    value: _A
    converter: Callable[ParamConcat[_A, _P], str]
    converter_args: Sequence[object] = attr.variadic.args()
    converter_kwargs: dict[str, object] = attr.variadic.kwargs()

    def __str__(self) -> str:
        return self.converter(value, *self.converter_args, **self.converter_kwargs)

There is actually downside here, in that you lose the ability to annotate the converter_args with the ParamSpec (see https://www.python.org/dev/peps/pep-0612/#id2).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

8 participants