Skip to content

[WIP] Add some typing hints for module_utils - #85260

Draft
felixfontein wants to merge 1 commit into
ansible:develfrom
felixfontein:typing
Draft

[WIP] Add some typing hints for module_utils#85260
felixfontein wants to merge 1 commit into
ansible:develfrom
felixfontein:typing

Conversation

@felixfontein

Copy link
Copy Markdown
Contributor
SUMMARY

Adds some typing hints for module_utils.

I was originally providing more, but didn't manage to get all of them into a good shape. For example, strict_optional = False prevents proper overloads for AnsibleModule.run_command() since the return types depend on whether encoding is None or a string; with strict_optional = False mypy always complains that the overloads do not differ since str includes None.

Not sure how to properly classify this (bugfix, feature, ...), for that reason I also didn't add a changelog fragment yet.

Right now the PR contains #85259, for that reason I've marked it WIP.

ISSUE TYPE
  • Refactoring Pull Request

@ansibot ansibot added the needs_triage Needs a first human triage before being processed. label Jun 4, 2025
Comment thread lib/ansible/galaxy/collection/__init__.py Outdated
Comment on lines +408 to +409
self._legal_inputs: list = [] # no longer used?
self._options_context: list = list() # no longer used?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Aren't these inferred? This is only useful if you add an item type.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

mypy complains about them once it checks the constructor 🤷 I guess it would prefer to have list[xxx] for some xxx, but can't figure out what xxx is.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Oh, right, it can't infer element types from empty containers.
Since it seems to be unused, why not make it always-empty in typing?

Suggested change
self._legal_inputs: list = [] # no longer used?
self._options_context: list = list() # no longer used?
self._legal_inputs: list[t.Never] = [] # no longer used?
self._options_context: list[t.Never] = [] # no longer used?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

t.Never cannot be used in module utils.

