├── src └── anypython │ ├── __init__.py │ └── nodes.py ├── tests ├── __init__.py ├── pytest.ini ├── conftest.py └── test_anypython.py ├── node.zip ├── resources └── img │ └── comfyUI-anyPython-example-workflow.png ├── MANIFEST.in ├── .github ├── workflows │ ├── validate.yml │ ├── publish_node.yml │ └── build-pipeline.yml └── ISSUE_TEMPLATE.md ├── .pre-commit-config.yaml ├── .vscode └── settings.json ├── .editorconfig ├── __init__.py ├── LICENSE ├── Examples ├── set-image-as-wallaper.py └── stipple-art-generator.py ├── .gitignore ├── pyproject.toml └── README.md /src/anypython/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit test package for anypython.""" 2 | -------------------------------------------------------------------------------- /node.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prabinpebam/anyPython/HEAD/node.zip -------------------------------------------------------------------------------- /resources/img/comfyUI-anyPython-example-workflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prabinpebam/anyPython/HEAD/resources/img/comfyUI-anyPython-example-workflow.png -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = . # Run tests in the current directory 3 | python_files = test_*.py # Run tests in files that start with "test_" 4 | norecursedirs = .. # Don't run tests in the parent directory 5 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | # Add the project root directory to Python path 5 | # This allows the tests to import the project 6 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 7 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | 4 | recursive-exclude * __pycache__ 5 | recursive-exclude * *.py[co] 6 | 7 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 8 | 9 | graft src/anypython/web 10 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate backwards compatibility 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | - main 8 | 9 | jobs: 10 | validate: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: comfy-org/node-diff@main 14 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | # Ruff version. 4 | rev: v0.4.9 5 | hooks: 6 | # Run the linter. 7 | - id: ruff 8 | args: [ --fix ] 9 | # Run the formatter. 10 | - id: ruff-format 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Required - change /PATH/TO to the absolute path to ComfyUI. Windows e.g.: D:/My Folder/ComfyUI/ 3 | // This pulls in ComfyUI Python types for the extension. 4 | "python.analysis.extraPaths": [ 5 | "/PATH/TO/ComfyUI/", 6 | "/PATH/TO/ComfyUI/custom_nodes/" 7 | ], 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * anypython version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | """Top-level package for anyPython.""" 2 | 3 | __all__ = [ 4 | "NODE_CLASS_MAPPINGS", 5 | "NODE_DISPLAY_NAME_MAPPINGS", 6 | "WEB_DIRECTORY", 7 | ] 8 | 9 | __author__ = """Prabin Pebam""" 10 | __email__ = "prabinpebam@gmail.com" 11 | __version__ = "0.0.2" 12 | 13 | from .src.anypython.nodes import NODE_CLASS_MAPPINGS 14 | from .src.anypython.nodes import NODE_DISPLAY_NAME_MAPPINGS 15 | 16 | WEB_DIRECTORY = "./web" 17 | -------------------------------------------------------------------------------- /.github/workflows/publish_node.yml: -------------------------------------------------------------------------------- 1 | name: 📦 Publish to Comfy registry 2 | on: 3 | workflow_dispatch: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | publish-node: 10 | name: Publish Custom Node to registry 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: ♻️ Check out code 14 | uses: actions/checkout@v4 15 | - name: 📦 Publish Custom Node 16 | uses: Comfy-Org/publish-node-action@main 17 | with: 18 | personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} 19 | -------------------------------------------------------------------------------- /tests/test_anypython.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """Tests for `anypython` package.""" 4 | 5 | import pytest 6 | from src.anypython.nodes import Example 7 | 8 | @pytest.fixture 9 | def example_node(): 10 | """Fixture to create an Example node instance.""" 11 | return Example() 12 | 13 | def test_example_node_initialization(example_node): 14 | """Test that the node can be instantiated.""" 15 | assert isinstance(example_node, Example) 16 | 17 | def test_return_types(): 18 | """Test the node's metadata.""" 19 | assert Example.RETURN_TYPES == ("IMAGE",) 20 | assert Example.FUNCTION == "test" 21 | assert Example.CATEGORY == "Example" 22 | -------------------------------------------------------------------------------- /.github/workflows/build-pipeline.yml: -------------------------------------------------------------------------------- 1 | # GitHub CI build pipeline 2 | name: anypython CI build 3 | 4 | on: 5 | pull_request: 6 | branches: 7 | - master 8 | - main 9 | jobs: 10 | build: 11 | runs-on: ${{ matrix.os }} 12 | env: 13 | PYTHONIOENCODING: "utf8" 14 | strategy: 15 | matrix: 16 | os: [ubuntu-latest] 17 | python-version: ["3.12"] 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Python 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install .[dev] 29 | - name: Run Linting 30 | run: | 31 | ruff check . 32 | - name: Run Tests 33 | run: | 34 | pytest tests/ 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025, Prabin Pebam 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /Examples/set-image-as-wallaper.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This code will take an image as input and set it as your Windows wallaper. 3 | Works on Windows 11. Haven't tested on Win 10. 4 | ''' 5 | 6 | 7 | import torch 8 | import torchvision.transforms as transforms 9 | from PIL import Image 10 | import ctypes 11 | import os 12 | import getpass 13 | 14 | # Assuming 'image' is your tensor image object with incorrect dimensions 15 | # Correcting the dimensions if they are in (batch size, height, width, channels) format 16 | if image.shape[1] > 4: # More than 4 channels suggests incorrect dimension order 17 | image = image.permute(0, 3, 1, 2) # Rearrange to (batch size, channels, height, width) 18 | 19 | # Continue with conversion and saving as before 20 | image = image.squeeze(0) # Remove the batch dimension 21 | transform = transforms.ToPILImage() 22 | pil_image = transform(image) 23 | 24 | # Save the PIL image in the specified path with dynamic current user replacement 25 | current_user = getpass.getuser() 26 | image_path = f"C:\\Users\\{current_user}\\Pictures\\Wallpaper\\wallpaper.png" # Changed .bmp to .png 27 | os.makedirs(os.path.dirname(image_path), exist_ok=True) # Create directory if it doesn't exist 28 | pil_image.save(image_path, 'PNG') # Specify PNG format 29 | 30 | # Use ctypes to change the wallpaper on Windows 11 31 | SPI_SETDESKWALLPAPER = 20 32 | ctypes.windll.user32.SystemParametersInfoW(SPI_SETDESKWALLPAPER, 0, image_path, 3) 33 | 34 | print("Successfully changed wallpaper!") 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # OSX useful to ignore 7 | *.DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | 11 | # Thumbnails 12 | ._* 13 | 14 | # Files that might appear in the root of a volume 15 | .DocumentRevisions-V100 16 | .fseventsd 17 | .Spotlight-V100 18 | .TemporaryItems 19 | .Trashes 20 | .VolumeIcon.icns 21 | .com.apple.timemachine.donotpresent 22 | 23 | # Directories potentially created on remote AFP share 24 | .AppleDB 25 | .AppleDesktop 26 | Network Trash Folder 27 | Temporary Items 28 | .apdisk 29 | 30 | # C extensions 31 | *.so 32 | 33 | # Distribution / packaging 34 | .Python 35 | .venv 36 | env/ 37 | venv/ 38 | build/ 39 | develop-eggs/ 40 | dist/ 41 | downloads/ 42 | eggs/ 43 | .eggs/ 44 | lib/ 45 | lib64/ 46 | parts/ 47 | sdist/ 48 | var/ 49 | *.egg-info/ 50 | .installed.cfg 51 | *.egg 52 | 53 | # PyInstaller 54 | # Usually these files are written by a python script from a template 55 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 56 | *.manifest 57 | *.spec 58 | 59 | # Installer logs 60 | pip-log.txt 61 | pip-delete-this-directory.txt 62 | 63 | # Unit test / coverage reports 64 | htmlcov/ 65 | .tox/ 66 | .coverage 67 | .coverage.* 68 | .cache 69 | nosetests.xml 70 | coverage.xml 71 | *,cover 72 | .hypothesis/ 73 | .pytest_cache/ 74 | 75 | # Translations 76 | *.mo 77 | *.pot 78 | 79 | # Django stuff: 80 | *.log 81 | 82 | # Sphinx documentation 83 | docs/_build/ 84 | 85 | # IntelliJ Idea 86 | .idea 87 | *.iml 88 | *.ipr 89 | *.iws 90 | 91 | # PyBuilder 92 | target/ 93 | 94 | # Cookiecutter 95 | output/ 96 | python_boilerplate/ 97 | cookiecutter-pypackage-env/ 98 | 99 | # vscode settings 100 | .history/ 101 | *.code-workspace 102 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=70.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "anyPython" 7 | version = "0.0.3" 8 | description = "A custom node for ComfyUI where you can paste/type any python code and it will get executed when you run the workflow." 9 | authors = [ 10 | {name = "Prabin Pebam", email = "prabinpebam@gmail.com"} 11 | ] 12 | readme = "README.md" 13 | license = {text = "MIT license"} 14 | classifiers = [] 15 | dependencies = [ 16 | 17 | ] 18 | 19 | [project.optional-dependencies] 20 | dev = [ 21 | "bump-my-version", 22 | "coverage", # testing 23 | "mypy", # linting 24 | "pre-commit", # runs linting on commit 25 | "pytest", # testing 26 | "ruff", # linting 27 | ] 28 | 29 | [project.urls] 30 | bugs = "https://github.com/prabinpebam/anyPython/issues" 31 | homepage = "https://github.com/prabinpebam/anyPython" 32 | 33 | 34 | [tool.comfy] 35 | PublisherId = "prabinpebam" 36 | DisplayName = "AnyPython" 37 | Icon = "" 38 | 39 | [tool.setuptools.package-data] 40 | "*" = ["*.*"] 41 | 42 | [tool.pytest.ini_options] 43 | minversion = "8.0" 44 | testpaths = [ 45 | "tests", 46 | ] 47 | 48 | [tool.mypy] 49 | files = "." 50 | 51 | # Use strict defaults 52 | strict = true 53 | warn_unreachable = true 54 | warn_no_return = true 55 | 56 | [[tool.mypy.overrides]] 57 | # Don't require test functions to include types 58 | module = "tests.*" 59 | allow_untyped_defs = true 60 | disable_error_code = "attr-defined" 61 | 62 | [tool.ruff] 63 | # extend-exclude = ["static", "ci/templates"] 64 | line-length = 140 65 | src = ["src", "tests"] 66 | target-version = "py39" 67 | 68 | # Add rules to ban exec/eval 69 | [tool.ruff.lint] 70 | select = [ 71 | "S102", # exec-builtin 72 | "S307", # eval-used 73 | "W293", 74 | "F", # The "F" series in Ruff stands for "Pyflakes" rules, which catch various Python syntax errors and undefined names. 75 | # See all rules here: https://docs.astral.sh/ruff/rules/#pyflakes-f 76 | ] 77 | 78 | [tool.ruff.lint.flake8-quotes] 79 | inline-quotes = "double" 80 | -------------------------------------------------------------------------------- /Examples/stipple-art-generator.py: -------------------------------------------------------------------------------- 1 | import torch 2 | import torchvision.transforms as transforms 3 | from PIL import Image, ImageDraw 4 | import numpy as np 5 | from scipy.spatial import Voronoi 6 | import random 7 | import math 8 | 9 | # --- Conversion Function --- 10 | def tensor_to_pil(image_tensor): 11 | """ 12 | Convert a tensor image to a PIL image. 13 | If the tensor is of shape (batch, height, width, channels) (i.e. channels in dim 1 > 4), 14 | it is permuted to (batch, channels, height, width) and the batch dimension is removed. 15 | """ 16 | if image_tensor.dim() == 4: 17 | # If more than 4 channels in the second dimension, assume (batch, height, width, channels) 18 | if image_tensor.shape[1] > 4: 19 | image_tensor = image_tensor.permute(0, 3, 1, 2) 20 | image_tensor = image_tensor.squeeze(0) # Remove the batch dimension 21 | transform = transforms.ToPILImage() 22 | return transform(image_tensor) 23 | 24 | # --- Utility Functions --- 25 | def get_brightness(x, y, img_array): 26 | """ 27 | Calculate brightness using standard luminance weights. 28 | """ 29 | h, w = img_array.shape[0], img_array.shape[1] 30 | xi = int(np.clip(x, 0, w - 1)) 31 | yi = int(np.clip(y, 0, h - 1)) 32 | r, g, b = img_array[yi, xi, :3] 33 | return 0.2126 * r + 0.7152 * g + 0.0722 * b 34 | 35 | def point_in_poly(x, y, poly): 36 | # Ray-casting algorithm for point-in-polygon test 37 | inside = False 38 | n = len(poly) 39 | p1x, p1y = poly[0] 40 | for i in range(1, n + 1): 41 | p2x, p2y = poly[i % n] 42 | if min(p1y, p2y) < y <= max(p1y, p2y): 43 | if p1y != p2y: 44 | xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x 45 | else: 46 | xinters = p1x 47 | if x <= xinters: 48 | inside = not inside 49 | p1x, p1y = p2x, p2y 50 | return inside 51 | 52 | def voronoi_finite_polygons_2d(vor, radius=None): 53 | """ 54 | Reconstruct infinite Voronoi regions in 2D to finite regions. 55 | Adapted from: https://stackoverflow.com/a/20678647/3357935 56 | """ 57 | if vor.points.shape[1] != 2: 58 | raise ValueError("Requires 2D input") 59 | new_regions = [] 60 | new_vertices = vor.vertices.tolist() 61 | 62 | center = vor.points.mean(axis=0) 63 | if radius is None: 64 | radius = vor.points.ptp().max() * 2 65 | 66 | # Map ridge vertices to all ridges for each point 67 | all_ridges = {} 68 | for (p1, p2), (v1, v2) in zip(vor.ridge_points, vor.ridge_vertices): 69 | all_ridges.setdefault(p1, []).append((p2, v1, v2)) 70 | all_ridges.setdefault(p2, []).append((p1, v1, v2)) 71 | 72 | # Reconstruct each region 73 | for p_idx, region_idx in enumerate(vor.point_region): 74 | vertices = vor.regions[region_idx] 75 | if all(v >= 0 for v in vertices): 76 | new_regions.append(vertices) 77 | continue 78 | 79 | ridges = all_ridges[p_idx] 80 | new_region = [v for v in vertices if v >= 0] 81 | 82 | for p2, v1, v2 in ridges: 83 | if v2 < 0: 84 | v1, v2 = v2, v1 85 | if v1 >= 0: 86 | continue 87 | t = vor.points[p2] - vor.points[p_idx] 88 | t /= np.linalg.norm(t) 89 | n = np.array([-t[1], t[0]]) 90 | midpoint = vor.points[[p_idx, p2]].mean(axis=0) 91 | direction = np.sign(np.dot(midpoint - center, n)) * n 92 | far_point = vor.vertices[v2] + direction * radius 93 | new_vertices.append(far_point.tolist()) 94 | new_region.append(len(new_vertices) - 1) 95 | 96 | vs = np.array([new_vertices[v] for v in new_region]) 97 | angles = np.arctan2(vs[:,1] - center[1], vs[:,0] - center[0]) 98 | new_region = [v for _, v in sorted(zip(angles, new_region))] 99 | new_regions.append(new_region) 100 | 101 | return new_regions, np.array(new_vertices) 102 | 103 | def compute_weighted_centroid(polygon, img_array, sample_count): 104 | """ 105 | Compute a weighted centroid for a polygon via Monte Carlo sampling. 106 | """ 107 | xs, ys = zip(*polygon) 108 | min_x, max_x = min(xs), max(xs) 109 | min_y, max_y = min(ys), max(ys) 110 | 111 | sum_x, sum_y, sum_w = 0.0, 0.0, 0.0 112 | valid_samples = 0 113 | for _ in range(sample_count): 114 | rx = random.uniform(min_x, max_x) 115 | ry = random.uniform(min_y, max_y) 116 | if point_in_poly(rx, ry, polygon): 117 | brightness = get_brightness(rx, ry, img_array) 118 | weight = brightness / 255.0 # For white dots on a dark background 119 | sum_x += rx * weight 120 | sum_y += ry * weight 121 | sum_w += weight 122 | valid_samples += 1 123 | if sum_w == 0 or valid_samples == 0: 124 | return (sum(xs) / len(xs), sum(ys) / len(ys)) 125 | return (sum_x / sum_w, sum_y / sum_w) 126 | 127 | def generate_stipple_art(image_tensor, 128 | stipple_count=1000, 129 | iteration_count=100, 130 | sample_count=30, 131 | white_cutoff=0.5, 132 | min_dot_size=1, 133 | dot_size_range=2, 134 | dot_color=(255, 255, 255), 135 | background_color=(51, 51, 51), 136 | random_seed=42): 137 | """ 138 | Generate stipple art from an input image tensor. 139 | Returns a PIL image. 140 | """ 141 | # Convert the tensor to a PIL image (the conversion function corrects the dimensions) 142 | pil_image = tensor_to_pil(image_tensor) 143 | width, height = pil_image.size 144 | img_array = np.array(pil_image).astype(np.float32) 145 | 146 | random.seed(random_seed) 147 | 148 | # Generate initial stipple points weighted by brightness. 149 | points = [] 150 | while len(points) < stipple_count: 151 | x = random.uniform(0, width) 152 | y = random.uniform(0, height) 153 | brightness = get_brightness(x, y, img_array) 154 | weight = brightness / 255.0 155 | if weight > white_cutoff and random.random() < weight: 156 | points.append((x, y)) 157 | while len(points) < stipple_count: 158 | points.append((random.uniform(0, width), random.uniform(0, height))) 159 | 160 | # Refine points iteratively using weighted Voronoi centroids. 161 | for _ in range(iteration_count): 162 | pts = np.array(points) 163 | vor = Voronoi(pts) 164 | regions, vertices = voronoi_finite_polygons_2d(vor, radius=width+height) 165 | new_points = [] 166 | for region in regions: 167 | poly = vertices[region] 168 | poly[:,0] = np.clip(poly[:,0], 0, width) 169 | poly[:,1] = np.clip(poly[:,1], 0, height) 170 | centroid = compute_weighted_centroid(poly.tolist(), img_array, sample_count) 171 | new_points.append(centroid) 172 | points = new_points 173 | 174 | # Create a new image with a dark background. 175 | stipple_img = Image.new("RGB", (width, height), background_color) 176 | draw = ImageDraw.Draw(stipple_img) 177 | 178 | # Draw dots with radius based on local brightness. 179 | for (x, y) in points: 180 | brightness = get_brightness(x, y, img_array) 181 | norm = brightness / 255.0 # Normalize brightness 182 | dot_radius = min_dot_size + norm * dot_size_range 183 | bbox = [x - dot_radius, y - dot_radius, x + dot_radius, y + dot_radius] 184 | draw.ellipse(bbox, fill=dot_color) 185 | 186 | return stipple_img 187 | 188 | # --- Main Code Execution --- 189 | # The input image is provided as a tensor via the custom node input (variable name: image). 190 | if image is None: 191 | raise ValueError("No image tensor provided") 192 | 193 | # Generate the stipple art as a PIL image. 194 | stipple_art = generate_stipple_art( 195 | image, 196 | stipple_count=1000, 197 | iteration_count=100, 198 | sample_count=30, 199 | white_cutoff=0.5, 200 | min_dot_size=1, 201 | dot_size_range=2, 202 | dot_color=(255, 255, 255), 203 | background_color=(51, 51, 51), 204 | random_seed=42 205 | ) 206 | 207 | # Convert the PIL image back to a tensor so that the output format matches the input. 208 | to_tensor = transforms.ToTensor() 209 | output_tensor = to_tensor(stipple_art) 210 | 211 | # Set the required global outputs. 212 | output = "Stipple art generated successfully." 213 | image = output_tensor # The image is now output as a tensor. 214 | -------------------------------------------------------------------------------- /src/anypython/nodes.py: -------------------------------------------------------------------------------- 1 | """ 2 | @author: prabinpebam 3 | @title: anyPython v0.0.3 4 | @nickname: anyPython 5 | @description: This node can execute Python operations with user-confirmed risk management 6 | """ 7 | 8 | import numpy as np 9 | from PIL import Image 10 | import cv2 11 | from typing import Dict, Any, Callable, Optional, Union, List 12 | import inspect 13 | import re 14 | import importlib 15 | import json 16 | import tempfile 17 | import os 18 | import runpy 19 | import sys 20 | import io 21 | 22 | class anyPython: 23 | # Dictionary of risky operations and their warning messages 24 | RISKY_OPERATIONS = { 25 | 'os': { 26 | 'risk_level': 'HIGH', 27 | 'warning': 'This module can access and modify system files and directories', 28 | 'operations': ['remove', 'rmdir', 'makedirs', 'system', 'popen', 'access'] 29 | }, 30 | 'sys': { 31 | 'risk_level': 'HIGH', 32 | 'warning': 'This module can modify Python runtime environment', 33 | 'operations': ['exit', 'modules', 'path'] 34 | }, 35 | 'subprocess': { 36 | 'risk_level': 'CRITICAL', 37 | 'warning': 'This module can execute system commands', 38 | 'operations': ['run', 'call', 'Popen'] 39 | }, 40 | 'shutil': { 41 | 'risk_level': 'HIGH', 42 | 'warning': 'This module can perform file operations', 43 | 'operations': ['rmtree', 'move', 'copy', 'copyfile'] 44 | }, 45 | 'socket': { 46 | 'risk_level': 'HIGH', 47 | 'warning': 'This module can create network connections', 48 | 'operations': ['socket', 'connect', 'bind', 'listen'] 49 | }, 50 | 'requests': { 51 | 'risk_level': 'MEDIUM', 52 | 'warning': 'This module can make HTTP requests', 53 | 'operations': ['get', 'post', 'put', 'delete'] 54 | }, 55 | 'ctypes': { 56 | 'risk_level': 'CRITICAL', 57 | 'warning': 'This module can access system-level functions', 58 | 'operations': ['windll', 'cdll', 'CDLL'] 59 | }, 60 | 'urllib': { 61 | 'risk_level': 'MEDIUM', 62 | 'warning': 'This module can make network requests', 63 | 'operations': ['urlopen', 'Request'] 64 | }, 65 | 'sqlite3': { 66 | 'risk_level': 'MEDIUM', 67 | 'warning': 'This module can perform database operations', 68 | 'operations': ['connect', 'execute'] 69 | }, 70 | 'pickle': { 71 | 'risk_level': 'HIGH', 72 | 'warning': 'This module can deserialize Python objects (potential security risk)', 73 | 'operations': ['load', 'loads'] 74 | }, 75 | 'pptx': { 76 | 'risk_level': 'LOW', 77 | 'warning': 'This module can create and modify PowerPoint files', 78 | 'operations': ['Presentation'] 79 | }, 80 | 'win32api': { 81 | 'risk_level': 'CRITICAL', 82 | 'warning': 'This module can access Windows API functions', 83 | 'operations': ['SendMessage', 'RegOpenKey'] 84 | }, 85 | 'getpass': { 86 | 'risk_level': 'MEDIUM', 87 | 'warning': 'This module can access user information', 88 | 'operations': ['getuser'] 89 | } 90 | } 91 | 92 | @classmethod 93 | def INPUT_TYPES(cls): 94 | return { 95 | "required": { 96 | "code": ("STRING", {"multiline": True, "default": "print(variable)"}), 97 | "confirm_risks": ("BOOLEAN", {"default": False, "label": "I understand and accept the risks"}), 98 | }, 99 | "optional": { 100 | "variable": ("STRING", {"multiline": True, "default": "5"}), 101 | "image": ("IMAGE",), 102 | } 103 | } 104 | 105 | RETURN_TYPES = ("STRING", "IMAGE",) 106 | FUNCTION = "execute_code" 107 | CATEGORY = "🚀 Any Python" 108 | 109 | def _analyze_code_risks(self, code: str) -> List[Dict[str, str]]: 110 | """Analyze code for potential risks""" 111 | risks = [] 112 | 113 | # Check for imports 114 | import_pattern = r'^import\s+(\w+(?:\.\w+)*)|^from\s+(\w+(?:\.\w+)*)\s+import\s+(.+)$' 115 | for line in code.split('\n'): 116 | line = line.strip() 117 | match = re.match(import_pattern, line) 118 | if match: 119 | module_name = match.group(1) or match.group(2) 120 | base_module = module_name.split('.')[0] 121 | if base_module in self.RISKY_OPERATIONS: 122 | risks.append({ 123 | 'module': base_module, 124 | 'risk_level': self.RISKY_OPERATIONS[base_module]['risk_level'], 125 | 'warning': self.RISKY_OPERATIONS[base_module]['warning'] 126 | }) 127 | 128 | # Check for risky operations 129 | for module, info in self.RISKY_OPERATIONS.items(): 130 | for operation in info['operations']: 131 | if re.search(rf'\b{module}\.{operation}\b', code): 132 | risks.append({ 133 | 'module': module, 134 | 'operation': operation, 135 | 'risk_level': info['risk_level'], 136 | 'warning': info['warning'] 137 | }) 138 | 139 | return risks 140 | 141 | def execute_code(self, code: str, confirm_risks: bool, variable: Optional[str] = None, image: Optional[np.ndarray] = None) -> tuple: 142 | """Execute code using a temporary module file and runpy.run_path for risk-free execution. 143 | Captures both explicit output variables and printed output. 144 | """ 145 | try: 146 | # Analyze risks 147 | risks = self._analyze_code_risks(code) 148 | 149 | # If risks are found and not confirmed, return warning 150 | if risks and not confirm_risks: 151 | risk_message = "SECURITY RISKS DETECTED:\n\n" 152 | for risk in risks: 153 | risk_message += f"⚠️ {risk['risk_level']} RISK: {risk['module']}\n" 154 | risk_message += f" Warning: {risk['warning']}\n" 155 | if 'operation' in risk: 156 | risk_message += f" Risky operation detected: {risk['operation']}\n" 157 | risk_message += "\nTo proceed, please check 'I understand and accept the risks'" 158 | return risk_message, image 159 | 160 | # Set up an initial globals dictionary with provided inputs. 161 | globals_dict = {'variable': variable, 'image': image} 162 | 163 | # Process imports from the code 164 | import_pattern = r'^import\s+(\w+(?:\.\w+)*)|^from\s+(\w+(?:\.\w+)*)\s+import\s+(.+)$' 165 | for line in code.split('\n'): 166 | line = line.strip() 167 | match = re.match(import_pattern, line) 168 | if match: 169 | try: 170 | if match.group(1): # Simple import 171 | module_name = match.group(1) 172 | globals_dict[module_name.split('.')[-1]] = importlib.import_module(module_name) 173 | elif match.group(2) and match.group(3): # From import 174 | module_name = match.group(2) 175 | imports = [i.strip() for i in match.group(3).split(',')] 176 | module = importlib.import_module(module_name) 177 | for imp in imports: 178 | if imp == '*': 179 | continue # Skip star imports 180 | globals_dict[imp] = getattr(module, imp) 181 | except ImportError as e: 182 | return f"Import Error: {str(e)}", image 183 | 184 | # Write the user's code to a temporary Python file. 185 | with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as tmp_file: 186 | tmp_file.write(code) 187 | tmp_filename = tmp_file.name 188 | 189 | # Redirect stdout to capture printed output. 190 | old_stdout = sys.stdout 191 | sys.stdout = io.StringIO() 192 | 193 | # Execute the temporary file as a module using runpy. 194 | result_globals = runpy.run_path(tmp_filename, init_globals=globals_dict) 195 | 196 | # Get any printed output. 197 | printed_output = sys.stdout.getvalue() 198 | sys.stdout = old_stdout 199 | 200 | # Clean up the temporary file. 201 | os.remove(tmp_filename) 202 | 203 | # Retrieve explicit output from the executed module. 204 | user_output = result_globals.get('output', None) 205 | # If no explicit output variable, use captured printed output. 206 | if user_output is None: 207 | user_output = printed_output.strip() 208 | 209 | # Retrieve image output from the executed module. 210 | result_image = result_globals.get('image', image) 211 | 212 | # If both outputs are empty, default to a success message. 213 | if not user_output and result_image is None: 214 | user_output = "Code executed successfully" 215 | 216 | return str(user_output), result_image 217 | 218 | except Exception as e: 219 | return f"Error: {str(e)}", image 220 | 221 | # Node class mappings 222 | NODE_CLASS_MAPPINGS = { 223 | "Any Python": anyPython 224 | } 225 | 226 | NODE_DISPLAY_NAME_MAPPINGS = { 227 | "Any Python": "anyPython" 228 | } 229 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚀 anyPython 0.0.3 2 | A custom node for ComfyUI where you can paste/type any python code and it will get executed when you run the workflow. 3 | 4 | ## 0.0.3 changes 5 | Updated for the node to work with the latest ComfyUI version as of 14th Feb 2025. 6 | Avoided using exec and eval fucntion to meet security requirements of ComfyUI. 7 | Introduced a toggle button that restricts risky code by default. Risk message is given. User have to explicitly turn it on to run risky code. 8 | 9 | 10 | ## Why this node? 11 | ComfyUI has a lot of custom nodes but you will still have a special use case for which there's no custom nodes available. You don't need to know how to write python code yourself. Use a LLM to generate the code you need, paste it in the node and voila!! you have your custom node which does exactly what you need. 12 | 13 | Here's some example use cases for which I've used this node. 14 | - For a given API, I want to get the json data and get a specific value. eg. I want the current temperature of a place using the weather API. 15 | - I want the current date, time, day etc. 16 | - For a given image, I want to calculate the dominant color and calculate a foreground color with proper contrast that can be used as font color to overlay on the image. 17 | - For a given RGB color, convert it into hex value. 18 | - For a given url, return the html 19 | - For a given html, return all the text content with markdown syntax. 20 | - set a given image as the wallpaper of my Win 11 PC. 21 | - Fetch the text content from a url, summarize the text using a LLM and put the summary in a powerpoint file. 22 | 23 | Let me know in the discussion how you would use this node. 24 | 25 | # TLDR: Writing Python Code for anyPython 26 | 27 | ## Key Points: 28 | - **Execution Environment:** 29 | Your code runs as a module with pre-defined globals: 30 | - `variable` (string, optional) 31 | - `image` (tensor) 32 | - `confirm_risks` (boolean) 33 | 34 | - **Input & Output Handling:** 35 | - **Input Image:** Provided as a tensor. 36 | → Convert to a PIL image for processing if needed, then convert back to a tensor. 37 | - **Outputs:** 38 | - Set global `output` (string) or use `print()` for text output. 39 | - Set global `image` (tensor) for the image output. 40 | 41 | - **Allowed:** 42 | Standard Python code (functions, loops, imports). 43 | External libraries (note: risky modules require risk confirmation). 44 | 45 | - **Not Allowed:** 46 | Top-level `return` statements. 47 | Modifying system/environment state outside allowed globals. 48 | 49 | ## Example Skeleton: 50 | ```python 51 | # Convert tensor to PIL image (for processing) 52 | pil_img = tensor_to_pil(image) 53 | 54 | # Process the image (e.g., create stipple art) 55 | processed_img = create_stipple_art(pil_img) 56 | 57 | # Convert the processed image back to tensor 58 | output_tensor = transforms.ToTensor()(processed_img) 59 | 60 | # Define outputs 61 | output = "Processing complete." 62 | image = output_tensor 63 | ``` 64 | 65 | 66 | # Guide for Writing Python Code for anyPython 67 | 68 | This guide explains how to write proper Python code to be used in a ComfyUI custom node. It covers how inputs and outputs are defined, what is allowed or not allowed, and provides example snippets. 69 | 70 | --- 71 | 72 | ## 1. Understanding the Node’s Execution Environment 73 | 74 | - **Execution Context:** 75 | Your code is written to a temporary file and executed using `runpy.run_path`. This means it runs as a module (i.e., top-level code) without the typical `if __name__ == '__main__':` guard. 76 | 77 | - **Input Variables:** 78 | The node creates a globals dictionary that includes: 79 | - `variable`: A string value (if provided). 80 | - `image`: An image input, always provided as a tensor. 81 | - `confirm_risks`: A boolean indicating whether you accept potential security risks. 82 | 83 | - **Risk Confirmation:** 84 | The node scans your code for risky operations (e.g., use of `os`, `sys`, or `subprocess`). If risky code is detected and `confirm_risks` is not set to `True`, execution is halted with a warning. 85 | 86 | --- 87 | 88 | ## 2. Defining the Outputs 89 | 90 | The node expects **two outputs**: 91 | 92 | - **String Output:** 93 | - The global variable `output` should hold the string output. 94 | - Alternatively, if `output` is not set, any text printed using `print()` is captured as the output. 95 | 96 | - **Image Output:** 97 | - The global variable `image` should contain the image output. 98 | - **Important:** Since the input image is provided as a tensor, if you process the image as a PIL image, you must convert it back to a tensor before assigning it to `image`. 99 | 100 | --- 101 | 102 | ## 3. What Is Allowed and What Is Not 103 | 104 | ### Allowed: 105 | - **Standard Python Code:** 106 | You can write any valid Python code, define functions, use loops, etc. 107 | - **Setting Globals for Output:** 108 | Assign your final outputs to the global variables `output` and `image`. 109 | - **Printing to Standard Output:** 110 | Using `print("message")` is acceptable; the printed text will be captured if `output` is not defined. 111 | - **Using External Libraries:** 112 | Libraries such as `torch`, `numpy`, `PIL`, etc., can be imported. 113 | *Caution:* Some modules (like `os`, `sys`, `subprocess`) are flagged as risky and require risk confirmation. 114 | 115 | ### Not Allowed / Cautions: 116 | - **Top-Level `return` Statements:** 117 | Do not use a top-level `return` statement in your code. Instead, assign the outputs to the globals. 118 | - **Modifying the Node’s Environment:** 119 | Avoid interfering with the provided globals (other than `output` and `image`) or modifying system-level settings without confirming risks. 120 | - **Ignoring the Input Format:** 121 | The input `image` is always a tensor. Convert it to a PIL image for processing if needed, then convert it back to a tensor before setting the output. 122 | 123 | --- 124 | 125 | ## 4. Suggested Code Structure 126 | 127 | Below is a sample structure for your code: 128 | 129 | ```python 130 | # --- Import necessary libraries --- 131 | import torch 132 | import torchvision.transforms as transforms 133 | from PIL import Image, ImageDraw 134 | import numpy as np 135 | import random 136 | 137 | # --- Helper Function to Convert Tensor to PIL Image --- 138 | def tensor_to_pil(image_tensor): 139 | """ 140 | Convert an image tensor to a PIL image. 141 | If the tensor is in (batch, height, width, channels) format, 142 | it will be permuted to (batch, channels, height, width) and squeezed. 143 | """ 144 | if image_tensor.dim() == 4: 145 | if image_tensor.shape[1] > 4: # Likely (batch, height, width, channels) 146 | image_tensor = image_tensor.permute(0, 3, 1, 2) 147 | image_tensor = image_tensor.squeeze(0) 148 | return transforms.ToPILImage()(image_tensor) 149 | 150 | # --- Example Processing Function (e.g., creating stipple art) --- 151 | def create_stipple_art(pil_img): 152 | width, height = pil_img.size 153 | # Create a new image with a dark background 154 | stipple_img = Image.new("RGB", (width, height), (51, 51, 51)) 155 | draw = ImageDraw.Draw(stipple_img) 156 | 157 | # Example: Draw random white dots on the image 158 | for _ in range(500): 159 | x = random.randint(0, width - 1) 160 | y = random.randint(0, height - 1) 161 | dot_radius = random.randint(1, 3) 162 | draw.ellipse([x - dot_radius, y - dot_radius, x + dot_radius, y + dot_radius], fill=(255, 255, 255)) 163 | 164 | return stipple_img 165 | 166 | # --- Main Code Execution --- 167 | # The input variables 'variable' and 'image' are provided automatically. 168 | # 'image' is a tensor. Convert it to a PIL image for processing. 169 | if image is None: 170 | raise ValueError("No image tensor provided") 171 | 172 | # Convert tensor to PIL image for processing. 173 | pil_img = tensor_to_pil(image) 174 | 175 | # Process the image (for example, create stipple art) 176 | processed_img = create_stipple_art(pil_img) 177 | 178 | # Convert the processed PIL image back to a tensor. 179 | to_tensor = transforms.ToTensor() 180 | output_tensor = to_tensor(processed_img) 181 | 182 | # --- Define Global Outputs --- 183 | # For textual output: 184 | output = "Stipple art created successfully." 185 | 186 | # For image output (as a tensor to match the input format): 187 | image = output_tensor 188 | 189 | # Note: 190 | # Do not use a 'return' statement in this code. 191 | ``` 192 | 193 | --- 194 | 195 | ## 5. Key Points Recap 196 | 197 | - **Input Variables:** 198 | - `variable` (string) – optional. 199 | - `image` (tensor) - (Optional) – the image input (must be converted if processing as a PIL image). 200 | - `confirm_risks` (boolean) – This is off by default. The code will not run if risk is detected. Check the string output for risk message. Toggle this on if you still want to run the script. 201 | 202 | - **Output Variables:** 203 | - `output` (string) – if not defined, printed output is captured. 204 | - `image` (tensor) – must match the format of the input image. 205 | 206 | - **Allowed Code:** 207 | - Standard Python code, function definitions, imports (with caution for risky modules), and assignment to globals. 208 | - **Avoid:** Top-level return statements or modifying system state without proper risk confirmation. 209 | 210 | --- 211 | 212 | By following this structure and these guidelines, you can ensure that your Python code will execute properly within the ComfyUI custom node environment with the correct handling of both string and image outputs. 213 | 214 | 215 | ![ComfyUI anyPython example workflow](/resources/img/comfyUI-anyPython-example-workflow.png) 216 | 217 | 218 | ## IMPORTANT! Security 219 | As you might be already thinking, since this can run any python code, it can run malicious codes also. You need to be extremely careful what code is being put in the node. It's also possible that someone might share a complex workflow with this node with malicious scripts already populated in the anyPython node. If you have acquired the comfyUI workflow from someone else you need to be vigilant and review any workflow that involves any anyPython node. Review each anyPython nodes individually. If you don't understand the code, copy it and let a LLM like Copilot or GPT review the code. --------------------------------------------------------------------------------