kiwoom-cli 2.2.0__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.0 → kiwoom_cli-2.3.0}/PKG-INFO +51 -3
  2. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/README.md +50 -2
  3. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/__init__.py +1 -1
  4. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/client.py +16 -0
  5. kiwoom_cli-2.3.0/kiwoom_cli/envelope.py +132 -0
  6. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/formatters.py +3 -3
  7. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/main.py +113 -34
  8. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli.egg-info/PKG-INFO +51 -3
  9. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli.egg-info/SOURCES.txt +2 -0
  10. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/tests/test_auth_cli.py +60 -4
  11. {kiwoom_cli-2.2.0 → 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.0 → kiwoom_cli-2.3.0}/tests/test_formatters.py +8 -8
  14. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/tests/test_json_purity.py +5 -3
  15. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/tests/test_us_account.py +6 -6
  16. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/LICENSE +0 -0
  17. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/api_spec.py +0 -0
  18. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/auth.py +0 -0
  19. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/__init__.py +0 -0
  20. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/_constants.py +0 -0
  21. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/account.py +0 -0
  22. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/dashboard.py +0 -0
  23. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/market.py +0 -0
  24. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/order.py +0 -0
  25. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/stock.py +0 -0
  26. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/stream.py +0 -0
  27. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/us/__init__.py +0 -0
  28. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/us/_constants.py +0 -0
  29. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/us/account_ops.py +0 -0
  30. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/us/detect.py +0 -0
  31. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/us/exchange.py +0 -0
  32. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/us/order_ops.py +0 -0
  33. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/us/stock_ops.py +0 -0
  34. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/commands/watch.py +0 -0
  35. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/config.py +0 -0
  36. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/output.py +0 -0
  37. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli/streaming.py +0 -0
  38. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli.egg-info/dependency_links.txt +0 -0
  39. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli.egg-info/entry_points.txt +0 -0
  40. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli.egg-info/requires.txt +0 -0
  41. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/kiwoom_cli.egg-info/top_level.txt +0 -0
  42. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/pyproject.toml +0 -0
  43. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/setup.cfg +0 -0
  44. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/tests/test_account.py +0 -0
  45. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/tests/test_auth.py +0 -0
  46. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/tests/test_client.py +0 -0
  47. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/tests/test_credentials.py +0 -0
  48. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/tests/test_formatters_property.py +0 -0
  49. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/tests/test_market.py +0 -0
  50. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/tests/test_migration.py +0 -0
  51. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/tests/test_order.py +0 -0
  52. {kiwoom_cli-2.2.0 → kiwoom_cli-2.3.0}/tests/test_stock.py +0 -0
  53. {kiwoom_cli-2.2.0 → 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.0
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.0"
3
+ __version__ = "2.3.0"
@@ -24,6 +24,10 @@ class KiwoomAPIError(Exception):
24
24
  super().__init__(f"[{code}] {msg}")
25
25
 
26
26
 
27
+ class KiwoomAuthError(Exception):
28
+ """No access token available — the caller must authenticate first."""
29
+
30
+
27
31
  class KiwoomClient:
28
32
  """Synchronous client for the Kiwoom REST API."""
29
33
 
@@ -80,6 +84,8 @@ class KiwoomClient:
80
84
  next_key: str = "",
81
85
  ) -> tuple[dict[str, Any], dict[str, str]]:
82
86
  """Make a single API request. Returns (body_json, response_headers)."""
87
+ if not self.token:
88
+ raise KiwoomAuthError()
83
89
  url_path = get_url(api_id)
84
90
  headers = self._headers(api_id, cont_yn, next_key)
85
91
  if self._should_spin():
@@ -98,6 +104,16 @@ class KiwoomClient:
98
104
  "cont-yn": resp.headers.get("cont-yn", ""),
99
105
  "next-key": resp.headers.get("next-key", ""),
100
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
+
101
117
  return data, resp_headers
102
118
 
103
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:
@@ -7,9 +7,10 @@ import os
7
7
 
8
8
  import click
9
9
  import httpx
10
+ import keyring
10
11
 
11
- from . import __version__, auth, config
12
- from .client import KiwoomClient, KiwoomAPIError
12
+ from . import __version__, auth, config, envelope
13
+ from .client import KiwoomClient, KiwoomAPIError, KiwoomAuthError
13
14
  from .commands.account import account
14
15
  from .commands.dashboard import dashboard
15
16
  from .commands.market import market
@@ -35,26 +36,68 @@ click.exceptions.UsageError.exit_code = EXIT_INPUT
35
36
  class KiwoomGroup(click.Group):
36
37
  """Custom group that catches API/network errors globally."""
37
38
 
39
+ @staticmethod
40
+ def _json_mode(ctx) -> bool:
41
+ return (ctx.obj.get("format", "table") if ctx.obj else "table") == "json"
42
+
38
43
  def invoke(self, ctx):
39
44
  try:
40
45
  return super().invoke(ctx)
41
46
  except KiwoomAPIError as e:
42
- fmt = ctx.obj.get("format", "table") if ctx.obj else "table"
43
- if fmt == "json":
44
- 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))
45
49
  else:
46
50
  console.print(f"[red]API 오류:[/] {e}")
47
51
  ctx.exit(EXIT_API)
52
+ except KiwoomAuthError:
53
+ if self._json_mode(ctx):
54
+ envelope.emit(error=envelope.error_body(
55
+ "토큰이 없습니다. 'kiwoom auth login'으로 발급하세요.",
56
+ code="AUTH_REQUIRED", retryable=False,
57
+ ))
58
+ else:
59
+ console.print("[red]인증 필요:[/] 토큰이 없습니다. [dim]kiwoom auth login[/]")
60
+ ctx.exit(EXIT_AUTH)
61
+ except keyring.errors.KeyringError:
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
+ )
73
+ ctx.exit(EXIT_INPUT)
48
74
  except httpx.HTTPStatusError as e:
49
- 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:
50
79
  console.print("[red]인증 오류:[/] 토큰이 만료되었습니다. [dim]kiwoom auth login[/]")
51
- ctx.exit(EXIT_AUTH)
52
80
  else:
53
- console.print(f"[red]HTTP 오류:[/] {e.response.status_code}")
54
- ctx.exit(EXIT_API)
81
+ console.print(f"[red]HTTP 오류:[/] {status}")
82
+ ctx.exit(EXIT_AUTH if status == 401 else EXIT_API)
55
83
  except httpx.ConnectError:
56
- 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 서버에 연결할 수 없습니다. 도메인을 확인하세요.")
57
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
58
101
 
59
102
 
60
103
  @click.group(cls=KiwoomGroup)
@@ -228,6 +271,17 @@ def config_profiles():
228
271
  cfg = config.load_config()
229
272
  profiles = cfg.get("profiles", {})
230
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
231
285
  if not profiles:
232
286
  human("[yellow]등록된 프로필이 없습니다.[/]")
233
287
  return
@@ -248,27 +302,43 @@ def auth_cmd():
248
302
  pass
249
303
 
250
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
+
251
314
  @auth_cmd.command("login")
252
315
  @click.pass_context
253
316
  def auth_login(ctx):
254
317
  """접근토큰 발급."""
255
318
  profile = config.resolve_profile(ctx.obj.get("profile") if ctx.obj else None)
256
319
  if not config.is_configured(profile):
257
- human("[red]설정이 필요합니다. 먼저 실행: kiwoom config setup[/]")
258
- raise SystemExit(1)
320
+ _fail_not_configured()
321
+ # 발급 실패(KiwoomAPIError)는 전역 핸들러가 envelope/exit 2로 처리
259
322
  with KiwoomClient() as c:
260
- try:
261
- token = c.issue_token()
262
- human("[green]토큰 발급 완료![/]")
263
- if config.get_token_storage(profile) == "env":
264
- human(" 저장 위치: [bold]없음 (env 모드)[/] — 아래를 셸에서 실행하세요:")
265
- human(f" export KIWOOM_TOKEN='{token}'")
266
- else:
267
- masked = token[:10] + "..." + token[-4:] if len(token) > 14 else token
268
- human(f" 토큰: {masked}")
269
- human(" 저장 위치: [bold]키체인[/]")
270
- except KiwoomAPIError as e:
271
- 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]키체인[/]")
272
342
 
273
343
 
274
344
  @auth_cmd.command("logout")
@@ -277,14 +347,14 @@ def auth_logout(ctx):
277
347
  """접근토큰 폐기."""
278
348
  profile = config.resolve_profile(ctx.obj.get("profile") if ctx.obj else None)
279
349
  if not config.is_configured(profile):
280
- human("[red]설정이 필요합니다. 먼저 실행: kiwoom config setup[/]")
281
- raise SystemExit(1)
350
+ _fail_not_configured()
351
+ # 폐기 실패(KiwoomAPIError)는 전역 핸들러가 envelope/exit 2로 처리
282
352
  with KiwoomClient() as c:
283
- try:
284
- c.revoke_token()
285
- human("[green]토큰 폐기 완료.[/]")
286
- except KiwoomAPIError as e:
287
- 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]토큰 폐기 완료.[/]")
288
358
  if os.environ.get("KIWOOM_TOKEN"):
289
359
  human("[yellow]KIWOOM_TOKEN 환경변수가 설정되어 있어 이 셸에서는 해당 토큰이 계속 사용됩니다. unset KIWOOM_TOKEN 으로 제거하세요.[/]")
290
360
 
@@ -330,7 +400,8 @@ def auth_status(ctx):
330
400
  @click.argument("api_id")
331
401
  @click.argument("body", default="{}")
332
402
  @click.option("--raw", is_flag=True, help="JSON 원본 출력")
333
- 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):
334
405
  """Raw API 호출. (예: kiwoom api ka10001 '{"stk_cd":"005930"}')"""
335
406
  try:
336
407
  body_dict = json.loads(body)
@@ -338,9 +409,17 @@ def raw_api(api_id: str, body: str, raw: bool):
338
409
  raise click.ClickException(f"Invalid JSON body: {e}")
339
410
 
340
411
  with KiwoomClient() as c:
341
- 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
+ )
342
417
  if raw:
343
- 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))
344
423
  else:
345
424
  from .api_spec import get_description
346
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.0
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