Skip to content

Commit 2b03a13

Browse files
authored
Merge pull request #6 from disguise-one/add-lazy-module-register
Add register module in lazy manner
2 parents 4ea618a + 3c8c0ac commit 2b03a13

File tree

5 files changed

+200
-13
lines changed

5 files changed

+200
-13
lines changed

.github/workflows/ci.yml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,17 @@ name: CI
22

33
on:
44
push:
5-
branches: [main]
5+
branches: [main, dev]
66
pull_request:
7-
branches: [main]
7+
branches: [main, dev]
88

99
jobs:
1010
test:
1111
runs-on: ubuntu-latest
1212
strategy:
13+
fail-fast: false
1314
matrix:
14-
python-version: ["3.11"]
15+
python-version: ["3.11", "3.12", "3.13"]
1516

1617
steps:
1718
- uses: actions/checkout@v4

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [1.3.0] - 2026-01-06
99

10+
### Added
11+
- **Lazy module registration**: `D3Session.execute()` and `D3AsyncSession.execute()` now automatically register a `@d3function` module on first use, eliminating the need to declare all modules in `context_modules` upfront.
12+
- `registered_modules` tracking on session instances prevents duplicate registration calls.
13+
1014
### Changed
1115
- `d3_api_plugin` has been renamed to `d3_api_execute`.
1216
- `d3_api_aplugin` has been renamed to `d3_api_aexecute`.
17+
- `context_modules` parameter type updated from `list[str]` to `set[str]` on `D3Session`, `D3AsyncSession`, and `D3SessionBase`.
1318
- Updated documentation to reflect `pystub` proxy support.
1419

1520
## [1.2.0] - 2025-12-02

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ The Functional API offers two decorators: `@d3pythonscript` and `@d3function`:
167167
- **`@d3function`**:
168168
- Must be registered on Designer before execution.
169169
- Functions decorated with the same `module_name` are grouped together and can call each other, enabling function chaining and code reuse.
170-
- Registration is automatic when you pass module names to the session context manager (e.g., `D3AsyncSession('localhost', 80, ["mymodule"])`). If you don't provide module names, no registration occurs.
170+
- Registration happens automatically on the first call to `execute()` or `rpc()` that references the module — no need to declare modules upfront. You can also pre-register specific modules by passing them to the session context manager (e.g., `D3AsyncSession('localhost', 80, {"mymodule"})`).
171171

172172
### Session API Methods
173173

@@ -209,11 +209,11 @@ def my_time() -> str:
209209
return str(datetime.datetime.now())
210210

211211
# Usage with async session
212-
async with D3AsyncSession('localhost', 80, ["mymodule"]) as session:
212+
async with D3AsyncSession('localhost', 80) as session:
213213
# d3pythonscript: no registration needed
214214
await session.rpc(rename_surface.payload("surface 1", "surface 2"))
215215

216-
# d3function: registered automatically via context manager
216+
# d3function: module is registered automatically on first call
217217
time: str = await session.rpc(
218218
rename_surface_get_time.payload("surface 1", "surface 2"))
219219

@@ -226,7 +226,7 @@ async with D3AsyncSession('localhost', 80, ["mymodule"]) as session:
226226

227227
# Sync usage
228228
from designer_plugin.d3sdk import D3Session
229-
with D3Session('localhost', 80, ["mymodule"]) as session:
229+
with D3Session('localhost', 80) as session:
230230
session.rpc(rename_surface.payload("surface 1", "surface 2"))
231231
```
232232

src/designer_plugin/d3sdk/session.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
class D3SessionBase:
3030
"""Base class for Designer session management."""
3131

32-
def __init__(self, hostname: str, port: int, context_modules: list[str]) -> None:
32+
def __init__(self, hostname: str, port: int, context_modules: set[str]) -> None:
3333
"""Initialize base session with connection details and module context.
3434
3535
Args:
@@ -39,7 +39,8 @@ def __init__(self, hostname: str, port: int, context_modules: list[str]) -> None
3939
"""
4040
self.hostname: str = hostname
4141
self.port: int = port
42-
self.context_modules: list[str] = context_modules
42+
self.context_modules: set[str] = context_modules
43+
self.registered_modules: set[str] = set()
4344

4445

4546
class D3Session(D3SessionBase):
@@ -53,7 +54,7 @@ def __init__(
5354
self,
5455
hostname: str,
5556
port: int = D3_PLUGIN_DEFAULT_PORT,
56-
context_modules: list[str] | None = None,
57+
context_modules: set[str] | None = None,
5758
) -> None:
5859
"""Initialize synchronous Designer session.
5960
@@ -62,7 +63,7 @@ def __init__(
6263
port: The port number of the Designer instance.
6364
context_modules: Optional list of module names to register when entering session context.
6465
"""
65-
super().__init__(hostname, port, context_modules or [])
66+
super().__init__(hostname, port, context_modules or set())
6667

6768
def __enter__(self) -> "D3Session":
6869
"""Enter context manager and register all context modules.
@@ -117,6 +118,9 @@ def execute(
117118
Raises:
118119
PluginException: If the plugin execution fails.
119120
"""
121+
if payload.moduleName and payload.moduleName not in self.registered_modules:
122+
self.register_module(payload.moduleName)
123+
120124
return d3_api_execute(self.hostname, self.port, payload, timeout_sec)
121125

122126
def request(self, method: Method, url_endpoint: str, **kwargs: Any) -> Any:
@@ -152,6 +156,7 @@ def register_module(
152156
)
153157
if payload:
154158
d3_api_register_module(self.hostname, self.port, payload, timeout_sec)
159+
self.registered_modules.add(module_name)
155160
return True
156161
return False
157162

@@ -186,7 +191,7 @@ def __init__(
186191
self,
187192
hostname: str,
188193
port: int = D3_PLUGIN_DEFAULT_PORT,
189-
context_modules: list[str] | None = None,
194+
context_modules: set[str] | None = None,
190195
) -> None:
191196
"""Initialize asynchronous Designer session.
192197
@@ -195,7 +200,7 @@ def __init__(
195200
port: The port number of the Designer instance.
196201
context_modules: Optional list of module names to register when entering session context.
197202
"""
198-
super().__init__(hostname, port, context_modules or [])
203+
super().__init__(hostname, port, context_modules or set())
199204

200205
async def __aenter__(self) -> "D3AsyncSession":
201206
"""Enter async context manager and register all context modules.
@@ -270,6 +275,9 @@ async def execute(
270275
Raises:
271276
PluginException: If the plugin execution fails.
272277
"""
278+
if payload.moduleName and payload.moduleName not in self.registered_modules:
279+
await self.register_module(payload.moduleName)
280+
273281
return await d3_api_aexecute(self.hostname, self.port, payload, timeout_sec)
274282

275283
async def register_module(
@@ -294,6 +302,7 @@ async def register_module(
294302
await d3_api_aregister_module(
295303
self.hostname, self.port, payload, timeout_sec
296304
)
305+
self.registered_modules.add(module_name)
297306
return True
298307
return False
299308

tests/test_session.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""
2+
MIT License
3+
Copyright (c) 2025 Disguise Technologies ltd
4+
"""
5+
6+
import asyncio
7+
from unittest.mock import AsyncMock, patch
8+
9+
from designer_plugin.d3sdk.function import d3function
10+
from designer_plugin.d3sdk.session import D3AsyncSession, D3Session
11+
from designer_plugin.models import PluginPayload, PluginResponse, PluginStatus
12+
13+
14+
# Register a module so D3Function._available_d3functions knows about it.
15+
@d3function("lazy_test_module")
16+
def _lazy_test_fn() -> str:
17+
return "hello world"
18+
19+
20+
def _make_response() -> PluginResponse:
21+
return PluginResponse(
22+
status=PluginStatus(code=0, message="OK", details=[]),
23+
returnValue=None,
24+
)
25+
26+
27+
def _module_payload() -> PluginPayload:
28+
"""Payload that references a registered @d3function module."""
29+
return PluginPayload(moduleName="lazy_test_module", script="return _lazy_test_fn()")
30+
31+
32+
def _script_payload() -> PluginPayload:
33+
"""Payload with no module (equivalent to @d3pythonscript)."""
34+
return PluginPayload(moduleName=None, script="return 42")
35+
36+
37+
class TestD3SessionLazyRegistration:
38+
"""Lazy registration behaviour for the synchronous D3Session."""
39+
40+
def test_registered_modules_starts_empty(self):
41+
session = D3Session("localhost", 80)
42+
assert session.registered_modules == set()
43+
44+
def test_module_registered_on_first_execute(self):
45+
session = D3Session("localhost", 80)
46+
with (
47+
patch("designer_plugin.d3sdk.session.d3_api_register_module") as mock_reg,
48+
patch("designer_plugin.d3sdk.session.d3_api_execute", return_value=_make_response()),
49+
):
50+
session.execute(_module_payload())
51+
mock_reg.assert_called_once()
52+
assert "lazy_test_module" in session.registered_modules
53+
54+
def test_module_not_re_registered_on_second_execute(self):
55+
session = D3Session("localhost", 80)
56+
with (
57+
patch("designer_plugin.d3sdk.session.d3_api_register_module") as mock_reg,
58+
patch("designer_plugin.d3sdk.session.d3_api_execute", return_value=_make_response()),
59+
):
60+
session.execute(_module_payload())
61+
session.execute(_module_payload())
62+
mock_reg.assert_called_once()
63+
64+
def test_no_registration_for_script_payload(self):
65+
"""Payloads without a moduleName must never trigger registration."""
66+
session = D3Session("localhost", 80)
67+
with (
68+
patch("designer_plugin.d3sdk.session.d3_api_register_module") as mock_reg,
69+
patch("designer_plugin.d3sdk.session.d3_api_execute", return_value=_make_response()),
70+
):
71+
session.execute(_script_payload())
72+
mock_reg.assert_not_called()
73+
74+
def test_context_module_not_re_registered_lazily(self):
75+
"""A module pre-registered via context_modules must not be registered again in execute()."""
76+
with (
77+
patch("designer_plugin.d3sdk.session.d3_api_register_module") as mock_reg,
78+
patch("designer_plugin.d3sdk.session.d3_api_execute", return_value=_make_response()),
79+
):
80+
with D3Session("localhost", 80, {"lazy_test_module"}) as session:
81+
assert "lazy_test_module" in session.registered_modules
82+
session.execute(_module_payload())
83+
mock_reg.assert_called_once() # only from __enter__, not from execute()
84+
85+
def test_registered_modules_updated_after_execute(self):
86+
session = D3Session("localhost", 80)
87+
assert "lazy_test_module" not in session.registered_modules
88+
with (
89+
patch("designer_plugin.d3sdk.session.d3_api_register_module"),
90+
patch("designer_plugin.d3sdk.session.d3_api_execute", return_value=_make_response()),
91+
):
92+
session.execute(_module_payload())
93+
assert "lazy_test_module" in session.registered_modules
94+
95+
96+
class TestD3AsyncSessionLazyRegistration:
97+
"""Lazy registration behaviour for the asynchronous D3AsyncSession."""
98+
99+
def test_registered_modules_starts_empty(self):
100+
session = D3AsyncSession("localhost", 80)
101+
assert session.registered_modules == set()
102+
103+
def test_module_registered_on_first_execute(self):
104+
async def run():
105+
session = D3AsyncSession("localhost", 80)
106+
with (
107+
patch("designer_plugin.d3sdk.session.d3_api_aregister_module", new_callable=AsyncMock) as mock_reg,
108+
patch("designer_plugin.d3sdk.session.d3_api_aexecute", new_callable=AsyncMock) as mock_exec,
109+
):
110+
mock_exec.return_value = _make_response()
111+
await session.execute(_module_payload())
112+
mock_reg.assert_called_once()
113+
assert "lazy_test_module" in session.registered_modules
114+
115+
asyncio.run(run())
116+
117+
def test_module_not_re_registered_on_second_execute(self):
118+
async def run():
119+
session = D3AsyncSession("localhost", 80)
120+
with (
121+
patch("designer_plugin.d3sdk.session.d3_api_aregister_module", new_callable=AsyncMock) as mock_reg,
122+
patch("designer_plugin.d3sdk.session.d3_api_aexecute", new_callable=AsyncMock) as mock_exec,
123+
):
124+
mock_exec.return_value = _make_response()
125+
await session.execute(_module_payload())
126+
await session.execute(_module_payload())
127+
mock_reg.assert_called_once()
128+
129+
asyncio.run(run())
130+
131+
def test_no_registration_for_script_payload(self):
132+
"""Payloads without a moduleName must never trigger registration."""
133+
async def run():
134+
session = D3AsyncSession("localhost", 80)
135+
with (
136+
patch("designer_plugin.d3sdk.session.d3_api_aregister_module", new_callable=AsyncMock) as mock_reg,
137+
patch("designer_plugin.d3sdk.session.d3_api_aexecute", new_callable=AsyncMock) as mock_exec,
138+
):
139+
mock_exec.return_value = _make_response()
140+
await session.execute(_script_payload())
141+
mock_reg.assert_not_called()
142+
143+
asyncio.run(run())
144+
145+
def test_context_module_not_re_registered_lazily(self):
146+
"""A module pre-registered via context_modules must not be registered again in execute()."""
147+
async def run():
148+
with (
149+
patch("designer_plugin.d3sdk.session.d3_api_aregister_module", new_callable=AsyncMock) as mock_reg,
150+
patch("designer_plugin.d3sdk.session.d3_api_aexecute", new_callable=AsyncMock) as mock_exec,
151+
):
152+
mock_exec.return_value = _make_response()
153+
async with D3AsyncSession("localhost", 80, {"lazy_test_module"}) as session:
154+
assert "lazy_test_module" in session.registered_modules
155+
await session.execute(_module_payload())
156+
mock_reg.assert_called_once()
157+
158+
asyncio.run(run())
159+
160+
def test_registered_modules_updated_after_execute(self):
161+
async def run():
162+
session = D3AsyncSession("localhost", 80)
163+
assert "lazy_test_module" not in session.registered_modules
164+
with (
165+
patch("designer_plugin.d3sdk.session.d3_api_aregister_module", new_callable=AsyncMock),
166+
patch("designer_plugin.d3sdk.session.d3_api_aexecute", new_callable=AsyncMock) as mock_exec,
167+
):
168+
mock_exec.return_value = _make_response()
169+
await session.execute(_module_payload())
170+
assert "lazy_test_module" in session.registered_modules
171+
172+
asyncio.run(run())

0 commit comments

Comments
 (0)