Skip to content

Report converter failure causes explicitly instead of through the error indicator - #157

Merged
quantconnect-kronos[bot] merged 1 commit into
masterfrom
kronos/capture-implicit-conversion-errors
Oct 30, 2026
Merged

quantconnect-kronos[bot] merged 1 commit into
masterfrom
kronos/capture-implicit-conversion-errors

Conversation

@quantconnect-kronos

Copy link
Copy Markdown

Closes #139

What changed

Converter.ToManagedValue raised a TypeError for a throwing implicit operator, and for a deleted type, even when called with setError: false. It did that so MethodBinder.Invoke could show the cause instead of the generic "No method matches given arguments". Any other caller that converts with setError: false and expects a clean error indicator could therefore leak that error into the next unrelated Python call, or fail a successful call with SystemError: ... returned a result with an exception set.

  • Converter: ToManaged/ToManagedValue take a new out string? conversionError and report the cause through it (the two implicit-operator catches and the deleted-type branch). The cause is raised only when setError is true, so setError: false no longer raises on these paths. The existing 4-argument overloads keep their signatures and discard the cause.
  • MethodBinder: Bind returns the cause of the last argument conversion it attempted. It is reset per parameter at the same point where the binder already called Exceptions.Clear(), so it is the cause a pending error would have carried before. When no overload matches, Invoke raises it as the same TypeError. The bind-failure messages are unchanged, including Failed to implicitly convert X to Y (ImplicitConversionErrorHandling) and the generic no-match message.
  • LookUpObject.Contains (in on types with ContainsKey/Count): it set a TypeError but returned 0. Then it called ContainsKey with whatever the failed conversion had left in its result. When that was a value of the wrong type (for example, the intermediate string from a throwing implicit operator T(string)), reflection threw an unhandled ArgumentException that crashed the process. It now returns -1, so key in obj raises the TypeError.

The defensive Exceptions.Clear() calls added in #138 are kept, and their comments are updated. They still protect against sub-paths outside the converter, such as a user decoder that raises and returns false. The private collection helpers (ToArray, ToList, MakeList, ToEnum) and ToPrimitive don't carry the cause. They already cleared element failures when setError is false, so carrying the cause through them would surface new messages rather than keep the current ones.

Tests

New embedding tests:

  • TestConverter.ThrowingImplicitConversionOnlyLeavesErrorWhenSettingErrors: covers both implicit-operator paths with setError true and false. It checks the reported cause, and that an error is pending only when setError is true, with the same message.
  • ThrowingImplicitConversionDoesNotLeakFromConvertibleToType, ...FromDynamicConversion and ...FromArrayContains all fail on master with a pending error.
  • ClassManagerTests.ContainsWithKeyOfWrongTypeRaisesTypeError: covers a look-up container and a dynamic one. On master the in returns False with the error left pending, and the throwing-implicit-key case crashes the test host.

