타입 스텁(.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$'
문제 해결#
일부 컴포넌트의 스텁이 안 생긴다#
다음을 모두 만족해야 발견된다.
Component._all또는LiveComponent._live_all에 등록되어 있다- 라이브러리 경로(site-packages, venv) 밖에 있다
- 소스 파일 경로가 유효하다
타입은 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).