diff mbox series

[libgpiod,07/22] bindings: python: fix Chip union-attr type errors

Message ID 20240927-vfazio-mypy-v1-7-91a7c2e20884@xes-inc.com
State New
Headers show
Series bindings: python: conform to mypy and ruff linter recommendations | expand

Commit Message

Vincent Fazio Sept. 27, 2024, 6:53 p.m. UTC
Since `Chip._chip` can be `None`, it's necessary to inform type checkers
of the state of the object to silence the union-attr errors.

Type checkers may not be able to infer that an object is not `None` from
an earlier call (such as `_check_closed`).

Instead of littering the code with "# type: ignore" comments, use casts
to inform type checkers that objects are not `None`.

Using `assert` is another option, however this duplicates the logic in
`_check_closed` so is redundant at best and, at worst, is not a safe
replacement as `assert` can be elided in optimized Python environments
and these checks need to be runtime enforced.

Signed-off-by: Vincent Fazio <vfazio@xes-inc.com>
---
 bindings/python/gpiod/chip.py | 11 ++++++++++-
 1 file changed, 10 insertions(+), 1 deletion(-)

Comments

Bartosz Golaszewski Oct. 8, 2024, 1:16 p.m. UTC | #1
On Fri, Sep 27, 2024 at 8:57 PM Vincent Fazio <vfazio@xes-inc.com> wrote:
>
> Since `Chip._chip` can be `None`, it's necessary to inform type checkers
> of the state of the object to silence the union-attr errors.
>
> Type checkers may not be able to infer that an object is not `None` from
> an earlier call (such as `_check_closed`).
>
> Instead of littering the code with "# type: ignore" comments, use casts
> to inform type checkers that objects are not `None`.
>
> Using `assert` is another option, however this duplicates the logic in
> `_check_closed` so is redundant at best and, at worst, is not a safe
> replacement as `assert` can be elided in optimized Python environments
> and these checks need to be runtime enforced.
>
> Signed-off-by: Vincent Fazio <vfazio@xes-inc.com>
> ---
>  bindings/python/gpiod/chip.py | 11 ++++++++++-
>  1 file changed, 10 insertions(+), 1 deletion(-)
>
> diff --git a/bindings/python/gpiod/chip.py b/bindings/python/gpiod/chip.py
> index 4aa5677f94caf8c5d863aa6d75915a5b650de137..fe7bcfe082d6e9f6220093d3fc45ff232b5d0d17 100644
> --- a/bindings/python/gpiod/chip.py
> +++ b/bindings/python/gpiod/chip.py
> @@ -7,7 +7,7 @@ from collections import Counter
>  from datetime import timedelta
>  from errno import ENOENT
>  from types import TracebackType
> -from typing import Optional, Union
> +from typing import Optional, Union, cast
>
>  from . import _ext
>  from ._internal import poll_fd
> @@ -97,6 +97,7 @@ class Chip:
>          longer be used after this method is called.
>          """
>          self._check_closed()
> +        self._chip = cast(_ext.Chip, self._chip)
>          self._chip.close()
>          self._chip = None
>

Ok, so I don't really understand what is happening here. We're going
to re-assign _chip in every function? What happens to cast() if _chip
IS None?

Bart
Vincent Fazio Oct. 8, 2024, 2:57 p.m. UTC | #2
On Tue, Oct 8, 2024 at 8:16 AM Bartosz Golaszewski <brgl@bgdev.pl> wrote:
>
>
> Ok, so I don't really understand what is happening here. We're going
> to re-assign _chip in every function? What happens to cast() if _chip
> IS None?

In this scenario, self._chip cannot be None. The self._check_closed() guard
ensures this, however, type checkers (at least mypy) cannot infer that from the
current code pattern.

`cast` is informing the type checker that past this point, self._chip should
not be considered as None and it's safe to reference attributes off the object

This seemed like the cleanest alternative, though others are:

* use a local variable for the cast result. This may be less confusing but
  incurs more changed lines

    self._check_closed()
    chip = cast(_ext.Chip, self._chip)
    return chip.path

* use asserts. These aren't always enforced during runtime so we cannot replace
  _check_closed but it does inform the type checker that it can narrow the type.
  Using both is ok, just slightly redundant.

    self._check_closed()
    assert self._chip is not None
    return self._chip.path

* add a `if self._chip is None` check that duplicates _check_closed
(or replace it completely)

* other "creative" solutions like a wrapper that returns a Chip or
throws an exception if it's None.

    def _chip_or_exc(self) -> Chip:
        if self._chip is None:
            raise Exception
        return self._chip

    chip = self._chip_or_exc()
    return chip.path

>
> Bart
Bartosz Golaszewski Oct. 8, 2024, 3:46 p.m. UTC | #3
On Tue, Oct 8, 2024 at 4:57 PM Vincent Fazio <vfazio@gmail.com> wrote:
>
> On Tue, Oct 8, 2024 at 8:16 AM Bartosz Golaszewski <brgl@bgdev.pl> wrote:
> >
> >
> > Ok, so I don't really understand what is happening here. We're going
> > to re-assign _chip in every function? What happens to cast() if _chip
> > IS None?
>
> In this scenario, self._chip cannot be None. The self._check_closed() guard
> ensures this, however, type checkers (at least mypy) cannot infer that from the
> current code pattern.
>
> `cast` is informing the type checker that past this point, self._chip should
> not be considered as None and it's safe to reference attributes off the object
>
> This seemed like the cleanest alternative, though others are:
>
> * use a local variable for the cast result. This may be less confusing but
>   incurs more changed lines
>
>     self._check_closed()
>     chip = cast(_ext.Chip, self._chip)
>     return chip.path
>

For the sake of readability, I would lean more towards this option if
I'm honest.

Or even - if you need to use the cast variable only once:

self._check_closed()
return cast(_ext.Chip, self._chip).path

?

> * use asserts. These aren't always enforced during runtime so we cannot replace
>   _check_closed but it does inform the type checker that it can narrow the type.
>   Using both is ok, just slightly redundant.
>
>     self._check_closed()
>     assert self._chip is not None
>     return self._chip.path
>

Yeah, this isn't optimal.

> * add a `if self._chip is None` check that duplicates _check_closed
> (or replace it completely)
>

Yep, no.

> * other "creative" solutions like a wrapper that returns a Chip or
> throws an exception if it's None.
>
>     def _chip_or_exc(self) -> Chip:
>         if self._chip is None:
>             raise Exception
>         return self._chip
>
>     chip = self._chip_or_exc()
>     return chip.path
>

Ugh. I see, cast() is the best solution.

Please consider the small change I suggested.

Bart
diff mbox series

Patch

diff --git a/bindings/python/gpiod/chip.py b/bindings/python/gpiod/chip.py
index 4aa5677f94caf8c5d863aa6d75915a5b650de137..fe7bcfe082d6e9f6220093d3fc45ff232b5d0d17 100644
--- a/bindings/python/gpiod/chip.py
+++ b/bindings/python/gpiod/chip.py
@@ -7,7 +7,7 @@  from collections import Counter
 from datetime import timedelta
 from errno import ENOENT
 from types import TracebackType
-from typing import Optional, Union
+from typing import Optional, Union, cast
 
 from . import _ext
 from ._internal import poll_fd
@@ -97,6 +97,7 @@  class Chip:
         longer be used after this method is called.
         """
         self._check_closed()
