kwcli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kiwoom/__init__.py +47 -0
- kiwoom/_data/kiwoom_api_spec.json +66372 -0
- kiwoom/core/__init__.py +6 -0
- kiwoom/core/auth.py +349 -0
- kiwoom/core/client.py +244 -0
- kiwoom/core/errors.py +177 -0
- kiwoom/core/platform_paths.py +68 -0
- kiwoom/core/profiles.py +143 -0
- kiwoom/core/runtime.py +153 -0
- kiwoom/core/secrets.py +217 -0
- kiwoom/core/settings.py +64 -0
- kiwoom/core/token_store.py +186 -0
- kiwoom/core/types.py +28 -0
- kiwoom/core/ws_client.py +262 -0
- kiwoom/realtime/__init__.py +30 -0
- kiwoom/realtime/decoders.py +171 -0
- kiwoom/realtime/events.py +98 -0
- kiwoom/realtime/packets.py +65 -0
- kiwoom/realtime/schemas.py +76 -0
- kiwoom/realtime/stream.py +213 -0
- kiwoom/specs.py +272 -0
- kiwoom_cli/README.md +671 -0
- kiwoom_cli/__init__.py +9 -0
- kiwoom_cli/__main__.py +5 -0
- kiwoom_cli/argument_maps.py +561 -0
- kiwoom_cli/arguments.py +147 -0
- kiwoom_cli/auth_context.py +64 -0
- kiwoom_cli/banner.py +125 -0
- kiwoom_cli/commands/__init__.py +1 -0
- kiwoom_cli/commands/auth.py +406 -0
- kiwoom_cli/commands/groups.py +281 -0
- kiwoom_cli/commands/mapped.py +74 -0
- kiwoom_cli/commands/orders.py +158 -0
- kiwoom_cli/commands/spec.py +98 -0
- kiwoom_cli/commands/stocks.py +100 -0
- kiwoom_cli/commands/streams.py +470 -0
- kiwoom_cli/doctor.py +324 -0
- kiwoom_cli/errors.py +24 -0
- kiwoom_cli/executor/__init__.py +33 -0
- kiwoom_cli/executor/condition.py +374 -0
- kiwoom_cli/executor/rest.py +132 -0
- kiwoom_cli/executor/waits.py +34 -0
- kiwoom_cli/executor/websocket.py +131 -0
- kiwoom_cli/main.py +203 -0
- kiwoom_cli/maps/README.md +99 -0
- kiwoom_cli/maps/api_commands.csv +209 -0
- kiwoom_cli/maps/arguments.csv +731 -0
- kiwoom_cli/maps/order_confirmation_commands.csv +13 -0
- kiwoom_cli/maps/order_confirmation_fields.csv +71 -0
- kiwoom_cli/maps/order_price_policies.csv +47 -0
- kiwoom_cli/maps/order_value_labels.csv +28 -0
- kiwoom_cli/maps/positional_arguments.csv +21 -0
- kiwoom_cli/order_confirmation.py +167 -0
- kiwoom_cli/output.py +136 -0
- kiwoom_cli/registry.py +95 -0
- kiwoom_cli/safety.py +30 -0
- kiwoom_cli/setup.py +533 -0
- kwcli-0.1.0.dist-info/METADATA +215 -0
- kwcli-0.1.0.dist-info/RECORD +62 -0
- kwcli-0.1.0.dist-info/WHEEL +4 -0
- kwcli-0.1.0.dist-info/entry_points.txt +2 -0
- kwcli-0.1.0.dist-info/licenses/LICENSE.md +36 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Stock resource commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from kiwoom_cli.argument_maps import build_body
|
|
9
|
+
from kiwoom_cli.commands.mapped import add_mapped_command_parsers
|
|
10
|
+
from kiwoom_cli.executor import execute_rest_command
|
|
11
|
+
from kiwoom_cli.output import format_payload, format_response
|
|
12
|
+
from kiwoom_cli.registry import get_implemented_command
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
STOCK_COMMANDS = (
|
|
16
|
+
("info", "종목 기본정보를 조회합니다."),
|
|
17
|
+
("trend", "종목 신용매매 동향을 조회합니다."),
|
|
18
|
+
("realtime-rank", "실시간 종목 조회 순위를 조회합니다."),
|
|
19
|
+
("brokers", "주식 거래원을 조회합니다."),
|
|
20
|
+
("fills", "체결 정보를 조회합니다."),
|
|
21
|
+
("daily-trades", "일별 거래 상세를 조회합니다."),
|
|
22
|
+
("new-high-low", "신고가/신저가 종목을 조회합니다."),
|
|
23
|
+
("limit-move", "상하한가/상승하락 종목을 조회합니다."),
|
|
24
|
+
("high-low-near", "고저가 근접 종목을 조회합니다."),
|
|
25
|
+
("price-spike", "가격 급등락 종목을 조회합니다."),
|
|
26
|
+
("volume-renewal", "거래량 갱신 종목을 조회합니다."),
|
|
27
|
+
("volume-zone", "매물대 집중 종목을 조회합니다."),
|
|
28
|
+
("valuation-rank", "고저 PER/PBR/ROE 순위를 조회합니다."),
|
|
29
|
+
("open-change", "시가 대비 등락률을 조회합니다."),
|
|
30
|
+
("broker-volume-zone", "거래원 매물대 분석을 조회합니다."),
|
|
31
|
+
("broker-instant-volume", "거래원 순간 거래량을 조회합니다."),
|
|
32
|
+
("vi-triggered", "변동성완화장치 발동 종목을 조회합니다."),
|
|
33
|
+
("today-previous-fills", "당일/전일 체결량을 조회합니다."),
|
|
34
|
+
("investor-daily", "투자자별 일별 매매 종목을 조회합니다."),
|
|
35
|
+
("investor-by-stock", "종목별 투자자/기관 매매를 조회합니다."),
|
|
36
|
+
("investor-by-stock-total", "종목별 투자자/기관 매매 합계를 조회합니다."),
|
|
37
|
+
("today-previous-trades", "당일/전일 체결을 조회합니다."),
|
|
38
|
+
("watchlist-info", "관심종목 정보를 조회합니다."),
|
|
39
|
+
("info-list", "종목정보 리스트를 조회합니다."),
|
|
40
|
+
("info-detail", "종목정보를 조회합니다."),
|
|
41
|
+
("sector-codes", "업종코드 리스트를 조회합니다."),
|
|
42
|
+
("member-firms", "회원사 리스트를 조회합니다."),
|
|
43
|
+
("program-net-top", "프로그램 순매수 상위 50을 조회합니다."),
|
|
44
|
+
("program-by-stock", "종목별 프로그램매매 현황을 조회합니다."),
|
|
45
|
+
("credit-loanable", "신용융자 가능 종목을 조회합니다."),
|
|
46
|
+
("credit-loanable-check", "신용융자 가능 여부를 조회합니다."),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def add_stocks_parser(subparsers: argparse._SubParsersAction) -> None:
|
|
51
|
+
stocks_parser = subparsers.add_parser("stocks", help="종목 정보 조회 명령입니다.")
|
|
52
|
+
stocks_subparsers = stocks_parser.add_subparsers(dest="stocks_command", required=True)
|
|
53
|
+
add_mapped_command_parsers(
|
|
54
|
+
stocks_subparsers,
|
|
55
|
+
group="stocks",
|
|
56
|
+
commands=STOCK_COMMANDS,
|
|
57
|
+
handler=handle_stocks_mapped,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def handle_stocks_mapped(args: argparse.Namespace) -> None:
|
|
62
|
+
_execute(args.stocks_command, args)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _execute(command: str, args: argparse.Namespace) -> None:
|
|
66
|
+
definition = get_implemented_command("stocks", command)
|
|
67
|
+
body = build_body(definition.command_path, args)
|
|
68
|
+
response = execute_rest_command(
|
|
69
|
+
definition,
|
|
70
|
+
body=body,
|
|
71
|
+
mode=args.mode,
|
|
72
|
+
profile=args.profile,
|
|
73
|
+
pages=getattr(args, "pages", 1),
|
|
74
|
+
)
|
|
75
|
+
if command == "credit-loanable-check":
|
|
76
|
+
payload = _with_credit_loanable_status(response.body)
|
|
77
|
+
print(format_payload(payload, output_format=args.format))
|
|
78
|
+
return
|
|
79
|
+
print(format_response(response, output_format=args.format))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _with_credit_loanable_status(payload: Any) -> Any:
|
|
83
|
+
if not isinstance(payload, dict):
|
|
84
|
+
return payload
|
|
85
|
+
normalized = dict(payload)
|
|
86
|
+
normalized["loanable"] = _derive_loanable(payload.get("crd_alow_yn"))
|
|
87
|
+
return normalized
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _derive_loanable(value: Any) -> bool | None:
|
|
91
|
+
if value is None:
|
|
92
|
+
return None
|
|
93
|
+
text = str(value).strip()
|
|
94
|
+
if not text:
|
|
95
|
+
return None
|
|
96
|
+
if "불가능" in text:
|
|
97
|
+
return False
|
|
98
|
+
if "가능" in text:
|
|
99
|
+
return True
|
|
100
|
+
return None
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""WebSocket stream commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from kiwoom.realtime import decode_realtime_message_named
|
|
11
|
+
from kiwoom_cli.argument_maps import (
|
|
12
|
+
TYPE_PARSERS,
|
|
13
|
+
add_mapped_arguments,
|
|
14
|
+
build_body,
|
|
15
|
+
get_argument_definitions,
|
|
16
|
+
)
|
|
17
|
+
from kiwoom_cli.arguments import add_output_format_arg, add_runtime_args
|
|
18
|
+
from kiwoom_cli.errors import CliInputError
|
|
19
|
+
from kiwoom_cli.executor import (
|
|
20
|
+
execute_condition_command,
|
|
21
|
+
execute_websocket_request_once,
|
|
22
|
+
execute_websocket_stream,
|
|
23
|
+
)
|
|
24
|
+
from kiwoom_cli.output import format_payload
|
|
25
|
+
from kiwoom_cli.registry import get_implemented_command
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
CONDITION_COMMANDS = {
|
|
29
|
+
"conditions-search",
|
|
30
|
+
"conditions-subscribe",
|
|
31
|
+
"conditions-unsubscribe",
|
|
32
|
+
}
|
|
33
|
+
DEFAULT_CONDITION_MAX_WAIT_SECONDS = 15.0
|
|
34
|
+
DEFAULT_STREAM_MAX_WAIT_SECONDS = 15.0
|
|
35
|
+
CONDITION_HTS_NOTE = (
|
|
36
|
+
"조건검색식 생성/수정은 영웅문 HTS에서 해야 합니다. "
|
|
37
|
+
"CLI는 HTS에 저장된 조건식을 목록 조회, 선택, 조회, 구독, 해제만 합니다."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Account numbers stay redacted; order numbers are operational identifiers the
|
|
41
|
+
# user needs and are not redacted.
|
|
42
|
+
STREAM_REDACT_FIELDS = (
|
|
43
|
+
"acctNo",
|
|
44
|
+
"acnt_no",
|
|
45
|
+
"acntNo",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
DOMESTIC_STREAM_COMMANDS = (
|
|
49
|
+
("conditions-list", "조건검색식 목록을 조회합니다."),
|
|
50
|
+
("conditions-search", "조건검색 결과를 1회 조회합니다."),
|
|
51
|
+
("conditions-subscribe", "조건검색 실시간 결과를 구독합니다."),
|
|
52
|
+
("conditions-unsubscribe", "조건검색 실시간 구독을 해제합니다."),
|
|
53
|
+
("order-fills", "주문체결 실시간 이벤트를 구독합니다."),
|
|
54
|
+
("balance", "잔고 실시간 이벤트를 구독합니다."),
|
|
55
|
+
("momentum", "주식기세 실시간 데이터를 구독합니다."),
|
|
56
|
+
("trades", "주식체결 실시간 데이터를 구독합니다."),
|
|
57
|
+
("best-quotes", "주식우선호가 실시간 데이터를 구독합니다."),
|
|
58
|
+
("orderbook", "주식호가잔량 실시간 데이터를 구독합니다."),
|
|
59
|
+
("after-hours-orderbook", "주식시간외호가 실시간 데이터를 구독합니다."),
|
|
60
|
+
("brokers", "주식당일거래원 실시간 데이터를 구독합니다."),
|
|
61
|
+
("etf-nav", "ETF NAV 실시간 데이터를 구독합니다."),
|
|
62
|
+
("expected-fills", "주식예상체결 실시간 데이터를 구독합니다."),
|
|
63
|
+
("gold-conversion", "국제금환산가격 실시간 데이터를 구독합니다."),
|
|
64
|
+
("sector-index", "업종지수 실시간 데이터를 구독합니다."),
|
|
65
|
+
("sector-change", "업종등락 실시간 데이터를 구독합니다."),
|
|
66
|
+
("stock-info", "주식종목정보 실시간 데이터를 구독합니다."),
|
|
67
|
+
("elw-theory", "ELW 이론가 실시간 데이터를 구독합니다."),
|
|
68
|
+
("market-open", "장시작시간 실시간 데이터를 구독합니다."),
|
|
69
|
+
("elw-indicator", "ELW 지표 실시간 데이터를 구독합니다."),
|
|
70
|
+
("program-trades", "종목프로그램매매 실시간 데이터를 구독합니다."),
|
|
71
|
+
("vi", "VI발동/해제 실시간 데이터를 구독합니다."),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
STREAM_MARKETS: dict[str, dict[str, Any]] = {
|
|
75
|
+
"domestic": {
|
|
76
|
+
"group": "streams",
|
|
77
|
+
"dest": "streams_command",
|
|
78
|
+
"help_text": "WebSocket 실시간/조건검색 명령입니다.",
|
|
79
|
+
"commands": DOMESTIC_STREAM_COMMANDS,
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def add_streams_parser(
|
|
85
|
+
subparsers: argparse._SubParsersAction, market: str = "domestic"
|
|
86
|
+
) -> None:
|
|
87
|
+
market_table = STREAM_MARKETS[market]
|
|
88
|
+
group: str = market_table["group"]
|
|
89
|
+
streams_parser = subparsers.add_parser("streams", help=market_table["help_text"])
|
|
90
|
+
streams_subparsers = streams_parser.add_subparsers(
|
|
91
|
+
dest=market_table["dest"], required=True
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
for command, help_text in market_table["commands"]:
|
|
95
|
+
definition = get_implemented_command(group, command)
|
|
96
|
+
parser_kwargs: dict[str, Any] = {"help": help_text}
|
|
97
|
+
if command.startswith("conditions-"):
|
|
98
|
+
parser_kwargs.update(
|
|
99
|
+
{
|
|
100
|
+
"description": f"{help_text} {CONDITION_HTS_NOTE}",
|
|
101
|
+
"epilog": CONDITION_HTS_NOTE,
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
command_parser = streams_subparsers.add_parser(command, **parser_kwargs)
|
|
105
|
+
code_action_overrides = (
|
|
106
|
+
{"code": "append"}
|
|
107
|
+
if _command_has_code_argument(definition.command_path)
|
|
108
|
+
else {}
|
|
109
|
+
)
|
|
110
|
+
code_required_overrides = {"code": False} if code_action_overrides else {}
|
|
111
|
+
add_mapped_arguments(
|
|
112
|
+
command_parser,
|
|
113
|
+
definition.command_path,
|
|
114
|
+
required_overrides=code_required_overrides,
|
|
115
|
+
action_overrides=code_action_overrides,
|
|
116
|
+
)
|
|
117
|
+
if command == "conditions-subscribe":
|
|
118
|
+
_add_count_duration_args(
|
|
119
|
+
command_parser,
|
|
120
|
+
duration_type=_positive_duration,
|
|
121
|
+
duration_help="조건검색 REAL 데이터를 기다릴 최대 시간(초)",
|
|
122
|
+
)
|
|
123
|
+
command_parser.add_argument(
|
|
124
|
+
"--check",
|
|
125
|
+
action="store_true",
|
|
126
|
+
help="등록 확인용으로 2초 동안 수집 후 REAL 수신 여부와 관계없이 종료합니다.",
|
|
127
|
+
)
|
|
128
|
+
if _supports_foreground_stream_options(command) and code_action_overrides:
|
|
129
|
+
command_parser.add_argument(
|
|
130
|
+
"--codes",
|
|
131
|
+
dest="codes",
|
|
132
|
+
help="쉼표로 구분한 복수 등록 요소/종목코드입니다. --code 반복 입력과 함께 사용할 수 있습니다.",
|
|
133
|
+
)
|
|
134
|
+
if _supports_foreground_stream_options(command):
|
|
135
|
+
_add_count_duration_args(command_parser)
|
|
136
|
+
command_parser.add_argument(
|
|
137
|
+
"--watch",
|
|
138
|
+
action="store_true",
|
|
139
|
+
help="시간/건수 제한 없이 계속 수신합니다.",
|
|
140
|
+
)
|
|
141
|
+
command_parser.add_argument(
|
|
142
|
+
"--check",
|
|
143
|
+
action="store_true",
|
|
144
|
+
help="등록 확인용으로 2초 동안 수신 후 종료합니다.",
|
|
145
|
+
)
|
|
146
|
+
command_parser.add_argument(
|
|
147
|
+
"--named",
|
|
148
|
+
action="store_true",
|
|
149
|
+
help="REAL values FID를 스키마 기반 이름으로 변환해 출력합니다.",
|
|
150
|
+
)
|
|
151
|
+
command_parser.add_argument(
|
|
152
|
+
"--output",
|
|
153
|
+
type=Path,
|
|
154
|
+
help="이벤트를 stdout 대신 이 JSONL 파일에 씁니다.",
|
|
155
|
+
)
|
|
156
|
+
add_output_format_arg(command_parser)
|
|
157
|
+
add_runtime_args(command_parser)
|
|
158
|
+
command_parser.set_defaults(
|
|
159
|
+
handler=handle_streams_mapped,
|
|
160
|
+
streams_group=group,
|
|
161
|
+
streams_command=command,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def handle_streams_mapped(args: argparse.Namespace) -> None:
|
|
166
|
+
asyncio.run(_run_streams_command(args))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def _run_streams_command(args: argparse.Namespace) -> None:
|
|
170
|
+
definition = get_implemented_command(args.streams_group, args.streams_command)
|
|
171
|
+
body = _build_websocket_body(definition.command_path, args)
|
|
172
|
+
redact_fields = STREAM_REDACT_FIELDS if definition.safety_policy == "account_read" else ()
|
|
173
|
+
|
|
174
|
+
if args.streams_command in CONDITION_COMMANDS:
|
|
175
|
+
condition_max_messages, condition_max_wait_seconds = _resolve_condition_limits(args)
|
|
176
|
+
response = await execute_condition_command(
|
|
177
|
+
args.streams_command,
|
|
178
|
+
definition,
|
|
179
|
+
body=body,
|
|
180
|
+
mode=args.mode,
|
|
181
|
+
profile=args.profile,
|
|
182
|
+
max_messages=condition_max_messages,
|
|
183
|
+
max_wait_seconds=condition_max_wait_seconds,
|
|
184
|
+
exchange=getattr(args, "exchange", None),
|
|
185
|
+
)
|
|
186
|
+
print(format_payload(response, output_format=args.format, redact_fields=redact_fields))
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
if _is_subscription_command(args.streams_command):
|
|
190
|
+
if body.get("trnm") == "REMOVE":
|
|
191
|
+
response = await execute_websocket_request_once(
|
|
192
|
+
definition,
|
|
193
|
+
body=body,
|
|
194
|
+
mode=args.mode,
|
|
195
|
+
profile=args.profile,
|
|
196
|
+
)
|
|
197
|
+
print(format_payload(response, output_format=args.format, redact_fields=redact_fields))
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
max_messages, max_wait_seconds = _resolve_stream_limits(args)
|
|
201
|
+
line_mode = _use_line_mode(args.format, max_messages=max_messages, max_wait_seconds=max_wait_seconds)
|
|
202
|
+
messages: list[Any] = []
|
|
203
|
+
try:
|
|
204
|
+
async for message in execute_websocket_stream(
|
|
205
|
+
definition,
|
|
206
|
+
body=body,
|
|
207
|
+
mode=args.mode,
|
|
208
|
+
profile=args.profile,
|
|
209
|
+
max_messages=max_messages,
|
|
210
|
+
max_wait_seconds=max_wait_seconds,
|
|
211
|
+
):
|
|
212
|
+
if getattr(args, "named", False):
|
|
213
|
+
message = decode_realtime_message_named(message)
|
|
214
|
+
if line_mode:
|
|
215
|
+
_emit_stream_payload(
|
|
216
|
+
message,
|
|
217
|
+
args=args,
|
|
218
|
+
redact_fields=redact_fields,
|
|
219
|
+
append=True,
|
|
220
|
+
)
|
|
221
|
+
else:
|
|
222
|
+
messages.append(message)
|
|
223
|
+
except Exception:
|
|
224
|
+
if not line_mode and messages:
|
|
225
|
+
_emit_stream_payload(
|
|
226
|
+
messages,
|
|
227
|
+
args=args,
|
|
228
|
+
redact_fields=redact_fields,
|
|
229
|
+
append=False,
|
|
230
|
+
)
|
|
231
|
+
raise
|
|
232
|
+
if not line_mode:
|
|
233
|
+
_emit_stream_payload(
|
|
234
|
+
messages,
|
|
235
|
+
args=args,
|
|
236
|
+
redact_fields=redact_fields,
|
|
237
|
+
append=False,
|
|
238
|
+
)
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
response = await execute_websocket_request_once(
|
|
242
|
+
definition,
|
|
243
|
+
body=body,
|
|
244
|
+
mode=args.mode,
|
|
245
|
+
profile=args.profile,
|
|
246
|
+
)
|
|
247
|
+
print(format_payload(response, output_format=args.format, redact_fields=redact_fields))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _build_websocket_body(command_path: str, args: argparse.Namespace) -> dict[str, Any]:
|
|
251
|
+
_merge_stream_code_args(command_path, args)
|
|
252
|
+
body = build_body(command_path, args)
|
|
253
|
+
if "type" not in body:
|
|
254
|
+
return body
|
|
255
|
+
|
|
256
|
+
stream_type = body.pop("type")
|
|
257
|
+
item_value = body.pop("item", "")
|
|
258
|
+
if isinstance(item_value, list):
|
|
259
|
+
raw_items = item_value
|
|
260
|
+
else:
|
|
261
|
+
raw_items = str(item_value).split(",")
|
|
262
|
+
items: list[Any] = [str(item).strip() for item in raw_items if str(item).strip()]
|
|
263
|
+
if not items:
|
|
264
|
+
items = [""]
|
|
265
|
+
body["data"] = [{"item": items, "type": [stream_type]}]
|
|
266
|
+
return body
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _command_has_code_argument(command_path: str) -> bool:
|
|
270
|
+
return any(
|
|
271
|
+
definition.dest == "code" and definition.option == "--code"
|
|
272
|
+
for definition in get_argument_definitions(command_path)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _supports_foreground_stream_options(command: str) -> bool:
|
|
277
|
+
return command not in {
|
|
278
|
+
"conditions-list",
|
|
279
|
+
"conditions-search",
|
|
280
|
+
"conditions-subscribe",
|
|
281
|
+
"conditions-unsubscribe",
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _emit_stream_payload(
|
|
286
|
+
payload: Any,
|
|
287
|
+
*,
|
|
288
|
+
args: argparse.Namespace,
|
|
289
|
+
redact_fields: tuple[str, ...],
|
|
290
|
+
append: bool,
|
|
291
|
+
) -> None:
|
|
292
|
+
rendered = format_payload(payload, output_format=args.format, redact_fields=redact_fields)
|
|
293
|
+
output_path = getattr(args, "output", None)
|
|
294
|
+
if output_path is None:
|
|
295
|
+
print(rendered, flush=True)
|
|
296
|
+
return
|
|
297
|
+
path = Path(output_path)
|
|
298
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
299
|
+
mode = "a" if append else "w"
|
|
300
|
+
with path.open(mode, encoding="utf-8") as file:
|
|
301
|
+
file.write(rendered)
|
|
302
|
+
file.write("\n")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _merge_stream_code_args(command_path: str, args: argparse.Namespace) -> None:
|
|
306
|
+
code_definition = next(
|
|
307
|
+
(
|
|
308
|
+
definition
|
|
309
|
+
for definition in get_argument_definitions(command_path)
|
|
310
|
+
if definition.dest == "code"
|
|
311
|
+
),
|
|
312
|
+
None,
|
|
313
|
+
)
|
|
314
|
+
if code_definition is None:
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
values: list[str] = []
|
|
318
|
+
code_value = getattr(args, "code", None)
|
|
319
|
+
if isinstance(code_value, list):
|
|
320
|
+
values.extend(str(item) for item in code_value if str(item).strip())
|
|
321
|
+
elif code_value not in (None, ""):
|
|
322
|
+
values.extend(str(code_value).split(","))
|
|
323
|
+
|
|
324
|
+
codes_value = getattr(args, "codes", None)
|
|
325
|
+
if codes_value not in (None, ""):
|
|
326
|
+
for item in str(codes_value).split(","):
|
|
327
|
+
stripped = item.strip()
|
|
328
|
+
if stripped:
|
|
329
|
+
values.append(_validate_stream_code_item(code_definition, stripped, source="--codes"))
|
|
330
|
+
|
|
331
|
+
deduped: list[str] = []
|
|
332
|
+
seen: set[str] = set()
|
|
333
|
+
for value in values:
|
|
334
|
+
stripped = str(value).strip()
|
|
335
|
+
if not stripped or stripped in seen:
|
|
336
|
+
continue
|
|
337
|
+
seen.add(stripped)
|
|
338
|
+
deduped.append(stripped)
|
|
339
|
+
if deduped:
|
|
340
|
+
setattr(args, "code", ",".join(deduped))
|
|
341
|
+
elif code_definition.required:
|
|
342
|
+
raise CliInputError(f"{command_path}: --code 또는 --codes 옵션이 필요합니다.")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _validate_stream_code_item(definition: Any, value: str, *, source: str) -> str:
|
|
346
|
+
parser = TYPE_PARSERS.get(definition.type_name)
|
|
347
|
+
try:
|
|
348
|
+
parsed = parser(value) if parser is not None else value
|
|
349
|
+
except argparse.ArgumentTypeError as exc:
|
|
350
|
+
raise CliInputError(f"{source} 값 오류: {exc}") from exc
|
|
351
|
+
if definition.choices and parsed not in definition.choices:
|
|
352
|
+
choices = ", ".join(definition.choices)
|
|
353
|
+
raise CliInputError(f"{source} 값 오류: {parsed!r} 값은 다음 중 하나여야 합니다: {choices}")
|
|
354
|
+
return str(parsed)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _resolve_stream_limits(args: argparse.Namespace) -> tuple[int, float | str]:
|
|
358
|
+
count = getattr(args, "count", None)
|
|
359
|
+
duration = getattr(args, "duration", None)
|
|
360
|
+
watch = bool(getattr(args, "watch", False))
|
|
361
|
+
check = bool(getattr(args, "check", False))
|
|
362
|
+
|
|
363
|
+
if check and (watch or count is not None or duration is not None):
|
|
364
|
+
raise CliInputError("--check는 --watch, --count, --duration과 함께 사용할 수 없습니다.")
|
|
365
|
+
if watch and count is not None:
|
|
366
|
+
raise CliInputError("--watch는 --count와 함께 사용할 수 없습니다.")
|
|
367
|
+
|
|
368
|
+
if check:
|
|
369
|
+
return 0, 2.0
|
|
370
|
+
if watch:
|
|
371
|
+
if getattr(args, "format", "pretty") in {"json", "yaml"}:
|
|
372
|
+
raise CliInputError(
|
|
373
|
+
"--watch에는 --format jsonl 또는 pretty를 사용해야 합니다. "
|
|
374
|
+
"--format json/yaml은 --count 또는 --duration으로 종료 조건을 지정해 주세요."
|
|
375
|
+
)
|
|
376
|
+
return 0, 0.0
|
|
377
|
+
|
|
378
|
+
max_messages = count if count is not None else 1
|
|
379
|
+
max_wait_seconds = duration if duration is not None else DEFAULT_STREAM_MAX_WAIT_SECONDS
|
|
380
|
+
return _normalize_max_messages(max_messages), max_wait_seconds
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _resolve_condition_limits(args: argparse.Namespace) -> tuple[int, float]:
|
|
384
|
+
count = getattr(args, "count", None)
|
|
385
|
+
duration = getattr(args, "duration", None)
|
|
386
|
+
check = bool(getattr(args, "check", False))
|
|
387
|
+
|
|
388
|
+
if check and (count is not None or duration is not None):
|
|
389
|
+
raise CliInputError("--check는 --count 또는 --duration과 함께 사용할 수 없습니다.")
|
|
390
|
+
if check:
|
|
391
|
+
return 0, 2.0
|
|
392
|
+
return count if count is not None else 1, duration or DEFAULT_CONDITION_MAX_WAIT_SECONDS
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _add_count_duration_args(
|
|
396
|
+
parser: argparse.ArgumentParser,
|
|
397
|
+
*,
|
|
398
|
+
duration_type: Any = None,
|
|
399
|
+
duration_help: str = "REAL 데이터를 기다릴 최대 시간(초)",
|
|
400
|
+
) -> None:
|
|
401
|
+
resolved_duration_type = duration_type or _nonnegative_duration
|
|
402
|
+
parser.add_argument(
|
|
403
|
+
"--count",
|
|
404
|
+
type=_positive_count,
|
|
405
|
+
help="수신 후 종료할 REAL 데이터 수",
|
|
406
|
+
)
|
|
407
|
+
parser.add_argument(
|
|
408
|
+
"--duration",
|
|
409
|
+
type=resolved_duration_type,
|
|
410
|
+
help=duration_help,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _positive_count(value: str) -> int:
|
|
415
|
+
try:
|
|
416
|
+
parsed = int(value)
|
|
417
|
+
except ValueError as exc:
|
|
418
|
+
raise argparse.ArgumentTypeError("--count 값은 정수여야 합니다.") from exc
|
|
419
|
+
if parsed <= 0:
|
|
420
|
+
raise argparse.ArgumentTypeError("--count 값은 0보다 커야 합니다.")
|
|
421
|
+
return parsed
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _nonnegative_duration(value: str) -> float:
|
|
425
|
+
try:
|
|
426
|
+
parsed = float(value)
|
|
427
|
+
except ValueError as exc:
|
|
428
|
+
raise argparse.ArgumentTypeError("--duration 값은 숫자여야 합니다.") from exc
|
|
429
|
+
if parsed < 0:
|
|
430
|
+
raise argparse.ArgumentTypeError("--duration 값은 0 이상이어야 합니다.")
|
|
431
|
+
return parsed
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _positive_duration(value: str) -> float:
|
|
435
|
+
try:
|
|
436
|
+
parsed = float(value)
|
|
437
|
+
except ValueError as exc:
|
|
438
|
+
raise argparse.ArgumentTypeError("--duration 값은 숫자여야 합니다.") from exc
|
|
439
|
+
if parsed <= 0:
|
|
440
|
+
raise argparse.ArgumentTypeError("--duration 값은 0보다 커야 합니다.")
|
|
441
|
+
return parsed
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _is_subscription_command(command: str) -> bool:
|
|
445
|
+
return command not in {"conditions-list", "conditions-search", "conditions-unsubscribe"}
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _normalize_max_messages(value: int | str) -> int:
|
|
449
|
+
try:
|
|
450
|
+
parsed = int(value)
|
|
451
|
+
except (TypeError, ValueError) as exc:
|
|
452
|
+
raise CliInputError("stream 메시지 수는 정수여야 합니다.") from exc
|
|
453
|
+
if parsed < 0:
|
|
454
|
+
raise CliInputError("stream 메시지 수는 0 이상이어야 합니다.")
|
|
455
|
+
return parsed
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _use_line_mode(
|
|
459
|
+
output_format: str,
|
|
460
|
+
*,
|
|
461
|
+
max_messages: int,
|
|
462
|
+
max_wait_seconds: float | int | str,
|
|
463
|
+
) -> bool:
|
|
464
|
+
if output_format == "jsonl":
|
|
465
|
+
return True
|
|
466
|
+
try:
|
|
467
|
+
wait_limit = float(max_wait_seconds)
|
|
468
|
+
except (TypeError, ValueError):
|
|
469
|
+
wait_limit = DEFAULT_STREAM_MAX_WAIT_SECONDS
|
|
470
|
+
return max_messages == 0 and wait_limit == 0
|