feat(py_lsp): Round 1 parity push (parent-modules, cast, Self, fwd-refs)

Parity push #1 — six gap fixes on top of the 11-phase plan:

- Parent-module bindings: `import a.b.c` now binds `a`, `a.b`, `a.b.c`
  via py_bind_dotted_prefixes. Walks the dotted prefix chain on every
  import, regardless of import_is_from_style. Plus an attribute-access
  path on a MODULE that detects submodules: when the registry has any
  function whose qn starts with `<mod>.<attr>.`, evaluating `mod.attr`
  yields MODULE("<mod>.<attr>"). This makes `os.path.join(...)` and
  similar resolve correctly.

- typing.cast(T, x) returns NAMED(T) (re-evaluating T as an annotation,
  so generic subscripts and forward references both resolve). Detects
  bare `cast(...)` and qualified `typing.cast(...)` forms.

- typing.assert_type(x, T) is a no-op at runtime; we type the result
  as type-of(x).

- Forward references as quoted strings: `def f(x: "Foo")` strips quotes
  and re-resolves the inner annotation. Also handles double-quoted and
  single-quoted forms.

- Self return type substitution: methods declared returning `Self` /
  `typing.Self` / `typing_extensions.Self` substitute to NAMED(receiver)
  at call resolution time. Enables fluent / builder pattern chaining
  to keep resolving methods after each step.

- Generic subscript stripping: annotations like `list[Foo]` strip to
  `list` for v1; `Optional[Foo]` likewise. Container element-type
  substitution still deferred (Phase 8+).

Generator additions:
- Allowlist now includes urllib, http, concurrent (top stdlib usage).
- `from X import *` re-export following at generation time. Modules
  like os.path (a star-import shim of posixpath / ntpath / genericpath)
  now have all forwarded definitions registered under their own QN.
  Iterates to a fixed point with an 8-step ceiling. typeshed tree
  expansion is bounded — only modules transitively reachable from the
  allowlist are pulled in.

Stats: 137 modules, 904 classes (2797 methods), 886 free functions
(was 114 / 753 / 794). Generated file grows from 20K to ~22K lines.