Comment thread lib/ansible/module_utils/basic.py Outdated
Comment thread lib/ansible/module_utils/basic.py Outdated
Comment thread lib/ansible/module_utils/basic.py Outdated
self.run_command_environ_update = {}
self._clean = {}
self.run_command_environ_update: dict[str, str] = {}
self._clean: dict | str | None = {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't see any place in the module that treats this like a dict. The default is an abomination… This could probably be

Suggested change
self._clean: dict | str | None = {}
self._clean: str | None = None

instead.

Alternatively, we could allow an empty dict only:

Suggested change
self._clean: dict | str | None = {}
self._clean: dict[t.Never, t.Never] | str | None = {}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think the empty dict is the better choice for now. I'd prefer if this PR avoids behavior changes when not strictly necessary to pass type checking.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So use the second suggestion, only allowing it to be empty on the typing level.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

t.Never is only supported for Python 3.11+. module utils typing must pass with Python versions from 3.9 to 3.13. So the second suggestion will not work.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Older versions represent the same as typing.NoReturn. So this should work then:

Suggested change
self._clean: dict | str | None = {}
self._clean: dict[t.NoReturn, t.NoReturn] | str | None = {}

Alternatively, we could check if typing-extensions has it.

self.required_if = required_if
self.required_by = required_by
self.cleanup_files = []
self.cleanup_files: list[str | bytes | os.PathLike] = []

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this should be something like

Suggested change
self.cleanup_files: list[str | bytes | os.PathLike] = []
self.cleanup_files: list[bytes] | list[str] | list[os.PathLike[bytes]] | list[os.PathLike[str]] = []

without a union type for the items.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Uhm, why? You can add all four different types during the same module run.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

But probably shouldn't? So why not make MyPy encourage the end-users to use same types?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You cannot do that with Python typing. Which type would you add to AnsibleModule.add_cleanup_file()'s path parameter then?

Comment thread lib/ansible/module_utils/basic.py Outdated
Comment on lines 371 to 372
mutually_exclusive=None, required_together=None,
required_one_of=None, add_file_common_args=False,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

add typing to these too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I forgot about add_file_common_args, but I didn't want to add types for the others since they tend to be very complicated (you can use lists or tuples, and elements can again be lists and tuples). Also I didn't want endless discussions about these types to delay this PR.

@bcoca bcoca Jun 4, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I've been toying with adding module_utils/common/typing and adding some 'custom complex types' for places were we take 'some iterables or string that can be , separated' and such, PATH is a big one as it can be unicode/bytes/file/Pathlib object/None
in many places

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Suggested change
mutually_exclusive=None, required_together=None,
required_one_of=None, add_file_common_args=False,
mutually_exclusive=None, required_together=None,
required_one_of=None, add_file_common_args: bool = False,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I've been toying with adding module_utils/common/typing and adding some 'custom complex types' for places were we take 'some iterables or string that can be , separated' and such, PATH is a big one as it can be unicode/bytes/file/Pathlib object/None

I think that would be a very good idea!

@felixfontein

Copy link
Copy Markdown
Contributor Author

BTW, one of the main motivations to start with this was adding typing to to_bytes and to_text, since these functions are pretty common and their return type is totally clear to humans, but not to mypy. AnsibleModule.run_command is another case where typing would be really helpful, but without being able to add proper overloads I guess typing there does more harm than it does good, since you need to add explicit casts to a million places as a result.

Comment thread lib/ansible/module_utils/basic.py Outdated
@bcoca

bcoca commented Jun 4, 2025

Copy link
Copy Markdown
Member

to_text/to_bytes should be going away, they were created for py2/py3 compatibility but they also did 'weird' things like passthrough non text types, depending on options. At this point they should be obsolete and either removed or just use str instead ... depending on use case.

@ansibot ansibot added the needs_revision This PR fails CI tests or a maintainer has requested a review/revision of the PR. label Jun 4, 2025
@mattclay mattclay removed the needs_triage Needs a first human triage before being processed. label Jun 5, 2025
return self.digest_from_file(filename, 'sha256')

def backup_local(self, fn):
def backup_local(self, fn: str | os.PathLike[str]) -> str:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this accept other filename variants? There's no runtime checks for non-bytes, after all.

Suggested change
def backup_local(self, fn: str | os.PathLike[str]) -> str:
def backup_local(self, fn: bytes | str | os.PathLike[bytes] | os.PathLike[str]) -> str:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

There isn't, but the filename of the backup will be totally screwed up if you pass bytes here:

backupdest = '%s.%s.%s' % (fn, os.getpid(), ext)

You'd a file named b'original_filename'.123.01-02-2023@01:02:03~.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

fstrings, then !r should fix it either way

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That only makes the situation worse for str-based paths. If you want to fix it you need to use to_text/to_native, or use to_bytes and use bytes instead of str for backupdest.

Comment on lines +29 to +56
_ErrorHandlers = _t.Literal[
# Composed error handlers, see above:
"surrogate_or_replace",
"surrogate_or_strict",
"surrogate_then_replace",
# Error handlers from https://docs.python.org/3/library/codecs.html#codec-base-classes:
"strict",
"ignore",
"replace",
"backslashreplace",
"surrogateescape",
"xmlcharrefreplace",
"namereplace",
"surrogatepass",
]

def to_bytes(obj, encoding='utf-8', errors=None, nonstring='simplerepr'):
_NonString = _t.Literal[
"simplerepr",
"empty",
"passthru",
"strict",
]

_NonStringNotPassthru = _t.Literal[
"simplerepr",
"empty",
"strict",
]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can these be turned into type aliases?

Suggested change
_ErrorHandlers = _t.Literal[
# Composed error handlers, see above:
"surrogate_or_replace",
"surrogate_or_strict",
"surrogate_then_replace",
# Error handlers from https://docs.python.org/3/library/codecs.html#codec-base-classes:
"strict",
"ignore",
"replace",
"backslashreplace",
"surrogateescape",
"xmlcharrefreplace",
"namereplace",
"surrogatepass",
]
def to_bytes(obj, encoding='utf-8', errors=None, nonstring='simplerepr'):
_NonString = _t.Literal[
"simplerepr",
"empty",
"passthru",
"strict",
]
_NonStringNotPassthru = _t.Literal[
"simplerepr",
"empty",
"strict",
]
_ErrorHandlers: _t.TypeAlias = _t.Literal[
# Composed error handlers, see above:
"surrogate_or_replace",
"surrogate_or_strict",
"surrogate_then_replace",
# Error handlers from https://docs.python.org/3/library/codecs.html#codec-base-classes:
"strict",
"ignore",
"replace",
"backslashreplace",
"surrogateescape",
"xmlcharrefreplace",
"namereplace",
"surrogatepass",
]
_NonString: _t.TypeAlias = _t.Literal[
"simplerepr",
"empty",
"passthru",
"strict",
]
_NonStringNotPassthru: _t.TypeAlias = _t.Literal[
"simplerepr",
"empty",
"strict",
]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

TypeAlias has been added in Python 3.10, so it won't work with Python 3.9.

(I don't really understand why type checking needs to work with every single Python version supported on the target anyway. After all there's from __future__ import annotations...)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It needs to work because people would be writing target code against those Python versions 🤷‍♂️

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Get it from typing-extensions, then: https://typing-extensions.readthedocs.io/en/latest/#typing_extensions.TypeAlias. Perhaps, it's even better to consider NewType instead.



def jsonify(data, **kwargs):
def jsonify(data: _t.Any, **kwargs) -> str:

@webknjaz webknjaz Jun 9, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's use

Suggested change
def jsonify(data: _t.Any, **kwargs) -> str:
def jsonify(data: object, **kwargs: object) -> str:

if possible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think it's a bad idea to add types that are wrong. Acceptable keyword arguments like skipkeys, ensure_ascii, check_circular, ... accept values of specific types, and not of type object. I think in such situations it's better to not add types so places like this which need more work are easier to find.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It's best to use a TypedDict or better mechanisms for kwargs. But object is almost always better than Any. Everything in Python inherits from object. So it's always correct (unless attributes/interfaces that are present in more specific subtypes are accessed).

https://mypy.readthedocs.io/en/stable/dynamic_typing.html#any-vs-object



def container_to_bytes(d, encoding='utf-8', errors='surrogate_or_strict'):
def container_to_bytes(d: _t.Any, encoding: str = 'utf-8', errors: _ErrorHandlers = 'surrogate_or_strict') -> _t.Any:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It looks like this is

Suggested change
def container_to_bytes(d: _t.Any, encoding: str = 'utf-8', errors: _ErrorHandlers = 'surrogate_or_strict') -> _t.Any:
def container_to_bytes(d: str | dict[bytes | str, bytes | str] | list[bytes | str] | tuple[bytes | str] | object, encoding: str = 'utf-8', errors: _ErrorHandlers = 'surrogate_or_strict') -> dict[bytes, bytes] | list[bytes] | tuple[bytes] | object:

@felixfontein felixfontein Jun 9, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Why? It also accepts all other types (and simply passes them through). Maybe it should be object instead of _t.Any, but I don't think it should be anything else. (Unless we have some specific type for JSON values.)

I missed the object.

But then, still: why? These expressions can be both simplified to object. So you'd have def container_to_bytes(d: object, ...) -> object:.

@webknjaz webknjaz Aug 28, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

object isn't equvivalent to a union of more accurate types as those allow more behaviors than just object. I'm not entirely sure that it'd work. But you could try. Not sure about the return type, though. Returning object is very broad. The arg types should be broad but return types should be narrow.



def container_to_text(d, encoding='utf-8', errors='surrogate_or_strict'):
def container_to_text(d: _t.Any, encoding: str = 'utf-8', errors: _ErrorHandlers = 'surrogate_or_strict') -> _t.Any:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
def container_to_text(d: _t.Any, encoding: str = 'utf-8', errors: _ErrorHandlers = 'surrogate_or_strict') -> _t.Any:
def container_to_text(d: str | dict[bytes | str, bytes | str] | list[bytes | str] | tuple[bytes | str] | object, encoding: str = 'utf-8', errors: _ErrorHandlers = 'surrogate_or_strict') -> dict[str, str] | list[str] | tuple[str] | object:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same as above - why not simply use object instead of this more complex expression?

(Besides that, the types listed there are random subsets of what the function accepts and produces. For example {1: [2.0, "foo"]} is acceptable input and output as well.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

For example {1: [2.0, "foo"]} is acceptable input and output as well.

Yes, I think this would require a recursive type definition that isn't inline. Most of my suggestions would benefit from this.

@ansibot ansibot removed the needs_revision This PR fails CI tests or a maintainer has requested a review/revision of the PR. label Jun 9, 2025
@ansibot ansibot added the stale_ci This PR has been tested by CI more than one week ago. Close and re-open this PR to get it retested. label Jun 21, 2025
@ansibot ansibot added the needs_rebase https://docs.ansible.com/ansible/devel/dev_guide/developing_rebasing.html label Sep 11, 2025
@ansibot ansibot removed the needs_rebase https://docs.ansible.com/ansible/devel/dev_guide/developing_rebasing.html label Nov 30, 2025
@ansibot ansibot added needs_revision This PR fails CI tests or a maintainer has requested a review/revision of the PR. pending_ci and removed stale_ci This PR has been tested by CI more than one week ago. Close and re-open this PR to get it retested. labels Nov 30, 2025
@ansibot ansibot removed needs_revision This PR fails CI tests or a maintainer has requested a review/revision of the PR. pending_ci labels Dec 1, 2025
@ansibot ansibot added the stale_ci This PR has been tested by CI more than one week ago. Close and re-open this PR to get it retested. label Dec 15, 2025
@ansibot ansibot added the needs_rebase https://docs.ansible.com/ansible/devel/dev_guide/developing_rebasing.html label Mar 7, 2026
@ansibot ansibot added needs_revision This PR fails CI tests or a maintainer has requested a review/revision of the PR. pending_ci and removed needs_rebase https://docs.ansible.com/ansible/devel/dev_guide/developing_rebasing.html stale_ci This PR has been tested by CI more than one week ago. Close and re-open this PR to get it retested. needs_revision This PR fails CI tests or a maintainer has requested a review/revision of the PR. pending_ci labels Mar 13, 2026
@ansibot ansibot added the stale_ci This PR has been tested by CI more than one week ago. Close and re-open this PR to get it retested. label Mar 31, 2026
@ansibot ansibot added the needs_rebase https://docs.ansible.com/ansible/devel/dev_guide/developing_rebasing.html label Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs_rebase https://docs.ansible.com/ansible/devel/dev_guide/developing_rebasing.html stale_ci This PR has been tested by CI more than one week ago. Close and re-open this PR to get it retested.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants