kiwoom-cli 2.2.1__tar.gz → 2.3.0__tar.gz

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 (53) hide show
  1. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/PKG-INFO +51 -3
  2. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/README.md +50 -2
  3. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/__init__.py +1 -1
  4. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/client.py +10 -0
  5. kiwoom_cli-2.3.0/kiwoom_cli/envelope.py +132 -0
  6. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/formatters.py +3 -3
  7. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/main.py +105 -42
  8. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli.egg-info/PKG-INFO +51 -3
  9. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli.egg-info/SOURCES.txt +2 -0
  10. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/tests/test_auth_cli.py +4 -4
  11. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/tests/test_cli.py +2 -1
  12. kiwoom_cli-2.3.0/tests/test_envelope.py +375 -0
  13. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/tests/test_formatters.py +8 -8
  14. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/tests/test_json_purity.py +5 -3
  15. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/tests/test_us_account.py +6 -6
  16. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/LICENSE +0 -0
  17. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/api_spec.py +0 -0
  18. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/auth.py +0 -0
  19. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/__init__.py +0 -0
  20. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/_constants.py +0 -0
  21. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/account.py +0 -0
  22. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/dashboard.py +0 -0
  23. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/market.py +0 -0
  24. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/order.py +0 -0
  25. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/stock.py +0 -0
  26. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/stream.py +0 -0
  27. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/us/__init__.py +0 -0
  28. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/us/_constants.py +0 -0
  29. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/us/account_ops.py +0 -0
  30. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/us/detect.py +0 -0
  31. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/us/exchange.py +0 -0
  32. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/us/order_ops.py +0 -0
  33. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/us/stock_ops.py +0 -0
  34. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/watch.py +0 -0
  35. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/config.py +0 -0
  36. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/output.py +0 -0
  37. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli/streaming.py +0 -0
  38. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli.egg-info/dependency_links.txt +0 -0
  39. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli.egg-info/entry_points.txt +0 -0
  40. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli.egg-info/requires.txt +0 -0
  41. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/kiwoom_cli.egg-info/top_level.txt +0 -0
  42. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/pyproject.toml +0 -0
  43. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/setup.cfg +0 -0
  44. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/tests/test_account.py +0 -0
  45. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/tests/test_auth.py +0 -0
  46. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/tests/test_client.py +0 -0
  47. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/tests/test_credentials.py +0 -0
  48. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/tests/test_formatters_property.py +0 -0
  49. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/tests/test_market.py +0 -0
  50. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/tests/test_migration.py +0 -0
  51. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/tests/test_order.py +0 -0
  52. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/tests/test_stock.py +0 -0
  53. {kiwoom_cli-2.2.1 → kiwoom_cli-2.3.0}/tests/test_us.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwoom-cli
3
- Version: 2.2.1
3
+ Version: 2.3.0
4
4
  Summary: User-friendly CLI for the Kiwoom Securities REST API (키움증권)
5
5
  License: LicenseRef-kiwoom-cli-Source-Available-1.0
6
6
  Project-URL: Homepage, https://github.com/gejyn14/kiwoom-cli
@@ -512,13 +512,61 @@ kiwoom --no-color stock info 005930 # 색상 없이 (파일 저장용)
512
512
  ```
513
513
 
514
514
  ```bash
515
- # jq로 필터링
516
- kiwoom -f json market rank volume | jq '.[].stk_nm'
515
+ # jq로 필터링 (본문은 .data 아래)
516
+ kiwoom -f json market rank volume | jq '.data[].stk_nm'
517
517
 
518
518
  # CSV를 파일로 저장
519
519
  kiwoom -f csv stock daily 005930 > samsung_daily.csv
520
520
  ```
521
521
 
522
+ ### JSON 응답 envelope (v1)
523
+
524
+ `-f json`의 모든 응답은 성공/실패 모두 하나의 안정적인 envelope로 감쌉니다. table/csv 모드는 그대로입니다.
525
+
526
+ ```json
527
+ {
528
+ "ok": true,
529
+ "schema": "v1",
530
+ "data": { "stk_nm": "삼성전자", "cur_prc": "+70000" },
531
+ "meta": { "profile": "default", "env": "prod", "cont": { "next_key": "..." } },
532
+ "error": null
533
+ }
534
+ ```
535
+
536
+ - `data` — 기존 응답 본문 그대로 (`return_code`/`return_msg`만 제거)
537
+ - `meta.profile` / `meta.env` — 해석된 프로필과 도메인(`prod`/`mock`)
538
+ - `meta.cont` — 연속조회 커서. 값이 있으면 다음 페이지가 존재: `kiwoom api <api_id> <body> --next-key <meta.cont.next_key>`
539
+ - 실패 시 `ok: false`, `data: null`이고 `error`에 안정적인 코드가 담깁니다:
540
+
541
+ ```json
542
+ { "code": "TOKEN_EXPIRED", "retryable": false, "message": "Token이 유효하지 않습니다", "upstream_code": 8005 }
543
+ ```
544
+
545
+ `error.code`는 키움 오류코드/HTTP 상태를 분류한 stable enum입니다 — 에이전트는 메시지 문자열 대신 이 코드로 분기하세요:
546
+
547
+ | code | retryable | 의미 (upstream) |
548
+ | --- | :---: | --- |
549
+ | `INVALID_INPUT` | X | 입력 값/필수 파라미터 오류 (2, 1511, 1512, 1517) |
550
+ | `INVALID_API` | X | 잘못된 API ID (1501, 1504, 1505) |
551
+ | `NOT_FOUND` | X | 시장/종목 정보 없음 (1901, 1902) |
552
+ | `AUTH_REQUIRED` | X | 인증 헤더/토큰 누락 (1513~1516, 토큰 미보유 시 로컬 감지) |
553
+ | `TOKEN_EXPIRED` | X | 토큰 무효·만료 (8005, HTTP 401) |
554
+ | `TOKEN_ISSUE_FAILED` | X | 토큰 발급 실패 (8003, 8006, 8009, 8011, 8012) |
555
+ | `TOKEN_REVOKE_FAILED` | X | 토큰 폐기 실패 (8015, 8016) |
556
+ | `INVALID_CREDENTIALS` | X | appkey/secretkey 검증 실패 (8001, 8002, 8020) |
557
+ | `IP_MISMATCH` | X | 발급 IP와 요청 IP 불일치 (8010) |
558
+ | `ENV_MISMATCH` | X | 실전/모의 구분 불일치 (8030, 8031) |
559
+ | `DEVICE_AUTH_FAILED` | X | 단말기 인증 실패 (8040, 8050, 8103) |
560
+ | `RATE_LIMITED` | 1700·429는 O | 요청 개수 초과 (1700, HTTP 429) / 재귀 호출 제한 (1687) |
561
+ | `NETWORK_ERROR` | O | API 서버 연결 실패 |
562
+ | `KEYCHAIN_UNAVAILABLE` | X | OS 키체인 접근 불가 (샌드박스/CI) |
563
+ | `NOT_CONFIGURED` | X | `config setup` 미실행 (CLI 로컬 감지) |
564
+ | `UPSTREAM_ERROR` | 5xx·1999는 O | 미분류 업스트림 오류 (기본값) |
565
+
566
+ CLI 수준의 인자/옵션 오류(잘못된 값, 인자 누락 등)도 json 모드에서는 `INVALID_INPUT` envelope로 출력됩니다 (`upstream_code: null`, exit 1). `kiwoom api --raw`는 json 모드에서도 envelope로 감싸되 `data`에 응답 원본을 그대로(`return_code` 포함) 담습니다. `auth login`은 json 모드에서 `{profile, token_storage, saved, token}`을 반환하며, `token` 원문은 env 모드에서만 포함됩니다.
567
+
568
+ exit code 계약은 그대로입니다 (0=성공, 1=입력오류, 2=API오류, 3=인증필요).
569
+
522
570
  ## Shell 자동완성
523
571
 
524
572
  ```bash
@@ -475,13 +475,61 @@ kiwoom --no-color stock info 005930 # 색상 없이 (파일 저장용)
475
475
  ```
476
476
 
477
477
  ```bash
478
- # jq로 필터링
479
- kiwoom -f json market rank volume | jq '.[].stk_nm'
478
+ # jq로 필터링 (본문은 .data 아래)
479
+ kiwoom -f json market rank volume | jq '.data[].stk_nm'
480
480
 
481
481
  # CSV를 파일로 저장
482
482
  kiwoom -f csv stock daily 005930 > samsung_daily.csv
483
483
  ```
484
484
 
485
+ ### JSON 응답 envelope (v1)
486
+
487
+ `-f json`의 모든 응답은 성공/실패 모두 하나의 안정적인 envelope로 감쌉니다. table/csv 모드는 그대로입니다.
488
+
489
+ ```json
490
+ {
491
+ "ok": true,
492
+ "schema": "v1",
493
+ "data": { "stk_nm": "삼성전자", "cur_prc": "+70000" },
494
+ "meta": { "profile": "default", "env": "prod", "cont": { "next_key": "..." } },
495
+ "error": null
496
+ }
497
+ ```
498
+
499
+ - `data` — 기존 응답 본문 그대로 (`return_code`/`return_msg`만 제거)
500
+ - `meta.profile` / `meta.env` — 해석된 프로필과 도메인(`prod`/`mock`)
501
+ - `meta.cont` — 연속조회 커서. 값이 있으면 다음 페이지가 존재: `kiwoom api <api_id> <body> --next-key <meta.cont.next_key>`
502
+ - 실패 시 `ok: false`, `data: null`이고 `error`에 안정적인 코드가 담깁니다:
503
+
504
+ ```json
505
+ { "code": "TOKEN_EXPIRED", "retryable": false, "message": "Token이 유효하지 않습니다", "upstream_code": 8005 }
506
+ ```
507
+
508
+ `error.code`는 키움 오류코드/HTTP 상태를 분류한 stable enum입니다 — 에이전트는 메시지 문자열 대신 이 코드로 분기하세요:
509
+
510
+ | code | retryable | 의미 (upstream) |
511
+ | --- | :---: | --- |
512
+ | `INVALID_INPUT` | X | 입력 값/필수 파라미터 오류 (2, 1511, 1512, 1517) |
513
+ | `INVALID_API` | X | 잘못된 API ID (1501, 1504, 1505) |
514
+ | `NOT_FOUND` | X | 시장/종목 정보 없음 (1901, 1902) |
515
+ | `AUTH_REQUIRED` | X | 인증 헤더/토큰 누락 (1513~1516, 토큰 미보유 시 로컬 감지) |
516
+ | `TOKEN_EXPIRED` | X | 토큰 무효·만료 (8005, HTTP 401) |
517
+ | `TOKEN_ISSUE_FAILED` | X | 토큰 발급 실패 (8003, 8006, 8009, 8011, 8012) |
518
+ | `TOKEN_REVOKE_FAILED` | X | 토큰 폐기 실패 (8015, 8016) |
519
+ | `INVALID_CREDENTIALS` | X | appkey/secretkey 검증 실패 (8001, 8002, 8020) |
520
+ | `IP_MISMATCH` | X | 발급 IP와 요청 IP 불일치 (8010) |
521
+ | `ENV_MISMATCH` | X | 실전/모의 구분 불일치 (8030, 8031) |
522
+ | `DEVICE_AUTH_FAILED` | X | 단말기 인증 실패 (8040, 8050, 8103) |
523
+ | `RATE_LIMITED` | 1700·429는 O | 요청 개수 초과 (1700, HTTP 429) / 재귀 호출 제한 (1687) |
524
+ | `NETWORK_ERROR` | O | API 서버 연결 실패 |
525
+ | `KEYCHAIN_UNAVAILABLE` | X | OS 키체인 접근 불가 (샌드박스/CI) |
526
+ | `NOT_CONFIGURED` | X | `config setup` 미실행 (CLI 로컬 감지) |
527
+ | `UPSTREAM_ERROR` | 5xx·1999는 O | 미분류 업스트림 오류 (기본값) |
528
+
529
+ CLI 수준의 인자/옵션 오류(잘못된 값, 인자 누락 등)도 json 모드에서는 `INVALID_INPUT` envelope로 출력됩니다 (`upstream_code: null`, exit 1). `kiwoom api --raw`는 json 모드에서도 envelope로 감싸되 `data`에 응답 원본을 그대로(`return_code` 포함) 담습니다. `auth login`은 json 모드에서 `{profile, token_storage, saved, token}`을 반환하며, `token` 원문은 env 모드에서만 포함됩니다.
530
+
531
+ exit code 계약은 그대로입니다 (0=성공, 1=입력오류, 2=API오류, 3=인증필요).
532
+
485
533
  ## Shell 자동완성
486
534
 
487
535
  ```bash
@@ -1,3 +1,3 @@
1
1
  """Kiwoom Securities REST API CLI."""
2
2
 
3
- __version__ = "2.2.1"
3
+ __version__ = "2.3.0"
@@ -104,6 +104,16 @@ class KiwoomClient:
104
104
  "cont-yn": resp.headers.get("cont-yn", ""),
105
105
  "next-key": resp.headers.get("next-key", ""),
106
106
  }
107
+
108
+ # 연속조회 커서를 Click 컨텍스트에 보관 → json envelope의 meta.cont로 노출
109
+ # (라이브러리로 쓰일 때는 컨텍스트가 없으므로 조용히 건너뜀)
110
+ ctx = click.get_current_context(silent=True)
111
+ if ctx is not None and isinstance(ctx.obj, dict):
112
+ if resp_headers["cont-yn"] == "Y" and resp_headers["next-key"]:
113
+ ctx.obj["last_cont"] = {"next_key": resp_headers["next-key"]}
114
+ else:
115
+ ctx.obj["last_cont"] = None
116
+
107
117
  return data, resp_headers
108
118
 
