Skip to content

Handle circular references while pretty printing - #177

Open
Dcastroro wants to merge 3 commits into
samuelcolvin:mainfrom
Dcastroro:fix/recursive-pretty-print-157
Open

Dcastroro wants to merge 3 commits into
samuelcolvin:mainfrom
Dcastroro:fix/recursive-pretty-print-157

Conversation

@Dcastroro

@Dcastroro Dcastroro commented Oct 19, 2026 •

Copy link
Copy Markdown

Fixes #157. Formatting a dictionary or other expanded object that points back to itself can exhaust the recursion limit and prevent debug() from showing its contents.

Track object identities only along the active formatting path. Render a repeated ancestor as <Recursion on TYPE>, then continue with the remaining fields. Remove identities in finally, so shared children are formatted normally in each branch and formatting errors do not leave stale state. The same tracking handles reentrant calls from __repr__ without changing the existing stream restoration.

Adds documentation and 12 tests covering self/mutual container cycles, dataclasses with and without slots, __pretty__, a self-yielding generator, reentrant __repr__, repeated shared children, recovery after an exception and debug.format().

Validation on Windows:

  • New tests against the unchanged base: 10 failed, 2 passed; with the fix: 12 passed.
  • Full suite on Python 3.13.14: 160 passed, 4 skipped, 1 xfailed, 1 xpassed. The original suite had 148 passed with the same remaining statuses.
  • Full suite on Python 3.10.20: 160 passed, 4 skipped, 2 xfailed, using the existing frozen lockfile.
  • Ruff checks/format, strict basedpyright (with the test virtualenv selected explicitly), documentation examples and strict Zensical build passed. Full diff review and git diff --check passed.

The inherited NO_COLOR environment variable causes the existing test_print_is_tty to fail on both base and patched code. The full-suite results above omit only that variable from the test subprocess environment; repository settings and tests are unchanged. The hosted OS/Python CI matrix remains to be verified.

No new dependencies, credentials, network/API calls in the new tests, or changes to workflows/security checks.

AI disclosure: Codex investigated the issue, implemented the change and tests, reviewed the diff, and ran the validation above.


Summary by cubic

Fixes recursive objects causing an infinite recursion error in debug() and PrettyFormat. Repeated ancestors are now rendered as <Recursion on TYPE> and formatting continues with the remaining fields.

  • Tracks object identities only on the active formatting path, using finally so shared children format normally in each branch and errors don't leave stale state.
  • Handles reentrant calls from __repr__, containers, dataclasses, __pretty__, and generators; values within simple_cutoff keep Python's compact ... recursion notation.
  • Adds 12 tests and documentation examples.

Written for commit 9b2da78. Summary will update on new commits.

View guided diff

Copilot AI balanced review requested due to automatic review settings October 19, 2026 15:33

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="devtools/prettier.py">

<violation number="1" location="devtools/prettier.py:119">
P2: Short self-referential containers still bypass the new recursion marker through the `simple_cutoff` fast path. Ensure recursive containers are dispatched through the path-aware formatter instead of returning `repr(value)` unchanged.</violation>
</file>

Reply to a comment to ask cubic a question or push back. It learns from your replies.

View guided diff | Re-trigger cubic

Comment thread devtools/prettier.py

self._active_ids.add(value_id)
try:
self._format_value(value, indent_current)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Short self-referential containers still bypass the new recursion marker through the simple_cutoff fast path. Ensure recursive containers are dispatched through the path-aware formatter instead of returning repr(value) unchanged.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At devtools/prettier.py, line 119:

<comment>Short self-referential containers still bypass the new recursion marker through the `simple_cutoff` fast path. Ensure recursive containers are dispatched through the path-aware formatter instead of returning `repr(value)` unchanged.</comment>

<file context>
@@ -108,6 +109,18 @@ def _format(self, value: Any, indent_current: int, indent_first: bool) -> None:
+
+        self._active_ids.add(value_id)
+        try:
+            self._format_value(value, indent_current)
+        finally:
+            self._active_ids.remove(value_id)
</file context>

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Checked this with self-referential lists and dictionaries. The compact path already terminates safely using Python's recursion-safe repr (for example, [[...]]), so forcing it through the expanded formatter would change the existing simple_cutoff behavior without fixing an additional recursion failure. In adfa031 I added four cases covering both compact and forced-expanded output and clarified this distinction in the documentation. The expanded path uses <Recursion on TYPE>; compact output remains unchanged. Full suite: 164 passed, 4 skipped, 1 xfailed, 1 xpassed on Windows/Python 3.13 (same clean NO_COLOR subprocess setup documented in the PR); Ruff and strict basedpyright passed. Prepared and validated with Codex assistance.

Copilot AI 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.

🔵 Needs a closer look

It modifies the central _format recursion path used by every debug()/pformat call and adds shared mutable state on the module-level singleton, so a human should confirm the broad formatting impact despite no defects being found.

No open issues 🎉

What changed in this PR

This PR fixes issue #157, where debug() / PrettyFormat could exhaust the recursion limit when formatting an object that references itself (directly or mutually). The fix tracks the object identities currently on the active formatting path and renders a repeated ancestor as <Recursion on TYPE> instead of re-expanding it, then continues formatting the remaining fields.

The implementation splits the existing _format into a thin guard (_format) plus the original body (_format_value). The guard records id(value) in a per-instance _active_ids set before recursing and removes it in a finally, so shared (non-ancestor) children still format normally in each branch and formatting errors don't leave stale state. Because the id set is shared across the existing reentrant-stream mechanism, recursion triggered from within a __repr__ is also handled.

Changes:

  • Add active-path identity tracking in PrettyFormat._format, emitting <Recursion on TYPE> for repeated ancestors and cleaning up via finally.
  • Add docs/usage.md note describing the new recursion handling.
  • Add tests/test_recursion.py with 12 tests covering self/mutual cycles, dataclasses (with/without slots), __pretty__, generators, reentrant __repr__, shared children, error recovery, and debug.format().
File Description
devtools/​prettier.py Introduces _active_ids guard in _format and extracts the formatting body into _format_value to break circular-reference recursion.
tests/​test_recursion.py New test module validating recursion rendering across containers, dataclasses, __pretty__, generators, reentrancy, shared children, and error recovery.
docs/​usage.md Documents that circular references render as <Recursion on TYPE> while other fields and shared objects still format normally.

Review effort: Balanced

Assisted-by: OpenAI GPT-6 (Codex)

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 2 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread docs/usage.md Outdated
Assisted-by: OpenAI GPT-6 (Codex)

This branch has not been deployed

No deployments
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.

Fix recursion problem in debug()

2 participants