Skip to content

Use tp_getattro for AttributeError suggestions instead of the __getattr__ hook#126

Merged
jhonabreul merged 2 commits into
QuantConnect:masterfrom
jhonabreul:attribute-error-suggestions-tp-getattro
Jul 2, 2026
Merged

Use tp_getattro for AttributeError suggestions instead of the __getattr__ hook#126
jhonabreul merged 2 commits into
QuantConnect:masterfrom
jhonabreul:attribute-error-suggestions-tp-getattro

Conversation

@jhonabreul

Copy link
Copy Markdown
Collaborator

Summary

Reimplements the AttributeError member-name suggestions (added in #124, v2.0.55) on top of a ClassObject.tp_getattro override, and removes the miss-only __getattr__ hook. Behavior is unchanged — a missing attribute on a .NET object still gets a snake_case Did you mean ...? hint:

>>> System.String("x").lenght
AttributeError: 'String' object has no attribute 'lenght' Did you mean: 'length'?

Version bumped to 2.0.56.

Why replace the hook

#124 installed a shared __getattr__ on every reflected type and manually rewired tp_getattro to CPython's private slot_tp_getattr_hook (address scraped from a probe class). That is a lot of low-level CPython surgery on the type system, done for one reason: to keep the successful attribute-access ("hit") path free of the small per-access cost that a tp_getattro override adds.

Benchmarking showed that cost is not worth the complexity:

  • The per-access overhead of the tp_getattro override is ~17–19 ns/access.
  • On realistic Lean backtests and a 50-algorithm regression subset it was within run-to-run noise (< 0.1% aggregate) — only a pathological loop dominated by raw C# property reads (~117M accesses) showed a measurable ~9%.

Given the negligible real-world impact, this PR drops the slot manipulation entirely and keeps the enrichment in a straightforward tp_getattro that delegates to PyObject_GenericGetAttr and only does work on a miss.

Diff vs master

 src/runtime/AttributeErrorHint.cs | 109 ------  (removed: hook + manual slot wiring)
 src/runtime/PythonEngine.cs       |   7 ---     (no Initialize/Shutdown hook calls)
 src/runtime/TypeManager.cs        |   4 --      (no per-type install)
 src/runtime/Types/ClassBase.cs    |  73 +--      (inline suggestion building)
 src/runtime/Types/ClassObject.cs  |  15 ++       (tp_getattro override)

DynamicClassObject continues to enrich on its own attribute-miss path (unchanged). Suggestions are ranked by case-insensitive Levenshtein distance + substring containment, dunder names are skipped, and member names are emitted in snake_case (const/static-readonly → UPPER_CASE) via the existing ToSnakeCase helpers.

Testing

  • Python tests (tests/test_class.py) and embedding tests (src/embed_tests/TestPropertyAccess.cs) — unchanged from Suggest similar member names on AttributeError for .NET objects #124 and passing.
  • Full Lean QuantConnect.Tests Python/Pandas unit suite and all Python/* regression algorithms were previously run against this feature with zero regressions vs master (all failures pre-existing/environmental).

🤖 Generated with Claude Code

jhonabreul and others added 2 commits July 1, 2026 18:06
Reimplement the AttributeError member-name suggestions on top of a
ClassObject.tp_getattro override, replacing the miss-only __getattr__
hook (AttributeErrorHint) that was merged in QuantConnect#124.

The hook installed a shared __getattr__ on every reflected type and
manually rewired tp_getattro to CPython's slot_tp_getattr_hook. That
surgery is significantly more invasive for no measurable real-world
benefit: the per-access cost it avoids (~17 ns) is lost in the noise on
realistic Lean workloads. This version keeps the enrichment entirely in
a tp_getattro override that delegates to PyObject_GenericGetAttr and only
does work on a miss, and drops all the slot manipulation.

Behaviour and messages are unchanged (snake_case "Did you mean ...?"
suggestions); the existing Python and embedding tests are untouched and
still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jhonabreul jhonabreul merged commit cccbaf7 into QuantConnect:master Jul 2, 2026
4 checks passed
@jhonabreul jhonabreul deleted the attribute-error-suggestions-tp-getattro branch July 2, 2026 13:31
jhonabreul added a commit that referenced this pull request Jul 6, 2026
…e re-land of the miss-only hook) (#129)

* Revert to the miss-only __getattr__ hook for AttributeError suggestions

Revert the code changes of #126, restoring the AttributeErrorHint
miss-only __getattr__ hook approach from #124: successful attribute
accesses go straight through CPython's native generic getattr with no
managed transition; only a miss enters managed code to build the
"Did you mean ...?" suggestions.

The package <Version> is left at 2.0.56 (not reverted).

Note: as restored here, the hook still has the off-GIL callback defect
that crashed Lean's CI on 2.0.55 (the __clr_attr_msg__ delegate is
invoked through MethodBinder, which releases the GIL); it is fixed in
the follow-up commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix off-GIL AttributeError-hint callback: use a native __getattr__ thunk

The __getattr__ hook restored from #124 exposed the hint builder to
Python as a .NET delegate (Func<PyObject, string, string>). Python
invoked it through DelegateObject.tp_call -> MethodBinder.Invoke, and
MethodBinder releases the GIL around every reflected invocation
(allow_threads defaults to true). The callback therefore ran CPython
C-API calls (GetPythonType, GetManagedString, ...) without holding the
GIL on every attribute miss (hasattr, getattr with default, typos). It
usually survived by luck, but segfaulted whenever the pythonnet
Finalizer fired mid-callback: Finalizer.DisposeAll() starts with
PyErr_Fetch, which dereferences the current thread state - NULL when
the GIL is not held. This is what crashed Lean's CI test host on
2.0.55 after all 35k tests passed.

Replace the Python-function-plus-delegate pair with a native method
descriptor: a PyMethodDef (METH_VARARGS) around a managed thunk,
turned into __getattr__ via PyDescr_NewMethod (newly bound). CPython's
slot_tp_getattr_hook now calls the managed hook directly as a native
method call with the GIL held - MethodBinder is never involved. The
PyMethodDef and thunk are allocated once and kept for the process
lifetime, since descriptors reference them and can outlive engine
shutdown bookkeeping.

Verified with an instrumented build (PyGILState_Check inside
BuildMissingAttributeMessage): the delegate-based hook reports
"GIL held: False" on every miss; this version reports "GIL held: True".
Also survives a 20s stress run of concurrent attribute misses plus
finalizer churn across 6 threads, and behaves identically for hasattr/
getattr-with-default, dunder probes, Python subclasses with their own
__getattr__, and suggestion messages.

Add a regression test asserting the installed __getattr__ is a native
method_descriptor, which is the property that keeps the callback out of
MethodBinder's allow-threads path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants