Farama-Foundation / Gymnasium

An API standard for single-agent reinforcement learning environments, with popular reference environments and related utilities (formerly Gym)

Home Page:https://gymnasium.farama.org

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

[Proposal] Change type of metadata from `dict` to `Mapping`

troiganto opened this issue · comments

Proposal

I propose to change the type annotation of Env.metadata from dict[str, Any] to Mapping[str, Any].

Motivation

The purpose of metadata is to be a read-only store of certain information about envs. As far as I can tell, the attribute is only ever set during class creation and only read afterwards.

This is not what the type annotation dict[str, Any] communicates, however. In the type hierarchy, it is considered a mutable mapping, meaning that mutating item access is explicitly allowed.

This makes it difficult to define an env when using type checkers or linters. For example, given this piece of code:

from gymnasium import Env


class MyEnv(Env):
    metadata = {
        "render.modes": ["human", "rgb_array"],
        "render_fps": 50,
    }

The ruff linter gives this warning:

example.py:5:16: RUF012 Mutable class attributes should be annotated with `typing.ClassVar`
Found 1 error.

The only solution right now is to disable this (generally sensible) warning either globally or for each env individually.

Changing the type annotation is a reasonably small change, should not change any runtime behavior and makes the code communicate better the use cases it anticipates. The only code that would negatively impacted (i.e. receive linter warnings where there were none before) would be code that is already highly suspect, e.g. code that modifies the metadata after class creation.

Pitch

I propose to change the definition of Env.metadata, which currently looks like this:

from typing import TYPE_CHECKING, Any, Generic, SupportsFloat, TypeVar
...
class Env(Generic[ObsType, ActType]):
    ...
    metadata: dict[str, Any] = {"render_modes": []}

to use the ABC Mapping:

from typing import TYPE_CHECKING, Any, Generic, Mapping, SupportsFloat, TypeVar
...
class Env(Generic[ObsType, ActType]):
    ...
    metadata: Mapping[str, Any] = {"render_modes": []}

Alternatives

One could also go one step further and declare metadata a ClassVar[Mapping[str, Any]]. This would imply that the metadata is only supposed to be defined on the class itself and not e.g. in __init__().

However, I haven't clearly thought through the implications that this change would have, especially given that e.g. on Wrapper, the metadata arguably is an instance rather than class attribute. Some incomplete testing tells me that this would at least be a non-trivial change:

from typing import Any, ClassVar, Mapping
from typing_extensions import reveal_type

class MyEnv:
    metadata: ClassVar[Mapping[str, Any]] = {
        "render.modes": [],
    }

class Wrapper(MyEnv):
    @property  # Cannot override writeable attribute with read-only property [override]
    def metadata(self) -> Mapping[str, Any]:
        return MyEnv.metadata

class SubEnv(MyEnv):
    metadata: ClassVar[Mapping[str, Any]] = {
        "render.modes": ["human", "rgb_array"],
        "render_fps": 50,
    }

reveal_type(MyEnv.metadata)      # Revealed type is "typing.Mapping[builtins.str, Any]"
reveal_type(MyEnv().metadata)    # Revealed type is "typing.Mapping[builtins.str, Any]"
reveal_type(Wrapper.metadata)    # Revealed type is "def (self: bar.Wrapper) -> typing.Mapping[builtins.str, Any]"
reveal_type(Wrapper().metadata)  # Revealed type is "typing.Mapping[builtins.str, Any]"
reveal_type(SubEnv.metadata)     # Revealed type is "typing.Mapping[builtins.str, Any]"
reveal_type(SubEnv().metadata)   # Revealed type is "typing.Mapping[builtins.str, Any]"
env: MyEnv = Wrapper()
reveal_type(env.metadata)        # Revealed type is "typing.Mapping[builtins.str, Any]"
reveal_type(type(env).metadata)  # Revealed type is "typing.Mapping[builtins.str, Any]"

Additional context

I use Mypy rather than Pyright for type checking, so it might be worthwhile that changing the annotation from dict to Mapping won't break anything for Pyright users.

Checklist

  • I have checked that there is no similar issue in the repo

So I would love to say yes to this, but I know of cases where the metadata is dynamically determined on __init__ or by wrappers.
For example, the mujoco environments, each have their own render fps however there is a base class for the environment that "dynamically" sets the metadata render fps. See

if "render_fps" in self.metadata:

Another example is the HumanRendering wrapper that enables environments with only rgb array support to have human like rendering. To make the metadata render modes correct, HumanRendering adds "human" to the modes

If you can think of a type aware method of solving this, that would be great but I'm not aware of one.

In a side comment, MyPy seems like the better type checker for our compared to Pyright particular with our use of NumPy so would be happy to accept any PRs that changed us across. This wouldn't need to happen all at once

Hi, thanks for the quick reply! The logic that you describe in BaseMujocoEnv and HumanRendering are some interesting use cases and I hadn't considered them until now, so thanks!

I've had a quick look into these two classes, and it seems to me that Mapping might still be a valid type annotation, even though metadata "morally" isn't immutable — thanks to those wonderful Python semantics that allow you to modify the items of a tuple[list[object], ...] even though tuples are immutable. 😉


Regarding BaseMujocoEnv around this line:

if "render_fps" in self.metadata:
assert (
int(np.round(1.0 / self.dt)) == self.metadata["render_fps"]
), f'Expected value: {int(np.round(1.0 / self.dt))}, Actual value: {self.metadata["render_fps"]}'

it looks like no modification of the dict actually happens. Instead, it is merely an assert on the value set by the subclasses.

As for HumanRendering around this line:

if "human" not in self.metadata["render_modes"]:
self.metadata = deepcopy(self.env.metadata)
self.metadata["render_modes"].append("human")

this is where Python's semantics save us: This piece of code does not actually modify the metadata mapping. Instead, it makes a deepcopy of it, modifies a nested list object (which is Fine™ as far as Python and its type checkers are concerned) and then assigns this new object to metadata. Bot reassignment and nested .append() are type-correct, because Mapping only prevents modifications of the dict itself, e.g.:

metadata["render.modes"] = some_new_list
render_fps = metadata.setdefault("render_fps", 25)

Finally, I've grepped for any occurrences of metadata\[.*\], render_fps and render.modes and found one more potentially problematic use case in a bunch of Mujoco envs, e.g. this:

self.metadata = {
"render_modes": [
"human",
"rgb_array",
"depth_array",
],
"render_fps": int(np.round(1.0 / self.dt)),
}

Here, self.metadata is assigned inside the __init__() method. This indeed rules out any use of ClassVar, fair enough. However, this case still qualifies as a Mapping because only the object as a whole is replaced.


Finally, I've put a small experiment together here: https://gist.github.com/troiganto/ce6d3eef305d14ea2c954291927e5d78
It's a heavily truncated definition of Env, Wrapper, HumanRendering and a bunch of concrete environments, but with metadata: Mapping[str, Any]. I've written down instructions on how to run the linters and explain why I think the errors they find confirm my thoughts.

(Regarding a switch from Pyright to Mypy: that sounds like a super interesting project! I might bring it up again in a new issue if I find the time, but unfortunately I'm completely swamped right now porting a lot of code from Gym to Gymnasium. Priorities unfortunately haven't allowed me to tackle this until now 😓 )