Repository: python/mypy

Issue:
`dmypy run` won't run, prints `"Restarting: configuration changed"` when `ignore_missing_imports = True` is configured
**Bug Report**

Since at least `v0.902`, executing `dmypy run` in a project that has `ignore_missing_imports = True` in `mypy.ini` is not working anymore.

**To Reproduce**

(Write your steps here:)

1. Create `src/something.py` with some code in it, and create `mypy.ini` with:
```ini
[mypy]
files = src
ignore_missing_imports = True
```
2. Run `dmypy run`

**Expected Behavior**

`dmypy run` should execute correctly, outputting something like:

```
Daemon started
src\something.py:1: error: Incompatible types in assignment (expression has type "str", variable has type "int")
Found 1 error in 1 file (checked 26 source files)
```

**Actual Behavior**

`dmypy run` keeps showing:

```
Restarting: configuration changed
Daemon stopped
Daemon started
Response: {'restart': 'configuration changed', 'platform': 'win32', 'python_version': '3_9', 'roundtrip_time': 0.5543885231018066}
```

even when trying to run it again.

**Your Environment**

- Mypy version used: `v0.902`, `v0.910` and `0.920+dev.379622de482ec16dd1676ad6d183dcfca5203194`
- Mypy command-line flags: `dmypy run`
- Mypy configuration options from `mypy.ini` (and other config files):
```
[mypy]
files = src
ignore_missing_imports = True
```
- Python version used: 3.9.5
- Operating system and version: Windows 10 21H1



Discussion:
I guess it has something to do with these changes: https://github.com/python/mypy/commit/4642a31bdd437edc2ebe788cc6deb3214f06d997#diff-eb09ce67c2059bc9c117d4175e0bd34b1d75bbe8a91c3b9f69a8d14a9c8bc09fR330
A workaround, for now, is to use this undocumented option together:

```ini
ignore_missing_imports_per_module = True
```

as in:

```ini
[mypy]
files = src
ignore_missing_imports = True
ignore_missing_imports_per_module = True
```
I can't reproduce this issue, at least on macOS and Python 3.9.0. Can you provide an example file under `src/` that triggers the issue? Also can you try this out in a fresh virtualenv?
I was trying it in a fresh venv, the source was just a dummy file `something.py` with

```python
something: int = "something"
```

Perhaps it's only in Windows? There are some `if sys.platform == 'win32':` in `dmypy` :)
I can indeed repro this on Windows with the latest mypy in a fresh venv... strange

It also appears the .dmypy.json file sticks around afterwards as well...
It's on my machine on Windows too.
Interestingly, the "temporary" fix does not work if

```toml
enable_error_code = ignore-without-code
```

is also set.
I'm getting this same error on windows. The same project works fine on MacOS :-)

Resolving patch:
diff --git a/mypy/dmypy_server.py b/mypy/dmypy_server.py
--- a/mypy/dmypy_server.py
+++ b/mypy/dmypy_server.py
@@ -330,7 +330,7 @@ def cmd_run(
                         header=argparse.SUPPRESS,
                     )
             # Signal that we need to restart if the options have changed
-            if self.options_snapshot != options.snapshot():
+            if not options.compare_stable(self.options_snapshot):
                 return {"restart": "configuration changed"}
             if __version__ != version:
                 return {"restart": "mypy version changed"}
diff --git a/mypy/errorcodes.py b/mypy/errorcodes.py
--- a/mypy/errorcodes.py
+++ b/mypy/errorcodes.py
@@ -37,6 +37,14 @@ def __init__(
     def __str__(self) -> str:
         return f"<ErrorCode {self.code}>"
 
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, ErrorCode):
+            return False
+        return self.code == other.code
+
+    def __hash__(self) -> int:
+        return hash((self.code,))
+
 
 ATTR_DEFINED: Final = ErrorCode("attr-defined", "Check that attribute exists", "General")
 NAME_DEFINED: Final = ErrorCode("name-defined", "Check that name is defined", "General")
diff --git a/mypy/options.py b/mypy/options.py
--- a/mypy/options.py
+++ b/mypy/options.py
@@ -373,7 +373,7 @@ def use_or_syntax(self) -> bool:
     def new_semantic_analyzer(self) -> bool:
         return True
 
-    def snapshot(self) -> object:
+    def snapshot(self) -> dict[str, object]:
         """Produce a comparable snapshot of this Option"""
         # Under mypyc, we don't have a __dict__, so we need to do worse things.
         d = dict(getattr(self, "__dict__", ()))
@@ -388,6 +388,7 @@ def __repr__(self) -> str:
         return f"Options({pprint.pformat(self.snapshot())})"
 
     def apply_changes(self, changes: dict[str, object]) -> Options:
+        # Note: effects of this method *must* be idempotent.
         new_options = Options()
         # Under mypyc, we don't have a __dict__, so we need to do worse things.
         replace_object_state(new_options, self, copy_dict=True)
@@ -413,6 +414,17 @@ def apply_changes(self, changes: dict[str, object]) -> Options:
 
         return new_options
 
+    def compare_stable(self, other_snapshot: dict[str, object]) -> bool:
+        """Compare options in a way that is stable for snapshot() -> apply_changes() roundtrip.
+
+        This is needed because apply_changes() has non-trivial effects for some flags, so
+        Options().apply_changes(options.snapshot()) may result in a (slightly) different object.
+        """
+        return (
+            Options().apply_changes(self.snapshot()).snapshot()
+            == Options().apply_changes(other_snapshot).snapshot()
+        )
+
     def build_per_module_cache(self) -> None:
         self._per_module_cache = {}
 


Test patch:
diff --git a/test-data/unit/daemon.test b/test-data/unit/daemon.test
--- a/test-data/unit/daemon.test
+++ b/test-data/unit/daemon.test
@@ -28,6 +28,33 @@ Daemon stopped
 [file foo.py]
 def f(): pass
 
+[case testDaemonRunIgnoreMissingImports]
+$ dmypy run -- foo.py --follow-imports=error --ignore-missing-imports
+Daemon started
+Success: no issues found in 1 source file
+$ dmypy stop
+Daemon stopped
+[file foo.py]
+def f(): pass
+
+[case testDaemonRunErrorCodes]
+$ dmypy run -- foo.py --follow-imports=error --disable-error-code=type-abstract
+Daemon started
+Success: no issues found in 1 source file
+$ dmypy stop
+Daemon stopped
+[file foo.py]
+def f(): pass
+
+[case testDaemonRunCombinedOptions]
+$ dmypy run -- foo.py --follow-imports=error --ignore-missing-imports --disable-error-code=type-abstract
+Daemon started
+Success: no issues found in 1 source file
+$ dmypy stop
+Daemon stopped
+[file foo.py]
+def f(): pass
+
 [case testDaemonIgnoreConfigFiles]
 $ dmypy start -- --follow-imports=error
 Daemon started