diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index 6d99ff794..52d2b877a 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -305,10 +305,16 @@ static const char *pxc_python_dotted_base_qn(const pxc_base_scope_t *bs, const c * Returns NULL when the base is not a confidently-known project type; * stdlib and third-party bases land here and keep their raw spelling. */ static const char *pxc_resolve_base_qn(const pxc_base_scope_t *bs, const char *raw, - const char *module_qn) { + const char *module_qn, const char *class_qn) { if (!bs || !bs->reg || !raw || !raw[0]) { return NULL; } + /* A base of a class nested in a class body: an earlier sibling first. */ + const char *sibling = + cbm_registry_resolve_python_class_body_base(bs->reg, bs->gbuf, class_qn, raw, bs->lang); + if (sibling) { + return sibling; + } const char *dotted = pxc_python_dotted_base_qn(bs, raw); if (dotted) { return dotted; @@ -338,7 +344,8 @@ static const char *pxc_resolve_base_qn(const pxc_base_scope_t *bs, const char *r * source spelling. Unresolved entries pass through verbatim so a base the * registry does not know keeps working exactly as before. */ static const char *pxc_join_base_qns(CBMArena *arena, const char *const *bases, - const pxc_base_scope_t *bs, const char *module_qn) { + const pxc_base_scope_t *bs, const char *module_qn, + const char *class_qn) { if (!bases || !bases[0]) { return NULL; } @@ -352,7 +359,7 @@ static const char *pxc_join_base_qns(CBMArena *arena, const char *const *bases, return pxc_join_pipe(arena, bases); } for (int i = 0; i < count; i++) { - const char *qn = pxc_resolve_base_qn(bs, bases[i], module_qn); + const char *qn = pxc_resolve_base_qn(bs, bases[i], module_qn, class_qn); resolved[i] = qn ? qn : bases[i]; } resolved[count] = NULL; @@ -520,9 +527,10 @@ static int pxc_build_lsp_def(CBMArena *arena, const CBMDefinition *src, const ch /* Languages whose cross registrars read embedded_types as QNs get their * bases resolved against the project registry; everyone else keeps the * raw source spelling their own registrar already knows how to handle. */ - dst->embedded_types = (bs && bs->reg && pxc_lang_resolves_base_qns(lang)) - ? pxc_join_base_qns(arena, src->base_classes, bs, module_qn) - : pxc_join_pipe(arena, src->base_classes); + dst->embedded_types = + (bs && bs->reg && pxc_lang_resolves_base_qns(lang)) + ? pxc_join_base_qns(arena, src->base_classes, bs, module_qn, src->qualified_name) + : pxc_join_pipe(arena, src->base_classes); dst->signature_param_types = NULL; dst->signature_param_count = 0; if (src->signature_param_types && src->signature_param_count > 0) { @@ -727,7 +735,7 @@ static void pxc_fold_py_field_types(CBMArena *arena, const CBMFileResult *result fields[f].type_qn = NULL; CBMLSPDef *owner = ft->class_qn ? (CBMLSPDef *)cbm_ht_get(by_qn, ft->class_qn) : NULL; const char *name = owner ? pxc_py_annotation_type_name(arena, ft->type_text) : NULL; - const char *qn = name ? pxc_resolve_base_qn(bs, name, module_qn) : NULL; + const char *qn = name ? pxc_resolve_base_qn(bs, name, module_qn, NULL) : NULL; if (!qn || !ft->field_name || !ft->field_name[0]) { continue; } diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 298b6b8d2..8ff578f5c 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -3910,13 +3910,17 @@ static void resolve_def_inherits(resolve_ctx_t *rc, resolve_worker_state_t *ws, if (cbm_pipeline_php_vendor_bound(php_vendor, def->base_classes[b])) { continue; } - const char *bqn = - resolve_as_class(rc->registry, def->base_classes[b], mq, ik, iv, ic, fs->lang); - /* Same external-base gate as the sequential semantic pass. */ - if (!bqn || - cbm_python_external_base_contradicts(fs->lang, fs->imports, def->base_classes[b], bqn, - rc->main_gbuf, rc->project_name, fs->rel)) { - continue; + /* Same order as the sequential semantic pass: an earlier sibling of + * the class body first (no external-base gate), then the registry. */ + const char *bqn = cbm_registry_resolve_python_class_body_base( + rc->registry, rc->main_gbuf, def->qualified_name, def->base_classes[b], fs->lang); + if (!bqn) { + bqn = resolve_as_class(rc->registry, def->base_classes[b], mq, ik, iv, ic, fs->lang); + if (!bqn || cbm_python_external_base_contradicts( + fs->lang, fs->imports, def->base_classes[b], bqn, rc->main_gbuf, + rc->project_name, fs->rel)) { + continue; + } } const cbm_gbuf_node_t *bn = cbm_gbuf_find_by_qn(rc->main_gbuf, bqn); if (bn && node->id != bn->id) { diff --git a/src/pipeline/pass_semantic.c b/src/pipeline/pass_semantic.c index 186b2dfdc..0c009e737 100644 --- a/src/pipeline/pass_semantic.c +++ b/src/pipeline/pass_semantic.c @@ -643,12 +643,18 @@ static void sem_process_def_edges(cbm_pipeline_ctx_t *ctx, const CBMDefinition * if (cbm_pipeline_php_vendor_bound(php_vendor, def->base_classes[b])) { continue; } - const char *base_qn = resolve_as_class(ctx->registry, def->base_classes[b], module_qn, - imp_keys, imp_vals, imp_count, lang); - if (!base_qn || cbm_python_external_base_contradicts( - fi->language, &result->imports, def->base_classes[b], base_qn, - ctx->gbuf, ctx->project_name, fi->rel_path)) { - continue; /* unresolved, or an external base (`unittest.TestCase`) */ + /* An earlier sibling of the class body shadows every module or + * imported name, so it needs no external-base gate. */ + const char *base_qn = cbm_registry_resolve_python_class_body_base( + ctx->registry, ctx->gbuf, def->qualified_name, def->base_classes[b], lang); + if (!base_qn) { + base_qn = resolve_as_class(ctx->registry, def->base_classes[b], module_qn, imp_keys, + imp_vals, imp_count, lang); + if (!base_qn || cbm_python_external_base_contradicts( + fi->language, &result->imports, def->base_classes[b], base_qn, + ctx->gbuf, ctx->project_name, fi->rel_path)) { + continue; /* unresolved, or an external base (`unittest.TestCase`) */ + } } const cbm_gbuf_node_t *base_node = cbm_gbuf_find_by_qn(ctx->gbuf, base_qn); if (base_node && node->id != base_node->id) { diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 0965e0398..5e5cd9506 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -270,6 +270,14 @@ cbm_resolution_t cbm_registry_resolve_call_scoped(const cbm_registry_t *r, const const char **import_map_vals, int import_map_count, CBMLanguage lang, const char *scope_qn); +/* Python: the bare base `base` of the class `class_qn` nested in a class body + * binds the sibling class of that body (`.`) when the + * sibling is defined earlier in the same file (Python evaluates the bases in + * the class body). Exact lookups only; the registry-owned sibling QN, or NULL + * to resolve the base as before. Every base-class resolver asks this first. */ +const char *cbm_registry_resolve_python_class_body_base(const cbm_registry_t *r, + const cbm_gbuf_t *gb, const char *class_qn, + const char *base, CBMLanguage lang); #ifdef CBM_ENABLE_TEST_SEAMS void cbm_pxc_test_import_probe_fail_after(int successful_allocations); #endif diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 5dda868a4..3269b9676 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -1383,6 +1383,47 @@ const char *cbm_registry_value_target(const cbm_registry_t *r, const char *qn) { return owned ? owned : qn; } +/* Python evaluates a class statement's bases in the namespace of the body + * that encloses it. For a class nested in a class, a bare base naming a class + * defined EARLIER in that same class body is that sibling + * (`class IntegrityError(DatabaseError)` in sqlalchemy's asyncpg adapter), + * whatever same-named classes the project has elsewhere. A sibling defined + * later is not bound yet when the base is evaluated. Exact lookups only. */ +const char *cbm_registry_resolve_python_class_body_base(const cbm_registry_t *r, + const cbm_gbuf_t *gb, const char *class_qn, + const char *base, CBMLanguage lang) { + if (lang != CBM_LANG_PYTHON || !r || !gb || !class_qn || !base || !base[0] || + strchr(base, '.')) { + return NULL; + } + const char *dot = strrchr(class_qn, '.'); + if (!dot || dot == class_qn) { + return NULL; + } + char sibling[CBM_SZ_1K]; + int enclosing_len = (int)(dot - class_qn); + int n = snprintf(sibling, sizeof(sibling), "%.*s", enclosing_len, class_qn); + if (n < 0 || (size_t)n >= sizeof(sibling) || + !cbm_label_is_type_like(cbm_registry_label_of(r, sibling))) { + return NULL; /* not nested in a class body (module level, or a def) */ + } + n = snprintf(sibling, sizeof(sibling), "%.*s.%s", enclosing_len, class_qn, base); + if (n < 0 || (size_t)n >= sizeof(sibling) || strcmp(sibling, class_qn) == 0) { + return NULL; + } + const char *owned = cbm_ht_get_key(r->exact, sibling); + if (!owned || !cbm_label_is_type_like(cbm_registry_label_of(r, owned))) { + return NULL; + } + const cbm_gbuf_node_t *cls = cbm_gbuf_find_by_qn(gb, class_qn); + const cbm_gbuf_node_t *sib = cbm_gbuf_find_by_qn(gb, owned); + if (!cls || !sib || !cls->file_path || !sib->file_path || + strcmp(cls->file_path, sib->file_path) != 0 || sib->start_line >= cls->start_line) { + return NULL; + } + return owned; +} + int cbm_registry_find_by_name(const cbm_registry_t *r, const char *name, const char ***out, int *count) { if (!r || !out || !count) { diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index a7b62caeb..7bb775025 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -15416,6 +15416,164 @@ TEST(pipeline_python_init_function_shares_submodule_qn_parallel_issue1548) { return pyn_run_init_collision(52); } +/* ── F15: a nested class's bare base resolves in its class body first ── */ + +/* The sqlalchemy asyncpg shape: an adapter class re-declares the DBAPI + * exception hierarchy as nested classes, each based on an EARLIER sibling + * spelled bare (`class IntegrityError(DatabaseError)`). Python evaluates a + * class statement's bases in the enclosing class body, so the sibling wins + * over the same-named module classes of exc.py, which the adapter imports + * as a module (so a name guess prefers them, as on sqlalchemy once its + * relative `from ... import exc` resolves). A sibling defined LATER in the + * body is not bound yet: `Early(Later)` names the module-level Later. */ +static const char pyb_exc_py[] = "class DBAPIError(Exception):\n" + " pass\n" + "\n" + "class DatabaseError(DBAPIError):\n" + " pass\n" + "\n" + "class IntegrityError(DatabaseError):\n" + " pass\n"; + +static const char pyb_adapter_py[] = "import exc\n" + "from exc import DBAPIError\n" + "\n" + "class Later(Exception):\n" + " pass\n" + "\n" + "class AsyncAdapt_dbapi:\n" + " class Error(DBAPIError):\n" + " pass\n" + "\n" + " class DatabaseError(Error):\n" + " pass\n" + "\n" + " class IntegrityError(DatabaseError):\n" + " pass\n" + "\n" + " class NotNullViolationError(IntegrityError):\n" + " pass\n" + "\n" + " class Early(Later):\n" + " pass\n" + "\n" + " class Later(Exception):\n" + " pass\n"; + +/* A second adapter with the same nested names, as sqlalchemy has one per + * driver: a name-only guess has several same-named candidates to pick. */ +static const char pyb_adapter2_py[] = "class AsyncAdapt_other_dbapi:\n" + " class Error(Exception):\n" + " pass\n" + "\n" + " class DatabaseError(Error):\n" + " pass\n" + "\n" + " class IntegrityError(DatabaseError):\n" + " pass\n"; + +static int pyb_run_class_body_base(int fillers) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_py_cbbase_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "exc.py", pyb_exc_py); + write_temp_file(tmp, "adapter.py", pyb_adapter_py); + write_temp_file(tmp, "adapter2.py", pyb_adapter2_py); + for (int i = 0; i < fillers; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "filler%d.py", i); + snprintf(body, sizeof(body), "def filler%d():\n return %d\n", i, i); + write_temp_file(tmp, name, body); + } + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + if (fillers > 0) { + cbm_setenv("CBM_WORKERS", "4", 1); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/cbbase.db", tmp); + int rc[2] = {-1, -1}; + bool db_sib[2] = {false, false}; + bool db_mod[2] = {true, true}; + bool ie_sib[2] = {false, false}; + bool ie_mod[2] = {true, true}; + bool nn_sib[2] = {false, false}; + bool err_imp[2] = {false, false}; + bool early_mod[2] = {false, false}; + bool early_later_sib[2] = {true, true}; + bool other_sib[2] = {false, false}; + for (int run = 0; run < 2; run++) { + if (run == 1) { + char path[512]; + snprintf(path, sizeof(path), "%s/adapter.py", tmp); + th_append_file(path, "\n# edited: the second run re-indexes incrementally\n"); + } + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + rc[run] = p ? cbm_pipeline_run(p) : -1; + cbm_store_t *s = rc[run] == 0 ? cbm_store_open_path(db_path) : NULL; + if (s) { + const char *pr = cbm_pipeline_project_name(p); + const char *cls = "adapter.AsyncAdapt_dbapi"; + char a[256]; + char b[256]; + snprintf(a, sizeof(a), "%s.DatabaseError", cls); + snprintf(b, sizeof(b), "%s.Error", cls); + db_sib[run] = pyn_edge(s, pr, a, b, "INHERITS"); + db_mod[run] = pyn_edge(s, pr, a, "adapter2.AsyncAdapt_other_dbapi.Error", "INHERITS"); + snprintf(a, sizeof(a), "%s.IntegrityError", cls); + snprintf(b, sizeof(b), "%s.DatabaseError", cls); + ie_sib[run] = pyn_edge(s, pr, a, b, "INHERITS"); + ie_mod[run] = pyn_edge(s, pr, a, "exc.DatabaseError", "INHERITS"); + snprintf(a, sizeof(a), "%s.NotNullViolationError", cls); + snprintf(b, sizeof(b), "%s.IntegrityError", cls); + nn_sib[run] = pyn_edge(s, pr, a, b, "INHERITS"); + snprintf(a, sizeof(a), "%s.Error", cls); + err_imp[run] = pyn_edge(s, pr, a, "exc.DBAPIError", "INHERITS"); + snprintf(a, sizeof(a), "%s.Early", cls); + snprintf(b, sizeof(b), "%s.Later", cls); + early_mod[run] = pyn_edge(s, pr, a, "adapter.Later", "INHERITS"); + early_later_sib[run] = pyn_edge(s, pr, a, b, "INHERITS"); + other_sib[run] = pyn_edge(s, pr, "adapter2.AsyncAdapt_other_dbapi.IntegrityError", + "adapter2.AsyncAdapt_other_dbapi.DatabaseError", "INHERITS"); + cbm_store_close(s); + } + if (p) { + cbm_pipeline_free(p); + } + } + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + for (int run = 0; run < 2; run++) { + ASSERT_EQ(rc[run], 0); + ASSERT_TRUE(db_sib[run]); /* DatabaseError(Error): the earlier sibling */ + ASSERT_FALSE(db_mod[run]); /* ... not the other adapter's Error */ + ASSERT_TRUE(ie_sib[run]); /* IntegrityError(DatabaseError): the sibling */ + ASSERT_FALSE(ie_mod[run]); /* ... not exc.DatabaseError */ + ASSERT_TRUE(nn_sib[run]); /* a chain of siblings */ + ASSERT_TRUE(err_imp[run]); /* control: a non-sibling base keeps the import */ + ASSERT_TRUE(early_mod[run]); /* a LATER sibling is not bound yet: module Later */ + ASSERT_FALSE(early_later_sib[run]); /* ... never the later sibling */ + ASSERT_TRUE(other_sib[run]); /* the second adapter binds its own sibling */ + } + PASS(); +} + +TEST(pipeline_python_class_body_sibling_base) { + return pyb_run_class_body_base(0); +} + +TEST(pipeline_python_class_body_sibling_base_parallel) { + return pyb_run_class_body_base(52); +} + /* #1548 keeps TESTS for calls made through a test's own nested helper: the * call now starts at the extracted helper def (not at the test FILE node, as * before #1548), so TESTS is attributed to the enclosing test function. A @@ -25114,6 +25272,8 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_super_follows_c3_mro_parallel); RUN_TEST(pipeline_python_init_function_shares_submodule_qn_issue1548); RUN_TEST(pipeline_python_init_function_shares_submodule_qn_parallel_issue1548); + RUN_TEST(pipeline_python_class_body_sibling_base); + RUN_TEST(pipeline_python_class_body_sibling_base_parallel); RUN_TEST(pipeline_python_nested_helper_keeps_tests_issue1548); RUN_TEST(pipeline_python_nested_helper_keeps_tests_parallel_issue1548); RUN_TEST(pipeline_python_decorated_nested_class_extracted);