Skip to content

Commit 691a20c

Browse files
committed
리액티브 위젯 값 객체(UiValue) — 위젯을 변수로 담아 .value를 읽게
ui.slider/dropdown/number/checkbox가 bare descriptor 대신 UiValue를 반환한다. UiValue는 위치 기반 안정 id({blockId}#{index})로 워커 store에 값을 영속하고(.value가 live로 읽음, 셀 재실행에도 유지), codaroDescriptor()로 기존 ui descriptor 형태(+elementId)를 내 프론트 렌더는 불변이다. analyzeCode가 slider.value를 use로 보므로 리액티브 그래프가 위젯→소비자 의존을 이미 포착한다. 워커: 블록 실행 시작 시 beginBlock으로 위치 카운터 리셋, _serializeUiValue로 셀 출력 렌더, layout 중첩 UiValue를 toDescriptor/_sanitizeValue/_sanitizeDescriptor가 descriptor로 변환, reset 시 store 초기화. 값-변경 리액티브 트리거는 다음 커밋(setUiValue 경로). widget-bridge 게이트 정합.
1 parent 77cc7c3 commit 691a20c

6 files changed

Lines changed: 235 additions & 39 deletions

File tree

src/codaro/outputDescriptor.py

Lines changed: 32 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import Any, Callable, Iterable, Mapping, Sequence
55

66
from .uiCallbacks import registerCallback
7+
from .uiValue import UiValue
78

89

910
DESCRIPTOR_TYPES = {
@@ -249,16 +250,14 @@ def number(
249250
max: int | float | None = None,
250251
step: int | float | None = None,
251252
onChange: Callable[..., Any] | None = None,
252-
) -> dict[str, Any]:
253-
return _uiDescriptor(
254-
"number",
255-
value=value,
256-
label=label,
257-
min=min,
258-
max=max,
259-
step=step,
260-
events=_bindEvents(change=onChange),
261-
)
253+
) -> UiValue:
254+
return UiValue("number", value, {
255+
"label": label,
256+
"min": min,
257+
"max": max,
258+
"step": step,
259+
"events": _bindEvents(change=onChange),
260+
})
262261

263262
def slider(
264263
self,
@@ -269,30 +268,26 @@ def slider(
269268
step: int | float = 1,
270269
label: str = "",
271270
onChange: Callable[..., Any] | None = None,
272-
) -> dict[str, Any]:
273-
return _uiDescriptor(
274-
"slider",
275-
value=start if value is None else value,
276-
label=label,
277-
min=start,
278-
max=stop,
279-
step=step,
280-
events=_bindEvents(change=onChange),
281-
)
271+
) -> UiValue:
272+
return UiValue("slider", start if value is None else value, {
273+
"label": label,
274+
"min": start,
275+
"max": stop,
276+
"step": step,
277+
"events": _bindEvents(change=onChange),
278+
})
282279

283280
def checkbox(
284281
self,
285282
value: bool = False,
286283
*,
287284
label: str = "",
288285
onChange: Callable[..., Any] | None = None,
289-
) -> dict[str, Any]:
290-
return _uiDescriptor(
291-
"checkbox",
292-
value=bool(value),
293-
label=label,
294-
events=_bindEvents(change=onChange),
295-
)
286+
) -> UiValue:
287+
return UiValue("checkbox", bool(value), {
288+
"label": label,
289+
"events": _bindEvents(change=onChange),
290+
})
296291

297292
def dropdown(
298293
self,
@@ -301,16 +296,14 @@ def dropdown(
301296
value: object | None = None,
302297
label: str = "",
303298
onChange: Callable[..., Any] | None = None,
304-
) -> dict[str, Any]:
299+
) -> UiValue:
305300
normalizedOptions = [str(option) for option in options]
306301
selected = str(value) if value is not None else (normalizedOptions[0] if normalizedOptions else "")
307-
return _uiDescriptor(
308-
"dropdown",
309-
value=selected,
310-
label=label,
311-
options=normalizedOptions,
312-
events=_bindEvents(change=onChange),
313-
)
302+
return UiValue("dropdown", selected, {
303+
"label": label,
304+
"options": normalizedOptions,
305+
"events": _bindEvents(change=onChange),
306+
})
314307

