넌 Python 전문가야. Python에서 계층적 구조를 가지고 있는 커스텀 클래스에 대해 보기 좋게 모든 속성 Tree를 출력하게 하고 싶어. 예를 들어 PyVerilog의 AST의 전체 구조를 알고 싶어.

좋아! “계층적 구조를 가진 커스텀 클래스(예: PyVerilog AST)”를 보기 좋게 트리로 출력하는 범용 유틸을 만들어볼게.
아래 코드는 다음 아이디어를 따릅니다:

  1. 노드가 무엇이든 처리: dict/list/tuple/set, 일반 객체(__dict__/__slots__), dataclass, Python ast.AST, 그리고 PyVerilog처럼 children()/attr_names 프로토콜을 가진 객체.

  2. 루프/사이클 안전: 이미 출력한 객체는 다시 들어가지 않음.

  3. 커스터마이즈: max_depth, max_items, 비공개 속성 포함 여부 등.

  4. 가독성: ├─/└─/│ ASCII 브랜치로 예쁘게 렌더링.

from dataclasses import is_dataclass, fields as dc_fields
import ast
from collections.abc import Mapping, Iterable

def is_primitive(x):
    return isinstance(x, (str, int, float, bool, type(None), bytes))

def safe_len(x):
    try:
        return len(x)
    except Exception:
        return None

def safe_repr(x, maxlen=80):
    try:
        r = repr(x)
    except Exception:
        r = f"<{type(x).__name__} ...>"
    if maxlen is not None and len(r) > maxlen:
        return r[:maxlen - 1] + ""
    return r

def iter_children(o, include_private=False, include_dunder=False, max_items=50):
    # 1) 매핑형(dict 등)
    if isinstance(o, Mapping):
        for i, (k, v) in enumerate(o.items()):
            if i >= max_items:
                yield ("…(truncated)", f"{i}+ items")
                break
            yield (f"{safe_repr(k)}", v)
        return

    # 2) 시퀀스/이터러블(문자열류 제외)
    if isinstance(o, Iterable) and not isinstance(o, (str, bytes, bytearray, memoryview)):
        if isinstance(o, (list, tuple, set, frozenset)):
            seq = list(o)
            for i, v in enumerate(seq[:max_items]):
                yield (f"[{i}]", v)
            if len(seq) > max_items:
                yield ("…(truncated)", f"{len(seq)-max_items} more")
            return

    # 3) Python 내장 AST
    if isinstance(o, ast.AST):
        for name, value in ast.iter_fields(o):
            yield (name, value)
        return

    # 4) PyVerilog 스타일: children() + attr_names
    if hasattr(o, "children") and callable(getattr(o, "children")):
        try:
            ch = o.children()
        except Exception:
            ch = []
        # children()이 [(name, child), ...] 형태면 그대로, 아니면 인덱스 부여
        if (isinstance(ch, (list, tuple)) and ch and isinstance(ch[0], (list, tuple)) 
            and len(ch[0]) == 2 and isinstance(ch[0][0], str)):
            for name, child in ch:
                yield (name, child)
        else:
            try:
                seq = list(ch)
            except Exception:
                seq = []
            for i, child in enumerate(seq[:max_items]):
                yield (f"[{i}]", child)
            if len(seq) > max_items:
                yield ("…(truncated)", f"{len(seq)-max_items} more")

        attr_names = getattr(o, "attr_names", None)
        if attr_names:
            for name in attr_names:
                try:
                    val = getattr(o, name)
                except Exception:
                    continue
                yield (name, val)
        return

    # 5) dataclass
    if is_dataclass(o):
        for f in dc_fields(o):
            try:
                yield (f.name, getattr(o, f.name))
            except Exception:
                continue
        return

    # 6) 일반 객체: __dict__/__slots__
    if hasattr(o, "__dict__"):
        items = list(vars(o).items())
        for name, val in items[:max_items]:
            if not include_dunder and name.startswith("__") and name.endswith("__"):
                continue
            if not include_private and name.startswith("_") and not (name.startswith("__") and name.endswith("__")):
                continue
            yield (name, val)
        if len(items) > max_items:
            yield ("…(truncated)", f"{len(items)-max_items} more")
        return

    if hasattr(o, "__slots__"):
        slots = getattr(o, "__slots__")
        if isinstance(slots, str):
            slots = [slots]
        for name in slots[:max_items]:
            if not include_dunder and name.startswith("__") and name.endswith("__"):
                continue
            if not include_private and name.startswith("_") and not (name.startswith("__") and name.endswith("__")):
                continue
            try:
                yield (name, getattr(o, name))
            except Exception:
                continue
        if len(slots) > max_items:
            yield ("…(truncated)", f"{len(slots)-max_items} more")
        return
    # 그 밖엔 자식 없음

def node_label(name, value, show_types=True, show_values=True):
    t = type(value).__name__
    if is_primitive(value):
        val = safe_repr(value, 60) if show_values else ""
        if show_types and show_values:
            return f"{name}: {val} ({t})"
        elif show_values:
            return f"{name}: {val}"
        elif show_types:
            return f"{name} ({t})"
        else:
            return f"{name}"
    n = safe_len(value)
    extra = f" len={n}" if n is not None else ""
    return f"{name}: <{t}{extra}>"

