Repository: klausweiss/typing-protocol-intersection (language: python)

Issue:
Cannot find component `ProtocolIntersection\u200b`
If one module imports `ProtocolIntersection`, and then another module attempts to import it from that module, `mypy` seems unable to lookup `ProtocolIntersection` in the imported module's namespace (maybe due to the presence of a zero-width space at the end?):
```python
Traceback (most recent call last):
  File "C:\Users\evanj\GitHub\coconut\coconut\command\mypy.py", line 69, in mypy_run
    stdout, stderr, exit_code = run(args)
  File "mypy\api.py", line 72, in run
  File "mypy\api.py", line 59, in _run
  File "mypy\api.py", line 73, in <lambda>
  File "mypy\main.py", line 95, in main
  File "mypy\main.py", line 174, in run_build
  File "mypy\build.py", line 194, in build
  File "mypy\build.py", line 277, in _build
  File "mypy\build.py", line 2930, in dispatch
  File "mypy\build.py", line 3321, in process_graph
  File "mypy\build.py", line 3402, in process_fresh_modules
  File "mypy\build.py", line 2121, in fix_cross_refs
  File "mypy\fixup.py", line 53, in fixup_module
  File "mypy\fixup.py", line 136, in visit_symbol_table
  File "mypy\fixup.py", line 73, in visit_type_info
  File "mypy\fixup.py", line 138, in visit_symbol_table
  File "mypy\nodes.py", line 774, in accept
  File "mypy\fixup.py", line 146, in visit_func_def
  File "mypy\types.py", line 1869, in accept
  File "mypy\fixup.py", line 244, in visit_callable_type
  File "mypy\types.py", line 2803, in accept
  File "mypy\fixup.py", line 338, in visit_type_type
  File "mypy\types.py", line 1357, in accept
  File "mypy\fixup.py", line 209, in visit_instance
  File "mypy\fixup.py", line 344, in lookup_fully_qualified_typeinfo
  File "mypy\lookup.py", line 49, in lookup_fully_qualified
AssertionError: Cannot find component 'ProtocolIntersection\u200b' for 'typing_protocol_intersection.types.ProtocolIntersection\u200b'
```

Also note that reproduction here is a bit tricky, since it seems to depend on the current status of `.mypy_cache`, though it always shows up for me after running `mypy` enough times. I'm on Python 3.9 and `mypy` 1.2.0. `mypy.ini` is just:
```ini
[mypy]
plugins = typing_protocol_intersection.mypy_plugin
```
Cannot find component `ProtocolIntersection\u200b`
If one module imports `ProtocolIntersection`, and then another module attempts to import it from that module, `mypy` seems unable to lookup `ProtocolIntersection` in the imported module's namespace (maybe due to the presence of a zero-width space at the end?):
```python
Traceback (most recent call last):
  File "C:\Users\evanj\GitHub\coconut\coconut\command\mypy.py", line 69, in mypy_run
    stdout, stderr, exit_code = run(args)
  File "mypy\api.py", line 72, in run
  File "mypy\api.py", line 59, in _run
  File "mypy\api.py", line 73, in <lambda>
  File "mypy\main.py", line 95, in main
  File "mypy\main.py", line 174, in run_build
  File "mypy\build.py", line 194, in build
  File "mypy\build.py", line 277, in _build
  File "mypy\build.py", line 2930, in dispatch
  File "mypy\build.py", line 3321, in process_graph
  File "mypy\build.py", line 3402, in process_fresh_modules
  File "mypy\build.py", line 2121, in fix_cross_refs
  File "mypy\fixup.py", line 53, in fixup_module
  File "mypy\fixup.py", line 136, in visit_symbol_table
  File "mypy\fixup.py", line 73, in visit_type_info
  File "mypy\fixup.py", line 138, in visit_symbol_table
  File "mypy\nodes.py", line 774, in accept
  File "mypy\fixup.py", line 146, in visit_func_def
  File "mypy\types.py", line 1869, in accept
  File "mypy\fixup.py", line 244, in visit_callable_type
  File "mypy\types.py", line 2803, in accept
  File "mypy\fixup.py", line 338, in visit_type_type
  File "mypy\types.py", line 1357, in accept
  File "mypy\fixup.py", line 209, in visit_instance
  File "mypy\fixup.py", line 344, in lookup_fully_qualified_typeinfo
  File "mypy\lookup.py", line 49, in lookup_fully_qualified
AssertionError: Cannot find component 'ProtocolIntersection\u200b' for 'typing_protocol_intersection.types.ProtocolIntersection\u200b'
```

