rainbow-rb-utils 0.0.9.dev45__tar.gz → 0.0.73.dev1__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 (26) hide show
  1. {rainbow_rb_utils-0.0.9.dev45 → rainbow_rb_utils-0.0.73.dev1}/PKG-INFO +3 -3
  2. {rainbow_rb_utils-0.0.9.dev45 → rainbow_rb_utils-0.0.73.dev1}/pyproject.toml +4 -4
  3. rainbow_rb_utils-0.0.73.dev1/src/rainbow/rb_utils/flatbuffer.py +145 -0
  4. {rainbow_rb_utils-0.0.9.dev45 → rainbow_rb_utils-0.0.73.dev1}/src/rainbow/rb_utils/flow_manager.py +130 -10
  5. {rainbow_rb_utils-0.0.9.dev45 → rainbow_rb_utils-0.0.73.dev1}/src/rainbow/rb_utils/helper.py +24 -10
  6. {rainbow_rb_utils-0.0.9.dev45 → rainbow_rb_utils-0.0.73.dev1}/src/rainbow/rb_utils/manipulate.py +3 -0
  7. rainbow_rb_utils-0.0.73.dev1/src/rainbow/rb_utils/program.py +135 -0
  8. {rainbow_rb_utils-0.0.9.dev45 → rainbow_rb_utils-0.0.73.dev1}/src/rainbow_rb_utils.egg-info/PKG-INFO +3 -3
  9. {rainbow_rb_utils-0.0.9.dev45 → rainbow_rb_utils-0.0.73.dev1}/src/rainbow_rb_utils.egg-info/SOURCES.txt +1 -0
  10. rainbow_rb_utils-0.0.73.dev1/src/rainbow_rb_utils.egg-info/requires.txt +2 -0
  11. rainbow_rb_utils-0.0.9.dev45/src/rainbow/rb_utils/program.py +0 -12
  12. rainbow_rb_utils-0.0.9.dev45/src/rainbow_rb_utils.egg-info/requires.txt +0 -2
  13. {rainbow_rb_utils-0.0.9.dev45 → rainbow_rb_utils-0.0.73.dev1}/README.md +0 -0
  14. {rainbow_rb_utils-0.0.9.dev45 → rainbow_rb_utils-0.0.73.dev1}/setup.cfg +0 -0
  15. {rainbow_rb_utils-0.0.9.dev45 → rainbow_rb_utils-0.0.73.dev1}/src/rainbow/rb_utils/__init__.py +0 -0
  16. {rainbow_rb_utils-0.0.9.dev45 → rainbow_rb_utils-0.0.73.dev1}/src/rainbow/rb_utils/asyncio_helper.py +0 -0
  17. {rainbow_rb_utils-0.0.9.dev45 → rainbow_rb_utils-0.0.73.dev1}/src/rainbow/rb_utils/date.py +0 -0
  18. {rainbow_rb_utils-0.0.9.dev45 → rainbow_rb_utils-0.0.73.dev1}/src/rainbow/rb_utils/env.py +0 -0
  19. {rainbow_rb_utils-0.0.9.dev45 → rainbow_rb_utils-0.0.73.dev1}/src/rainbow/rb_utils/file.py +0 -0
  20. {rainbow_rb_utils-0.0.9.dev45 → rainbow_rb_utils-0.0.73.dev1}/src/rainbow/rb_utils/pagination.py +0 -0
  21. {rainbow_rb_utils-0.0.9.dev45 → rainbow_rb_utils-0.0.73.dev1}/src/rainbow/rb_utils/parser.py +0 -0
  22. {rainbow_rb_utils-0.0.9.dev45 → rainbow_rb_utils-0.0.73.dev1}/src/rainbow/rb_utils/path_utils.py +0 -0
  23. {rainbow_rb_utils-0.0.9.dev45 → rainbow_rb_utils-0.0.73.dev1}/src/rainbow/rb_utils/py.typed +0 -0
  24. {rainbow_rb_utils-0.0.9.dev45 → rainbow_rb_utils-0.0.73.dev1}/src/rainbow/rb_utils/service_exception.py +0 -0
  25. {rainbow_rb_utils-0.0.9.dev45 → rainbow_rb_utils-0.0.73.dev1}/src/rainbow_rb_utils.egg-info/dependency_links.txt +0 -0
  26. {rainbow_rb_utils-0.0.9.dev45 → rainbow_rb_utils-0.0.73.dev1}/src/rainbow_rb_utils.egg-info/top_level.txt +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rainbow-rb-utils