315308
def button(
316309
self,
@@ -439,6 +432,8 @@ def isDescriptorPayload(value: object) -> bool:
439432
def toDescriptor(value: object) -> object:
440433
if value is None:
441434
return {"type": "plain", "content": ""}
435+
if isinstance(value, UiValue):
436+
return _sanitizeValue(value.codaroDescriptor())
442437
if isDescriptorPayload(value):
443438
return _sanitizeValue(value)
444439
if isinstance(value, str):
@@ -487,6 +482,8 @@ def _uiDescriptor(component: str, **props: object) -> dict[str, Any]:
487482

488483

489484
def _sanitizeValue(value: object) -> Any:
485+
if isinstance(value, UiValue):
486+
return _sanitizeValue(value.codaroDescriptor())
490487
if isDescriptorPayload(value):
491488
payload = value if isinstance(value, dict) else {}
492489
return {key: _sanitizeValue(item) for key, item in payload.items()}

src/codaro/runtime/localWorker.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from ..document.analysis import analyzeCode
2727
from ..errorGuard import safeRepr
2828
from ..outputDescriptor import isDescriptorPayload
29+
from ..uiValue import beginBlock, resetStore
2930

3031

3132
def runLocalWorker(
@@ -119,6 +120,7 @@ def emitEvent(eventType: str, payload: Any = None) -> None:
119120
if action == "reset":
120121
registry.clear()
121122
cellDefinitions.clear()
123+
resetStore()
122124
executionCount = 0
123125
_workerSend(connection, _buildStateResponse(registry, cellDefinitions, executionCount))
124126
continue
@@ -181,6 +183,10 @@ def _executeCommand(
181183

182184
emitEvent("started", {"status": "running"})
183185

186+
# 위젯 위치 카운터 리셋 + 현재 blockId 설정 — 이 블록에서 만드는 ui.* 위젯이
187+
# {blockId}#{index} 안정 id를 받아 값이 셀 재실행에도 영속한다.
188+
beginBlock(blockId)
189+
184190
beforeVariables = _mapVariablesByName(_collectVariables(registry))
185191
stdoutBuf = _StreamingTextBuffer("stdout", emitEvent)
186192
stderrBuf = _StreamingTextBuffer("stderr", emitEvent)
@@ -521,6 +527,10 @@ def _normalizeResult(value: object) -> tuple[str, object]:
521527
if dataframePayload is not None:
522528
return "dataframe", dataframePayload
523529

530+
uiValuePayload = _serializeUiValue(value)
531+
if uiValuePayload is not None:
532+
return "layout", uiValuePayload
533+
524534
descriptorPayload = _serializeDescriptor(value)
525535
if descriptorPayload is not None:
526536
return "layout", descriptorPayload
@@ -619,13 +629,32 @@ def _serializeDataFrame(value: object) -> dict[str, object] | None:
619629
return None
620630

621631

632+
def _serializeUiValue(value: object) -> dict[str, object] | None:
633+
"""UiValue(리액티브 위젯 값 객체)를 descriptor로 직렬화한다(없으면 None).
634+
635+
객체의 `codaroDescriptor()`가 기존 ui descriptor 형태({type:ui, component, value, elementId})를
636+
내고, 그걸 표준 descriptor 직렬화에 태워 프론트 렌더를 불변으로 유지한다.
637+
"""
638+
builder = getattr(value, "codaroDescriptor", None)
639+
if not callable(builder):
640+
return None
641+
descriptor = builder()
642+
if not isDescriptorPayload(descriptor):
643+
return None
644+
return _sanitizeDescriptor(descriptor)
645+
646+
622647
def _serializeDescriptor(value: object) -> dict[str, object] | None:
623648
if not isDescriptorPayload(value):
624649
return None
625650
return _sanitizeDescriptor(value)
626651

627652

628653
def _sanitizeDescriptor(value: object) -> object:
654+
# layout(hstack/tabs/...) 안에 중첩된 UiValue(위젯 값 객체)를 descriptor로 변환.
655+
builder = getattr(value, "codaroDescriptor", None)
656+
if callable(builder):
657+
return _sanitizeDescriptor(builder())
629658
if isinstance(value, dict):
630659
return {str(key): _sanitizeDescriptor(item) for key, item in value.items()}
631660
if isinstance(value, list):

src/codaro/uiValue.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""리액티브 위젯 값 객체 + per-worker 값 store.
2+
3+
위젯을 변수에 담으면(`slider = ui.slider(0, 100)`) 그 변수를 쓰는 셀이 자동 재실행되는
4+
marimo식 값-바인딩의 백엔드. `analyzeCode`가 `slider.value`를 `slider` use로 보므로 리액티브
5+
그래프가 위젯→소비자 의존을 이미 포착한다 — 여기서 빠진 *값 객체*와 *값 영속*만 채운다.
6+
7+
값은 `elementId`(위치 기반 안정 id `{blockId}#{index}`)로 store에 영속하고 셀 재실행에도 유지된다.
8+
store/카운터는 워커 프로세스의 모듈 전역에 살아 per-session 격리된다(워커가 세션마다 1개).
9+
"""
10+
from __future__ import annotations
11+
12+
from typing import Any
13+
14+
# 워커 프로세스 전역 — 세션(=워커) 단위 격리.
15+
_store: dict[str, Any] = {}
16+
_currentBlockId: str = ""
17+
_counter: int = 0
18+
19+
20+
def beginBlock(blockId: str | None) -> None:
21+
"""블록 실행 시작 — 현재 blockId 설정 + 위젯 위치 카운터 리셋."""
22+
global _currentBlockId, _counter
23+
_currentBlockId = blockId or ""
24+
_counter = 0
25+
26+
27+
def nextElementId() -> str:
28+
"""현재 블록 내에서 위젯 생성 순서대로 안정 id를 발급한다."""
29+
global _counter
30+
elementId = f"{_currentBlockId}#{_counter}"
31+
_counter += 1
32+
return elementId
33+
34+
35+
def storedValue(elementId: str, default: Any) -> Any:
36+
return _store.get(elementId, default)
37+
38+
39+
def setStoredValue(elementId: str, value: Any) -> None:
40+
_store[elementId] = value
41+
42+
43+
def resetStore() -> None:
44+
"""세션 reset 시 호출 — 값 store와 카운터를 비운다."""
45+
global _currentBlockId, _counter
46+
_store.clear()
47+
_currentBlockId = ""
48+
_counter = 0
49+
50+
51+
class UiValue:
52+
"""변수에 담겨 리액티브 의존을 형성하는 위젯 값 객체. `.value`는 store에서 live로 읽는다.
53+
54+
셀 출력으로 렌더될 때는 `codaroDescriptor()`(기존 `_uiDescriptor` 형태 + elementId)로
55+
직렬화된다 — 프론트 위젯 렌더는 불변, elementId만 추가로 실어 값-변경 경로를 연다.
56+
"""
57+
58+
def __init__(self, kind: str, defaultValue: Any, meta: dict[str, Any] | None = None) -> None:
59+
self.kind = kind
60+
self.elementId = nextElementId()
61+
self._default = defaultValue
62+
self.meta = dict(meta or {})
63+
if self.elementId not in _store:
64+
_store[self.elementId] = defaultValue
65+
66+
@property
67+
def value(self) -> Any:
68+
return _store.get(self.elementId, self._default)
69+
70+
def codaroDescriptor(self) -> dict[str, Any]:
71+
descriptor: dict[str, Any] = {
72+
"type": "ui",
73+
"component": self.kind,
74+
"value": self.value,
75+
"elementId": self.elementId,
76+
}
77+
descriptor.update(self.meta)
78+
return descriptor
79+
80+
def __repr__(self) -> str:
81+
return f"UiValue({self.kind}={self.value!r})"

tests/testUiValueBinding.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""리액티브 위젯 값 객체(UiValue) + 위치 기반 id + 값 영속 + 세션 렌더 테스트."""
2+
from __future__ import annotations
3+
4+
import asyncio
5+
6+
from codaro.kernel.session import KernelSession
7+
from codaro.uiValue import (
8+
UiValue,
9+
beginBlock,
10+
resetStore,
11+
setStoredValue,
12+
)
13+
14+
15+
def _run(coro):
16+
return asyncio.new_event_loop().run_until_complete(coro)
17+
18+
19+
def testPositionalElementId() -> None:
20+
resetStore()
21+
beginBlock("b1")
22+
a = UiValue("slider", 5)
23+
b = UiValue("slider", 7)
24+
assert a.elementId == "b1#0"
25+
assert b.elementId == "b1#1"
26+
27+
28+
def testValueReadsStoredDefault() -> None:
29+
resetStore()
30+
beginBlock("b1")
31+
slider = UiValue("slider", 5)
32+
assert slider.value == 5
33+
34+
35+
def testSetStoredValueUpdatesValue() -> None:
36+
resetStore()
37+
beginBlock("b1")
38+
slider = UiValue("slider", 5)
39+
setStoredValue(slider.elementId, 42)
40+
assert slider.value == 42
41+
42+
43+
def testValuePersistsAcrossRerun() -> None:
44+
# 위젯 셀 재실행 시 같은 위치 id → 값 유지(기본값으로 리셋 안 됨).
45+
resetStore()
46+
beginBlock("b1")
47+
first = UiValue("slider", 5)
48+
setStoredValue(first.elementId, 42)
49+
50+
beginBlock("b1") # 재실행
51+
second = UiValue("slider", 5)
52+
assert second.elementId == "b1#0"
53+
assert second.value == 42
54+
55+
56+
def testCodaroDescriptorShape() -> None:
57+
resetStore()
58+
beginBlock("b1")
59+
slider = UiValue("slider", 0, {"label": "x", "min": 0, "max": 100, "step": 1, "events": {}})
60+
descriptor = slider.codaroDescriptor()
61+
assert descriptor["type"] == "ui"
62+
assert descriptor["component"] == "slider"
63+
assert descriptor["elementId"] == "b1#0"
64+
assert descriptor["value"] == 0
65+
assert descriptor["min"] == 0 and descriptor["max"] == 100
66+
67+
68+
def testSessionSliderRendersAndValueReadable() -> None:
69+
session = KernelSession()
70+
result = _run(session.execute(
71+
"from codaro.outputDescriptor import ui\nslider = ui.slider(0, 100)\nslider",
72+
blockId="b1",
73+
))
74+
assert result.status == "done"
75+
# 위젯이 ui descriptor로 렌더되고 elementId를 싣는다.
76+
assert isinstance(result.data, dict)
77+
assert result.data.get("component") == "slider"
78+
assert result.data.get("elementId") == "b1#0"
79+
80+
# 다운스트림 셀에서 slider.value를 읽으면 기본값(0).
81+
consumer = _run(session.execute("print(slider.value)", blockId="b2"))
82+
assert "0" in consumer.stdout
83+
session.dispose()

tests/testWidgetBridge.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ def testButtonRegistersClickCallback() -> None:
3131
def testCheckboxChangeCallbackReceivesPayload() -> None:
3232
setUp()
3333
received: list[object] = []
34-
descriptor = ui.checkbox(False, label="동의", onChange=lambda value: received.append(value))
34+
# 값 위젯은 이제 UiValue 객체 — 렌더 형태는 codaroDescriptor()로 본다.
35+
descriptor = ui.checkbox(False, label="동의", onChange=lambda value: received.append(value)).codaroDescriptor()
3536

3637
callbackId = descriptor["events"]["change"]
3738
invokeCallback(callbackId, True)
@@ -43,7 +44,7 @@ def testCheckboxChangeCallbackReceivesPayload() -> None:
4344
def testSliderEventsBindWithMinMaxStep() -> None:
4445
setUp()
4546
received: list[float] = []
46-
descriptor = ui.slider(0, 200, value=10, step=5, onChange=lambda value: received.append(float(value)))
47+
descriptor = ui.slider(0, 200, value=10, step=5, onChange=lambda value: received.append(float(value))).codaroDescriptor()
4748

4849
assert descriptor["component"] == "slider"
4950
assert descriptor["min"] == 0
@@ -56,7 +57,7 @@ def testSliderEventsBindWithMinMaxStep() -> None:
5657
def testDropdownDefaultSelectsFirstOption() -> None:
5758
setUp()
5859
received: list[str] = []
59-
descriptor = ui.dropdown(["red", "green", "blue"], onChange=lambda value: received.append(value))
60+
descriptor = ui.dropdown(["red", "green", "blue"], onChange=lambda value: received.append(value)).codaroDescriptor()
6061

6162
assert descriptor["value"] == "red"
6263
assert descriptor["options"] == ["red", "green", "blue"]

0 commit comments

Comments
 (0)