def pretty_tree(obj, root_name="root", max_depth=10, max_items=50,
                include_private=False, include_dunder=False,
                show_types=True, show_values=True):
    """객체 전체 속성/자식 구조를 트리로 문자열 렌더링."""
    seen = set()
    lines = []

    def walk(name, value, prefix="", is_last=True, depth=0):
        lines.append(prefix + ("└── " if is_last else "├── ") + 
                    node_label(name, value, show_types, show_values))
        if depth >= max_depth:
            return
        if not is_primitive(value):
            oid = id(value)
            if oid in seen:
                lines.append(prefix + ("    " if is_last else "") + "↪ (cycle)")
                return
            seen.add(oid)

        children = list(iter_children(value, include_private, include_dunder, max_items)) or []
        L = len(children)
        for i, (child_name, child_val) in enumerate(children):
            child_prefix = prefix + ("    " if is_last else "")
            walk(child_name, child_val, prefix=child_prefix, is_last=(i == L - 1), depth=depth + 1)

    walk(root_name, obj, "", True, 0)
    return "\n".join(lines)

# 편의: 바로 출력
def print_tree(obj, **kwargs):
    print(pretty_tree(obj, **kwargs))

사용 예시 1) Python 표준 AST

import ast
code = "a = 1 + 2\nb = a * 3"
tree = ast.parse(code)
print_tree(tree, root_name="Module", max_depth=3, max_items=10)

사용 예시 2) PyVerilog AST 전체 구조

PyVerilog에서는 보통 노드가 children() 메서드(자식 노드 나열)와 attr_names(스칼라 속성 이름들)를 제공합니다. 위 유틸은 이 프로토콜을 자동 인식합니다. ```python from pyverilog.vparser.parser import parse

ast_root, _ = parse(["top.v"]) # 분석할 Verilog 파일들

트리 전체(주의: 크면 방대합니다!)

print_tree(ast_root, root_name="PyVerilogAST", max_depth=6, max_items=50)


### 팁

  * 트리가 너무 크면 `max_depth`/`max_items`를 줄이세요.  
예: `print_tree(ast_root, max_depth=4, max_items=20)`

  * 비공개/던더 속성까지 보고 싶으면:  
`print_tree(obj, include_private=True, include_dunder=True)`

  * 값이 너무 길면 자동으로 `…`로 생략합니다.

  * 순환 참조가 있으면 `(cycle)`로 표시하여 무한루프를 방지합니다.

원하는 출력 스타일(예: 값 숨기기, 타입 숨기기, 특정 속성 제외, 파일로 저장 등)로 더 커스터마이즈한 버전도 바로 만들어줄 수 있어요. PyVerilog에서 모듈 단위로만 보고 싶다든지, 특정 노드 유형만 필터링한다든지—원하는 뷰를 알려주면 거기에 맞춰 손질해줄게요.

<p class="chat--user" markdown="1">

PyVerilog 스타일에 집착하지 말고 범용적으로 모든 속성 dict 에 대해 트리를 만들어줘. “member 변수명: class명” 형태로 나오면 좋겠어

</p>

좋아—“속성 dict” 중심으로, 어떤 객체든 **멤버 변수 이름: 클래스명** 형태로 트리를 그려주는 범용 유틸을 바로 줄게.  
(값은 보여주지 않고, 타입(클래스명)만 출력. 사이클/깊이/개수 제한, `__dict__`/`__slots__`/dict/시퀀스 다 지원.)
```python
from dataclasses import is_dataclass, fields as dc_fields
from collections.abc import Mapping, Iterable
import types

# ----- 설정 가능한 필터 -----
DATA_LIKE_TYPES = (str, bytes, bytearray, memoryview, int, float, bool, type(None), complex)

def _class_name(x):
    try:
        return type(x).__name__
    except Exception:
        return "<unknown>"

def _is_primitive(x):
    return isinstance(x, DATA_LIKE_TYPES)

def _iter_object_members(o, include_private=False, include_dunder=False, include_properties=False):
    """
    객체의 '멤버 변수' 후보를 (name, value)로 yield.
    - __dict__, __slots__, dataclass 필드 중심
    - include_properties=True 면 property 게터도 포함(예외 발생 시 무시)
    - 메서드/함수/바운드메서드는 제외
    """
    yielded = set()

    # dataclass 우선
    if is_dataclass(o):
        for f in dc_fields(o):
            name = f.name
            try:
                val = getattr(o, name)
            except Exception:
                continue
            if not include_dunder and name.startswith("__") and name.endswith("__"):
                continue
            if not include_private and name.startswith("_") and not (name.startswith("__") and name.endswith("__")):
                continue
            yielded.add(name)
            yield name, val

    # __dict__
    if hasattr(o, "__dict__"):
        for name, val in vars(o).items():
            if not include_dunder and name.startswith("__") and name.endswith("__"):
                continue
            if not include_private and name.startswith("_") and not (name.startswith("__") and name.endswith("__")):
                continue
            yielded.add(name)
            yield name, val

    # __slots__
    if hasattr(o, "__slots__"):
        slots = getattr(o, "__slots__")
        if isinstance(slots, str):
            slots = [slots]
        for name in slots:
            if name in yielded:
                continue
            if not include_dunder and name.startswith("__") and name.endswith("__"):
                continue
            if not include_private and name.startswith("_") and not (name.startswith("__") and name.endswith("__")):
                continue
            try:
                val = getattr(o, name)
            except Exception:
                continue
            yielded.add(name)
            yield name, val

    # 선택: property
    if include_properties:
        # dir 기반으로 property만 추가
        for name in dir(o):
            if name in yielded:
                continue
            if not include_dunder and name.startswith("__") and name.endswith("__"):
                continue
            if not include_private and name.startswith("_") and not (name.startswith("__") and name.endswith("__")):
                continue
            try:
                attr = getattr(type(o), name, None)
                if isinstance(attr, property):
                    try:
                        val = getattr(o, name)
                    except Exception:
                        continue
                    yield name, val
            except Exception:
                continue

