Describe the Bug
A generic class and a second class live in the same module. When that module is edited on disk (reported through workspace/didChangeWatchedFiles), an open file that uses the generic class through another module gets Indexer[Item].__getitem__ typed as returning the bare type parameter T instead of Item. Attribute access on the result then fails with Object of class 'object' has no attribute …. A fresh pyrefly check of the same files reports no error. Restarting the language server clears it.
We hit this in VS Code after a git pull fast-forwarded the checkout under a running server: a file nobody had touched showed a new error until the server was restarted.
Reproduction
The script below is self-contained (standard library only). It writes four small modules and an empty pyrefly.toml to a temp directory, starts pyrefly lsp, opens app.py, edits indexing.py on disk, reports the change through workspace/didChangeWatchedFiles, and prints app.py's diagnostics before and after. It then runs a fresh pyrefly check on the edited files.
python repro.py [path/to/pyrefly]
repro.py
"""Pyrefly LSP: after an on-disk edit to the module that defines a generic class, an unchanged,
open importer sees `Indexer[Item].__getitem__` return the unsolved `T` (reported as `object`).
Usage: python repro.py [path/to/pyrefly] stdlib only; the default is `pyrefly` on PATH
"""
import json
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path
PYREFLY = sys.argv[1] if len(sys.argv) > 1 else "pyrefly"
FILES = {
"pyrefly.toml": "",
"indexing.py": """\
class Indexer[T]:
def __getitem__(self, idx: int) -> T:
raise NotImplementedError
class Provider:
size: int = 0
""",
"holder.py": """\
from indexing import Indexer
class Item:
day: int = 0
class Holder:
items: Indexer[Item]
""",
"user.py": """\
from indexing import Provider
def use(provider: Provider) -> None:
pass
""",
"app.py": """\
from typing import reveal_type
from holder import Holder
from user import use
def first_day(holder: Holder, provider) -> int:
day = holder.items[0].day
reveal_type(holder.items.__getitem__)
use(provider)
return day
""",
}
# One line added above `Indexer`, and `Provider` loses its attribute. Either change alone does not reproduce.
EDITED_INDEXING = """\
class Indexer[T]:
def __getitem__(self, idx: int) -> T:
raise NotImplementedError
class Provider:
pass
"""
class Client:
def __init__(self, cwd: Path) -> None:
self.proc = subprocess.Popen([PYREFLY, "lsp"], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, cwd=cwd)
self.lock = threading.Lock()
self.pending: dict[int, threading.Event] = {}
self.next_id = 1
self.last_message = time.time()
self.diagnostics: dict[str, list] = {}
threading.Thread(target=self._read, daemon=True).start()
def _send(self, msg: dict) -> None:
body = json.dumps({"jsonrpc": "2.0", **msg}).encode()
with self.lock:
self.proc.stdin.write(b"Content-Length: %d\r\n\r\n" % len(body) + body)
self.proc.stdin.flush()
def request(self, method: str, params: dict) -> None:
done = self.pending[self.next_id] = threading.Event()
self._send({"id": self.next_id, "method": method, "params": params})
self.next_id += 1
done.wait(60)
def notify(self, method: str, params: dict) -> None:
self._send({"method": method, "params": params})
def _read(self) -> None:
out = self.proc.stdout
while True:
length = 0
while line := out.readline().strip():
if line.lower().startswith(b"content-length"):
length = int(line.split(b":")[1])
if not length:
return
msg = json.loads(out.read(length))
self.last_message = time.time()
if "id" in msg and "method" in msg: # a server request: defaults for configuration, null otherwise
items = msg["params"].get("items") if msg["method"] == "workspace/configuration" else None
self._send({"id": msg["id"], "result": [{}] * len(items) if items is not None else None})
elif "id" in msg:
self.pending.pop(msg["id"]).set()
elif msg.get("method") == "textDocument/publishDiagnostics":
self.diagnostics[msg["params"]["uri"]] = msg["params"]["diagnostics"]
def settle(self) -> None:
"""Wait until the server has been quiet for two seconds."""
while True:
time.sleep(0.25)
if time.time() - self.last_message > 2:
return
root = Path(tempfile.mkdtemp()).resolve()
for rel, text in FILES.items():
(root / rel).parent.mkdir(parents=True, exist_ok=True)
(root / rel).write_text(text)
app, indexing = root / "app.py", root / "indexing.py"
def show(client: Client, label: str) -> None:
print(label)
for d in client.diagnostics.get(app.as_uri(), []):
print(f" app.py:{d['range']['start']['line'] + 1}: [{d.get('code')}] {d['message']}")
client = Client(cwd=root)
client.request("initialize", {
"processId": None, "rootUri": root.as_uri(),
"workspaceFolders": [{"uri": root.as_uri(), "name": "repro"}],
"capabilities": {"workspace": {"configuration": True, "workspaceFolders": True,
"didChangeWatchedFiles": {"dynamicRegistration": True}}},
})
client.notify("initialized", {})
client.notify("textDocument/didOpen", {"textDocument": {
"uri": app.as_uri(), "languageId": "python", "version": 1, "text": app.read_text()}})
client.settle()
show(client, "before the edit:")
indexing.write_text(EDITED_INDEXING) # on disk, as `git pull` or a branch switch would
client.notify("workspace/didChangeWatchedFiles", {"changes": [{"uri": indexing.as_uri(), "type": 2}]})
client.settle()
show(client, "after the edit, incremental:")
client.proc.kill()
fresh = subprocess.run([PYREFLY, "check", "--output-format", "min-text", "app.py"], cwd=root,
capture_output=True, text=True)
print("fresh `pyrefly check` of the edited files:")
print(" " + ("\n ".join(fresh.stdout.split("\n")).strip() or "no errors"))
The modules:
# indexing.py
class Indexer[T]:
def __getitem__(self, idx: int) -> T:
raise NotImplementedError
class Provider:
size: int = 0
# holder.py
from indexing import Indexer
class Item:
day: int = 0
class Holder:
items: Indexer[Item]
# user.py
from indexing import Provider
def use(provider: Provider) -> None:
pass
# app.py (open in the editor, never changed)
from typing import reveal_type
from holder import Holder
from user import use
def first_day(holder: Holder, provider) -> int:
day = holder.items[0].day
reveal_type(holder.items.__getitem__)
use(provider)
return day
The edit to indexing.py adds a blank line above Indexer and removes Provider's attribute:
class Indexer[T]:
def __getitem__(self, idx: int) -> T:
raise NotImplementedError
class Provider:
pass
Output (1.3.1 and 1.4.0-dev.1 print the same)
before the edit:
app.py:9: [reveal-type] revealed type: (idx: int) -> Item
after the edit, incremental:
app.py:8: [missing-attribute] Object of class `object` has no attribute `day`
app.py:9: [reveal-type] revealed type: (idx: int) -> T
fresh `pyrefly check` of the edited files:
INFO app.py:9:16-42: revealed type: (idx: int) -> Item [reveal-type]
Expected: after the edit, __getitem__ is still (idx: int) -> Item, and there is no missing-attribute error, matching the fresh check.
What is needed to trigger it
Deterministic in our runs. Each of these is required; removing any one makes the error disappear:
- Both parts of the edit. Only shifting
Indexer down a line, or only changing Provider, does not reproduce.
app.py calling a function whose signature mentions Provider (use(provider) here), from a module other than indexing.py.
- A config file. An empty
pyrefly.toml is enough. Without one, it does not reproduce.
It does not depend on the interpreter or on the attribute's name. It also reproduces with the modules in a package under src/.
Possibly related
#4216 and #4171 also describe incremental rechecks that end in a different state from a fresh check. We could not tell whether the cause is shared.
Sandbox Link
Not possible: the bug needs an on-disk edit under a running language server. The self-contained script above reproduces it instead.
(Only applicable for extension issues) IDE Information
- Pyrefly VS Code extension 1.3.1, with
pyrefly.lspPath pointing at a pinned pyrefly 1.3.1 binary. The script reproduces it without the editor, against 1.3.1 and 1.4.0-dev.1.
- macOS 15, arm64.
- The output pane shows no panic or error around the recheck; the only warnings are unrelated config-migration messages.
- The workspace is multi-root, but one folder is enough to reproduce.
Describe the Bug
A generic class and a second class live in the same module. When that module is edited on disk (reported through
workspace/didChangeWatchedFiles), an open file that uses the generic class through another module getsIndexer[Item].__getitem__typed as returning the bare type parameterTinstead ofItem. Attribute access on the result then fails withObject of class 'object' has no attribute …. A freshpyrefly checkof the same files reports no error. Restarting the language server clears it.We hit this in VS Code after a
git pullfast-forwarded the checkout under a running server: a file nobody had touched showed a new error until the server was restarted.Reproduction
The script below is self-contained (standard library only). It writes four small modules and an empty
pyrefly.tomlto a temp directory, startspyrefly lsp, opensapp.py, editsindexing.pyon disk, reports the change throughworkspace/didChangeWatchedFiles, and printsapp.py's diagnostics before and after. It then runs a freshpyrefly checkon the edited files.repro.pyThe modules:
The edit to
indexing.pyadds a blank line aboveIndexerand removesProvider's attribute:Output (1.3.1 and 1.4.0-dev.1 print the same)
Expected: after the edit,
__getitem__is still(idx: int) -> Item, and there is nomissing-attributeerror, matching the fresh check.What is needed to trigger it
Deterministic in our runs. Each of these is required; removing any one makes the error disappear:
Indexerdown a line, or only changingProvider, does not reproduce.app.pycalling a function whose signature mentionsProvider(use(provider)here), from a module other thanindexing.py.pyrefly.tomlis enough. Without one, it does not reproduce.It does not depend on the interpreter or on the attribute's name. It also reproduces with the modules in a package under
src/.Possibly related
#4216 and #4171 also describe incremental rechecks that end in a different state from a fresh check. We could not tell whether the cause is shared.
Sandbox Link
Not possible: the bug needs an on-disk edit under a running language server. The self-contained script above reproduces it instead.
(Only applicable for extension issues) IDE Information
pyrefly.lspPathpointing at a pinnedpyrefly1.3.1 binary. The script reproduces it without the editor, against 1.3.1 and 1.4.0-dev.1.