Also note that reproduction here is a bit tricky, since it seems to depend on the current status of `.mypy_cache`, though it always shows up for me after running `mypy` enough times. I'm on Python 3.9 and `mypy` 1.2.0. `mypy.ini` is just:
```ini
[mypy]
plugins = typing_protocol_intersection.mypy_plugin
```


Discussion / hints:
Hmmm... I'm sometimes seeing this error now even when everything is contained in a single file without importing `ProtocolIntersection` from elsewhere, so I'm a bit stumped as to what's causing it. It continues to be very dependent on `.mypy_cache`—it seems like it generally works on the first try before any `.mypy_cache` has been generated, then fails at some point later when it loads that `.mypy_cache`.
[This gist](https://gist.github.com/evhub/5ef01a7d33582e84c9838bdbe9405cd4) seems sufficient to reproduce the error for me after it has been run a couple of times to generate a `.mypy_cache`, though I don't really know which components of it are necessary for the error to occur.
Hey. Thanks for reporting! Just wanted to mention that life's hectic, so I might take a few days to look at this issue in depth. Should you have any ideas before I investigate, shoot.
I have no solution so far. Frankly I didn't have time to dive deep, I only scratched a surface. If https://github.com/mypyc/mypyc/issues/973 gets fixed, we can return to the old implementation that didn't add artificial suffixes to the class names. I'm happy to consider any alternative approaches to the implementation anyone might have.
Hmmm... I'm sometimes seeing this error now even when everything is contained in a single file without importing `ProtocolIntersection` from elsewhere, so I'm a bit stumped as to what's causing it. It continues to be very dependent on `.mypy_cache`—it seems like it generally works on the first try before any `.mypy_cache` has been generated, then fails at some point later when it loads that `.mypy_cache`.
[This gist](https://gist.github.com/evhub/5ef01a7d33582e84c9838bdbe9405cd4) seems sufficient to reproduce the error for me after it has been run a couple of times to generate a `.mypy_cache`, though I don't really know which components of it are necessary for the error to occur.
Hey. Thanks for reporting! Just wanted to mention that life's hectic, so I might take a few days to look at this issue in depth. Should you have any ideas before I investigate, shoot.
I have no solution so far. Frankly I didn't have time to dive deep, I only scratched a surface. If https://github.com/mypyc/mypyc/issues/973 gets fixed, we can return to the old implementation that didn't add artificial suffixes to the class names. I'm happy to consider any alternative approaches to the implementation anyone might have.

Fix patch:
diff --git a/typing_protocol_intersection/mypy_plugin.py b/typing_protocol_intersection/mypy_plugin.py
index daa8610..4fef292 100644
--- a/typing_protocol_intersection/mypy_plugin.py
+++ b/typing_protocol_intersection/mypy_plugin.py
@@ -23,6 +23,15 @@
 class ProtocolIntersectionPlugin(mypy.plugin.Plugin):
     # pylint: disable=unused-argument
 
+    def report_config_data(self, ctx: mypy.plugin.ReportConfigContext) -> int:
+        # Whatever this method returns is used by mypy to determine whether a module should be checked again or if a
+        # cache-loaded info will do. If the obtained value is different from the previous one, cache is invalidated.
+        #
+        # As far as this plugin is concerned, this method is only used to aid generation of `UniqueFullname`s. If it
+        # wasn't for this method, mypy couldn't identify where a runtime-generated class name comes from, as it looks up
+        # the class names in the cache (and subsequently crashes when it fails to find them).
+        return UniqueFullname.instance_counter
+
     def get_type_analyze_hook(
         self, fullname: str
     ) -> Optional[Callable[[mypy.plugin.AnalyzeTypeContext], mypy.types.Type]]:


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..e71d340
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,34 @@
+import typing
+from pathlib import Path
+
+import mypy.api
+import pytest
+
+HERE = Path(__file__).parent
+
+
+@pytest.fixture
+def run_mypy(strip_invisible: typing.Callable[[str], str]):
+    def _run_mypy(input_file: Path, no_incremental: bool = True) -> typing.Tuple[str, str]:
+        args = [str(input_file), "--config-file", str(HERE / "test-mypy.ini")]
+        if no_incremental:
+            args.append("--no-incremental")
+        stdout, stderr, _ = mypy.api.run(args)
+        return strip_invisible(stdout), strip_invisible(stderr)
+
+    return _run_mypy
+
+
+@pytest.fixture
+def strip_invisible() -> typing.Callable[[str], str]:
+    def _strip_invisible(string: str) -> str:
+        """Removes all zero-width spaces from the input text and strips
+        whitespaces.
+
+        The need for the former was born with an ugly hack that we use
+        to trick mypyc.
+        """
+        zero_width_space = "\u200B"
+        return string.strip().replace(zero_width_space, "")
+
+    return _strip_invisible
diff --git a/tests/test_mypy_plugin.py b/tests/test_mypy_plugin.py
index 3de0441..05a2330 100644
--- a/tests/test_mypy_plugin.py
+++ b/tests/test_mypy_plugin.py
@@ -1,8 +1,6 @@
 import typing
 from pathlib import Path
 
-import mypy.api
-import mypy.version
 import pytest
 
 import typing_protocol_intersection.mypy_plugin
@@ -45,14 +43,14 @@ def get_expected_stderr(contents: str) -> str:
 
 
 @pytest.fixture()
-def testcase_file(request):
+def testcase_file(request, strip_invisible):
     path = request.param
     with open(HERE / path, encoding="utf-8") as file:
         contents = file.read()
     return _TestCase(
         HERE / path,
-        _strip(get_expected_stdout(contents)),
-        _strip(get_expected_stderr(contents)),
+        strip_invisible(get_expected_stdout(contents)),
+        strip_invisible(get_expected_stderr(contents)),
     )
 
 
@@ -96,29 +94,11 @@ def testcase_file(request):
     ],
     indirect=["testcase_file"],
 )