def _iter_children(node, include_private, include_dunder, include_properties, max_items):
    """
    노드의 '자식'을 (name, value)로 통일해 열거.
    규칙:
      1) dict류면 key를 멤버명처럼 사용
      2) list/tuple/set 등은 인덱스/순번으로 표기
      3) 기타 객체는 멤버 변수들(__dict__/__slots__/dataclass/property)
    """
    # 1) 매핑
    if isinstance(node, Mapping):
        for i, (k, v) in enumerate(node.items()):
            if i >= max_items:
                yield ("…(truncated)", f"{len(node) - max_items} more")
                return
            yield (f"{repr(k)}", v)
        return

    # 2) 문자열류 제외한 일반 이터러블(시퀀스/세트 등)
    if isinstance(node, Iterable) and not isinstance(node, (str, bytes, bytearray, memoryview)):
        if isinstance(node, (list, tuple)):
            for i, v in enumerate(node[:max_items]):
                yield (f"[{i}]", v)
            if len(node) > max_items:
                yield ("…(truncated)", f"{len(node) - max_items} more")
            return
        elif isinstance(node, (set, frozenset)):
            for i, v in enumerate(list(node)[:max_items]):
                yield (f"[{i}]", v)
            if len(node) > max_items:
                yield ("…(truncated)", f"{len(node) - max_items} more")
            return

    # 3) 일반 객체: 멤버 변수 dict
    for name, val in _iter_object_members(
        node,
        include_private=include_private,
        include_dunder=include_dunder,
        include_properties=include_properties,
    ):
        yield (name, val)

def attr_tree(
    obj,
    root_name="root",
    max_depth=10,
    max_items=50,
    include_private=False,
    include_dunder=False,
    include_properties=False,
):
    """
    객체의 '속성 dict'을 기반으로 트리를 그립니다.
    출력 포맷: "멤버명: 클래스명"
    - dict/시퀀스 요소도 가독성을 위해 키/인덱스를 '멤버명'처럼 출력합니다.
    """
    seen = set()
    lines = []

    def add_line(prefix, is_last, name, value):
        cls = _class_name(value)
        lines.append(prefix + ("└── " if is_last else "├── ") + f"{name}: {cls}")

    def walk(name, value, prefix="", is_last=True, depth=0):
        add_line(prefix, is_last, name, value)
        if depth >= max_depth:
            return

        # 원시 타입은 더 내려가지 않음
        if _is_primitive(value):
            return

        # 사이클 방지
        oid = id(value)
        if oid in seen:
            child_prefix = prefix + ("    " if is_last else "│   ")
            lines.append(child_prefix + "↪ (cycle)")
            return
        seen.add(oid)

        # 자식 열거
        children = list(_iter_children(
            value,
            include_private=include_private,
            include_dunder=include_dunder,
            include_properties=include_properties,
            max_items=max_items,
        ))

        L = len(children)
        for i, (child_name, child_val) in enumerate(children):
            child_prefix = prefix + ("    " if is_last else "│   ")
            walk(child_name, child_val, prefix=child_prefix, is_last=(i == L - 1), depth=depth + 1)

    walk(root_name, obj, "", True, 0)
    return "\n".join(lines)

def print_attr_tree(obj, **kwargs):
    print(attr_tree(obj, **kwargs))

핵심 아이디어 (step-by-step)

  1. “무엇을 자식으로 볼 것인가?”

    • dict → 키를 멤버명으로 사용

    • list/tuple/set[인덱스] 또는 순번

    • 그 외 객체 → 속성 dict : __dict__, __slots__, (옵션) dataclass 필드와 @property

  2. 출력은 값 대신 클래스명 만 (멤버명: 클래스명).

  3. 사이클 감지 로 무한 재귀 방지(객체 id 기억).

  4. 탐색 한도 : max_depth, max_items로 볼륨 제어.

  5. 가시성 옵션 : _private/__dunder__/property 노출 여부 선택.

간단 사용 예시

class B:
    __slots__ = ("y",)
    def __init__(self): self.y = {"k": [1, 2, 3]}

class A:
    def __init__(self):
        self.x = B()
        self.lst = [{"a": 1}, (2, 3)]
        self._hidden = "secret"
    @property
    def p(self): return 42

a = A()
print_attr_tree(
    a,
    root_name="A",
    max_depth=5,
    include_private=False,     # _hidden 숨김
    include_dunder=False,      # __dunder__ 숨김
    include_properties=True    # property p 포함
)

예상 출력(축약 예):

└── A: A
    ├── x: B
       └── y: dict
           └── 'k': list
               ├── [0]: int
               ├── [1]: int
               └── [2]: int
    ├── lst: list
       ├── [0]: dict
          └── 'a': int
       └── [1]: tuple
           ├── [0]: int
           └── [1]: int
    └── p: int