3
- Version: 0.0.9.dev45
3
+ Version: 0.0.73.dev1
4
4
  Summary: Rainbow Python Utilities
5
5
  Author-email: Derek <dfd1123@rainbow-robotics.com>
6
- Requires-Python: >=3.11
6
+ Requires-Python: <3.14,>=3.11
7
7
  Description-Content-Type: text/markdown
8
8
  Requires-Dist: psutil<8.0.0,>=7.0.0
9
- Requires-Dist: rainbow-rb-log==0.0.9.dev45
9
+ Requires-Dist: rainbow-rb-log==0.0.73.dev1
10
10
 
11
11
  # rb_utils
12
12
 
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "rainbow-rb-utils"
7
- version = "0.0.9.dev45"
8
- requires-python = ">=3.11"
7
+ version = "0.0.73.dev1"
8
+ requires-python = ">=3.11,<3.14"
9
9
  description = "Rainbow Python Utilities"
10
10
  authors = [
11
11
  { name = "Derek", email = "dfd1123@rainbow-robotics.com" },
@@ -14,7 +14,7 @@ readme = "README.md"
14
14
 
15
15
  dependencies = [
16
16
  "psutil>=7.0.0,<8.0.0",
17
- "rainbow-rb-log==0.0.9.dev45",
17
+ "rainbow-rb-log==0.0.73.dev1",
18
18
  ]
19
19
 
20
20
  [tool.setuptools]
@@ -26,5 +26,5 @@ where = ["src"]
26
26
  include = ["rainbow.rb_utils*"]
27
27
 
28
28
  [tool.setuptools.package-data]
29
- "*" = ["py.typed", "*.pyi", "_core*.so", "_core*.pyd"]
29
+ "*" = ["py.typed", "*.pyi", "*.so", "*.pyd"]
30
30
  "rainbow.rb_utils" = ["examples/**", "examples/**/*", ".claude/**", ".claude/**/*"]
@@ -0,0 +1,145 @@
1
+ """서비스 dict를 생성된 FlatBuffer ``*T`` 객체로 변환하는 유틸리티."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import importlib
7
+ import importlib.util
8
+ from datetime import datetime
9
+ from enum import Enum
10
+ from functools import lru_cache
11
+ from typing import Any
12
+
13
+ from rainbow.rb_zenoh.fb_codec import FbTypeError, resolve_fb_type
14
+
15
+ from rainbow.rb_utils.parser import camel_to_snake
16
+
17
+
18
+ @lru_cache(maxsize=256)
19
+ def _field_annotations(t_cls: type) -> dict[str, str]:
20
+ """flatc가 생성한 sibling ``.pyi``에서 T 객체의 필드 타입을 읽는다."""
21
+ spec = importlib.util.find_spec(t_cls.__module__)
22
+ if spec is None or not spec.origin or not spec.origin.endswith(".py"):
23
+ return {}
24
+
25
+ try:
26
+ with open(spec.origin[:-3] + ".pyi", encoding="utf-8") as file:
27
+ tree = ast.parse(file.read())
28
+ except (OSError, SyntaxError):
29
+ return {}
30
+
31
+ for node in ast.walk(tree):
32
+ if isinstance(node, ast.ClassDef) and node.name == t_cls.__name__:
33
+ return {
34
+ statement.target.id: ast.unparse(statement.annotation)
35
+ for statement in node.body
36
+ if isinstance(statement, ast.AnnAssign)
37
+ and isinstance(statement.target, ast.Name)
38
+ }
39
+ return {}
40
+
41
+
42
+ def _strip_optional(annotation: str) -> str:
43
+ annotation = annotation.strip()
44
+ for prefix in ("typing.Optional[", "Optional["):
45
+ if annotation.startswith(prefix) and annotation.endswith("]"):
46
+ return annotation[len(prefix):-1].strip()
47
+ union_parts = [part.strip() for part in annotation.split("|")]
48
+ if len(union_parts) == 2 and "None" in union_parts:
49
+ return next(part for part in union_parts if part != "None")
50
+ return annotation
51
+
52
+
53
+ def _list_element_type(annotation: str) -> str | None:
54
+ annotation = _strip_optional(annotation)
55
+ for prefix in ("typing.List[", "List[", "list["):
56
+ if annotation.startswith(prefix) and annotation.endswith("]"):
57
+ return annotation[len(prefix):-1].strip().strip("'\"")
58
+ return None
59
+
60
+
61
+ def _coerce_literal(annotation: str, value: Any, owner_module: str) -> Any:
62
+ if not annotation.startswith(("typing.Literal[", "Literal[")):
63
+ return value
64
+
65
+ if isinstance(value, Enum):
66
+ value = value.value
67
+ if not isinstance(value, str):
68
+ return value
69
+
70
+ for member in annotation[annotation.index("[") + 1:-1].split(","):
71
+ enum_name, separator, member_name = member.strip().rpartition(".")
72
+ if not separator or member_name != value:
73
+ continue
74
+ enum_module = importlib.import_module(
75
+ f"{owner_module.rpartition('.')[0]}.{enum_name}"
76
+ )
77
+ enum_cls = getattr(enum_module, enum_name)
78
+ return getattr(enum_cls, member_name)
79
+ return value
80
+
81
+
82
+ def _coerce_scalar(annotation: str, value: Any, owner_module: str) -> Any:
83
+ annotation = _strip_optional(annotation)
84
+ value = _coerce_literal(annotation, value, owner_module)
85
+
86
+ if annotation == "int":
87
+ if isinstance(value, datetime):
88
+ return int(value.timestamp() * 1000)
89
+ try:
90
+ return int(value)
91
+ except (TypeError, ValueError):
92
+ return int(datetime.fromisoformat(value).timestamp() * 1000)
93
+ if annotation == "float":
94
+ return float(value)
95
+ if annotation == "bool":
96
+ if isinstance(value, str):
97
+ return value.lower() in {"1", "true", "yes"}
98
+ return bool(value)
99
+ if annotation == "str":
100
+ return str(value)
101
+ return value
102
+
103
+
104
+ def _build_value(annotation: str | None, value: Any, owner_module: str) -> Any:
105
+ if value is None or annotation is None:
106
+ return value
107
+
108
+ element_type = _list_element_type(annotation)
109
+ if element_type is not None:
110
+ values = value if isinstance(value, list | tuple) else [value]
111
+ return [_build_value(element_type, item, owner_module) for item in values]
112
+
113
+ nested_type = _strip_optional(annotation).strip("'\"")
114
+ if isinstance(value, dict):
115
+ try:
116
+ nested_cls = resolve_fb_type(nested_type)
117
+ except FbTypeError:
118
+ return value
119
+ return dict_to_flatbuffer(nested_cls, value)
120
+
121
+ return _coerce_scalar(nested_type, value, owner_module)
122
+
123
+
124
+ def dict_to_flatbuffer(t_cls: type, data: dict[str, Any]) -> Any:
125
+ """dict를 ``t_cls``와 중첩된 FlatBuffer T 객체로 재귀 변환한다."""
126
+ if not isinstance(data, dict):
127
+ raise TypeError(f"{t_cls.__name__}: dict required, got {type(data).__name__}")
128
+
129
+ obj = t_cls()
130
+ annotations = _field_annotations(t_cls)
131
+ fields = {key: value for key, value in vars(obj).items() if not key.startswith("_")}
132
+ normalized_data = {camel_to_snake(key): value for key, value in data.items()}
133
+
134
+ for field, default in fields.items():
135
+ normalized_field = camel_to_snake(field)
136
+ if normalized_field not in normalized_data:
137
+ continue
138
+
139
+ value = normalized_data[normalized_field]
140
+ annotation = annotations.get(field)
141
+ if value is None and default is not None and annotation is not None:
142
+ continue
143
+ setattr(obj, field, _build_value(annotation, value, t_cls.__module__))
144
+
145
+ return obj
@@ -74,7 +74,9 @@ def safe_eval_expr(
74
74
  variables: dict[str, dict[str, Any]] | None = None,
75
75
  get_global_variable: Callable[[str], Any] | None = None,
76
76
  get_var: Callable[..., Any] | None = None,
77
+ on_write: Callable[[str, str], None] | None = None,
77
78
  ) -> Any:
79
+ """on_write: 대입/증감 시 (변수명, "local"|"global") 통보. 쓰기 없으면 호출 안 됨."""
78
80
  if not isinstance(expr, str):
79
81
  return expr
80
82
 
@@ -88,7 +90,9 @@ def safe_eval_expr(
88
90
  if not s:
89
91
  return ""
90
92
 
91
- is_explicit_expression = bool(EXPLICIT_EXPR_PATTERN.search(s) or RB_GLOBAL_PATTERN.search(s) or GET_VAR_PATTERN.search(s))
93
+ is_explicit_expression = bool(
94
+ EXPLICIT_EXPR_PATTERN.search(s) or RB_GLOBAL_PATTERN.search(s) or GET_VAR_PATTERN.search(s)
95
+ )
92
96
  normalized = EXPLICIT_EXPR_PATTERN.sub(lambda match: match.group()[1:], s)
93
97
 
94
98
  # --- 내부 평가기 ------------------------------------------------------------
@@ -115,15 +119,30 @@ def safe_eval_expr(
115
119
  return ALLOWED_CONSTS[name]
116
120
  if name == "math": # math.* 전용
117
121
  return "math"
118
- if name in vars_.get("local", {}):
119
- return vars_["local"].get(name)
120
122
 
123
+ # RB_ 는 로봇 시스템변수 예약 네임스페이스다. 항상 라이브 조회가 먼저다.
124
+ # 캐시를 먼저 보면 한 번 담긴 값(state_monitoring 의 [string] 이거나 이전 조회
125
+ # 결과)에 갇혀 로봇이 갱신해도 반영되지 않는다.
126
+ # 라이브 조회가 실패한 경우(로봇 미응답 등)에만 마지막으로 알려진 캐시로 폴백한다.
121
127
  if name.startswith("RB_"):
122
128
  global_val = get_global_variable(name) if get_global_variable is not None else None
123
129
  if global_val is not None:
124
130
  return global_val
131
+
132
+ if name in vars_.get("local", {}):
133
+ return vars_["local"].get(name)
134
+
135
+ if name in vars_.get("global", {}):
136
+ return vars_["global"].get(name)
137
+
125
138
  raise ValueError(f"global variable {name} not found")
126
139
 
140
+ if name in vars_.get("local", {}):
141
+ return vars_["local"].get(name)
142
+
143
+ if name in vars_.get("global", {}):
144
+ return vars_["global"].get(name)
145
+
127
146
  return name # 모르는 식별자는 문자열로 취급(원하면 여기서 NameError)
128
147
 
129
148
  if isinstance(node, ast.Attribute):
@@ -187,7 +206,9 @@ def safe_eval_expr(
187
206
  if get_var is None:
188
207
  raise ValueError("get_var is not available in this context")
189
208
  if len(node.args) not in (2, 3):
190
- raise ValueError("get_var requires 2 or 3 arguments: get_var(pid_or_model, var_name[, default])")
209
+ raise ValueError(
210
+ "get_var requires 2 or 3 arguments: get_var(pid_or_model, var_name[, default])"
211
+ )
191
212
  pid_or_model = _eval(node.args[0])
192
213
  var_name = _eval(node.args[1])
193
214
  if len(node.args) == 3:
@@ -225,6 +246,22 @@ def safe_eval_expr(
225
246
  return slice(lo, up, st)
226
247
  return _eval(sl)
227
248
 
249
+ def _notify_write(target: ast.AST) -> None:
250
+ """대입 대상의 root 변수명 통보. a[0][1] = 3 → "a" """
251
+ if on_write is None:
252
+ return
253
+
254
+ node: ast.AST = target
255
+ while isinstance(node, ast.Subscript | ast.Attribute):
256
+ node = node.value
257
+ if not isinstance(node, ast.Name):
258
+ return
259
+
260
+ if node is target:
261
+ on_write(node.id, "local") # 이름 대입은 항상 local
262
+ else: # a[0] = 3 → a가 들어있는 스코프
263
+ on_write(node.id, "global" if node.id in vars_.get("global", {}) else "local")
264
+
228
265
  def _apply_increment(target_expr: str, *, delta: int, is_prefix: bool) -> Any:
229
266
  try:
230
267
  target = ast.parse(target_expr, mode="eval").body
@@ -245,6 +282,7 @@ def safe_eval_expr(
245
282
 
246
283
  new_val = _increment_value(cur, delta, target_expr) if exists else delta
247
284
  vars_.setdefault("local", {})[name] = new_val
285
+ _notify_write(target)
248
286
  return new_val if is_prefix else cur
249
287
 
250
288
  if isinstance(target, ast.Subscript):
@@ -256,6 +294,7 @@ def safe_eval_expr(
256
294
 
257
295
  new_val = _increment_value(cur, delta, target_expr)
258
296
  container[key] = new_val
297
+ _notify_write(target)
259
298
  return new_val if is_prefix else cur
260
299
 
261
300
  raise ValueError("only variable or subscript increment targets are allowed")
@@ -278,11 +317,13 @@ def safe_eval_expr(
278
317
  def _set_assign_target_value(target: ast.AST, value: Any) -> Any:
279
318
  if isinstance(target, ast.Name):
280
319
  vars_.setdefault("local", {})[target.id] = value
320
+ _notify_write(target)
281
321
  return value
282
322
 
283
323
  if isinstance(target, ast.Subscript):
284
324
  container = _eval(target.value)
285
325
  container[_eval_subscript_key(target)] = value
326
+ _notify_write(target)
286
327
  return value
287
328
 
288
329
  raise ValueError("only variable or subscript assignment targets are allowed")
@@ -342,9 +383,9 @@ def safe_eval_expr(
342
383
  if is_explicit_expression:
343
384
  raise ValueError(f"failed to evaluate expression: {expr}") from None
344
385
  return s
345
- except Exception:
386
+ except Exception as e:
346
387
  if is_explicit_expression:
347
- raise ValueError(f"failed to evaluate expression: {expr}") from None
388
+ raise ValueError(f"failed to evaluate expression: {expr} ({e})") from e
348
389
  return s
349
390
 
350
391
 
@@ -362,9 +403,68 @@ def _pick_target_loop(func, provided: dict) -> asyncio.AbstractEventLoop | None:
362
403
  return None
363
404
 
364
405
 
406
+ _PYBIND_SIG_RE = re.compile(r"^\w+\((?P<params>.*)\)\s*->")
407
+
408
+
409
+ def _split_top_level(src: str) -> list[str]:
410
+ parts: list[str] = []
411
+ depth = 0
412
+ buf: list[str] = []
413
+ for ch in src:
414
+ if ch in "([{":
415
+ depth += 1
416
+ elif ch in ")]}":
417
+ depth -= 1
418
+ if ch == "," and depth == 0:
419
+ parts.append("".join(buf))
420
+ buf = []
421
+ else:
422
+ buf.append(ch)
423
+ parts.append("".join(buf))
424
+ return [p.strip() for p in parts if p.strip()]
425
+
426
+
427
+ def _signature_from_pybind_doc(func) -> inspect.Signature | None:
428
+ """pybind11 함수는 __text_signature__가 없어 inspect.signature가 실패한다.
429
+ 시그니처가 유일하게 남아 있는 __doc__ 첫 줄에서 인자 이름/기본값 유무만 복원한다."""
430
+ doc = (getattr(func, "__doc__", None) or "").lstrip()
431
+ match = _PYBIND_SIG_RE.match(doc.splitlines()[0]) if doc else None
432
+ if match is None:
433
+ return None
434
+
435
+ params: list[inspect.Parameter] = []
436
+ for token in _split_top_level(match.group("params")):
437
+ head, _, default = token.partition("=")
438
+ name = head.split(":", 1)[0].strip()
439
+
440
+ if name.startswith("**"):
441
+ kind, name = inspect.Parameter.VAR_KEYWORD, name[2:]
442
+ elif name.startswith("*"):
443
+ kind, name = inspect.Parameter.VAR_POSITIONAL, name[1:]
444
+ else:
445
+ kind = inspect.Parameter.KEYWORD_ONLY
446
+
447
+ if not name or (name == "self" and not params):
448
+ continue
449
+
450
+ has_default = bool(default.strip()) and kind is inspect.Parameter.KEYWORD_ONLY
451
+ params.append(
452
+ inspect.Parameter(
453
+ name, kind, default=None if has_default else inspect.Parameter.empty
454
+ )
455
+ )
456
+
457
+ return inspect.Signature(params)
458
+
459
+
365
460
  def call_with_matching_args(func, **provided):
366
461
  """func의 시그니처를 보고 필요한 인자만 추려서 호출 (동기/비동기 모두 지원, 루프 안전)"""
367
- sig = inspect.signature(func)
462
+ try:
463
+ sig = inspect.signature(func)
464
+ except ValueError:
465
+ sig = _signature_from_pybind_doc(func)
466
+ if sig is None:
467
+ raise
368
468
  call_kwargs = {}
369
469
 
370
470
  for name, param in sig.parameters.items():
@@ -408,6 +508,7 @@ def eval_value(
408
508
  variables: dict[str, dict[str, Any]] | None = None,
409
509
  get_global_variable: Callable[[str], Any] | None = None,
410
510
  get_var: Callable[..., Any] | None = None,
511
+ on_write: Callable[[str, str], None] | None = None,
411
512
  ) -> Any:
412
513
  # 1) 문자열이면 → 기존 safe_eval_expr 호출
413
514
  if isinstance(value, str):
@@ -416,26 +517,45 @@ def eval_value(
416
517
  variables=variables,
417
518
  get_global_variable=get_global_variable,
418
519
  get_var=get_var,
520
+ on_write=on_write,
419
521
  )
420
522
 
421
523
  # 2) 리스트면 → 각 요소를 재귀적으로 평가
422
524
  if isinstance(value, list):
423
525
  return [
424
- eval_value(v, variables=variables, get_global_variable=get_global_variable, get_var=get_var)
526
+ eval_value(
527
+ v,
528
+ variables=variables,
529
+ get_global_variable=get_global_variable,
530
+ get_var=get_var,
531
+ on_write=on_write,
532
+ )
425
533
  for v in value
426
534
  ]
427
535
 
428
536
  # 3) 튜플도 동일
429
537
  if isinstance(value, tuple):
430
538
  return tuple(
431
- eval_value(v, variables=variables, get_global_variable=get_global_variable, get_var=get_var)
539
+ eval_value(
540
+ v,
541
+ variables=variables,
542
+ get_global_variable=get_global_variable,
543
+ get_var=get_var,
544
+ on_write=on_write,
545
+ )
432
546
  for v in value
433
547
  )
434
548
 
435
549
  # 4) 딕셔너리면 → value만 재귀적으로 평가 (key는 그대로)
436
550
  if isinstance(value, dict):
437
551
  return {
438
- k: eval_value(v, variables=variables, get_global_variable=get_global_variable, get_var=get_var)
552
+ k: eval_value(
553
+ v,
554
+ variables=variables,
555
+ get_global_variable=get_global_variable,
556
+ get_var=get_var,
557
+ on_write=on_write,
558
+ )
439
559
  for k, v in value.items()
440
560
  }
441
561
 
@@ -1,6 +1,7 @@
1
1
  import asyncio
2
2
  import math
3
3
  import socket
4
+ from concurrent.futures import ThreadPoolExecutor
4
5
  from typing import Any
5
6
 
6
7
  from .parser import t_to_dict
@@ -18,23 +19,36 @@ def ManipulateZenohResHelper(obj: Any):
18
19
  raise ValueError("obj is not a dict")
19
20
 
20
21
 
22
+ # 기본 executor 는 쓰지 않는다. wait_for 는 시작된 스레드를 못 죽여서, 여기가
23
+ # 막히면 이 함수를 넘어 MuscatRouter 핸들러까지 같이 굶는다.
24
+ _dns_fallback_pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="rb-ip-dns")
25
+
26
+
27
+ def _route_lookup_ip() -> str | None:
28
+ """기본 경로로 나가는 인터페이스 IP. 패킷 안 나가고 라우팅 테이블만 본다 (µs)."""
29
+ try:
30
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
31
+ sock.connect(("8.8.8.8", 80))
32
+ return sock.getsockname()[0]
33
+ except OSError:
34
+ return None # 기본 경로 없음
35
+
36
+
21
37
  async def get_current_ip(timeout: float = 0.5) -> str:
22
- def resolve_ip() -> str:
23
- try:
24
- with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
25
- sock.connect(("8.8.8.8", 80))
26
- return sock.getsockname()[0]
27
- except Exception:
28
- return socket.gethostbyname(socket.gethostname())
38
+ ip = _route_lookup_ip()
39
+ if ip is not None:
40
+ return ip
29
41
 
42
+ # 기본 경로가 없을 때만. 호스트명 해석은 DNS 를 탈 수 있다.
30
43
  loop = asyncio.get_running_loop()
31
44
  try:
32
45
  return await asyncio.wait_for(
33
- loop.run_in_executor(None, resolve_ip),
46
+ loop.run_in_executor(
47
+ _dns_fallback_pool, lambda: socket.gethostbyname(socket.gethostname())
48
+ ),
34
49
  timeout=timeout,
35
50
  )
36
- except Exception:
37
- # DNS가 막히면 이전 IP를 쓰거나, 로컬 IP로 폴백
51
+ except (TimeoutError, OSError):
38
52
  return "127.0.0.1"
39
53
 
40
54
 
@@ -79,6 +79,9 @@ def validate_single_return_value(res: QueryResult[T], fm_args: FlowManagerArgs |
79
79
 
80
80
  obj_payload = res.get("obj_payload", None)
81
81
  if obj_payload is None:
82
+ err = res.get("err")
83
+ if err:
84
+ raise RuntimeError(f"{snake_to_title(call_function)} failed: {err}")
82
85
  raise RuntimeError(f"{snake_to_title(call_function)} failed: obj_payload is None")
83
86
 
84
87
  if hasattr(obj_payload, "returnValue") and obj_payload.returnValue != 0:
@@ -0,0 +1,135 @@
1
+ import ipaddress
2
+ from typing import Any, Literal
3
+
4
+
5
+ def get_task_id(args: Any | None) -> str:
6
+ if not args:
7
+ return "unknown_module"
8
+
9
+ # FlowManagerArgs 객체와 dict 입력을 모두 허용한다.
10
+ ctx = args.get("ctx") if isinstance(args, dict) else getattr(args, "ctx", None)
11
+
12
+ process_id = getattr(ctx, "process_id", None)
13
+ return str(process_id) if process_id else "unknown_module"
14
+
15
+
16
+ _MISSING = object()
17
+
18
+ _RESOLVE_OPTIONS = ("SKIP", "STOP", "ASK")
19
+
20
+
21
+ class InterfaceResult:
22
+ def __init__(self, success: bool, error: str | None = None):
23
+ self.success = success
24
+ self.error = error
25
+
26
+ def __str__(self) -> str:
27
+ if self.error:
28
+ return self.error
29
+ return "success" if self.success else "The request failed."
30
+
31
+
32
+ def to_int(
33
+ data_or_value,
34
+ field_name: str | None = None,
35
+ default: int | object = _MISSING,
36
+ error_prefix: str | None = None,
37
+ ) -> int:
38
+ """값을 int로 변환하고, default가 있을 때만 None/빈 문자열을 대체한다."""
39
+
40
+ def _raise(msg: str) -> None:
41
+ if error_prefix:
42
+ raise ValueError(f"{error_prefix}: {msg}")
43
+ raise ValueError(msg)
44
+
45
+ if field_name is not None and hasattr(data_or_value, "get"):
46
+ value = data_or_value.get(field_name)
47
+ name = field_name
48
+ else:
49
+ value = data_or_value
50
+ name = field_name or "value"
51
+
52
+ if value is None or value == "":
53
+ if default is _MISSING:
54
+ _raise(f"failed: {name} is required")
55
+ return int(default)
56
+ try:
57
+ return int(value)
58
+ except (TypeError, ValueError) as exc:
59
+ if error_prefix:
60
+ raise ValueError(
61
+ f"{error_prefix}: failed: int conversion failed {name}={value}"
62
+ ) from exc
63
+ raise ValueError(f"failed: int conversion failed {name}={value}") from exc
64
+
65
+
66
+ def validate_server_ip(data, error_prefix: str) -> str:
67
+ server_ip = data.get("server_ip")
68
+
69
+ if server_ip in (None, "", Ellipsis):
70
+ raise ValueError(f"{error_prefix}: server_ip is required")
71
+
72
+ if not isinstance(server_ip, str):
73
+ raise ValueError(f"{error_prefix}: server_ip must be a string")
74
+
75
+ server_ip = server_ip.strip()
76
+ if not server_ip:
77
+ raise ValueError(f"{error_prefix}: server_ip is required")
78
+
79
+ try:
80
+ ipaddress.ip_address(server_ip)
81
+ except ValueError as exc:
82
+ raise ValueError(f"{error_prefix}: invalid server_ip={server_ip}") from exc
83
+
84
+ return server_ip
85
+
86
+
87
+ def get_resolve_option(data, error_prefix: str) -> str:
88
+ value = data.get("resolve_option", "SKIP")
89
+ if value not in _RESOLVE_OPTIONS:
90
+ raise ValueError(
91
+ f"{error_prefix}: invalid resolve_option={value!r} (allowed: {_RESOLVE_OPTIONS})"
92
+ )
93
+ return value
94
+
95
+
96
+ def get_timeout_sec(data, error_prefix: str, default: float | None = None) -> int:
97
+ value = data.get("timeout_sec")
98
+ if value is None or value == "":
99
+ if default is None:
100
+ raise ValueError(f"{error_prefix}: timeout_sec is required")
101
+ return int(default * 1000)
102
+ try:
103
+ return int(float(value) * 1000)
104
+ except (TypeError, ValueError) as exc:
105
+ raise ValueError(
106
+ f"{error_prefix}: failed: timeout_sec conversion failed value={value}"
107
+ ) from exc
108
+
109
+
110
+ def get_required_field(data, field_name: str, error_prefix: str):
111
+ value = data.get(field_name)
112
+ if value in (None, "", Ellipsis):
113
+ raise ValueError(f"{error_prefix}: {field_name} is required")
114
+ return value
115
+
116
+
117
+ def resolve_interface_flow(
118
+ result,
119
+ resolve_option: Literal["SKIP", "STOP", "ASK"],
120
+ flow_manager_args,
121
+ alarm_fn=None,
122
+ error_prefix: str = "Interface",
123
+ ):
124
+ err_msg = getattr(result, "error", None)
125
+ if err_msg in (None, ""):
126
+ err_msg = str(result) if result not in (None, False) else "The request failed."
127
+ if resolve_option == "ASK" and alarm_fn is not None:
128
+ alarm_fn(title="Interface Debug", content=f"{error_prefix}.(Err:{err_msg})")
129
+ flow_manager_args.ctx.pause_all()
130
+ flow_manager_args.ctx.pause()
131
+ flow_manager_args.ctx.check_stop()
132
+ elif resolve_option == "STOP":
133
+ raise RuntimeError(f"{error_prefix}: result={err_msg}")
134
+ else:
135
+ pass # SKIP: do nothing and continue
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rainbow-rb-utils
3
- Version: 0.0.9.dev45
3
+ Version: 0.0.73.dev1
4
4
  Summary: Rainbow Python Utilities
5
5
  Author-email: Derek <dfd1123@rainbow-robotics.com>
6
- Requires-Python: >=3.11
6
+ Requires-Python: <3.14,>=3.11
7
7
  Description-Content-Type: text/markdown
8
8
  Requires-Dist: psutil<8.0.0,>=7.0.0
9
- Requires-Dist: rainbow-rb-log==0.0.9.dev45
9
+ Requires-Dist: rainbow-rb-log==0.0.73.dev1
10
10
 
11
11
  # rb_utils
12
12
 
@@ -5,6 +5,7 @@ src/rainbow/rb_utils/asyncio_helper.py
5
5
  src/rainbow/rb_utils/date.py
6
6
  src/rainbow/rb_utils/env.py
7
7
  src/rainbow/rb_utils/file.py
8
+ src/rainbow/rb_utils/flatbuffer.py
8
9
  src/rainbow/rb_utils/flow_manager.py
9
10
  src/rainbow/rb_utils/helper.py
10
11
  src/rainbow/rb_utils/manipulate.py
@@ -0,0 +1,2 @@
1
+ psutil<8.0.0,>=7.0.0
2
+ rainbow-rb-log==0.0.73.dev1
@@ -1,12 +0,0 @@
1
- from typing import Any
2
-
3
-
4
- def get_task_id(args: Any | None) -> str:
5
- if not args:
6
- return "unknown_module"
7
-
8
- # FlowManagerArgs 객체와 dict 입력을 모두 허용한다.
9
- ctx = args.get("ctx") if isinstance(args, dict) else getattr(args, "ctx", None)
10
-
11
- process_id = getattr(ctx, "process_id", None)
12
- return str(process_id) if process_id else "unknown_module"
@@ -1,2 +0,0 @@
1
- psutil<8.0.0,>=7.0.0
2
- rainbow-rb-log==0.0.9.dev45