타입 스텁(.pyi) 자동 생성

컴포넌트의 .pyi 스텁을 생성해 IDE 자동완성과 mypy·pyright의 정적 검사를 받게 한다. 파이썬 코드 쪽의 지원이다. 템플릿 쪽(컴포넌트·핸들러 이름의 자동완성과 진단)은 편집기 지원이 맡는다.

빠른 시작#

모든 컴포넌트의 스텁을 만든다.

python manage.py wireview_stubs

CLI 옵션#

옵션 뜻
--dry-run 파일을 쓰지 않고 미리 본다
--check CI 모드. 스텁이 낡았으면 exit 1
--output-dir=DIR 출력 디렉터리 (기본: 소스 옆)
--app=NAME 앱 이름으로 거른다 (여러 번 지정 가능)
-v 2 자세한 출력

예#

# 무엇이 생성될지 미리 본다
python manage.py wireview_stubs --dry-run

# 스텁이 최신인지 확인한다 (CI용)
python manage.py wireview_stubs --check

# 특정 앱만
python manage.py wireview_stubs --app myapp

# 다른 디렉터리에 생성
python manage.py wireview_stubs --output-dir=./stubs

# 자세한 출력
python manage.py wireview_stubs -v 2

자동 생성#

DEBUG=True이면 Django가 뜰 때마다 스텁이 다시 생성된다 — runserver뿐 아니라 check·migrate·test 같은 manage.py 명령 전부다. 컴포넌트를 정의한 모듈 옆에 생기므로(myapp/live.py → myapp/live.pyi) 처음 보는 사람에게는 영문 모를 파일이 늘어난 것처럼 보인다. 생성물이라 커밋하지 않는다 — 스타터 템플릿의 .gitignore는 *.pyi를 뺀다. 손으로 쓴 스텁도 있는 프로젝트라면 생성되는 파일만 적는다.

# settings.py
WIREVIEW = {
    "AUTO_GENERATE_STUBS": True,  # 기본값: True (DEBUG 모드에서)
}

끄려면 False로 둔다.

WIREVIEW = {
    "AUTO_GENERATE_STUBS": False,
}

생성 결과 예#

이런 컴포넌트가 있으면

# myapp/live.py
from wireview import Component

class Counter(Component):
    """A simple counter component."""
    class Meta:
        template_name = "counter.html"

    count: int = 0
    step: int = 1

    async def increment(self, amount: int = 1) -> None:
        """Increment the counter."""
        self.count += amount

이런 스텁(myapp/live.pyi)이 나온다.

"""Auto-generated type stubs for wireview components.

DO NOT EDIT - regenerate with: python manage.py wireview_stubs
"""

from typing import Any, ClassVar
from wireview import Component

class Counter(Component):
    """A simple counter component."""

    count: int
    step: int

    async def increment(self, amount: int = ...) -> None: ...

    # Wireview metadata for IDE support
    __wireview_attrs__: ClassVar[dict[str, dict[str, Any]]] = {
        'count': {'type': 'int', 'required': False, 'default': 0},
        'step': {'type': 'int', 'required': False, 'default': 1}
    }
    __wireview_handlers__: ClassVar[list[str]] = ['increment']

메타데이터 속성#

스텁에는 IDE·LSP가 읽을 메타데이터가 함께 들어간다.

__wireview_attrs__#

컴포넌트 필드와 그 정보다.

__wireview_attrs__ = {
    'count': {
        'type': 'int',       # 타입 애너테이션(문자열)
        'required': False,   # 필수 여부
        'default': 0,        # 기본값
    }
}

__wireview_handlers__#

이벤트 핸들러 메서드 이름 목록이다.

__wireview_handlers__ = ['increment', 'decrement', 'reset']

지원하는 컴포넌트 종류#

종류 설명
Component 일반 상태 컴포넌트
LiveComponent 독립 상태를 가진 중첩 컴포넌트
FunctionComponent 상태 없는 템플릿 함수

LiveComponent과 FunctionComponent#

같은 모듈에 이런 둘이 있으면

# myapp/live.py
from django.utils.html import format_html
from wireview import LiveComponent, function_component

class Counter(LiveComponent):
    """A nested counter."""
    class Meta:
        template_name = "counter.html"

    count: int = 0

    async def increment(self) -> None:
        self.count += 1

    async def update(self, **assigns) -> None:
        await super().update(**assigns)

@function_component
def button(text: str, variant: str = "primary"):
    """Simple button component."""
    return format_html('<button class="btn btn-{}">{}</button>', variant, text)

스텁은 이렇다. 모든 import는 공개 패키지 wireview에서 온다. 오버라이드한 프레임워크 메서드(update)는 스텁에 남지만 __wireview_handlers__에는 들어가지 않는다 — 클라이언트가 부를 수 없기 때문이다. 함수 컴포넌트는 이름과 docstring만 남는다.

