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

[lint] run mypy on tests/test_config/test_config.py #12198

Open
wants to merge 3 commits into
base: master
Choose a base branch
from

Conversation

picnixz
Copy link
Member

@picnixz picnixz commented Mar 25, 2024

When implementing #12196, I wanted to use mypy on it. So I did that. However, I observed that we are facing some issues (that I didn't think of before...)

  • Patched objects (i.e., those with mock.patch) are NonCallableMock objects. However, typing them as such makes PyCharm lose it's autocompletion... On the other hand, typing them as Any make mypy lose its purpose... (and note that NonCallableMock inherits from Any in typeshed).
  • The Config object does not support __setattr__ explicitly, although it should, making mypy raise a lot of attr-defined errors. This only happens if you type app as Sphinx. So... I'm not really sure we are gaining anything in the future if we need to either add an explicit __setattr__ or if we need to add # type: ignore everywhere...

I think we need to delay #12097 until we have a good idea on how to minimize the introduction of # type: ignore. The only good idea I can come up with, without destroying both autocompletion and mypy at the same time, is to implement a mypy plugin which would allow us bypassing type annotations at the level of the test itself. (plugin wouldn't solve the issue).

I had the habit to ask people to either not type or type everything in the function. However, with the limitations by mypy and IDEs, I feel we need to allow incomplete definitions (for tests at least).

cc @danieleades @chrisjsewell @jayaddison

@picnixz picnixz added the awaiting:decision PR waiting for a consensus from maintainers. label Mar 25, 2024
config = Config({}, {'copyright': conf_copyright})
correct_copyright_year(_app=None, config=config)
correct_copyright_year(..., config=config) # type: ignore[arg-type]
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggetion: we could try to unpick this a bit and make the interface, and then test case, cleaner. Perhaps correct_copyright_year should only accept a single argument, the app to check/correct copyright for?

@chrisjsewell
Copy link
Member

I had the habit to ask people to either not type or type everything in the function. However, with the limitations by mypy and IDEs, I feel we need to allow incomplete definitions (for tests at least).

yes, I would certainly suggest enforcing types less strictly in tests, since obviously these are only for our internal use rather than anything exposed to the public, and we don't want to making writing test to much of a burden 😅

@danieleades
Copy link
Contributor

  • The Config object does not support __setattr__ explicitly, although it should, making mypy raise a lot of attr-defined errors. This only happens if you type app as Sphinx. So... I'm not really sure we are gaining anything in the future if we need to either add an explicit __setattr__ or if we need to add # type: ignore everywhere...

i think adding and assuming the existence of properties on a foreign config object is a pattern which is fundamentally opposed to strict type checking.

i presume adding __getattr__ and __setattr__ won't remove all the ignores because they'll return Any...

A better mechanism (though a much larger architectural change) might be for plugins to somehow subclass Config and add their own properties that way

@picnixz
Copy link
Member Author

picnixz commented Mar 26, 2024

i presume adding getattr and setattr won't remove all the ignores because they'll return Any...

__getattr__ already exists but not __setattr__ actually (and yes the first returns Any as it should be)

A better mechanism (though a much larger architectural change) might be for plugins to somehow subclass Config and add their own properties that way

This one is feasible, but there is an issue with the fact that configuration objects behave as Any but raise an AttributeError upon non-existing attributes. However, the attributes are not known at compilation time since they are dynamically added via config.add (which does not require a literal string...). So, a plugin would "half-solve" the problem if the configuration values are literal strings.

If you want, it's like a types.SimpleNamespace object but with dynamic set of allowed properties. And honestly, the best typing for that is... Any (+ explicit interface on Config objects).

@chrisjsewell
Copy link
Member

A better mechanism (though a much larger architectural change) might be for plugins to somehow subclass Config and add their own properties that way
So, a plugin would "half-solve" the problem if the configuration values are literal strings.