참고 옵션

  • 깊이/개수 제한: print_attr_tree(obj, max_depth=4, max_items=30)

  • 숨겨진 멤버까지: include_private=True, include_dunder=True

  • property 제외(기본): include_properties=False

필요하면 특정 타입을 “자식 확장 대상에서 제외/포함”하는 훅, 정렬, 출력 대상 화이트리스트/블랙리스트(예: 메서드/콜러블 전부 제외 등)도 바로 덧붙여 줄게.

기존 유틸이나 패키지는 없나

좋은 질문이에요 👍
제가 앞에서 짜드린 건 “직접 커스텀해서 범용 트리 뷰어”를 만드는 코드였는데, 사실 이미 비슷한 기능을 하는 유틸/패키지 가 몇 가지 있습니다. 다만 요구하신

member 변수명: class명 형태의 계층적 트리
그대로 해주는 건 없고, 비슷한 걸 응용할 수 있는 도구들이 있습니다.


1. 표준/내장 계열

  • pprint (표준 라이브러리)
    dict/list를 보기 좋게 출력하지만, 깊은 계층 + 클래스 멤버까지는 한계가 있음.

  • inspect (표준 라이브러리)
    객체의 signature, members를 introspection 하는 데 특화.
    → raw 데이터를 얻는 데는 좋지만 tree 형태는 직접 그려야 함.


2. 트리/구조 출력용

  • anytree
    파이썬 객체를 트리 구조로 모델링해서 문자열/Graphviz로 출력 가능.
    직접 노드 생성해서 넣어야 하므로 “자동 introspection”은 아니고, “구조화된 데이터 → 트리 출력”에 적합.

  • asciitree
    dict 같은 nested 구조를 ├─/└─ 형태로 예쁘게 출력.
    → 값 대신 type(value).__name__만 넣도록 래퍼를 짜면 바로 활용 가능.

  • richTree
    pretty console 출력 라이브러리.
    계층적 데이터를 트리로 그려주는 Tree 컴포넌트가 있음.
    → dict/object를 순회해서 Tree.add(f"{name}: {type(val).__name__}")만 해주면 요구한 형태로 쉽게 구현 가능.


3. 디버깅/개발 편의 패키지

  • objprint
    객체 내부 구조를 이쁘게 출력해줌. dict/list 지원, 순환 감지.
    → 다만 출력 포맷이 “값” 중심이라 멤버명: 클래스명 전용은 아님.

  • varname
    변수명 추적 라이브러리. 직접 tree 출력은 없지만 introspection에 유용.

  • glom
    중첩 데이터 구조를 탐색/변환하는 도구. 출력은 덜 예쁘지만, dict 트리 처리에 적합.


4. 추천 조합

만약 “바로 가져다 쓰고 싶다”면:

  • rich.Tree + 짧은 재귀 함수 → “멤버명: 클래스명” 트리 바로 구현 가능.

  • asciitree도 lightweight 대안.


👉 결론: 기존 패키지로는rich + introspection 유틸이 가장 범용적이고 예쁘게 나옵니다.
제가 위에서 짠 함수도 print_attr_tree 대신 rich.Tree를 써서 출력만 바꾸면 훨씬 더 보기 좋게 쓸 수 있어요.


원하시면 제가 rich.Tree 버전으로 “member: class” 전용 트리 프린터 예제를 짜드릴까요?

leaf일 경우 값도 보여주면 좋겠어 age: int(9) 이런 식으로

좋습니다 👍
말씀해주신 대로 leaf 노드(더 이상 내려갈 속성이 없는 경우)에는 값까지 같이 보여주면 훨씬 직관적이에요.
출력 포맷은 예시처럼:

age: int(9)

코드 예제 (rich.Tree 기반)

from dataclasses import is_dataclass, fields as dc_fields
from collections.abc import Mapping, Iterable
from rich.tree import Tree
from rich import print as rprint

PRIMITIVES = (str, int, float, bool, type(None), bytes, complex)

def _is_primitive(x):
    return isinstance(x, PRIMITIVES)

def _short_repr(x, maxlen=30):
    try:
        s = repr(x)
    except Exception:
        s = "<?>"
    if len(s) > maxlen:
        s = s[:maxlen] + ""
    return s

def _iter_members(o, include_private=False, include_dunder=False):
    if is_dataclass(o):
        for f in dc_fields(o):
            yield f.name, getattr(o, f.name, None)
        return
    if hasattr(o, "__dict__"):
        for k, v in vars(o).items():
            if not include_dunder and k.startswith("__") and k.endswith("__"):
                continue
            if not include_private and k.startswith("_") and not (k.startswith("__") and k.endswith("__")):
                continue
            yield k, v
    if hasattr(o, "__slots__"):
        slots = getattr(o, "__slots__")
        if isinstance(slots, str):
            slots = [slots]
        for k in slots:
            try:
                v = getattr(o, k)
            except Exception:
                continue
            yield k, v

def _iter_children(node, **kwargs):
    # dict
    if isinstance(node, Mapping):
        for k, v in node.items():
            yield f"{k!r}", v
        return
    # sequence (문자열 제외)
    if isinstance(node, Iterable) and not isinstance(node, (str, bytes, bytearray)):
        if isinstance(node, (list, tuple, set, frozenset)):
            for i, v in enumerate(node):
                yield f"[{i}]", v
            return
    # object
    yield from _iter_members(node, **kwargs)

