├── .idea └── .gitignore ├── .gitattributes ├── icons └── blender_icon.ico ├── pythonFiles ├── templates │ ├── script.py │ ├── operator_simple.py │ ├── panel_simple.py │ ├── addons │ │ ├── simple │ │ │ └── __init__.py │ │ └── with_auto_load │ │ │ ├── __init__.py │ │ │ └── auto_load.py │ └── blender_manifest.toml ├── include │ └── blender_vscode │ │ ├── operators │ │ ├── stop_blender.py │ │ ├── __init__.py │ │ ├── addon_update.py │ │ └── script_runner.py │ │ ├── ui.py │ │ ├── log.py │ │ ├── __init__.py │ │ ├── utils.py │ │ ├── environment.py │ │ ├── installation.py │ │ ├── communication.py │ │ └── load_addons.py ├── generate_data.py ├── launch.py └── tests │ └── blender_vscode │ └── test_load_addons.py ├── .gitignore ├── .vscodeignore ├── .vscode ├── extensions.json ├── tasks.json ├── settings.json └── launch.json ├── src ├── paths.ts ├── commands_scripts_data_loader.ts ├── select_utils.ts ├── blender_executable_windows.ts ├── blender_executable_linux.ts ├── commands_new_operator.ts ├── notifications.ts ├── python_debugging.ts ├── addon_folder.ts ├── extension.ts ├── utils.ts ├── commands_scripts.ts ├── commands_new_addon.ts ├── communication.ts └── blender_executable.ts ├── tsconfig.json ├── .github └── workflows │ ├── publish.yml │ └── publish-beta.yml ├── LICENSE ├── DEVELOPMENT.md ├── eslint.config.js ├── generated └── enums.json ├── EXTENSION-SUPPORT.md ├── README.md ├── CHANGELOG.md └── package.json /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behavior to automatically normalize line endings. 2 | * text=auto 3 | 4 | -------------------------------------------------------------------------------- /icons/blender_icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JacquesLucke/blender_vscode/HEAD/icons/blender_icon.ico -------------------------------------------------------------------------------- /pythonFiles/templates/script.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from mathutils import * 3 | 4 | D = bpy.data 5 | C = bpy.context 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | node_modules 3 | .vscode-test/ 4 | *.vsix 5 | npm-debug.log 6 | package-lock.json 7 | __pycache__ 8 | .idea -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | out/test/** 4 | out/**/*.map 5 | src/** 6 | .gitignore 7 | tsconfig.json 8 | vsc-extension-quickstart.md 9 | tslint.json -------------------------------------------------------------------------------- /pythonFiles/templates/operator_simple.py: -------------------------------------------------------------------------------- 1 | class CLASS_NAME(OPERATOR_CLASS): 2 | bl_idname = "IDNAME" 3 | bl_label = "LABEL" 4 | 5 | def execute(self, context): 6 | return {"FINISHED"} 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /pythonFiles/templates/panel_simple.py: -------------------------------------------------------------------------------- 1 | class CLASS_NAME(PANEL_CLASS): 2 | bl_idname = "IDNAME" 3 | bl_label = "LABEL" 4 | bl_space_type = "SPACE_TYPE" 5 | bl_region_type = "REGION_TYPE" 6 | 7 | def draw(self, context): 8 | layout = self.layout 9 | -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/operators/stop_blender.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from ..communication import register_post_action 3 | 4 | 5 | def stop_action(data): 6 | bpy.ops.wm.quit_blender() 7 | 8 | 9 | def register(): 10 | register_post_action("stop", stop_action) 11 | -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/operators/__init__.py: -------------------------------------------------------------------------------- 1 | from . import addon_update 2 | from . import script_runner 3 | from . import stop_blender 4 | 5 | modules = ( 6 | addon_update, 7 | script_runner, 8 | stop_blender, 9 | ) 10 | 11 | 12 | def register(): 13 | for module in modules: 14 | module.register() 15 | -------------------------------------------------------------------------------- /src/paths.ts: -------------------------------------------------------------------------------- 1 | import { join, dirname } from 'path'; 2 | 3 | const mainDir = dirname(__dirname); 4 | export const pythonFilesDir = join(mainDir, 'pythonFiles'); 5 | export const templateFilesDir = join(pythonFilesDir, 'templates'); 6 | export const launchPath = join(pythonFilesDir, 'launch.py'); 7 | export const generatedDir = join(mainDir, 'generated'); 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2020", 5 | "outDir": "out", 6 | "lib": [ 7 | "es2020" 8 | ], 9 | "esModuleInterop": true, 10 | "sourceMap": true, 11 | "rootDir": "src", 12 | 13 | "strict": true, 14 | "noUnusedLocals": true, 15 | "noImplicitReturns": true, 16 | "noFallthroughCasesInSwitch": true 17 | }, 18 | "exclude": [ 19 | "node_modules", 20 | ".vscode-test" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /src/commands_scripts_data_loader.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { generatedDir } from './paths'; 3 | import { readTextFile } from './utils'; 4 | 5 | let enumsPath = path.join(generatedDir, 'enums.json'); 6 | 7 | interface EnumItem { 8 | identifier: string; 9 | name: string; 10 | description: string; 11 | } 12 | 13 | export async function getAreaTypeItems() { 14 | return getGeneratedEnumData('areaTypeItems'); 15 | } 16 | 17 | async function getGeneratedEnumData(identifier: string): Promise { 18 | const text = await readTextFile(enumsPath); 19 | const data = JSON.parse(text); 20 | return data[identifier]; 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off", 11 | "python.linting.enabled": false, 12 | "spellright.language": [ 13 | "en_US" 14 | ], 15 | "spellright.documentTypes": [ 16 | "markdown", 17 | "plaintext" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | 4 | 5 | name: Deploy Extension 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v5 11 | - uses: actions/setup-node@v4 12 | with: 13 | node-version: 22 14 | - run: npm ci 15 | # - name: Publish to Open VSX Registry 16 | # uses: HaaLeo/publish-vscode-extension@v2 17 | # with: 18 | # pat: ${{ secrets.OPEN_VSX_TOKEN }} 19 | - name: Publish to Visual Studio Marketplace 20 | uses: HaaLeo/publish-vscode-extension@v2 21 | with: 22 | pat: ${{ secrets.VS_MARKETPLACE_TOKEN }} 23 | registryUrl: https://marketplace.visualstudio.com -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/ui.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from .communication import get_blender_port, get_debugpy_port, get_editor_address 3 | 4 | 5 | class DevelopmentPanel(bpy.types.Panel): 6 | bl_idname = "DEV_PT_panel" 7 | bl_label = "Development" 8 | bl_space_type = "VIEW_3D" 9 | bl_region_type = "UI" 10 | bl_category = "Dev" 11 | 12 | def draw(self, context): 13 | layout = self.layout 14 | layout.label(text=f"Blender at Port {get_blender_port()}") 15 | layout.label(text=f"debugpy at Port {get_debugpy_port()}") 16 | layout.label(text=f"Editor at Address {get_editor_address()}") 17 | 18 | 19 | classes = (DevelopmentPanel,) 20 | 21 | 22 | def register(): 23 | for cls in classes: 24 | bpy.utils.register_class(cls) 25 | -------------------------------------------------------------------------------- /.github/workflows/publish-beta.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | # push: 4 | # branches: 5 | # - main 6 | 7 | name: Deploy Extension pre-release 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v5 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 22 16 | - run: npm ci 17 | # - name: Publish to Open VSX Registry 18 | # uses: HaaLeo/publish-vscode-extension@v2 19 | # with: 20 | # pat: ${{ secrets.OPEN_VSX_TOKEN }} 21 | # preRelease: true 22 | - name: Publish to Visual Studio Marketplace 23 | uses: HaaLeo/publish-vscode-extension@v2 24 | with: 25 | pat: ${{ secrets.VS_MARKETPLACE_TOKEN }} 26 | registryUrl: https://marketplace.visualstudio.com 27 | preRelease: true -------------------------------------------------------------------------------- /pythonFiles/generate_data.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import json 3 | from pathlib import Path 4 | 5 | output_dir = Path(__file__).parent.parent / "generated" 6 | enums_output_path = output_dir / "enums.json" 7 | 8 | 9 | def insert_enum_data(data, identifier): 10 | type_name, prop_name = identifier.split(".") 11 | enum_name = type_name.lower() + prop_name.title() + "Items" 12 | data[enum_name] = enum_prop_to_dict(type_name, prop_name) 13 | 14 | 15 | def enum_prop_to_dict(type_name, prop_name): 16 | type = getattr(bpy.types, type_name) 17 | prop = type.bl_rna.properties[prop_name] 18 | return enum_items_to_dict(prop.enum_items) 19 | 20 | 21 | def enum_items_to_dict(items): 22 | return [{"identifier": item.identifier, "name": item.name, "description": item.description} for item in items] 23 | 24 | 25 | data = {} 26 | insert_enum_data(data, "Area.type") 27 | 28 | with open(enums_output_path, "w") as f: 29 | f.write(json.dumps(data, indent=2)) 30 | -------------------------------------------------------------------------------- /src/select_utils.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { cancel } from './utils'; 3 | import { QuickPickItem } from 'vscode'; 4 | import { BlenderExecutableData } from './blender_executable'; 5 | 6 | export interface PickItem extends QuickPickItem { 7 | data?: any | (() => Promise), 8 | } 9 | 10 | export async function letUserPickItem(items: PickItem[], placeholder: undefined | string = undefined): Promise { 11 | let quickPick = vscode.window.createQuickPick(); 12 | quickPick.items = items; 13 | quickPick.placeholder = placeholder; 14 | 15 | return new Promise((resolve, reject) => { 16 | quickPick.onDidAccept(() => { 17 | resolve(quickPick.activeItems[0]); 18 | quickPick.hide(); 19 | }); 20 | quickPick.onDidHide(() => { 21 | reject(cancel()); 22 | quickPick.dispose(); 23 | }); 24 | quickPick.show(); 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /pythonFiles/templates/addons/simple/__init__.py: -------------------------------------------------------------------------------- 1 | # This program is free software; you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation; either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful, but 7 | # WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTIBILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 9 | # General Public License for more details. 10 | # 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | 14 | bl_info = { 15 | "name": "ADDON_NAME", 16 | "author": "AUTHOR_NAME", 17 | "description": "", 18 | "blender": (2, 80, 0), 19 | "version": (0, 0, 1), 20 | "location": "", 21 | "warning": "", 22 | "category": "Generic", 23 | } 24 | 25 | 26 | def register(): ... 27 | 28 | 29 | def unregister(): ... 30 | -------------------------------------------------------------------------------- /pythonFiles/launch.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import sys 4 | import json 5 | import traceback 6 | from pathlib import Path 7 | from typing import TYPE_CHECKING 8 | 9 | include_dir = Path(__file__).parent / "include" 10 | sys.path.append(str(include_dir)) 11 | 12 | # Get proper type hinting without impacting runtime 13 | if TYPE_CHECKING: 14 | from .include import blender_vscode 15 | else: 16 | import blender_vscode 17 | 18 | LOG = blender_vscode.log.getLogger() 19 | LOG.info(f"ADDONS_TO_LOAD {json.loads(os.environ['ADDONS_TO_LOAD'])}") 20 | 21 | try: 22 | addons_to_load = [] 23 | for info in json.loads(os.environ["ADDONS_TO_LOAD"]): 24 | addon_info = blender_vscode.AddonInfo(**info) 25 | addon_info.load_dir = Path(addon_info.load_dir) 26 | addons_to_load.append(addon_info) 27 | 28 | blender_vscode.startup( 29 | editor_address=f"http://localhost:{os.environ['EDITOR_PORT']}", 30 | addons_to_load=addons_to_load, 31 | ) 32 | except Exception as e: 33 | if type(e) is not SystemExit: 34 | traceback.print_exc() 35 | sys.exit() 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jacques Lucke 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 | -------------------------------------------------------------------------------- /pythonFiles/templates/addons/with_auto_load/__init__.py: -------------------------------------------------------------------------------- 1 | # This program is free software; you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation; either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful, but 7 | # WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTIBILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 9 | # General Public License for more details. 10 | # 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | 14 | bl_info = { 15 | "name": "ADDON_NAME", 16 | "author": "AUTHOR_NAME", 17 | "description": "", 18 | "blender": (2, 80, 0), 19 | "version": (0, 0, 1), 20 | "location": "", 21 | "warning": "", 22 | "category": "Generic", 23 | } 24 | 25 | from . import auto_load 26 | 27 | auto_load.init() 28 | 29 | 30 | def register(): 31 | auto_load.register() 32 | 33 | 34 | def unregister(): 35 | auto_load.unregister() 36 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Please make sure that your PR works with Blender 2.80: 4 | - That requires you to use python 3.7 and 5 | - Compatible Blender API (do a version check) 6 | - Remember to update [CHANGELOG](./CHANGELOG.md): https://keepachangelog.com/en/1.0.0/ 7 | - Upcoming releases (roadmap) are planned in [milestones](https://github.com/JacquesLucke/blender_vscode/milestones) 8 | - Generally don't commit commented out code unless there is a really good reason 9 | - Prefer comments to be full sentences 10 | 11 | # Structure 12 | 13 | - Blender entrypoint is in `pythonFiles/launch.py` 14 | - VS Code entrypoint is `src/extension.ts`. Refer to VS code docs, there is nothing non standard here. 15 | 16 | # Python guideline 17 | 18 | Use Black formatter with `black --line-length 120` 19 | 20 | ## Python tests 21 | 22 | There is no clear guideline or commitment to testing. 23 | 24 | Some tests are prototyped in `pythonFiles/tests/blender_vscode/test_load_addons.py`. 25 | They should be run outside of Blender what makes them easy to execute, but prone to breaking: there is a lot of patching 26 | for small number of test. 27 | 28 | Run tests: 29 | 30 | ```powershell 31 | pip install pytest 32 | cd pythonFile 33 | $env:PYTHONPATH="./include" # powershell 34 | pytest -s .\tests 35 | ``` 36 | 37 | # Typescript guideline 38 | 39 | Nothing more than `tslint.json`. -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "runtimeExecutable": "${execPath}", 13 | "args": [ 14 | "--extensionDevelopmentPath=${workspaceFolder}" 15 | ], 16 | "outFiles": [ 17 | "${workspaceFolder}/out/**/*.js" 18 | ], 19 | "preLaunchTask": "npm: watch" 20 | }, 21 | { 22 | "name": "Extension Tests", 23 | "type": "extensionHost", 24 | "request": "launch", 25 | "runtimeExecutable": "${execPath}", 26 | "args": [ 27 | "--extensionDevelopmentPath=${workspaceFolder}", 28 | "--extensionTestsPath=${workspaceFolder}/out/test" 29 | ], 30 | "outFiles": [ 31 | "${workspaceFolder}/out/test/**/*.js" 32 | ], 33 | "preLaunchTask": "npm: watch" 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .environment import LOG_LEVEL 4 | 5 | 6 | class ColoredFormatter(logging.Formatter): 7 | white = "\x1b[1;37;20m" 8 | grey = "\x1b[1;38;20m" 9 | yellow = "\x1b[1;33;20m" 10 | red = "\x1b[1;31;20m" 11 | bold_red = "\x1b[1;31;1m" 12 | reset = "\x1b[1;0m" 13 | format = "%(levelname)s: %(message)s (%(filename)s:%(lineno)d)" 14 | 15 | FORMATS = { 16 | logging.DEBUG: grey + format + reset, 17 | logging.INFO: white + format + reset, 18 | logging.WARNING: yellow + format + reset, 19 | logging.ERROR: red + format + reset, 20 | logging.CRITICAL: bold_red + format + reset, 21 | } 22 | 23 | def format(self, record): 24 | log_fmt = self.FORMATS.get(record.levelno) 25 | formatter = logging.Formatter(log_fmt) 26 | return formatter.format(record) 27 | 28 | 29 | def getLogger(name: str = "blender_vs"): 30 | logging.getLogger().setLevel(LOG_LEVEL) 31 | 32 | log = logging.getLogger(name) 33 | if log.handlers: 34 | # log is already configured 35 | return log 36 | log.propagate = False 37 | log.setLevel(LOG_LEVEL) 38 | 39 | # create console handler with a higher log level 40 | ch = logging.StreamHandler() 41 | ch.setLevel(logging.DEBUG) 42 | 43 | ch.setFormatter(ColoredFormatter()) 44 | 45 | log.addHandler(ch) 46 | 47 | return log 48 | -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pprint import pformat 3 | from dataclasses import dataclass 4 | from pathlib import Path 5 | from typing import List 6 | 7 | import bpy 8 | 9 | from . import log 10 | 11 | LOG = log.getLogger() 12 | 13 | 14 | @dataclass 15 | class AddonInfo: 16 | load_dir: Path 17 | module_name: str 18 | 19 | 20 | def startup(editor_address, addons_to_load: List[AddonInfo]): 21 | if bpy.app.version < (2, 80, 34): 22 | handle_fatal_error("Please use a newer version of Blender") 23 | 24 | from . import installation 25 | 26 | # blender 2.80 'ssl' module is compiled with 'OpenSSL 1.1.0h' what breaks with requests >2.29.0 27 | installation.ensure_packages_are_installed(["debugpy", "requests<=2.29.0", "werkzeug<=3.0.3", "flask<=3.0.3"]) 28 | 29 | from . import load_addons 30 | 31 | path_mappings = load_addons.setup_addon_links(addons_to_load) 32 | 33 | from . import communication 34 | 35 | communication.setup(editor_address, path_mappings) 36 | 37 | from . import operators, ui 38 | 39 | ui.register() 40 | operators.register() 41 | 42 | load_addons.load(addons_to_load) 43 | 44 | 45 | def handle_fatal_error(message): 46 | print() 47 | print("#" * 80) 48 | for line in message.splitlines(): 49 | print("> ", line) 50 | print("#" * 80) 51 | print(f"PATHONPATH: {pformat(sys.path)}") 52 | print() 53 | sys.exit(1) 54 | -------------------------------------------------------------------------------- /src/blender_executable_windows.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as fs from 'fs'; 3 | 4 | 5 | async function getDirectories(path_: string): Promise { 6 | const entries = await fs.promises.readdir(path_); 7 | 8 | const directories: string[] = []; 9 | for (const name of entries) { 10 | const stats = await fs.promises.stat(path.join(path_, name)); 11 | if (stats.isDirectory()) { 12 | directories.push(name); 13 | } 14 | } 15 | 16 | return directories; 17 | } 18 | 19 | // todo read from registry Blender installation path 20 | const typicalWindowsBlenderFoundationPaths: string[] = [ 21 | path.join(process.env.ProgramFiles || "C:\\Program Files", "Blender Foundation"), 22 | path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "Blender Foundation"), 23 | ]; 24 | 25 | 26 | export async function getBlenderWindows(): Promise { 27 | const blenders: string[] = []; 28 | const dirsToCheck: string[] = []; 29 | for (const typicalPath of typicalWindowsBlenderFoundationPaths) { 30 | const dirs = await getDirectories(typicalPath).catch((err: NodeJS.ErrnoException) => []); 31 | dirsToCheck.push(...dirs.map(dir => path.join(typicalPath, dir))); 32 | } 33 | 34 | const exe = "blender.exe"; 35 | for (const folder of dirsToCheck) { 36 | const executable = path.join(folder, exe); 37 | const stats = await fs.promises.stat(executable).catch((err: NodeJS.ErrnoException) => undefined); 38 | if (stats === undefined) { 39 | continue; 40 | } 41 | if (stats.isFile()) { 42 | blenders.push(executable); 43 | } 44 | } 45 | 46 | return blenders; 47 | } -------------------------------------------------------------------------------- /src/blender_executable_linux.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | 3 | import { BlenderExecutableData } from "./blender_executable"; 4 | 5 | export async function deduplicateSameHardLinks( 6 | blenderPathsToReduce: BlenderExecutableData[], 7 | removeMissingFiles = true, 8 | additionalBlenderPaths: BlenderExecutableData[] = [] 9 | ): Promise { 10 | let missingItem = -1; 11 | const additionalBlenderPathsInodes = new Set(); 12 | 13 | for (const item of additionalBlenderPaths) { 14 | if (item.linuxInode === undefined) { 15 | const stats = await fs.promises.stat(item.path).catch(() => undefined); 16 | if (stats === undefined) { 17 | continue; 18 | } 19 | item.linuxInode = stats.ino; 20 | } 21 | 22 | additionalBlenderPathsInodes.add(item.linuxInode); 23 | } 24 | 25 | const deduplicateHardLinks = new Map(); 26 | for (const item of blenderPathsToReduce) { 27 | if (item.linuxInode === undefined) { 28 | const stats = await fs.promises.stat(item.path).catch(() => undefined); 29 | if (stats === undefined) { 30 | if (removeMissingFiles) { 31 | deduplicateHardLinks.set(missingItem, item); 32 | missingItem -= 1; 33 | } 34 | continue; 35 | } 36 | item.linuxInode = stats.ino; 37 | } 38 | 39 | const inode = item.linuxInode; 40 | if (deduplicateHardLinks.has(inode)) { 41 | continue; 42 | } 43 | if (additionalBlenderPathsInodes.has(inode)) { 44 | continue; 45 | } 46 | 47 | deduplicateHardLinks.set(inode, item); 48 | } 49 | 50 | return Array.from(deduplicateHardLinks.values()); 51 | } 52 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | const { 2 | defineConfig, 3 | } = require("eslint/config"); 4 | 5 | const globals = require("globals"); 6 | const tsParser = require("@typescript-eslint/parser"); 7 | const typescriptEslint = require("@typescript-eslint/eslint-plugin"); 8 | const stylistic = require('@stylistic/eslint-plugin'); 9 | const js = require("@eslint/js"); 10 | 11 | const { 12 | FlatCompat, 13 | } = require("@eslint/eslintrc"); 14 | 15 | const compat = new FlatCompat({ 16 | baseDirectory: __dirname, 17 | recommendedConfig: js.configs.recommended, 18 | allConfig: js.configs.all 19 | }); 20 | 21 | module.exports = defineConfig([{ 22 | files: ["src/**/*.ts"], 23 | languageOptions: { 24 | globals: { 25 | ...globals.node, 26 | }, 27 | 28 | parser: tsParser, 29 | "sourceType": "module", 30 | 31 | parserOptions: { 32 | "project": "tsconfig.json", 33 | }, 34 | }, 35 | 36 | extends: compat.extends("prettier"), 37 | 38 | plugins: { 39 | "@typescript-eslint": typescriptEslint, 40 | "@stylistic": stylistic, 41 | }, 42 | 43 | "rules": { 44 | "@typescript-eslint/naming-convention": "warn", 45 | "@typescript-eslint/no-unused-expressions": "warn", 46 | "curly": ["warn", "multi-line"], 47 | "eqeqeq": ["warn", "always"], 48 | "no-redeclare": "warn", 49 | "no-throw-literal": "warn", 50 | "no-unused-expressions": "off", 51 | "stylistic/semi": ["off"], 52 | "stylistic/member-delimiter-style": ["warn", { 53 | "multiline": { 54 | "delimiter": "semi", 55 | "requireLast": true, 56 | }, 57 | "singleline": { 58 | "delimiter": "semi", 59 | "requireLast": false, 60 | }, 61 | }], 62 | }, 63 | }]); 64 | -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/utils.py: -------------------------------------------------------------------------------- 1 | import ast 2 | from pathlib import Path 3 | import bpy 4 | import queue 5 | import traceback 6 | 7 | 8 | def is_addon_legacy(addon_dir: Path) -> bool: 9 | """Return whether an addon uses the legacy bl_info behavior, or the new blender_manifest behavior""" 10 | if bpy.app.version < (4, 2, 0): 11 | return True 12 | if not (addon_dir / "blender_manifest.toml").exists(): 13 | return True 14 | return False 15 | 16 | 17 | def addon_has_bl_info(addon_dir: Path) -> bool: 18 | """Perform best effort check to find bl_info. Does not perform an import on file to avoid code execution.""" 19 | with open(addon_dir / "__init__.py") as init_addon_file: 20 | node = ast.parse(init_addon_file.read()) 21 | for element in node.body: 22 | if not isinstance(element, ast.Assign): 23 | continue 24 | for target in element.targets: 25 | if not isinstance(target, ast.Name): 26 | continue 27 | if target.id == "bl_info": 28 | return True 29 | return False 30 | 31 | 32 | def redraw_all(): 33 | for window in bpy.context.window_manager.windows: 34 | for area in window.screen.areas: 35 | area.tag_redraw() 36 | 37 | 38 | def get_prefixes(all_names, separator): 39 | return set(name.split(separator)[0] for name in all_names if separator in name) 40 | 41 | 42 | execution_queue = queue.Queue() 43 | 44 | 45 | def run_in_main_thread(func): 46 | execution_queue.put(func) 47 | 48 | 49 | def always(): 50 | while not execution_queue.empty(): 51 | func = execution_queue.get() 52 | try: 53 | func() 54 | except Exception: 55 | traceback.print_exc() 56 | return 0.1 57 | 58 | 59 | bpy.app.timers.register(always, persistent=True) 60 | -------------------------------------------------------------------------------- /src/commands_new_operator.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as path from 'path'; 3 | import { templateFilesDir } from './paths'; 4 | import { 5 | cancel, nameToClassIdentifier, nameToIdentifier, readTextFile, 6 | multiReplaceText 7 | } from './utils'; 8 | 9 | export async function COMMAND_newOperator(): Promise { 10 | const editor = vscode.window.activeTextEditor; 11 | if (editor === undefined) return; 12 | 13 | const operatorName = await vscode.window.showInputBox({ 14 | placeHolder: 'Name', 15 | }); 16 | if (operatorName === undefined) return Promise.reject(cancel()); 17 | 18 | const group: string = 'object'; 19 | await insertOperator(editor, operatorName, group); 20 | } 21 | 22 | async function insertOperator(editor: vscode.TextEditor, name: string, group: string) { 23 | const className = nameToClassIdentifier(name) + 'Operator'; 24 | const idname = group + '.' + nameToIdentifier(name); 25 | 26 | let text = await readTextFile(path.join(templateFilesDir, 'operator_simple.py')); 27 | text = multiReplaceText(text, { 28 | CLASS_NAME: className, 29 | OPERATOR_CLASS: 'bpy.types.Operator', 30 | IDNAME: idname, 31 | LABEL: name, 32 | }); 33 | 34 | const workspaceEdit = new vscode.WorkspaceEdit(); 35 | 36 | if (!hasImportBpy(editor.document)) { 37 | workspaceEdit.insert(editor.document.uri, new vscode.Position(0, 0), 'import bpy\n'); 38 | } 39 | 40 | workspaceEdit.replace(editor.document.uri, editor.selection, '\n' + text + '\n'); 41 | await vscode.workspace.applyEdit(workspaceEdit); 42 | } 43 | 44 | function hasImportBpy(document: vscode.TextDocument) { 45 | for (let i = 0; i< document.lineCount; i++) { 46 | const line = document.lineAt(i); 47 | if (line.text.match(/import.*\bbpy\b/)) { 48 | return true; 49 | } 50 | } 51 | return false; 52 | } -------------------------------------------------------------------------------- /src/notifications.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { getConfig } from './utils'; 3 | import { BlenderExecutableData, BlenderExecutableSettings } from './blender_executable'; 4 | 5 | export function factoryShowNotificationAddDefault(context: vscode.ExtensionContext) { 6 | return async function showNotificationAddDefault(executable : BlenderExecutableData 7 | ) { 8 | // context.globalState.update('showNotificationAddDefault', undefined); 9 | const show = context.globalState.get('showNotificationAddDefault'); 10 | if (show == false) { 11 | return 12 | } 13 | 14 | const choice = await vscode.window.showInformationMessage( 15 | `Make "${executable.name}" default?\n\`${executable.path}\``, 16 | 'Never show again', 17 | 'Make default' 18 | ); 19 | if (choice === 'Never show again') { 20 | context.globalState.update('showNotificationAddDefault', false); 21 | } else if (choice === 'Make default') { 22 | let config = getConfig(); 23 | const settingsBlenderPaths = (config.get('executables')); 24 | 25 | const toSave: BlenderExecutableSettings[] = settingsBlenderPaths.map(item => { return { 'name': item.name, 'path': item.path, 'isDefault': item.isDefault } }) 26 | 27 | let matchFound = false 28 | for (const setting of toSave) { 29 | setting.isDefault = undefined 30 | if (setting.path == executable.path) { 31 | setting.isDefault = true 32 | matchFound = true 33 | } 34 | } 35 | 36 | if (matchFound === false) { 37 | toSave.push({ 38 | name: executable.name, 39 | path: executable.path, 40 | isDefault: true 41 | }) 42 | } 43 | 44 | config.update('executables', toSave, vscode.ConfigurationTarget.Global); 45 | vscode.window.showInformationMessage(`"${executable.name}" is now default. Use settings \`blender.executables\` to change that.`); 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/environment.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import sys 4 | from pathlib import Path 5 | from typing import Optional 6 | from typing import Tuple 7 | 8 | import addon_utils 9 | import bpy 10 | 11 | _str_to_log_level = { 12 | "debug-with-flask": logging.DEBUG, 13 | "debug": logging.DEBUG, 14 | "info": logging.INFO, 15 | "warning": logging.WARNING, 16 | "error": logging.ERROR, 17 | "critical": logging.CRITICAL, 18 | } 19 | 20 | 21 | def _parse_log(env_var_name: str) -> Tuple[int, bool]: 22 | log_env_global = os.environ.get(env_var_name, "info") or "info" 23 | try: 24 | enable_flask_logs = "debug-with-flask" == log_env_global 25 | return _str_to_log_level[log_env_global], enable_flask_logs 26 | except KeyError as e: 27 | logging.warning(f"Log level for {env_var_name} not set: {e}") 28 | return logging.WARNING, False 29 | 30 | 31 | # binary_path_python was removed in blender 2.92 32 | # but it is the most reliable way of getting python path for older versions 33 | # https://github.com/JacquesLucke/blender_vscode/issues/80 34 | python_path = Path(getattr(bpy.app, "binary_path_python", sys.executable)) 35 | blender_path = Path(bpy.app.binary_path) 36 | blender_directory = blender_path.parent 37 | 38 | version = bpy.app.version 39 | scripts_folder = blender_path.parent / f"{version[0]}.{version[1]}" / "scripts" 40 | addon_directories = tuple(map(Path, addon_utils.paths())) 41 | 42 | EXTENSIONS_REPOSITORY: Optional[str] = os.environ.get("VSCODE_EXTENSIONS_REPOSITORY", "user_default") or "user_default" 43 | LOG_LEVEL, LOG_FLASK = _parse_log("VSCODE_LOG_LEVEL") 44 | VSCODE_IDENTIFIER: Optional[str] = os.environ.get("VSCODE_IDENTIFIER", "") or "" 45 | 46 | logging.getLogger("werkzeug").setLevel(logging.DEBUG if LOG_FLASK else logging.ERROR) 47 | # to mute all logs, disable also those logs. Be careful, the libs are extremely popular and it will mute logs for everyone! 48 | # logging.getLogger("requests").setLevel(logging.DEBUG if LOG_FLASK else logging.INFO) 49 | # logging.getLogger("urllib3").setLevel(logging.DEBUG if LOG_FLASK else logging.INFO) 50 | 51 | VSCODE_IDENTIFIER: Optional[str] = os.environ.get("VSCODE_IDENTIFIER", "") or "" -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/operators/addon_update.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import traceback 3 | from pathlib import Path 4 | 5 | import bpy 6 | from bpy.props import * 7 | 8 | from ..environment import EXTENSIONS_REPOSITORY 9 | from ..utils import addon_has_bl_info 10 | from ..load_addons import is_in_any_addon_directory 11 | from ..communication import send_dict_as_json, register_post_action 12 | from ..utils import is_addon_legacy, redraw_all 13 | 14 | 15 | class UpdateAddonOperator(bpy.types.Operator): 16 | bl_idname = "dev.update_addon" 17 | bl_label = "Update Addon" 18 | 19 | module_name: StringProperty() 20 | 21 | def execute(self, context): 22 | try: 23 | bpy.ops.preferences.addon_disable(module=self.module_name) 24 | except Exception: 25 | traceback.print_exc() 26 | send_dict_as_json({"type": "disableFailure"}) 27 | return {"CANCELLED"} 28 | 29 | for name in list(sys.modules.keys()): 30 | if name == self.module_name or name.startswith(self.module_name + "."): 31 | del sys.modules[name] 32 | 33 | try: 34 | bpy.ops.preferences.addon_enable(module=self.module_name) 35 | except Exception: 36 | traceback.print_exc() 37 | send_dict_as_json({"type": "enableFailure"}) 38 | return {"CANCELLED"} 39 | 40 | send_dict_as_json({"type": "addonUpdated"}) 41 | 42 | redraw_all() 43 | return {"FINISHED"} 44 | 45 | 46 | def reload_addon_action(data): 47 | module_names = [] 48 | for name, dir in zip(data["names"], data["dirs"]): 49 | if is_addon_legacy(Path(dir)): 50 | module_names.append(name) 51 | elif addon_has_bl_info(Path(dir)) and is_in_any_addon_directory(Path(dir)): 52 | # this addon is compatible with legacy addons and extensions 53 | # but user is developing it in addon directory. Treat it as addon. 54 | module_names.append(name) 55 | else: 56 | module_names.append("bl_ext." + EXTENSIONS_REPOSITORY + "." + name) 57 | 58 | for name in module_names: 59 | bpy.ops.dev.update_addon(module_name=name) 60 | 61 | 62 | def register(): 63 | bpy.utils.register_class(UpdateAddonOperator) 64 | register_post_action("reload", reload_addon_action) 65 | -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/operators/script_runner.py: -------------------------------------------------------------------------------- 1 | import re 2 | import bpy 3 | import runpy 4 | from pprint import pformat 5 | from bpy.props import * 6 | from ..utils import redraw_all 7 | from ..communication import register_post_action 8 | from .. import log 9 | 10 | LOG = log.getLogger() 11 | 12 | 13 | class RunScriptOperator(bpy.types.Operator): 14 | bl_idname = "dev.run_script" 15 | bl_label = "Run Script" 16 | 17 | filepath: StringProperty() 18 | 19 | def execute(self, context): 20 | ctx = prepare_script_context(self.filepath) 21 | LOG.info(f'Run script: "{self.filepath}"') 22 | LOG.debug(f"Run script context override: {pformat(ctx)}") 23 | runpy.run_path(self.filepath, init_globals={"CTX": ctx}) 24 | redraw_all() 25 | return {"FINISHED"} 26 | 27 | 28 | def run_script_action(data): 29 | path = data["path"] 30 | context = prepare_script_context(path) 31 | 32 | if bpy.app.version < (4, 0, 0): 33 | bpy.ops.dev.run_script(context, filepath=path) 34 | return 35 | 36 | with bpy.context.temp_override(**context): 37 | bpy.ops.dev.run_script(filepath=path) 38 | 39 | 40 | def prepare_script_context(filepath): 41 | with open(filepath) as fs: 42 | text = fs.read() 43 | 44 | area_type = "VIEW_3D" 45 | region_type = "WINDOW" 46 | 47 | for line in text.splitlines(): 48 | match = re.match(r"^\s*#\s*context\.area\s*:\s*(\w+)", line, re.IGNORECASE) 49 | if match: 50 | area_type = match.group(1) 51 | 52 | context = {} 53 | context["window_manager"] = bpy.data.window_managers[0] 54 | context["window"] = context["window_manager"].windows[0] 55 | context["scene"] = context["window"].scene 56 | context["view_layer"] = context["window"].view_layer 57 | context["screen"] = context["window"].screen 58 | context["workspace"] = context["window"].workspace 59 | context["area"] = get_area_by_type(area_type) 60 | context["region"] = get_region_in_area(context["area"], region_type) if context["area"] else None 61 | return context 62 | 63 | 64 | def get_area_by_type(area_type): 65 | for area in bpy.data.window_managers[0].windows[0].screen.areas: 66 | if area.type == area_type: 67 | return area 68 | return None 69 | 70 | 71 | def get_region_in_area(area, region_type): 72 | for region in area.regions: 73 | if region.type == region_type: 74 | return region 75 | return None 76 | 77 | 78 | def register(): 79 | bpy.utils.register_class(RunScriptOperator) 80 | register_post_action("script", run_script_action) 81 | -------------------------------------------------------------------------------- /generated/enums.json: -------------------------------------------------------------------------------- 1 | { 2 | "areaTypeItems": [ 3 | { 4 | "identifier": "EMPTY", 5 | "name": "Empty", 6 | "description": "" 7 | }, 8 | { 9 | "identifier": "VIEW_3D", 10 | "name": "3D Viewport", 11 | "description": "Manipulate objects in a 3D environment" 12 | }, 13 | { 14 | "identifier": "IMAGE_EDITOR", 15 | "name": "UV/Image Editor", 16 | "description": "View and edit images and UV Maps" 17 | }, 18 | { 19 | "identifier": "NODE_EDITOR", 20 | "name": "Node Editor", 21 | "description": "Editor for node-based shading and compositing tools" 22 | }, 23 | { 24 | "identifier": "SEQUENCE_EDITOR", 25 | "name": "Video Sequencer", 26 | "description": "Video editing tools" 27 | }, 28 | { 29 | "identifier": "CLIP_EDITOR", 30 | "name": "Movie Clip Editor", 31 | "description": "Motion tracking tools" 32 | }, 33 | { 34 | "identifier": "DOPESHEET_EDITOR", 35 | "name": "Dope Sheet", 36 | "description": "Adjust timing of keyframes" 37 | }, 38 | { 39 | "identifier": "GRAPH_EDITOR", 40 | "name": "Graph Editor", 41 | "description": "Edit drivers and keyframe interpolation" 42 | }, 43 | { 44 | "identifier": "NLA_EDITOR", 45 | "name": "Nonlinear Animation", 46 | "description": "Combine and layer Actions" 47 | }, 48 | { 49 | "identifier": "TEXT_EDITOR", 50 | "name": "Text Editor", 51 | "description": "Edit scripts and in-file documentation" 52 | }, 53 | { 54 | "identifier": "CONSOLE", 55 | "name": "Python Console", 56 | "description": "Interactive programmatic console for advanced editing and script development" 57 | }, 58 | { 59 | "identifier": "INFO", 60 | "name": "Info", 61 | "description": "Main menu bar and list of error messages (drag down to expand and display)" 62 | }, 63 | { 64 | "identifier": "TOPBAR", 65 | "name": "Top Bar", 66 | "description": "Global bar at the top of the screen for global per-window settings" 67 | }, 68 | { 69 | "identifier": "STATUSBAR", 70 | "name": "Status Bar", 71 | "description": "Global bar at the bottom of the screen for general status information" 72 | }, 73 | { 74 | "identifier": "OUTLINER", 75 | "name": "Outliner", 76 | "description": "Overview of scene graph and all available data-blocks" 77 | }, 78 | { 79 | "identifier": "PROPERTIES", 80 | "name": "Properties", 81 | "description": "Edit properties of active object and related data-blocks" 82 | }, 83 | { 84 | "identifier": "FILE_BROWSER", 85 | "name": "File Browser", 86 | "description": "Browse for files and assets" 87 | }, 88 | { 89 | "identifier": "USER_PREFERENCES", 90 | "name": "User Preferences", 91 | "description": "Edit persistent configuration settings" 92 | } 93 | ] 94 | } 95 | -------------------------------------------------------------------------------- /pythonFiles/templates/blender_manifest.toml: -------------------------------------------------------------------------------- 1 | schema_version = "1.0.0" 2 | 3 | # Example of manifest file for a Blender extension 4 | # Change the values according to your extension 5 | id = "ADDON_ID" 6 | version = "1.0.0" 7 | name = "ADDON_NAME" 8 | tagline = "This is another extension" 9 | maintainer = "AUTHOR_NAME" 10 | # Supported types: "add-on", "theme" 11 | type = "add-on" 12 | 13 | # Optional link to documentation, support, source files, etc 14 | # website = "https://extensions.blender.org/add-ons/my-example-package/" 15 | 16 | # Optional list defined by Blender and server, see: 17 | # https://docs.blender.org/manual/en/dev/advanced/extensions/tags.html 18 | tags = ["Animation", "Sequencer"] 19 | 20 | blender_version_min = "4.2.0" 21 | # # Optional: Blender version that the extension does not support, earlier versions are supported. 22 | # # This can be omitted and defined later on the extensions platform if an issue is found. 23 | # blender_version_max = "5.1.0" 24 | 25 | # License conforming to https://spdx.org/licenses/ (use "SPDX: prefix) 26 | # https://docs.blender.org/manual/en/dev/advanced/extensions/licenses.html 27 | license = [ 28 | "SPDX:GPL-3.0-or-later", 29 | ] 30 | # Optional: required by some licenses. 31 | # copyright = [ 32 | # "2002-2024 Developer Name", 33 | # "1998 Company Name", 34 | # ] 35 | 36 | # Optional list of supported platforms. If omitted, the extension will be available in all operating systems. 37 | # platforms = ["windows-x64", "macos-arm64", "linux-x64"] 38 | # Other supported platforms: "windows-arm64", "macos-x64" 39 | 40 | # Optional: bundle 3rd party Python modules. 41 | # https://docs.blender.org/manual/en/dev/advanced/extensions/python_wheels.html 42 | # wheels = [ 43 | # "./wheels/hexdump-3.3-py3-none-any.whl", 44 | # "./wheels/jsmin-3.0.1-py3-none-any.whl", 45 | # ] 46 | 47 | # Optional: add-ons can list which resources they will require: 48 | # * files (for access of any filesystem operations) 49 | # * network (for internet access) 50 | # * clipboard (to read and/or write the system clipboard) 51 | # * camera (to capture photos and videos) 52 | # * microphone (to capture audio) 53 | # 54 | # If using network, remember to also check `bpy.app.online_access` 55 | # https://docs.blender.org/manual/en/dev/advanced/extensions/addons.html#internet-access 56 | # 57 | # For each permission it is important to also specify the reason why it is required. 58 | # Keep this a single short sentence without a period (.) at the end. 59 | # For longer explanations use the documentation or detail page. 60 | # 61 | # [permissions] 62 | # network = "Need to sync motion-capture data to server" 63 | # files = "Import/export FBX from/to disk" 64 | # clipboard = "Copy and paste bone transforms" 65 | 66 | # Optional: build settings. 67 | # https://docs.blender.org/manual/en/dev/advanced/extensions/command_line_arguments.html#command-line-args-extension-build 68 | # [build] 69 | # paths_exclude_pattern = [ 70 | # "__pycache__/", 71 | # "/.git/", 72 | # "/*.zip", 73 | # ] 74 | -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/installation.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import subprocess 3 | 4 | import bpy 5 | 6 | from pathlib import Path 7 | 8 | from . import handle_fatal_error 9 | from . import log 10 | from .environment import python_path 11 | 12 | LOG = log.getLogger() 13 | _CWD_FOR_SUBPROCESSES = python_path.parent 14 | 15 | 16 | def ensure_packages_are_installed(package_names): 17 | if packages_are_installed(package_names): 18 | return 19 | 20 | install_packages(package_names) 21 | 22 | 23 | def packages_are_installed(package_names): 24 | return all(module_can_be_imported(name) for name in package_names) 25 | 26 | 27 | def install_packages(package_names): 28 | if not module_can_be_imported("pip"): 29 | install_pip() 30 | 31 | for name in package_names: 32 | ensure_package_is_installed(name) 33 | 34 | assert packages_are_installed(package_names) 35 | 36 | 37 | def ensure_package_is_installed(name: str): 38 | if not module_can_be_imported(name): 39 | install_package(name) 40 | 41 | 42 | def install_package(name: str): 43 | target = get_package_install_directory() 44 | command = [str(python_path), "-m", "pip", "install", name, "--target", target] 45 | LOG.info(f"Execute: {' '.join(command)}") 46 | subprocess.run(command, cwd=_CWD_FOR_SUBPROCESSES) 47 | 48 | if not module_can_be_imported(name): 49 | handle_fatal_error(f"could not install {name}") 50 | 51 | 52 | def install_pip(): 53 | # try ensurepip before get-pip.py 54 | if module_can_be_imported("ensurepip"): 55 | command = [str(python_path), "-m", "ensurepip", "--upgrade"] 56 | LOG.info(f"Execute: {' '.join(command)}") 57 | subprocess.run(command, cwd=_CWD_FOR_SUBPROCESSES) 58 | return 59 | # pip can not necessarily be imported into Blender after this 60 | get_pip_path = Path(__file__).parent / "external" / "get-pip.py" 61 | subprocess.run([str(python_path), str(get_pip_path)], cwd=_CWD_FOR_SUBPROCESSES) 62 | 63 | 64 | def get_package_install_directory() -> str: 65 | # user modules loaded are loaded by default by blender from this path 66 | # https://docs.blender.org/manual/en/4.2/editors/preferences/file_paths.html#script-directories 67 | modules_path = bpy.utils.user_resource("SCRIPTS", path="modules") 68 | if modules_path not in sys.path: 69 | # if the path does not exist blender will not load it, usually occurs in fresh install 70 | sys.path.append(modules_path) 71 | return modules_path 72 | 73 | 74 | def module_can_be_imported(name: str): 75 | try: 76 | stripped_name = _strip_pip_version(name) 77 | mod = __import__(stripped_name) 78 | LOG.info("module: " + name + " is already installed") 79 | LOG.debug(stripped_name + ":" + getattr(mod ,"__version__", "None") + " in path: " + getattr(mod, "__file__", "None")) 80 | return True 81 | except ModuleNotFoundError: 82 | return False 83 | 84 | 85 | def _strip_pip_version(name: str) -> str: 86 | name_strip_comparison_sign = name.replace(">", "=").replace("<", "=") 87 | return name_strip_comparison_sign.split("=")[0] 88 | -------------------------------------------------------------------------------- /src/python_debugging.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os'; 2 | import * as vscode from 'vscode'; 3 | import { getStoredScriptFolders } from './commands_scripts'; 4 | import { AddonPathMapping } from './communication'; 5 | import { outputChannel } from './extension'; 6 | import { getAnyWorkspaceFolder } from './utils'; 7 | 8 | type PathMapping = { localRoot: string, remoteRoot: string }; 9 | 10 | export async function attachPythonDebuggerToBlender( 11 | port: number, blenderPath: string, justMyCode: boolean, scriptsFolder: string, 12 | addonPathMappings: AddonPathMapping[], identifier: string) { 13 | 14 | let mappings = await getPythonPathMappings(scriptsFolder, addonPathMappings); 15 | return attachPythonDebugger(port, justMyCode, mappings, identifier); 16 | } 17 | 18 | function attachPythonDebugger(port: number, justMyCode: boolean, pathMappings: PathMapping[], identifier: string) { 19 | let configuration: vscode.DebugConfiguration = { 20 | name: `Python at Port ${port}`, 21 | request: "attach", 22 | type: 'python', 23 | port: port, 24 | host: 'localhost', 25 | pathMappings: pathMappings, 26 | justMyCode: justMyCode, 27 | identifier: identifier, 28 | }; 29 | 30 | outputChannel.appendLine("Python debug configuration: " + JSON.stringify(configuration, undefined, 2)); 31 | 32 | return vscode.debug.startDebugging(undefined, configuration); 33 | } 34 | 35 | async function getPythonPathMappings(scriptsFolder: string, addonPathMappings: AddonPathMapping[]) { 36 | let mappings = []; 37 | 38 | // first, add the mapping to the addon as it is the most specific one. 39 | mappings.push(...addonPathMappings.map(item => ({ 40 | localRoot: item.src, 41 | remoteRoot: item.load 42 | }))); 43 | 44 | // add optional scripts folders 45 | for (let folder of getStoredScriptFolders()) { 46 | mappings.push({ 47 | localRoot: folder.path, 48 | remoteRoot: folder.path 49 | }); 50 | } 51 | 52 | // add blender scripts last, otherwise it seem to take all the scope and not let the proper mapping of other files 53 | mappings.push(await getBlenderScriptsPathMapping(scriptsFolder)); 54 | 55 | // add the workspace folder as last resort for mapping loose scripts inside it 56 | let wsFolder = getAnyWorkspaceFolder(); 57 | mappings.push({ 58 | localRoot: wsFolder.uri.fsPath, 59 | remoteRoot: wsFolder.uri.fsPath 60 | }); 61 | 62 | // change drive letter for some systems 63 | fixMappings(mappings); 64 | return mappings; 65 | } 66 | 67 | async function getBlenderScriptsPathMapping(scriptsFolder: string): Promise { 68 | return { 69 | localRoot: scriptsFolder, 70 | remoteRoot: scriptsFolder 71 | }; 72 | } 73 | 74 | function fixMappings(mappings: PathMapping[]) { 75 | for (let i = 0; i < mappings.length; i++) { 76 | mappings[i].localRoot = fixPath(mappings[i].localRoot); 77 | } 78 | } 79 | 80 | /* This is to work around a bug where vscode does not find 81 | * the path: c:\... but only C:\... on windows. 82 | * https://github.com/Microsoft/vscode-python/issues/2976 */ 83 | function fixPath(filepath: string) { 84 | if (os.platform() !== 'win32') return filepath; 85 | 86 | if (filepath.match(/^[a-zA-Z]:/) !== null) { 87 | return filepath[0].toUpperCase() + filepath.substring(1); 88 | } 89 | 90 | return filepath; 91 | } 92 | -------------------------------------------------------------------------------- /EXTENSION-SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Addon/Extension support 2 | 3 | > With the introduction of Extensions in Blender 4.2, the old way of creating add-ons is considered deprecated. 4 | 5 | [Extensions](https://docs.blender.org/manual/en/4.2/advanced/extensions/getting_started.html) are supported. 6 | For general comparison visit [Legacy vs Extension Add-ons](https://docs.blender.org/manual/en/4.2/advanced/extensions/addons.html#legacy-vs-extension-add-ons). 7 | VS code uses the automatic logic to determine if you are using addon or extension: 8 | 9 | | Blender version | Addon has `blender_manifest.toml` | Development location: | Your addon is interpreted as: | Link is created? | 10 | | --------------- | --------------------------------- | ---------------------- | ----------------------------- | -------------------------------------------------- | 11 | | Pre 4.2 | Does not matter | ADDON_DEFAULT_LOCATION | Legacy addon | No link is created | 12 | | Pre 4.2 | Does not matter | Anywhere on disk. | Legacy addon | You addon is linked to ADDON_DEFAULT_LOCATION | 13 | | 4.2 and above | True | Anywhere on disk. | Extension | You addon is linked to EXTENSIONS_DEFAULT_LOCATION | 14 | | 4.2 and above | True | ADDON_DEFAULT_LOCATION | Legacy addon | No link is created | 15 | | 4.2 and above | True | ANY_REPO_LOCATION | Extension | No link is created | 16 | | 4.2 and above | False | Does not apply. | Legacy addon | You addon is linked to ADDON_DEFAULT_LOCATION | 17 | 18 | There is no setting to override this behaviour. Find out your default paths: 19 | ```python 20 | >>> ADDON_DEFAULT_LOCATION = bpy.utils.user_resource("SCRIPTS", path="addons") 21 | '/home/user/.config/blender/4.2/scripts/addons' 22 | >>> ANY_REPO_LOCATION = [repo.custom_directory if repo.use_custom_directory else repo.directory for repo in bpy.context.preferences.extensions.repos if repo.enabled] 23 | ['/home/user/.config/blender/4.2/extensions/blender_org', '/home/user/.config/blender/4.2/extensions/user_default', '/snap/blender/5088/4.2/extensions/system'] 24 | >>> EXTENSIONS_DEFAULT_LOCATION = bpy.utils.user_resource("EXTENSIONS", path="vscode_development") 25 | '/home/user/.config/blender/4.2/extensions/vscode_development' 26 | ``` 27 | 28 | Note: `EXTENSIONS_DEFAULT_LOCATION` is defined by [`blender.addon.extensionsRepository`](vscode://settings/blender.addon.extensionsRepository) 29 | 30 | ## Examples 31 | 32 | I am using Blender 3.0 to develop my addon in `/home/user/blender-projects/test_extension/`. 33 | My addon supports addons and extensions (has both `bl_info` defined in `__init__.py` and `blender_manifest.toml` file.) 34 | - Result: my addon is interpreted to be addon (because Blender 3 does not support extension). My addon is linked to ADDON_DEFAULT_LOCATION 35 | 36 | I am using Blender 4.2 to develop my addon in `/home/user/blender-projects/test_extension/`. 37 | My addon supports addons and extensions (has both `bl_info` defined in `__init__.py` and `blender_manifest.toml` file.) 38 | - Result: my addon is interpreted to be extension. My addon is linked to EXTENSIONS_DEFAULT_LOCATION 39 | 40 | # Uninstall addon and cleanup 41 | 42 | ## How to uninstall addon? 43 | 44 | Manually remove links from locations: 45 | 46 | - Extensions (Blender 4.2 onwards): `bpy.utils.user_resource("EXTENSIONS", path="vscode_development")` 47 | - Addons: `bpy.utils.user_resource("SCRIPTS", path="addons")` 48 | - For older installations manually remove links from: `bpy.utils.user_resource("EXTENSIONS", path="user_default")` 49 | 50 | > [!WARNING] 51 | > Do not use Blender UI to uninstall addons: 52 | > - On windows uninstalling addon with Blender Preferences will result in data loss. It does not matter if your addon is linked or you are developing in directory that Blender recognizes by default (see above table). 53 | > - On linux/mac from blender [2.80](https://projects.blender.org/blender/blender/commit/e6ba760ce8fda5cf2e18bf26dddeeabdb4021066) uninstalling **linked** addon with Blender Preferences is handled correctly. If you are developing in that Blender recognizes by default (see above table) data loss will occur. 54 | 55 | ## How to completely cleanup all changes? 56 | 57 | - Remove installed dependencies in path: `bpy.utils.user_resource("SCRIPTS", path="addons")` 58 | - Older version install dependencies to global Blender packages folder and they are impossible to remove easily `/4.2/python/Lib/site-packages` 59 | - Remove extension repository called `vscode_development`: `Blender -> Preferences -> Get Extensions -> Repositories (dropdown, top right)` 60 | 61 | -------------------------------------------------------------------------------- /src/addon_folder.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as vscode from 'vscode'; 3 | import { 4 | getConfig, readTextFile, getWorkspaceFolders, 5 | getSubfolders, executeTask, getAnyWorkspaceFolder, pathExists 6 | } from './utils'; 7 | 8 | // TODO: It would be superior to use custom AddonFolder interface that is not bound to the 9 | // vscode.WorkspaceFolder directly. The 'uri' property is only one used at this point. 10 | 11 | export class AddonWorkspaceFolder { 12 | folder: vscode.WorkspaceFolder; 13 | 14 | constructor(folder: vscode.WorkspaceFolder) { 15 | this.folder = folder; 16 | } 17 | 18 | public static async All(): Promise { 19 | // Search folders specified by settings first, if nothing is specified 20 | // search workspace folders instead. 21 | const addonFolders = await foldersToWorkspaceFoldersMockup( 22 | getConfig().get('addonFolders')); 23 | 24 | const searchableFolders = addonFolders.length !== 0 ? addonFolders : getWorkspaceFolders(); 25 | const folders: AddonWorkspaceFolder[] = []; 26 | for (const folder of searchableFolders) { 27 | const addon = new AddonWorkspaceFolder(folder); 28 | if (await addon.hasAddonEntryPoint()) { 29 | folders.push(addon); 30 | } 31 | } 32 | return folders; 33 | } 34 | 35 | get uri() { 36 | return this.folder.uri; 37 | } 38 | 39 | get buildTaskName() { 40 | return this.getConfig().get('addon.buildTaskName'); 41 | } 42 | 43 | get reloadOnSave() { 44 | return this.getConfig().get('addon.reloadOnSave'); 45 | } 46 | 47 | get justMyCode() { 48 | return this.getConfig().get('addon.justMyCode'); 49 | } 50 | 51 | public async hasAddonEntryPoint(): Promise { 52 | try { 53 | let sourceDir = await this.getSourceDirectory(); 54 | return folderContainsAddonEntry(sourceDir); 55 | } 56 | catch (err) { 57 | return false; 58 | } 59 | } 60 | 61 | public async buildIfNecessary() { 62 | let taskName = this.buildTaskName; 63 | if (taskName === '') { 64 | return; 65 | } 66 | await executeTask(taskName, true); 67 | } 68 | 69 | public getConfig() { 70 | return getConfig(this.uri); 71 | } 72 | 73 | public async getLoadDirectoryAndModuleName() { 74 | const load_dir = await this.getLoadDirectory(); 75 | const module_name = await this.getModuleName(); 76 | return { 77 | 'load_dir' : load_dir, 78 | 'module_name' : module_name, 79 | }; 80 | } 81 | 82 | public async getModuleName() { 83 | const value = getConfig(this.uri).get('addon.moduleName'); 84 | if (value === 'auto') { 85 | return path.basename(await this.getLoadDirectory()); 86 | } 87 | return value; 88 | } 89 | 90 | public async getLoadDirectory() { 91 | const value = getConfig(this.uri).get('addon.loadDirectory'); 92 | if (value === 'auto') { 93 | return this.getSourceDirectory(); 94 | } 95 | return this.makePathAbsolute(value); 96 | } 97 | 98 | public async getSourceDirectory() { 99 | const value = getConfig(this.uri).get('addon.sourceDirectory'); 100 | if (value === 'auto') { 101 | return await tryFindActualAddonFolder(this.uri.fsPath); 102 | } 103 | return this.makePathAbsolute(value); 104 | } 105 | 106 | private makePathAbsolute(directory: string) { 107 | if (path.isAbsolute(directory)) { 108 | return directory; 109 | } 110 | else { 111 | return path.join(this.uri.fsPath, directory); 112 | } 113 | } 114 | } 115 | 116 | async function tryFindActualAddonFolder(root: string) { 117 | if (await folderContainsAddonEntry(root)) { 118 | return root; 119 | } 120 | for (const folder of await getSubfolders(root)) { 121 | if (await folderContainsAddonEntry(folder)) { 122 | return folder; 123 | } 124 | } 125 | return Promise.reject(new Error('cannot find actual addon code, please set the path in the settings')); 126 | } 127 | 128 | async function folderContainsAddonEntry(folderPath: string) { 129 | const manifestPath = path.join(folderPath, "blender_manifest.toml"); 130 | if (await pathExists(manifestPath)) { 131 | return true; 132 | } 133 | 134 | const initPath = path.join(folderPath, '__init__.py'); 135 | try { 136 | const content = await readTextFile(initPath); 137 | return content.includes('bl_info'); 138 | } 139 | catch { 140 | return false; 141 | } 142 | } 143 | 144 | async function foldersToWorkspaceFoldersMockup(folders: string[]) { 145 | const mockups: vscode.WorkspaceFolder[] = []; 146 | // Assume this functionality is only used with a single workspace folder for now. 147 | const rootFolder = getAnyWorkspaceFolder(); 148 | for (let i = 0; i < folders.length; i++) { 149 | const absolutePath = path.isAbsolute(folders[i]) 150 | ? folders[i] 151 | : path.join(rootFolder.uri.fsPath, folders[i]); 152 | 153 | const exists = await pathExists(absolutePath); 154 | if (!exists) { 155 | vscode.window.showInformationMessage( 156 | `Revise settings, path to addon doesn't exist ${absolutePath}`); 157 | continue; 158 | } 159 | 160 | mockups.push({ 161 | name: path.basename(absolutePath), 162 | uri: vscode.Uri.from({ scheme: "file", path: absolutePath }), 163 | index: i 164 | }); 165 | } 166 | return mockups; 167 | } 168 | -------------------------------------------------------------------------------- /pythonFiles/templates/addons/with_auto_load/auto_load.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import typing 3 | import inspect 4 | import pkgutil 5 | import importlib 6 | from pathlib import Path 7 | 8 | __all__ = ( 9 | "init", 10 | "register", 11 | "unregister", 12 | ) 13 | 14 | blender_version = bpy.app.version 15 | 16 | modules = None 17 | ordered_classes = None 18 | 19 | 20 | def init(): 21 | global modules 22 | global ordered_classes 23 | 24 | modules = get_all_submodules(Path(__file__).parent) 25 | ordered_classes = get_ordered_classes_to_register(modules) 26 | 27 | 28 | def register(): 29 | for cls in ordered_classes: 30 | bpy.utils.register_class(cls) 31 | 32 | for module in modules: 33 | if module.__name__ == __name__: 34 | continue 35 | if hasattr(module, "register"): 36 | module.register() 37 | 38 | 39 | def unregister(): 40 | for cls in reversed(ordered_classes): 41 | bpy.utils.unregister_class(cls) 42 | 43 | for module in modules: 44 | if module.__name__ == __name__: 45 | continue 46 | if hasattr(module, "unregister"): 47 | module.unregister() 48 | 49 | 50 | # Import modules 51 | ################################################# 52 | 53 | 54 | def get_all_submodules(directory): 55 | return list(iter_submodules(directory, __package__)) 56 | 57 | 58 | def iter_submodules(path, package_name): 59 | for name in sorted(iter_submodule_names(path)): 60 | yield importlib.import_module("." + name, package_name) 61 | 62 | 63 | def iter_submodule_names(path, root=""): 64 | for _, module_name, is_package in pkgutil.iter_modules([str(path)]): 65 | if is_package: 66 | sub_path = path / module_name 67 | sub_root = root + module_name + "." 68 | yield from iter_submodule_names(sub_path, sub_root) 69 | else: 70 | yield root + module_name 71 | 72 | 73 | # Find classes to register 74 | ################################################# 75 | 76 | 77 | def get_ordered_classes_to_register(modules): 78 | return toposort(get_register_deps_dict(modules)) 79 | 80 | 81 | def get_register_deps_dict(modules): 82 | my_classes = set(iter_my_classes(modules)) 83 | my_classes_by_idname = {cls.bl_idname: cls for cls in my_classes if hasattr(cls, "bl_idname")} 84 | 85 | deps_dict = {} 86 | for cls in my_classes: 87 | deps_dict[cls] = set(iter_my_register_deps(cls, my_classes, my_classes_by_idname)) 88 | return deps_dict 89 | 90 | 91 | def iter_my_register_deps(cls, my_classes, my_classes_by_idname): 92 | yield from iter_my_deps_from_annotations(cls, my_classes) 93 | yield from iter_my_deps_from_parent_id(cls, my_classes_by_idname) 94 | 95 | 96 | def iter_my_deps_from_annotations(cls, my_classes): 97 | for value in typing.get_type_hints(cls, {}, {}).values(): 98 | dependency = get_dependency_from_annotation(value) 99 | if dependency is not None: 100 | if dependency in my_classes: 101 | yield dependency 102 | 103 | 104 | def get_dependency_from_annotation(value): 105 | if blender_version >= (2, 93): 106 | if isinstance(value, bpy.props._PropertyDeferred): 107 | return value.keywords.get("type") 108 | else: 109 | if isinstance(value, tuple) and len(value) == 2: 110 | if value[0] in (bpy.props.PointerProperty, bpy.props.CollectionProperty): 111 | return value[1]["type"] 112 | return None 113 | 114 | 115 | def iter_my_deps_from_parent_id(cls, my_classes_by_idname): 116 | if issubclass(cls, bpy.types.Panel): 117 | parent_idname = getattr(cls, "bl_parent_id", None) 118 | if parent_idname is not None: 119 | parent_cls = my_classes_by_idname.get(parent_idname) 120 | if parent_cls is not None: 121 | yield parent_cls 122 | 123 | 124 | def iter_my_classes(modules): 125 | base_types = get_register_base_types() 126 | for cls in get_classes_in_modules(modules): 127 | if any(issubclass(cls, base) for base in base_types): 128 | if not getattr(cls, "is_registered", False): 129 | yield cls 130 | 131 | 132 | def get_classes_in_modules(modules): 133 | classes = set() 134 | for module in modules: 135 | for cls in iter_classes_in_module(module): 136 | classes.add(cls) 137 | return classes 138 | 139 | 140 | def iter_classes_in_module(module): 141 | for value in module.__dict__.values(): 142 | if inspect.isclass(value): 143 | yield value 144 | 145 | 146 | def get_register_base_types(): 147 | return set( 148 | getattr(bpy.types, name) 149 | for name in [ 150 | "Panel", 151 | "Operator", 152 | "PropertyGroup", 153 | "AddonPreferences", 154 | "Header", 155 | "Menu", 156 | "Node", 157 | "NodeSocket", 158 | "NodeTree", 159 | "UIList", 160 | "RenderEngine", 161 | "Gizmo", 162 | "GizmoGroup", 163 | ] 164 | ) 165 | 166 | 167 | # Find order to register to solve dependencies 168 | ################################################# 169 | 170 | 171 | def toposort(deps_dict): 172 | sorted_list = [] 173 | sorted_values = set() 174 | while len(deps_dict) > 0: 175 | unsorted = [] 176 | sorted_list_sub = [] # helper for additional sorting by bl_order - in panels 177 | for value, deps in deps_dict.items(): 178 | if len(deps) == 0: 179 | sorted_list_sub.append(value) 180 | sorted_values.add(value) 181 | else: 182 | unsorted.append(value) 183 | deps_dict = {value: deps_dict[value] - sorted_values for value in unsorted} 184 | sorted_list_sub.sort(key=lambda cls: getattr(cls, "bl_order", 0)) 185 | sorted_list.extend(sorted_list_sub) 186 | return sorted_list 187 | -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/communication.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | import threading 4 | import time 5 | from functools import partial 6 | from typing import Callable, Dict 7 | 8 | import debugpy 9 | import flask 10 | import requests 11 | from werkzeug.serving import make_server 12 | 13 | from . import log 14 | from .environment import (LOG_FLASK, VSCODE_IDENTIFIER, blender_path, 15 | python_path, scripts_folder) 16 | from .utils import run_in_main_thread 17 | 18 | LOG = log.getLogger() 19 | 20 | EDITOR_ADDRESS = None 21 | OWN_SERVER_PORT = None 22 | DEBUGPY_PORT = None 23 | 24 | SERVER = flask.Flask("Blender Server") 25 | SERVER.logger.setLevel(logging.DEBUG if LOG_FLASK else logging.ERROR) 26 | POST_HANDLERS = {} 27 | 28 | 29 | def setup(address: str, path_mappings): 30 | global EDITOR_ADDRESS, OWN_SERVER_PORT, DEBUGPY_PORT 31 | EDITOR_ADDRESS = address 32 | 33 | OWN_SERVER_PORT = start_own_server() 34 | DEBUGPY_PORT = start_debug_server() 35 | 36 | send_connection_information(path_mappings) 37 | 38 | LOG.info("Waiting for debug client.") 39 | debugpy.wait_for_client() 40 | LOG.info("Debug client attached.") 41 | 42 | 43 | def start_own_server(): 44 | server_started = threading.Event() 45 | startup_failed = threading.Event() 46 | result = {"port": None, "exception": None} 47 | 48 | def server_thread_function(): 49 | for _attempt in range(10): 50 | port = get_random_port() 51 | try: 52 | httpd = make_server("127.0.0.1", port, SERVER) 53 | 54 | # Startup was successful — signal and continue 55 | result["port"] = port 56 | server_started.set() 57 | 58 | # Blocks here. If it fails the error remains unhandled. 59 | httpd.serve_forever() 60 | return 61 | except OSError as e: 62 | # retry on port conflicts, etc 63 | LOG.info(f"Port {port} failed with OSError, retrying... ({e})") 64 | continue 65 | except Exception as e: 66 | LOG.error(f"Unexpected error starting server on port {port}: {e}") 67 | result["exception"] = e 68 | startup_failed.set() 69 | return 70 | # If loop exhausted without success and without unexpected exception 71 | LOG.exception("Failed to start server after 10 attempts.") 72 | startup_failed.set() 73 | 74 | thread = threading.Thread(target=server_thread_function, daemon=True) 75 | thread.start() 76 | 77 | timeout = 15 # seconds 78 | deadline = time.time() + timeout 79 | # Wait for either success or failure 80 | while time.time() < deadline: 81 | if server_started.is_set(): 82 | LOG.debug(f"Flask server started on port {result['port']}") 83 | return result["port"] 84 | if startup_failed.is_set(): 85 | raise RuntimeError("Failed to start Flask server. See logs for details.") from result["exception"] 86 | time.sleep(0.07) 87 | raise TimeoutError(f"Falsk server did not start within {timeout} seconds.") 88 | 89 | 90 | def start_debug_server(): 91 | # retry on port conflicts, todo catch only specific exceptions 92 | # note debugpy changed exception types between versions, todo investigate 93 | last_exception = None 94 | for _attempt in range(15): 95 | port = get_random_port() 96 | last_exception = None 97 | try: 98 | # for < 2.92 support (debugpy has problems when using bpy.app.binary_path_python) 99 | # https://github.com/microsoft/debugpy/issues/1330 100 | debugpy.configure(python=str(python_path)) 101 | debugpy.listen(("localhost", port)) 102 | return port 103 | except Exception as e: 104 | LOG.warning(f"Debugpy failed to start on port {port}: {e}") 105 | last_exception = e 106 | raise RuntimeError(f"Failed to start debugpy after 15 attempts.") from last_exception 107 | 108 | 109 | # Server 110 | ######################################### 111 | 112 | 113 | @SERVER.route("/", methods=["POST"]) 114 | def handle_post(): 115 | data = flask.request.get_json() 116 | LOG.debug(f"Got POST: {data}") 117 | 118 | if data["type"] in POST_HANDLERS: 119 | return POST_HANDLERS[data["type"]](data) 120 | else: 121 | LOG.warning(f"Unhandled POST: {data}") 122 | 123 | return "OK" 124 | 125 | 126 | @SERVER.route("/ping", methods=["GET"]) 127 | def handle_get_ping(): 128 | LOG.debug(f"Got ping") 129 | return "OK" 130 | 131 | 132 | def register_post_handler(type: str, handler: Callable): 133 | assert type not in POST_HANDLERS, POST_HANDLERS 134 | POST_HANDLERS[type] = handler 135 | 136 | 137 | def register_post_action(type: str, handler: Callable): 138 | def request_handler_wrapper(data): 139 | run_in_main_thread(partial(handler, data)) 140 | return "OK" 141 | 142 | register_post_handler(type, request_handler_wrapper) 143 | 144 | 145 | # Sending Data 146 | ############################### 147 | 148 | 149 | def send_connection_information(path_mappings: Dict): 150 | send_dict_as_json( 151 | { 152 | "type": "setup", 153 | "blenderPort": OWN_SERVER_PORT, 154 | "debugpyPort": DEBUGPY_PORT, 155 | "blenderPath": str(blender_path), 156 | "scriptsFolder": str(scripts_folder), 157 | "addonPathMappings": path_mappings, 158 | "vscodeIdentifier": VSCODE_IDENTIFIER, 159 | } 160 | ) 161 | 162 | 163 | def send_dict_as_json(data): 164 | LOG.debug(f"Sending: {data}") 165 | requests.post(EDITOR_ADDRESS, json=data) 166 | 167 | 168 | # Utils 169 | ############################### 170 | 171 | 172 | def get_random_port(): 173 | return random.randint(49152, 65535) 174 | 175 | 176 | def get_blender_port(): 177 | return OWN_SERVER_PORT 178 | 179 | 180 | def get_debugpy_port(): 181 | return DEBUGPY_PORT 182 | 183 | 184 | def get_editor_address(): 185 | return EDITOR_ADDRESS 186 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as vscode from 'vscode'; 4 | import { AddonWorkspaceFolder } from './addon_folder'; 5 | import { BlenderExecutableData, BlenderExecutableSettings, LaunchAny, LaunchAnyInteractive } from './blender_executable'; 6 | import { RunningBlenders, startServer, stopServer } from './communication'; 7 | import { COMMAND_newAddon } from './commands_new_addon'; 8 | import { COMMAND_newOperator } from './commands_new_operator'; 9 | import { factoryShowNotificationAddDefault } from './notifications'; 10 | import { 11 | COMMAND_newScript, 12 | COMMAND_openScriptsFolder, 13 | COMMAND_runScript, 14 | COMMAND_runScript_registerCleanup, 15 | COMMAND_setScriptContext 16 | } from './commands_scripts'; 17 | import { getDefaultBlenderSettings, handleErrors } from './utils'; 18 | 19 | export let outputChannel: vscode.OutputChannel; 20 | 21 | 22 | /* Registration 23 | *********************************************/ 24 | 25 | export let showNotificationAddDefault: (executable: BlenderExecutableData) => Promise 26 | 27 | 28 | export function activate(context: vscode.ExtensionContext) { 29 | outputChannel = vscode.window.createOutputChannel("Blender debugpy"); 30 | outputChannel.appendLine("Addon starting."); 31 | outputChannel.show(true); 32 | type CommandFuncType = (args?: any) => Promise 33 | let commands: [string, CommandFuncType][] = [ 34 | ['blender.start', COMMAND_start], 35 | ['blender.stop', COMMAND_stop], 36 | ['blender.reloadAddons', COMMAND_reloadAddons], 37 | ['blender.newAddon', COMMAND_newAddon], 38 | ['blender.newScript', COMMAND_newScript], 39 | ['blender.openScriptsFolder', COMMAND_openScriptsFolder], 40 | ['blender.openFiles', COMMAND_openFiles], 41 | ['blender.openWithBlender', COMMAND_openWithBlender], 42 | ['blender.runScript', COMMAND_runScript], 43 | ['blender.setScriptContext', COMMAND_setScriptContext], 44 | ['blender.newOperator', COMMAND_newOperator], 45 | ]; 46 | 47 | let disposables = [ 48 | vscode.workspace.onDidSaveTextDocument(HANDLER_updateOnSave), 49 | ]; 50 | 51 | for (const [identifier, func] of commands) { 52 | const command = vscode.commands.registerCommand(identifier, handleErrors(func)); 53 | disposables.push(command); 54 | } 55 | disposables.push(...COMMAND_runScript_registerCleanup()) 56 | 57 | context.subscriptions.push(...disposables); 58 | showNotificationAddDefault = factoryShowNotificationAddDefault(context) 59 | startServer(); 60 | } 61 | 62 | export function deactivate() { 63 | stopServer(); 64 | } 65 | 66 | 67 | /* Commands 68 | *********************************************/ 69 | 70 | export type StartCommandArguments = { 71 | blenderExecutable?: BlenderExecutableSettings; 72 | blendFilepaths?: string[] 73 | // run python script after degugger is attached 74 | script?: string 75 | // additionalArguments?: string[]; // support someday 76 | } 77 | 78 | export async function COMMAND_start(args?: StartCommandArguments) { 79 | let blenderToRun = getDefaultBlenderSettings() 80 | let filePaths: string[] | undefined = undefined 81 | let script: string | undefined = undefined 82 | if (args !== undefined) { 83 | script = args.script 84 | if (args.blenderExecutable !== undefined) { 85 | if (args.blenderExecutable.path !== undefined) { 86 | blenderToRun = args.blenderExecutable 87 | } 88 | filePaths = args.blendFilepaths 89 | } 90 | } 91 | 92 | if (blenderToRun === undefined) { 93 | await LaunchAnyInteractive(filePaths, script) 94 | } else { 95 | await LaunchAny(blenderToRun, filePaths, script) 96 | } 97 | } 98 | 99 | async function COMMAND_openWithBlender(resource: vscode.Uri) { 100 | const args: StartCommandArguments = { 101 | blendFilepaths: [resource.fsPath] 102 | } 103 | COMMAND_start(args); 104 | } 105 | 106 | async function COMMAND_openFiles() { 107 | let resources = await vscode.window.showOpenDialog({ 108 | canSelectFiles: true, 109 | canSelectFolders: false, 110 | canSelectMany: true, 111 | filters: { 'Blender files': ['blend'] }, 112 | openLabel: "Select .blend file(s)" 113 | }); 114 | if (resources === undefined) { 115 | return Promise.reject(new Error('No .blend file selected.')); 116 | } 117 | const args: StartCommandArguments = { 118 | blendFilepaths: resources.map(r => r.fsPath) 119 | } 120 | COMMAND_start(args); 121 | } 122 | 123 | async function COMMAND_stop() { 124 | RunningBlenders.sendToAll({ type: 'stop' }); 125 | } 126 | 127 | let isSavingForReload = false; 128 | 129 | async function COMMAND_reloadAddons() { 130 | isSavingForReload = true; 131 | await vscode.workspace.saveAll(false); 132 | isSavingForReload = false; 133 | await reloadAddons(await AddonWorkspaceFolder.All()); 134 | } 135 | 136 | async function reloadAddons(addons: AddonWorkspaceFolder[]) { 137 | if (addons.length === 0) return; 138 | let instances = await RunningBlenders.getResponsive(); 139 | if (instances.length === 0) return; 140 | 141 | await rebuildAddons(addons); 142 | let names = await Promise.all(addons.map(a => a.getModuleName())); 143 | // Send source dirs so that the python script can determine if each addon is an extension or not. 144 | let dirs = await Promise.all(addons.map(a => a.getSourceDirectory())); 145 | instances.forEach((instance) => { 146 | void instance.post({ type: 'reload', names: names, dirs: dirs }).catch((error) => { 147 | instance.connectionErrors.push(error instanceof Error ? error : new Error(String(error))); 148 | }); 149 | }); 150 | } 151 | 152 | async function rebuildAddons(addons: AddonWorkspaceFolder[]) { 153 | await Promise.all(addons.map(a => a.buildIfNecessary())); 154 | } 155 | 156 | 157 | /* Event Handlers 158 | ***************************************/ 159 | 160 | async function HANDLER_updateOnSave(document: vscode.TextDocument) { 161 | if (isSavingForReload) return; 162 | let addons = await AddonWorkspaceFolder.All(); 163 | await reloadAddons(addons.filter(a => a.reloadOnSave)); 164 | } 165 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import * as vscode from 'vscode'; 4 | import * as crypto from 'crypto'; 5 | import { BlenderExecutableSettings } from './blender_executable'; 6 | 7 | const CANCEL = 'CANCEL'; 8 | 9 | export function cancel() { 10 | return new Error(CANCEL); 11 | } 12 | 13 | export async function waitUntilTaskEnds(taskName: string) { 14 | return new Promise(resolve => { 15 | let disposable = vscode.tasks.onDidEndTask(e => { 16 | if (e.execution.task.name === taskName) { 17 | disposable.dispose(); 18 | resolve(); 19 | } 20 | }); 21 | }); 22 | } 23 | 24 | export async function executeTask(taskName: string, wait: boolean = false) { 25 | await vscode.commands.executeCommand('workbench.action.tasks.runTask', taskName); 26 | if (wait) { 27 | await waitUntilTaskEnds(taskName); 28 | } 29 | } 30 | 31 | export function getWorkspaceFolders() { 32 | let folders = vscode.workspace.workspaceFolders; 33 | if (folders === undefined) return []; 34 | else return folders; 35 | } 36 | 37 | export function getAnyWorkspaceFolder() { 38 | let folders = getWorkspaceFolders(); 39 | if (folders.length === 0) { 40 | throw new Error('no workspace folder found'); 41 | } 42 | return folders[0]; 43 | } 44 | 45 | export function handleErrors(func: (args?: any) => Promise) { 46 | return async (args?: any) => { 47 | try { 48 | await func(args); 49 | } 50 | catch (err: any) { 51 | if (err instanceof Error) { 52 | if (err.message !== CANCEL) { 53 | vscode.window.showErrorMessage(err.message); 54 | } 55 | } 56 | } 57 | }; 58 | } 59 | 60 | export function getRandomString(length: number = 12) { 61 | return crypto.randomBytes(length).toString('hex').substring(0, length); 62 | } 63 | 64 | export function readTextFile(path: string) { 65 | return new Promise((resolve, reject) => { 66 | fs.readFile(path, 'utf8', (err, data) => { 67 | if (err !== null) { 68 | reject(new Error(`Could not read the file: ${path}`)); 69 | } 70 | else { 71 | resolve(data); 72 | } 73 | }); 74 | }); 75 | } 76 | 77 | export async function writeTextFile(path: string, content: string) { 78 | return new Promise((resove, reject) => { 79 | fs.writeFile(path, content, err => { 80 | if (err !== null) { 81 | return reject(err); 82 | } 83 | else { 84 | resove(); 85 | } 86 | }); 87 | }); 88 | } 89 | 90 | export async function renamePath(oldPath: string, newPath: string) { 91 | return new Promise((resolve, reject) => { 92 | fs.rename(oldPath, newPath, err => { 93 | if (err !== null) { 94 | reject(err); 95 | } 96 | else { 97 | resolve(); 98 | } 99 | }); 100 | }); 101 | } 102 | 103 | export async function copyFile(from: string, to: string) { 104 | return new Promise((resolve, reject) => { 105 | fs.copyFile(from, to, err => { 106 | if (err === null) resolve(); 107 | else reject(err); 108 | }); 109 | }); 110 | } 111 | 112 | export async function pathExists(path: string) { 113 | return new Promise(resolve => { 114 | fs.stat(path, (err, stats) => { 115 | resolve(err === null); 116 | }); 117 | }); 118 | } 119 | 120 | export async function pathsExist(paths: string[]) { 121 | let promises = paths.map(p => pathExists(p)); 122 | let results = await Promise.all(promises); 123 | return results.every(v => v); 124 | } 125 | 126 | export async function getSubfolders(root: string) { 127 | return new Promise((resolve, reject) => { 128 | fs.readdir(root, { encoding: 'utf8' }, async (err, files) => { 129 | if (err !== null) { 130 | reject(err); 131 | return; 132 | } 133 | 134 | let folders = []; 135 | for (let name of files) { 136 | let fullpath = path.join(root, name); 137 | if (await isDirectory(fullpath)) { 138 | folders.push(fullpath); 139 | } 140 | } 141 | 142 | resolve(folders); 143 | }); 144 | }); 145 | } 146 | 147 | export async function isDirectory(filepath: string) { 148 | return new Promise(resolve => { 149 | fs.stat(filepath, (err, stat) => { 150 | if (err !== null) resolve(false); 151 | else resolve(stat.isDirectory()); 152 | }); 153 | }); 154 | } 155 | 156 | export function getConfig(resource: vscode.Uri | undefined = undefined) { 157 | return vscode.workspace.getConfiguration('blender', resource); 158 | } 159 | 160 | export function getDefaultBlenderSettings(): BlenderExecutableSettings | undefined { 161 | let config = getConfig(); 162 | const settingsBlenderPaths = (config.get('executables')); 163 | const defaultBlenders = settingsBlenderPaths.filter(item => item.isDefault) 164 | const defaultBlender = defaultBlenders[0] 165 | return defaultBlender 166 | } 167 | 168 | export async function runTask( 169 | name: string, 170 | execution: vscode.ProcessExecution | vscode.ShellExecution, 171 | vscode_identifier: string, 172 | target: vscode.WorkspaceFolder = getAnyWorkspaceFolder(), 173 | ) { 174 | let taskDefinition = { type: vscode_identifier }; 175 | let source = 'blender'; 176 | let problemMatchers: string[] = []; 177 | if (execution.options === undefined) 178 | execution.options = {} 179 | if (execution.options.env === undefined) 180 | execution.options.env = {} 181 | execution.options.env['VSCODE_IDENTIFIER'] = vscode_identifier; 182 | let task = new vscode.Task(taskDefinition, target, name, source, execution, problemMatchers); 183 | let taskExecution = await vscode.tasks.executeTask(task); 184 | 185 | // if (wait) { 186 | // return new Promise(resolve => { 187 | // let disposable = vscode.tasks.onDidEndTask(e => { 188 | // if (e.execution.task.definition.type === vscode_identifier) { 189 | // disposable.dispose(); 190 | // resolve(taskExecution); 191 | // } 192 | // }); 193 | // }); 194 | // } 195 | // else { 196 | return taskExecution; 197 | // } 198 | } 199 | 200 | export function addFolderToWorkspace(folder: string) { 201 | /* Warning: This might restart all extensions if there was no folder before. */ 202 | vscode.workspace.updateWorkspaceFolders(getWorkspaceFolders().length, null, { uri: vscode.Uri.file(folder) }); 203 | } 204 | 205 | export function nameToIdentifier(name: string) { 206 | return name.toLowerCase().replace(/\W+/, '_'); 207 | } 208 | 209 | export function nameToClassIdentifier(name: string) { 210 | let parts = name.split(/\W+/); 211 | let result = ''; 212 | let allowNumber = false; 213 | for (let part of parts) { 214 | if (part.length > 0 && (allowNumber || !startsWithNumber(part))) { 215 | result += part.charAt(0).toUpperCase() + part.slice(1); 216 | allowNumber = true; 217 | } 218 | } 219 | return result; 220 | } 221 | 222 | export function startsWithNumber(text: string) { 223 | return text.charAt(0).match(/[0-9]/) !== null; 224 | } 225 | 226 | export function multiReplaceText(text: string, replacements: object) { 227 | for (let old of Object.keys(replacements)) { 228 | let matcher = RegExp(old, 'g'); 229 | text = text.replace(matcher, (replacements)[old]); 230 | } 231 | return text; 232 | } 233 | 234 | export function isValidPythonModuleName(text: string): boolean { 235 | let match = text.match(/^[_a-z][_0-9a-z]*$/i); 236 | return match !== null; 237 | } 238 | 239 | export function toTitleCase(str: string) { 240 | return str.replace( 241 | /\w\S*/g, 242 | text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase() 243 | ); 244 | } -------------------------------------------------------------------------------- /src/commands_scripts.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as vscode from 'vscode'; 3 | import { RunningBlenders } from './communication'; 4 | import { getAreaTypeItems } from './commands_scripts_data_loader'; 5 | import { COMMAND_start, outputChannel, StartCommandArguments } from './extension'; 6 | import { templateFilesDir } from './paths'; 7 | import { letUserPickItem, PickItem } from './select_utils'; 8 | import { addFolderToWorkspace, cancel, copyFile, getConfig, getRandomString, pathExists } from './utils'; 9 | 10 | export function COMMAND_runScript_registerCleanup(): vscode.Disposable[] { 11 | // const disposableDebugSessionListener = vscode.debug.onDidTerminateDebugSession(session => { 12 | // // if (session.name !== 'Debug Blender' && !session.name.startsWith('Python at Port ')) 13 | // // return 14 | // const id = session.configuration.identifier; 15 | // RunningBlenders.kill(id); 16 | // }); 17 | const disposableTaskListener = vscode.tasks.onDidEndTaskProcess((event) => { 18 | if (event.execution.task.source !== 'blender') { 19 | return; 20 | } 21 | 22 | const id = event.execution.task.definition.type; 23 | RunningBlenders.kill(id); 24 | }); 25 | return [disposableTaskListener]; 26 | } 27 | 28 | type RunScriptCommandArguments = { 29 | path?: string; 30 | } & StartCommandArguments; 31 | 32 | export async function COMMAND_runScript(args?: RunScriptCommandArguments): Promise { 33 | const explicitScriptPath = args?.script ?? args?.path; 34 | let scriptPath: string | undefined = explicitScriptPath; 35 | 36 | if (scriptPath === undefined) { 37 | const editor = vscode.window.activeTextEditor; 38 | if (!editor) { 39 | throw new Error('no active script'); 40 | } 41 | 42 | const { document } = editor; 43 | await document.save(); 44 | outputChannel.appendLine(`Blender: Run Script: ${document.uri.fsPath}`); 45 | scriptPath = document.uri.fsPath; 46 | } 47 | 48 | if (!scriptPath) { 49 | throw new Error('script path could not be determined'); 50 | } 51 | 52 | const instances = RunningBlenders.getAlive(); 53 | 54 | if (instances.length > 0) { 55 | await RunningBlenders.sendToResponsive({ type: 'script', path: scriptPath }); 56 | return; 57 | } 58 | 59 | const commandArgs: StartCommandArguments = { 60 | script: scriptPath, 61 | blenderExecutable: args?.blenderExecutable, 62 | }; 63 | await COMMAND_start(commandArgs); 64 | } 65 | 66 | export async function COMMAND_newScript(): Promise { 67 | const [folderPath, filePath] = await getPathForNewScript(); 68 | await createNewScriptAtPath(filePath); 69 | 70 | await vscode.window.showTextDocument(vscode.Uri.file(filePath)); 71 | await vscode.commands.executeCommand('cursorBottom'); 72 | addFolderToWorkspace(folderPath); 73 | } 74 | 75 | export async function COMMAND_openScriptsFolder(): Promise { 76 | const folderPath = await getFolderForScripts(); 77 | addFolderToWorkspace(folderPath); 78 | } 79 | 80 | export async function COMMAND_setScriptContext(): Promise { 81 | const editor = vscode.window.activeTextEditor; 82 | if (!editor) { 83 | return; 84 | } 85 | 86 | const items = (await getAreaTypeItems()).map((item) => ({ 87 | label: item.name, 88 | description: item.identifier, 89 | })); 90 | const item = await letUserPickItem(items); 91 | 92 | if (typeof item.description !== 'string') { 93 | throw new Error('No context selected.'); 94 | } 95 | 96 | await setScriptContext(editor.document, item.description); 97 | } 98 | 99 | async function setScriptContext(document: vscode.TextDocument, areaType: string): Promise { 100 | const workspaceEdit = new vscode.WorkspaceEdit(); 101 | const [line, match] = findAreaContextLine(document); 102 | 103 | if (match === null) { 104 | workspaceEdit.insert(document.uri, new vscode.Position(0, 0), `# context.area: ${areaType}\n`); 105 | } else { 106 | const start = new vscode.Position(line, match[0].length); 107 | const end = new vscode.Position(line, document.lineAt(line).range.end.character); 108 | const range = new vscode.Range(start, end); 109 | workspaceEdit.replace(document.uri, range, areaType); 110 | } 111 | 112 | await vscode.workspace.applyEdit(workspaceEdit); 113 | } 114 | 115 | function findAreaContextLine(document: vscode.TextDocument): [number, RegExpMatchArray | null] { 116 | for (let lineIndex = 0; lineIndex < document.lineCount; lineIndex++) { 117 | const line = document.lineAt(lineIndex); 118 | const match = line.text.match(/^\s*#\s*context\.area\s*:\s*/i); 119 | if (match !== null) { 120 | return [lineIndex, match]; 121 | } 122 | } 123 | 124 | return [-1, null]; 125 | } 126 | 127 | async function getPathForNewScript(): Promise<[string, string]> { 128 | const folderPath = await getFolderForScripts(); 129 | const fileName = await askUser_ScriptFileName(folderPath); 130 | const filePath = path.join(folderPath, fileName); 131 | 132 | if (await pathExists(filePath)) { 133 | throw new Error('file exists already'); 134 | } 135 | 136 | return [folderPath, filePath]; 137 | } 138 | 139 | async function createNewScriptAtPath(filePath: string): Promise { 140 | const defaultScriptPath = path.join(templateFilesDir, 'script.py'); 141 | await copyFile(defaultScriptPath, filePath); 142 | } 143 | 144 | export interface ScriptFolderData { 145 | path: string; 146 | name: string; 147 | } 148 | 149 | async function getFolderForScripts(): Promise { 150 | const scriptFolders = getStoredScriptFolders(); 151 | 152 | const items: PickItem[] = scriptFolders.map((folderData) => { 153 | const useCustomName = folderData.name !== ''; 154 | return { 155 | label: useCustomName ? folderData.name : folderData.path, 156 | data: async () => folderData, 157 | }; 158 | }); 159 | 160 | items.push({ 161 | label: 'New Folder...', 162 | data: askUser_ScriptFolder, 163 | }); 164 | 165 | const item = await letUserPickItem(items); 166 | const folderData = await item.data(); 167 | 168 | if (!scriptFolders.some((data) => data.path === folderData.path)) { 169 | scriptFolders.push(folderData); 170 | const config = getConfig(); 171 | await config.update('scripts.directories', scriptFolders, vscode.ConfigurationTarget.Global); 172 | } 173 | 174 | return folderData.path; 175 | } 176 | 177 | export function getStoredScriptFolders(): ScriptFolderData[] { 178 | const config = getConfig(); 179 | const folders = config.get('scripts.directories'); 180 | return Array.isArray(folders) ? [...folders] : []; 181 | } 182 | 183 | async function askUser_ScriptFolder(): Promise { 184 | const selection = await vscode.window.showOpenDialog({ 185 | canSelectFiles: false, 186 | canSelectFolders: true, 187 | canSelectMany: false, 188 | openLabel: 'Script Folder', 189 | }); 190 | 191 | if (!selection || selection.length === 0) { 192 | throw cancel(); 193 | } 194 | 195 | const [folderUri] = selection; 196 | return { 197 | path: folderUri.fsPath, 198 | name: '', 199 | }; 200 | } 201 | 202 | async function askUser_ScriptFileName(folder: string): Promise { 203 | const defaultName = await getDefaultScriptName(folder); 204 | const input = await vscode.window.showInputBox({ 205 | value: defaultName, 206 | }); 207 | 208 | if (input === undefined) { 209 | throw cancel(); 210 | } 211 | 212 | const trimmedName = input.trim(); 213 | if (trimmedName.length === 0) { 214 | throw new Error('script name cannot be empty'); 215 | } 216 | 217 | return trimmedName.toLowerCase().endsWith('.py') ? trimmedName : `${trimmedName}.py`; 218 | } 219 | 220 | async function getDefaultScriptName(folder: string): Promise { 221 | // eslint-disable-next-line no-constant-condition 222 | while (true) { 223 | const name = `script ${getRandomString(10)}.py`; 224 | const candidatePath = path.join(folder, name); 225 | if (!(await pathExists(candidatePath))) { 226 | return name; 227 | } 228 | } 229 | } -------------------------------------------------------------------------------- /src/commands_new_addon.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as path from 'path'; 3 | import * as fs from 'fs'; 4 | import { templateFilesDir } from './paths'; 5 | import { letUserPickItem } from './select_utils'; 6 | import { 7 | cancel, readTextFile, writeTextFile, getWorkspaceFolders, 8 | addFolderToWorkspace, multiReplaceText, pathExists, 9 | isValidPythonModuleName, renamePath, toTitleCase 10 | } from './utils'; 11 | 12 | type AddonBuilder = (path: string, addonName: string, authorName: string, supportLegacy: boolean) => Promise; 13 | 14 | const addonTemplateDir = path.join(templateFilesDir, 'addons'); 15 | const manifestFile = path.join(templateFilesDir, 'blender_manifest.toml'); 16 | 17 | export async function COMMAND_newAddon() { 18 | const builder = await getNewAddonGenerator(); 19 | const [addonName, authorName, supportLegacy] = await askUser_SettingsForNewAddon(); 20 | let folderPath = await getFolderForNewAddon(); 21 | folderPath = await fixAddonFolderName(folderPath); 22 | const mainPath = await builder(folderPath, addonName, authorName, supportLegacy); 23 | 24 | await vscode.window.showTextDocument(vscode.Uri.file(mainPath)); 25 | addFolderToWorkspace(folderPath); 26 | } 27 | 28 | async function getNewAddonGenerator(): Promise { 29 | const items = []; 30 | items.push({ label: 'Simple', data: generateAddon_Simple }); 31 | items.push({ label: 'With Auto Load', data: generateAddon_WithAutoLoad }); 32 | const item = await letUserPickItem(items, 'Choose Template'); 33 | return item.data; 34 | } 35 | 36 | async function getFolderForNewAddon(): Promise { 37 | const items = []; 38 | 39 | for (const workspaceFolder of getWorkspaceFolders()) { 40 | const folderPath = workspaceFolder.uri.fsPath; 41 | if (await canAddonBeCreatedInFolder(folderPath)) { 42 | items.push({ data: async () => folderPath, label: folderPath }); 43 | } 44 | } 45 | 46 | if (items.length > 0) { 47 | items.push({ data: selectFolderForAddon, label: 'Open Folder...' }); 48 | const item = await letUserPickItem(items); 49 | return await item.data(); 50 | } 51 | return await selectFolderForAddon(); 52 | } 53 | 54 | async function selectFolderForAddon() { 55 | const value = await vscode.window.showOpenDialog({ 56 | canSelectFiles: false, 57 | canSelectFolders: true, 58 | canSelectMany: false, 59 | openLabel: 'New Addon' 60 | }); 61 | if (value === undefined) { 62 | return Promise.reject(cancel()); 63 | } 64 | const folderPath = value[0].fsPath; 65 | 66 | if (!(await canAddonBeCreatedInFolder(folderPath))) { 67 | let message: string = 'Cannot create new addon in this folder.'; 68 | message += ' Maybe it contains other files already.'; 69 | return Promise.reject(new Error(message)); 70 | } 71 | 72 | return folderPath; 73 | } 74 | 75 | async function canAddonBeCreatedInFolder(folder: string) { 76 | try { 77 | const stat = await fs.promises.stat(folder); 78 | if (!stat.isDirectory()) { 79 | return false; 80 | } 81 | 82 | const files = await fs.promises.readdir(folder); 83 | for (const name of files) { 84 | if (!name.startsWith('.')) { 85 | return false; 86 | } 87 | } 88 | 89 | return true; 90 | } catch { 91 | return false; 92 | } 93 | } 94 | 95 | async function fixAddonFolderName(folder: string) { 96 | let name = path.basename(folder); 97 | if (isValidPythonModuleName(name)) { 98 | return folder; 99 | } 100 | 101 | const items = []; 102 | const directory = path.dirname(folder); 103 | for (const newName of getFolderNameAlternatives(name)) { 104 | const candidate = path.join(directory, newName); 105 | if (!(await pathExists(candidate))) { 106 | items.push({ label: candidate, data: candidate }); 107 | } 108 | } 109 | items.push({ label: "Don't change the name.", data: folder }); 110 | 111 | const item = await letUserPickItem(items, 'Warning: This folder name should not be used.'); 112 | const newPath = item.data; 113 | if (folder !== newPath) { 114 | renamePath(folder, newPath); 115 | } 116 | return newPath; 117 | } 118 | 119 | function getFolderNameAlternatives(name: string): string[] { 120 | return [name.replace(/\W/, '_'), name.replace(/\W/, '')]; 121 | } 122 | 123 | async function askUser_SettingsForNewAddon() { 124 | const addonName = await vscode.window.showInputBox({ placeHolder: 'Addon Name' }); 125 | if (addonName === undefined) { 126 | return Promise.reject(cancel()); 127 | } 128 | if (addonName === '') { 129 | return Promise.reject(new Error('Can\'t create an addon without a name.')); 130 | } 131 | 132 | const authorName = await vscode.window.showInputBox({ placeHolder: 'Your Name' }); 133 | if (authorName === undefined) { 134 | return Promise.reject(cancel()); 135 | } 136 | if (authorName === '') { 137 | return Promise.reject(new Error('Can\'t create an addon without an author name.')); 138 | } 139 | 140 | const items = [ 141 | { label: 'Yes', data: true }, 142 | { label: 'No', data: false }, 143 | ]; 144 | const item = await letUserPickItem(items, 'Support legacy Blender versions (<4.2)?'); 145 | const supportLegacy = item.data; 146 | 147 | return [addonName, authorName, supportLegacy]; 148 | } 149 | 150 | async function generateAddon_Simple(folder: string, addonName: string, authorName: string, supportLegacy: boolean) { 151 | const srcDir = path.join(addonTemplateDir, 'simple'); 152 | 153 | const initSourcePath = path.join(srcDir, '__init__.py'); 154 | const initTargetPath = path.join(folder, '__init__.py'); 155 | await copyModifiedInitFile(initSourcePath, initTargetPath, addonName, authorName, supportLegacy); 156 | 157 | const manifestTargetPath = path.join(folder, 'blender_manifest.toml'); 158 | await copyModifiedManifestFile(manifestFile, manifestTargetPath, addonName, authorName); 159 | 160 | return manifestTargetPath; 161 | } 162 | 163 | async function generateAddon_WithAutoLoad(folder: string, addonName: string, authorName: string, supportLegacy: boolean) { 164 | const srcDir = path.join(addonTemplateDir, 'with_auto_load'); 165 | 166 | const initSourcePath = path.join(srcDir, '__init__.py'); 167 | const initTargetPath = path.join(folder, '__init__.py'); 168 | await copyModifiedInitFile(initSourcePath, initTargetPath, addonName, authorName, supportLegacy); 169 | 170 | const manifestTargetPath = path.join(folder, 'blender_manifest.toml'); 171 | await copyModifiedManifestFile(manifestFile, manifestTargetPath, addonName, authorName); 172 | 173 | const autoLoadSourcePath = path.join(srcDir, 'auto_load.py'); 174 | const autoLoadTargetPath = path.join(folder, 'auto_load.py'); 175 | await copyFileWithReplacedText(autoLoadSourcePath, autoLoadTargetPath, {}); 176 | 177 | try { 178 | const defaultFilePath = path.join(folder, await getDefaultFileName()); 179 | if (!(await pathExists(defaultFilePath))) { 180 | await writeTextFile(defaultFilePath, 'import bpy\n'); 181 | } 182 | return defaultFilePath; 183 | } 184 | catch { 185 | return manifestTargetPath; 186 | } 187 | } 188 | 189 | async function getDefaultFileName() { 190 | const items = [ 191 | { label: '__init__.py' }, 192 | { label: 'operators.py' }, 193 | ]; 194 | const item = await letUserPickItem(items, 'Open File'); 195 | return item.label; 196 | } 197 | 198 | async function copyModifiedInitFile(src: string, dst: string, addonName: string, authorName: string, supportLegacy: boolean) { 199 | const replacements = supportLegacy 200 | ? { 201 | ADDON_NAME: toTitleCase(addonName), 202 | AUTHOR_NAME: authorName, 203 | } 204 | : { 205 | // https://regex101.com/r/RmBWrk/1 206 | 'bl_info.+=.+{[\s\S]*}\s*': '', 207 | }; 208 | 209 | await copyFileWithReplacedText(src, dst, replacements); 210 | } 211 | 212 | async function copyModifiedManifestFile(src: string, dst: string, addonName: string, authorName: string) { 213 | const replacements = { 214 | ADDON_ID: addonName.toLowerCase().replace(/\s/g, '_'), 215 | ADDON_NAME: toTitleCase(addonName), 216 | AUTHOR_NAME: authorName, 217 | }; 218 | await copyFileWithReplacedText(src, dst, replacements); 219 | } 220 | 221 | async function copyFileWithReplacedText(src: string, dst: string, replacements: object) { 222 | const text = await readTextFile(src); 223 | const updatedText = multiReplaceText(text, replacements); 224 | await writeTextFile(dst, updatedText); 225 | } 226 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!TIP] 2 | > Check [CHANGELOG](./CHANGELOG.md) for changes and new features. 3 | > 4 | > Prefer the previous layout? Browse the [classic README](https://github.com/JacquesLucke/blender_vscode/blob/b4c6ebba67172d9425f28533e0ece5cac1977da6/README.md). 5 | 6 | # Blender VS Code 7 | 8 | **Blender addon development with python debugger.** Everything you need is available through the `Blender` command palette menu (press `Ctrl+Shift+P`). 9 | 10 | ## Table of Contents 11 | - [Blender VS Code](#blender-vs-code) 12 | - [Table of Contents](#table-of-contents) 13 | - [Quick Start](#quick-start) 14 | - [Addon Development](#addon-development) 15 | - [Creating a new addon](#creating-a-new-addon) 16 | - [Opening an existing addon](#opening-an-existing-addon) 17 | - [Environment Isolation](#environment-isolation) 18 | - [Script Tools](#script-tools) 19 | - [Customization \& Shortcuts](#customization--shortcuts) 20 | - [Keyboard Shortcuts](#keyboard-shortcuts) 21 | - [Troubleshooting \& Logs](#troubleshooting--logs) 22 | - [Status \& Contribution](#status--contribution) 23 | 24 | ## Quick Start 25 | 1. Install the extension the same way you install any VS Code extension (id: `JacquesLucke.blender-development`). 26 | 2. Open the addon folder (one per workspace) and press `Ctrl+Shift+P` → `Blender: Start`. 27 | 3. Choose a Blender executable (any Blender ≥ 2.8.34) and wait for the session to launch. 28 | 4. Use `Blender: Reload Addons` after editing your addon and `Blender: Run Script` to execute scripts. 29 | 30 | > Opening Blender for the first time may take longer because dependency libraries are set up automatically. 31 | 32 | ## Addon Development 33 | 34 | [Extensions](https://docs.blender.org/manual/en/4.2/advanced/extensions/getting_started.html) and legacy addons (pre Blender 4.2) are supported. 35 | For migration guide visit [Legacy vs Extension Add-ons](https://docs.blender.org/manual/en/4.2/advanced/extensions/addons.html#legacy-vs-extension-add-ons). 36 | VS code uses the [automatic logic to determine if you are using addon or extension](./EXTENSION-SUPPORT.md) 37 | 38 | > [!NOTE] 39 | > VS Code automatically creates permanent symlinks (junctions on Windows) so your addon is visble inside Blender: 40 | > - Addons: `bpy.utils.user_resource("SCRIPTS", path="addons")` 41 | > - Extensions: `bpy.utils.user_resource("EXTENSIONS", path="vscode_development")` 42 | 43 | ### Creating a new addon 44 | - Run **`Blender: New Addon`** to scaffold a ready-to-use addon folder. The wizard asks which template to use, where to save the addon (prefer an empty folder without spaces), what to name it, and who is authoring it. 45 | - Once the scaffold exists, open it in VS Code to start developing. All commands, including reload and script runners, work immediately because VS Code creates the required symlinks. 46 | 47 | ### Opening an existing addon 48 | - This extension works with folder-based addons or extensions. If your addon is just single file `something.py`, move it into a folder and rename the file to `__init__.py`. 49 | - Open the folder for your addon in VS Code, run `Ctrl+Shift+P` → `Blender: Start`, and point the command at a Blender executable (Blender ≥ 2.8.34). The terminal output appears inside VS Code and you can debug as usual with breakpoints. 50 | - The very first launch can take longer because Blender installs the required Python dependencies automatically; keep a stable internet connection during that run. 51 | - Debugging is limited to workspace files by default. Disable [`blender.addon.justMyCode`](vscode://settings/blender.addon.justMyCode) if you want to step into third-party libraries (caution: this can make Blender less stable in rare cases). 52 | - Use `Blender: Reload Addons` after each change (requires Blender started via the extension). 53 | - Enable [`blender.addon.reloadOnSave`](vscode://settings/blender.addon.reloadOnSave) to trigger reload automatically on save. 54 | 55 | 56 | > [!WARNING] 57 | > In some cases uninstalling addon using Blender Preferences UI interface [might lead to data loss](./EXTENSION-SUPPORT.md#uninstall-addon-and-cleanup). So don't use UI to uninstall, just delete the link manually. 58 | 59 | ### Environment Isolation 60 | Set [`blender.environmentVariables`](vscode://settings/blender.environmentVariables) to point Blender to a dedicated development workspace: 61 | 62 | ```json 63 | "blender.environmentVariables": { 64 | "BLENDER_USER_RESOURCES": "${workspaceFolder}/blender_vscode_development" 65 | } 66 | ``` 67 | 68 |
69 | 70 | 71 | This keeps settings, addons, and user scripts separate from your daily Blender setup. You can also specify the finer-grained `BLENDER_USER_*` variables listed here: 72 | 73 | 74 | ``` 75 | Environment Variables: 76 | $BLENDER_USER_RESOURCES Replace default directory of all user files. 77 | Other 'BLENDER_USER_*' variables override when set. 78 | $BLENDER_USER_CONFIG Directory for user configuration files. 79 | $BLENDER_USER_SCRIPTS Directory for user scripts. 80 | $BLENDER_USER_EXTENSIONS Directory for user extensions. 81 | $BLENDER_USER_DATAFILES Directory for user data files (icons, translations, ..). 82 | ``` 83 | 84 |
85 | 86 | ## Script Tools 87 | This extension helps you write, run, and debug standalone Blender scripts that are not full addons. 88 | 89 | > [!WARNING] 90 | > Running scripts from VS Code occasionally crashes Blender. Keep your work saved and restart Blender if it becomes unresponsive. Don't go crazy with you scripts! 91 | 92 | - Execute `Blender: New Script` and follow the prompts to create a script in your chosen folder. 93 | - Run `Blender: Run Script` to execute every script in any open Blender session started through VS Code. Blender will automatically start if no instances are running. 94 | - Insert a comment like `#context.area: VIEW_3D` or run `Blender: Set Script Context` to control where scripts execute. 95 | - Pass CLI arguments to python script by adding them after `--` in [`blender.additionalArguments`](vscode://settings/blender.additionalArguments) (they become available in `sys.argv`). Note: newer approach is to register command with [`bpy.utils.register_cli_command`](https://docs.blender.org/api/current/bpy.utils.html#bpy.utils.register_cli_command) (Blender 4.2 and newer) and use `--command` to call it. 96 | 97 | **Common pitfalls**: 98 | - Avoid calling `sys.exit` inside Blender scripts (see [sys.exit gotcha](https://docs.blender.org/api/current/info_gotchas_crashes.html#sys-exit)). 99 | - Prefer `bpy.utils.register_cli_command` when wiring command line entry points. 100 | 101 | 102 | ## Customization & Shortcuts 103 | The extension is driven by settings (search for `blender.` inside VS Code settings). A few useful ones: 104 | - [`blender.additionalArguments`](vscode://settings/blender.additionalArguments): pass extra CLI flags and optionally a default `.blend` file (prefer this as the last argument). 105 | - [`blender.preFileArguments`](vscode://settings/blender.preFileArguments) / [`blender.postFileArguments`](vscode://settings/blender.postFileArguments): control where Blender expects file names in the argument list. 106 | - [`blender.executables`](vscode://settings/blender.executables): register frequently used Blender installations and **mark one with `"isDefault": true` to keep prompts silent**. 107 | - [`blender.addon.justMyCode`](vscode://settings/blender.addon.justMyCode): disable to step into third-party libraries while debugging. 108 | - [`blender.addon.reloadOnSave`](vscode://settings/blender.addon.reloadOnSave): reload addons every time a workspace file changes while Blender is running. 109 | - [`blender.addon.logLevel`](vscode://settings/blender.addon.logLevel): control the verbosity of the Blender output channel for debugging. 110 | 111 | ### Keyboard Shortcuts 112 | Add entries to `keybindings.json` to trigger commands: 113 | 114 | ```json 115 | { 116 | "key": "ctrl+h", 117 | "command": "blender.start" 118 | } 119 | ``` 120 | 121 | For advanced usage (choose a specific executable or script): 122 | 123 | ```json 124 | { 125 | "key": "ctrl+h", 126 | "command": "blender.start", 127 | "args": { 128 | "blenderExecutable": { "path": "C:\\path\\blender.exe" }, 129 | "script": "C:\\path\\script.py" 130 | } 131 | } 132 | ``` 133 | 134 | Run scripts with shortcuts as well: 135 | 136 | ```json 137 | { 138 | "key": "ctrl+shift+enter", 139 | "command": "blender.runScript", 140 | "when": "editorLangId == 'python'" 141 | } 142 | ``` 143 | 144 | ## Troubleshooting & Logs 145 | - Use the latest VS Code and Blender builds. 146 | - Check `CHANGELOG.md` for breaking changes. 147 | - Search issues on GitHub before filing a new one. 148 | - Enable debug logs via [`blender.addon.logLevel`](vscode://settings/blender.addon.logLevel) and inspect the `Blender` output channel in VS Code. 149 | 150 | ## Status & Contribution 151 | - The extension is no longer in active feature development. 152 | - Bugs are welcome; please file issues with as much detail as possible. 153 | - Want to help? Follow the instructions in [DEVELOPMENT.md](./DEVELOPMENT.md) to get started. 154 | -------------------------------------------------------------------------------- /src/communication.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'http'; 2 | import type { IncomingMessage, ServerResponse } from 'http'; 3 | import * as vscode from 'vscode'; 4 | import axios from 'axios'; 5 | import { getConfig } from './utils'; 6 | import { attachPythonDebuggerToBlender } from './python_debugging'; 7 | import { BlenderTask } from './blender_executable'; 8 | 9 | const RESPONSIVE_LIMIT_MS = 1000; 10 | 11 | type JsonPayload = Record; 12 | 13 | 14 | /* Manage connected Blender instances 15 | ********************************************** */ 16 | 17 | export type AddonPathMapping = { src: string, load: string }; 18 | 19 | export class BlenderInstance { 20 | public readonly blenderPort: number; 21 | public readonly debugpyPort: number; 22 | public readonly justMyCode: boolean; 23 | public readonly path: string; 24 | public readonly scriptsFolder: string; 25 | public readonly addonPathMappings: AddonPathMapping[]; 26 | public readonly connectionErrors: Error[]; 27 | public readonly vscodeIdentifier: string; // can identify VS Code task and in HTTP communication 28 | 29 | constructor(blenderPort: number, debugpyPort: number, justMyCode: boolean, path: string, 30 | scriptsFolder: string, addonPathMappings: AddonPathMapping[], vscodeIdentifier: string) { 31 | this.blenderPort = blenderPort; 32 | this.debugpyPort = debugpyPort; 33 | this.justMyCode = justMyCode; 34 | this.path = path; 35 | this.scriptsFolder = scriptsFolder; 36 | this.addonPathMappings = addonPathMappings; 37 | this.connectionErrors = []; 38 | this.vscodeIdentifier = vscodeIdentifier; 39 | } 40 | 41 | async post(data: JsonPayload): Promise { 42 | await axios.post(this.address, data); 43 | } 44 | 45 | async ping(): Promise { 46 | try { 47 | await axios.get(`${this.address}/ping`); 48 | } catch (error) { 49 | this.connectionErrors.push(error instanceof Error ? error : new Error(String(error))); 50 | throw error; 51 | } 52 | } 53 | 54 | async isResponsive(timeout: number = RESPONSIVE_LIMIT_MS): Promise { 55 | const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeout)); 56 | try { 57 | await Promise.race([this.ping(), timeoutPromise]); 58 | return true; 59 | } catch { 60 | return false; 61 | } 62 | } 63 | 64 | attachDebugger(): Thenable { 65 | return attachPythonDebuggerToBlender(this.debugpyPort, this.path, this.justMyCode, this.scriptsFolder, this.addonPathMappings, this.vscodeIdentifier); 66 | } 67 | 68 | get address(): string { 69 | return `http://localhost:${this.blenderPort}`; 70 | } 71 | } 72 | 73 | export class RunningBlenderInstances { 74 | protected instances: BlenderInstance[]; 75 | protected tasks: BlenderTask[]; 76 | 77 | constructor() { 78 | this.instances = []; 79 | this.tasks = []; 80 | } 81 | 82 | registerInstance(instance: BlenderInstance): void { 83 | this.instances = this.instances.filter(item => item.vscodeIdentifier !== instance.vscodeIdentifier); 84 | this.instances.push(instance); 85 | } 86 | registerTask(task: BlenderTask): void { 87 | this.tasks.push(task); 88 | } 89 | 90 | public getTask(vscodeIdentifier: string): BlenderTask | undefined { 91 | return this.tasks.find(item => item.vscodeIdentifier === vscodeIdentifier); 92 | } 93 | public getInstance(vscodeIdentifier: string): BlenderInstance | undefined { 94 | return this.instances.find(item => item.vscodeIdentifier === vscodeIdentifier); 95 | } 96 | 97 | public kill(vscodeIdentifier: string): void { 98 | const task = this.getTask(vscodeIdentifier); 99 | task?.task.terminate(); 100 | this.tasks = this.tasks.filter(item => item.vscodeIdentifier !== vscodeIdentifier); 101 | this.instances = this.instances.filter(item => item.vscodeIdentifier !== vscodeIdentifier); 102 | } 103 | 104 | /** Blender with alive and responsive web server is considered running. 105 | * Note: no response might be caused by CPU usage or active breakpoint in debugger. 106 | */ 107 | async getResponsive(timeout: number = RESPONSIVE_LIMIT_MS): Promise { 108 | if (this.instances.length === 0) { 109 | return []; 110 | } 111 | 112 | const responsiveness = await Promise.all(this.instances.map(instance => instance.isResponsive(timeout))); 113 | return this.instances.filter((_, index) => responsiveness[index]); 114 | } 115 | 116 | /** There is no direct way to check if process is alive. 117 | * The correctness is guaranteed by correct cleanup in kill method. 118 | */ 119 | getAlive(): BlenderInstance[] { 120 | return this.instances; 121 | } 122 | 123 | async sendToResponsive(data: JsonPayload, timeout: number = RESPONSIVE_LIMIT_MS): Promise { 124 | const responsiveness = await Promise.all(this.instances.map(instance => instance.isResponsive(timeout))); 125 | const pending: Promise[] = []; 126 | 127 | for (let index = 0; index < this.instances.length; index++) { 128 | if (!responsiveness[index]) { 129 | continue; 130 | } 131 | 132 | const instance = this.instances[index]; 133 | try { 134 | const promise = instance.post(data).catch((error) => { 135 | instance.connectionErrors.push(error instanceof Error ? error : new Error(String(error))); 136 | }); 137 | pending.push(promise); 138 | } catch (error) { 139 | instance.connectionErrors.push(error instanceof Error ? error : new Error(String(error))); 140 | } 141 | } 142 | 143 | await Promise.all(pending); 144 | } 145 | 146 | sendToAll(data: JsonPayload): void { 147 | for (const instance of this.instances) { 148 | try { 149 | void instance.post(data).catch((error) => { 150 | instance.connectionErrors.push(error instanceof Error ? error : new Error(String(error))); 151 | }); 152 | } catch (error) { 153 | instance.connectionErrors.push(error instanceof Error ? error : new Error(String(error))); 154 | } 155 | } 156 | } 157 | } 158 | 159 | 160 | /* Own server 161 | ********************************************** */ 162 | 163 | export function startServer(): void { 164 | if (server) { 165 | return; 166 | } 167 | 168 | server = http.createServer(handleRequest); 169 | server.listen(); 170 | } 171 | 172 | export function stopServer(): void { 173 | if (!server) { 174 | return; 175 | } 176 | 177 | server.close(); 178 | server = undefined; 179 | } 180 | 181 | export function getServerPort(): number { 182 | if (!server) { 183 | throw new Error('Server has not been started.'); 184 | } 185 | 186 | const address = server.address(); 187 | if (!address || typeof address === 'string') { 188 | throw new Error('Server address is not available.'); 189 | } 190 | 191 | return address.port; 192 | } 193 | 194 | function handleRequest(request: IncomingMessage, response: ServerResponse): void { 195 | if (request.method !== 'POST') { 196 | response.writeHead(405).end('Method Not Allowed'); 197 | return; 198 | } 199 | 200 | const chunks: Buffer[] = []; 201 | request.on('data', chunk => { 202 | chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); 203 | }); 204 | 205 | request.on('error', () => { 206 | if (!response.writableEnded) { 207 | response.writeHead(500).end('Error receiving data'); 208 | } 209 | }); 210 | 211 | request.on('end', () => { 212 | if (response.writableEnded) { 213 | return; 214 | } 215 | const body = Buffer.concat(chunks).toString(); 216 | let payload: JsonPayload; 217 | try { 218 | payload = JSON.parse(body); 219 | } catch { 220 | response.writeHead(400).end('Invalid JSON'); 221 | return; 222 | } 223 | 224 | const type = typeof payload.type === 'string' ? payload.type : ''; 225 | 226 | switch (type) { 227 | case 'setup': { 228 | const config = getConfig(); 229 | const blenderPort = Number(payload.blenderPort); 230 | const debugpyPort = Number(payload.debugpyPort); 231 | const blenderPath = typeof payload.blenderPath === 'string' ? payload.blenderPath : ''; 232 | const scriptsFolder = typeof payload.scriptsFolder === 'string' ? payload.scriptsFolder : ''; 233 | const vscodeIdentifier = typeof payload.vscodeIdentifier === 'string' ? payload.vscodeIdentifier : ''; 234 | 235 | if (!Number.isFinite(blenderPort) || !Number.isFinite(debugpyPort) || blenderPath === '' || scriptsFolder === '' || vscodeIdentifier === '') { 236 | response.writeHead(400).end('Invalid setup payload'); 237 | return; 238 | } 239 | 240 | const addonPathMappings = Array.isArray(payload.addonPathMappings) 241 | ? (payload.addonPathMappings as AddonPathMapping[]).filter(item => typeof item?.src === 'string' && typeof item?.load === 'string') 242 | : []; 243 | const justMyCode = Boolean(config.get('addon.justMyCode')); 244 | const instance = new BlenderInstance(blenderPort, debugpyPort, justMyCode, blenderPath, scriptsFolder, addonPathMappings, vscodeIdentifier); 245 | response.end('OK'); 246 | 247 | const attachResult = instance.attachDebugger(); 248 | Promise.resolve(attachResult) 249 | .then(() => { 250 | RunningBlenders.registerInstance(instance); 251 | RunningBlenders.getTask(instance.vscodeIdentifier)?.onStartDebugging(); 252 | }) 253 | .catch((error: unknown) => { 254 | instance.connectionErrors.push(error instanceof Error ? error : new Error(String(error))); 255 | vscode.window.showErrorMessage('Failed to attach debugger to Blender instance.'); 256 | }); 257 | break; 258 | } 259 | case 'enableFailure': { 260 | vscode.window.showWarningMessage('Enabling the addon failed. See console.'); 261 | response.end('OK'); 262 | break; 263 | } 264 | case 'disableFailure': { 265 | vscode.window.showWarningMessage('Disabling the addon failed. See console.'); 266 | response.end('OK'); 267 | break; 268 | } 269 | case 'addonUpdated': { 270 | response.end('OK'); 271 | break; 272 | } 273 | default: { 274 | response.writeHead(400).end('Unknown type'); 275 | } 276 | } 277 | }); 278 | } 279 | 280 | let server: http.Server | undefined; 281 | export const RunningBlenders = new RunningBlenderInstances(); -------------------------------------------------------------------------------- /pythonFiles/include/blender_vscode/load_addons.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import sys 4 | import traceback 5 | from pathlib import Path 6 | from typing import List, Union, Optional, Dict 7 | 8 | import bpy 9 | 10 | from . import AddonInfo, log 11 | from .communication import send_dict_as_json 12 | from .environment import addon_directories, EXTENSIONS_REPOSITORY 13 | from .utils import is_addon_legacy, addon_has_bl_info 14 | 15 | LOG = log.getLogger() 16 | 17 | if bpy.app.version >= (4, 2, 0): 18 | _EXTENSIONS_DEFAULT_DIR = Path(bpy.utils.user_resource("EXTENSIONS", path=EXTENSIONS_REPOSITORY)) 19 | else: 20 | _EXTENSIONS_DEFAULT_DIR = None 21 | _ADDONS_DEFAULT_DIR = Path(bpy.utils.user_resource("SCRIPTS", path="addons")) 22 | 23 | 24 | def setup_addon_links(addons_to_load: List[AddonInfo]) -> List[Dict]: 25 | path_mappings: List[Dict] = [] 26 | 27 | # always make sure addons are in path, important when running fresh blender install 28 | # do it always to avoid very confusing logic in the loop below 29 | os.makedirs(_ADDONS_DEFAULT_DIR, exist_ok=True) 30 | if str(_ADDONS_DEFAULT_DIR) not in sys.path: 31 | sys.path.append(str(_ADDONS_DEFAULT_DIR)) 32 | 33 | remove_broken_addon_links() 34 | if bpy.app.version >= (4, 2, 0): 35 | ensure_extension_repo_exists(EXTENSIONS_REPOSITORY) 36 | remove_broken_extension_links() 37 | 38 | for addon_info in addons_to_load: 39 | try: 40 | load_path = _link_addon_or_extension(addon_info) 41 | except PermissionError as e: 42 | LOG.error( 43 | f"""ERROR: {e} 44 | Path "{e.filename}" can not be removed. **Please remove it manually!** Most likely causes: 45 | - Path requires admin permissions to remove 46 | - Windows only: You upgraded Blender version and imported old setting. Now links became real directories. 47 | - Path is a real directory with the same name as addon (removing might cause data loss!)""" 48 | ) 49 | raise e 50 | else: 51 | path_mappings.append({"src": str(addon_info.load_dir), "load": str(load_path)}) 52 | 53 | return path_mappings 54 | 55 | 56 | def _link_addon_or_extension(addon_info: AddonInfo) -> Path: 57 | if is_addon_legacy(addon_info.load_dir): 58 | if is_in_any_addon_directory(addon_info.load_dir): 59 | # blender knows about addon and can load it 60 | load_path = addon_info.load_dir 61 | else: # addon is in external dir or is in extensions dir 62 | _remove_duplicate_addon_links(addon_info) 63 | load_path = _ADDONS_DEFAULT_DIR / addon_info.module_name 64 | create_link_in_user_addon_directory(addon_info.load_dir, load_path) 65 | else: 66 | if addon_has_bl_info(addon_info.load_dir) and is_in_any_addon_directory(addon_info.load_dir): 67 | # this addon is compatible with legacy addons and extensions 68 | # but user is developing it in addon directory. Treat it as addon. 69 | load_path = addon_info.load_dir 70 | elif is_in_any_extension_directory(addon_info.load_dir): 71 | # blender knows about extension and can load it 72 | load_path = addon_info.load_dir 73 | else: 74 | # blender does not know about extension, and it must be linked to default location 75 | _remove_duplicate_extension_links(addon_info) 76 | _remove_duplicate_addon_links(addon_info) 77 | os.makedirs(_EXTENSIONS_DEFAULT_DIR, exist_ok=True) 78 | load_path = _EXTENSIONS_DEFAULT_DIR / addon_info.module_name 79 | create_link_in_user_addon_directory(addon_info.load_dir, load_path) 80 | return load_path 81 | 82 | 83 | def _remove_duplicate_addon_links(addon_info: AddonInfo): 84 | existing_addon_with_the_same_target = does_addon_link_exist(addon_info.load_dir) 85 | while existing_addon_with_the_same_target: 86 | if existing_addon_with_the_same_target: 87 | LOG.info(f"Removing old link: {existing_addon_with_the_same_target}") 88 | os.remove(existing_addon_with_the_same_target) 89 | existing_addon_with_the_same_target = does_addon_link_exist(addon_info.load_dir) 90 | 91 | 92 | def _remove_duplicate_extension_links(addon_info: AddonInfo): 93 | existing_extension_with_the_same_target = does_extension_link_exist(addon_info.load_dir) 94 | while existing_extension_with_the_same_target: 95 | if existing_extension_with_the_same_target: 96 | LOG.info(f"Removing old link: {existing_extension_with_the_same_target}") 97 | os.remove(existing_extension_with_the_same_target) 98 | existing_extension_with_the_same_target = does_extension_link_exist(addon_info.load_dir) 99 | 100 | 101 | def _resolve_link_windows_cmd(path: Path) -> Optional[str]: 102 | IO_REPARSE_TAG_MOUNT_POINT = "0xa0000003" 103 | JUNCTION_INDICATOR = f"Reparse Tag Value : {IO_REPARSE_TAG_MOUNT_POINT}" 104 | try: 105 | output = subprocess.check_output(["fsutil", "reparsepoint", "query", str(path)]) 106 | except subprocess.CalledProcessError: 107 | return None 108 | output_lines = output.decode().split(os.linesep) 109 | if not output_lines[0].startswith(JUNCTION_INDICATOR): 110 | return None 111 | TARGET_PATH_INDICATOR = "Print Name: " 112 | for line in output_lines: 113 | if line.startswith(TARGET_PATH_INDICATOR): 114 | possible_target = line[len(TARGET_PATH_INDICATOR) :] 115 | if os.path.exists(possible_target): 116 | return possible_target 117 | 118 | 119 | def _resolve_link(path: Path) -> Optional[str]: 120 | """Return target if is symlink or junction""" 121 | try: 122 | return os.readlink(str(path)) 123 | except OSError as e: 124 | # OSError: [WinError 4390] The file or directory is not a reparse point 125 | if sys.platform == "win32": 126 | if e.winerror == 4390: 127 | return None 128 | else: 129 | # OSError: [Errno 22] Invalid argument: '/snap/blender/5088/4.2/extensions/system/readme.txt' 130 | if e.errno == 22: 131 | return None 132 | LOG.warning(f"can not resolve link target {e}") 133 | return None 134 | except ValueError as e: 135 | # there are major differences in python windows junction support (3.7.0 and 3.7.9 give different errors) 136 | if sys.platform == "win32": 137 | return _resolve_link_windows_cmd(path) 138 | else: 139 | LOG.warning(f"can not resolve link target {e}") 140 | return None 141 | 142 | 143 | def does_addon_link_exist(development_directory: Path) -> Optional[Path]: 144 | """Search default addon path and return first path that links to `development_directory`""" 145 | for file in os.listdir(_ADDONS_DEFAULT_DIR): 146 | existing_addon_dir = Path(_ADDONS_DEFAULT_DIR, file) 147 | target = _resolve_link(existing_addon_dir) 148 | if target: 149 | windows_being_windows = target.lstrip(r"\\?") 150 | if Path(windows_being_windows) == Path(development_directory): 151 | return existing_addon_dir 152 | return None 153 | 154 | 155 | def does_extension_link_exist(development_directory: Path) -> Optional[Path]: 156 | """Search all available extension paths and return path that links to `development_directory""" 157 | for repo in bpy.context.preferences.extensions.repos: 158 | if not repo.enabled: 159 | continue 160 | repo_dir = repo.custom_directory if repo.use_custom_directory else repo.directory 161 | if not os.path.isdir(repo_dir): 162 | continue # repo dir might not exist 163 | for file in os.listdir(repo_dir): 164 | existing_extension_dir = Path(repo_dir, file) 165 | target = _resolve_link(existing_extension_dir) 166 | if target: 167 | windows_being_windows = target.lstrip(r"\\?") 168 | if Path(windows_being_windows) == Path(development_directory): 169 | return existing_extension_dir 170 | return None 171 | 172 | 173 | def ensure_extension_repo_exists(extensions_repository: str): 174 | for repo in bpy.context.preferences.extensions.repos: 175 | repo: bpy.types.UserExtensionRepo 176 | if repo.module == extensions_repository: 177 | return repo 178 | LOG.debug(f'New extensions repository "{extensions_repository}" created') 179 | return bpy.context.preferences.extensions.repos.new(name=extensions_repository, module=extensions_repository) 180 | 181 | 182 | def remove_broken_addon_links(): 183 | for file in os.listdir(_ADDONS_DEFAULT_DIR): 184 | addon_dir = _ADDONS_DEFAULT_DIR / file 185 | if not addon_dir.is_dir(): 186 | continue 187 | target = _resolve_link(addon_dir) 188 | if target and not os.path.exists(target): 189 | LOG.info(f"Removing invalid link: {addon_dir} -> {target}") 190 | os.remove(addon_dir) 191 | 192 | 193 | def remove_broken_extension_links(): 194 | for repo in bpy.context.preferences.extensions.repos: 195 | if not repo.enabled: 196 | continue 197 | repo_dir = repo.custom_directory if repo.use_custom_directory else repo.directory 198 | repo_dir = Path(repo_dir) 199 | if not repo_dir.is_dir(): 200 | continue 201 | for file in os.listdir(repo_dir): 202 | existing_extension_dir = repo_dir / file 203 | target = _resolve_link(existing_extension_dir) 204 | if target and not os.path.exists(target): 205 | LOG.info(f"Removing invalid link: {existing_extension_dir} -> {target}") 206 | os.remove(existing_extension_dir) 207 | 208 | 209 | def load(addons_to_load: List[AddonInfo]): 210 | for addon_info in addons_to_load: 211 | if is_addon_legacy(Path(addon_info.load_dir)): 212 | bpy.ops.preferences.addon_refresh() 213 | addon_name = addon_info.module_name 214 | elif addon_has_bl_info(addon_info.load_dir) and is_in_any_addon_directory(addon_info.load_dir): 215 | # this addon is compatible with legacy addons and extensions 216 | # but user is developing it in addon directory. Treat it as addon. 217 | bpy.ops.preferences.addon_refresh() 218 | addon_name = addon_info.module_name 219 | else: 220 | bpy.ops.extensions.repo_refresh_all() 221 | addon_name = "bl_ext." + EXTENSIONS_REPOSITORY + "." + addon_info.module_name 222 | 223 | try: 224 | bpy.ops.preferences.addon_enable(module=addon_name) 225 | except Exception: 226 | traceback.print_exc() 227 | send_dict_as_json({"type": "enableFailure", "addonPath": str(addon_info.load_dir)}) 228 | 229 | 230 | def create_link_in_user_addon_directory(directory: Union[str, os.PathLike], link_path: Union[str, os.PathLike]): 231 | if os.path.exists(link_path): 232 | os.remove(link_path) 233 | 234 | if sys.platform == "win32": 235 | import _winapi 236 | 237 | _winapi.CreateJunction(str(directory), str(link_path)) 238 | else: 239 | os.symlink(str(directory), str(link_path), target_is_directory=True) 240 | 241 | 242 | def is_in_any_addon_directory(module_path: Path) -> bool: 243 | for path in addon_directories: 244 | if path == module_path.parent: 245 | return True 246 | return False 247 | 248 | 249 | def is_in_any_extension_directory(module_path: Path) -> Optional["bpy.types.UserExtensionRepo"]: 250 | for repo in bpy.context.preferences.extensions.repos: 251 | if not repo.enabled: 252 | continue 253 | repo_dir = repo.custom_directory if repo.use_custom_directory else repo.directory 254 | if Path(repo_dir) == module_path.parent: 255 | return repo 256 | return None 257 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | ## [0.0.30] - 2025-12-20 6 | 7 | ### Fixed 8 | 9 | * Run Script: do not launch new Blender if the instance is busy. 10 | 11 | ## [0.0.29] - 2025-11-29 12 | 13 | ### Fixed 14 | 15 | * Removed operators were still shown in command palette. 16 | 17 | ## [0.0.28] - 2025-11-28 18 | 19 | ### Changed 20 | 21 | * Revamped readme 22 | 23 | ### Fixed 24 | 25 | * Improve server startup error handling and retry logic ([#252](https://github.com/JacquesLucke/blender_vscode/pull/252)) 26 | * Remove deprecated request library in favor of axios 27 | 28 | ## [0.0.27] - 2025-11-25 29 | 30 | ### Fixed 31 | 32 | - Use IANA-recommended port range ([#246](https://github.com/JacquesLucke/blender_vscode/pull/246)) 33 | 34 | ### Changed 35 | 36 | - Disconnecting debug session will now hard close/terminate blender 37 | 38 | ### Added 39 | 40 | - A `blender.executable` can be now marked as default. 41 | - When no blender is marked as default, a notification will appear after and offer setting it as default 42 | ```json 43 | "blender.executables": [ 44 | { 45 | "name": "blender-4.5.1", 46 | "path": "C:\\...\\blender-4.5.1-windows-x64\\blender.exe", 47 | "isDefault": true 48 | } 49 | ] 50 | ``` 51 | - Run `Blender: Start` using single button by adding snippet to `keybindings.json`. Other commands are not supported. 52 | 53 | Simple example: 54 | ```json 55 | { 56 | "key": "ctrl+h", 57 | "command": "blender.start", 58 | } 59 | ``` 60 | 61 | Advanced example: 62 | ```json 63 | { 64 | "key": "ctrl+h", 65 | "command": "blender.start", 66 | "args": { 67 | "blenderExecutable": { 68 | "path": "C:\\...\\blender.exe" 69 | }, 70 | // optional, run script after debugger is attached, must be absolute path 71 | "script": "C\\script.py" 72 | } 73 | } 74 | ``` 75 | - Improvements for `Blender: Run Script`: 76 | - When **no** Blender instances are running, start blender automatically 77 | - When Blender instances are running, just run the script on all available instances (consistent with old behavior) 78 | - Run `Blender: Run Script` using single button by adding snippet to `keybindings.json`. 79 | 80 | Simple example: 81 | ```json 82 | { 83 | "key": "ctrl+shift+enter", 84 | "command": "blender.runScript", 85 | "when": "editorLangId == 'python'" 86 | } 87 | ``` 88 | 89 | Advanced example: 90 | ```json 91 | { 92 | "key": "ctrl+shift+enter", 93 | "command": "blender.runScript", 94 | "args": { 95 | // optional, same format as item in blender.executables 96 | // if missing user will be prompted to choose blender.exe or default blender.exe will be used 97 | "blenderExecutable": { 98 | "path": "C:\\...\\blender.exe" 99 | }, 100 | // optional, run script after debugger is attached, must be absolute path, defaults to current open file 101 | "script": "C:\\script.py" 102 | }, 103 | "when": "editorLangId == 'python'" 104 | } 105 | ``` 106 | 107 | ### Removed 108 | 109 | - Removed dependency on `ms-vscode.cpptools` what causes problems for other editors #235, #157. There are no plans to further support Blender Core development in this add-on. 110 | - Deprecated setting: `blender.core.buildDebugCommand` 111 | - Removed commands: 112 | - `blender.build` 113 | - `blender.buildAndStart` 114 | - `blender.startWithoutCDebugger` 115 | - `blender.buildPythonApiDocs` 116 | 117 | 118 | ## [0.0.26] - 2025-08-14 119 | 120 | ### Added 121 | 122 | - Run `Blender: Start` using single button by adding snippet to `keybindings.json`. Other Commands (like `Blender: Build and Start`) are not supported ([#199](https://github.com/JacquesLucke/blender_vscode/pull/199)). 123 | ```json 124 | { 125 | "key": "ctrl+h", 126 | "command": "blender.start", 127 | "args": { 128 | "blenderExecutable": { // optional, same format as item in blender.executables 129 | "path": "C:\\...\\blender.exe" // optional, if missing user will be prompted to choose blender.exe 130 | }, 131 | // define command line arguments in setting blender.additionalArguments 132 | } 133 | ``` 134 | - You can now configure VS Code internal log level using [`blender.addon.logLevel`](vscode://settings/blender.addon.logLevel) ([#198](https://github.com/JacquesLucke/blender_vscode/pull/198)) 135 | - to mute logs set log level to critical 136 | - to enable more logs set log level to debug 137 | - changing log level required blender restart 138 | - logs now have colors 139 | - Print python dependency version (log level info) and path (log level debug) even when it is already installed 140 | - 2 new operators: `blender.openWithBlender` - usable with right click from file explorer and `blender.openFiles` - usable from command palette ([#225](https://github.com/JacquesLucke/blender_vscode/pull/225)). 141 | - 2 new settings: `blender.preFileArguments` and `blender.postFileArguments` - they work only with above new commands. Placement of file path within command line arguments is important, this distincion was needed ([#225](https://github.com/JacquesLucke/blender_vscode/pull/225)). 142 | - `blender.postFileArguments` can not have `--` in args (it causes invalid syntax). Note: the `--` is used for passing arguments to python scripts inside blender. 143 | - `blender.additionalArguments` remains unchanged and will work only with `Blender: Start` command. 144 | 145 | ### Fixed 146 | 147 | - Linux only: temporary variable `linuxInode` will no longer be saved in VS Code settings ([#208](https://github.com/JacquesLucke/blender_vscode/pull/208)). 148 | 149 | ### Changed 150 | 151 | - updated dependency engines.vscode to 1.63 (to support beta releases) 152 | 153 | ## [0.0.25] - 2024-11-07 154 | 155 | ### Fixed 156 | 157 | - Remove clashes with legacy version when linking extension ([#210](https://github.com/JacquesLucke/blender_vscode/pull/210)). 158 | 159 | ## [0.0.24] - 2024-09-12 160 | 161 | ### Fixed 162 | 163 | - Starting Blender with C and Python debugger. 164 | - Pin Werkzeug library to avoid crash when opening add-ons in user-preferences ([#191](https://github.com/JacquesLucke/blender_vscode/pull/191)). 165 | 166 | ## [0.0.23] - 2024-09-06 167 | 168 | ### Added 169 | 170 | - Make `.blend` files use the Blender icon (#187) 171 | 172 | ### Fixed 173 | 174 | - Linux and MacOS: fixed `blender.executables` not showing when calling `Blender: Start` (introduced in #179) 175 | 176 | ## [0.0.22] - 2024-09-04 177 | 178 | ### Added 179 | - Add setting to specify to which repository install addons from VS code. Default value is `vscode_development` ([#180](https://github.com/JacquesLucke/blender_vscode/pull/180)) 180 | - Automatically add Blender executables to quick pick window. Search PATH and typical installation folders ([#179](https://github.com/JacquesLucke/blender_vscode/pull/179)) 181 | - If Blender executable does not exist indicate it in quick pick window ([#179](https://github.com/JacquesLucke/blender_vscode/pull/179)) 182 | - Support bl_order in auto_load.py (#118) 183 | - Allow user to develop addon even it is placed in directories like (#172) 184 | - `\4.2\scripts\addons` -> default dir for addons 185 | - `\4.2\extensions\blender_org` -> directory indicated by `bpy.context.preferences.extensions.repos` (list of directories) 186 | - Remove duplicate links to development (VSCode) directory (#172) 187 | - Remove broken links in addon and extension dir (#172) 188 | 189 | ### Changed 190 | - Updated dependencies. Now oldest supported VS Code version is `1.28.0` - version from September 2018. ([#147](https://github.com/JacquesLucke/blender_vscode/pull/147)) 191 | - Addon_update operator: Check more precisely which module to delete (#175) 192 | - Formatted all python code with `black -l 120` (#167) 193 | - Fix most of the user reported permission denied errors by changing python packages directory ([#177](https://github.com/JacquesLucke/blender_vscode/pull/177)): 194 | - Instead of installing to system python interpreter (`.\blender-4.2.0-windows-x64\4.2\python\Lib\site-packages`) 195 | - Install to local blender modules `%appdata%\Blender Foundation\Blender\4.2\scripts\modules` (path indicated by `bpy.utils.user_resource("SCRIPTS", path="modules")`). 196 | - Existing installations will work fine, it is not a breaking change 197 | 198 | ### Deprecated 199 | - setting `blender.allowModifyExternalPython` is now deprecated ([#177](https://github.com/JacquesLucke/blender_vscode/pull/177)) 200 | 201 | ### Fixed 202 | - Path to addon indicated by [`blender.addonFolders`](vscode://settings/blender.addonFolders) now works correctly for non-system drive (usually `C:`) on Windows ([#147](https://github.com/JacquesLucke/blender_vscode/pull/147)) 203 | - Pinned requests to version 2.29 to maintain compatibility with blender 2.80 ([#177](https://github.com/JacquesLucke/blender_vscode/pull/177)) 204 | - Find correct python path for blender 2.92 and before (#174). This partly fixes compatibility with blender 2.80. 205 | - "Blender: Run Script" will no longer open read-only file when hitting debug point (#142) 206 | 207 | ## [0.0.21] - 2024-07-16 208 | 209 | ### Added 210 | - Initial support for extensions for Blender 4.2. 211 | 212 | ## [0.0.20] - 2024-05-01 213 | 214 | ### Added 215 | - New `blender.addon.justMyCode` option. Previously, this was enabled by default and made it more difficult to debug addons that used external libraries. Restart Blender debug session after changing this option. 216 | 217 | ### Fixed 218 | - Prioritize addon path mappings to make it more likely that the right path is mapped. 219 | 220 | ## [0.0.19] - 2023-12-05 221 | 222 | ### Fixed 223 | - Fixed "Run Script" support for Blender 4.0. 224 | 225 | ## [0.0.18] - 2023-04-02 226 | 227 | ### Added 228 | - New `blender.environmentVariables` option. Can be used to define environment variables passed to 229 | blender on `Blender Start`. 230 | - New `blender.additionalArguments` option. Can be used to define additional arguments used when 231 | starting blender on `Blender Start`. 232 | 233 | ### Changed 234 | - Changed scope of `blender.executables` to `resource`. The value is firstly looked up in workspace 235 | settings before user settings. 236 | 237 | ### Fixed 238 | - Behavior of scripts that changed context like the active object. 239 | 240 | ## [0.0.17] - 2022-06-08 241 | 242 | ### Added 243 | - New `blender.addonFolders` option. Allows to specify absolute or root workspace relative 244 | directories where to search for addons. If not specified all workspace folders are searched. 245 | 246 | ### Fixed 247 | - Update `get-pip.py`. 248 | - Use `ensurepip` if available. 249 | 250 | ## [0.0.16] - 2021-06-15 251 | 252 | ### Fixed 253 | - Fix after api breakage. 254 | 255 | ## [0.0.15] - 2021-05-10 256 | 257 | ### Fixed 258 | - Use `debugpy` instead of deprecated `ptvsd`. 259 | 260 | ## [0.0.14] - 2021-02-27 261 | 262 | ### Fixed 263 | - Update `auto_load.py` again. 264 | 265 | ## [0.0.13] - 2021-02-21 266 | 267 | ### Fixed 268 | - Update `auto_load.py` to its latest version to support Blender 2.93. 269 | 270 | ## [0.0.12] - 2019-04-24 271 | 272 | ### Added 273 | - New `blender.addon.moduleName` setting. It controls the name if the generated symlink into the addon directory. By default, the original addon folder name is used. 274 | 275 | ### Fixed 276 | - Fix detection for possibly bad addon folder names. 277 | - Addon templates did not contain `version` field in `bl_info`. 278 | 279 | ## [0.0.11] - 2019-03-06 280 | 281 | ### Added 282 | - New `Blender: Open Scripts Folder` command. 283 | - New `CTX` variable that is passed into scripts to make overwriting the context easier. It can be used when calling operators (e.g. `bpy.ops.object.join(CTX)`). This will hopefully be replaced as soon as I find a more automatic reliable solution. 284 | 285 | ### Fixed 286 | - Scripts were debugged in new readonly documents on some platforms. 287 | - Addon package was put in `sys.path` in subprocesses for installation. 288 | - Warn user when new addon folder name is not a valid Python module name. 289 | - Path to Blender executable can contain spaces. 290 | 291 | ## [0.0.10] - 2018-12-02 292 | 293 | ### Added 294 | - Support for multiple addon templates. 295 | - Addon template with automatic class registration. 296 | - Initial `Blender: New Operator` command. 297 | 298 | ### Fixed 299 | - Handle path to `.app` file on MacOS correctly ([#5](https://github.com/JacquesLucke/blender_vscode/issues/5)). 300 | - Better error handling when there is no internet connection. 301 | -------------------------------------------------------------------------------- /src/blender_executable.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os'; 2 | import * as path from 'path'; 3 | import * as vscode from 'vscode'; 4 | import * as child_process from 'child_process'; 5 | import * as fs from 'fs'; 6 | 7 | import { launchPath } from './paths'; 8 | import { getServerPort, RunningBlenders } from './communication'; 9 | import { letUserPickItem, PickItem } from './select_utils'; 10 | import { getConfig, cancel, runTask, getAnyWorkspaceFolder, getRandomString } from './utils'; 11 | import { AddonWorkspaceFolder } from './addon_folder'; 12 | import { outputChannel, showNotificationAddDefault } from './extension'; 13 | import { getBlenderWindows } from './blender_executable_windows'; 14 | import { deduplicateSameHardLinks } from './blender_executable_linux'; 15 | 16 | export async function LaunchAnyInteractive(blend_filepaths?: string[], script?: string) { 17 | const executable = await getFilteredBlenderPath({ 18 | label: 'Blender Executable', 19 | selectNewLabel: 'Choose a new Blender executable...', 20 | predicate: () => true, 21 | setSettings: () => { } 22 | }); 23 | showNotificationAddDefault(executable); 24 | return await LaunchAny(executable, blend_filepaths, script); 25 | } 26 | 27 | export async function LaunchAny(executable: BlenderExecutableData, blend_filepaths?: string[], script?: string) { 28 | if (blend_filepaths === undefined || !blend_filepaths.length) { 29 | await launch(executable, undefined, script); 30 | return; 31 | } 32 | for (const blend_filepath of blend_filepaths) { 33 | await launch(executable, blend_filepath, script); 34 | } 35 | } 36 | 37 | export class BlenderTask { 38 | task: vscode.TaskExecution; 39 | script?: string; 40 | vscodeIdentifier: string; 41 | 42 | constructor(task: vscode.TaskExecution, vscode_identifier: string, script?: string) { 43 | this.task = task; 44 | this.script = script; 45 | this.vscodeIdentifier = vscode_identifier; 46 | } 47 | 48 | public onStartDebugging() { 49 | if (this.script !== undefined) { 50 | RunningBlenders.sendToResponsive({ type: 'script', path: this.script }); 51 | } 52 | } 53 | } 54 | 55 | export async function launch(data: BlenderExecutableData, blend_filepath?: string, script?: string) { 56 | const blenderArgs = getBlenderLaunchArgs(blend_filepath); 57 | const execution = new vscode.ProcessExecution( 58 | data.path, 59 | blenderArgs, 60 | { env: await getBlenderLaunchEnv() } 61 | ); 62 | outputChannel.appendLine(`Starting blender: ${data.path} ${blenderArgs.join(' ')}`); 63 | outputChannel.appendLine('With ENV Vars: ' + JSON.stringify(execution.options?.env, undefined, 2)); 64 | 65 | const vscode_identifier = getRandomString(); 66 | const task = await runTask('blender', execution, vscode_identifier); 67 | 68 | const blenderTask = new BlenderTask(task, vscode_identifier, script); 69 | RunningBlenders.registerTask(blenderTask); 70 | 71 | return task; 72 | } 73 | 74 | export type BlenderExecutableSettings = { 75 | path: string; 76 | name: string; 77 | linuxInode?: never; 78 | isDefault?: boolean; 79 | } 80 | 81 | export type BlenderExecutableData = { 82 | path: string; 83 | name: string; 84 | linuxInode?: number; 85 | isDefault?: boolean; 86 | } 87 | 88 | async function searchBlenderInSystem(): Promise { 89 | const blenders: BlenderExecutableData[] = []; 90 | if (process.platform === 'win32') { 91 | const windowsBlenders = await getBlenderWindows(); 92 | blenders.push(...windowsBlenders.map(blend_path => ({ path: blend_path, name: '' }))); 93 | } 94 | const separator = process.platform === 'win32' ? ';' : ':'; 95 | const path_env = process.env.PATH?.split(separator); 96 | if (path_env === undefined) { 97 | return blenders; 98 | } 99 | const exe = process.platform === 'win32' ? 'blender.exe' : 'blender'; 100 | for (const p of path_env) { 101 | const executable = path.join(p, exe); 102 | const stats = await fs.promises.stat(executable).catch(() => undefined); 103 | if (stats === undefined || !stats.isFile()) continue; 104 | blenders.push({ path: executable, name: '', linuxInode: stats.ino }); 105 | } 106 | return blenders; 107 | } 108 | 109 | interface BlenderType { 110 | label: string; 111 | selectNewLabel: string; 112 | predicate: (item: BlenderExecutableData) => boolean; 113 | setSettings: (item: BlenderExecutableData) => void; 114 | } 115 | 116 | async function getFilteredBlenderPath(type: BlenderType): Promise { 117 | let result: BlenderExecutableData[] = []; 118 | { 119 | const blenderPathsInSystem: BlenderExecutableData[] = await searchBlenderInSystem(); 120 | const deduplicatedBlenderPaths: BlenderExecutableData[] = deduplicateSamePaths(blenderPathsInSystem); 121 | if (process.platform !== 'win32') { 122 | try { 123 | result = await deduplicateSameHardLinks(deduplicatedBlenderPaths, true); 124 | } catch { // weird cases as network attached storage or FAT32 file system are not tested 125 | result = deduplicatedBlenderPaths; 126 | } 127 | } else { 128 | result = deduplicatedBlenderPaths; 129 | } 130 | } 131 | 132 | const config = getConfig(); 133 | const settingsBlenderPaths = (config.get('executables')).filter(type.predicate); 134 | { // deduplicate Blender paths twice: it preserves proper order in UI 135 | const deduplicatedBlenderPaths: BlenderExecutableData[] = deduplicateSamePaths(result, settingsBlenderPaths); 136 | if (process.platform !== 'win32') { 137 | try { 138 | result = [ 139 | ...settingsBlenderPaths, 140 | ...await deduplicateSameHardLinks(deduplicatedBlenderPaths, false, settingsBlenderPaths) 141 | ]; 142 | } catch { 143 | result = [...settingsBlenderPaths, ...deduplicatedBlenderPaths]; 144 | } 145 | } else { 146 | result = [...settingsBlenderPaths, ...deduplicatedBlenderPaths]; 147 | } 148 | } 149 | 150 | const quickPickItems: PickItem[] = []; 151 | for (const blenderPath of result) { 152 | quickPickItems.push({ 153 | data: async () => blenderPath, 154 | label: blenderPath.name || blenderPath.path, 155 | description: await fs.promises 156 | .stat(path.isAbsolute(blenderPath.path) ? blenderPath.path : path.join(getAnyWorkspaceFolder().uri.fsPath, blenderPath.path)) 157 | .then(() => undefined) 158 | .catch(() => 'File does not exist') 159 | }); 160 | } 161 | 162 | quickPickItems.push({ label: type.selectNewLabel, data: async () => askUser_FilteredBlenderPath(type) }); 163 | 164 | const pickedItem = await letUserPickItem(quickPickItems); 165 | const pathData: BlenderExecutableData = await pickedItem.data(); 166 | 167 | // update VScode settings 168 | if (settingsBlenderPaths.find(data => data.path === pathData.path) === undefined) { 169 | settingsBlenderPaths.push(pathData); 170 | const toSave: BlenderExecutableSettings[] = settingsBlenderPaths.map(item => ({ name: item.name, path: item.path, isDefault: item.isDefault })); 171 | config.update('executables', toSave, vscode.ConfigurationTarget.Global); 172 | } 173 | 174 | return pathData; 175 | } 176 | 177 | function deduplicateSamePaths(blenderPathsToReduce: BlenderExecutableData[], additionalBlenderPaths: BlenderExecutableData[] = []) { 178 | const deduplicatedBlenderPaths: BlenderExecutableData[] = []; 179 | const uniqueBlenderPaths: string[] = []; 180 | const isTheSamePath = (path_one: string, path_two: string) => path.relative(path_one, path_two) === ''; 181 | for (const item of blenderPathsToReduce) { 182 | if (uniqueBlenderPaths.some(path => isTheSamePath(item.path, path))) { 183 | continue; 184 | } 185 | if (additionalBlenderPaths.some(blenderPath => isTheSamePath(item.path, blenderPath.path))) { 186 | continue; 187 | } 188 | uniqueBlenderPaths.push(item.path); 189 | deduplicatedBlenderPaths.push(item); 190 | } 191 | return deduplicatedBlenderPaths; 192 | } 193 | 194 | async function askUser_FilteredBlenderPath(type: BlenderType): Promise { 195 | const filepath = await askUser_BlenderPath(type.label); 196 | const pathData: BlenderExecutableData = { 197 | path: filepath, 198 | name: '' 199 | }; 200 | type.setSettings(pathData); 201 | return pathData; 202 | } 203 | 204 | async function askUser_BlenderPath(openLabel: string) { 205 | const value = await vscode.window.showOpenDialog({ 206 | canSelectFiles: true, 207 | canSelectFolders: false, 208 | canSelectMany: false, 209 | openLabel: openLabel 210 | }); 211 | if (value === undefined) return Promise.reject(cancel()); 212 | let filepath = value[0].fsPath; 213 | 214 | if (os.platform() === 'darwin' && filepath.toLowerCase().endsWith('.app')) { 215 | filepath += '/Contents/MacOS/blender'; 216 | } 217 | 218 | await testIfPathIsBlender(filepath); 219 | return filepath; 220 | } 221 | 222 | async function testIfPathIsBlender(filepath: string) { 223 | const name = path.basename(filepath); 224 | 225 | if (!name.toLowerCase().startsWith('blender')) { 226 | return Promise.reject(new Error('Expected executable name to begin with \'blender\'')); 227 | } 228 | 229 | const testString = '###TEST_BLENDER###'; 230 | const command = `"${filepath}" --factory-startup -b --python-expr "import sys;print('${testString}');sys.stdout.flush();sys.exit()"`; 231 | 232 | return new Promise((resolve, reject) => { 233 | child_process.exec(command, {}, (err, stdout) => { 234 | const text = stdout.toString(); 235 | if (!text.includes(testString)) { 236 | let message = 'A simple check to test if the selected file is Blender failed.'; 237 | message += ' Please create a bug report when you are sure that the selected file is Blender 2.8 or newer.'; 238 | message += ' The report should contain the full path to the executable.'; 239 | reject(new Error(message)); 240 | } else { 241 | resolve(); 242 | } 243 | }); 244 | }); 245 | } 246 | 247 | function getBlenderLaunchArgs(blend_filepath?: string) { 248 | const config = getConfig(); 249 | const additional_args: string[] = []; 250 | if (blend_filepath !== undefined) { 251 | if (!fs.existsSync(blend_filepath)) { 252 | throw new Error(`File does not exist: '${blend_filepath}'`); 253 | } 254 | const pre_args = config.get('preFileArguments', []); 255 | const post_args = config.get('postFileArguments', []); 256 | for (const [index, arg] of pre_args.entries()) { 257 | if (arg === '--' || arg.startsWith('-- ')) { 258 | outputChannel.appendLine(`WARNING: ignoring any remaining arguments: '--' argument cannot be in preFileArguments. Please put arguments [${pre_args.slice(index).toString()}] in postFileArguments`); 259 | break; 260 | } 261 | additional_args.push(arg); 262 | } 263 | additional_args.push(blend_filepath); 264 | additional_args.push(...post_args); 265 | } else { 266 | additional_args.push(...(config.get('additionalArguments', []))); 267 | } 268 | const args = ['--python', launchPath, ...additional_args]; 269 | return args; 270 | } 271 | 272 | async function getBlenderLaunchEnv() { 273 | const config = getConfig(); 274 | const addons = await AddonWorkspaceFolder.All(); 275 | const loadDirsWithNames = await Promise.all(addons.map(a => a.getLoadDirectoryAndModuleName())); 276 | 277 | return { 278 | ADDONS_TO_LOAD: JSON.stringify(loadDirsWithNames), 279 | VSCODE_EXTENSIONS_REPOSITORY: config.get('addon.extensionsRepository'), 280 | VSCODE_LOG_LEVEL: config.get('addon.logLevel'), 281 | EDITOR_PORT: getServerPort().toString(), 282 | ...config.get('environmentVariables', {}) 283 | }; 284 | } 285 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blender-development", 3 | "displayName": "Blender Development", 4 | "description": "Tools to simplify Blender development.", 5 | "version": "0.0.30", 6 | "publisher": "JacquesLucke", 7 | "license": "MIT", 8 | "engines": { 9 | "vscode": "^1.63.0" 10 | }, 11 | "categories": [ 12 | "Other" 13 | ], 14 | "activationEvents": [ 15 | "onCommand:blender.start", 16 | "onCommand:blender.stop", 17 | "onCommand:blender.reloadAddons", 18 | "onCommand:blender.newAddon", 19 | "onCommand:blender.newScript", 20 | "onCommand:blender.runScript", 21 | "onCommand:blender.setScriptContext", 22 | "onCommand:blender.openScriptsFolder", 23 | "onCommand:blender.newOperator", 24 | "onCommand:blender.openWithBlender", 25 | "onCommand:blender.openFiles" 26 | ], 27 | "main": "./out/extension", 28 | "repository": { 29 | "type": "git", 30 | "url": "https://github.com/JacquesLucke/blender_vscode" 31 | }, 32 | "bugs": { 33 | "url": "https://github.com/JacquesLucke/blender_vscode/issues" 34 | }, 35 | "contributes": { 36 | "commands": [ 37 | { 38 | "command": "blender.start", 39 | "title": "Start", 40 | "category": "Blender" 41 | }, 42 | { 43 | "command": "blender.stop", 44 | "title": "Stop", 45 | "category": "Blender" 46 | }, 47 | { 48 | "command": "blender.reloadAddons", 49 | "title": "Reload Addons", 50 | "category": "Blender" 51 | }, 52 | { 53 | "command": "blender.newAddon", 54 | "title": "New Addon", 55 | "category": "Blender" 56 | }, 57 | { 58 | "command": "blender.newScript", 59 | "title": "New Script", 60 | "category": "Blender" 61 | }, 62 | { 63 | "command": "blender.runScript", 64 | "title": "Run Script", 65 | "category": "Blender" 66 | }, 67 | { 68 | "command": "blender.setScriptContext", 69 | "title": "Set Script Context", 70 | "category": "Blender" 71 | }, 72 | { 73 | "command": "blender.openScriptsFolder", 74 | "title": "Open Scripts Folder", 75 | "category": "Blender" 76 | }, 77 | { 78 | "command": "blender.newOperator", 79 | "title": "New Operator", 80 | "category": "Blender" 81 | }, 82 | { 83 | "command": "blender.openWithBlender", 84 | "title": "Open With Blender", 85 | "category": "Blender" 86 | }, 87 | { 88 | "command": "blender.openFiles", 89 | "title": "Open File(s)", 90 | "category": "Blender" 91 | } 92 | ], 93 | "configuration": [ 94 | { 95 | "title": "Blender", 96 | "properties": { 97 | "blender.executables": { 98 | "type": "array", 99 | "scope": "resource", 100 | "description": "Paths to Blender executables.", 101 | "items": { 102 | "type": "object", 103 | "title": "Single Blender Path", 104 | "properties": { 105 | "path": { 106 | "type": "string", 107 | "description": "Absolute file path to a Blender executable." 108 | }, 109 | "name": { 110 | "type": "string", 111 | "description": "Custom name for this Blender version." 112 | }, 113 | "isDefault": { 114 | "type": "boolean", 115 | "description": "Set default choice for blender executable. The interactive choise will no longer be offered.", 116 | "default": false 117 | } 118 | } 119 | } 120 | }, 121 | "blender.allowModifyExternalPython": { 122 | "type": "boolean", 123 | "scope": "application", 124 | "default": false, 125 | "description": "Deprecated: automatically installing modules in Python distributions outside of Blender.", 126 | "markdownDeprecationMessage": "**Deprecated**: modules are now installed to `bpy.utils.user_resource(\"SCRIPTS\", path=\"modules\")`.", 127 | "deprecationMessage": "Deprecated: modules are now installed to `bpy.utils.user_resource(\"SCRIPTS\", path=\"modules\")`." 128 | }, 129 | "blender.addon.reloadOnSave": { 130 | "type": "boolean", 131 | "scope": "resource", 132 | "default": false, 133 | "description": "Reload addon in Blender when a document is saved." 134 | }, 135 | "blender.addon.justMyCode": { 136 | "type": "boolean", 137 | "scope": "resource", 138 | "default": true, 139 | "description": "If true, debug only the code in this addon. Otherwise, allow stepping into external python library code." 140 | }, 141 | "blender.addon.buildTaskName": { 142 | "type": "string", 143 | "scope": "resource", 144 | "description": "Task that should be executed before the addon can be loaded into Blender." 145 | }, 146 | "blender.addon.loadDirectory": { 147 | "type": "string", 148 | "scope": "resource", 149 | "default": "auto", 150 | "examples": [ 151 | "auto", 152 | "./", 153 | "./build" 154 | ], 155 | "description": "Directory that contains the addon that should be loaded into Blender." 156 | }, 157 | "blender.addon.sourceDirectory": { 158 | "type": "string", 159 | "scope": "resource", 160 | "default": "auto", 161 | "examples": [ 162 | "auto", 163 | "./", 164 | "./source" 165 | ], 166 | "description": "Directory that contains the source code of the addon (used for path mapping for the debugger)." 167 | }, 168 | "blender.addon.moduleName": { 169 | "type": "string", 170 | "scope": "resource", 171 | "default": "auto", 172 | "examples": [ 173 | "auto", 174 | "my_addon_name" 175 | ], 176 | "description": "Name or the symlink that is created in Blenders addon folder." 177 | }, 178 | "blender.addon.extensionsRepository": { 179 | "type": "string", 180 | "scope": "resource", 181 | "default": "vscode_development", 182 | "examples": [ 183 | "vscode_development", 184 | "user_default", 185 | "blender_org" 186 | ], 187 | "description": "Blender extensions only: repository to use when developing addon. \nBlender -> Preferences -> Get Extensions -> Repositories (dropdown, top right)", 188 | "pattern": "^[\\w_]+$", 189 | "patternErrorMessage": "Must be valid name of Python module (allowed: lower/upper case, underscore)" 190 | }, 191 | "blender.core.buildDebugCommand": { 192 | "type": "string", 193 | "scope": "resource", 194 | "description": "Command used to compile Blender.", 195 | "default": "make debug", 196 | "deprecationMessage": "Removed in version 0.0.27" 197 | }, 198 | "blender.scripts.directories": { 199 | "type": "array", 200 | "scope": "application", 201 | "description": "Directories to store scripts in.", 202 | "items": { 203 | "type": "object", 204 | "title": "Single Script Directory", 205 | "properties": { 206 | "path": { 207 | "type": "string", 208 | "description": "Absolute file path to a Blender executable." 209 | }, 210 | "name": { 211 | "type": "string", 212 | "description": "Custom name for this Blender version." 213 | } 214 | } 215 | } 216 | }, 217 | "blender.addonFolders": { 218 | "type": "array", 219 | "scope": "resource", 220 | "description": "Array of paths to addon folders. Relative folders are resolved from the path of root workspace. If empty workspace folders are used.", 221 | "items": { 222 | "type": "string" 223 | } 224 | }, 225 | "blender.environmentVariables": { 226 | "type": "object", 227 | "scope": "resource", 228 | "title": "Startup Environment Variables", 229 | "description": "Environment variables set before Blender starts. Keys of the object are used as environment variable names, values as values.", 230 | "examples": [ 231 | { 232 | "BLENDER_USER_SCRIPTS": "C:/custom_scripts_folder", 233 | "BLENDER_USER_CONFIG": "C:/custom_user_config" 234 | } 235 | ] 236 | }, 237 | "blender.additionalArguments": { 238 | "type": "array", 239 | "scope": "resource", 240 | "title": "Command Line Additional Arguments", 241 | "markdownDescription": "Additional arguments used for starting Blender via the `Blender: Start` command. One argument per line. Note: option `--python` is already used by `blender_vscode` extension.", 242 | "items": { 243 | "type": "string" 244 | }, 245 | "examples": [ 246 | [ 247 | "--factory-startup" 248 | ] 249 | ] 250 | }, 251 | "blender.preFileArguments": { 252 | "type": "array", 253 | "scope": "resource", 254 | "title": "Command Line Arguments: Before File Path", 255 | "markdownDescription": "Arguments passed **before** file path, used only with `Open With Blender`(right click menu) and `Blender: Open File(s)` commands.\n\nPopulates 'preFileArgs' in `blender.exe [preFileArgs ...] [file] [postFileArgs ...]`", 256 | "items": { 257 | "type": "string" 258 | }, 259 | "examples": [ 260 | [ 261 | "--window-fullscreen" 262 | ] 263 | ] 264 | }, 265 | "blender.postFileArguments": { 266 | "type": "array", 267 | "scope": "resource", 268 | "title": "Command Line Arguments: After File Path", 269 | "markdownDescription": "Arguments passed **after** file path, used only with `Open With Blender`(right click menu) and `Blender: Open File(s)` commands.\n\nPopulates 'preFileArgs' in `blender.exe [preFileArgs ...] [file] [postFileArgs ...]`", 270 | "items": { 271 | "type": "string" 272 | }, 273 | "examples": [ 274 | [ 275 | "--render-output", 276 | "/tmp" 277 | ] 278 | ] 279 | }, 280 | "blender.addon.logLevel": { 281 | "type": "string", 282 | "scope": "resource", 283 | "description": "Log level for blender_vscode extension inside Blender. Debug is most verbose.", 284 | "default": "info", 285 | "enum": [ 286 | "debug-with-flask", 287 | "debug", 288 | "info", 289 | "warning", 290 | "error", 291 | "critical" 292 | ] 293 | } 294 | } 295 | } 296 | ], 297 | "menus": { 298 | "commandPalette": [ 299 | { 300 | "command": "blender.openWithBlender", 301 | "when": "false" 302 | } 303 | ], 304 | "explorer/context": [ 305 | { 306 | "command": "blender.openWithBlender", 307 | "group": "navigation", 308 | "when": "resourceScheme == file && resourceExtname == .blend" 309 | } 310 | ] 311 | }, 312 | "languages": [ 313 | { 314 | "id": "blend", 315 | "extensions": [ 316 | ".blend" 317 | ], 318 | "aliases": [ 319 | "Blend File", 320 | "blend" 321 | ], 322 | "icon": { 323 | "light": "./icons/blender_icon.ico", 324 | "dark": "./icons/blender_icon.ico" 325 | }, 326 | "filenames": [ 327 | "blender", 328 | "blender.exe", 329 | "blender-launcher", 330 | "blender-launcher.exe" 331 | ] 332 | } 333 | ] 334 | }, 335 | "scripts": { 336 | "vscode:prepublish": "npm run compile", 337 | "compile": "tsc -p ./", 338 | "watch": "tsc -watch -p ./", 339 | "lint": "eslint \"src/**/*.ts\" --max-warnings=0" 340 | }, 341 | "devDependencies": { 342 | "@stylistic/eslint-plugin": "^5.6.1", 343 | "@types/mocha": "^10.0.10", 344 | "@types/node": "^20.0.0", 345 | "@types/vscode": "^1.63.0", 346 | "@typescript-eslint/eslint-plugin": "^8.48.0", 347 | "@typescript-eslint/parser": "^8.48.0", 348 | "eslint": "^9.39.1", 349 | "eslint-config-prettier": "^10.1.8", 350 | "eslint-plugin-prettier": "^5.5.4", 351 | "prettier": "^3.7.0", 352 | "typescript": "^5.9.3" 353 | }, 354 | "dependencies": { 355 | "axios": "^1.13.2" 356 | }, 357 | "extensionDependencies": [ 358 | "ms-python.python" 359 | ] 360 | } -------------------------------------------------------------------------------- /pythonFiles/tests/blender_vscode/test_load_addons.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import os.path 3 | import sys 4 | from pathlib import Path 5 | from typing import Dict 6 | from unittest.mock import MagicMock, patch, Mock, PropertyMock 7 | 8 | import pytest 9 | 10 | 11 | @pytest.fixture(scope="function", autouse=True) 12 | def bpy_global_defaults(request: pytest.FixtureRequest): 13 | # selection of modules provided with blender 14 | # when fake-bpy-module is installed: override it 15 | # when bpy is not available: provide Mock for further patching 16 | sys.modules["bpy"] = Mock() 17 | sys.modules["addon_utils"] = Mock() 18 | # DANGER: patching imports with global scope. Use returned patches to modify those values. 19 | # those defaults are required by global variables in blender_vscode.environment 20 | # todo those values are different for different blender versions 21 | patches = { 22 | "bpy.app": patch( 23 | "bpy.app", 24 | binary_path="/bin/usr/blender", 25 | # binary_path_python="/bin/usr/blender/Lib/bin/python", # enable to emulate blender <2.92 26 | version=(4, 2, 0), 27 | spec_set=[ 28 | "binary_path", 29 | "version", 30 | "timers", 31 | # "binary_path_python", # enable to emulate blender <2.92 32 | ], 33 | ), 34 | "bpy.utils.user_resource": patch("bpy.utils.user_resource", side_effect=bpy_utils_user_resource), 35 | "addon_utils.paths": patch("addon_utils.paths", return_value=[]), 36 | } 37 | with contextlib.ExitStack() as stack: 38 | active_patches = {key: stack.enter_context(value) for key, value in patches.items()} 39 | yield active_patches 40 | 41 | # unload modules 42 | for module_name in [k for k in sys.modules.keys()]: 43 | if ( 44 | module_name.startswith("blender_vscode") 45 | or module_name.startswith("bpy") 46 | or module_name.startswith("addon_utils") 47 | ): 48 | try: 49 | del sys.modules[module_name] 50 | except: 51 | pass 52 | 53 | 54 | def bpy_utils_user_resource(resource_type, path=None): 55 | if resource_type == "SCRIPTS": 56 | return os.path.sep.join(("", "4.2", "scripts", path)) 57 | elif resource_type == "EXTENSIONS": 58 | return os.path.sep.join(("", "4.2", "extensions", path)) 59 | else: 60 | raise ValueError("This resource is not supported in tests") 61 | 62 | 63 | @patch("blender_vscode.load_addons.sys", path=[]) 64 | @patch("blender_vscode.load_addons.os.makedirs") 65 | @patch("blender_vscode.load_addons.is_addon_legacy", return_value=True) 66 | @patch("blender_vscode.load_addons.create_link_in_user_addon_directory") 67 | @patch("blender_vscode.load_addons.bpy.context", **{"preferences.extensions.repos": []}) 68 | @patch("blender_vscode.load_addons.os.listdir", return_value=[]) 69 | class TestSetupAddonLinksDevelopAddon: 70 | @patch("blender_vscode.load_addons.is_in_any_addon_directory", return_value=False) 71 | @patch("blender_vscode.load_addons.is_in_any_extension_directory", return_value=False) 72 | def test_setup_addon_links_develop_addon_in_external_dir( 73 | self, 74 | is_in_any_extension_directory: MagicMock, 75 | is_in_any_addon_directory: MagicMock, 76 | listdir: MagicMock, 77 | bpy_context: MagicMock, 78 | create_link_in_user_addon_directory: MagicMock, 79 | is_addon_legacy: MagicMock, 80 | makedirs: MagicMock, 81 | sys_mock: MagicMock, 82 | ): 83 | """Example: user is developing addon in `/home/user/blenderProject`""" 84 | from blender_vscode import AddonInfo 85 | from blender_vscode.load_addons import setup_addon_links 86 | 87 | addons_to_load = [AddonInfo(load_dir=Path("/home/user/blenderProject/test-addon"), module_name="test_addon")] 88 | 89 | mappings = setup_addon_links(addons_to_load=addons_to_load) 90 | 91 | assert mappings == [ 92 | { 93 | "src": os.path.sep.join("/home/user/blenderProject/test-addon".split("/")), 94 | "load": os.path.sep.join("/4.2/scripts/addons/test_addon".split("/")), 95 | } 96 | ] 97 | is_addon_legacy.assert_called_once() 98 | is_in_any_extension_directory.assert_not_called() 99 | create_link_in_user_addon_directory.assert_called_once_with( 100 | Path("/home/user/blenderProject/test-addon"), 101 | Path("/4.2/scripts/addons/test_addon"), 102 | ) 103 | 104 | @patch("blender_vscode.load_addons.is_in_any_addon_directory", return_value=False) 105 | @patch("blender_vscode.load_addons.is_in_any_extension_directory", return_value=True) 106 | def test_setup_addon_links_develop_addon_in_extension_dir( 107 | self, 108 | is_in_any_extension_directory: MagicMock, 109 | is_in_any_addon_directory: MagicMock, 110 | listdir: MagicMock, 111 | bpy_context: MagicMock, 112 | create_link_in_user_addon_directory: MagicMock, 113 | is_addon_legacy: MagicMock, 114 | makedirs: MagicMock, 115 | sys_mock: MagicMock, 116 | ): 117 | """Example: user is developing addon in `/4.2/scripts/extensions/blender_org`""" 118 | from blender_vscode import AddonInfo 119 | from blender_vscode.load_addons import setup_addon_links 120 | 121 | addons_to_load = [ 122 | AddonInfo(load_dir=Path("/4.2/scripts/extensions/blender_org/test-addon"), module_name="test_addon") 123 | ] 124 | 125 | mappings = setup_addon_links(addons_to_load=addons_to_load) 126 | 127 | assert mappings == [ 128 | { 129 | "src": os.path.sep.join("/4.2/scripts/extensions/blender_org/test-addon".split("/")), 130 | "load": os.path.sep.join("/4.2/scripts/addons/test_addon".split("/")), 131 | } 132 | ] 133 | is_addon_legacy.assert_called_once() 134 | create_link_in_user_addon_directory.assert_called_once() 135 | is_in_any_extension_directory.assert_not_called() 136 | 137 | @patch("blender_vscode.load_addons.is_in_any_addon_directory", return_value=True) 138 | @patch("blender_vscode.load_addons.is_in_any_extension_directory", return_value=False) 139 | def test_setup_addon_links_develop_addon_in_addon_dir( 140 | self, 141 | is_in_any_extension_directory: MagicMock, 142 | is_in_any_addon_directory: MagicMock, 143 | listdir: MagicMock, 144 | bpy_context: MagicMock, 145 | create_link_in_user_addon_directory: MagicMock, 146 | is_addon_legacy: MagicMock, 147 | makedirs: MagicMock, 148 | sys_mock: MagicMock, 149 | ): 150 | """Example: user is developing addon in `/4.2/scripts/extensions/blender_org`""" 151 | from blender_vscode import AddonInfo 152 | from blender_vscode.load_addons import setup_addon_links 153 | 154 | addons_to_load = [AddonInfo(load_dir=Path("/4.2/scripts/addons/test_addon"), module_name="test_addon")] 155 | 156 | mappings = setup_addon_links(addons_to_load=addons_to_load) 157 | 158 | assert mappings == [ 159 | { 160 | "src": os.path.sep.join("/4.2/scripts/addons/test_addon".split("/")), 161 | "load": os.path.sep.join("/4.2/scripts/addons/test_addon".split("/")), 162 | } 163 | ] 164 | is_in_any_addon_directory.assert_called_once() 165 | create_link_in_user_addon_directory.assert_not_called() 166 | is_in_any_extension_directory.assert_not_called() 167 | 168 | 169 | @patch("blender_vscode.load_addons.sys", path=[]) 170 | @patch("blender_vscode.load_addons.os.makedirs") 171 | @patch("blender_vscode.load_addons.is_addon_legacy", return_value=False) 172 | @patch("blender_vscode.load_addons.create_link_in_user_addon_directory") 173 | @patch("blender_vscode.load_addons.is_in_any_extension_directory", return_value=None) 174 | @patch("blender_vscode.load_addons.addon_has_bl_info", return_value=False) 175 | @patch("blender_vscode.load_addons.bpy.context", **{"preferences.extensions.repos": []}) 176 | @patch("blender_vscode.load_addons.os.listdir", return_value=[]) 177 | class TestSetupAddonLinksDevelopExtension: 178 | @patch("blender_vscode.load_addons.is_in_any_addon_directory", return_value=True) 179 | def test_setup_addon_links_develop_extension_in_addon_dir_is_treated_as_addon( 180 | self, 181 | is_in_any_addon_directory: MagicMock, 182 | listdir: MagicMock, 183 | bpy_context: MagicMock, 184 | addon_has_bl_info: MagicMock, 185 | is_in_any_extension_directory: MagicMock, 186 | create_link_in_user_addon_directory: MagicMock, 187 | is_addon_legacy: MagicMock, 188 | makedirs: MagicMock, 189 | sys_mock: MagicMock, 190 | ): 191 | """Example: user is developing extension in `/4.2/scripts/addon/test-extension` **but** extension supports legacy addons""" 192 | addon_has_bl_info.return_value = True 193 | is_in_any_extension_directory.return_value = None 194 | 195 | from blender_vscode import AddonInfo 196 | from blender_vscode.load_addons import setup_addon_links 197 | 198 | addons_to_load = [AddonInfo(load_dir=Path("/4.2/scripts/addons/test-extension"), module_name="test_extension")] 199 | 200 | mappings = setup_addon_links(addons_to_load=addons_to_load) 201 | 202 | assert mappings == [ 203 | { 204 | "src": os.path.sep.join("/4.2/scripts/addons/test-extension".split("/")), 205 | "load": os.path.sep.join("/4.2/scripts/addons/test-extension".split("/")), 206 | } 207 | ] 208 | create_link_in_user_addon_directory.assert_not_called() 209 | 210 | @patch("blender_vscode.load_addons.is_in_any_addon_directory", return_value=False) 211 | def test_setup_addon_links_develop_extension_in_extension_dir( 212 | self, 213 | is_in_any_addon_directory: MagicMock, 214 | listdir: MagicMock, 215 | bpy_context: MagicMock, 216 | addon_has_bl_info: MagicMock, 217 | is_in_any_extension_directory: MagicMock, 218 | create_link_in_user_addon_directory: MagicMock, 219 | is_addon_legacy: MagicMock, 220 | makedirs: MagicMock, 221 | sys_mock: MagicMock, 222 | ): 223 | """Example: user is developing extension in `/4.2/scripts/extensions/blender_org`""" 224 | repo_mock = Mock( 225 | enabled=True, 226 | use_custom_directory=False, 227 | custom_directory="", 228 | directory="/4.2/scripts/extensions/blender_org", 229 | ) 230 | is_in_any_extension_directory.return_value = repo_mock 231 | 232 | from blender_vscode import AddonInfo 233 | from blender_vscode.load_addons import setup_addon_links 234 | 235 | addons_to_load = [ 236 | AddonInfo(load_dir=Path("/4.2/scripts/extensions/blender_org/test-extension"), module_name="test_extension") 237 | ] 238 | 239 | mappings = setup_addon_links(addons_to_load=addons_to_load) 240 | 241 | assert mappings == [ 242 | { 243 | "src": os.path.sep.join("/4.2/scripts/extensions/blender_org/test-extension".split("/")), 244 | "load": os.path.sep.join("/4.2/scripts/extensions/blender_org/test-extension".split("/")), 245 | } 246 | ] 247 | is_in_any_addon_directory.assert_not_called() 248 | create_link_in_user_addon_directory.assert_not_called() 249 | is_in_any_extension_directory.assert_called() 250 | 251 | @patch("blender_vscode.load_addons.is_in_any_addon_directory", return_value=True) 252 | def test_setup_addon_links_develop_extension_in_addon_dir( 253 | self, 254 | is_in_any_addon_directory: MagicMock, 255 | listdir: MagicMock, 256 | bpy_context: MagicMock, 257 | addon_has_bl_info: MagicMock, 258 | is_in_any_extension_directory: MagicMock, 259 | create_link_in_user_addon_directory: MagicMock, 260 | is_addon_legacy: MagicMock, 261 | makedirs: MagicMock, 262 | sys_mock: MagicMock, 263 | ): 264 | """Example: user is developing extension in `/4.2/scripts/addons/test-extension`""" 265 | is_in_any_extension_directory.return_value = None 266 | 267 | from blender_vscode import AddonInfo 268 | from blender_vscode.load_addons import setup_addon_links 269 | 270 | addons_to_load = [AddonInfo(load_dir=Path("/4.2/scripts/addons/test-extension"), module_name="test_extension")] 271 | 272 | mappings = setup_addon_links(addons_to_load=addons_to_load) 273 | 274 | assert mappings == [ 275 | { 276 | "src": os.path.sep.join("/4.2/scripts/addons/test-extension".split("/")), 277 | "load": os.path.sep.join("/4.2/extensions/user_default/test_extension".split("/")), 278 | } 279 | ] 280 | is_in_any_addon_directory.assert_not_called() 281 | create_link_in_user_addon_directory.assert_called_once() 282 | is_in_any_extension_directory.assert_called() 283 | 284 | @patch("blender_vscode.load_addons.is_in_any_addon_directory", return_value=False) 285 | def test_setup_addon_links_develop_extension_in_external_dir( 286 | self, 287 | is_in_any_addon_directory: MagicMock, 288 | listdir: MagicMock, 289 | bpy_context: MagicMock, 290 | addon_has_bl_info: MagicMock, 291 | is_in_any_extension_directory: MagicMock, 292 | create_link_in_user_addon_directory: MagicMock, 293 | is_addon_legacy: MagicMock, 294 | makedirs: MagicMock, 295 | sys_mock: MagicMock, 296 | ): 297 | """Example: user is developing extension in `/home/user/blenderProjects/test-extension`""" 298 | from blender_vscode.load_addons import setup_addon_links 299 | from blender_vscode import AddonInfo 300 | 301 | addons_to_load = [ 302 | AddonInfo(load_dir=Path("/home/user/blenderProject/test-extension"), module_name="test_extension") 303 | ] 304 | 305 | mappings = setup_addon_links(addons_to_load=addons_to_load) 306 | 307 | assert mappings == [ 308 | { 309 | "src": os.path.sep.join("/home/user/blenderProject/test-extension".split("/")), 310 | "load": os.path.sep.join("/4.2/extensions/user_default/test_extension".split("/")), 311 | } 312 | ] 313 | create_link_in_user_addon_directory.assert_called_once() 314 | is_in_any_extension_directory.assert_called() 315 | 316 | 317 | class TestIsInAnyAddonDirectory: 318 | def test_is_in_any_addon_directory(self, bpy_global_defaults: Dict): 319 | bpy_global_defaults["addon_utils.paths"].return_value = ["/4.2/scripts/addons"] 320 | 321 | import blender_vscode.load_addons as load_addons 322 | 323 | ret = load_addons.is_in_any_addon_directory(Path("/4.2/scripts/addons/my-addon1")) 324 | assert ret 325 | 326 | ret = load_addons.is_in_any_addon_directory(Path("scripts/my-addon2")) 327 | assert not ret 328 | 329 | 330 | class TestIsInAnyExtensionDirectory: 331 | def test_is_in_any_extension_directory(self): 332 | repo_mock = Mock( 333 | enabled=True, 334 | use_custom_directory=False, 335 | custom_directory="", 336 | directory="/4.2/scripts/extensions/blender_org", 337 | ) 338 | with patch("blender_vscode.load_addons.bpy", **{"context.preferences.extensions.repos": [repo_mock]}) as repos: 339 | from blender_vscode import load_addons 340 | 341 | ret = load_addons.is_in_any_extension_directory(Path("/4.2/scripts/addons/my-addon1")) 342 | assert ret is None 343 | 344 | ret = load_addons.is_in_any_extension_directory(Path("/4.2/scripts/extensions/blender_org/my-addon2")) 345 | assert ret is repo_mock 346 | 347 | 348 | @patch("blender_vscode.load_addons.bpy.ops.preferences.addon_refresh") 349 | @patch("blender_vscode.load_addons.bpy.ops.preferences.addon_enable") 350 | @patch("blender_vscode.load_addons.bpy.ops.extensions.repo_refresh_all") 351 | @patch("blender_vscode.load_addons.addon_has_bl_info", return_value=False) 352 | @patch("blender_vscode.load_addons.is_in_any_addon_directory", return_value=False) 353 | class TestLoad: 354 | @patch("blender_vscode.load_addons.is_addon_legacy", return_value=True) 355 | def test_load_legacy_addon_from_addons_dir( 356 | self, 357 | is_addon_legacy: MagicMock, 358 | addon_has_bl_info: MagicMock, 359 | is_in_any_addon_directory: MagicMock, 360 | repo_refresh_all: MagicMock, 361 | addon_enable: MagicMock, 362 | addon_refresh: MagicMock, 363 | ): 364 | from blender_vscode import AddonInfo 365 | 366 | addons_to_load = [AddonInfo(load_dir=Path("/4.2/scripts/addons/test-addon"), module_name="test-addon")] 367 | from blender_vscode.load_addons import load 368 | 369 | load(addons_to_load=addons_to_load) 370 | 371 | addon_enable.assert_called_once_with(module="test-addon") 372 | is_addon_legacy.assert_called_once() 373 | addon_refresh.assert_called_once() 374 | repo_refresh_all.assert_not_called() 375 | 376 | @patch("blender_vscode.load_addons.is_addon_legacy", return_value=False) 377 | def test_load_extension_from_extensions_dir( 378 | self, 379 | is_addon_legacy: MagicMock, 380 | addon_has_bl_info: MagicMock, 381 | is_in_any_addon_directory: MagicMock, 382 | repo_refresh_all: MagicMock, 383 | addon_enable: MagicMock, 384 | addon_refresh: MagicMock, 385 | ): 386 | repo_mock = Mock( 387 | enabled=True, 388 | use_custom_directory=False, 389 | custom_directory="", 390 | directory="/4.2/scripts/extensions/blender_org", 391 | module="blender_org", 392 | ) 393 | 394 | with patch("blender_vscode.load_addons.bpy.context", **{"preferences.extensions.repos": [repo_mock]}): 395 | from blender_vscode import AddonInfo 396 | 397 | addons_to_load = [ 398 | AddonInfo(load_dir=Path("/4.2/scripts/extensions/blender_org/test-addon2"), module_name="testaddon2"), 399 | ] 400 | 401 | from blender_vscode.load_addons import load 402 | 403 | load(addons_to_load=addons_to_load) 404 | 405 | addon_enable.assert_called_once_with(module="bl_ext.blender_org.testaddon2") 406 | is_addon_legacy.assert_called_once() 407 | repo_refresh_all.assert_called_once() 408 | addon_refresh.assert_not_called() 409 | 410 | @patch("blender_vscode.load_addons.is_addon_legacy", return_value=False) 411 | def test_load_extension_extension_in_addon_dir_is_treated_as_addon( 412 | self, 413 | is_addon_legacy: MagicMock, 414 | addon_has_bl_info: MagicMock, 415 | is_in_any_addon_directory: MagicMock, 416 | repo_refresh_all: MagicMock, 417 | addon_enable: MagicMock, 418 | addon_refresh: MagicMock, 419 | ): 420 | addon_has_bl_info.return_value = True 421 | is_in_any_addon_directory.return_value = True 422 | from blender_vscode import AddonInfo 423 | 424 | addons_to_load = [AddonInfo(load_dir=Path("/4.2/scripts/addons/test-addon"), module_name="test-addon")] 425 | from blender_vscode.load_addons import load 426 | 427 | load(addons_to_load=addons_to_load) 428 | 429 | addon_enable.assert_called_once_with(module="test-addon") 430 | is_addon_legacy.assert_called_once() 431 | addon_refresh.assert_called_once() 432 | repo_refresh_all.assert_not_called() 433 | --------------------------------------------------------------------------------