-
Notifications
You must be signed in to change notification settings - Fork 0
Detect the used python package and add it when register the python automatically #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: jupyter-support
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, let's examine the file and the specific lines
head -540 src/designer_plugin/d3sdk/ast_utils.py | tail -30Repository: 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.pyRepository: 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.pyRepository: 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")
EOFRepository: 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 -20Repository: 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}")
EOFRepository: disguise-one/python-plugin Length of output: 875 Fix dotted import binding to match Python's import semantics. When 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 |
||
| 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()) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: disguise-one/python-plugin
Length of output: 2993
🏁 Script executed:
Repository: disguise-one/python-plugin
Length of output: 2479
🏁 Script executed:
Repository: disguise-one/python-plugin
Length of output: 1141
Broaden discovery beyond direct module-body imports.
Lines 517–530 only walk
module_tree.bodydirectly, missing imports declared inside the target function itself and imports wrapped in module-levelif/tryblocks. Functions can depend on packages through either pattern, but they will not be auto-registered. For example:def my_func(): from pathlib import Path; ...if sys.version_info >= (3, 9): from importlib import xyz🤖 Prompt for AI Agents