Yeh i think these are beyond the scope of this PR,
but what I can tell you is that in myst-parser and sphinx-needs, I utilise a dataclass as the "source of truth" for extension configuration (with type checking): https://github.com/executablebooks/MyST-Parser/blob/dfaf6d7abfdd988c74a1d6e1bbdc637e9f5b5bcb/myst_parser/config/main.py#L181

Then I have a layer on top of sphinx to:

  1. call app.add_config_value for all variables
  2. add config value type validation (in a config-inited event)
  3. create the dataclass instance from the app.config, to access the variables
  4. Also have a custom directive that auto-generates the config documentation, ensuring it is always in-sync with the codebase 😄 : https://myst-parser.readthedocs.io/en/latest/configuration.html

I'd be intersting if any of this, at least conceptually, could be upstreamed to sphinx core

@picnixz
Copy link
Member Author

picnixz commented Mar 26, 2024

but what I can tell you is that in myst-parser and sphinx-needs, I utilise a dataclass as the "source of truth" for extension configuration (with type checking):

From what I understood, extensions would write a dataclass as their "additional configuration" and you would merge them together right, producing a "single" configuration class that inherits from those dataclasses? While this logic could work, since Python does not have an intersection type (unlike TypeScript), mypy would not be able to tell that the configuration object after adding multiple subconfigurations dataclasses supports all those fields (unless we do a plugin I think and fake an inheritance).

Currently, in Sphinx, the simple fact that you can add configuration values however you see fit makes Config objects impossible (from a static point of view) to lint them.

@chrisjsewell
Copy link
Member

chrisjsewell commented Mar 26, 2024

From what I understood, extensions would write a dataclass as their "additional configuration" and you would merge them together right, producing a "single" configuration class that inherits from those dataclasses?

yep, something like that 😅
very simply, my solution still has an aspect of "type-unsafeness", but that is confined to a single point in the codebase (when you generate the MdParserConfig from the Config), then at least every other part of the code base is "type-safe"

While this logic could work ... unless we do a plugin I think and fake an inheritance

Exactly; although the code I have set up in myst-parser works for me, and I'm pretty happy with it, I'm certainly not claiming it is "strictly correct" (if that it is even possible)
Plus, it would certainly take additional work, to make it more generically applicable for sphinx core/extensions

@danieleades
Copy link
Contributor

this is conceptually similar to what i do in sphinx-graph (which itself is a much, much leaner take on the sphinx-needs use-case)

(in this case i'm reading from the environment rather than config)

https://github.com/danieleades/sphinx-graph/blob/27c84c3c2e28a1e23edde2f0b564b87d0a9732c0/src/sphinx_graph/vertex/state.py#L94-L116

class State:
    """State object for Sphinx Graph vertices."""


    def __init__(
        self,
        vertices: dict[str, tuple[int, Info]],
        graph: rx.PyDiGraph[str, str | None],
    ) -> None:
        """Create a new state object."""
        self._vertices = vertices
        self._graph = graph


    @classmethod
    def read(cls, env: BuildEnvironment) -> State:
        """Read the State object for the given environment.


        This is a read-only view of the state. Changes will not be saved.
        """
        vertices = getattr(env, "graph_vertices", {})
        graph: rx.PyDiGraph[str, str | None] = getattr(
            env, "graph_graph", rx.PyDiGraph(multigraph=False)
        )
        return State(vertices, graph)

it's 'loosely-typed', but i do it only once in the codebase and use a typed interface everywhere else

@chrisjsewell
Copy link
Member

in this case i'm reading from the environment rather than config

sphinx-needs also does a similar thing, to return extension specific state from the environment 😄

at the end of the day, this seems probably the most pragmatic approach (confining the loose typing to a single place)

I don't know if there is any generalisation to be had here, to help extension developers use the "best practices", or if you just leave them to their ad-hoc solutions 🤔

@picnixz
Copy link
Member Author

picnixz commented Mar 27, 2024

I think we should wait until we have intersection types in Python (see python/typing#213). After that, it will be easier to type extendable configurations.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
awaiting:decision PR waiting for a consensus from maintainers.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants