Skip to content

Add stubs for python-decouple #10743

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

Open
wants to merge 36 commits into
base: main
Choose a base branch
from
Open

Add stubs for python-decouple #10743

wants to merge 36 commits into from

Conversation

markis
Copy link

@markis markis commented Sep 21, 2023

This is a small single file module which for the most has a main config function and a few supporting classes.

I used the docs as a first reference, since they contain some type hints and then verified it with the actual implementation.

The doc is available here: https://github.com/HBNetwork/python-decouple/blob/master/README.rst
Source code here: https://github.com/HBNetwork/python-decouple/blob/master/decouple.py

Comment on lines 77 to 93
class _CsvType(Protocol[_T, _TCsv]):
cast: Callable[..., _T]
delimiter: str
strip: str
post_process: Callable[..., _TCsv]
def __call__(self, value: str) -> _TCsv: ...

@overload
def Csv() -> _CsvType[str, list[str]]: ...
@overload
def Csv(cast: Callable[..., _T], delimter: str = ..., strip: str = ...) -> _CsvType[_T, list[_T]]: ...
@overload
def Csv(cast: Callable[..., _T], delimiter: str, strip: str, post_process: Callable[..., _TCsv]) -> _CsvType[_T, _TCsv]: ...
@overload
def Csv(
*, cast: Callable[..., _T], post_process: Callable[..., _TCsv], delimiter: str = ..., strip: str = ...
) -> _CsvType[_T, _TCsv]: ...
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is likely the most complex use case in this module. The Csv class exhibits default behavior that can be modified based on parameters passed into its __init__ method. By default, cast is set to str, and post_process is set to list. Therefore, if these parameters are explicitly defined, the types provided should be used. However, if they are not specified, the return type of the __call__ method should default to list[str].

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is a better way to express this, I am open to suggestions.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't looked deeply, but it sounds like you simply need a generic __init__ method.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@markis markis marked this pull request as ready for review September 21, 2023 18:45
@github-actions

This comment has been minimized.

Comment on lines 84 to 86
def __init__(
self, cast: Callable[..., _T] = str, delimiter: str = ..., strip: str = ..., post_process: Callable[..., _TCsv] = list[_T]
) -> None: ...
Copy link
Contributor

