Skip to content

RecursionError in __getattr__ Dynamic Import System #141

@mencnert

Description

@mencnert

Summary

Bug Report: The mergepythonclient library (version 2.3.1) contains a critical flaw in its dynamic import system that causes infinite recursion when tools like freezegun attempt to introspect module attributes. This results in a RecursionError: maximum recursion depth exceeded and prevents the library
from being used in testing environments that rely on module introspection.

Environment

  • Package: mergepythonclient version 2.3.1
  • Python: 3.13.2 (also affects other Python versions)
  • Platform: All platforms (tested on macOS Darwin 24.6.0)
  • Trigger: Module introspection tools (e.g., freezegun, potentially others)

Root Cause

The issue is located in /merge/resources/init.py where the _dynamic_imports dictionary contains self-referential entries:

  _dynamic_imports: typing.Dict[str, str] = {
      "accounting": ".",  # ← Problematic: "." refers to the same module
      "ats": ".",
      "crm": ".",
      "filestorage": ".",
      "hris": ".",
      "ticketing": ".",
  }

When the getattr method is triggered:

  1. import_module(".", package) imports the same module (merge.resources)
  2. getattr(module, attr_name) attempts to access the attribute on the same module
  3. This triggers getattr again → infinite recursion

Reproduction Steps

"""
Reproduces the RecursionError in mergepythonclient 2.3.1
Save as reproduce_bug.py and run: python reproduce_bug.py
"""

import sys
import traceback


def test_recursion_bug():
    try:
        from freezegun import freeze_time

        @freeze_time("2025-01-01T00:00:00")
        def dummy_test():
            return "success"

        result = dummy_test()
        print("✅ Test passed:", result)

    except RecursionError as e:
        print("❌ RecursionError occurred:")
        print(f"Error: {str(e)}")
        tb = traceback.format_exc()
        print(tb)
        return False
    except Exception as e:
        print(f"❌ Other error: {e}")
        return False

    return True


if __name__ == "__main__":
    print("Testing mergepythonclient recursion bug...")
    print(f"Python version: {sys.version}")

    # Check if merge is installed
    try:
        import merge

        print(f"Merge version: {getattr(merge, '__version__', 'unknown')}")
    except ImportError:
        print(
            "❌ mergepythonclient not installed. Install with: pip install mergepythonclient"
        )
        sys.exit(1)

    success = test_recursion_bug()
    if not success:
        print("\n🐛 Bug confirmed: RecursionError in mergepythonclient")
        sys.exit(1)

Stack trace

Traceback (most recent call last):
  File "/Users/user/devel/bug/test.py", line 18, in test_recursion_bug
    result = dummy_test()
  File "/Users/user/devel/bug/.venv/lib/python3.13/site-packages/freezegun/api.py", line 901, in wrapper
    with self as time_factory:
         ^^^^
  File "/Users/user/devel/bug/.venv/lib/python3.13/site-packages/freezegun/api.py", line 717, in __enter__
    return self.start()
           ~~~~~~~~~~^^
  File "/Users/user/devel/bug/.venv/lib/python3.13/site-packages/freezegun/api.py", line 806, in start
    module_attrs = _get_cached_module_attributes(module)
  File "/Users/user/devel/bug/.venv/lib/python3.13/site-packages/freezegun/api.py", line 144, in _get_cached_module_attributes
    _setup_module_cache(module)
    ~~~~~~~~~~~~~~~~~~~^^^^^^^^
  File "/Users/user/devel/bug/.venv/lib/python3.13/site-packages/freezegun/api.py", line 123, in _setup_module_cache
    all_module_attributes = _get_module_attributes(module)
  File "/Users/user/devel/bug/.venv/lib/python3.13/site-packages/freezegun/api.py", line 112, in _get_module_attributes
    attribute_value = getattr(module, attribute_name)
  File "/Users/user/devel/bug/.venv/lib/python3.13/site-packages/merge/__init__.py", line 33, in __getattr__
    result = getattr(module, attr_name)
  File "/Users/user/devel/bug/.venv/lib/python3.13/site-packages/merge/resources/__init__.py", line 26, in __getattr__
    result = getattr(module, attr_name)
  File "/Users/user/devel/bug/.venv/lib/python3.13/site-packages/merge/resources/__init__.py", line 26, in __getattr__
    result = getattr(module, attr_name)
  File "/Users/user/devel/bug/.venv/lib/python3.13/site-packages/merge/resources/__init__.py", line 26, in __getattr__
    result = getattr(module, attr_name)
  [Previous line repeated 985 more times]
  File "/Users/user/devel/bug/.venv/lib/python3.13/site-packages/merge/resources/__init__.py", line 25, in __getattr__
    module = import_module(module_name, __package__)
  File "/Users/user/.local/share/mise/installs/python/3.13.2/lib/python3.13/importlib/__init__.py", line 88, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<frozen importlib._bootstrap>", line 1384, in _gcd_import
RecursionError: maximum recursion depth exceeded

Impact

  • Critical: Prevents use of mergepythonclient in test environments using freezegun
  • Widespread: Affects any tool that performs module introspection

Suggested Fix

Replace the self-referential "." entries in /merge/resources/init.py:

Current (Broken) Code:

  _dynamic_imports: typing.Dict[str, str] = {
      "accounting": ".",
      "ats": ".",
      "crm": ".",
      "filestorage": ".",
      "hris": ".",
      "ticketing": ".",
  }

Suggested Fix:

  _dynamic_imports: typing.Dict[str, str] = {
      "accounting": ".accounting",
      "ats": ".ats",
      "crm": ".crm",
      "filestorage": ".filestorage",
      "hris": ".hris",
      "ticketing": ".ticketing",
  }

Alternative Safer Approach:

Add recursion protection to the getattr method:

  _in_getattr = set()

  def __getattr__(attr_name: str) -> typing.Any:
      if attr_name in _in_getattr:
          raise AttributeError(f"Recursive access detected for {attr_name}")

      module_name = _dynamic_imports.get(attr_name)
      if module_name is None:
          raise AttributeError(f"No {attr_name} found in _dynamic_imports for module name -> {__name__}")

      try:
          _in_getattr.add(attr_name)
          module = import_module(module_name, __package__)
          result = getattr(module, attr_name)
          return result
      except ImportError as e:
          raise ImportError(f"Failed to import {attr_name} from {module_name}: {e}") from e
      except AttributeError as e:
          raise AttributeError(f"Failed to get {attr_name} from {module_name}: {e}") from e
      finally:
          _in_getattr.discard(attr_name)

Workarounds for Users

Until fixed, users can work around this issue by configuring freezegun to ignore the merge package:

  import freezegun
  freezegun.configure(extend_ignore_list=['merge'])

Additional Notes

  • This bug was introduced in the dynamic import system design
  • The same pattern may exist in other auto-generated SDK code
  • Testing with module introspection tools should be part of the CI/CD pipeline
  • Consider adding unit tests that use freezegun or similar introspection tools

Repository Information

Thank you for maintaining this library! This fix would greatly improve compatibility with testing frameworks.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions