Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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.
- `registered_modules` tracking on session instances prevents duplicate registration calls.
- **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.
- **Automatic import detection**: `@d3function` now automatically discovers file-level imports used by the decorated function and includes them in the registered module. In Jupyter notebooks, place imports inside the function body instead.

### Removed
- `add_packages_in_current_file()`: Removed. Imports are now detected automatically by `@d3function`.
- `find_packages_in_current_file()`: Removed. Replaced by `find_imports_for_function()`.

### Changed
- `d3_api_plugin` has been renamed to `d3_api_execute`.
Expand Down
11 changes: 8 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,19 @@ Thank you for your interest in contributing to designer-plugin! This document pr

### Running Tests

Run the full test suite:
Run unit tests (default):
```bash
uv run pytest
```

Run tests with verbose output:
Run integration tests (requires a running d3 instance):
```bash
uv run pytest -v
uv run pytest -m integration
```

Run all tests:
```bash
uv run pytest -m ""
```

Run specific test file:
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,14 @@ The Functional API offers two decorators: `@d3pythonscript` and `@d3function`:
- Functions decorated with the same `module_name` are grouped together and can call each other, enabling function chaining and code reuse.
- 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"})`).

> **Jupyter Notebook:** File-level imports (e.g., `import numpy as np` in a separate cell) cannot be automatically detected. In Jupyter, place any required imports inside the function body itself:
> ```python
> @d3function("mymodule")
> def my_fn():
> import numpy as np
> return np.array([1, 2])
> ```

### Session API Methods

Both `D3AsyncSession` and `D3Session` provide two methods for executing functions:
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"-v",
"-m", "not integration",
"--strict-markers",
"--strict-config",
]
markers = [
"integration: tests that require a running d3 instance",
]

4 changes: 2 additions & 2 deletions src/designer_plugin/d3sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from .client import D3PluginClient
from .function import (
add_packages_in_current_file,
PackageInfo,
d3function,
d3pythonscript,
get_all_d3functions,
Expand All @@ -18,9 +18,9 @@
"D3AsyncSession",
"D3PluginClient",
"D3Session",
"PackageInfo",
"d3pythonscript",
"d3function",
"add_packages_in_current_file",
"get_register_payload",
"get_all_d3functions",
"get_all_modules",
Expand Down
233 changes: 167 additions & 66 deletions src/designer_plugin/d3sdk/ast_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,68 @@
"""

import ast
import functools
import inspect
import textwrap
import types
from collections.abc import Callable
from typing import Any

from pydantic import BaseModel, Field

from designer_plugin.d3sdk.builtin_modules import SUPPORTED_MODULES


###############################################################################
# Package info models
class ImportAlias(BaseModel):
"""Represents a single imported name with an optional alias.

Mirrors the structure of ast.alias for Pydantic compatibility.
"""

name: str = Field(
description="The imported name (e.g., 'Path' in 'from pathlib import Path')"
)
asname: str | None = Field(
default=None,
description="The alias (e.g., 'np' in 'import numpy as np')",
)


class PackageInfo(BaseModel):
"""Structured representation of a Python import statement.

Rendering rules (via to_import_statement using ast.unparse):
- package only → import package
- package + alias → import package as alias
- package + methods → from package import method1, method2
- package + methods w/alias → from package import method1 as alias1
"""

package: str = Field(description="The module/package name to import")
alias: str | None = Field(
default=None,
description="Alias for the package (e.g., 'np' in 'import numpy as np')",
)
methods: list[ImportAlias] = Field(
default=[],
description="Imported names for 'from X import ...' style imports",
)

def to_import_statement(self) -> str:
"""Render back to a Python import statement using ast.unparse."""
node: ast.stmt
if self.methods:
node = ast.ImportFrom(
module=self.package,
names=[ast.alias(name=m.name, asname=m.asname) for m in self.methods],
level=0,
)
else:
node = ast.Import(names=[ast.alias(name=self.package, asname=self.alias)])
return ast.unparse(node)


###############################################################################
# Source code extraction utilities
Expand Down Expand Up @@ -369,94 +426,138 @@ def validate_and_extract_args(


###############################################################################
# Python package finder utility
def find_packages_in_current_file(caller_stack: int = 1) -> list[str]:
"""Find all import statements in the caller's file by inspecting the call stack.
# Function-scoped import extraction utility
def _collect_used_names(func_node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]:
"""Collect all identifier names used inside a function body.

Walks the function's AST body and extracts:
- Simple names (ast.Name nodes, e.g., ``foo`` in ``foo()``)
- Root names of attribute chains (e.g., ``np`` in ``np.array()``)

Args:
func_node: The function AST node to analyse.

Returns:
Set of identifier strings used in the function body.
"""
names: set[str] = set()
for node in ast.walk(func_node):
if isinstance(node, ast.Name):
names.add(node.id)
elif isinstance(node, ast.Attribute):
# Walk down the attribute chain to find the root name
root: ast.expr = node
while isinstance(root, ast.Attribute):
root = root.value
if isinstance(root, ast.Name):
names.add(root.id)
return names


def _is_type_checking_block(node: ast.If) -> bool:
"""Check if an if statement is ``if TYPE_CHECKING:``."""
return isinstance(node.test, ast.Name) and node.test.id == "TYPE_CHECKING"


def _is_builtin_package(module_name: str) -> bool:
"""Check if a module name matches python builtin package."""
return module_name in SUPPORTED_MODULES


@functools.cache
def _get_module_ast(module: types.ModuleType) -> ast.Module | None:
"""Return the parsed AST for *module*, cached by module identity."""
try:
return ast.parse(inspect.getsource(module))
except (OSError, TypeError):
return None


def find_imports_for_function(func: Callable[..., Any]) -> list[PackageInfo]:
"""Extract import statements used by a function from its source file.

This function walks up the call stack to find the module where it was called from,
then parses that module's source code to extract all import statements that are
compatible with Python 2.7 and safe to send to Designer.
Inspects the module containing *func*, parses all top-level imports, then
filters them down to only those whose imported names are actually referenced
inside the function body.

Args:
caller_stack: Number of frames to go up the call stack. Default is 1 (immediate caller).
Use higher values to inspect files further up the call chain.
func: The callable to analyse.

Returns:
Sorted list of unique import statement strings (e.g., "import ast", "from pathlib import Path").
Sorted list of :class:`PackageInfo` objects representing the imports
used by *func*.

Filters applied:
- Excludes imports inside `if TYPE_CHECKING:` blocks (type checking only)
- Excludes imports from the 'd3blobgen' package (client-side only)
- Excludes imports from the 'typing' module (not supported in Python 2.7)
- Excludes imports of this function itself to avoid circular references
- Excludes imports inside ``if TYPE_CHECKING:`` blocks
- Excludes imports from the ``d3blobgen`` package (client-side only)
- Excludes imports from the ``typing`` module (not supported in Python 2.7)
- Only includes imports whose names are actually used in the function body
"""
# Get the this file frame
current_frame: types.FrameType | None = inspect.currentframe()
if not current_frame:
# --- 1. Get the function's module source ---
module = inspect.getmodule(func)
if not module:
return []

# Get the caller's frame (file where this function is called)
caller_frame: types.FrameType | None = current_frame
for _ in range(caller_stack):
if not caller_frame or not caller_frame.f_back:
return []
caller_frame = caller_frame.f_back

if not caller_frame:
module_tree = _get_module_ast(module)
if module_tree is None:
return []

modules: types.ModuleType | None = inspect.getmodule(caller_frame)
if not modules:
# --- 2. Collect names used inside the function body ---
func_source = textwrap.dedent(inspect.getsource(func))
func_tree = ast.parse(func_source)
if not func_tree.body:
return []

source: str = inspect.getsource(modules)

# Parse the source code
tree = ast.parse(source)

# Get the name of this function to filter it out
# For example, we don't want `from core import find_packages_in_current_file`
function_name: str = current_frame.f_code.co_name
# Skip any package from d3blobgen
d3blobgen_package_name: str = "d3blobgen"
# typing not supported in python2.7
typing_package_name: str = "typing"
func_node = func_tree.body[0]
if not isinstance(func_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
return []

def is_type_checking_block(node: ast.If) -> bool:
"""Check if an if statement is 'if TYPE_CHECKING:'"""
return isinstance(node.test, ast.Name) and node.test.id == "TYPE_CHECKING"
used_names = _collect_used_names(func_node)

imports: list[str] = []
for node in tree.body:
# Skip TYPE_CHECKING blocks entirely
if isinstance(node, ast.If) and is_type_checking_block(node):
# --- 3. Parse file-level imports and filter to used ones ---
packages: list[PackageInfo] = []
for node in module_tree.body:
# Skip TYPE_CHECKING blocks
if isinstance(node, ast.If) and _is_type_checking_block(node):
continue

Comment on lines +505 to 523
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Get the full find_imports_for_function implementation
sed -n '495,565p' src/designer_plugin/d3sdk/ast_utils.py | cat -n

Repository: disguise-one/python-plugin

Length of output: 2993


🏁 Script executed:

# Also check the _collect_used_names function to see what it collects
sed -n '440,495p' src/designer_plugin/d3sdk/ast_utils.py | cat -n

Repository: disguise-one/python-plugin

Length of output: 2479


🏁 Script executed:

# Check if there are any function-internal imports or conditional imports in tests
rg "import.*\(" src/designer_plugin/d3sdk/ast_utils.py -A 3 -B 1

Repository: disguise-one/python-plugin

Length of output: 1141


Broaden discovery beyond direct module-body imports.

Lines 517–530 only walk module_tree.body directly, missing imports declared inside the target function itself and imports wrapped in module-level if/try blocks. Functions can depend on packages through either pattern, but they will not be auto-registered. For example:

  • Imports inside the function: def my_func(): from pathlib import Path; ...
  • Conditional imports: if sys.version_info >= (3, 9): from importlib import xyz
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/designer_plugin/d3sdk/ast_utils.py` around lines 505 - 523, The current
import collection only iterates module_tree.body and therefore misses imports
inside the function body and imports nested inside module-level control blocks
(If/Try) or inside the function node itself; update the import-discovery to walk
the entire AST subtree for relevant Import and ImportFrom nodes (e.g., use
ast.walk on module_tree and also inspect func_node for local imports), then
filter those imports by used_names from _collect_used_names and populate
packages (PackageInfo) accordingly; ensure you still skip TYPE_CHECKING blocks
via _is_type_checking_block and preserve existing behavior for async vs sync
function nodes.

if isinstance(node, ast.Import):
imported_modules: list[str] = [alias.name for alias in node.names]
# Skip imports that include d3blobgen
if any(d3blobgen_package_name in module for module in imported_modules):
continue
if any(typing_package_name in module for module in imported_modules):
continue
import_text: str = f"import {', '.join(imported_modules)}"
imports.append(import_text)
for alias in node.names:
if not _is_builtin_package(alias.name):
continue

# The name used in code is the alias if present, otherwise the module name
effective_name = alias.asname if alias.asname else alias.name
if effective_name in used_names:
Comment on lines 524 to +531
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the file and the specific lines
head -540 src/designer_plugin/d3sdk/ast_utils.py | tail -30

Repository: disguise-one/python-plugin

Length of output: 1192


🏁 Script executed:

# Let's see more context around those lines
sed -n '510,540p' src/designer_plugin/d3sdk/ast_utils.py

Repository: disguise-one/python-plugin

Length of output: 1193


🏁 Script executed:

# Check if there are tests for this function
find . -name "*test*" -type f -exec grep -l "ast_utils\|_is_builtin_package" {} \;

Repository: disguise-one/python-plugin

Length of output: 118


🏁 Script executed:

# Check the test file for relevant tests
cat ./tests/test_ast_utils.py

Repository: disguise-one/python-plugin

Length of output: 41997


🏁 Script executed:

# Let's also verify Python's import behavior with a quick test
python3 << 'EOF'
import ast

# Test 1: import sklearn.linear_model
code1 = "import sklearn.linear_model"
tree1 = ast.parse(code1)
import_node = tree1.body[0]
alias = import_node.names[0]
print(f"Test 1: {code1}")
print(f"  alias.name: {alias.name}")
print(f"  alias.asname: {alias.asname}")

# What gets bound in the namespace?
import sys
namespace = {}
exec(code1, namespace)
print(f"  Names in namespace: {[k for k in namespace.keys() if not k.startswith('_')]}")

# Test 2: import sklearn.linear_model as slm
code2 = "import sklearn.linear_model as slm"
tree2 = ast.parse(code2)
import_node2 = tree2.body[0]
alias2 = import_node2.names[0]
print(f"\nTest 2: {code2}")
print(f"  alias.name: {alias2.name}")
print(f"  alias.asname: {alias2.asname}")

namespace2 = {}
try:
    exec(code2, namespace2)
    print(f"  Names in namespace: {[k for k in namespace2.keys() if not k.startswith('_')]}")
except:
    print(f"  Names in namespace: Would be ['slm'] if sklearn were available")

# Test 3: What about usage?
code3 = """
import sklearn.linear_model
x = sklearn.linear_model.LogisticRegression()
used_names = set()
for node in ast.walk(tree):
    if isinstance(node, ast.Name):
        used_names.add(node.id)
"""
print(f"\nTest 3: When using sklearn.linear_model in code, what name is accessed?")
print(f"  You would use 'sklearn' first, so 'sklearn' would be in used_names")
print(f"  But the code checks if 'sklearn.linear_model' is in used_names - this would FAIL")

EOF

Repository: disguise-one/python-plugin

Length of output: 338


🏁 Script executed:

# Search for tests that use dotted imports
grep -n "import.*\." ./tests/test_ast_utils.py | head -20

Repository: disguise-one/python-plugin

Length of output: 474


🏁 Script executed:

# Let's check if there are any tests of find_imports_for_function with dotted imports
grep -A 20 "class TestFindImportsForFunction" ./tests/test_ast_utils.py | grep -E "(def test|import.*\.)"

Repository: disguise-one/python-plugin

Length of output: 265


🏁 Script executed:

# Now let's verify Python's actual import binding behavior more thoroughly
python3 << 'EOF'
import ast

# Demonstrate the issue clearly
test_cases = [
    "import os",
    "import os.path",
    "import sklearn.linear_model",
    "import numpy as np",
    "import sklearn.linear_model as skm",
]

print("=== Import Binding Behavior ===\n")
for code in test_cases:
    tree = ast.parse(code)
    alias = tree.body[0].names[0]
    
    # What gets bound?
    if alias.asname:
        bound_name = alias.asname
    else:
        # For dotted imports, Python binds only the first component
        bound_name = alias.name.split(".", 1)[0]
    
    print(f"Code: {code}")
    print(f"  alias.name: {alias.name}")
    print(f"  alias.asname: {alias.asname}")
    print(f"  Name bound in namespace: {bound_name}")
    print()

# Now demonstrate the bug scenario
print("=== Bug Scenario ===\n")
code = """
import sklearn.linear_model
model = sklearn.linear_model.LogisticRegression()
"""

tree = ast.parse(code)
# Get the import
import_node = tree.body[0]
alias = import_node.names[0]

# Current buggy approach
buggy_name = alias.asname if alias.asname else alias.name
print(f"Current code checks for: '{buggy_name}' in used_names")

# Collect used names
used_names = set()
for node in ast.walk(tree):
    if isinstance(node, ast.Name):
        used_names.add(node.id)

print(f"Actually used names: {used_names}")
print(f"Would '{buggy_name}' match? {buggy_name in used_names}")

# Fixed approach
fixed_name = alias.asname or alias.name.split(".", 1)[0]
print(f"\nFixed code checks for: '{fixed_name}' in used_names")
print(f"Would '{fixed_name}' match? {fixed_name in used_names}")
EOF

Repository: disguise-one/python-plugin

Length of output: 875


Fix dotted import binding to match Python's import semantics.

When import sklearn.linear_model is used without an alias, Python binds only sklearn to the namespace, not the full path. The current code incorrectly checks if sklearn.linear_model is in used_names, which will never match the actual binding. This causes valid dotted imports to be silently skipped from dependency detection.

Suggested fix
-                # The name used in code is the alias if present, otherwise the module name
-                effective_name = alias.asname if alias.asname else alias.name
+                # For `import pkg.submodule`, Python binds `pkg` unless an alias is used.
+                effective_name = alias.asname or alias.name.split(".", 1)[0]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/designer_plugin/d3sdk/ast_utils.py` around lines 524 - 531, The import
handling currently treats the full dotted module name (alias.name) as the bound
name, which is wrong for statements like "import sklearn.linear_model" where
Python binds only "sklearn"; update the binding logic in the ast.Import branch
(inside the function processing node/alias) so that effective_name =
alias.asname if alias.asname else alias.name.split(".")[0] (i.e., take the
top-level package when there is no alias), then use that effective_name when
checking membership in used_names and for downstream dependency detection; keep
the existing _is_builtin_package check and other logic unchanged.

packages.append(
PackageInfo(
package=alias.name,
alias=alias.asname,
)
)

elif isinstance(node, ast.ImportFrom):
imported_module: str | None = node.module
imported_names: list[str] = [alias.name for alias in node.names]
if not imported_module:
continue
# Skip imports that include d3blobgen
if d3blobgen_package_name in imported_module:
if not node.module:
continue
elif typing_package_name in imported_module:
continue
# Skip imports that include this function itself
if function_name in imported_names:
if not _is_builtin_package(node.module):
continue

line_text = f"from {imported_module} import {', '.join(imported_names)}"
imports.append(line_text)
# Filter to only methods actually used by the function
matched_methods: list[ImportAlias] = []
for alias in node.names:
effective_name = alias.asname if alias.asname else alias.name
if effective_name in used_names:
matched_methods.append(
ImportAlias(name=alias.name, asname=alias.asname)
)

if matched_methods:
packages.append(
PackageInfo(
package=node.module,
methods=matched_methods,
)
)

return sorted(set(imports))
# Sort by import statement string for deterministic output
return sorted(packages, key=lambda p: p.to_import_statement())
Loading