def build_tree(obj, name="root", max_depth=5, include_private=False, include_dunder=False):
    seen = set()

    def walk(tree: Tree, name, value, depth):
        tname = type(value).__name__

        # leaf & primitive → 값까지 출력
        if _is_primitive(value) or depth >= max_depth:
            label = f"{name}: {tname}({_short_repr(value)})"
            tree.add(label)
            return

        oid = id(value)
        if oid in seen:
            tree.add(f"{name}: {tname} ↪ (cycle)")
            return
        seen.add(oid)

        node_label = f"{name}: {tname}"
        child_tree = tree.add(node_label)

        children = list(_iter_children(
            value,
            include_private=include_private,
            include_dunder=include_dunder
        ))

        if not children:  # leaf지만 primitive 아님 (예: 빈 dict)
            child_tree.add(f"(empty)")
        else:
            for k, v in children:
                walk(child_tree, k, v, depth + 1)

    root = Tree(f"{name}: {type(obj).__name__}")
    walk(root, name, obj, 0)
    return root

def print_attr_tree(obj, **kwargs):
    rprint(build_tree(obj, **kwargs))

사용 예시

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age
        self.pets = ["dog", "cat"]

p = Person("Alice", 9)
print_attr_tree(p, name="Person")

출력 예시:

Person: Person
└── Person: Person
    ├── name: str('Alice')
    ├── age: int(9)
    └── pets: list
        ├── [0]: str('dog')
        └── [1]: str('cat')

👉 이렇게 하면 leaf 노드일 경우 값까지클래스명(값) 형태로 표시됩니다.
값이 너무 길면 자동으로 으로 잘라주도록 했어요.

원하시면 max_depth, maxlen, 특정 타입 제외 필터 같은 추가 옵션도 넣어드릴 수 있는데, 혹시 필요하실까요?

라이브러리 기반 말고 아까 짜준 코드 기반으로

좋아! 라이브러리 없이 아까 만든 범용 트리 유틸을 그대로 확장해서, leaf일 때는 값까지멤버: 타입(값) 형태로 출력하도록 손봤어.
(leaf 정의: 더 내려갈 자식이 없거나, 원시형이거나, 최대 깊이에 도달한 경우)

from dataclasses import is_dataclass, fields as dc_fields
from collections.abc import Mapping, Iterable

PRIMITIVES = (str, int, float, bool, type(None), bytes, complex)

def _is_primitive(x):
    return isinstance(x, PRIMITIVES)

def _class_name(x):
    try:
        return type(x).__name__
    except Exception:
        return "<unknown>"

def _short_repr(x, maxlen=60):
    try:
        s = repr(x)
    except Exception:
        s = "<?>"
    if maxlen and len(s) > maxlen:
        s = s[:maxlen] + ""
    return s

def _iter_object_members(o, include_private=False, include_dunder=False, include_properties=False):
    yielded = set()

    if is_dataclass(o):
        for f in dc_fields(o):
            name = f.name
            try:
                val = getattr(o, name)
            except Exception:
                continue
            if not include_dunder and name.startswith("__") and name.endswith("__"):
                continue
            if not include_private and name.startswith("_") and not (name.startswith("__") and name.endswith("__")):
                continue
            yielded.add(name)
            yield name, val

    if hasattr(o, "__dict__"):
        for name, val in vars(o).items():
            if not include_dunder and name.startswith("__") and name.endswith("__"):
                continue
            if not include_private and name.startswith("_") and not (name.startswith("__") and name.endswith("__")):
                continue
            yielded.add(name)
            yield name, val

    if hasattr(o, "__slots__"):
        slots = getattr(o, "__slots__")
        if isinstance(slots, str):
            slots = [slots]
        for name in slots:
            if name in yielded:
                continue
            if not include_dunder and name.startswith("__") and name.endswith("__"):
                continue
            if not include_private and name.startswith("_") and not (name.startswith("__") and name.endswith("__")):
                continue
            try:
                val = getattr(o, name)
            except Exception:
                continue
            yielded.add(name)
            yield name, val

    if include_properties:
        for name in dir(o):
            if name in yielded:
                continue
            if not include_dunder and name.startswith("__") and name.endswith("__"):
                continue
            if not include_private and name.startswith("_") and not (name.startswith("__") and name.endswith("__")):
                continue
            try:
                attr = getattr(type(o), name, None)
                if isinstance(attr, property):
                    try:
                        val = getattr(o, name)
                    except Exception:
                        continue
                    yield name, val
            except Exception:
                continue

def _iter_children(node, include_private, include_dunder, include_properties, max_items):
    # dict류
    if isinstance(node, Mapping):
        items = list(node.items())
        for i, (k, v) in enumerate(items[:max_items]):
            yield f"{k!r}", v
        if len(items) > max_items:
            yield "…(truncated)", f"{len(items) - max_items} more"
        return

    # 시퀀스/세트 (문자열류 제외)
    if isinstance(node, Iterable) and not isinstance(node, (str, bytes, bytearray, memoryview)):
        if isinstance(node, (list, tuple)):
            for i, v in enumerate(list(node)[:max_items]):
                yield f"[{i}]", v
            if len(node) > max_items:
                yield "…(truncated)", f"{len(node) - max_items} more"
            return
        if isinstance(node, (set, frozenset)):
            seq = list(node)
            for i, v in enumerate(seq[:max_items]):
                yield f"[{i}]", v
            if len(seq) > max_items:
                yield "…(truncated)", f"{len(seq) - max_items} more"
            return

    # 일반 객체 멤버
    yield from _iter_object_members(
        node,
        include_private=include_private,
        include_dunder=include_dunder,
        include_properties=include_properties,
    )