@Daverball Daverball Sep 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def __init__(
self, cast: Callable[..., _T] = str, delimiter: str = ..., strip: str = ..., post_process: Callable[..., _TCsv] = list[_T]
) -> None: ...
@overload
def __init__(
self: Csv[str, list[str]], cast: Callable[[str], str] = ..., delimiter: str = ",", strip: str = ..., post_process: Callable[[Iterable[str]], list[str]] = ...
) -> None: ...
@overload
def __init__(
self: Csv[_T, list[_T]], cast: Callable[[str], _T], delimiter: str = ",", strip: str = ..., post_process: Callable[[Iterable[_T]], list[_T]] = ...
) -> None: ...
) -> None: ...
@overload
def __init__(
self: Csv[str, _TCsv], cast: Callable[[str], str] = ..., delimiter: str = ",", strip: str = ..., *, post_process: Callable[Iterable[str], _TCsv]
) -> None: ...
@overload
def __init__(
self: Csv[_T, _TCsv], cast: Callable[[str], _T], delimiter: str, strip: str, post_process: Callable[[Iterable[_T]], _TCsv]

I think this should be something along these lines. The arguments of cast/post_process are very much defined by the implementation and since they are optional parameters you will need to provide overloads, because otherwise _T/_TCsv will not get bound.

It's also worth noting that you will need to import Iterable to make this suggestion work.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this suggestion! I did not realize you could suggest the types of _T and _Csv through the self argument. Really interesting stuff.

After I implemented your suggestions, I am getting some regressions on my code while using this type file. Would you know how to fix it? Also, I am wondering if the Iterable is having some unwanted side effects. Also, the errors I am seeing are coming from mypy, I haven't checked the other type checkers.

from decouple import Choices, Csv
from typing_extensions import reveal_type

# No error and correct type
reveal_type(Csv()(""))  # note: Revealed type is "builtins.list[builtins.str]"

# No error and correct type
reveal_type(Csv(cast=int)(""))  # note: Revealed type is "builtins.list[builtins.int]"

# error: Argument "post_process" to "Csv" has incompatible type "type[set[Any]]"; expected "Callable[[Iterable[str]], list[str]]"  [arg-type]
reveal_type(Csv(post_process=set)(""))  # note: Revealed type is "builtins.list[builtins.str]"

# error: Argument 1 to "Csv" has incompatible type "type[int]"; expected "Callable[[str], str]"  [arg-type]
# error: Argument 4 to "Csv" has incompatible type "type[tuple[Any, ...]]"; expected "Callable[[Iterable[str]], list[str]]"  [arg-type]#
reveal_type(Csv(int, ",", " ", tuple)(""))

# error: Argument "cast" to "Csv" has incompatible type "type[int]"; expected "Callable[[str], str]"  [arg-type]
# error: Argument "post_process" to "Csv" has incompatible type "type[tuple[Any, ...]]"; expected "Callable[[Iterable[str]], list[str]]"  [arg-type]
reveal_type(Csv(cast=int, post_process=tuple)(""))

# error: Argument "cast" to "Csv" has incompatible type "type[int]"; expected "Callable[[str], str]"  [arg-type]
# error: Argument "post_process" to "Csv" has incompatible type "type[tuple[Any, ...]]"; expected "Callable[[Iterable[str]], list[str]]"  [arg-type]
reveal_type(Csv(cast=int, delimiter=",", post_process=tuple)(""))

Copy link
Contributor

@Daverball Daverball Sep 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I am not surprised there are some cases where mypy can't infer this correctly, since there's quite some complex matching logic involved and the one type var technically depends on the other, however there is no real way to express this relationship between the two, it would require higher kinded type vars.

The main complication stems from using callables like set, tuple as arguments for post_process, since they are generics in themselves, if you use callables with fixed arguments, then it will work no problem.

I've played around a bit on mypy-play, it needed one more overload to cover all the cases correctly, I've also tried a callback protocol, but that just resulted in very strange behavior of inferring Csv(post_process=set) as Csv[Any, list[Any]] which is very incorrect.

This is the closest I could get, it's not perfect, since it results in unbound type vars for your examples, so if you try to use the result of __call__ mypy will tell you to specify the type, since it can't fully infer it itself. I've also had to give up on some amount of type safety for post_process and define the input as Iterable[Any] rather than Iterable[_T].
https://mypy-play.net/?mypy=latest&python=3.11&gist=8aace8121128e574dae382a18dd3fc02

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main complication stems from using callables like set, tuple as arguments for post_process, since they are generics in themselves, if you use callables with fixed arguments, then it will work no problem.

I was trying to work through that as well, and I have come to same conclusion. However, at least there is a solution.

This is the closest I could get, it's not perfect, since it results in unbound type vars for your examples, so if you try to use the result of call mypy will tell you to specify the type, since it can't fully infer it itself. I've also had to give up on some amount of type safety for post_process and define the input as Iterable[Any] rather than Iterable[_T]. https://mypy-play.net/?mypy=latest&python=3.11&gist=8aace8121128e574dae382a18dd3fc02

I think I have incorporated all of your updates into the PR. Thanks for the suggestions.

from typing import Any, Generic, TextIO, TypeVar, overload

_T = TypeVar("_T")
_TCsv = TypeVar("_TCsv", bound=Sequence[Any])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this upper bound is too strict, I would change this to Collection[Any] at the very least, but technically this can be anything and the docs also state as much (you could e.g. imagine someone calculating an integer hash from the individual values by providing post_process=lambda x: hash(tuple(x))) so I'm not sure if it's worth providing an upper bound.

Copy link
Author

@markis markis Sep 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, that's a great point! Updated!

Comment on lines 90 to 98
cast: Callable[..., _T]
choices: Sequence[_T]
def __init__(
self,
flat: Sequence[_T] | None = None,
cast: Callable[..., _T] = ...,
choices: Sequence[_T | tuple[Incomplete, _T]] | None = None,
) -> None: ...
def __call__(self, value: str) -> _T: ...
Copy link
Contributor

@Daverball Daverball Sep 21, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
cast: Callable[..., _T]
choices: Sequence[_T]
def __init__(
self,
flat: Sequence[_T] | None = None,
cast: Callable[..., _T] = ...,
choices: Sequence[_T | tuple[Incomplete, _T]] | None = None,
) -> None: ...
def __call__(self, value: str) -> _T: ...
cast: Callable[[str], _T]
choices: Sequence[tuple[_T, str]]
@overload
def __init__(
self: Choices[str],
flat: Sequence[str] | None = None,
cast: Callable[[str], str] = ...,
choices: Sequence[tuple[str, str]] | None = None,
) -> None: ...
@overload
def __init__(
self: Choices[_T],
flat: Sequence[_T],
cast: Callable[[str], _T] = ...,
choices: Sequence[tuple[_T, str]] | None = None,
) -> None: ...
@overload
def __init__(
self: Choices[_T],
flat: None,
cast: Callable[[str], _T],
choices: Sequence[tuple[_T, str]] | None = None,
) -> None: ...
@overload
def __init__(
self: Choices[_T],
flat: None = None,
cast: Callable[[str], _T] = ...,
*,
choices: Sequence[tuple[_T, str]],
) -> None: ...
def __call__(self, value: str) -> _T: ...

Same deal here, this takes some overloads to bind correctly and the parameters of cast are given, and choices does need to be a sequence of 2 value tuples, the second value of which is always a string.

Technically you could make this generic in the input value type as well, but since it is meant for dealing with environment variables, it probably makes sense to stick with str as all the examples have as well.

Copy link
Author

@markis markis Sep 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great! Thanks again for the suggestion.

I have one issue, it seems like when cast is used by itself mypy seems to get confused. Would you know of a way to fix that use case?

from decouple import Choices, Csv
from typing_extensions import reveal_type

# error: Argument "cast" to "Choices" has incompatible type "type[int]"; expected "Callable[[str], str]"  [arg-type]
reveal_type(Choices(cast=int)(""))

reveal_type(Choices(flat=[1, 2])("")) # note: Revealed type is "builtins.int"
reveal_type(Choices(choices=[(1, "one"), (2, "two")])("")) #note: Revealed type is "builtins.int"
reveal_type(Choices(cast=int, flat=[1, 2])("")) #note: Revealed type is "builtins.int"
reveal_type(Choices(cast=int, choices=[(1, "one"), (2, "two")])("")) #note: Revealed type is "builtins.int"
reveal_type(Choices(flat=[1, 2], choices=[(1, "one"), (2, "two")])("")) #note: Revealed type is "builtins.int"
reveal_type(Choices(cast=int, flat=[1, 2], choices=[(1, "one"), (2, "two")])("")) #note: Revealed type is "builtins.int"

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs one additional overload to cover that case:

    @overload
    def __init__(
        self: Choices[_T],
        flat: Sequence[_T] | None = None,
        *,
        cast: Callable[[str], _T],
        choices: Sequence[tuple[_T, str]] | None = None,
    ) -> None: ...

Copy link
Contributor

@Daverball Daverball Sep 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generic overload matching gets complicated really quickly if you want to cover every possible case on arguments other than the first one or two...

I wish overloads would let you violate the requirement of positional optional parameters having to come after all the required ones, it would get rid of a lot of redundant overloads, that have to currently be used in order to express both the positional and keyword version of the overload.

But that would of course require some changes to the language, since the parser would have to ignore these violations specifically only on overloads, the alternative would be to have some typing specific sentinel value that means the parameter is required in order to match that overload, but the parser can still treat it like an optional one. But I am getting off course here.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generic overload matching gets complicated really quickly if you want to cover every possible case on arguments other than the first one or two...

Yeah, I know what you mean.

I wish overloads would let you violate the requirement of positional optional parameters having to come after all the required ones, it would get rid of a lot of redundant overloads, that have to currently be used in order to express both the positional and keyword version of the overload.

But that would of course require some changes to the language, since the parser would have to ignore these violations specifically only on overloads, the alternative would be to have some typing specific sentinel value that means the parameter is required in order to match that overload, but the parser can still treat it like an optional one. But I am getting off course here.

TBH, this is how I thought it would work but the more I dug into making this type module, the more I realized that wasn't the case. But, now I know and thanks for the background!

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

I realized that Config doesn't really care if repository inherits from RepsositoryEmpty or not, but it just cares if it has `__contain__` and `__getitem__`
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

Also remove type safety on post_process, since it could be `_T` or it could be `str`
@github-actions

This comment has been minimized.

cast: Callable[[str], _T]
delimiter: str
strip: str
post_process: Callable[[Iterable[Any]], _TCsv]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the Any be _T as the post-processor gets passed whatever came out of cast?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that was my first suggestion, and we tried that, however mypy will then reject generic classes as a post_process since it can't seem to solve for both type vars simultaneously i.e. it will reject post_process=set but it would accept post_process=set[str].

I agree that this should work, but it's also a very challenging scenario for an overload solver, so I am not surprised mypy can't do it yet.

Copy link
Author

@markis markis Sep 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I add a comment that "it should be _T but mypy wasn't able to support it." So in the future, it's more obvious about the decision?

delimiter: str
strip: str
post_process: Callable[[Iterable[Any]], _TCsv]
def __call__(self, value: str) -> _TCsv: ...
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation also supports passing None. In that case, post_process is called with no arguments. Typing that accurately would probably require making a callback protocol for post_process.

Copy link
Contributor

@Daverball Daverball Sep 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, I glossed over that part in my review. I am however a little concerned how well a callback protocol will behave, considering mypy would match Csv(post_process=set) to Csv[Any, builtins.list[Any]] when I tried to replace it with a callback protocol. Changing the order of overloads, so the first one is fully generic, would probably solve that, but you would still end up with unsolved type vars. Although I am not sure I have tried a callback protocol with Any for the first argument, instead of _T, so maybe that will behave a bit better.

class Choices(Generic[_T]):
flat: Sequence[_T]
cast: Callable[[str], _T]
choices: Sequence[_T]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be Sequence[tuple[_T, object]]?

Also in the overloads below I think the second tuple member in choices should be object, as the implementation ignores it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, I had already suggested the same change for choices but I guess it fell victim to manual copy pasta.

While I do agree that the second member in the tuple is ignored and could be anything, I don't think it's a good change.
It expects that when cast is used on the second element in the tuple, for it to return the first element, so while the implementation is bogus and backwards and doesn't verify that, the stubs should probably reflect the intent and the intent was for the second element to be the string representation of the choice.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

This comment has been minimized.

This comment has been minimized.

This comment has been minimized.

This comment has been minimized.

This comment has been minimized.

This comment has been minimized.

@srittau
Copy link
Collaborator

srittau commented Feb 28, 2025

@JelleZijlstra Since you've already fixed some problem, do you want to close the remaining problems (__init__ -> __new__)? Otherwise I would close this PR.

Copy link
Contributor

According to mypy_primer, this change has no effect on the checked open source code. 🤖🎉

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

Successfully merging this pull request may close these issues.

4 participants