Skip to content

Commit e54d1d0

Browse files
authored
Merge pull request #7 from disguise-one/jupyter-support
Jupyter support
2 parents 2b03a13 + e1f62ea commit e54d1d0

6 files changed

Lines changed: 141 additions & 4 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ jobs:
1515
python-version: ["3.11", "3.12", "3.13"]
1616

1717
steps:
18-
- uses: actions/checkout@v4
18+
- uses: actions/checkout@v6
1919

2020
- name: Install uv
21-
uses: astral-sh/setup-uv@v5
21+
uses: astral-sh/setup-uv@v7
2222
with:
2323
enable-cache: true
2424

.github/workflows/test-publish.yml

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
name: Publish to Test PyPI
2+
3+
on:
4+
workflow_dispatch:
5+
6+
permissions:
7+
contents: read
8+
id-token: write # Required for trusted publishing
9+
10+
jobs:
11+
test-publish:
12+
runs-on: ubuntu-latest
13+
14+
steps:
15+
- name: Checkout repository
16+
uses: actions/checkout@v6
17+
18+
- name: Set up Python
19+
uses: actions/setup-python@v6
20+
with:
21+
python-version: '3.12' # tomllib requires >= 3.11
22+
23+
- name: Install uv
24+
uses: astral-sh/setup-uv@v7
25+
26+
- name: Append .dev suffix for unique Test PyPI versions
27+
run: |
28+
python -c "
29+
import tomllib, pathlib, re
30+
path = pathlib.Path('pyproject.toml')
31+
text = path.read_text()
32+
data = tomllib.loads(text)
33+
version = data['project']['version']
34+
dev_version = f'{version}.dev${{ github.run_number }}'
35+
# Only replace the version inside the [project] section to avoid
36+
# accidentally matching a version key in [tool.*] sections.
37+
def replace_in_project_section(text, old_ver, new_ver):
38+
project_match = re.search(r'^\[project\]', text, re.MULTILINE)
39+
if not project_match:
40+
raise RuntimeError('[project] section not found in pyproject.toml')
41+
start = project_match.start()
42+
# Find the next top-level section header or end of file
43+
next_section = re.search(r'^\[(?!project[.\]])', text[start+1:], re.MULTILINE)
44+
end = (start + 1 + next_section.start()) if next_section else len(text)
45+
section = text[start:end]
46+
section = re.sub(
47+
r'(version\s*=\s*\")' + re.escape(old_ver) + r'\"',
48+
r'\g<1>' + new_ver + '\"',
49+
section, count=1,
50+
)
51+
return text[:start] + section + text[end:]
52+
text = replace_in_project_section(text, version, dev_version)
53+
path.write_text(text)
54+
print(f'Version set to {dev_version}')
55+
"
56+
57+
- name: Build package
58+
run: uv build
59+
60+
- name: Publish to Test PyPI
61+
uses: pypa/gh-action-pypi-publish@release/v1
62+
with:
63+
repository-url: https://test.pypi.org/legacy/
64+
skip-existing: true

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111
- **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.
1212
- `registered_modules` tracking on session instances prevents duplicate registration calls.
13+
- **Jupyter notebook support**: `@d3function` now automatically replaces a previously registered function when the same name is re-registered in the same module, with a warning log. This enables iterative workflows in Jupyter notebooks where cells are re-executed.
1314

1415
### Changed
1516
- `d3_api_plugin` has been renamed to `d3_api_execute`.
1617
- `d3_api_aplugin` has been renamed to `d3_api_aexecute`.
1718
- `context_modules` parameter type updated from `list[str]` to `set[str]` on `D3Session`, `D3AsyncSession`, and `D3SessionBase`.
1819
- Updated documentation to reflect `pystub` proxy support.
20+
- Bumped `actions/checkout` to v6 and `astral-sh/setup-uv` to v7 in CI.
21+
- Added Test PyPI publish workflow (`test-publish.yml`) for dev version releases.
1922

2023
## [1.2.0] - 2025-12-02
2124

