jetstream-api 0.21.12__tar.gz → 0.22.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.
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/PKG-INFO +21 -2
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/README.md +20 -1
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/ilink/__init__.py +3 -1
- jetstream_api-0.22.0/ilink/_propv2.py +440 -0
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/ilink/admin.py +212 -96
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/ilink/admintopic.py +3 -5
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/ilink/common.py +6 -6
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/ilink/properties.py +297 -125
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/ilink/qmgr.py +19 -20
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/ilink/topic.py +4 -6
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/jetstream_api.egg-info/PKG-INFO +21 -2
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/jetstream_api.egg-info/SOURCES.txt +1 -0
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/pyproject.toml +1 -1
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/LICENSE.txt +0 -0
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/ilink/_codec.py +0 -0
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/ilink/_consumer.py +0 -0
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/ilink/_groupwire.py +0 -0
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/ilink/_qleader.py +0 -0
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/ilink/_throttle.py +0 -0
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/ilink/connectivity.py +0 -0
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/ilink/exception.py +0 -0
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/ilink/msg.py +0 -0
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/ilink/partitioner.py +0 -0
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/ilink/pattern.py +0 -0
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/ilink/producer.py +0 -0
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/ilink/py.typed +0 -0
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/ilink/util.py +0 -0
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/jetstream_api.egg-info/dependency_links.txt +0 -0
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/jetstream_api.egg-info/requires.txt +0 -0
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/jetstream_api.egg-info/top_level.txt +0 -0
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/setup.cfg +0 -0
- {jetstream_api-0.21.12 → jetstream_api-0.22.0}/tests/test_example.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jetstream-api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.22.0
|
|
4
4
|
Summary: Python client API for iLink M.O.M. (Message Oriented Middleware)
|
|
5
5
|
Author: snowjeans
|
|
6
6
|
License: Boost Software License - Version 1.0 - August 17th, 2003
|
|
@@ -60,6 +60,25 @@ lz4 배치 압축을 쓰려면 `pip install "jetstream-api[lz4]"`, zstd 배치
|
|
|
60
60
|
## 설치 방법
|
|
61
61
|
pip install jetstream-api
|
|
62
62
|
|
|
63
|
+
## 0.22.0 변경 - 7 신규 오브젝트(쿼터 · 디스크 가드 · 토픽 · 클러스터) 관리를 Property v2(JSON)로 (자바 v2.18.0 와 같음, 짝 엔진 7.0.1.3601 이상)
|
|
64
|
+
|
|
65
|
+
- **와이어**: 쿼터 · 디스크 가드 · 토픽 · 클러스터의 생성 · 조회 · 목록 · 변경 · 삭제가 새 요청 코드 3600~3604(본문 = UTF-8 JSON)로 간다.
|
|
66
|
+
★ 이 네 오브젝트는 **엔진 7.0.1.3601 이상에서만** 된다(엔진이 옛 코드를 지웠다). 6.x 엔진에는 원래 없는 기능이라 보내지 않고
|
|
67
|
+
``MIMQE_NOT_SUPPORTED (... requires ILink 7)`` 로 거절한다. 큐 · 채널 · 큐 관리자 · 세션 등 6.x 오브젝트와 데이터 경로는 바뀌지 않았다.
|
|
68
|
+
- **바꾼 값만 보낸다(patch)**: ``setQuotaProperty`` · ``setDiskGuardProperty`` · ``setClusterProperty`` 는 **세터(또는 필드 대입)로 바꾼
|
|
69
|
+
칸만** 보낸다. 조회한 객체를 그대로 넘기면 아무것도 보내지 않는다. 쿼터 클라 항목 · 클러스터 노드 목록은 바뀌면 통째로 간다.
|
|
70
|
+
클러스터는 읽은 정의 판(defTerm, defVersion)을 낙관적 잠금으로 자동으로 싣는다.
|
|
71
|
+
- **새 함수 - 결과를 돌려준다**: ``setQuotaPropertyWithResult`` · ``setDiskGuardPropertyWithResult`` · ``setClusterPropertyWithResult`` ·
|
|
72
|
+
``createTopicWithResult`` · ``setTopicPropertyWithResult`` · ``removeTopicWithResult`` -> :class:`ILPropertyResult`(적용된 키와 반영 시점 ·
|
|
73
|
+
무시된 키와 까닭 · 바꿔 받은 값 · 조작별 결과). 무시된 키가 있으면 기존 함수도 WARNING 로그(``ilink.propv2``)를 남긴다.
|
|
74
|
+
- **동작 변화**: 토픽 옵션 문자열의 모르는 키는 거절되지 않고 **무시 + 경고**가 된다(모르는 키 무시 규칙). 범위 밖 토픽 값(SEGMENTBYTES 등)은
|
|
75
|
+
엔진이 고쳐 받지 않고 **거절**한다. 없는 토픽 조회는 ``MIMQE_OBJECT_NOT_FOUND``. 토픽 목록(``getTopicList`` · ``list_topics``)은 예전과 같은
|
|
76
|
+
``name|earliest|next|epoch|partitions`` 줄로 돌려준다.
|
|
77
|
+
- **없앤 것(옛 와이어 코덱)**: ``ILQuotaProperty(text)`` · ``to_text``, ``ILDiskGuardProperty(csv)`` · ``to_csv``. 클러스터의 옛 블롭 코덱은
|
|
78
|
+
아직 남아 있으나 쓰지 않는다(다음 정리 대상).
|
|
79
|
+
- 문서: 트랜잭션 커밋이 ``MIMQE_FOLLOWER_NODE`` 로 실패하면 적용 안 됨 - 처음부터 다시(엔진 r3598). 클러스터 노드 번호는 1~62.
|
|
80
|
+
엔진 판 표기 ``v7.1.x`` 를 엔진 리비전(rNNNN)으로.
|
|
81
|
+
|
|
63
82
|
## 0.21.12 변경 - 긴 실패 사유를 자르지 않는다 (자바 v2.17.12 와 같음, 짝 엔진 7.0.1.3597 이상)
|
|
64
83
|
|
|
65
84
|
- **실패 사유 전문**: 응답의 사유 칸은 69바이트라 긴 사유가 잘렸다. 엔진 7.0.1.3597 부터는 그보다 긴 사유의 전문(최대 8192 바이트,
|
|
@@ -972,7 +991,7 @@ svc.createCluster(c)
|
|
|
972
991
|
큐에 더해 **pub/sub 토픽**을 지원합니다. 발행된 메시지는 retention 정책이 지울 때까지 보존되어
|
|
973
992
|
모든 구독자에게 각자의 커서로 전달됩니다(팬아웃). 큐와 달리 소비해도 사라지지 않습니다.
|
|
974
993
|
|
|
975
|
-
> 엔진
|
|
994
|
+
> 엔진 r3265 이상이 필요합니다.
|
|
976
995
|
|
|
977
996
|
### 구독
|
|
978
997
|
|
|
@@ -10,6 +10,25 @@ lz4 배치 압축을 쓰려면 `pip install "jetstream-api[lz4]"`, zstd 배치
|
|
|
10
10
|
## 설치 방법
|
|
11
11
|
pip install jetstream-api
|
|
12
12
|
|
|
13
|
+
## 0.22.0 변경 - 7 신규 오브젝트(쿼터 · 디스크 가드 · 토픽 · 클러스터) 관리를 Property v2(JSON)로 (자바 v2.18.0 와 같음, 짝 엔진 7.0.1.3601 이상)
|
|
14
|
+
|
|
15
|
+
- **와이어**: 쿼터 · 디스크 가드 · 토픽 · 클러스터의 생성 · 조회 · 목록 · 변경 · 삭제가 새 요청 코드 3600~3604(본문 = UTF-8 JSON)로 간다.
|
|
16
|
+
★ 이 네 오브젝트는 **엔진 7.0.1.3601 이상에서만** 된다(엔진이 옛 코드를 지웠다). 6.x 엔진에는 원래 없는 기능이라 보내지 않고
|
|
17
|
+
``MIMQE_NOT_SUPPORTED (... requires ILink 7)`` 로 거절한다. 큐 · 채널 · 큐 관리자 · 세션 등 6.x 오브젝트와 데이터 경로는 바뀌지 않았다.
|
|
18
|
+
- **바꾼 값만 보낸다(patch)**: ``setQuotaProperty`` · ``setDiskGuardProperty`` · ``setClusterProperty`` 는 **세터(또는 필드 대입)로 바꾼
|
|
19
|
+
칸만** 보낸다. 조회한 객체를 그대로 넘기면 아무것도 보내지 않는다. 쿼터 클라 항목 · 클러스터 노드 목록은 바뀌면 통째로 간다.
|
|
20
|
+
클러스터는 읽은 정의 판(defTerm, defVersion)을 낙관적 잠금으로 자동으로 싣는다.
|
|
21
|
+
- **새 함수 - 결과를 돌려준다**: ``setQuotaPropertyWithResult`` · ``setDiskGuardPropertyWithResult`` · ``setClusterPropertyWithResult`` ·
|
|
22
|
+
``createTopicWithResult`` · ``setTopicPropertyWithResult`` · ``removeTopicWithResult`` -> :class:`ILPropertyResult`(적용된 키와 반영 시점 ·
|
|
23
|
+
무시된 키와 까닭 · 바꿔 받은 값 · 조작별 결과). 무시된 키가 있으면 기존 함수도 WARNING 로그(``ilink.propv2``)를 남긴다.
|
|
24
|
+
- **동작 변화**: 토픽 옵션 문자열의 모르는 키는 거절되지 않고 **무시 + 경고**가 된다(모르는 키 무시 규칙). 범위 밖 토픽 값(SEGMENTBYTES 등)은
|
|
25
|
+
엔진이 고쳐 받지 않고 **거절**한다. 없는 토픽 조회는 ``MIMQE_OBJECT_NOT_FOUND``. 토픽 목록(``getTopicList`` · ``list_topics``)은 예전과 같은
|
|
26
|
+
``name|earliest|next|epoch|partitions`` 줄로 돌려준다.
|
|
27
|
+
- **없앤 것(옛 와이어 코덱)**: ``ILQuotaProperty(text)`` · ``to_text``, ``ILDiskGuardProperty(csv)`` · ``to_csv``. 클러스터의 옛 블롭 코덱은
|
|
28
|
+
아직 남아 있으나 쓰지 않는다(다음 정리 대상).
|
|
29
|
+
- 문서: 트랜잭션 커밋이 ``MIMQE_FOLLOWER_NODE`` 로 실패하면 적용 안 됨 - 처음부터 다시(엔진 r3598). 클러스터 노드 번호는 1~62.
|
|
30
|
+
엔진 판 표기 ``v7.1.x`` 를 엔진 리비전(rNNNN)으로.
|
|
31
|
+
|
|
13
32
|
## 0.21.12 변경 - 긴 실패 사유를 자르지 않는다 (자바 v2.17.12 와 같음, 짝 엔진 7.0.1.3597 이상)
|
|
14
33
|
|
|
15
34
|
- **실패 사유 전문**: 응답의 사유 칸은 69바이트라 긴 사유가 잘렸다. 엔진 7.0.1.3597 부터는 그보다 긴 사유의 전문(최대 8192 바이트,
|
|
@@ -922,7 +941,7 @@ svc.createCluster(c)
|
|
|
922
941
|
큐에 더해 **pub/sub 토픽**을 지원합니다. 발행된 메시지는 retention 정책이 지울 때까지 보존되어
|
|
923
942
|
모든 구독자에게 각자의 커서로 전달됩니다(팬아웃). 큐와 달리 소비해도 사라지지 않습니다.
|
|
924
943
|
|
|
925
|
-
> 엔진
|
|
944
|
+
> 엔진 r3265 이상이 필요합니다.
|
|
926
945
|
|
|
927
946
|
### 구독
|
|
928
947
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""iLink - Python client API for iLink M.O.M. (Message Oriented Middleware)."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.
|
|
3
|
+
__version__ = "0.22.0"
|
|
4
4
|
|
|
5
5
|
# --- Admin / Service ---
|
|
6
6
|
from .admin import ILAdminService, ILAdminQmgr, ILLogBrowser, ILQueueBrowser
|
|
@@ -58,6 +58,7 @@ from .properties import (
|
|
|
58
58
|
ILQuotaMeter,
|
|
59
59
|
ILQuotaFollower,
|
|
60
60
|
ILQuotaRefusedPeer,
|
|
61
|
+
ILPropertyResult,
|
|
61
62
|
ILThrottleStats,
|
|
62
63
|
)
|
|
63
64
|
|
|
@@ -131,6 +132,7 @@ __all__ = [
|
|
|
131
132
|
"ILQuotaMeter",
|
|
132
133
|
"ILQuotaFollower",
|
|
133
134
|
"ILQuotaRefusedPeer",
|
|
135
|
+
"ILPropertyResult",
|
|
134
136
|
"ILThrottleStats",
|
|
135
137
|
# Exceptions
|
|
136
138
|
"ILException",
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""Property v2 속성 와이어 - JSON 본문 규칙(모듈 내부용, 엔진 PROPV2-1).
|
|
2
|
+
|
|
3
|
+
본문은 UTF-8 JSON 객체 하나다(BOM 없음). 오브젝트 속성은 최상위에 평평하게 싣고, ``_`` 로 시작하는 키는 메타다
|
|
4
|
+
(``_TYPE`` · ``_KEYS`` · ``_ITEMS`` · ``_APPLIED`` · ``_IGNORED`` · ``_NORMALIZED`` · ``_RESULT`` · ``_EXPECT``).
|
|
5
|
+
정본은 엔진 ``DESIGN_PROPERTY_V2.md`` §3 이고, 세 언어가 같은 시험 벡터(``propv2_test/json_rule_vectors.txt``)로 맞춘다.
|
|
6
|
+
|
|
7
|
+
파서는 엄격하다 - 깨진 UTF-8 · 중복 키(중첩 포함) · 깊이 8 초과 · null · 섞인 배열 · 문자열 안 NUL · 뒤 쓰레기 ·
|
|
8
|
+
비표준 확장(주석 · 끝 쉼표 · NaN · 작은따옴표 · 앞자리 0)은 거절한다. 키는 ``[A-Z0-9_]`` 1~63자, 첫 글자 ``[A-Z_]``.
|
|
9
|
+
|
|
10
|
+
값 종류(:func:`take`): ``INT`` = 정수 number(int32), ``LONG`` = 10진 문자열(int64, ``-?[0-9]+``), ``DOUBLE`` = number,
|
|
11
|
+
``BOOL`` = true/false, ``STR`` = string.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
MAX_KEY_LEN = 63
|
|
21
|
+
MAX_DEPTH = 8 # 최상위 객체 = 0, 안쪽 객체 / 배열 한 겹마다 +1
|
|
22
|
+
|
|
23
|
+
_INT_MIN, _INT_MAX = -(1 << 31), (1 << 31) - 1
|
|
24
|
+
_LONG_MIN, _LONG_MAX = -(1 << 63), (1 << 63) - 1
|
|
25
|
+
_LONG_RE = re.compile(r"-?[0-9]+\Z")
|
|
26
|
+
_KEY_RE = re.compile(r"[A-Z_][A-Z0-9_]*\Z")
|
|
27
|
+
_WS = " \t\r\n"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PropV2Error(ValueError):
|
|
31
|
+
"""본문 규칙 위반."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _no_constant(name: str):
|
|
35
|
+
raise PropV2Error("JSON: %s is not allowed" % name)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _pairs(pairs):
|
|
39
|
+
# 중복 키를 그 자리에서 잡는다(경로는 아래 검사가 붙인다) - 순서는 보존한다
|
|
40
|
+
d = {}
|
|
41
|
+
for k, v in pairs:
|
|
42
|
+
if k in d:
|
|
43
|
+
raise PropV2Error("duplicate key %s" % k)
|
|
44
|
+
d[k] = v
|
|
45
|
+
return d
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_valid_key(k: str) -> bool:
|
|
49
|
+
"""키 문자 규칙(1~63자, ``[A-Z0-9_]``, 첫 글자 ``[A-Z_]``)에 맞는가.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
bool: 맞으면 True.
|
|
53
|
+
"""
|
|
54
|
+
return bool(k) and len(k) <= MAX_KEY_LEN and _KEY_RE.match(k) is not None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _kind(v) -> str:
|
|
58
|
+
if isinstance(v, bool):
|
|
59
|
+
return "b"
|
|
60
|
+
if isinstance(v, (int, float)):
|
|
61
|
+
return "n"
|
|
62
|
+
if isinstance(v, str):
|
|
63
|
+
return "s"
|
|
64
|
+
if isinstance(v, dict):
|
|
65
|
+
return "o"
|
|
66
|
+
if isinstance(v, list):
|
|
67
|
+
return "a"
|
|
68
|
+
return "?"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _check(v, path: str, depth: int) -> None:
|
|
72
|
+
if depth > MAX_DEPTH:
|
|
73
|
+
raise PropV2Error("nesting deeper than 8")
|
|
74
|
+
if v is None:
|
|
75
|
+
raise PropV2Error("%s must not be null" % (path or "value"))
|
|
76
|
+
if isinstance(v, float) and (v != v or v in (float("inf"), float("-inf"))):
|
|
77
|
+
raise PropV2Error("JSON: number out of range")
|
|
78
|
+
if isinstance(v, str):
|
|
79
|
+
if "\x00" in v:
|
|
80
|
+
raise PropV2Error("%s contains NUL" % path)
|
|
81
|
+
try:
|
|
82
|
+
v.encode("utf-8")
|
|
83
|
+
except UnicodeEncodeError:
|
|
84
|
+
raise PropV2Error("JSON: lone surrogate in %s" % path) from None
|
|
85
|
+
elif isinstance(v, dict):
|
|
86
|
+
for k, x in v.items():
|
|
87
|
+
if not is_valid_key(k):
|
|
88
|
+
raise PropV2Error("bad key '%s'" % k)
|
|
89
|
+
_check(x, k if not path else path + "." + k, depth + 1)
|
|
90
|
+
elif isinstance(v, list):
|
|
91
|
+
kinds = set()
|
|
92
|
+
for i, x in enumerate(v):
|
|
93
|
+
if x is None:
|
|
94
|
+
raise PropV2Error("%s[%d] must not be null" % (path, i))
|
|
95
|
+
kinds.add(_kind(x))
|
|
96
|
+
_check(x, "%s[%d]" % (path, i), depth + 1)
|
|
97
|
+
if len(kinds) > 1:
|
|
98
|
+
raise PropV2Error("%s mixes element types" % path)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse(data: bytes | bytearray | memoryview) -> dict:
|
|
102
|
+
"""본문 바이트를 객체(dict, 키 순서 보존)로 푼다.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
dict: 최상위 객체.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
PropV2Error: 규칙 위반(부분 결과 없음).
|
|
109
|
+
"""
|
|
110
|
+
b = bytes(data)
|
|
111
|
+
try:
|
|
112
|
+
text = b.decode("utf-8")
|
|
113
|
+
except UnicodeDecodeError:
|
|
114
|
+
raise PropV2Error("JSON: invalid UTF-8") from None
|
|
115
|
+
if not text.strip(_WS):
|
|
116
|
+
raise PropV2Error("JSON: empty body")
|
|
117
|
+
try:
|
|
118
|
+
obj = json.loads(text, object_pairs_hook=_pairs, parse_constant=_no_constant)
|
|
119
|
+
except PropV2Error as e:
|
|
120
|
+
if str(e).startswith("duplicate key"):
|
|
121
|
+
raise PropV2Error(_dup_path(text) or str(e)) from None
|
|
122
|
+
raise
|
|
123
|
+
except RecursionError:
|
|
124
|
+
raise PropV2Error("nesting deeper than 8") from None
|
|
125
|
+
except ValueError as e:
|
|
126
|
+
raise PropV2Error("JSON: %s" % e) from None
|
|
127
|
+
if not isinstance(obj, dict):
|
|
128
|
+
raise PropV2Error("must be an object")
|
|
129
|
+
_check(obj, "", 0)
|
|
130
|
+
return obj
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _dup_path(text: str) -> str:
|
|
134
|
+
# 중복 키의 경로(O.X · L[1].X)를 사유에 싣는다 - 다시 풀며 경로를 따라간다(실패 경로에서만 돈다)
|
|
135
|
+
found = []
|
|
136
|
+
|
|
137
|
+
def walk(pairs_list, path):
|
|
138
|
+
seen = set()
|
|
139
|
+
for k, v in pairs_list:
|
|
140
|
+
p = k if not path else path + "." + k
|
|
141
|
+
if k in seen:
|
|
142
|
+
found.append(p)
|
|
143
|
+
return
|
|
144
|
+
seen.add(k)
|
|
145
|
+
|
|
146
|
+
def hook(pairs):
|
|
147
|
+
return ("__pairs__", pairs)
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
raw = json.loads(text, object_pairs_hook=hook, parse_constant=_no_constant)
|
|
151
|
+
except Exception:
|
|
152
|
+
return ""
|
|
153
|
+
|
|
154
|
+
def visit(node, path):
|
|
155
|
+
if found:
|
|
156
|
+
return
|
|
157
|
+
if isinstance(node, tuple) and node and node[0] == "__pairs__":
|
|
158
|
+
walk(node[1], path)
|
|
159
|
+
for k, v in node[1]:
|
|
160
|
+
visit(v, k if not path else path + "." + k)
|
|
161
|
+
elif isinstance(node, list):
|
|
162
|
+
for i, x in enumerate(node):
|
|
163
|
+
visit(x, "%s[%d]" % (path, i))
|
|
164
|
+
|
|
165
|
+
visit(raw, "")
|
|
166
|
+
return "duplicate key " + found[0] if found else ""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def take(obj: dict, key: str, kind: str):
|
|
170
|
+
"""객체에서 키 하나를 그 종류로 꺼낸다.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
obj (dict): :func:`parse` 결과(또는 그 안쪽 객체).
|
|
174
|
+
key (str): 키.
|
|
175
|
+
kind (str): 값 종류. ``"INT"`` · ``"LONG"`` · ``"DOUBLE"`` · ``"BOOL"`` · ``"STR"`` 중 하나.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
int | float | bool | str | None: 값. 키가 없으면 None.
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
PropV2Error: 값이 그 종류가 아닐 때(``<KEY> must be LONG (decimal string)`` 등).
|
|
182
|
+
"""
|
|
183
|
+
if key not in obj:
|
|
184
|
+
return None
|
|
185
|
+
v = obj[key]
|
|
186
|
+
if kind == "INT":
|
|
187
|
+
if type(v) is int and _INT_MIN <= v <= _INT_MAX:
|
|
188
|
+
return v
|
|
189
|
+
elif kind == "LONG":
|
|
190
|
+
if type(v) is str and _LONG_RE.match(v):
|
|
191
|
+
n = int(v)
|
|
192
|
+
if _LONG_MIN <= n <= _LONG_MAX:
|
|
193
|
+
return n
|
|
194
|
+
raise PropV2Error("%s must be LONG (decimal string)" % key)
|
|
195
|
+
elif kind == "DOUBLE":
|
|
196
|
+
if type(v) in (int, float):
|
|
197
|
+
return float(v)
|
|
198
|
+
elif kind == "BOOL":
|
|
199
|
+
if type(v) is bool:
|
|
200
|
+
return v
|
|
201
|
+
elif kind == "STR":
|
|
202
|
+
if type(v) is str:
|
|
203
|
+
return v
|
|
204
|
+
else:
|
|
205
|
+
raise ValueError("unknown kind %r" % kind)
|
|
206
|
+
raise PropV2Error("%s must be %s" % (key, kind))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def long_str(n: int) -> str:
|
|
210
|
+
"""LONG 값을 와이어 표기(10진 문자열)로.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
str: 10진 문자열.
|
|
214
|
+
"""
|
|
215
|
+
return "%d" % int(n)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def dumps(obj: dict) -> bytes:
|
|
219
|
+
"""객체를 본문 바이트(UTF-8, 공백 없는 JSON)로. 보내기 전에 규칙을 같은 검사로 확인한다.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
bytes: 본문 바이트.
|
|
223
|
+
|
|
224
|
+
Raises:
|
|
225
|
+
PropV2Error: 규칙 위반(키 · null · 섞인 배열 · 깊이 등).
|
|
226
|
+
"""
|
|
227
|
+
if not isinstance(obj, dict):
|
|
228
|
+
raise PropV2Error("must be an object")
|
|
229
|
+
_check(obj, "", 0)
|
|
230
|
+
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ── 요청(엔진 설계 §5-1) ───────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
PROP_CREATE = 3600
|
|
236
|
+
PROP_ALTER = 3601 # 코드 자체가 patch 다(본문에 표지 없음)
|
|
237
|
+
PROP_GET = 3602
|
|
238
|
+
PROP_LIST = 3603
|
|
239
|
+
PROP_DELETE = 3604
|
|
240
|
+
PROP_FAILED = 4601 # 실패 REPORT(arg2 = 사유). 성공은 LONG_REPORT(본문 JSON) - 4600 은 예약만
|
|
241
|
+
|
|
242
|
+
_LOG = logging.getLogger("ilink.propv2")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def require_v2(version: str | None, what: str) -> None:
|
|
246
|
+
"""상대가 Property v2 를 말하지 않으면 보내지 않고 거절한다(7 신규 오브젝트는 옛 와이어가 없다).
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
version (str | None): 접속 응답의 버전 문자열.
|
|
250
|
+
what (str): 사유에 실을 대상(``"quota"`` 등).
|
|
251
|
+
|
|
252
|
+
Raises:
|
|
253
|
+
ILOperationException: ``MIMQE_NOT_SUPPORTED (<what> requires ILink 7, current: <버전>)``.
|
|
254
|
+
"""
|
|
255
|
+
if not peer_speaks_v2(version):
|
|
256
|
+
from .exception import ILOperationException
|
|
257
|
+
raise ILOperationException("MIMQE_NOT_SUPPORTED (%s requires ILink 7, current: %s)" % (what, version or "?"))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def request(helper, qmgr_name: str, code: int, obj: dict) -> dict:
|
|
261
|
+
"""요청 하나를 보내고 응답 본문을 푼다. ``_IGNORED`` 가 있으면 WARNING 로그를 남긴다.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
helper: 연결(``ILRequestHelper``).
|
|
265
|
+
qmgr_name (str): 대상 큐 관리자 이름. ``""`` 이면 서비스(ilsvc)가 직접 처리한다(쿼터 · 디스크 가드).
|
|
266
|
+
code (int): ``PROP_CREATE`` · ``PROP_ALTER`` · ``PROP_GET`` · ``PROP_LIST`` · ``PROP_DELETE`` 중 하나.
|
|
267
|
+
obj (dict): 본문(``_TYPE`` 포함).
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
dict: 응답 본문. 빈 결과면 ``{}``.
|
|
271
|
+
|
|
272
|
+
Raises:
|
|
273
|
+
ILOperationException: 엔진이 거절했을 때(4601 의 사유 그대로). 토픽 FLUSH 와 겹친 거절은 조용히 몇 번 다시 보낸다.
|
|
274
|
+
PropV2Error: 응답 본문이 규칙에 맞지 않을 때.
|
|
275
|
+
"""
|
|
276
|
+
from .common import ILC
|
|
277
|
+
from .connectivity import _FLUSH_RETRY_MAX, _flush_backoff, _is_topic_flushed
|
|
278
|
+
from .exception import ILOperationException
|
|
279
|
+
from .msg import ILMsgUtil
|
|
280
|
+
body = dumps(obj)
|
|
281
|
+
for attempt in range(_FLUSH_RETRY_MAX + 1):
|
|
282
|
+
msg = (ILMsgUtil.create_long_request_msg(code, body) if not qmgr_name
|
|
283
|
+
else ILMsgUtil.create_long_request_msg_with_qmgr(qmgr_name, code, body))
|
|
284
|
+
res = helper._call(msg, helper.sessionTimeoutMS)
|
|
285
|
+
if res.getType() == ILC.MIMQ_REPORT_MESSAGE:
|
|
286
|
+
exc = ILOperationException(res.getReportMsg().getArg2())
|
|
287
|
+
if attempt >= _FLUSH_RETRY_MAX or not _is_topic_flushed(exc):
|
|
288
|
+
raise exc
|
|
289
|
+
_flush_backoff(attempt)
|
|
290
|
+
continue
|
|
291
|
+
data = res.getDataMsg().getData()
|
|
292
|
+
out = parse(data) if data and bytes(data).strip(b" \t\r\n") else {}
|
|
293
|
+
ign = out.get("_IGNORED")
|
|
294
|
+
if ign:
|
|
295
|
+
_LOG.warning("property v2 %s %s: engine ignored %s", obj.get("_TYPE", "?"), code,
|
|
296
|
+
", ".join("%s(%s)" % (e.get("KEY", "?"), e.get("WHY", "?")) for e in ign if isinstance(e, dict)))
|
|
297
|
+
return out
|
|
298
|
+
raise AssertionError("unreachable")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ── 토픽(엔진 설계 §13-3) ─────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
#: 토픽 설정 키의 종류. 옵션 문자열(``"k=v;..."``)을 본문으로 바꿀 때 쓴다 - 표에 없는 키는 STR 로 실어 보내고
|
|
304
|
+
#: 엔진이 모르면 ``_IGNORED unknown`` 으로 돌려준다(U4).
|
|
305
|
+
TOPIC_KINDS = {
|
|
306
|
+
"PARTITIONS": "INT", "INDEXINTERVAL": "INT",
|
|
307
|
+
"SEGMENTBYTES": "LONG", "SEGMENTROLLMS": "LONG", "RETENTIONMS": "LONG", "RETENTIONBYTES": "LONG",
|
|
308
|
+
"APPENDRUNBYTES": "LONG", "TOMBSTONERETENTIONMS": "LONG",
|
|
309
|
+
"MINCLEANABLERATIO": "DOUBLE", "SAFERETENTION": "BOOL", "CLEANUPPOLICY": "STR",
|
|
310
|
+
}
|
|
311
|
+
#: 읽기 전용 칸의 종류(GET / LIST 응답)
|
|
312
|
+
TOPIC_RO_KINDS = {
|
|
313
|
+
"EPOCH": "LONG", "EARLIEST": "LONG", "NEXT": "LONG", "TOTALBYTES": "LONG", "PUBLISHED": "LONG",
|
|
314
|
+
"DELIVERED": "LONG", "CURSORJUMPS": "LONG", "EXPIREDSUBS": "LONG", "SEGMENTCOUNT": "INT", "SUBSCRIPTIONS": "INT",
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def topic_options(options: str) -> dict:
|
|
319
|
+
"""옛 옵션 문자열(``"partitions=3;retentionms=86400000"``)을 본문 키로 바꾼다.
|
|
320
|
+
|
|
321
|
+
키는 대소문자를 가리지 않고 대문자로 바꾼다(옛 엔진 ApplyOptions 와 같다). 값은 :data:`TOPIC_KINDS` 의 종류로
|
|
322
|
+
바꾸고, 표에 없는 키는 문자열로 싣는다.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
options (str): ``key=value`` 를 ``;`` 로 이은 문자열. 빈 칸은 건너뛴다.
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
dict: 본문 키 -> 값.
|
|
329
|
+
|
|
330
|
+
Raises:
|
|
331
|
+
PropV2Error: ``=`` 가 없는 칸, 키 문자 규칙 위반, 값이 그 키의 종류로 읽히지 않을 때.
|
|
332
|
+
"""
|
|
333
|
+
out: dict = {}
|
|
334
|
+
for tok in str(options or "").split(";"):
|
|
335
|
+
tok = tok.strip()
|
|
336
|
+
if not tok:
|
|
337
|
+
continue
|
|
338
|
+
eq = tok.find("=")
|
|
339
|
+
if eq < 0:
|
|
340
|
+
raise PropV2Error("topic option without '=' : %s" % tok)
|
|
341
|
+
key = tok[:eq].strip().upper()
|
|
342
|
+
val = tok[eq + 1:].strip()
|
|
343
|
+
if not is_valid_key(key) or key.startswith("_"):
|
|
344
|
+
raise PropV2Error("bad topic option key '%s'" % tok[:eq].strip())
|
|
345
|
+
kind = TOPIC_KINDS.get(key, "STR")
|
|
346
|
+
try:
|
|
347
|
+
if kind == "INT":
|
|
348
|
+
n = int(val, 10)
|
|
349
|
+
if not _INT_MIN <= n <= _INT_MAX:
|
|
350
|
+
raise ValueError(val)
|
|
351
|
+
out[key] = n
|
|
352
|
+
elif kind == "LONG":
|
|
353
|
+
if not _LONG_RE.match(val) or not _LONG_MIN <= int(val) <= _LONG_MAX:
|
|
354
|
+
raise ValueError(val)
|
|
355
|
+
out[key] = long_str(int(val))
|
|
356
|
+
elif kind == "DOUBLE":
|
|
357
|
+
d = float(val)
|
|
358
|
+
if d != d or d in (float("inf"), float("-inf")):
|
|
359
|
+
raise ValueError(val)
|
|
360
|
+
out[key] = d
|
|
361
|
+
elif kind == "BOOL":
|
|
362
|
+
if val.lower() not in ("true", "false"):
|
|
363
|
+
raise ValueError(val)
|
|
364
|
+
out[key] = val.lower() == "true"
|
|
365
|
+
else:
|
|
366
|
+
out[key] = val.upper() if key == "CLEANUPPOLICY" else val
|
|
367
|
+
except ValueError:
|
|
368
|
+
raise PropV2Error("%s must be %s : %s" % (key, kind, val)) from None
|
|
369
|
+
return out
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def topic_list_text(obj: dict) -> str:
|
|
373
|
+
"""LIST 응답을 옛 목록 줄(``name|earliest|next|epoch|partitions`` + ``\\n``)로 다시 만든다.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
obj (dict): LIST 응답 본문(``_ITEMS``).
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
str: 토픽당 한 줄(엔진 옛 GetTopicDisplayList 와 같은 모양). 토픽이 없으면 빈 문자열.
|
|
380
|
+
"""
|
|
381
|
+
out = []
|
|
382
|
+
for e in obj.get("_ITEMS", []) or []:
|
|
383
|
+
if not isinstance(e, dict):
|
|
384
|
+
raise PropV2Error("_ITEMS must be ARRAY of OBJ")
|
|
385
|
+
out.append("%s|%d|%d|%d|%d\n" % (take(e, "NAME", "STR") or "", take(e, "EARLIEST", "LONG") or 0,
|
|
386
|
+
take(e, "NEXT", "LONG") or 0, take(e, "EPOCH", "LONG") or 0,
|
|
387
|
+
take(e, "PARTITIONS", "INT") or 0))
|
|
388
|
+
return "".join(out)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def topic_names(obj: dict) -> list:
|
|
392
|
+
"""LIST 응답에서 이름만 모은다.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
list[str]: 토픽 이름들(응답 순서).
|
|
396
|
+
"""
|
|
397
|
+
return [take(e, "NAME", "STR") or "" for e in obj.get("_ITEMS", []) or [] if isinstance(e, dict)]
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def topic_detail_text(obj: dict) -> str:
|
|
401
|
+
"""GET 응답을 사람이 읽는 평문으로 만든다(형식은 고정하지 않는다).
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
str: ``KEY : 값`` 줄들과 파티션마다 ``PART n ...`` 줄.
|
|
405
|
+
"""
|
|
406
|
+
lines = []
|
|
407
|
+
for k, v in obj.items():
|
|
408
|
+
if k.startswith("_") or k == "PARTS":
|
|
409
|
+
continue
|
|
410
|
+
lines.append("%s : %s" % (k, ("true" if v else "false") if isinstance(v, bool) else v))
|
|
411
|
+
for p in obj.get("PARTS", []) or []:
|
|
412
|
+
if isinstance(p, dict):
|
|
413
|
+
lines.append("PART %s : earliest=%s next=%s bytes=%s leader=%s epoch=%s isr=%s" % (
|
|
414
|
+
p.get("PARTITION", "?"), p.get("EARLIEST", "?"), p.get("NEXT", "?"), p.get("BYTES", "?"),
|
|
415
|
+
p.get("LEADER", "?"), p.get("EPOCH", "?"), p.get("ISR", "")))
|
|
416
|
+
return "".join(x + "\n" for x in lines)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def peer_speaks_v2(version: str | None) -> bool:
|
|
420
|
+
"""상대(접속 응답 arg2 의 버전 문자열)가 Property v2 를 말하는가.
|
|
421
|
+
|
|
422
|
+
앞뒤 공백 · 탭을 뺀 뒤 첫 '.'(없으면 끝)까지가 비지 않은 숫자열(6자리 이하)이고 그 값이 7 이상이면 참.
|
|
423
|
+
revision · 브랜드는 보지 않는다. 비었거나 못 읽으면 거짓(기존 방식). split 을 쓰지 않는다 - ".7.0.1" 은 거짓이다.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
bool: Property v2 를 말하면 True.
|
|
427
|
+
"""
|
|
428
|
+
if not version:
|
|
429
|
+
return False
|
|
430
|
+
s = version.lstrip(" \t")
|
|
431
|
+
if not s:
|
|
432
|
+
return False
|
|
433
|
+
i = s.find(".")
|
|
434
|
+
major = (s if i < 0 else s[:i]).rstrip(" \t")
|
|
435
|
+
if not major or len(major) > 6:
|
|
436
|
+
return False
|
|
437
|
+
for c in major:
|
|
438
|
+
if not ("0" <= c <= "9"):
|
|
439
|
+
return False
|
|
440
|
+
return int(major) >= 7
|