from typing import Any, ClassVar
from wireview import FunctionComponent
from wireview import LiveComponent

class Counter(LiveComponent):
    """A nested counter."""

    count: int

    async def increment(self) -> None: ...
    async def update(self, **assigns: Any) -> None: ...

    # Wireview metadata for IDE support
    __wireview_attrs__: ClassVar[dict[str, dict[str, Any]]] = {'count': {'type': 'int', 'required': False, 'default': 0}}
    __wireview_handlers__: ClassVar[list[str]] = ['increment']

button: FunctionComponent
"""Simple button component."""

동적 구독#

get_subscriptions()를 오버라이드했다면 스텁에 일반 메서드로 적힌다. class Meta:의 값은 스텁에 적지 않는다. 기반 클래스 Component가 _meta의 타입을 이미 선언하고 있다.

class XTodoItem(Component):
    item: Item

    def get_subscriptions(self) -> set[str]: ...

CI 연동#

--check로 스텁이 최신인지 확인한다.

# GitHub Actions 예
- name: Check type stubs
  run: python manage.py wireview_stubs --check

종료 코드:

  • 0 — 스텁이 최신이다
  • 1 — 다시 생성해야 한다

pre-commit 훅#

.pre-commit-config.yaml에 넣는다.

- repo: local
  hooks:
    - id: wireview-stubs
      name: Generate wireview type stubs
      entry: python manage.py wireview_stubs
      language: system
      pass_filenames: false
      files: '.*live\.py$'

문제 해결#

일부 컴포넌트의 스텁이 안 생긴다#

다음을 모두 만족해야 발견된다.

  1. Component._all 또는 LiveComponent._live_all에 등록되어 있다
  2. 라이브러리 경로(site-packages, venv) 밖에 있다
  3. 소스 파일 경로가 유효하다

타입은 import되거나 Any가 된다#

스텁은 타입 검사기가 모듈 대신 읽는 파일이라, 원본 모듈의 import가 스텁에는 없다. 그래서 생성기는 애너테이션을 소스 텍스트로 복사하지 않고 해석된 객체에서 다시 쓴다(from __future__ import annotations로 문자열이 된 애너테이션도 먼저 해석한다). 스텁이 읽는 이름은 모두 스텁 안에서 import된다.

애너테이션 스텁
내장 타입, list[str], dict[str, Any], X | None 그대로 (t.List·t.Optional도 이 모양으로)
typing.Any, Literal[...], typing.IO·BinaryIO·TextIO from typing import ...
Callable, Awaitable 등 from collections.abc import ...
Callable[P, R], Callable[Concatenate[X, P], R] (ParamSpec) Callable[..., R] — 매개변수 목록을 쓸 수 없으므로 개수를 틀리게 쓰는 대신 비운다
다른 모듈의 클래스 (datetime.date, 다른 앱의 모델) from <모듈> import <이름>
같은 스텁에 선언되는 컴포넌트 이름 그대로
같은 모듈에만 있는 다른 클래스, 중첩 클래스, TypeVar, 함수 안에서 정의한 클래스, 해석되지 않는 이름 Any
내장 이름·베이스 클래스·컴포넌트·typing 이름·다른 모듈의 같은 이름 클래스와 겹치는 클래스 Any — import가 다른 이름을 덮지 않게

마지막 두 줄은 원본보다 느슨하지만 틀리지 않는다. 컴포넌트 이름이 Any처럼 typing 이름과 같으면 typing 쪽을 import typing as _typing으로 가져와 _typing.Any로 쓴다. 같은 모듈의 모델을 정확한 타입으로 받고 싶으면 모델을 models.py처럼 다른 모듈에 두면 된다. 애너테이션이 없는 파라미터도 Any다.

*args, **kwargs, 키워드 전용(*,)과 위치 전용(/) 표시, @classmethod·@staticmethod는 원본대로 남는다. 받는 쪽(self·cls)은 이름이 아니라 위치로 빠지므로 cls라는 매개변수를 받는 메서드나 self라는 매개변수를 받는 staticmethod도 그대로다.

메타데이터의 기본값은 리터럴로 쓸 수 있는 것만 싣고(inf·nan, 임의 객체를 값으로 가진 Enum은 ...), 파이썬 식별자가 아닌 필드 이름(pydantic.create_model로 만든 class 같은)은 선언에서 빼고 메타데이터에만 남긴다. 클래스 docstring은 그 클래스 자신의 것만 싣고, 삼중 따옴표나 역슬래시가 들어 있으면 일반 문자열 리터럴로 쓴다. 애너테이션은 각각 한 번씩만 평가한다. 생성된 스텁은 저장소의 테스트가 파싱되는지, 읽는 이름이 모두 바인딩되는지, import한 이름을 모두 쓰는지를 검사한다(tests/test_stubs_valid.py).