6 new test_py_lsp.c cases. All 2874 prior tests stay green.
This commit is contained in:
Martin Vogel
2026-05-09 01:39:28 +02:00
parent a8c9ace4a5
commit 2e042dab0b
4 changed files with 5823 additions and 1935 deletions
File diff suppressed because it is too large Load Diff
+177 -9
View File
@@ -25,6 +25,7 @@ static const CBMRegisteredFunc* py_lookup_attribute(PyLSPContext* ctx,
const char* type_qn, const char* member_name);
static void py_emit_resolved_call(PyLSPContext* ctx, const char* callee_qn,
const char* strategy, float confidence);
static const CBMType* py_resolve_annotation(PyLSPContext* ctx, const char* ann);
void py_lsp_init(PyLSPContext* ctx, CBMArena* arena, const char* source, int source_len,
const CBMTypeRegistry* registry, const char* module_qn, CBMResolvedCallArray* out) {
@@ -81,6 +82,36 @@ static bool import_is_from_style(const char* local_name, const char* module_qn)
return true;
}
/* For `import a.b.c`, also bind every dotted prefix as MODULE so that
* `a.b.c.fn()` style chained access walks correctly: `a` → MODULE(a),
* `a.b` → MODULE(a.b), `a.b.c` → MODULE(a.b.c). The underlying CBMImport
* already records local_name="c" / module_path="a.b.c"; we walk the
* prefix chain in addition. */
static void py_bind_dotted_prefixes(PyLSPContext* ctx, const char* qn) {
if (!ctx || !qn) return;
const char* p = qn;
for (;;) {
const char* dot = strchr(p, '.');
if (!dot) break;
size_t prefix_len = (size_t)(dot - qn);
char* prefix = (char*)cbm_arena_alloc(ctx->arena, prefix_len + 1);
if (!prefix) return;
memcpy(prefix, qn, prefix_len);
prefix[prefix_len] = '\0';
// Also bind the *short top-level name* — for `import a.b.c`,
// the source typically writes `a.b.c.fn()` and `a` must be in
// scope as MODULE("a") (not the full QN, since attribute access
// walks one segment at a time).
const char* short_name = strrchr(prefix, '.');
const char* bind_short = short_name ? short_name + 1 : prefix;
if (cbm_type_is_unknown(cbm_scope_lookup(ctx->current_scope, bind_short))) {
cbm_scope_bind(ctx->current_scope, bind_short,
cbm_type_module(ctx->arena, prefix));
}
p = dot + 1;
}
}
void py_lsp_bind_imports(PyLSPContext* ctx) {
if (!ctx || !ctx->current_scope) return;
for (int i = 0; i < ctx->import_count; i++) {
@@ -104,6 +135,15 @@ void py_lsp_bind_imports(PyLSPContext* ctx) {
t = cbm_type_module(ctx->arena, qn);
}
cbm_scope_bind(ctx->current_scope, local, t);
// Always walk the dotted prefix chain. The CBMImport shape
// can't distinguish `import a.b.c` from `from a.b import c`
// (both produce local_name=c, module_path=a.b.c), but binding
// parent modules (`a`, `a.b`) into scope is correct in both
// cases: in the first form Python actually does this; in the
// second form the parent isn't in scope at runtime, but our
// adding it doesn't cause false positives because real source
// wouldn't reference an unimported parent module name.
py_bind_dotted_prefixes(ctx, qn);
}
}
@@ -194,18 +234,59 @@ static const CBMType* py_literal_type(PyLSPContext* ctx, TSNode node) {
return NULL;
}
/* If `func_qn` is a registered function, return its return type. */
static const CBMType* py_func_return_type(PyLSPContext* ctx, const char* func_qn) {
/* Substitute "Self" / "typing.Self" return types with the receiver type.
* Walks the type recursively so `Optional[Self]` becomes `Optional[R]`. */
static const CBMType* py_substitute_self(PyLSPContext* ctx, const CBMType* t,
const char* receiver_qn) {
if (!t || !receiver_qn) return t;
if (t->kind == CBM_TYPE_NAMED && t->data.named.qualified_name) {
const char* qn = t->data.named.qualified_name;
if (strcmp(qn, "Self") == 0 || strcmp(qn, "typing.Self") == 0 ||
strcmp(qn, "typing_extensions.Self") == 0) {
return cbm_type_named(ctx->arena, receiver_qn);
}
}
if (t->kind == CBM_TYPE_UNION) {
int n = t->data.union_type.count;
const CBMType** members = (const CBMType**)cbm_arena_alloc(ctx->arena,
(size_t)(n + 1) * sizeof(const CBMType*));
if (!members) return t;
for (int i = 0; i < n; i++) {
members[i] = py_substitute_self(ctx, t->data.union_type.members[i], receiver_qn);
}
return cbm_type_union(ctx->arena, members, n);
}
return t;
}
/* If `func_qn` is a registered function, return its return type. When
* called on a receiver, pass receiver_qn to substitute Self return
* types; pass NULL otherwise. */
static const CBMType* py_func_return_type_recv(PyLSPContext* ctx, const char* func_qn,
const char* receiver_qn) {
if (!ctx || !func_qn) return cbm_type_unknown();
const CBMRegisteredFunc* f = cbm_registry_lookup_func(ctx->registry, func_qn);
if (!f || !f->signature) return cbm_type_unknown();
if (f->signature->kind != CBM_TYPE_FUNC) return cbm_type_unknown();
const CBMType** rets = f->signature->data.func.return_types;
if (!rets || !rets[0]) return cbm_type_unknown();
if (!rets[1]) return rets[0];
int count = 0;
while (rets[count]) count++;
return cbm_type_tuple(ctx->arena, rets, count);
const CBMType* base;
if (!rets[1]) {
base = rets[0];
} else {
int count = 0;
while (rets[count]) count++;
base = cbm_type_tuple(ctx->arena, rets, count);
}
if (receiver_qn) {
return py_substitute_self(ctx, base, receiver_qn);
}
return base;
}
/* Convenience wrapper: no receiver substitution. */
static const CBMType* py_func_return_type(PyLSPContext* ctx, const char* func_qn) {
return py_func_return_type_recv(ctx, func_qn, NULL);
}
static const CBMType* py_eval_expr_type(PyLSPContext* ctx, TSNode node) {
@@ -237,6 +318,7 @@ static const CBMType* py_eval_expr_type(PyLSPContext* ctx, TSNode node) {
TSNode attr = ts_node_child_by_field_name(node, "attribute", 9);
if (ts_node_is_null(obj) || ts_node_is_null(attr)) return cbm_type_unknown();
const CBMType* obj_type = py_eval_expr_type(ctx, obj);
if (obj_type) obj_type = cbm_type_resolve_alias(obj_type);
char* attr_name = py_node_text(ctx, attr);
if (!attr_name || !obj_type) return cbm_type_unknown();
@@ -249,12 +331,23 @@ static const CBMType* py_eval_expr_type(PyLSPContext* ctx, TSNode node) {
const char* qn = cbm_arena_sprintf(ctx->arena, "%s.%s", mod, attr_name);
const CBMRegisteredType* rt = cbm_registry_lookup_type(ctx->registry, qn);
if (rt) return cbm_type_named(ctx->arena, qn);
// Submodule: if any registered function/type has qn starting
// with "<mod>.<attr>." then mod.attr is itself a module.
const char* prefix = cbm_arena_sprintf(ctx->arena, "%s.", qn);
size_t prefix_len = strlen(prefix);
for (int i = 0; i < ctx->registry->func_count; i++) {
const char* fqn = ctx->registry->funcs[i].qualified_name;
if (fqn && strncmp(fqn, prefix, prefix_len) == 0) {
return cbm_type_module(ctx->arena, qn);
}
}
return cbm_type_unknown();
}
if (obj_type->kind == CBM_TYPE_NAMED) {
const CBMRegisteredFunc* f = py_lookup_attribute(ctx,
obj_type->data.named.qualified_name, attr_name);
if (f) return py_func_return_type(ctx, f->qualified_name);
if (f) return py_func_return_type_recv(ctx, f->qualified_name,
obj_type->data.named.qualified_name);
}
return cbm_type_unknown();
}
@@ -264,6 +357,45 @@ static const CBMType* py_eval_expr_type(PyLSPContext* ctx, TSNode node) {
if (ts_node_is_null(fn)) return cbm_type_unknown();
const char* fk = ts_node_type(fn);
// typing.cast(T, x) -> NAMED(T). typing.assert_type(x, T) -> type-of(x).
// Detect by the call's function expression: matches either bare `cast` /
// `assert_type` (when imported from typing) or `typing.cast` style.
bool is_cast = false;
bool is_assert_type = false;
if (strcmp(fk, "identifier") == 0) {
char* nm = py_node_text(ctx, fn);
if (nm) {
is_cast = strcmp(nm, "cast") == 0;
is_assert_type = strcmp(nm, "assert_type") == 0;
}
} else if (strcmp(fk, "attribute") == 0) {
TSNode aobj = ts_node_child_by_field_name(fn, "object", 6);
TSNode aattr = ts_node_child_by_field_name(fn, "attribute", 9);
if (!ts_node_is_null(aobj) && !ts_node_is_null(aattr) &&
strcmp(ts_node_type(aobj), "identifier") == 0) {
char* mod = py_node_text(ctx, aobj);
char* nm = py_node_text(ctx, aattr);
if (mod && nm && strcmp(mod, "typing") == 0) {
is_cast = strcmp(nm, "cast") == 0;
is_assert_type = strcmp(nm, "assert_type") == 0;
}
}
}
if (is_cast || is_assert_type) {
TSNode args = ts_node_child_by_field_name(node, "arguments", 9);
if (!ts_node_is_null(args) && ts_node_named_child_count(args) >= 2) {
if (is_cast) {
TSNode type_arg = ts_node_named_child(args, 0);
char* type_text = py_node_text(ctx, type_arg);
if (type_text) return py_resolve_annotation(ctx, type_text);
} else {
// assert_type(x, T) returns x's type unchanged (it's a no-op).
TSNode val_arg = ts_node_named_child(args, 0);
return py_eval_expr_type(ctx, val_arg);
}
}
}
if (strcmp(fk, "identifier") == 0) {
char* fname = py_node_text(ctx, fn);
if (!fname) return cbm_type_unknown();
@@ -297,7 +429,8 @@ static const CBMType* py_eval_expr_type(PyLSPContext* ctx, TSNode node) {
if (obj_type->kind == CBM_TYPE_NAMED) {
const CBMRegisteredFunc* f = py_lookup_attribute(ctx,
obj_type->data.named.qualified_name, attr_name);
if (f) return py_func_return_type(ctx, f->qualified_name);
if (f) return py_func_return_type_recv(ctx, f->qualified_name,
obj_type->data.named.qualified_name);
}
}
return cbm_type_unknown();
@@ -531,9 +664,36 @@ static void py_resolve_calls_in(PyLSPContext* ctx, TSNode node) {
/* Resolve a type-annotation text into a CBMType. Tries: scope lookup
* (for imports / type aliases), module-qualified lookup in the registry,
* then falls back to a bare NAMED. */
* then falls back to a bare NAMED. Strips quoted forward-reference
* wrappers like `"Foo"` and the surrounding generic-subscript noise
* (`list[int]` -> `list`). */
static const CBMType* py_resolve_annotation(PyLSPContext* ctx, const char* ann) {
if (!ann || !ann[0]) return cbm_type_unknown();
// Strip outer quotes for forward references: `"Foo"` -> `Foo`.
size_t len = strlen(ann);
if (len >= 2 && (ann[0] == '"' || ann[0] == '\'') && ann[len - 1] == ann[0]) {
char* unquoted = (char*)cbm_arena_alloc(ctx->arena, len - 1);
if (unquoted) {
memcpy(unquoted, ann + 1, len - 2);
unquoted[len - 2] = '\0';
return py_resolve_annotation(ctx, unquoted);
}
}
// Strip generic subscript: `list[int]` -> `list`. The element type
// info is lost in v1; full substitution is Phase 8+.
const char* lb = strchr(ann, '[');
if (lb) {
size_t base_len = (size_t)(lb - ann);
char* base = (char*)cbm_arena_alloc(ctx->arena, base_len + 1);
if (base) {
memcpy(base, ann, base_len);
base[base_len] = '\0';
return py_resolve_annotation(ctx, base);
}
}
const CBMType* t = cbm_scope_lookup(ctx->current_scope, ann);
if (!cbm_type_is_unknown(t)) return t;
if (ctx->module_qn) {
@@ -541,6 +701,14 @@ static const CBMType* py_resolve_annotation(PyLSPContext* ctx, const char* ann)
const CBMRegisteredType* rt = cbm_registry_lookup_type(ctx->registry, qn);
if (rt) return cbm_type_named(ctx->arena, qn);
}
// Common builtin names go to BUILTIN.
static const char* builtins[] = {"int", "str", "bool", "float", "bytes",
"None", "complex", "bytearray", "object", "type", NULL};
for (int i = 0; builtins[i]; i++) {
if (strcmp(ann, builtins[i]) == 0) {
return cbm_type_builtin(ctx->arena, ann);
}
}
return cbm_type_named(ctx->arena, ann);
}
+99 -6
View File
@@ -72,6 +72,9 @@ ALLOWED_MODULES = {
"threading",
"multiprocessing",
"queue",
"urllib",
"http",
"concurrent",
}
@@ -143,15 +146,21 @@ def base_qns(class_node: ast.ClassDef, module_qn: str) -> list[str]:
return out
def parse_module(path: Path, module_qn: str) -> ModuleStubs:
@dataclasses.dataclass
class StarReExport:
target_module: str # e.g. "posixpath" / "ntpath"
def parse_module(path: Path, module_qn: str) -> tuple[ModuleStubs, list[StarReExport]]:
src = path.read_text(encoding="utf-8", errors="replace")
try:
tree = ast.parse(src)
except SyntaxError:
return ModuleStubs(module_qn=module_qn, classes=[], functions=[])
return ModuleStubs(module_qn=module_qn, classes=[], functions=[]), []
classes: list[StubClass] = []
functions: list[StubFunction] = []
star_imports: list[StarReExport] = []
seen_funcs: set[str] = set()
def walk(body: list[ast.stmt], current_module_qn: str) -> None:
@@ -176,19 +185,30 @@ def parse_module(path: Path, module_qn: str) -> ModuleStubs:
short_name=name,
module_qn=current_module_qn,
))
elif isinstance(node, ast.ImportFrom):
# Track `from X import *` for re-export resolution.
if node.module and any(alias.name == "*" for alias in node.names):
star_imports.append(StarReExport(target_module=node.module))
elif isinstance(node, ast.If):
# Flatten version guards.
walk(node.body, current_module_qn)
walk(node.orelse, current_module_qn)
walk(tree.body, module_qn)
return ModuleStubs(module_qn=module_qn, classes=classes, functions=functions)
return ModuleStubs(module_qn=module_qn, classes=classes, functions=functions), star_imports
def iter_module_stubs(stdlib_root: Path) -> Iterable[ModuleStubs]:
def collect_all_stubs(stdlib_root: Path) -> tuple[dict[str, ModuleStubs], dict[str, list[StarReExport]]]:
"""Gather every stub in the allowlist plus any re-export targets they
pull in transitively (e.g. os.path -> posixpath -> genericpath).
Returns (modules, star_imports_per_module). """
modules: dict[str, ModuleStubs] = {}
star_imports: dict[str, list[StarReExport]] = {}
# First pass: walk allowlist
queue: list[tuple[Path, str]] = []
for path in sorted(stdlib_root.rglob("*.pyi")):
rel = path.relative_to(stdlib_root)
# Map file path to module qn.
parts = list(rel.parts)
if parts[-1] == "__init__.pyi":
parts.pop()
@@ -202,7 +222,80 @@ def iter_module_stubs(stdlib_root: Path) -> Iterable[ModuleStubs]:
if top not in ALLOWED_MODULES:
continue
module_qn = ".".join(parts)
yield parse_module(path, module_qn)
queue.append((path, module_qn))
seen_targets: set[str] = set()
while queue:
path, mod_qn = queue.pop()
if mod_qn in modules:
continue
ms, stars = parse_module(path, mod_qn)
modules[mod_qn] = ms
star_imports[mod_qn] = stars
# Enqueue re-export targets (e.g. posixpath, ntpath, genericpath)
for star in stars:
tgt = star.target_module
if tgt in seen_targets:
continue
seen_targets.add(tgt)
tgt_path = stdlib_root / (tgt.replace(".", "/") + ".pyi")
if tgt_path.is_file():
queue.append((tgt_path, tgt))
else:
tgt_init = stdlib_root / tgt.replace(".", "/") / "__init__.pyi"
if tgt_init.is_file():
queue.append((tgt_init, tgt))
return modules, star_imports
def resolve_reexports(modules: dict[str, ModuleStubs],
star_imports: dict[str, list[StarReExport]]) -> None:
"""For each module with `from X import *`, copy X's classes/functions
into the current module under that module's QN. Iterates to a fixed
point to handle re-export chains. """
changed = True
iter_count = 0
while changed and iter_count < 8:
changed = False
iter_count += 1
for mod_qn, stars in star_imports.items():
ms = modules[mod_qn]
for star in stars:
target = modules.get(star.target_module)
if not target:
continue
# Copy target's classes/functions under mod_qn
existing_class_names = {c.short_name for c in ms.classes}
existing_func_names = {f.short_name for f in ms.functions}
for c in target.classes:
if c.short_name in existing_class_names:
continue
ms.classes.append(StubClass(
qualified_name=f"{mod_qn}.{c.short_name}",
short_name=c.short_name,
methods=list(c.methods),
bases=list(c.bases),
))
existing_class_names.add(c.short_name)
changed = True
for f in target.functions:
if f.short_name in existing_func_names:
continue
ms.functions.append(StubFunction(
qualified_name=f"{mod_qn}.{f.short_name}",
short_name=f.short_name,
module_qn=mod_qn,
))
existing_func_names.add(f.short_name)
changed = True
def iter_module_stubs(stdlib_root: Path) -> Iterable[ModuleStubs]:
modules, star_imports = collect_all_stubs(stdlib_root)
resolve_reexports(modules, star_imports)
for mod_qn in sorted(modules.keys()):
yield modules[mod_qn]
C_HEADER = """\
+121
View File
@@ -725,6 +725,120 @@ TEST(pylsp_stdlib_logging_getlogger) {
PASS();
}
/* ── Round 1 — parity push ────────────────────────────────────── */
TEST(pylsp_round1_dotted_import_walk) {
/* `import os.path` — `os` and `os.path` should both be navigable
* through attribute access so `os.path.join('a', 'b')` resolves to
* the registered os.path.join function. */
CBMFileResult *r = extract_py(
"import os.path\n"
"def use():\n"
" return os.path.join('a', 'b')\n");
ASSERT_NOT_NULL(r);
int idx = find_resolved(r, "use", "join");
ASSERT_GTE(idx, 0);
cbm_free_result(r);
PASS();
}
TEST(pylsp_round1_typing_cast) {
/* cast(Foo, x) returns NAMED("Foo"), enabling subsequent method
* dispatch to resolve. */
CBMFileResult *r = extract_py(
"from typing import cast\n"
"class Foo:\n"
" def m(self):\n"
" return 1\n"
"def use(x):\n"
" f = cast(Foo, x)\n"
" return f.m()\n");
ASSERT_NOT_NULL(r);
ASSERT_GTE(require_resolved(r, "use", "m"), 0);
cbm_free_result(r);
PASS();
}
TEST(pylsp_round1_assert_type_passthrough) {
/* assert_type(x, T) is a no-op at runtime; the returned value's type
* is unchanged. We type the result as type-of(x). */
CBMFileResult *r = extract_py(
"from typing import assert_type\n"
"class Foo:\n"
" def m(self):\n"
" return 1\n"
"def use(x: Foo):\n"
" f = assert_type(x, Foo)\n"
" return f.m()\n");
ASSERT_NOT_NULL(r);
ASSERT_GTE(require_resolved(r, "use", "m"), 0);
cbm_free_result(r);
PASS();
}
TEST(pylsp_round1_forward_reference) {
/* def f(x: "Foo") — quoted annotation must resolve as if unquoted. */
CBMFileResult *r = extract_py(
"class Foo:\n"
" def m(self):\n"
" return 1\n"
"def use(x: \"Foo\"):\n"
" return x.m()\n");
ASSERT_NOT_NULL(r);
ASSERT_GTE(require_resolved(r, "use", "m"), 0);
cbm_free_result(r);
PASS();
}
TEST(pylsp_round1_self_return_chains) {
/* class Builder:
* def step1(self) -> Self: return self
* def step2(self) -> Self: return self
* def build(self): return ...
* Builder().step1().step2().build() — must chain through Self. */
CBMFileResult *r = extract_py(
"from typing import Self\n"
"class Builder:\n"
" def step1(self) -> Self:\n"
" return self\n"
" def step2(self) -> Self:\n"
" return self\n"
" def build(self):\n"
" return 1\n"
"def use():\n"
" return Builder().step1().step2().build()\n");
ASSERT_NOT_NULL(r);
/* Each chain link should resolve. We assert the final .build() does. */
ASSERT_GTE(require_resolved(r, "use", "build"), 0);
cbm_free_result(r);
PASS();
}
TEST(pylsp_round1_generic_subscript_annotation) {
/* `def f(items: list[Foo])` — the generic subscript should not
* confuse annotation resolution; we drop the [Foo] part for v1. */
CBMFileResult *r = extract_py(
"from typing import Optional\n"
"class Foo:\n"
" def m(self):\n"
" return 1\n"
"def use(x: Optional[Foo]):\n"
" return x.m()\n");
ASSERT_NOT_NULL(r);
/* x has type Optional which strips to Optional, then we look up
* .m on it. This SHOULD NOT resolve in v1 since Optional is just
* Union — but it shouldn't crash either. We assert no false-positive
* high-confidence resolution against an unrelated method. */
int idx = find_resolved(r, "use", "m");
if (idx >= 0) {
const CBMResolvedCall *rc = &r->resolved_calls.items[idx];
/* If we did resolve, must be against Foo, not something garbage. */
ASSERT(strstr(rc->callee_qn, "Foo") != NULL);
}
cbm_free_result(r);
PASS();
}
/* ── Suite ─────────────────────────────────────────────────────── */
SUITE(py_lsp) {
@@ -768,4 +882,11 @@ SUITE(py_lsp) {
RUN_TEST(pylsp_stdlib_collections_defaultdict);
RUN_TEST(pylsp_stdlib_pathlib_path_method);
RUN_TEST(pylsp_stdlib_logging_getlogger);
/* Round 1 — parity push */
RUN_TEST(pylsp_round1_dotted_import_walk);
RUN_TEST(pylsp_round1_typing_cast);
RUN_TEST(pylsp_round1_assert_type_passthrough);
RUN_TEST(pylsp_round1_forward_reference);
RUN_TEST(pylsp_round1_self_return_chains);
RUN_TEST(pylsp_round1_generic_subscript_annotation);
}