astral-sh/ruff

[C416 unnecessary-comprehension] Suggests a fix leading to RecursionError

Closed

#13752 opened on Oct 14, 2024

View on GitHub
 (3 comments) (0 reactions) (1 assignee)Rust (47,527 stars) (2,088 forks)batch import
documentationhelp wanted

Description

Rule unnecessary-comprehension (C416) suggests replacing comprehensions by the constructor instead. However, in some cases, when the replacement is done in code inside or called by __len__, it causes a recursion error.

After searching a bit on SO, the list constructor calls __len__ as an optimization when available, https://stackoverflow.com/questions/41474829/why-does-list-ask-about-len https://github.com/python/cpython/blob/67f6e08147bc005e460d82fcce85bf5d56009cf5/Objects/listobject.c#L1164

What I would expect:

  • (Easier) A mention of general cases where the rule is unsafe in the docs, rather than suggesting that the rule is unsafe only because of comments possibly lost
  • (Harder, but not absolutely required) Track if the change to calling list constructor is done inside the __len__ method of the same object that the list is called on.
  • (Hardest) Also track through function calls, not only inside the same method.

List of keywords searched:

C416, list len (all open and open closed issues for all keywords)

A stripped-down repro:

  • File is named: c416_recursionerror.py
  • The command you invoked: ruff check --isolated c416_recursionerror.py --select C416 --fix --unsafe-fixes

Take note that some other instances of the same problem in our project is wrapping some wxPython, not files, and can't directly be reshaped in a pythonic way easily, in order to store the items in the class and cache the length; it needs to iterate. Stripped down an easier case here, but the others had def __len__(self): return len([layer for layer in self]) but I couldn't isolate it.

from abc import abstractmethod
from os import listdir
from os.path import join, isdir
import fnmatch


def is_valid(value, path, type):
    """Private function to check the correctness of a value."""
    return True


def return_bool(argument):
    return True


def always_true_for_repro():
    return True


class Mapset:
    def __init__(self, mapset="", location="", gisdbase=""):
        self.gisdbase = gisdbase
        self.location = location
        self.name = mapset


class LocationBase:
    """Location object ::

        >>> from grass.script.core import gisenv
        >>> location = Location()
        >>> location                                      # doctest: +ELLIPSIS
        Location(...)
        >>> location.gisdbase == gisenv()['GISDBASE']
        True
        >>> location.name == gisenv()['LOCATION_NAME']
        True

    ..
    """

    def __init__(self, location="", dbase_path="."):
        self.name = location
        self.path = join(dbase_path, self.name)

    # def __getitem__(self, mapset):
    #     if mapset in self.mapsets():
    #         return Mapset(mapset)
    #     raise KeyError("Mapset: %s does not exist" % mapset)

    def __iter__(self):
        lpath = self.path
        return (
            m
            for m in listdir(lpath)
            if (
                always_true_for_repro()
                or (isdir(join(lpath, m)) and is_valid(m, lpath, "MAPSET"))
            )
        )

    def __len__(self):
        return len(self.mapsets())

    @abstractmethod
    def mapsets(self, pattern=None, permissions=True) -> list[str]:
        """Return a list of the available mapsets.

        :param pattern: the pattern to filter the result
        :type pattern: str
        :param permissions: check the permission of mapset
        :type permissions: bool
        :return: a list of mapset's names
        :rtype: list of strings

        ::

            >>> location = Location()
            >>> sorted(location.mapsets())                # doctest: +ELLIPSIS
            [...]

        """


class LocationOk(LocationBase):
    def mapsets(self, pattern=None, permissions=True):
        mapsets = [mapset for mapset in self]  # Should add noqa: C416
        # mapsets = list(self)  # Causes RecursionError
        if permissions:
            mapsets = [mapset for mapset in mapsets if return_bool(mapset)]
        if pattern:
            return fnmatch.filter(mapsets, pattern)
        return mapsets


class LocationC416(LocationBase):
    def mapsets(self, pattern=None, permissions=True):
        # mapsets = [mapset for mapset in self]  # Should add noqa: C416
        mapsets = list(self)  # Causes RecursionError
        if permissions:
            mapsets = [mapset for mapset in mapsets if return_bool(mapset)]
        if pattern:
            return fnmatch.filter(mapsets, pattern)
        return mapsets


if __name__ == "__main__":
    print("in main")
    a = LocationOk()
    b = LocationC416()
    print(f"a is: {a!r}")
    print(f"a is: {b!r}")
    print(a.path)
    print(b.path)
    print(len(a))
    print(len(b))
    print("end main")

Ruff version:

$ ruff --version
ruff 0.6.9

Contributor guide