Results:

  • dotnet test --runtime any-x64 src/embed_tests/ --filter "FullyQualifiedName!~SetPythonPath": 1038 passed, 8 skipped, 0 failed. The isolated SetPythonPath run: 1 passed.
  • pytest --runtime netcore tests: 477 passed, 23 skipped, 1 failed. The failure is test_module.py::test_explicit_assembly_load (assembly not found in this environment), which fails the same way on master.
  • dotnet test --runtime any-x64 src/python_tests_runner/: 1 passed, 1 failed (No module named 'tests.utils', an environment path issue that also fails on master).
  • Lean (latest master) built against this Python.Runtime.dll, with the test selection of the Lean Python Unit Tests workflow: 39220 passed, 100 skipped, 0 failed.
  • Tested end to end with a Python algorithm, comparing this build with master:
    • self.securities.contains_key("BAD TICKER") (Symbol's implicit operator from string throws) raises TypeError: Failed to implicitly convert System.String to QuantConnect.Symbol on both builds.
    • After that error, contains_key("SPY") still returns True, and the backtest trades and completes normally.
    • "BAD TICKER" in self.securities crashes the process on master with the ArgumentException above. With this change it raises TypeError: invalid parameter type for sq_contains: ... and the algorithm continues.

The deleted-type branch gets the same treatment as the implicit-operator catches, but no test covers it, because building a deleted type needs a domain reload.

Internal signatures change (Converter.ToManaged/ToManagedValue overloads, MethodBinder.Bind). Public API and bind-failure messages are unchanged. The visible behavior changes are: no stray pending errors after failed setError: false conversions, and TypeError instead of a leaked error or crash from in on look-up types.

🤖 Generated with Claude Code

…or indicator

Converter.ToManagedValue raised a TypeError for a throwing implicit
operator (and for a deleted type) even when called with setError: false,
so that MethodBinder.Invoke could surface it as the bind-failure reason.
Every other caller that converts with setError: false and expects a clean
error indicator could leak that error (ArrayObject.sq_contains,
MetaType.sq_contains, PyObject's IConvertible.ToType and dynamic
TryConvert, LookUpObject.Contains).

The converter now reports that cause through an out conversionError and
only raises it when setError is true. MethodBinder.Bind keeps the cause
of the last conversion it attempted and Invoke raises it as the same
TypeError when no overload matches, so the bind-failure messages are
unchanged.

LookUpObject.Contains also returned 0 after setting a TypeError, and
went on to call ContainsKey with whatever the failed conversion left in
its result, which crashed the process when that was a value of the wrong
type. It now returns -1 so the TypeError is raised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@quantconnect-kronos quantconnect-kronos Bot added kronos:develop:working develop: in progress kronos:review Request review labels Oct 30, 2026
@quantconnect-kronos-reviewer quantconnect-kronos-reviewer Bot added kronos:review:queued review: queued kronos:review:working review: in progress and removed kronos:review Request review kronos:review:queued review: queued labels Oct 30, 2026
@quantconnect-kronos-reviewer
quantconnect-kronos-reviewer Bot marked this pull request as ready for review October 30, 2026 14:10

@quantconnect-kronos-reviewer quantconnect-kronos-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The converter now reports why a conversion failed through conversionError instead of leaving it on the Python error indicator. setError: false now really means nothing is raised, and the bind-failure messages are the same as before.

What I checked

  • Binder messages unchanged. Bind now resets conversionError at the start of each parameter. master cleared the pending error at about the same point: in the op == null branch and after PyObject_Type when methods.Count > 1. With a single candidate, the failing parameter ends the search, so there is nothing to compare. Every way out matches the old pending-error behavior:

    • a later candidate rejected by typematch before converting → generic message
    • the decimal/float probe failing with no implicit fallback → specific message
    • a probe failing but a later ToManaged succeeding → the cause is overwritten with null
    • a full match → binding != null, so the cause is ignored

    The only paths in Bind that return null after a match set their own error. ClassObject's base-constructor Bind uses an empty argument tuple, so it is unaffected.

  • Nested conversions. The discarded causes are in the KeyValuePair key/value conversion and the MakeList element conversion. On master those errors never reached Invoke anyway: type_error and MakeList call Exceptions.Clear() when setError is false. So discarding them changes no message, as the description says.

  • Other setError: false callers: ArrayObject/MetaType sq_contains, IConvertible.ToType, dynamic TryConvert, TupleCodecs, TryAsManagedObject, and the closest-overload diagnosis. None of them read the pending error on purpose, so each one only loses the leak.

  • LookUpObject.Contains: the added return -1 was missing. On master, a key that fails to convert returned 0/1 with a TypeError set. Pure Python code carries on, but the error comes back as a SystemError at the next C boundary, so no caller could rely on getting False. If the leftover value had the wrong type, the call crashed the process instead. MetaType.sq_contains already raises the same TypeError, so the two are now consistent.

  • API: only internal signatures change. The only InternalsVisibleTo targets are this repo's own test assemblies, so downstream code is unaffected.

  • Cost: one extra out argument per conversion. Messages are still built only on failure.

Evidence: CI is green: Build and Test on Python 3.8–3.14, lean-python-unit-tests and lean-python-regression. The new tests cover both implicit-operator paths with setError true and false, the leak-prone callers (three of the four fail on master), and the in crash. ImplicitConversionErrorHandling still passes. The description also covers an end-to-end algorithm run, comparing it with master. As the description says, the deleted-type branch has no test because it needs a domain reload. It follows the same pattern as the operator catches, which are tested.

Other repositories: no source changes are needed. Lean picks this up with its next pythonnet package update.

Risk: risk:low. The runtime change is small and easy to revert. It keeps the existing behavior on every binder path above. Its one visible change replaces a leaked error or a process crash with the TypeError the code was already trying to raise. It is backed by the new tests, green CI including Lean's suites, and an end-to-end run.

@quantconnect-kronos
quantconnect-kronos Bot merged commit 87238d4 into master Oct 30, 2026
9 checks passed
@quantconnect-kronos
quantconnect-kronos Bot deleted the kronos/capture-implicit-conversion-errors branch October 30, 2026 14:10
@quantconnect-kronos-reviewer quantconnect-kronos-reviewer Bot removed the kronos:review:working review: in progress label Oct 30, 2026
@quantconnect-kronos quantconnect-kronos Bot removed the kronos:develop:working develop: in progress label Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:low Low risk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Converter failure contract conflates 'no match' with 'user code threw', forcing error-indicator smuggling under setError: false

0 participants