def attr_tree(
    obj,
    root_name="root",
    max_depth=10,
    max_items=50,
    include_private=False,
    include_dunder=False,
    include_properties=False,
    leaf_value_maxlen=60,
):
    """
    '속성 dict' 기반 범용 트리 출력.
    - 내부 노드:  name: ClassName
    - leaf 노드: name: ClassName(value_repr)
    """
    seen = set()
    lines = []

    def add_leaf_line(prefix, is_last, name, value):
        cls = _class_name(value)
        lines.append(prefix + ("└── " if is_last else "├── ") + f"{name}: {cls}({_short_repr(value, leaf_value_maxlen)})")

    def add_inner_line(prefix, is_last, name, value):
        cls = _class_name(value)
        lines.append(prefix + ("└── " if is_last else "├── ") + f"{name}: {cls}")

    def walk(name, value, prefix="", is_last=True, depth=0):
        # 깊이 제한 또는 원시형이면 leaf
        if depth >= max_depth or _is_primitive(value):
            add_leaf_line(prefix, is_last, name, value)
            return

        # 사이클 방지
        oid = id(value)
        if oid in seen:
            add_inner_line(prefix, is_last, name, value)
            child_prefix = prefix + ("    " if is_last else "")
            lines.append(child_prefix + "↪ (cycle)")
            return
        seen.add(oid)

        # 자식 미리 구해보고, 없으면 leaf 취급(+값 표시)
        children = list(_iter_children(
            value,
            include_private=include_private,
            include_dunder=include_dunder,
            include_properties=include_properties,
            max_items=max_items,
        ))

        if not children:
            add_leaf_line(prefix, is_last, name, value)
            return

        # 내부 노드
        add_inner_line(prefix, is_last, name, value)
        child_prefix = prefix + ("    " if is_last else "")

        L = len(children)
        for i, (child_name, child_val) in enumerate(children):
            walk(child_name, child_val, prefix=child_prefix, is_last=(i == L - 1), depth=depth + 1)

    walk(root_name, obj, "", True, 0)
    return "\n".join(lines)

def print_attr_tree(obj, **kwargs):
    print(attr_tree(obj, **kwargs))

예시

class B:
    __slots__ = ("y",)
    def __init__(self): self.y = {"k": [1, 2, 3]}

class A:
    def __init__(self):
        self.name = "Alice"
        self.age = 9
        self.x = B()
        self.lst = [{"a": 1}, (2, 3)]
        self._hidden = "secret"

a = A()
print_attr_tree(
    a,
    root_name="A",
    max_depth=5,
    include_private=False,    # _hidden 숨김
    include_dunder=False,     # __dunder__ 숨김
    include_properties=False
)

출력(예):

└── A: A
    ├── name: str('Alice')
    ├── age: int(9)
    ├── x: B
       └── y: dict
           └── 'k': list
               ├── [0]: int(1)
               ├── [1]: int(2)
               └── [2]: int(3)
    └── lst: list
        ├── [0]: dict
           └── 'a': int(1)
        └── [1]: tuple
            ├── [0]: int(2)
            └── [1]: int(3)

참고 (step-by-step)

  1. leaf 판단 : (a) 원시형(PRIMITIVES), (b) max_depth 도달, (c) 자식 탐색 결과 없음 → 값까지 출력.

  2. 내부 노드 : 자식이 있으면 name: ClassName만 찍고 아래로 재귀.

  3. 사이클 : 이미 본 객체면 (cycle) 표시하고 중단.

  4. 가시성 옵션 : _private, __dunder__, @property 포함 여부 조절.

  5. 길이 제한 : leaf_value_maxlen으로 leaf 값 repr 길이 제어.

더 원하는 포맷(예: key="..." 따옴표 통일, dict 키 정렬, 특정 타입 축약 등) 있으면 말해줘—바로 반영할게!

가장 root 앞에 ㄴ— 가 들어가서 전체적으로 인덴트가 너무 많아져. 첫 루트는 선 없이 그냥 바로 A: A 이렇게 가게해줘. 하위 속성들도 1칸씩 인덱트 당기고

좋아! “루트에는 선(└/├) 없이 바로 A: A로 시작”하고, “하위 속성도 한 칸(레벨) 덜 들여쓰기” 되도록 코드를 손봤어. 핵심은 루트 라벨은 일반 텍스트로 출력 하고, 자식부터 브랜치 기호 를 붙이는 거야.

아래는 바뀐 전체 코드(외부 라이브러리 없음):

from dataclasses import is_dataclass, fields as dc_fields
from collections.abc import Mapping, Iterable

PRIMITIVES = (str, int, float, bool, type(None), bytes, complex)

def _is_primitive(x):
    return isinstance(x, PRIMITIVES)

def _class_name(x):
    try:
        return type(x).__name__
    except Exception:
        return "<unknown>"