+        self._chip = cast(_ext.Chip, self._chip)
         self._chip.close()
         self._chip = None
 
@@ -108,6 +109,7 @@  class Chip:
           New gpiod.ChipInfo object.
         """
         self._check_closed()
+        self._chip = cast(_ext.Chip, self._chip)
 
         if not self._info:
             self._info = self._chip.get_info()
@@ -132,6 +134,7 @@  class Chip:
           so - returns it.
         """
         self._check_closed()
+        self._chip = cast(_ext.Chip, self._chip)
 
         if not isinstance(id, int):
             try:
@@ -154,6 +157,7 @@  class Chip:
 
     def _get_line_info(self, line: Union[int, str], watch: bool) -> LineInfo:
         self._check_closed()
+        self._chip = cast(_ext.Chip, self._chip)
         return self._chip.get_line_info(self.line_offset_from_id(line), watch)
 
     def get_line_info(self, line: Union[int, str]) -> LineInfo:
@@ -192,6 +196,7 @@  class Chip:
             Offset or name of the line to stop watching.
         """
         self._check_closed()
+        self._chip = cast(_ext.Chip, self._chip)
         return self._chip.unwatch_line_info(self.line_offset_from_id(line))
 
     def wait_info_event(
@@ -226,6 +231,7 @@  class Chip:
           This function may block if there are no available events in the queue.
         """
         self._check_closed()
+        self._chip = cast(_ext.Chip, self._chip)
         return self._chip.read_info_event()
 
     def request_lines(
@@ -258,6 +264,7 @@  class Chip:
           New LineRequest object.
         """
         self._check_closed()
+        self._chip = cast(_ext.Chip, self._chip)
 
         line_cfg = _ext.LineConfig()
 
@@ -362,6 +369,7 @@  class Chip:
         Filesystem path used to open this chip.
         """
         self._check_closed()
+        self._chip = cast(_ext.Chip, self._chip)
         return self._chip.path
 
     @property
@@ -370,4 +378,5 @@  class Chip:
         File descriptor associated with this chip.
         """
         self._check_closed()
+        self._chip = cast(_ext.Chip, self._chip)
         return self._chip.fd