- Add Python toki_socket package (base_client, communicator, tcp/ws client/server) - Add protobuf message definitions for Python - Add go_python.go cross-test implementation and python_go_client - Add agent-task/python_impl plan and code review docs - Update .gitignore for Python cache files with recursive patterns
25 KiB
Python 구현체 추가 (toki_socket)
이 파일을 읽는 구현 에이전트에게
각 항목의 체크리스트를 완료하면서 [ ]를 [x]로 표시하라.
중간 검증 명령은 해당 항목 구현 직후 실행하고, 출력을 CODE_REVIEW.md의 검증 결과 섹션에 붙여넣어라.
최종 검증도 마찬가지로 실행 후 출력을 기록하라.
계획과 다르게 구현한 부분은 이유와 함께 CODE_REVIEW.md의 "계획 대비 변경 사항"에 기록하라.
배경
PROTOCOL.md의 Multi-language Implementations 표에 Python이 Planned로 등록되어 있다.
Go 구현체(go/)가 레퍼런스이며, PORTING_GUIDE.md에 Python 매핑 표와 주의사항이 명시되어 있다.
Python 패키지를 python/ 디렉터리에 생성하고, Go×Python 양방향 크로스 테스트를 추가하여 프로토콜 호환성을 검증한다.
TLS/WSS는 이번 범위에서 제외하고 README에 명시한다.
의존 관계 및 구현 순서
[API-1] scaffold → [API-2] Communicator → [API-3] BaseClient
→ [API-4] TCP Transport
→ [API-5] WebSocket Transport
[API-2~5] 완료 → [API-6] 단위 테스트 → [API-7] 크로스 테스트
[API-1] Python 패키지 scaffold
문제
python/ 디렉터리가 없다. 패키지 매니저, proto binding, 최소 패키지 구조가 필요하다.
해결 방법
python/pyproject.toml생성 (setuptools, Python 3.11+)python/toki_socket/__init__.py생성dart/lib/src/packets/message_common.proto를python/toki_socket/packets/message_common.proto로 복사 (schema만, 언어 option 추가 불필요)protoc로message_common_pb2.py,message_common_pb2.pyi생성
pyproject.toml 핵심:
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.backends.legacy:build"
[project]
name = "toki-socket"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"protobuf>=4.25",
"websockets>=12.0",
]
[project.optional-dependencies]
dev = ["pytest>=8.0", "pytest-asyncio>=0.23"]
디렉터리 구조:
python/
toki_socket/
__init__.py
communicator.py
base_client.py
tcp_client.py
tcp_server.py
ws_client.py
ws_server.py
packets/
__init__.py
message_common.proto
message_common_pb2.py (generated)
message_common_pb2.pyi (generated)
test/
__init__.py
test_communicator.py
test_tcp.py
crosstest/
go_python_client.py
python_go.py
pyproject.toml
README.md
수정 파일 및 체크리스트
python/pyproject.toml생성python/toki_socket/__init__.py생성 (public export 비워둠)python/toki_socket/packets/__init__.py생성python/toki_socket/packets/message_common.proto복사python/toki_socket/packets/message_common_pb2.py생성 (protoc 실행 또는 직접 작성)python/toki_socket/packets/message_common_pb2.pyi생성python/README.md생성 (TLS 미지원 명시 포함)
테스트 작성
skip — scaffold 단계, 코드 로직 없음.
중간 검증
cd python && pip install -e ".[dev]" --quiet && python -c "from toki_socket.packets import message_common_pb2; print(message_common_pb2.TestData.DESCRIPTOR.name)"
예상 출력: TestData
[API-2] Communicator 구현
문제
go/communicator.go에 해당하는 Python 비동기 구현이 없다.
Transport Protocol, Communicator 클래스, write 큐, nonce 관리, listener/requestListener 라우팅을 구현해야 한다.
해결 방법
python/toki_socket/communicator.py에 아래 구조로 구현한다.
Transport Protocol (Go의 Transport interface):
from typing import Protocol, runtime_checkable
from toki_socket.packets.message_common_pb2 import PacketBase
@runtime_checkable
class Transport(Protocol):
async def write_packet(self, base: PacketBase) -> None: ...
async def close(self) -> None: ...
ParserMap: dict[str, Callable[[bytes], Message]]
Communicator 핵심:
class Communicator:
def __init__(self) -> None:
self._nonce: int = 0
self._is_alive: bool = False
self._parser_map: dict[str, Callable] = {}
self._handlers: dict[str, list[Callable]] = {}
self._req_handlers: dict[str, Callable] = {}
self._pending_requests: dict[int, asyncio.Future] = {}
self._write_queue: asyncio.Queue = asyncio.Queue(maxsize=64)
self._closed: asyncio.Event = asyncio.Event()
self._transport: Transport | None = None
self._write_loop_task: asyncio.Task | None = None
self._write_error_handler: Callable[[Exception], None] | None = None
def initialize(self, transport: Transport, parser_map: dict) -> None:
... # 하트비트 파서 추가, write_loop Task 시작
def is_alive(self) -> bool: ...
def _next_nonce(self) -> int: ...
def _shutdown(self) -> None: ...
async def close(self) -> None: ...
async def _write_loop(self) -> None: ...
async def queue_packet(self, base: PacketBase) -> None: ...
async def send(self, message: Message) -> None: ...
async def send_request(self, req: Message, res_type: type[T], timeout: float = 30.0) -> T: ...
def add_listener(self, type_name: str, fn: Callable) -> None: ...
def remove_listeners(self, type_name: str) -> None: ...
def add_request_listener(self, type_name: str, fn: Callable) -> None: ...
def on_received_data(self, type_name: str, data: bytes, nonce: int, response_nonce: int) -> None: ...
def _handle_response(self, type_name: str, data: bytes, response_nonce: int) -> None: ...
def _parse(self, type_name: str, data: bytes) -> Message: ...
def type_name_of(message_class) -> str:
return message_class.DESCRIPTOR.name
write_loop 패턴 — asyncio 단일 스레드이므로 sentinel 방식 사용:
_STOP = object()
async def _write_loop(self) -> None:
while True:
item = await self._write_queue.get()
if item is _STOP:
return
base, fut = item
try:
await self._transport.write_packet(base)
if not fut.done():
fut.set_result(None)
except Exception as e:
if not fut.done():
fut.set_exception(e)
if self._write_error_handler:
self._write_error_handler(e)
_shutdown()에서 _write_queue.put_nowait(_STOP) 호출.
send_request — asyncio.get_event_loop().create_future()로 pending 등록, asyncio.wait_for(fut, timeout)으로 대기.
addListener / addRequestListener 상호 배타 — 같은 typeName 중복 시 ValueError raise.
HeartBeat 자동 처리 — initialize() 내에서 HeartBeat 파서를 _parser_map에 추가; BaseClient에서 listener 등록.
on_received_data — asyncio 단일 스레드이므로 요청 핸들러는 asyncio.create_task()로 실행 (Go의 go reqHandler(...)와 동일).
수정 파일 및 체크리스트
python/toki_socket/communicator.py생성TransportProtocol 정의type_name_of(cls)함수 —cls.DESCRIPTOR.name반환Communicator.initialize()— parser_map + HeartBeat 파서 등록, write_loop Task 시작Communicator._write_loop()— sentinel 패턴Communicator.queue_packet()— closed 체크, Future 등록, put, awaitCommunicator.send()— marshal → queue_packetCommunicator.send_request()— pending 등록, marshal, queue, asyncio.wait_forCommunicator.add_listener()/add_request_listener()— 상호 배타 검사Communicator.on_received_data()— response_nonce > 0이면 handleResponse, 아니면 listener/reqHandlerCommunicator._shutdown()— is_alive=False, closed.set(), write_queue에 STOP 전달
테스트 작성
python/test/test_communicator.py에 작성:
test_add_listener_exclusivity— 같은 typeName에 add_listener 후 add_request_listener 시 ValueError 확인test_send_and_receive— FakeTransport (write_packet 캡처)로 send() 후 패킷 내용 검증test_send_request_response— FakeTransport로 send_request 후 수동으로 on_received_data 호출하여 Future resolve 검증test_shutdown— close() 후 is_alive() == False 검증
중간 검증
cd python && python -m pytest test/test_communicator.py -v
예상: 4개 테스트 PASS
[API-3] BaseClient 구현
문제
go/base_client.go에 해당하는 heartbeat, disconnect listener, connCloseOnce 패턴이 없다.
해결 방법
python/toki_socket/base_client.py에 구현.
Go sync.Once → asyncio.Lock + bool 플래그:
self._close_called: bool = False
self._close_lock: asyncio.Lock = asyncio.Lock()
class BaseClient:
def __init__(self, interval_sec: int, wait_sec: int, do_close: Callable[[], Awaitable[None]]) -> None:
self.communicator: Communicator = Communicator()
self._interval_sec = interval_sec
self._wait_sec = wait_sec
self._do_close = do_close
self._close_called = False
self._close_lock: asyncio.Lock # initialize()에서 생성
self._disconnect_listeners: list[Callable] = []
self._hb_task: asyncio.Task | None = None
self._waiting_hb_response: bool = False
def _init_base(self) -> None:
self._close_lock = asyncio.Lock()
async def close(self) -> None:
async with self._close_lock:
if self._close_called:
return
self._close_called = True
self.communicator._shutdown()
self._stop_heartbeat()
if self._do_close:
await self._do_close()
await self._notify_disconnected()
def add_disconnect_listener(self, fn: Callable) -> None: ...
def remove_disconnect_listeners(self) -> None: ...
async def _notify_disconnected(self) -> None: ...
async def _on_disconnected(self) -> None:
await self.close()
async def _send_heartbeat(self) -> None: ... # Go sendHeartBeat() 패턴
async def _on_heartbeat(self) -> None: ... # Go onHeartBeat() 패턴
def _stop_heartbeat(self) -> None: ...
Heartbeat 타이머: asyncio.get_event_loop().call_later() 또는 asyncio.create_task(asyncio.sleep(n))으로 구현.
Go의 HeartbeatTimer에 해당하는 _HbTimer 헬퍼 클래스를 동일 파일에 작성.
수정 파일 및 체크리스트
python/toki_socket/base_client.py생성_HbTimer—asyncio.create_task(sleep+callback),cancel()메서드BaseClient._init_base()— close_lock 초기화 (asyncio.Lock은 루프 바인딩 필요)BaseClient.close()— connCloseOnce 패턴, shutdown → stop_heartbeat → do_close → notifyBaseClient._send_heartbeat()— interval > 0이면 타이머 설정BaseClient._on_heartbeat()— waiting 상태이면 False로 리셋, 아니면 HeartBeat 송신BaseClient._notify_disconnected()— listener 복사 후 asyncio.gather로 호출
테스트 작성
단위 테스트 skip — Transport 없이 단독 테스트가 어렵고, TCP 테스트에서 통합 검증.
중간 검증
cd python && python -c "from toki_socket.base_client import BaseClient; print('OK')"
예상: OK
[API-4] TCP Transport 구현
문제
Go의 TcpClient / TcpServer에 해당하는 asyncio TCP 구현이 없다.
해결 방법
python/toki_socket/tcp_client.py, python/toki_socket/tcp_server.py 구현.
framing (PROTOCOL.md 기준):
- 송신: 4바이트 big-endian 길이 + PacketBase bytes
- 수신: 4바이트 읽기 → 길이 → 나머지 읽기
- 최대 패킷 크기: 64 MiB (Go와 동일)
# tcp_client.py
class TcpClient(BaseClient):
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter,
interval_sec: int, wait_sec: int, parser_map: ParserMap) -> None:
super().__init__(interval_sec, wait_sec, do_close=self._do_close_impl)
self._reader = reader
self._writer = writer
self._init_base()
self.communicator.initialize(self, parser_map)
self.communicator.set_write_error_handler(lambda e: asyncio.create_task(self._on_disconnected()))
self.communicator.add_listener(
type_name_of(HeartBeat), lambda m: asyncio.create_task(self._on_heartbeat())
)
asyncio.create_task(self._read_loop())
asyncio.create_task(self._send_heartbeat())
async def write_packet(self, base: PacketBase) -> None:
data = base.SerializeToString()
header = struct.pack(">I", len(data))
self._writer.write(header + data)
await self._writer.drain()
async def _do_close_impl(self) -> None:
self._writer.close()
await self._writer.wait_closed()
async def _read_loop(self) -> None:
while self.communicator.is_alive():
try:
header = await self._reader.readexactly(4)
length = struct.unpack(">I", header)[0]
if length == 0:
continue
if length > MAX_PACKET_SIZE:
await self._on_disconnected(); return
data = await self._reader.readexactly(length)
base = PacketBase()
base.ParseFromString(data)
self.communicator.on_received_data(
base.typeName, base.data, base.nonce, base.responseNonce
)
asyncio.create_task(self._send_heartbeat())
except (asyncio.IncompleteReadError, ConnectionError, OSError):
await self._on_disconnected(); return
async def connect_tcp(host: str, port: int, interval_sec: int, wait_sec: int,
parser_map: ParserMap) -> TcpClient:
reader, writer = await asyncio.open_connection(host, port)
return TcpClient(reader, writer, interval_sec, wait_sec, parser_map)
# tcp_server.py
class TcpServer:
def __init__(self, host: str, port: int, new_client: Callable) -> None:
self._host = host
self._port = port
self._new_client = new_client
self._clients: list[TcpClient] = []
self._server: asyncio.Server | None = None
self.on_client_connected: Callable[[TcpClient], None] = lambda c: None
async def start(self) -> None: ...
async def stop(self) -> None: ...
def clients(self) -> list[TcpClient]: ...
async def broadcast(self, message) -> None: ...
수정 파일 및 체크리스트
python/toki_socket/tcp_client.py생성MAX_PACKET_SIZE = 64 << 20TcpClient(BaseClient)— reader/writer, read_loop, write_packet, do_closeconnect_tcp(host, port, interval_sec, wait_sec, parser_map)async 함수
python/toki_socket/tcp_server.py생성TcpServer— start/stop/clients/broadcast, on_client_connected 콜백_handle_connection()— TcpClient 생성, disconnect_listener로 clients 목록 관리
테스트 작성
python/test/test_tcp.py:
test_tcp_send_receive— 루프백 TCP로 양방향 TestData 교환test_tcp_request_response— send_request / add_request_listener 검증
@pytest.mark.asyncio
async def test_tcp_send_receive():
received = asyncio.Future()
server = TcpServer("127.0.0.1", 0, lambda r, w: TcpClient(r, w, 0, 0, parser_map()))
server.on_client_connected = lambda c: c.communicator.add_listener(
"TestData", lambda m: received.set_result(m)
)
await server.start()
port = server.port # 동적 포트
client = await connect_tcp("127.0.0.1", port, 0, 0, parser_map())
await client.communicator.send(TestData(index=1, message="hello"))
result = await asyncio.wait_for(received, 2.0)
assert result.index == 1 and result.message == "hello"
await client.close()
await server.stop()
중간 검증
cd python && python -m pytest test/test_tcp.py -v
예상: 2개 테스트 PASS
[API-5] WebSocket Transport 구현
문제
Go의 WsClient / WsServer에 해당하는 asyncio WebSocket 구현이 없다.
해결 방법
websockets 라이브러리 사용. WebSocket은 메시지 경계가 보장되므로 길이 헤더 없이 바이너리 프레임으로 전송.
# ws_client.py
import websockets
class WsClient(BaseClient):
def __init__(self, ws: websockets.WebSocketClientProtocol | websockets.WebSocketServerProtocol,
interval_sec: int, wait_sec: int, parser_map: ParserMap) -> None:
super().__init__(interval_sec, wait_sec, do_close=self._do_close_impl)
self._ws = ws
self._init_base()
self.communicator.initialize(self, parser_map)
self.communicator.set_write_error_handler(lambda e: asyncio.create_task(self._on_disconnected()))
self.communicator.add_listener(
type_name_of(HeartBeat), lambda m: asyncio.create_task(self._on_heartbeat())
)
asyncio.create_task(self._read_loop())
asyncio.create_task(self._send_heartbeat())
async def write_packet(self, base: PacketBase) -> None:
await self._ws.send(base.SerializeToString())
async def _do_close_impl(self) -> None:
await self._ws.close()
async def _read_loop(self) -> None:
try:
async for message in self._ws:
if not isinstance(message, bytes):
continue
base = PacketBase()
base.ParseFromString(message)
self.communicator.on_received_data(
base.typeName, base.data, base.nonce, base.responseNonce
)
asyncio.create_task(self._send_heartbeat())
except Exception:
pass
finally:
await self._on_disconnected()
async def connect_ws(host: str, port: int, path: str,
interval_sec: int, wait_sec: int, parser_map: ParserMap) -> WsClient:
uri = f"ws://{host}:{port}{path}"
ws = await websockets.connect(uri)
return WsClient(ws, interval_sec, wait_sec, parser_map)
# ws_server.py
class WsServer:
def __init__(self, host: str, port: int, path: str, new_client: Callable) -> None: ...
async def start(self) -> None: ...
async def stop(self) -> None: ...
def clients(self) -> list[WsClient]: ...
async def broadcast(self, message) -> None: ...
수정 파일 및 체크리스트
python/toki_socket/ws_client.py생성WsClient(BaseClient)— websocket conn, read_loop, write_packet, do_closeconnect_ws(host, port, path, interval_sec, wait_sec, parser_map)async 함수
python/toki_socket/ws_server.py생성WsServer— start/stop/clients/broadcast, path 기반 핸들러_handler(ws, path)— WsClient 생성, disconnect_listener로 목록 관리
테스트 작성
skip — TCP 테스트로 핵심 로직이 검증되며, WS는 크로스 테스트에서 검증.
중간 검증
cd python && python -c "from toki_socket.ws_client import WsClient; from toki_socket.ws_server import WsServer; print('OK')"
예상: OK
[API-6] 단위 테스트
API-2, API-4 항목에서 이미 기술. 해당 항목 체크리스트 참조.
[API-7] Python×Go 크로스 테스트
문제
Python이 Go 서버와 통신할 수 있는지, Go가 Python 서버와 통신할 수 있는지 검증이 없다.
해결 방법
포트 할당:
- Go 서버 / Python 클라이언트: TCP
29390, WS29392 - Python 서버 / Go 클라이언트: TCP
29490, WS29492
Python 클라이언트 헬퍼 python/crosstest/go_python_client.py:
--mode tcp|ws,--port N,--phase send-push|requests플래그INFO typeName python=TestData출력- 각 시나리오 결과를
PASS scenario=N detail=.../FAIL scenario=N error=...출력 - send-push: 시나리오 1(fire-and-forget), 2(server push 수신)
- requests: 시나리오 3(단일 요청), 4(동시 5개 요청 nonce 검증)
- Go crosstest 클라이언트(
go/crosstest/dart_go_client/main.go)와 동일한 구조
Go 오케스트레이터 go/crosstest/go_python.go:
- Go 서버를 시작하고
python go_python_client.py서브프로세스를 실행 - 기존
go_dart.go패턴과 동일 (//go:build ignore,runPythonClient()헬퍼) - Python 패키지 디렉터리를 소스 위치 기반으로 찾는
pythonPackageDir()함수
Python 오케스트레이터 python/crosstest/python_go.py:
- Python 서버를 시작하고
go run ./crosstest/python_go_client서브프로세스를 실행 go/crosstest/dart_go_client/main.go패턴을 참고하여--mode,--port,--phase플래그 지원- Go 패키지 디렉터리를 소스 위치 기반으로 찾는 헬퍼
Go 클라이언트 헬퍼 go/crosstest/python_go_client/main.go:
- 기존
dart_go_client/main.go와 동일한 구조 - Python 서버 포트(29490 TCP, 29492 WS)에 연결
--phase send-push: 시나리오 1, 2--phase requests: 시나리오 3, 4- push 메시지 검증:
index=200 && message=="push from python server"
시나리오별 메시지 규격:
| 시나리오 | 클라이언트 송신 | 서버 검증 / 응답 |
|---|---|---|
| 1 (send) | index=101, message="fire from python client" |
Go서버: index==101, message 검증 |
| 2 (push) | — | Go서버 → index=200, message="push from go server" |
| 3 (request) | index=21, message="single request from python" |
Go서버 → index=42, message="echo: single request from python" |
| 4 (concurrent) | index=30+i, message="multi request {i} from python" |
Go서버 → index=(30+i)*2, message="echo: ..." |
Python서버일 때는 go클라이언트 메시지 검증:
- 1:
index=101, message="fire from go client" - 2: Python서버 →
index=200, message="push from python server"
수정 파일 및 체크리스트
python/crosstest/__init__.py생성 (빈 파일)python/crosstest/go_python_client.py생성- argparse로
--mode,--port,--phase파싱 INFO typeName python=TestData출력dial_with_retry(mode, port)— 3초 내 재시도run_send_push(client)— 시나리오 1, 2run_requests(client)— 시나리오 3, 4 (asyncio.gather로 동시 요청)- 비동기 main →
asyncio.run(main())
- argparse로
go/crosstest/go_python.go생성//go:build ignore태그goPythonTCPPort = 29390,goPythonWSPort = 29392runTCPSendPush/TCPRequests/WSSendPush/WSRequests()4개 함수runPythonClient(mode, port, phase, expected)—python go_python_client.py실행pythonPackageDir()— 소스 위치 기반 탐색
python/crosstest/python_go.py생성pythonGoTCPPort = 29490,pythonGoWSPort = 29492run_tcp_send_push/tcp_requests/ws_send_push/ws_requests()4개 함수run_go_client(mode, port, phase, expected)—go run ./crosstest/python_go_client실행go_package_dir()— 소스 위치 기반 탐색- 비동기 main →
asyncio.run(main())
go/crosstest/python_go_client/main.go생성dart_go_client/main.go와 동일 구조- Python서버 포트(
--port N)에 연결 - push 메시지:
"push from python server"
테스트 작성
크로스 테스트가 곧 테스트. 별도 단위 테스트 skip.
중간 검증
Go 서버 / Python 클라이언트:
cd go && go run ./crosstest/go_python.go
Python 서버 / Go 클라이언트:
cd python && python crosstest/python_go.py
예상: 각각 PASS all ... 출력
수정 파일 요약
| 파일 | 항목 |
|---|---|
python/pyproject.toml |
API-1 |
python/toki_socket/__init__.py |
API-1 |
python/toki_socket/packets/__init__.py |
API-1 |
python/toki_socket/packets/message_common.proto |
API-1 |
python/toki_socket/packets/message_common_pb2.py |
API-1 |
python/toki_socket/packets/message_common_pb2.pyi |
API-1 |
python/README.md |
API-1 |
python/toki_socket/communicator.py |
API-2 |
python/test/__init__.py |
API-2 |
python/test/test_communicator.py |
API-2 |
python/toki_socket/base_client.py |
API-3 |
python/toki_socket/tcp_client.py |
API-4 |
python/toki_socket/tcp_server.py |
API-4 |
python/test/test_tcp.py |
API-4 |
python/toki_socket/ws_client.py |
API-5 |
python/toki_socket/ws_server.py |
API-5 |
python/crosstest/__init__.py |
API-7 |
python/crosstest/go_python_client.py |
API-7 |
python/crosstest/python_go.py |
API-7 |
go/crosstest/go_python.go |
API-7 |
go/crosstest/python_go_client/main.go |
API-7 |
최종 검증
# 1. Python 단위 테스트
cd python && pip install -e ".[dev]" && python -m pytest test/ -v
# 2. Go 서버 / Python 클라이언트 크로스 테스트
cd go && go run ./crosstest/go_python.go
# 3. Python 서버 / Go 클라이언트 크로스 테스트
cd python && python crosstest/python_go.py
# 4. 기존 Go 크로스 테스트 회귀 확인
cd go && go run ./crosstest/go_dart.go
cd go && go run ./crosstest/go_kotlin.go
예상 결과:
pytest: 모든 테스트 PASSgo run ./crosstest/go_python.go:PASS all go-server/python-client crosstests passedpython crosstest/python_go.py:PASS all python-server/go-client crosstests passed- 기존 크로스 테스트: 회귀 없음 (PASS)