def _short_repr(x, maxlen=60):
    try:
        s = repr(x)
    except Exception:
        s = "<?>"
    if maxlen and len(s) > maxlen:
        s = s[:maxlen] + ""
    return s

def _iter_object_members(o, include_private=False, include_dunder=False, include_properties=False):
    yielded = set()

    if is_dataclass(o):
        for f in dc_fields(o):
            name = f.name
            try:
                val = getattr(o, name)
            except Exception:
                continue
            if not include_dunder and name.startswith("__") and name.endswith("__"):
                continue
            if not include_private and name.startswith("_") and not (name.startswith("__") and name.endswith("__")):
                continue
            yielded.add(name)
            yield name, val

    if hasattr(o, "__dict__"):
        for name, val in vars(o).items():
            if not include_dunder and name.startswith("__") and name.endswith("__"):
                continue
            if not include_private and name.startswith("_") and not (name.startswith("__") and name.endswith("__")):
                continue
            yielded.add(name)
            yield name, val

    if hasattr(o, "__slots__"):
        slots = getattr(o, "__slots__")
        if isinstance(slots, str):
            slots = [slots]
        for name in slots:
            if name in yielded:
                continue
            if not include_dunder and name.startswith("__") and name.endswith("__"):
                continue
            if not include_private and name.startswith("_") and not (name.startswith("__") and name.endswith("__")):
                continue
            try:
                val = getattr(o, name)
            except Exception:
                continue
            yielded.add(name)
            yield name, val

    if include_properties:
        for name in dir(o):
            if name in yielded:
                continue
            if not include_dunder and name.startswith("__") and name.endswith("__"):
                continue
            if not include_private and name.startswith("_") and not (name.startswith("__") and name.endswith("__")):
                continue
            try:
                attr = getattr(type(o), name, None)
                if isinstance(attr, property):
                    try:
                        val = getattr(o, name)
                    except Exception:
                        continue
                    yield name, val
            except Exception:
                continue

def _iter_children(node, include_private, include_dunder, include_properties, max_items):
    # dict류
    if isinstance(node, Mapping):
        items = list(node.items())
        for i, (k, v) in enumerate(items[:max_items]):
            yield f"{k!r}", v
        if len(items) > max_items:
            yield "…(truncated)", f"{len(items) - max_items} more"
        return

    # 시퀀스/세트 (문자열류 제외)
    if isinstance(node, Iterable) and not isinstance(node, (str, bytes, bytearray, memoryview)):
        if isinstance(node, (list, tuple)):
            seq = list(node)
            for i, v in enumerate(seq[:max_items]):
                yield f"[{i}]", v
            if len(seq) > max_items:
                yield "…(truncated)", f"{len(seq) - max_items} more"
            return
        if isinstance(node, (set, frozenset)):
            seq = list(node)
            for i, v in enumerate(seq[:max_items]):
                yield f"[{i}]", v
            if len(seq) > max_items:
                yield "…(truncated)", f"{len(seq) - max_items} more"
            return

    # 일반 객체 멤버
    yield from _iter_object_members(
        node,
        include_private=include_private,
        include_dunder=include_dunder,
        include_properties=include_properties,
    )

def attr_tree(
    obj,
    root_name="root",
    max_depth=10,
    max_items=50,
    include_private=False,
    include_dunder=False,
    include_properties=False,
    leaf_value_maxlen=60,
):
    """
    '속성 dict' 기반 범용 트리 출력.
    - 루트:  name: ClassName  (브랜치 선 없음)
    - 내부 노드:  name: ClassName
    - leaf 노드: name: ClassName(value_repr)
    """
    seen = set()
    lines = []

    def add_leaf_line(prefix, is_last, name, value):
        cls = _class_name(value)
        lines.append(prefix + ("└── " if is_last else "├── ") + f"{name}: {cls}({_short_repr(value, leaf_value_maxlen)})")

    def add_inner_line(prefix, is_last, name, value):
        cls = _class_name(value)
        lines.append(prefix + ("└── " if is_last else "├── ") + f"{name}: {cls}")

    def walk(name, value, prefix="", is_last=True, depth=0):
        # 깊이 제한 또는 원시형이면 leaf
        if depth >= max_depth or _is_primitive(value):
            add_leaf_line(prefix, is_last, name, value)
            return

        oid = id(value)
        if oid in seen:
            add_inner_line(prefix, is_last, name, value)
            child_prefix = prefix + ("    " if is_last else "")
            lines.append(child_prefix + "↪ (cycle)")
            return
        seen.add(oid)

        children = list(_iter_children(
            value,
            include_private=include_private,
            include_dunder=include_dunder,
            include_properties=include_properties,
            max_items=max_items,
        ))

        if not children:
            add_leaf_line(prefix, is_last, name, value)
            return

        # 내부 노드
        add_inner_line(prefix, is_last, name, value)
        child_prefix = prefix + ("    " if is_last else "")

        L = len(children)
        for i, (child_name, child_val) in enumerate(children):
            walk(child_name, child_val, prefix=child_prefix, is_last=(i == L - 1), depth=depth + 1)

    # --- 루트 출력 (브랜치 선/인덴트 없음) ---
    root_cls = _class_name(obj)
    if _is_primitive(obj):
        lines.append(f"{root_name}: {root_cls}({_short_repr(obj, leaf_value_maxlen)})")
        return "\n".join(lines)

    lines.append(f"{root_name}: {root_cls}")

    # 루트의 자식부터는 브랜치 기호를 사용하되, 추가 인덴트 없이 바로 시작
    root_children = list(_iter_children(
        obj,
        include_private=include_private,
        include_dunder=include_dunder,
        include_properties=include_properties,
        max_items=max_items,
    ))

    if not root_children:
        # 자식이 없는 비-원시 루트 (예: 빈 dict 등)
        lines.append("(empty)")
        return "\n".join(lines)

    L = len(root_children)
    for i, (child_name, child_val) in enumerate(root_children):
        # prefix=""로 시작 → 하위가 한 칸(레벨) 당겨진 효과
        walk(child_name, child_val, prefix="", is_last=(i == L - 1), depth=1)

    return "\n".join(lines)