109
119
  def request_all(
@@ -0,0 +1,132 @@
1
+ """Stable JSON response envelope (v1) for `-f json` output.
2
+
3
+ 성공/실패 모두 하나의 envelope로 감쌉니다:
4
+ {"ok": bool, "schema": "v1", "data": ..., "meta": {...}, "error": ...}
5
+
6
+ 에러는 안정적인 enum 코드로 분류되어 자동화/에이전트가 upstream 메시지를
7
+ 파싱하지 않고도 분기할 수 있습니다. csv/table 모드에는 적용되지 않습니다.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from typing import Any
15
+
16
+ import click
17
+
18
+ from . import config
19
+
20
+ SCHEMA = "v1"
21
+
22
+ # 키움 REST API 공식 오류코드 (docs/키움 REST API 문서.xlsx '오류코드' 시트)
23
+ # → (stable_code, retryable). 미등록 코드는 ("UPSTREAM_ERROR", False).
24
+ CODE_MAP: dict[int, tuple[str, bool]] = {
25
+ # API 라우팅
26
+ 1501: ("INVALID_API", False), # API ID Null/없음
27
+ 1504: ("INVALID_API", False), # URI가 지원하지 않는 API ID
28
+ 1505: ("INVALID_API", False), # 존재하지 않는 API ID
29
+ # 입력 검증
30
+ 2: ("INVALID_INPUT", False), # 입력 값 오류 (실측)
31
+ 1511: ("INVALID_INPUT", False), # 필수 입력 누락
32
+ 1512: ("INVALID_INPUT", False), # Http header 값 누락/불가독
33
+ 1517: ("INVALID_INPUT", False), # 입력 값 형식 오류
34
+ # 인증 헤더/토큰
35
+ 1513: ("AUTH_REQUIRED", False), # authorization 필드 없음
36
+ 1514: ("AUTH_REQUIRED", False), # authorization 형식 오류
37
+ 1515: ("AUTH_REQUIRED", False), # Grant Type 형식 오류
38
+ 1516: ("AUTH_REQUIRED", False), # Token 미정의
39
+ 8005: ("TOKEN_EXPIRED", False), # Token 유효하지 않음
40
+ # 호출 제한
41
+ 1687: ("RATE_LIMITED", False), # 재귀 호출 제한 (재시도 무의미)
42
+ 1700: ("RATE_LIMITED", True), # 허용 요청 개수 초과
43
+ # 종목/시장 조회
44
+ 1901: ("NOT_FOUND", False), # 시장 코드 없음
45
+ 1902: ("NOT_FOUND", False), # 종목 정보 없음
46
+ # 서버 오류
47
+ 1999: ("UPSTREAM_ERROR", True), # 예기치 못한 에러
48
+ # 앱키/토큰 발급·폐기
49
+ 8001: ("INVALID_CREDENTIALS", False), # App/Secret Key 검증 실패
50
+ 8002: ("INVALID_CREDENTIALS", False),
51
+ 8020: ("INVALID_CREDENTIALS", False), # appkey/secretkey 미입력
52
+ 8003: ("TOKEN_ISSUE_FAILED", False), # Access Token 조회 실패
53
+ 8006: ("TOKEN_ISSUE_FAILED", False),
54
+ 8009: ("TOKEN_ISSUE_FAILED", False),
55
+ 8011: ("TOKEN_ISSUE_FAILED", False),
56
+ 8012: ("TOKEN_ISSUE_FAILED", False),
57
+ 8015: ("TOKEN_REVOKE_FAILED", False),
58
+ 8016: ("TOKEN_REVOKE_FAILED", False),
59
+ # 환경/단말 제약
60
+ 8010: ("IP_MISMATCH", False), # 발급 IP와 요청 IP 불일치
61
+ 8030: ("ENV_MISMATCH", False), # 실전/모의 구분 불일치 (Appkey)
62
+ 8031: ("ENV_MISMATCH", False), # 실전/모의 구분 불일치 (Token)
63
+ 8040: ("DEVICE_AUTH_FAILED", False),
64
+ 8050: ("DEVICE_AUTH_FAILED", False),
65
+ 8103: ("DEVICE_AUTH_FAILED", False),
66
+ }
67
+
68
+ HTTP_MAP: dict[int, tuple[str, bool]] = {
69
+ 401: ("TOKEN_EXPIRED", False),
70
+ 429: ("RATE_LIMITED", True),
71
+ }
72
+
73
+ DEFAULT = ("UPSTREAM_ERROR", False)
74
+
75
+
76
+ def classify(upstream_code: int | None = None, http_status: int | None = None) -> tuple[str, bool]:
77
+ """(stable_code, retryable)로 분류. http_status가 있으면 우선."""
78
+ if http_status is not None:
79
+ if http_status in HTTP_MAP:
80
+ return HTTP_MAP[http_status]
81
+ if 500 <= http_status < 600:
82
+ return ("UPSTREAM_ERROR", True)
83
+ return DEFAULT
84
+ if upstream_code is not None and upstream_code in CODE_MAP:
85
+ return CODE_MAP[upstream_code]
86
+ return DEFAULT
87
+
88
+
89
+ def build_meta() -> dict[str, Any]:
90
+ ctx = click.get_current_context(silent=True)
91
+ obj = ctx.obj if ctx is not None and isinstance(ctx.obj, dict) else {}
92
+ profile = config.resolve_profile(obj.get("profile"))
93
+ env = os.environ.get("KIWOOM_DOMAIN")
94
+ if env not in config.DOMAINS:
95
+ cfg = config.load_config()
96
+ env = cfg.get("profiles", {}).get(profile, {}).get("domain", "mock")
97
+ if env not in config.DOMAINS:
98
+ env = "mock"
99
+ return {"profile": profile, "env": env, "cont": obj.get("last_cont") or None}
100
+
101
+
102
+ def emit(data: Any = None, *, error: dict[str, Any] | None = None) -> None:
103
+ """Envelope 전체를 단일 JSON 문서로 stdout에 출력."""
104
+ doc = {
105
+ "ok": error is None,
106
+ "schema": SCHEMA,
107
+ "data": data,
108
+ "meta": build_meta(),
109
+ "error": error,
110
+ }
111
+ click.echo(json.dumps(doc, ensure_ascii=False, indent=2))
112
+
113
+
114
+ def error_body(
115
+ message: str,
116
+ *,
117
+ upstream_code: int | None = None,
118
+ http_status: int | None = None,
119
+ code: str | None = None,
120
+ retryable: bool | None = None,
121
+ ) -> dict[str, Any]:
122
+ """에러 dict 구성. code/retryable을 직접 주지 않으면 classify()로 결정."""
123
+ if code is None or retryable is None:
124
+ c, r = classify(upstream_code=upstream_code, http_status=http_status)
125
+ code = code if code is not None else c
126
+ retryable = retryable if retryable is not None else r
127
+ return {
128
+ "code": code,
129
+ "retryable": retryable,
130
+ "message": message,
131
+ "upstream_code": upstream_code if upstream_code is not None else http_status,
132
+ }
@@ -3,7 +3,6 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import csv
6
- import json
7
6
  import sys
8
7
  from typing import Any
9
8
 
@@ -12,6 +11,7 @@ from rich.panel import Panel
12
11
  from rich.table import Table
13
12
  from rich.text import Text
14
13
 
14
+ from . import envelope
15
15
  from .output import console, err_console
16
16
 
17
17
 
@@ -35,11 +35,11 @@ def human(renderable: Any) -> None:
35
35
 
36
36
 
37
37
  def _output_json(data: Any) -> None:
38
- """Write data as JSON to stdout."""
38
+ """Write data as an enveloped JSON document to stdout."""
39
39
  clean = data
40
40
  if isinstance(data, dict):
41
41
  clean = {k: v for k, v in data.items() if k not in ("return_code", "return_msg")}
42
- click.echo(json.dumps(clean, ensure_ascii=False, indent=2))
42
+ envelope.emit(data=clean)
43
43
 
44
44
 
45
45
  def _output_csv(rows: list[dict], keys: list[str] | None = None) -> None:
@@ -9,7 +9,7 @@ import click
9
9
  import httpx
10
10
  import keyring
11
11
 
12
- from . import __version__, auth, config
12
+ from . import __version__, auth, config, envelope
13
13
  from .client import KiwoomClient, KiwoomAPIError, KiwoomAuthError
14
14
  from .commands.account import account
15
15
  from .commands.dashboard import dashboard
@@ -36,41 +36,68 @@ click.exceptions.UsageError.exit_code = EXIT_INPUT
36
36
  class KiwoomGroup(click.Group):
37
37
  """Custom group that catches API/network errors globally."""
38
38
 
39
+ @staticmethod
40
+ def _json_mode(ctx) -> bool:
41
+ return (ctx.obj.get("format", "table") if ctx.obj else "table") == "json"
42
+
39
43
  def invoke(self, ctx):
40
44
  try:
41
45
  return super().invoke(ctx)
42
46
  except KiwoomAPIError as e:
43
- fmt = ctx.obj.get("format", "table") if ctx.obj else "table"
44
- if fmt == "json":
45
- click.echo(json.dumps({"error": e.msg, "code": e.code}, ensure_ascii=False))
47
+ if self._json_mode(ctx):
48
+ envelope.emit(error=envelope.error_body(e.msg, upstream_code=e.code))
46
49
  else:
47
50
  console.print(f"[red]API 오류:[/] {e}")
48
51
  ctx.exit(EXIT_API)
49
52
  except KiwoomAuthError:
50
- fmt = ctx.obj.get("format", "table") if ctx.obj else "table"
51
- msg = "토큰이 없습니다. 'kiwoom auth login'으로 발급하세요."
52
- if fmt == "json":
53
- click.echo(json.dumps({"error": msg, "code": "auth_required"}, ensure_ascii=False))
53
+ if self._json_mode(ctx):
54
+ envelope.emit(error=envelope.error_body(
55
+ "토큰이 없습니다. 'kiwoom auth login'으로 발급하세요.",
56
+ code="AUTH_REQUIRED", retryable=False,
57
+ ))
54
58
  else:
55
59
  console.print("[red]인증 필요:[/] 토큰이 없습니다. [dim]kiwoom auth login[/]")
56
60
  ctx.exit(EXIT_AUTH)
57
61
  except keyring.errors.KeyringError:
58
- console.print("[red]키체인 오류:[/] OS 키체인에 접근할 수 없습니다 (잠김 또는 비대화형 세션).")
59
- console.print(
60
- "[dim]키체인 없는 환경에서는 본인 터미널에서 토큰을 발급한 "
61
- "KIWOOM_TOKEN 환경변수로 전달하세요. (README '샌드박스 환경' 참고)[/]"
62
- )
62
+ if self._json_mode(ctx):
63
+ envelope.emit(error=envelope.error_body(
64
+ "OS 키체인에 접근할 없습니다. KIWOOM_TOKEN 환경변수를 사용하세요.",
65
+ code="KEYCHAIN_UNAVAILABLE", retryable=False,
66
+ ))
67
+ else:
68
+ console.print("[red]키체인 오류:[/] OS 키체인에 접근할 수 없습니다 (잠김 또는 비대화형 세션).")
69
+ console.print(
70
+ "[dim]키체인 없는 환경에서는 본인 터미널에서 토큰을 발급한 뒤 "
71
+ "KIWOOM_TOKEN 환경변수로 전달하세요. (README '샌드박스 환경' 참고)[/]"
72
+ )
63
73
  ctx.exit(EXIT_INPUT)
64
74
  except httpx.HTTPStatusError as e:
65
- if e.response.status_code == 401:
75
+ status = e.response.status_code
76
+ if self._json_mode(ctx):
77
+ envelope.emit(error=envelope.error_body(f"HTTP {status}", http_status=status))
78
+ elif status == 401:
66
79
  console.print("[red]인증 오류:[/] 토큰이 만료되었습니다. [dim]kiwoom auth login[/]")
67
- ctx.exit(EXIT_AUTH)
68
80
  else:
69
- console.print(f"[red]HTTP 오류:[/] {e.response.status_code}")
70
- ctx.exit(EXIT_API)
81
+ console.print(f"[red]HTTP 오류:[/] {status}")
82
+ ctx.exit(EXIT_AUTH if status == 401 else EXIT_API)
71
83
  except httpx.ConnectError:
72
- console.print("[red]연결 오류:[/] API 서버에 연결할 수 없습니다. 도메인을 확인하세요.")
84
+ if self._json_mode(ctx):
85
+ envelope.emit(error=envelope.error_body(
86
+ "API 서버에 연결할 수 없습니다. 도메인을 확인하세요.",
87
+ code="NETWORK_ERROR", retryable=True,
88
+ ))
89
+ else:
90
+ console.print("[red]연결 오류:[/] API 서버에 연결할 수 없습니다. 도메인을 확인하세요.")
73
91
  ctx.exit(EXIT_API)
92
+ except click.ClickException as e:
93
+ # 인자/옵션 오류(UsageError 포함)도 json 모드에서는 envelope로.
94
+ # table 모드는 Click 기본 출력 그대로 (re-raise).
95
+ if self._json_mode(ctx):
96
+ envelope.emit(error=envelope.error_body(
97
+ e.format_message(), code="INVALID_INPUT", retryable=False,
98
+ ))
99
+ ctx.exit(e.exit_code)
100
+ raise
74
101
 
75
102
 
76
103
  @click.group(cls=KiwoomGroup)
@@ -244,6 +271,17 @@ def config_profiles():
244
271
  cfg = config.load_config()
245
272
  profiles = cfg.get("profiles", {})
246
273
  default = config.get_default_profile()
274
+ if _get_format() == "json":
275
+ envelope.emit(data=[
276
+ {
277
+ "name": name,
278
+ "domain": settings.get("domain", "mock"),
279
+ "account": settings.get("account", ""),
280
+ "default": name == default,
281
+ }
282
+ for name, settings in profiles.items()
283
+ ])
284
+ return
247
285
  if not profiles:
248
286
  human("[yellow]등록된 프로필이 없습니다.[/]")
249
287
  return
@@ -264,27 +302,43 @@ def auth_cmd():
264
302
  pass
265
303
 
266
304
 
305
+ def _fail_not_configured():
306
+ msg = "설정이 필요합니다. 먼저 실행: kiwoom config setup"
307
+ if _get_format() == "json":
308
+ envelope.emit(error=envelope.error_body(msg, code="NOT_CONFIGURED", retryable=False))
309
+ else:
310
+ human(f"[red]{msg}[/]")
311
+ raise SystemExit(EXIT_INPUT)
312
+
313
+
267
314
  @auth_cmd.command("login")
268
315
  @click.pass_context
269
316
  def auth_login(ctx):
270
317
  """접근토큰 발급."""
271
318
  profile = config.resolve_profile(ctx.obj.get("profile") if ctx.obj else None)
272
319
  if not config.is_configured(profile):
273
- human("[red]설정이 필요합니다. 먼저 실행: kiwoom config setup[/]")
274
- raise SystemExit(1)
320
+ _fail_not_configured()
321
+ # 발급 실패(KiwoomAPIError)는 전역 핸들러가 envelope/exit 2로 처리
275
322
  with KiwoomClient() as c:
276
- try:
277
- token = c.issue_token()
278
- human("[green]토큰 발급 완료![/]")
279
- if config.get_token_storage(profile) == "env":
280
- human(" 저장 위치: [bold]없음 (env 모드)[/] — 아래를 셸에서 실행하세요:")
281
- human(f" export KIWOOM_TOKEN='{token}'")
282
- else:
283
- masked = token[:10] + "..." + token[-4:] if len(token) > 14 else token
284
- human(f" 토큰: {masked}")
285
- human(" 저장 위치: [bold]키체인[/]")
286
- except KiwoomAPIError as e:
287
- human(f"[red]토큰 발급 실패:[/] {e}")
323
+ token = c.issue_token()
324
+ storage = config.get_token_storage(profile)
325
+ if _get_format() == "json":
326
+ # env 모드에서는 사용자가 직접 관리해야 하므로 토큰 원문을 포함
327
+ envelope.emit(data={
328
+ "profile": profile,
329
+ "token_storage": storage,
330
+ "saved": storage != "env",
331
+ "token": token if storage == "env" else None,
332
+ })
333
+ return
334
+ human("[green]토큰 발급 완료![/]")
335
+ if storage == "env":
336
+ human(" 저장 위치: [bold]없음 (env 모드)[/] — 아래를 셸에서 실행하세요:")
337
+ human(f" export KIWOOM_TOKEN='{token}'")
338
+ else:
339
+ masked = token[:10] + "..." + token[-4:] if len(token) > 14 else token
340
+ human(f" 토큰: {masked}")
341
+ human(" 저장 위치: [bold]키체인[/]")
288
342
 
289
343
 
290
344
  @auth_cmd.command("logout")
@@ -293,14 +347,14 @@ def auth_logout(ctx):
293
347
  """접근토큰 폐기."""
294
348
  profile = config.resolve_profile(ctx.obj.get("profile") if ctx.obj else None)
295
349
  if not config.is_configured(profile):
296
- human("[red]설정이 필요합니다. 먼저 실행: kiwoom config setup[/]")
297
- raise SystemExit(1)
350
+ _fail_not_configured()
351
+ # 폐기 실패(KiwoomAPIError)는 전역 핸들러가 envelope/exit 2로 처리
298
352
  with KiwoomClient() as c:
299
- try:
300
- c.revoke_token()
301
- human("[green]토큰 폐기 완료.[/]")
302
- except KiwoomAPIError as e:
303
- human(f"[red]토큰 폐기 실패:[/] {e}")
353
+ c.revoke_token()
354
+ if _get_format() == "json":
355
+ envelope.emit(data={"profile": profile, "revoked": True})
356
+ else:
357
+ human("[green]토큰 폐기 완료.[/]")
304
358
  if os.environ.get("KIWOOM_TOKEN"):
305
359
  human("[yellow]KIWOOM_TOKEN 환경변수가 설정되어 있어 이 셸에서는 해당 토큰이 계속 사용됩니다. unset KIWOOM_TOKEN 으로 제거하세요.[/]")
306
360
 
@@ -346,7 +400,8 @@ def auth_status(ctx):
346
400
  @click.argument("api_id")
347
401
  @click.argument("body", default="{}")
348
402
  @click.option("--raw", is_flag=True, help="JSON 원본 출력")
349
- def raw_api(api_id: str, body: str, raw: bool):
403
+ @click.option("--next-key", "next_key", default="", help="연속조회 커서 (이전 응답의 meta.cont.next_key)")
404
+ def raw_api(api_id: str, body: str, raw: bool, next_key: str):
350
405
  """Raw API 호출. (예: kiwoom api ka10001 '{"stk_cd":"005930"}')"""
351
406
  try:
352
407
  body_dict = json.loads(body)
@@ -354,9 +409,17 @@ def raw_api(api_id: str, body: str, raw: bool):
354
409
  raise click.ClickException(f"Invalid JSON body: {e}")
355
410
 
356
411
  with KiwoomClient() as c:
357
- data, headers = c.request(api_id, body_dict)
412
+ data, headers = c.request(
413
+ api_id, body_dict,
414
+ cont_yn="Y" if next_key else "",
415
+ next_key=next_key,
416
+ )
358
417
  if raw:
359
- console.print_json(json.dumps(data, ensure_ascii=False, indent=2))
418
+ if _get_format() == "json":
419
+ # --raw는 필드 제거만 생략: stdout은 여전히 단일 envelope 문서
420
+ envelope.emit(data=data)
421
+ else:
422
+ console.print_json(json.dumps(data, ensure_ascii=False, indent=2))
360
423
  else:
361
424
  from .api_spec import get_description
362
425
  title = get_description(api_id)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwoom-cli
3
- Version: 2.2.1
3
+ Version: 2.3.0
4
4
  Summary: User-friendly CLI for the Kiwoom Securities REST API (키움증권)
5
5
  License: LicenseRef-kiwoom-cli-Source-Available-1.0
6
6
  Project-URL: Homepage, https://github.com/gejyn14/kiwoom-cli
@@ -512,13 +512,61 @@ kiwoom --no-color stock info 005930 # 색상 없이 (파일 저장용)
512
512
  ```
513
513
 
514
514
  ```bash
515
- # jq로 필터링
516
- kiwoom -f json market rank volume | jq '.[].stk_nm'
515
+ # jq로 필터링 (본문은 .data 아래)
516
+ kiwoom -f json market rank volume | jq '.data[].stk_nm'
517
517
 
518
518
  # CSV를 파일로 저장
519
519
  kiwoom -f csv stock daily 005930 > samsung_daily.csv
520
520
  ```
521
521
 
522
+ ### JSON 응답 envelope (v1)
523
+
524
+ `-f json`의 모든 응답은 성공/실패 모두 하나의 안정적인 envelope로 감쌉니다. table/csv 모드는 그대로입니다.
525
+
526
+ ```json
527
+ {
528
+ "ok": true,
529
+ "schema": "v1",
530
+ "data": { "stk_nm": "삼성전자", "cur_prc": "+70000" },
531
+ "meta": { "profile": "default", "env": "prod", "cont": { "next_key": "..." } },
532
+ "error": null
533
+ }
534
+ ```
535
+
536
+ - `data` — 기존 응답 본문 그대로 (`return_code`/`return_msg`만 제거)
537
+ - `meta.profile` / `meta.env` — 해석된 프로필과 도메인(`prod`/`mock`)
538
+ - `meta.cont` — 연속조회 커서. 값이 있으면 다음 페이지가 존재: `kiwoom api <api_id> <body> --next-key <meta.cont.next_key>`
539
+ - 실패 시 `ok: false`, `data: null`이고 `error`에 안정적인 코드가 담깁니다:
540
+
541
+ ```json
542
+ { "code": "TOKEN_EXPIRED", "retryable": false, "message": "Token이 유효하지 않습니다", "upstream_code": 8005 }
543
+ ```
544
+
545
+ `error.code`는 키움 오류코드/HTTP 상태를 분류한 stable enum입니다 — 에이전트는 메시지 문자열 대신 이 코드로 분기하세요:
546
+
547
+ | code | retryable | 의미 (upstream) |
548
+ | --- | :---: | --- |
549
+ | `INVALID_INPUT` | X | 입력 값/필수 파라미터 오류 (2, 1511, 1512, 1517) |
550
+ | `INVALID_API` | X | 잘못된 API ID (1501, 1504, 1505) |
551
+ | `NOT_FOUND` | X | 시장/종목 정보 없음 (1901, 1902) |
552
+ | `AUTH_REQUIRED` | X | 인증 헤더/토큰 누락 (1513~1516, 토큰 미보유 시 로컬 감지) |
553
+ | `TOKEN_EXPIRED` | X | 토큰 무효·만료 (8005, HTTP 401) |
554
+ | `TOKEN_ISSUE_FAILED` | X | 토큰 발급 실패 (8003, 8006, 8009, 8011, 8012) |
555
+ | `TOKEN_REVOKE_FAILED` | X | 토큰 폐기 실패 (8015, 8016) |
556
+ | `INVALID_CREDENTIALS` | X | appkey/secretkey 검증 실패 (8001, 8002, 8020) |
557
+ | `IP_MISMATCH` | X | 발급 IP와 요청 IP 불일치 (8010) |
558
+ | `ENV_MISMATCH` | X | 실전/모의 구분 불일치 (8030, 8031) |
559
+ | `DEVICE_AUTH_FAILED` | X | 단말기 인증 실패 (8040, 8050, 8103) |
560
+ | `RATE_LIMITED` | 1700·429는 O | 요청 개수 초과 (1700, HTTP 429) / 재귀 호출 제한 (1687) |
561
+ | `NETWORK_ERROR` | O | API 서버 연결 실패 |
562
+ | `KEYCHAIN_UNAVAILABLE` | X | OS 키체인 접근 불가 (샌드박스/CI) |
563
+ | `NOT_CONFIGURED` | X | `config setup` 미실행 (CLI 로컬 감지) |
564
+ | `UPSTREAM_ERROR` | 5xx·1999는 O | 미분류 업스트림 오류 (기본값) |
565
+
566
+ CLI 수준의 인자/옵션 오류(잘못된 값, 인자 누락 등)도 json 모드에서는 `INVALID_INPUT` envelope로 출력됩니다 (`upstream_code: null`, exit 1). `kiwoom api --raw`는 json 모드에서도 envelope로 감싸되 `data`에 응답 원본을 그대로(`return_code` 포함) 담습니다. `auth login`은 json 모드에서 `{profile, token_storage, saved, token}`을 반환하며, `token` 원문은 env 모드에서만 포함됩니다.
567
+
568
+ exit code 계약은 그대로입니다 (0=성공, 1=입력오류, 2=API오류, 3=인증필요).
569
+
522
570
  ## Shell 자동완성
523
571
 
524
572
  ```bash
@@ -6,6 +6,7 @@ kiwoom_cli/api_spec.py
6
6
  kiwoom_cli/auth.py
7
7
  kiwoom_cli/client.py
8
8
  kiwoom_cli/config.py
9
+ kiwoom_cli/envelope.py
9
10
  kiwoom_cli/formatters.py
10
11
  kiwoom_cli/main.py
11
12
  kiwoom_cli/output.py
@@ -38,6 +39,7 @@ tests/test_auth_cli.py
38
39
  tests/test_cli.py
39
40
  tests/test_client.py
40
41
  tests/test_credentials.py
42
+ tests/test_envelope.py
41
43
  tests/test_formatters.py
42
44
  tests/test_formatters_property.py
43
45
  tests/test_json_purity.py
@@ -206,8 +206,8 @@ def test_auth_status_shows_env_storage_mode(mem_keyring):
206
206
  result = runner.invoke(cli, ["-f", "json", "auth", "status"])
207
207
  assert result.exit_code == 0
208
208
  doc = _json.loads(result.stdout)
209
- assert doc["token_storage"] == "env"
210
- assert doc["has_token"] is False
209
+ assert doc["data"]["token_storage"] == "env"
210
+ assert doc["data"]["has_token"] is False
211
211
 
212
212
  result = runner.invoke(cli, ["auth", "status"])
213
213
  assert result.exit_code == 0
@@ -228,8 +228,8 @@ def test_auth_status_reflects_env_token_with_broken_keyring(monkeypatch):
228
228
  result = runner.invoke(cli, ["-f", "json", "auth", "status"])
229
229
  assert result.exit_code == 0
230
230
  doc = _json.loads(result.stdout)
231
- assert doc["has_token"] is True
232
- assert doc["token_source"] == "env"
231
+ assert doc["data"]["has_token"] is True
232
+ assert doc["data"]["token_source"] == "env"
233
233
 
234
234
  result = runner.invoke(cli, ["auth", "status"])
235
235
  assert result.exit_code == 0