src/designer_plugin/d3sdk/function.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import ast
77
import functools
88
import inspect
9+
import logging
910
import textwrap
1011
from collections import defaultdict
1112
from collections.abc import Callable
@@ -24,6 +25,8 @@
2425
RegisterPayload,
2526
)
2627

28+
logger = logging.getLogger(__name__)
29+
2730

2831
###############################################################################
2932
# Plugin function related implementations
@@ -253,6 +256,16 @@ def __init__(self, module_name: str, func: Callable[P, T]):
253256

254257
super().__init__(func)
255258

259+
# Update the function in case the function was updated in the same session.
260+
# For example, jupyter notebook server can be running, but function signature can
261+
# change constantly.
262+
if self in D3Function._available_d3functions[module_name]:
263+
logger.debug(
264+
"Function '%s' in module '%s' is being replaced.",
265+
self.name,
266+
module_name,
267+
)
268+
D3Function._available_d3functions[module_name].discard(self)
256269
D3Function._available_d3functions[module_name].add(self)
257270

258271
def __eq__(self, other: object) -> bool:

src/designer_plugin/d3sdk/session.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def __init__(
6161
Args:
6262
hostname: The hostname of the Designer instance.
6363
port: The port number of the Designer instance.
64-
context_modules: Optional list of module names to register when entering session context.
64+
context_modules: Optional set of module names to register when entering session context.
6565
"""
6666
super().__init__(hostname, port, context_modules or set())
6767

@@ -198,7 +198,7 @@ def __init__(
198198
Args:
199199
hostname: The hostname of the Designer instance.
200200
port: The port number of the Designer instance.
201-
context_modules: Optional list of module names to register when entering session context.
201+
context_modules: Optional set of module names to register when entering session context.
202202
"""
203203
super().__init__(hostname, port, context_modules or set())
204204

tests/test_core.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
Copyright (c) 2025 Disguise Technologies ltd
44
"""
55

6+
import logging
7+
68
import pytest
79

810
from designer_plugin.d3sdk.function import (
@@ -329,6 +331,61 @@ def test_inequality_different_functions(self):
329331

330332

331333

334+
class TestD3FunctionReplacement:
335+
"""Test that re-registering a D3Function with the same name replaces the old one."""
336+
337+
def test_reregister_replaces_function(self):
338+
"""Re-registering a function with the same name should replace it in the set."""
339+
module = "test_replace_module"
340+
D3Function._available_d3functions[module].clear()
341+
342+
@d3function(module)
343+
def my_func(a: int) -> int:
344+
return a
345+
346+
@d3function(module)
347+
def my_func(a: int, b: int) -> int: # noqa: F811
348+
return a + b
349+
350+
funcs = D3Function._available_d3functions[module]
351+
matching = [f for f in funcs if f.name == "my_func"]
352+
assert len(matching) == 1
353+
assert matching[0].function_info.args == ["a", "b"]
354+
355+
def test_reregister_logs_debug(self, caplog):
356+
"""Re-registering should log a debug message."""
357+
module = "test_replace_warn_module"
358+
D3Function._available_d3functions[module].clear()
359+
360+
@d3function(module)
361+
def warn_func() -> None:
362+
pass
363+
364+
with caplog.at_level(logging.DEBUG, logger="designer_plugin.d3sdk.function"):
365+
@d3function(module)
366+
def warn_func() -> int: # noqa: F811
367+
return 1
368+
369+
assert any("warn_func" in msg and "being replaced" in msg for msg in caplog.messages)
370+
371+
def test_set_size_unchanged_after_replacement(self):
372+
"""The function set size should stay the same after replacement."""
373+
module = "test_replace_size_module"
374+
D3Function._available_d3functions[module].clear()
375+
376+
@d3function(module)
377+
def size_func(x: int) -> int:
378+
return x
379+
380+
assert len(D3Function._available_d3functions[module]) == 1
381+
382+
@d3function(module)
383+
def size_func(x: int, y: int) -> int: # noqa: F811
384+
return x + y
385+
386+
assert len(D3Function._available_d3functions[module]) == 1
387+
388+
332389
class TestD3PythonScript:
333390
def test_d3pythonscript_decorator(self):
334391
@d3pythonscript

0 commit comments

Comments
 (0)