def print_attr_tree(obj, **kwargs):
    print(attr_tree(obj, **kwargs))

동작 확인 예시

class B:
    __slots__ = ("y",)
    def __init__(self): self.y = {"k": [1, 2, 3]}

class A:
    def __init__(self):
        self.name = "Alice"
        self.age = 9
        self.x = B()
        self.lst = [{"a": 1}, (2, 3)]
        self._hidden = "secret"

a = A()
print_attr_tree(
    a,
    root_name="A",
    max_depth=5,
    include_private=False,
    include_dunder=False,
    include_properties=False
)

기대 출력(인덴트 한 단계 감소 + 루트에 선 없음)

A: A
├── name: str('Alice')
├── age: int(9)
├── x: B
   └── y: dict
       └── 'k': list
           ├── [0]: int(1)
           ├── [1]: int(2)
           └── [2]: int(3)
└── lst: list
    ├── [0]: dict
       └── 'a': int(1)
    └── [1]: tuple
        ├── [0]: int(2)
        └── [1]: int(3)

필요하면 루트만 값 표시 여부(지금은 원시형 루트일 때만 type(value)(repr) 표시), 키 정렬 옵션, set 정렬, 길이 제한 등을 더 넣어줄 수도 있어. 원하는 포맷 있으면 말해줘!

아 그런데 @dataclass에 대해서 이걸 적용했더니 같은 속성이 2번씩 나오네

맞아요—그 현상은 dataclass 필드를 먼저 yield 한 뒤에, 다시 __dict__에서 같은 이름을 한 번 더 내보내서 생긴 중복이에요.
해결은 간단합니다: __dict__를 순회할 때 이미 낸 이름이면 건너뛰기 만 추가하면 됩니다. (__slots__/property 쪽은 이미 중복 방지가 되어 있었어요.)

아래처럼 _iter_object_members 함수만 교체하세요. (나머지 코드는 그대로)

def _iter_object_members(o, include_private=False, include_dunder=False, include_properties=False):
    yielded = set()

    # 1) dataclass 우선
    if is_dataclass(o):
        for f in dc_fields(o):
            name = f.name
            try:
                val = getattr(o, name)
            except Exception:
                continue
            if not include_dunder and name.startswith("__") and name.endswith("__"):
                continue
            if not include_private and name.startswith("_") and not (name.startswith("__") and name.endswith("__")):
                continue
            yielded.add(name)
            yield name, val

    # 2) __dict__ (⚠️ 추가된 중복 방지)
    if hasattr(o, "__dict__"):
        for name, val in vars(o).items():
            if name in yielded:           # <<< 중복 방지 (핵심 수정)
                continue
            if not include_dunder and name.startswith("__") and name.endswith("__"):
                continue
            if not include_private and name.startswith("_") and not (name.startswith("__") and name.endswith("__")):
                continue
            yielded.add(name)
            yield name, val

    # 3) __slots__ (이미 중복 방지 있음)
    if hasattr(o, "__slots__"):
        slots = getattr(o, "__slots__")
        if isinstance(slots, str):
            slots = [slots]
        for name in slots:
            if name in yielded:
                continue
            if not include_dunder and name.startswith("__") and name.endswith("__"):
                continue
            if not include_private and name.startswith("_") and not (name.startswith("__") and name.endswith("__")):
                continue
            try:
                val = getattr(o, name)
            except Exception:
                continue
            yielded.add(name)
            yield name, val

    # 4) @property (이미 중복 방지 있음)
    if include_properties:
        for name in dir(o):
            if name in yielded:
                continue
            if not include_dunder and name.startswith("__") and name.endswith("__"):
                continue
            if not include_private and name.startswith("_") and not (name.startswith("__") and name.endswith("__")):
                continue
            try:
                attr = getattr(type(o), name, None)
                if isinstance(attr, property):
                    try:
                        val = getattr(o, name)
                    except Exception:
                        continue
                    yielded.add(name)     # (일관성)
                    yield name, val
            except Exception:
                continue

왜 이게 맞나 (step-by-step)

  1. dataclass 필드 를 먼저 내보냄(원하는 순서).

  2. 같은 이름이 __dict__에도 있으므로, yielded에 기록된 이름이면 건너뜀.

  3. __slots__/property도 동일한 규칙으로 중복 방지.

  4. 결과: dataclass에서 중복 없이 한 번만 표기됩니다.

원하면 dataclass + slots=True 조합(파이썬 3.10+)도 테스트 예제 드릴게요.

Categories:

Updated: