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.
Files changed (62) hide show
  1. kiwoom/__init__.py +47 -0
  2. kiwoom/_data/kiwoom_api_spec.json +66372 -0
  3. kiwoom/core/__init__.py +6 -0
  4. kiwoom/core/auth.py +349 -0
  5. kiwoom/core/client.py +244 -0
  6. kiwoom/core/errors.py +177 -0
  7. kiwoom/core/platform_paths.py +68 -0
  8. kiwoom/core/profiles.py +143 -0
  9. kiwoom/core/runtime.py +153 -0
  10. kiwoom/core/secrets.py +217 -0
  11. kiwoom/core/settings.py +64 -0
  12. kiwoom/core/token_store.py +186 -0
  13. kiwoom/core/types.py +28 -0
  14. kiwoom/core/ws_client.py +262 -0
  15. kiwoom/realtime/__init__.py +30 -0
  16. kiwoom/realtime/decoders.py +171 -0
  17. kiwoom/realtime/events.py +98 -0
  18. kiwoom/realtime/packets.py +65 -0
  19. kiwoom/realtime/schemas.py +76 -0
  20. kiwoom/realtime/stream.py +213 -0
  21. kiwoom/specs.py +272 -0
  22. kiwoom_cli/README.md +671 -0
  23. kiwoom_cli/__init__.py +9 -0
  24. kiwoom_cli/__main__.py +5 -0
  25. kiwoom_cli/argument_maps.py +561 -0
  26. kiwoom_cli/arguments.py +147 -0
  27. kiwoom_cli/auth_context.py +64 -0
  28. kiwoom_cli/banner.py +125 -0
  29. kiwoom_cli/commands/__init__.py +1 -0
  30. kiwoom_cli/commands/auth.py +406 -0
  31. kiwoom_cli/commands/groups.py +281 -0
  32. kiwoom_cli/commands/mapped.py +74 -0
  33. kiwoom_cli/commands/orders.py +158 -0
  34. kiwoom_cli/commands/spec.py +98 -0
  35. kiwoom_cli/commands/stocks.py +100 -0
  36. kiwoom_cli/commands/streams.py +470 -0
  37. kiwoom_cli/doctor.py +324 -0
  38. kiwoom_cli/errors.py +24 -0
  39. kiwoom_cli/executor/__init__.py +33 -0
  40. kiwoom_cli/executor/condition.py +374 -0
  41. kiwoom_cli/executor/rest.py +132 -0
  42. kiwoom_cli/executor/waits.py +34 -0
  43. kiwoom_cli/executor/websocket.py +131 -0
  44. kiwoom_cli/main.py +203 -0
  45. kiwoom_cli/maps/README.md +99 -0
  46. kiwoom_cli/maps/api_commands.csv +209 -0
  47. kiwoom_cli/maps/arguments.csv +731 -0
  48. kiwoom_cli/maps/order_confirmation_commands.csv +13 -0
  49. kiwoom_cli/maps/order_confirmation_fields.csv +71 -0
  50. kiwoom_cli/maps/order_price_policies.csv +47 -0
  51. kiwoom_cli/maps/order_value_labels.csv +28 -0
  52. kiwoom_cli/maps/positional_arguments.csv +21 -0
  53. kiwoom_cli/order_confirmation.py +167 -0
  54. kiwoom_cli/output.py +136 -0
  55. kiwoom_cli/registry.py +95 -0
  56. kiwoom_cli/safety.py +30 -0
  57. kiwoom_cli/setup.py +533 -0
  58. kwcli-0.1.0.dist-info/METADATA +215 -0
  59. kwcli-0.1.0.dist-info/RECORD +62 -0
  60. kwcli-0.1.0.dist-info/WHEEL +4 -0
  61. kwcli-0.1.0.dist-info/entry_points.txt +2 -0
  62. 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