-def test_mypy_plugin(testcase_file: _TestCase):
-    stdout, stderr = _run_mypy(testcase_file.path)
+def test_mypy_plugin(testcase_file: _TestCase, run_mypy):
+    stdout, stderr = run_mypy(testcase_file.path)
     assert (stdout.strip(), stderr.strip()) == (testcase_file.expected_stdout, testcase_file.expected_stderr)
 
 
-def _run_mypy(input_file: Path) -> typing.Tuple[str, str]:
-    stdout, stderr, _ = mypy.api.run(
-        [str(input_file), "--config-file", str(HERE / "test-mypy.ini"), "--no-incremental"]
-    )
-    return _strip(stdout), _strip(stderr)
-
-
-def _strip(string: str) -> str:
-    """Removes all zero-width spaces from the input text and strips
-    whitespaces.
-
-    The need for the former was born with an ugly hack that we use to
-    trick mypyc.
-    """
-    zero_width_space = "\u200B"
-    return string.strip().replace(zero_width_space, "")
-
-
 @pytest.mark.parametrize(
     "version",
     [
diff --git a/tests/test_past_issues/test_4_mypy_cache/input.py b/tests/test_past_issues/test_4_mypy_cache/input.py
new file mode 100644
index 0000000..718eff2
--- /dev/null
+++ b/tests/test_past_issues/test_4_mypy_cache/input.py
@@ -0,0 +1,37 @@
+# pylint: skip-file
+"""
+source: https://gist.github.com/evhub/5ef01a7d33582e84c9838bdbe9405cd4
+
+Note this file won't typecheck, but it's a different issue we aim to detect is not there. Not only would the file not
+typecheck, but mypy would crash with:
+  AssertionError: Cannot find component 'ProtocolIntersection\u200b' for 'typing_protocol_intersection.types.ProtocolIntersection\u200b'
+See https://github.com/klausweiss/typing-protocol-intersection/issues/4 for details
+"""
+
+from typing import Protocol
+
+
+class X(Protocol):
+    x: str
+
+
+class Y(Protocol):
+    y: str
+
+
+class XY:
+    x: str = "asdf"
+    y: str = "dfaf"
+
+
+class container:
+    from typing_protocol_intersection import ProtocolIntersection as _ProtocolIntersection
+
+    ProtocolIntersection = _ProtocolIntersection
+
+
+def foo(xy: container.ProtocolIntersection[X, Y]) -> None:
+    print(xy.x, xy.y)
+
+
+foo(XY())
diff --git a/tests/test_past_issues/test_4_mypy_cache/test_4_mypy_cache.py b/tests/test_past_issues/test_4_mypy_cache/test_4_mypy_cache.py
new file mode 100644
index 0000000..9b2467d
--- /dev/null
+++ b/tests/test_past_issues/test_4_mypy_cache/test_4_mypy_cache.py
@@ -0,0 +1,17 @@
+"""
+ref: https://github.com/klausweiss/typing-protocol-intersection/issues/4
+"""
+from pathlib import Path
+
+HERE = Path(__file__).parent
+
+
+def test_4_mypy_cache(run_mypy):
+    # given
+    input_file = HERE / "input.py"
+    # when
+    # This may not be 100% accurate, but when the bug was present, running mypy twice was (consistently) enough
+    # to reproduce the issue. If there's no exception after 4 runs, we assume the bug's not there.
+    for _ in range(4):
+        run_mypy(input_file, no_incremental=False)
+    # then no error