Repository: xzhuah/BlenderAddonPackageTool Branch: master Commit: 4b07662a38fe Files: 34 Total size: 86.7 KB Directory structure: gitextract_qx3brcgf/ ├── .gitignore ├── LICENSE ├── README.md ├── addons/ │ ├── __init__.py │ └── sample_addon/ │ ├── __init__.py │ ├── blender_manifest.toml │ ├── config.py │ ├── i18n/ │ │ ├── __init__.py │ │ └── dictionary.py │ ├── operators/ │ │ ├── AddonOperators.py │ │ └── __init__.py │ ├── panels/ │ │ ├── AddonPanels.py │ │ └── __init__.py │ └── preference/ │ ├── AddonPreferences.py │ └── __init__.py ├── common/ │ ├── __init__.py │ ├── class_loader/ │ │ ├── __init__.py │ │ ├── auto_load.py │ │ └── module_installer.py │ ├── i18n/ │ │ ├── __init__.py │ │ ├── dictionary.py │ │ └── i18n.py │ ├── io/ │ │ ├── FileManagerClient.py │ │ └── __init__.py │ └── types/ │ ├── __init__.py │ └── framework.py ├── create.py ├── framework.py ├── main.py ├── release.py ├── requirements.txt ├── test.py ├── wheels/ │ └── README.md └── workflows/ └── addon_release.yml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/#use-with-ide .pdm.toml # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ config.ini ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 ZHU Xinyu Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Blender Add-on Development Framework and Packaging Tool ### Demo 1: Auto-update while developing 开发过程支持自动更新 ![Demo1](https://github.com/xzhuah/demo_resource/blob/main/blender_addon_tool_demo1.gif) ### Demo 2: Built-in I18n solution 内置字典翻译方案 ![Demo2](https://github.com/xzhuah/demo_resource/blob/main/blender_addon_tool_demo2.gif) ### Demo 3: Load Blender Component class like Operation, Panel etc automatically 自动加载Blender组件类(Operation, Panel等) ![Demo2](https://github.com/xzhuah/demo_resource/blob/main/blender_addon_tool_demo3.gif) The project has been updated to use MIT open source license since 2024.12.02. This project provides a lightweight, easy-to-use framework for developing and packaging Blender addons. The main features include: 1. A single command to create a template add-on, facilitating quick development. 1. Support developing multiple addons in one project, which allows you to reuse functions across different addons, and helps you package only necessary modules into the zip file. 1. Allows you to run and test your add-on in Blender with a single command. Support hot swap for code updates. You can see the updates in Blender immediately after saving the code. 1. A single command to package the add-on into an installable addon package, making it easier for users to install. The packaging tool automatically detects and includes dependencies needed for the add-on within your project. (Not including 3rd party libraries) 1. Provide utility functions for basic development, like an auto-load utility for automatic class loading and an internationalization (i18n) tool, to help new developers creating high-quality addons. 1. Support extension development in Blender 4.2 and later versions. You can choose to package your addon as a legacy addon or as an extension by setting the IS_EXTENSION configuration You can check out an overview about this framework on YouTube: - https://www.youtube.com/watch?v=eRSXO_WkY0s - https://youtu.be/udPBrXJZT1g The following external library will be installed automatically when you run the framework code, you can also install them manually: - https://github.com/nutti/fake-bpy-module - https://github.com/gorakhargosh/watchdog ## Basic Framework - [main.py](main.py): Configures the Blender path, add-on installation path, default add-on, package ignore files, and add-on release path, among other settings. - [test.py](test.py): A testing tool to run and test add-ons. - [create.py](create.py): A tool to create add-ons, allowing you to quickly create an add-on based on the `sample_addon` template. - [release.py](release.py): A packaging tool that packages add-ons into an installable package. - [framework.py](framework.py): The core business logic of the framework, which automates the development process. - [addons](addons): A directory to store add-ons, with each add-on in its own sub-directory. Use `create.py` to quickly create a new add-on. - [common](common): A directory to store shared utilities. ## Framework Development Guidelines Blender Version >= 2.93 Platform Supported: Windows, MacOs, Linux Each add-on, while adhering to the basic structure of a Blender add-on, should include a `config.py` file to configure the add-on's package name, ensuring it doesn't conflict with other add-ons. This project depends on the `addons` folder; do not rename this folder. When packaging an add-on, the framework will generate a __init__.py file in the add-on directory. By copying bl_info, and importing the register and unregister method from your target addon's __init__.py. Usually this won't cause any issue, but if you notice anything that might be related to this, please let us know. ### Notice for extension developers To meet the standard at https://docs.blender.org/manual/en/latest/advanced/extensions/addons.html#user-preferences-and-package You need to follow the following instruction when using preferences in your extension addon. These instructions are also applicable to the legacy addon, but not enforced. Since addons developed by this framework usually have submodules. To access preferences, you must use the __addon_name__ defined in the config.py file as the bl_idname of the preferences. Define: ```python class ExampleAddonPreferences(AddonPreferences): bl_idname = __addon_name__ ``` Access ```python from ..config import __addon_name__ addon_prefs = bpy.context.preferences.addons[__addon_name__].preferences # use addon_prefs addon_prefs.some_property ``` ## Usage 1. Clone this repository. 1. Open this project in your IDE. Optional: Configure the IDE to use the same python interpreter as Blender. 1. Note: For PyCharm users, change the value idea.max.intellisense.filesize in idea.properties file ( Help | Edit Custom Properties.) to more than 2600 because some modules have the issue of being too big for intelliSense to work. You might also need to associate the __init__.pyi file as the python File Types in ![setting](https://i.ibb.co/QcYZytw/script.png) to get the auto code completion working. 1. Configure the name of the addon you want to create (ACTIVE_ADDON) in [main.py](main.py). 1. Run create.py to create a new addon in your IDE. The first time you run this, it will download dependencies, including watchdog and fake-bpy-module. 1. Develop your addon in the newly created addon directory. 1. Run test.py to test your addon in Blender. 1. Run release.py to package your addon into an installable package. The packaged addon path will appears in the terminal when packaged successfully. ## Features Provided by the Framework 1. You don't need to worry about register and unregister classes in Blender add-ons. The framework automatically loads and unloads classes in your add-ons. You just need to define your classes in the addon's folder. Note that the classes that are automatically loaded need to be placed in a directory with an `__init__.py` file to be recognized and loaded by the framework's auto load mechanism. 1. You can use internationalization in your add-ons. Just add translations in the standard format to the `dictionary.py` file in the `i18n` folder of your add-on. 1. You can define RNA properties declaratively. Just follow the examples in the `__init__.py` file to add your RNA properties. The framework will automatically register and unregister your RNA properties. 1. You can choose to package your addon as a legacy addon or as an extension in Blender 4.2 and later versions. Just set the `IS_EXTENSION` configuration to switch between the two. The framework will convert absolute import to relative import for you when releasing. Notice only `from XXX.XXX import XXX` is supported, `import XXX.XX` is not supported for converting to relative import. 1. You can use the `ExpandableUi` class in `common/types/framework.py` to easily extend Blender's native UI components, such as menus, panels, pie menus, and headers. Just inherit from this class and implement the `draw` method. You can specify the ID of the native UI component you want to extend using `target_id` and specify whether to append or prepend using `expand_mode`. 1. You can use the `reg_order` decorator in `common/types/framework.py` to specify the order of registration for your classes. This is useful when you need to ensure that certain classes are registered before others. For example the initial order of Panels will be in the order they are registered. ## Add Optional Configuration File To avoid having to modify the configuration items in `main.py` every time you update the framework, you can create a `config.ini` file in the root directory of your project to store your configuration information. This file will override the configuration information in `main.py`. Here is an example of a `config.ini` file: ```ini [blender] ; path to the blender executable exe_path = C:/software/general/Blender/Blender3.5/blender.exe ; exe_path = C:/software/general/Blender/Blender3.6/blender.exe ; path to the addon directory, testing addon will be temporarily installed here ; usually you don't need to configure this since it can be derived from the exe_path addon_path = C:/software/general/Blender/Blender3.5/scripts/addons/ [default] ; name of the addon to be created, tested and released addon = sample_addon ; Whether the addon is an extension, if True, the addon will be packaged when released. is_extension = False ; the path to store released addon zip files. Do not release to your source code directory release_dir = C:/path/to/release/dir ; the path to store addon files used for testing, during testing, the framework will first release the addon to here and copy it to Blender's addon directory. Do not release to your source code directory test_release_dir = C:/path/to/test/release/dir ``` ## Contributions 1. Framework Updates: If you are using this framework in your project and need to migrate to a newer version, you will need to manually replace the framework files to get the new features. You may fork this project and use `git fetch upstream` to update. We are looking for more user-friendly migration experience. In general, we aim to keep the framework lightweight and avoid making structural changes. Most future updates are expected to just adding new features rather than making major changes to the framework structure. So unless you personally made changes to the framework code locally, you will only need to replace the old files with the new ones in future updates. 1. Breakpoint Debugging: The framework currently does not support breakpoint debugging within the IDE. Implementing this feature requires some modifications to the framework code, which may increase the complexity of using the framework. We are looking for a lightweight solution to enable this feature. However, in general, breakpoint debugging is not necessary for developing add-ons. Breakpoint debugging is helpful for complex add-ons features, but logging is sufficient in most of the cases. For this framework, breakpoint debugging would be a nice-to-have feature, but not a must-have. # Blender 插件开发框架及打包工具 本项目已于2024.12.02更新为MIT开源许可证。 本项目是一个轻量级的Blender插件开发框架和打包工具. 主要功能包括: 1. 一条命令创建模版插件,方便进行快速开发 1. 支持在一个项目中开发多个插件,可以让你在不同的插件之间复用函数功能,它可以自动检测功能模块之间的依赖关系,将相关联的模块打包到zip文件中,而不包含不必要的模块 1. 在IDE中可以通过一条命令在Blender上运行插件的测试, 支持代码热更新,保存代码后可以立即在Blender中看到变化 1. 一条命令将插件打包成一个安装包,方便用户安装,打包工具自动检测插件的依赖关系,自动打包插件所需的依赖文件( 不包括引用的外部库) 1. 提供了常用的插件开发工具,比如自动加载类的auto_load工具,提供国际化翻译的i18n工具,方便新手开发者进行高水平插件开发 1. 你可以选择将你的插件打包成传统插件或者扩展插件,只需要设置IS_EXTENSION配置即可切换,框架会在打包时自动将绝对导入转换为相对导入 注意只支持`from XXX.XXX import XXX`这种形式的转换,`import XXX.XX`这种形式的导入不支持转换为相对导入 1. 兼容Blender 4.2及以后版本的扩展开发,你可以选择将你的插件打包成传统插件或者扩展插件,只需要设置IS_EXTENSION配置即可切换 欢迎观看我们的中文视频教程: - [B站](https://www.bilibili.com/video/BV1Gn4y1d7Bp) - [深度技术讲解](https://www.bilibili.com/video/BV1VBqcY4E6x) - [YouTube](https://www.youtube.com/watch?v=Pjf7wg3IzDE&list=PLPkz3Ny42tJtxzw7xVUWvLI3FwEeETVOj&index=1&t=5s) 下外部库会在框架代码运行时自动安装,你也可以手动安装它们: - https://github.com/nutti/fake-bpy-module - https://github.com/gorakhargosh/watchdog ## 基础框架 [main.py](main.py): 可以配置Blender路径,插件安装路径,当前默认插件,插件发布路径等 [test.py](test.py): 测试工具,可以运行插件的测试 [create.py](create.py): 创建插件的工具,可以根据sample_addon模版快速创建一个插件 [release.py](release.py): 打包工具,可以将插件打包成一个安装包 [framework.py](framework.py): 框架的核心业务代码,用于实现开发流程的自动化 [addons](addons): 存放插件的目录,每个插件一个目录,使用create.py可以快速创建一个插件 [common](common): 存放公共工具的目录 ## 框架开发要求 Blender 版本 >= 2.93 支持的平台: Windows, MacOs, Linux 每个插件在符合Blender插件的结构基础上,需要有一个config.py文件用于配置插件的包名,避免与其他插件冲突。 注意项目依赖addons文件夹,请勿更改这个文件夹的名称。 在打包插件时,框架会在插件目录下生成一个__init__.py文件,这个__init__.py文件会复制你的插件的__init__.py文件中bl_info, 并导入register和unregister方法。 通常这不会引起任何问题,但如果你发现与这个有关的问题,请与我们联系。 ### 扩展插件开发注意事项 为了满足https://docs.blender.org/manual/en/latest/advanced/extensions/addons.html#user-preferences-and-package 的要求,当你在扩展插件中定义和使用偏好设置时,你需要遵循以下要求。这些要求也适用于传统插件,但不是强制的。 由于本框架开发的插件通常带有子模块,为了定义和访问插件的偏好设置,你必须使用config.py文件中定义的__addon_name__作为偏好设置的bl_idname。 定义偏好设置类: ```python class ExampleAddonPreferences(AddonPreferences): bl_idname = __addon_name__ ``` 访问偏好设置: ```python from ..config import __addon_name__ addon_prefs = bpy.context.preferences.addons[__addon_name__].preferences # use addon_prefs addon_prefs.some_property ``` ## 使用说明 1. 克隆此项目。 1. 在您的 IDE 中打开此项目。你可以将IDE使用的Python.exe配置成与Blender相同。 1. 对于PyCharm用户,请将idea.properties文件(点击 Help | Edit Custom Properties.) 中的idea.max.intellisense.filesize的值更改为大于2600,因为某些模块的大小超过了intelliSense的工作范围。你可能需要将__init__ .pyi文件关联到python File Types ![setting](https://i.ibb.co/QcYZytw/script.png) 以使自动代码补全正常工作。 1. 在 [main.py](main.py) 中配置 Blender 可执行文件路径(BLENDER_EXE_PATH) 1. 在 [main.py](main.py) 中配置您想要创建的插件名称(ACTIVE_ADDON)。 1. 运行 create.py 在您的 IDE 中创建一个新的插件。第一次运行时需要联网下载依赖库,包括watchdog和fake-bpy-module 1. 在新创建的插件目录中开发您的插件。 1. 运行 test.py 在 Blender 中测试您的插件。 1. 运行 release.py 将您的插件打包成可安装的包。成功打包后,终端中将显示打包插件的路径。 ## 框架提供的功能 1. 你基本上无需关心Blender插件的类的加载和卸载,框架会自动加载和卸载你的插件中的类,你只需要在插件目录下定义你的类即可,注意自动加载的类需要放在有__init__ .py文件的目录下才能被框架自动类加载机制识别并加载 1. 你可以在插件中使用国际化翻译,只需要在插件文件夹中的i18n中的dictionary.py文件中按标准格式添加翻译即可 1. 你可以使用声明式的方式定义RNA属性,只需要根据__init__.py中的注释示例添加你的RNA属性即可,框架会自动注册和卸载你的RNA属性 1. 你可以使用common/types/framework.py中的ExpandableUi类来方便的扩展Blender原生的菜单,面板,饼菜单,标题栏等UI组件, 只需继承该类并实现draw方法,你可以通过target_id来指定需要扩展的原生UI组件的ID, 通过expand_mode来指定向前还是向后扩展。 1. 你可以使用common/types/framework.py中的reg_order装饰器来指定类的注册顺序,当你需要确保某些类在其他类之前注册时,可以利用这个功能。 比如Panel的初始顺序将会按照注册的顺序来排列。 ## 添加可选的配置文件 为了避免每次更新框架时需要重新修改main.py中的配置,你可以在项目的根目录下创建一个config.ini文件,用于存放你的配置信息, 这个文件会覆盖main.py中的配置信息。 以下是一个config.ini文件的示例: ```ini [blender] ; Blender的可执行文件路径 exe_path = C:/software/general/Blender/Blender3.5/blender.exe ; exe_path = C:/software/general/Blender/Blender3.6/blender.exe ; 插件目录路径,测试时插件将被临时安装到这里 ; 通常不需要配置此项,因为框架可以通过exe_path的路径推导出来 addon_path = C:/software/general/Blender/Blender3.5/scripts/addons/ [default] ; 创建、测试和发布的目标插件名称 addon = sample_addon ; 插件是否为扩展,如果为True,则插件在发布时会被打包成扩展的形式 is_extension = False ; 发布插件zip文件的存放路径。注意不要发布到源码所在的目录中 release_dir = C:/path/to/release/dir ; 用于测试时插件文件的临时存放路径,测试是框架首先会发布插件到这里,然后再复制到Blender的插件目录。注意不要发布到源码所在的目录中 test_release_dir = C:/path/to/test/release/dir ``` ## 框架在以下方面可进一步完善,欢迎贡献意见和代码 1. 框架的更新:如果你已经在项目中使用了这个框架进行开发,当你需要迁移到更新的框架版本时,你需要手动替换框架代码文件来获取新的功能,你可以fork这个项目,然后使用 `git fetch upstream`来拉取最新的代码。 我们希望探索一些更友好的方式来帮助更新框架代码,欢迎提供意见。但总的来说,我们希望框架保持轻量,不会在结构上有太大的变化。 可以预计未来的大部分更新都是在增加新的功能,而不是对框架结构进行大的调整。除非你在本地对框架代码有所改动,未来的更新你只需要将新的文件替换旧的文件即可。 1. 断点调试:目前框架并不支持IDE内的断点调试,实现这个功能需要对框架代码进行一些修改,这也许会增加框架的使用难度,我们力求寻找尽量轻量级的解决方案。但总的来说, 断点调试对应开发插件并不是必须的,大部分的插件功能并没有复杂到需要断点调试,打印日志也可以达到大部分的调试的目的。对于这个框架,如果我们力求寻找一个简单的方案来支持断点调试,但这不是必须的。 ================================================ FILE: addons/__init__.py ================================================ ================================================ FILE: addons/sample_addon/__init__.py ================================================ import bpy from .config import __addon_name__ from .i18n.dictionary import dictionary from ...common.class_loader import auto_load from ...common.class_loader.auto_load import add_properties, remove_properties from ...common.i18n.dictionary import common_dictionary from ...common.i18n.i18n import load_dictionary # Add-on info bl_info = { "name": "Basic Add-on Sample", "author": "[You name]", "blender": (3, 5, 0), "version": (0, 0, 1), "description": "This is a template for building addons", "warning": "", "doc_url": "[documentation url]", "tracker_url": "[contact email]", "support": "COMMUNITY", "category": "3D View" } _addon_properties = {} # You may declare properties like following, framework will automatically add and remove them. # Do not define your own property group class in the __init__.py file. Define it in a separate file and import it here. # 注意不要在__init__.py文件中自定义PropertyGroup类。请在单独的文件中定义它们并在此处导入。 # _addon_properties = { # bpy.types.Scene: { # "property_name": bpy.props.StringProperty(name="property_name"), # }, # } def register(): # Register classes auto_load.init() auto_load.register() add_properties(_addon_properties) # Internationalization load_dictionary(dictionary) bpy.app.translations.register(__addon_name__, common_dictionary) print("{} addon is installed.".format(__addon_name__)) def unregister(): # Internationalization bpy.app.translations.unregister(__addon_name__) # unRegister classes auto_load.unregister() remove_properties(_addon_properties) print("{} addon is uninstalled.".format(__addon_name__)) ================================================ FILE: addons/sample_addon/blender_manifest.toml ================================================ schema_version = "1.0.0" # Example of manifest file for a Blender extension # Change the values according to your extension # id must be the same as the directory name, and must be unique id = "sample_addon" version = "0.0.1" name = "Basic Add-on Sample" tagline = "This is another extension" maintainer = "Developer name " # Supported types: "add-on", "theme" type = "add-on" # Optional link to documentation, support, source files, etc # website = "https://extensions.blender.org/add-ons/my-example-package/" # Optional list defined by Blender and server, see: # https://docs.blender.org/manual/en/dev/advanced/extensions/tags.html tags = ["Animation", "Sequencer"] blender_version_min = "4.2.0" # # Optional: Blender version that the extension does not support, earlier versions are supported. # # This can be omitted and defined later on the extensions platform if an issue is found. # blender_version_max = "5.1.0" # License conforming to https://spdx.org/licenses/ (use "SPDX: prefix) # https://docs.blender.org/manual/en/dev/advanced/extensions/licenses.html license = [ "SPDX:GPL-3.0-or-later", ] # Optional: required by some licenses. # copyright = [ # "2002-2024 Developer Name", # "1998 Company Name", # ] # Optional list of supported platforms. If omitted, the extension will be available in all operating systems. # platforms = ["windows-x64", "macos-arm64", "linux-x64"] # Other supported platforms: "windows-arm64", "macos-x64" # Optional: bundle 3rd party Python modules. # https://docs.blender.org/manual/en/dev/advanced/extensions/python_wheels.html # wheels = [ # "./wheels/hexdump-3.3-py3-none-any.whl", # "./wheels/jsmin-3.0.1-py3-none-any.whl", # ] # # Optional: add-ons can list which resources they will require: # # * files (for access of any filesystem operations) # # * network (for internet access) # # * clipboard (to read and/or write the system clipboard) # # * camera (to capture photos and videos) # # * microphone (to capture audio) # # # # If using network, remember to also check `bpy.app.online_access` # # https://docs.blender.org/manual/en/dev/advanced/extensions/addons.html#internet-access # # # # For each permission it is important to also specify the reason why it is required. # # Keep this a single short sentence without a period (.) at the end. # # For longer explanations use the documentation or detail page. # # [permissions] # network = "Need to sync motion-capture data to server" # files = "Import/export FBX from/to disk" # clipboard = "Copy and paste bone transforms" # Optional: build settings. # https://docs.blender.org/manual/en/dev/advanced/extensions/command_line_arguments.html#command-line-args-extension-build # [build] # paths_exclude_pattern = [ # "__pycache__/", # "/.git/", # "/*.zip", # ] ================================================ FILE: addons/sample_addon/config.py ================================================ from ...common.types.framework import is_extension # https://docs.blender.org/manual/en/latest/advanced/extensions/addons.html#extensions-and-namespace # This is the unique package name of the addon, it is different from the add-on name in bl_info. # This name is used to identify the add-on in python code. It should also be the same to the package name of the add-on. __addon_name__ = ".".join(__package__.split(".")[0:3]) if is_extension() else __package__.split(".")[0] ================================================ FILE: addons/sample_addon/i18n/__init__.py ================================================ ================================================ FILE: addons/sample_addon/i18n/dictionary.py ================================================ from common.i18n.dictionary import preprocess_dictionary dictionary = { "zh_CN": { ("*", "Example Addon Side Bar Panel"): "示例插件面板", ("*", "Example Functions"): "示例功能", ("*", "ExampleAddon"): "示例插件", ("*", "Resource Folder"): "资源文件夹", ("*", "Int Config"): "整数参数", # This is not a standard way to define a translation, but it is still supported with preprocess_dictionary. "Boolean Config": "布尔参数", "Second Panel": "第二面板", ("*", "Add-on Preferences View"): "插件设置面板", ("Operator", "ExampleOperator"): "示例操作", } } dictionary = preprocess_dictionary(dictionary) dictionary["zh_HANS"] = dictionary["zh_CN"] ================================================ FILE: addons/sample_addon/operators/AddonOperators.py ================================================ import bpy from ..config import __addon_name__ from ..preference.AddonPreferences import ExampleAddonPreferences # This Example Operator will scale up the selected object class ExampleOperator(bpy.types.Operator): '''ExampleAddon''' bl_idname = "object.example_ops" bl_label = "ExampleOperator" # 确保在操作之前备份数据,用户撤销操作时可以恢复 bl_options = {'REGISTER', 'UNDO'} @classmethod def poll(cls, context: bpy.types.Context): return context.active_object is not None def execute(self, context: bpy.types.Context): addon_prefs = bpy.context.preferences.addons[__addon_name__].preferences assert isinstance(addon_prefs, ExampleAddonPreferences) # use operator # bpy.ops.transform.resize(value=(2, 2, 2)) # manipulate the scale directly context.active_object.scale *= addon_prefs.number return {'FINISHED'} ================================================ FILE: addons/sample_addon/operators/__init__.py ================================================ ================================================ FILE: addons/sample_addon/panels/AddonPanels.py ================================================ import bpy from ..config import __addon_name__ from ..operators.AddonOperators import ExampleOperator from ....common.i18n.i18n import i18n from ....common.types.framework import reg_order class BasePanel(object): bl_space_type = "VIEW_3D" bl_region_type = "UI" bl_category = "ExampleAddon" @classmethod def poll(cls, context: bpy.types.Context): return True @reg_order(0) class ExampleAddonPanel(BasePanel, bpy.types.Panel): bl_label = "Example Addon Side Bar Panel" bl_idname = "SCENE_PT_sample" def draw(self, context: bpy.types.Context): addon_prefs = context.preferences.addons[__addon_name__].preferences layout = self.layout layout.label(text=i18n("Example Functions") + ": " + str(addon_prefs.number)) layout.prop(addon_prefs, "filepath") layout.separator() row = layout.row() row.prop(addon_prefs, "number") row.prop(addon_prefs, "boolean") layout.operator(ExampleOperator.bl_idname) @classmethod def poll(cls, context: bpy.types.Context): return True # This panel will be drawn after ExampleAddonPanel since it has a higher order value @reg_order(1) class ExampleAddonPanel2(BasePanel, bpy.types.Panel): bl_label = "Example Addon Side Bar Panel" bl_idname = "SCENE_PT_sample2" def draw(self, context: bpy.types.Context): layout = self.layout layout.label(text="Second Panel") layout.operator(ExampleOperator.bl_idname) ================================================ FILE: addons/sample_addon/panels/__init__.py ================================================ ================================================ FILE: addons/sample_addon/preference/AddonPreferences.py ================================================ import os import bpy from bpy.props import StringProperty, IntProperty, BoolProperty from bpy.types import AddonPreferences from ..config import __addon_name__ class ExampleAddonPreferences(AddonPreferences): # this must match the add-on name (the folder name of the unzipped file) bl_idname = __addon_name__ # https://docs.blender.org/api/current/bpy.props.html # The name can't be dynamically translated during blender programming running as they are defined # when the class is registered, i.e. we need to restart blender for the property name to be correctly translated. filepath: StringProperty( name="Resource Folder", default=os.path.join(os.path.expanduser("~"), "Documents", __addon_name__), subtype='DIR_PATH', ) number: IntProperty( name="Int Config", default=2, ) boolean: BoolProperty( name="Boolean Config", default=False, ) def draw(self, context: bpy.types.Context): layout = self.layout layout.label(text="Add-on Preferences View") layout.prop(self, "filepath") layout.prop(self, "number") layout.prop(self, "boolean") ================================================ FILE: addons/sample_addon/preference/__init__.py ================================================ ================================================ FILE: common/__init__.py ================================================ ================================================ FILE: common/class_loader/__init__.py ================================================ ================================================ FILE: common/class_loader/auto_load.py ================================================ import importlib import inspect import pkgutil import typing from pathlib import Path import bpy __all__ = ( "init", "register", "unregister", "add_properties", "remove_properties", ) from ..types.framework import ExpandableUi, is_extension blender_version = bpy.app.version modules = None ordered_classes = None frame_work_classes = None def init(): global modules global ordered_classes global frame_work_classes # notice here, the path root is the root of the project modules = get_all_submodules(Path(__file__).parent.parent.parent) ordered_classes = get_ordered_classes_to_register(modules) frame_work_classes = get_framework_classes(modules) def register(): for cls in ordered_classes: bpy.utils.register_class(cls) for module in modules: if module.__name__ == __name__: continue if hasattr(module, "register"): module.register() for cls in frame_work_classes: register_framework_class(cls) def unregister(): for cls in reversed(ordered_classes): bpy.utils.unregister_class(cls) for module in modules: if module.__name__ == __name__: continue if hasattr(module, "unregister"): module.unregister() for cls in frame_work_classes: unregister_framework_class(cls) # Import modules ################################################# def get_all_submodules(directory): return list(iter_submodules(directory)) def iter_submodules(path): import_as_extension = is_extension() for name in sorted(iter_submodule_names(path)): if import_as_extension: yield importlib.import_module("..." + name, __package__) else: yield importlib.import_module("." + name, path.name) def iter_submodule_names(path, root=""): for _, module_name, is_package in pkgutil.iter_modules([str(path)]): if is_package: sub_path = path / module_name sub_root = root + module_name + "." yield from iter_submodule_names(sub_path, sub_root) else: yield root + module_name # Find classes to register ################################################# def get_ordered_classes_to_register(modules): return toposort(get_register_deps_dict(modules)) def get_framework_classes(modules): base_types = get_framework_base_classes() all_framework_classes = set() for cls in get_classes_in_modules(modules): if any(base in base_types for base in cls.__mro__[1:]): all_framework_classes.add(cls) return all_framework_classes def get_register_deps_dict(modules): my_classes = set(iter_my_classes(modules)) my_classes_by_idname = {cls.bl_idname: cls for cls in my_classes if hasattr(cls, "bl_idname")} deps_dict = {} for cls in my_classes: deps_dict[cls] = set(iter_my_register_deps(cls, my_classes, my_classes_by_idname)) return deps_dict def iter_my_register_deps(cls, my_classes, my_classes_by_idname): yield from iter_my_deps_from_annotations(cls, my_classes) yield from iter_my_deps_from_inheritance(cls, my_classes) yield from iter_my_deps_from_parent_id(cls, my_classes_by_idname) def iter_my_deps_from_annotations(cls, my_classes): # Fix for https://github.com/xzhuah/BlenderAddonPackageTool/issues/15 import sys module = sys.modules.get(cls.__module__) globalns = module.__dict__ if module else {} for value in typing.get_type_hints(cls, globalns, {}).values(): dependency = get_dependency_from_annotation(value) if dependency is not None: if dependency in my_classes: yield dependency def iter_my_deps_from_inheritance(cls, my_classes): for base_cls in cls.__mro__[1:]: if base_cls in my_classes: yield base_cls def get_dependency_from_annotation(value): if blender_version >= (2, 93): # Avoid checking private types # if isinstance(value, bpy.props._PropertyDeferred): # return value.keywords.get("type") if hasattr(value, "keywords") and isinstance(value.keywords, dict): return value.keywords.get("type") else: if isinstance(value, tuple) and len(value) == 2: if value[0] in (bpy.props.PointerProperty, bpy.props.CollectionProperty): return value[1]["type"] return None def iter_my_deps_from_parent_id(cls, my_classes_by_idname): if bpy.types.Panel in cls.__mro__[1:]: parent_idname = getattr(cls, "bl_parent_id", None) if parent_idname is not None: parent_cls = my_classes_by_idname.get(parent_idname) if parent_cls is not None: yield parent_cls def iter_my_classes(modules): base_types = get_register_base_types() for cls in get_classes_in_modules(modules): if any(base in base_types for base in cls.__mro__[1:]): if not getattr(cls, "is_registered", False): yield cls def get_classes_in_modules(modules): classes = set() for module in modules: for cls in iter_classes_in_module(module): classes.add(cls) return classes def iter_classes_in_module(module): for value in module.__dict__.values(): if inspect.isclass(value): yield value def get_register_base_types(): names = [ "Panel", "Operator", "PropertyGroup", "AddonPreferences", "Header", "Menu", "Node", "NodeSocket", "NodeTree", "UIList", "RenderEngine", "Gizmo", "GizmoGroup", "FileHandler", ] return {getattr(bpy.types, name) for name in names if hasattr(bpy.types, name)} def get_framework_base_classes(): return {ExpandableUi} # Find order to register to solve dependencies ################################################# def toposort(deps_dict): # Disallow circular dependencies if deps_dict: test_deps = {k: v.copy() for k, v in deps_dict.items()} for _ in range(len(deps_dict)): resolved = [k for k, v in test_deps.items() if not v] if not resolved: # 避免循环依赖 raise ValueError(f"Circular dependency detected: {list(test_deps.keys())}") for k in resolved: del test_deps[k] for v in test_deps.values(): v -= set(resolved) if not test_deps: break sorted_list = [] sorted_values = set() while len(deps_dict) > 0: unsorted = [] # class with no dependencies independent = [] for value, deps in deps_dict.items(): if len(deps) == 0: independent.append(value) else: unsorted.append(value) # sort no dependencies by _reg_order independent.sort(key=lambda x: getattr(x, "_reg_order", float('inf'))) # add to sorted list for value in independent: sorted_list.append(value) sorted_values.add(value) deps_dict = {value: deps_dict[value] - sorted_values for value in unsorted} return sorted_list def register_framework_class(cls): if issubclass(cls, ExpandableUi): if hasattr(bpy.types, cls.target_id): if cls.expand_mode == "APPEND": getattr(bpy.types, cls.target_id).append(cls.draw) elif cls.expand_mode == "PREPEND": getattr(bpy.types, cls.target_id).prepend(cls.draw) else: raise ValueError(f"Invalid expand_mode: {cls.expand_mode}") else: print(f"Warning: Target ID not found: {cls.target_id}") def unregister_framework_class(cls): if issubclass(cls, ExpandableUi): if hasattr(bpy.types, cls.target_id): getattr(bpy.types, cls.target_id).remove(cls.draw) # support adding properties in a declarative way def add_properties(property_dict: dict[typing.Any, dict[str, typing.Any]]): for cls, properties in property_dict.items(): for name, prop in properties.items(): setattr(cls, name, prop) # support removing properties in a declarative way def remove_properties(property_dict: dict[typing.Any, dict[str, typing.Any]]): for cls, properties in property_dict.items(): for name in properties.keys(): if hasattr(cls, name): delattr(cls, name) ================================================ FILE: common/class_loader/module_installer.py ================================================ # Notice: Please do not use functions in this file for developing your Blender Addons, this file is for internal use of # the framework, it contains some modules that Blender officially prohibits using in Addons. Such as sys # 注意:请不要在Blender中使用此文件中的函数,此文件用于框架内部使用,包含了一些Blender官方禁止在插件中使用的模块 如sys import importlib.metadata import importlib.util import os import platform import subprocess import sys def install(package): subprocess.check_call([sys.executable, "-m", "pip", "install", package]) def has_module(module_name): try: return importlib.util.find_spec(module_name) is not None except Exception as e: return False def is_package_installed(package_name): try: importlib.metadata.version(package_name) return True except importlib.metadata.PackageNotFoundError: return False def install_if_missing(package): if not has_module(package): install(package) def get_blender_version(blender_exe_path): try: # Run the Blender executable with --version result = subprocess.run( [blender_exe_path, "--version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) # Check if the process was successful if result.returncode == 0: output = result.stdout # Parse the version from the output for line in output.splitlines(): if line.startswith("Blender"): # Extract version number return line.split()[1] # e.g., "3.6.2" else: print("Error running Blender:", result.stderr) except Exception as e: print(f"An error occurred when trying to determine Blender version: {e}") return None def extract_blender_version(blender_exe_path: str): """Extract the first two version numbers from the Blender executable path.""" version_str = get_blender_version(blender_exe_path) try: return ".".join(version_str.split(".")[0:2]) except Exception as e: print(f"An error occurred when trying to extract Blender version from {version_str}: {e}") return None def install_fake_bpy(blender_path: str): blender_version = extract_blender_version(blender_path) if blender_version is None: print("Blender version not found in path: " + blender_path) blender_version = "latest" desired_module = "fake-bpy-module-" + blender_version if has_module("bpy"): if not is_package_installed(desired_module): print("Your fake bpy module is different from the current blender version! You might need to update it.") return else: print("Installing fake bpy module for Blender version: " + blender_version) try: install(desired_module) except Exception as e: if desired_module != "fake-bpy-module-latest": print( "Failed to install fake bpy module for Blender version: " + blender_version + "! Trying to install the latest version.") install("fake-bpy-module-latest") def normalize_blender_path_by_system(blender_path: str): if is_mac(): if blender_path.endswith(".app"): blender_path = os.path.join(blender_path, "Contents/MacOS/Blender") return blender_path def default_blender_addon_path(blender_path: str): blender_path = normalize_blender_path_by_system(blender_path) blender_version = extract_blender_version(blender_path) assert blender_version is not None, "Can not detect Blender version with " + blender_path + "!" if is_windows() or is_linux(): new_path = os.path.join(os.path.dirname(blender_path), blender_version, "scripts", "addons_core") if os.path.exists(new_path): return new_path return os.path.join(os.path.dirname(blender_path), blender_version, "scripts", "addons") elif is_mac(): user_path = os.path.expanduser("~") return os.path.join(user_path, f"Library/Application Support/Blender/{blender_version}/scripts/addons") else: raise Exception( "This Framework is currently not compatible with your operating system! Please use Windows, MacOS or Linux.") def is_windows(): return platform.system() == "Windows" def is_linux(): return platform.system() == "Linux" def is_mac(): return platform.system() == "Darwin" ================================================ FILE: common/i18n/__init__.py ================================================ ================================================ FILE: common/i18n/dictionary.py ================================================ common_dictionary = { "zh_CN": { # ("*", "translation"): "翻译", } } common_dictionary["zh_HANS"] = common_dictionary["zh_CN"] # preprocess dictionary def preprocess_dictionary(dictionary): for key in dictionary: invalid_items = {} for translate_key in dictionary[key]: if isinstance(translate_key, str): invalid_items[translate_key] = dictionary[key][translate_key] for invalid_item in invalid_items: translation = invalid_items[invalid_item] dictionary[key][("*", invalid_item)] = translation dictionary[key][("Operator", invalid_item)] = translation del dictionary[key][invalid_item] return dictionary ================================================ FILE: common/i18n/i18n.py ================================================ import bpy # Get the language code when addon start up __language_code__ = bpy.context.preferences.view.language from .dictionary import common_dictionary __dictionary__ = common_dictionary # Dictionary for translation: https://docs.blender.org/api/current/bpy.app.translations.html # { # "en_US": { # ("*", code1): "translation1", # ("Operator", code2): "translation2", # }, # "zh_CN": { # ("*", key): "翻译", # ("*, key2): "翻译2" # } # } # Set a new dictionary for translation def set_dictionary(new_dictionary: dict[str, dict[tuple, str]]): global __dictionary__ __dictionary__ = new_dictionary # Load additional dictionary for translation def load_dictionary(additional_dictionary: dict[str, dict[tuple, str]]): global __dictionary__ for key in additional_dictionary: if key in __dictionary__: __dictionary__[key].update(additional_dictionary[key]) else: __dictionary__[key] = {} __dictionary__[key].update(additional_dictionary[key]) # 在需要拼接字符串的地方使用i18n函数 def i18n(content: str) -> str: global __language_code__, __dictionary__ __language_code__ = bpy.context.preferences.view.language if __language_code__ not in __dictionary__: return content tuple_contents = [("*", content), ("Operator", content)] for tuple_content in tuple_contents: if tuple_content in __dictionary__[__language_code__]: return __dictionary__[__language_code__][tuple_content] for key in __dictionary__[__language_code__]: if key[1] == content: return __dictionary__[__language_code__][key] return content ================================================ FILE: common/io/FileManagerClient.py ================================================ import hashlib import os from os import listdir def get_all_filename(folder_path: str) -> list: if os.path.exists(folder_path): return [f for f in listdir(folder_path) if os.path.isfile(os.path.join(folder_path, f))] else: return [] def get_all_subfolder(folder_path: str) -> list: return [f for f in listdir(folder_path) if os.path.isdir(os.path.join(folder_path, f))] # return true if path_a is a subdirectory under path_b def is_subdirectory(path_a, path_b) -> bool: path_a = os.path.abspath(path_a) path_b = os.path.abspath(path_b) return os.path.commonpath([path_b]) == os.path.commonpath([path_a, path_b]) def is_filename_postfix_in(filename: str, target_set: set): if target_set is None or len(target_set) == 0: return True for postfix in target_set: if filename.lower().endswith(postfix.lower()): return True return False # 搜索文件夹下所有文件 post_filter为后缀名集合 全小写 def search_files(folder_path: str, post_filter: set) -> list: def __depth_first_search_files_helper__(current_folder: str, pre_result: list): for filename in get_all_filename(current_folder): if is_filename_postfix_in(filename, post_filter): pre_result.append(os.path.join(current_folder, filename)) all_folders = get_all_subfolder(current_folder) for folder in all_folders: __depth_first_search_files_helper__(os.path.join(current_folder, folder), pre_result) all_file = [] __depth_first_search_files_helper__(folder_path, all_file) return all_file def get_md5(filename): return hashlib.md5(open(filename, 'rb').read()).hexdigest() def get_md5_folder(folder_path: str) -> str: all_files = search_files(folder_path, set()) md5_content = "" for file in all_files: md5_content += get_md5(file) return hashlib.md5(md5_content.encode("utf-8")).hexdigest() def read_utf8(filepath: str) -> str: with open(filepath, mode="r", encoding="utf-8") as f: return f.read() def read_utf8_in_lines(filepath: str) -> list[str]: with open(filepath, mode="r", encoding="utf-8") as f: return f.readlines() def write_utf8(filepath: str, content: str): with open(filepath, encoding="utf-8", mode="w") as f: f.write(content) def write_utf8_in_lines(filepath: str, content: list[str]): with open(filepath, encoding="utf-8", mode="w") as f: f.writelines(content) ================================================ FILE: common/io/__init__.py ================================================ ================================================ FILE: common/types/__init__.py ================================================ ================================================ FILE: common/types/framework.py ================================================ import bpy def is_extension(): # Blender extension package starts with "bl_ext." # https://docs.blender.org/manual/en/latest/advanced/extensions/addons.html#extensions-and-namespace return str(__package__).startswith("bl_ext.") # This is a helper base class for you to expand native Blender UI class ExpandableUi: # ID of the target panel.menu to be expanded to target_id: str # mode of expansion, either "PREPEND" or "APPEND" expand_mode: str = "APPEND" def draw(self, context: bpy.types.Context): raise NotImplementedError("draw method must be implemented") def reg_order(order_value: int): """ This decorator is used to specify the relative registration order of a class. The class with lower order value will be registered first, class without this decorator will be registered last. Notice it still respect the dependencies between classes. Only classes with no dependencies relationship will be sorted by order value. Notice, for UI classes, updating the order won't take effect in real-time during testing, you need to also update the bl_idname of the class to let Blender clean up the drawing cache. 这个装饰器用于指定类的相对注册顺序。具有较低顺序值的类将首先注册,没有此装饰器的类将最后注册。 请注意,它仍然尊重类之间的依赖关系。只有没有依赖关系的类才会按顺序值排序。 请注意,对于UI类,更新顺序不会在测试期间实时生效,您还需要更新类的bl_idname,以便Blender清除绘图缓存。 """ def class_decorator(cls): cls._reg_order = order_value return cls return class_decorator ================================================ FILE: create.py ================================================ from framework import new_addon from main import ACTIVE_ADDON # 创建前请修改以下参数 # The name of the addon to be created, this name is defined in the config.py of the addon as __addon_name__ # 插件的config.py文件中定义的插件名称 __addon_name__ if __name__ == '__main__': import argparse parser = argparse.ArgumentParser() parser.add_argument('addon', default=ACTIVE_ADDON, nargs='?', help='addon name') args = parser.parse_args() new_addon(args.addon) ================================================ FILE: framework.py ================================================ import ast import atexit import os import re import shutil import subprocess import sys import threading import time from datetime import datetime from pathlib import Path from common.class_loader.module_installer import install_if_missing, install_fake_bpy from common.io.FileManagerClient import search_files, read_utf8, write_utf8, is_subdirectory, get_md5_folder, \ read_utf8_in_lines, write_utf8_in_lines from main import PROJECT_ROOT, BLENDER_ADDON_PATH, BLENDER_EXE_PATH, DEFAULT_RELEASE_DIR, TEST_RELEASE_DIR, IS_EXTENSION try: # added in python3.11 import tomllib except ImportError: # for python3.10 and below install_if_missing("toml") import toml # Following variables are used internally in the framework according to some protocols defined by Blender or # the framework itself. Do not change them unless you know what you are doing. _addon_namespace_pattern = re.compile("^[a-zA-Z]+[a-zA-Z0-9_]*$") _import_module_pattern = re.compile("from ([a-zA-Z_][a-zA-Z0-9_.]*) import (.+)") _relative_import_pattern = re.compile(r'^\s*(from\s+(\.+))(.*)$') _absolute_import_pattern = re.compile(r'^\s*from\s+(\w+[\w.]*)\s+import\s+(.*)$') _addon_md5__signature = "addon.txt" _ADDON_MANIFEST_FILE = "blender_manifest.toml" _WHEELS_PATH = "wheels" # 默认使用的插件模板 不要轻易修改 _ADDON_TEMPLATE = "sample_addon" _ADDONS_FOLDER = "addons" _ADDON_ROOT = os.path.join(PROJECT_ROOT, _ADDONS_FOLDER) # Install fake bpy module only when user have configured the blender executable path # 仅在用户配置了Blender可执行文件路径时安装fake bpy模块 避免在非Blender环境下安装fake bpy模块(如CICD流程中) if os.path.isfile(BLENDER_EXE_PATH): install_fake_bpy(BLENDER_EXE_PATH) def new_addon(addon_name: str): new_addon_path = os.path.join(_ADDON_ROOT, addon_name) if os.path.exists(new_addon_path): raise ValueError("Addon already exists: " + addon_name) if not bool(_addon_namespace_pattern.match(addon_name)): raise ValueError("Invalid addon name: " + addon_name + " Please name it as a python package name") shutil.copytree(os.path.join(_ADDON_ROOT, _ADDON_TEMPLATE), new_addon_path) all_template_file = search_files(new_addon_path, {".py", ".toml"}) for py_file in all_template_file: content = read_utf8(py_file).replace(_ADDON_TEMPLATE, addon_name) write_utf8(py_file, content) def test_addon(addon_name, enable_watch=True): init_file = get_init_file_path(addon_name) if not enable_watch: print('Do not auto reload addon when file changed') start_test(init_file, addon_name, enable_watch=enable_watch) def get_init_file_path(addon_name): # addon_name is the name defined in addon's config.py target_init_file_path = os.path.join(_ADDON_ROOT, addon_name, "__init__.py") if not os.path.exists(target_init_file_path): raise ValueError(f"Release failed: Addon {addon_name} not found.") return target_init_file_path # The following code will be injected into the blender python environment to enable hot reload # https://devtalk.blender.org/t/plugin-hot-reload-by-cleaning-sys-modules/20040 start_up_command = """ import bpy from bpy.app.handlers import persistent import os import sys existing_addon_md5 = "" try: bpy.ops.preferences.addon_enable(module="{addon_name}") except Exception as e: print("Addon enable failed:", e) def watch_update_tick(): global existing_addon_md5 if os.path.exists("{addon_signature}"): with open("{addon_signature}", "r") as f: addon_md5 = f.read() if existing_addon_md5 == "": existing_addon_md5 = addon_md5 elif existing_addon_md5 != addon_md5: print("Addon file changed, start to update the addon") try: bpy.ops.preferences.addon_disable(module="{addon_name}") all_modules = sys.modules all_modules = dict(sorted(all_modules.items(),key= lambda x:x[0])) #sort them for k,v in all_modules.items(): if k.startswith("{addon_name}"): del sys.modules[k] bpy.ops.preferences.addon_enable(module="{addon_name}") except Exception as e: print("Addon update failed:", e) existing_addon_md5 = addon_md5 print("Addon updated") return 1.0 @persistent def register_watch_update_tick(dummy): print("Watching for addon update...") bpy.app.timers.register(watch_update_tick) register_watch_update_tick(None) bpy.app.handlers.load_post.append(register_watch_update_tick) """ def start_test(init_file, addon_name, enable_watch=True): update_addon_for_test(init_file, addon_name) test_addon_path = os.path.normpath(os.path.join(BLENDER_ADDON_PATH, addon_name)) if not enable_watch: def exit_handler(): if os.path.exists(test_addon_path): shutil.rmtree(test_addon_path) atexit.register(exit_handler) try: execute_blender_script( [BLENDER_EXE_PATH, "--python-use-system-env", "--python-expr", f"import bpy\nbpy.ops.preferences.addon_enable(module=\"{addon_name}\")"], test_addon_path) finally: exit_handler() return # start_watch_for_update(init_file, addon_name) stop_event = threading.Event() thread = threading.Thread(target=start_watch_for_update, args=(init_file, addon_name, stop_event)) thread.start() def exit_handler(): stop_event.set() thread.join() if os.path.exists(test_addon_path): shutil.rmtree(test_addon_path) atexit.register(exit_handler) python_script = start_up_command.format(addon_name=addon_name, addon_signature=os.path.join(test_addon_path, _addon_md5__signature).replace("\\", "/")) try: execute_blender_script([BLENDER_EXE_PATH, "--python-use-system-env", "--python-expr", python_script], test_addon_path) finally: exit_handler() # This is the only corner case need to handle _addon_on_init_file = os.path.abspath(os.path.join(PROJECT_ROOT, "__init__.py")) def execute_blender_script(args, addon_path): process = subprocess.Popen(args, stderr=subprocess.PIPE, text=True, encoding="utf-8") try: for line in process.stderr: line: str if line.lstrip().startswith("File"): line = line.replace(addon_path, PROJECT_ROOT) sys.stderr.write(line) except KeyboardInterrupt: sys.stderr.write("interrupted, terminating the child process...\n") finally: process.terminate() process.wait() def read_ext_config(addon_config_file): with open(addon_config_file, 'r', encoding='utf-8') as f: try: addon_config = tomllib.loads(f.read()) except Exception as e: addon_config = toml.load(f) return addon_config def release_addon(target_init_file, addon_name, release_dir=DEFAULT_RELEASE_DIR, need_zip=True, is_extension=IS_EXTENSION, with_timestamp=False, with_version=False): # if release dir is under PROJECT_ROOT, it's not allowed if is_subdirectory(release_dir, PROJECT_ROOT): # 不要将插件发布目录设置在当前项目内 raise ValueError("Invalid release dir:", release_dir, "Please set a release/test dir outside the current workspace") if not bool(_addon_namespace_pattern.match(addon_name)): raise ValueError("InValid addon_name:", addon_name, "Please name it as a python package name") if is_extension: # 发布为扩展时,请确保您在config.py正确的定义了__addon_name__ # Release as extension, please make sure you defined __addon_name__ correctly in config.py" # Make sure toml file exists addon_config_file = os.path.join(_ADDON_ROOT, addon_name, _ADDON_MANIFEST_FILE) if not os.path.isfile(addon_config_file): raise ValueError("Extension config file not found:", addon_config_file) if not os.path.isdir(release_dir): Path(release_dir).mkdir(parents=True, exist_ok=True) # remove the folder if already exists release_folder = os.path.join(release_dir, addon_name) if os.path.exists(release_folder): shutil.rmtree(release_folder) os.mkdir(release_folder) bootstrap_init_file = generate_bootstrap_init_file(addon_name, get_addon_info(target_init_file)) write_utf8(os.path.join(release_folder, "__init__.py"), bootstrap_init_file) # shutil.copyfile(target_init_file, os.path.join(release_folder, "__init__.py")) # 将target_init_file同级的其他非py文件复制到发布目录 如 toml xml等可能跟插件有关的配置文件 for file in os.listdir(os.path.dirname(target_init_file)): file_path = os.path.join(os.path.dirname(target_init_file), file) if os.path.isdir(file_path) or file.endswith(".py"): continue shutil.copy(file_path, release_folder) # 将插件文件夹复制到发布目录 shutil.copytree(os.path.join(_ADDON_ROOT, addon_name), os.path.join(release_folder, _ADDONS_FOLDER, addon_name)) shutil.copyfile(os.path.join(_ADDON_ROOT, "__init__.py"), os.path.join(release_folder, _ADDONS_FOLDER, "__init__.py")) all_py_files = search_files(os.path.join(_ADDON_ROOT, addon_name), {".py"}) # 对插件文件夹中的每一个py文件进行分析,找到每个py文件中依赖的其他py文件 visited_py_files = set() for py_file in all_py_files: visited_py_files.add(os.path.abspath(py_file)) # 注意不要漏掉__init__.py文件 visited_py_files.add(os.path.abspath(os.path.join(_ADDON_ROOT, "__init__.py"))) dependencies = find_all_dependencies(list(visited_py_files), PROJECT_ROOT) for dependency in dependencies: dependency = os.path.abspath(dependency) if dependency in visited_py_files: continue visited_py_files.add(dependency) target_path = os.path.join(release_folder, os.path.relpath(dependency, PROJECT_ROOT)) if not os.path.exists(os.path.dirname(target_path)): os.makedirs(os.path.dirname(target_path)) shutil.copy(dependency, os.path.join(release_folder, os.path.relpath(dependency, PROJECT_ROOT))) remove_pyc_files(release_folder) removed_path = 1 while removed_path > 0: removed_path = remove_empty_folders(release_folder) # 必须先将绝对导入转换为相对导入,否则enhance_import_for_py_files一步会改变绝对导入的路径导致出错 # convert absolute import to relative import if it's an extension if is_extension: for py_file in search_files(release_folder, {".py"}): convert_absolute_to_relative(py_file, release_folder) # 更新打包后的绝对导入路径:由于打包后文件夹的层级关系发生了变化,需要更新打包后的绝对导入路径 enhance_import_for_py_files(release_folder) # enhance relative import for root __init__.py # enhance_relative_import_for_init_py(os.path.join(release_folder, "__init__.py"), # _ADDONS_FOLDER, addon_name) # include wheel files when need to be zipped addon_config_file = os.path.join(_ADDON_ROOT, addon_name, _ADDON_MANIFEST_FILE) addon_config = {} if os.path.exists(addon_config_file) and is_extension: addon_config = read_ext_config(addon_config_file) if need_zip: # package whl files into extension if "wheels" in addon_config: wheel_files = addon_config["wheels"] if len(wheel_files) > 0: wheel_folder = os.path.join(release_folder, _WHEELS_PATH) os.mkdir(wheel_folder) for wheel_file in wheel_files: # You much put the .whl file directly under the wheels folder, not in a subfolder # 你必须将.whl文件直接放在wheels文件夹下,而不是在子文件夹中 assert wheel_file.startswith("./wheels/") and wheel_file.count("/") == 2 wheel_source = os.path.join(PROJECT_ROOT, wheel_file) if not os.path.exists(wheel_source): raise ValueError("Wheel file not found:", wheel_source, ". Please download the required wheel file to the wheels folder.") shutil.copy(wheel_source, wheel_folder) real_addon_name = "{addon_name}".format(addon_name=release_folder) if is_extension: real_addon_name = f"{real_addon_name}_ext" if with_version: _version: str if not is_extension: bl_info = get_addon_info(target_init_file) if bl_info is not None: _version = '.'.join([str(x) for x in bl_info['version']]) else: raise ValueError("bl_info not found in:", target_init_file) else: if "version" in addon_config: _version = addon_config["version"] else: raise ValueError("version not found in:", addon_config_file) real_addon_name = f"{real_addon_name}_V{_version}" if with_timestamp: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") real_addon_name = f"{real_addon_name}_{timestamp}" released_addon_path = os.path.abspath(os.path.join(release_dir, real_addon_name) + ".zip") # zip the addon if need_zip: zip_folder(release_folder, real_addon_name, is_extension) print("Add on released:", released_addon_path) return released_addon_path def get_addon_info(filename: str): file_content = read_utf8(filename) try: parsed_ast = ast.parse(file_content) for node in ast.walk(parsed_ast): if isinstance(node, ast.Assign): for target in node.targets: if isinstance(target, ast.Name) and target.id == "bl_info": return ast.literal_eval(node.value) except Exception as e: return None def generate_bootstrap_init_file(addon_name: str, bl_info: dict): bootstrap_init_file_template = """from .addons.{addon_name} import register as addon_register, unregister as addon_unregister bl_info = {bl_info} def register(): addon_register() def unregister(): addon_unregister() """ bl_info_str = ( "{\n" + ",\n".join( f' "{key}": {repr(value)}' for key, value in bl_info.items() ) + "\n}" ) return bootstrap_init_file_template.format(addon_name=addon_name, bl_info=bl_info_str) # pyc files are auto generated, need to be removed before release def remove_pyc_files(release_folder: str): all_pyc_file = search_files(release_folder, {"pyc"}) for pyc_file in all_pyc_file: os.remove(pyc_file) def remove_empty_folders(root_path): all_folder_to_remove = [] for root, dirnames, filenames in os.walk(root_path, topdown=False): for dirname in dirnames: dir_to_check = os.path.join(root, dirname) if not os.listdir(dir_to_check): all_folder_to_remove.append(dir_to_check) for folder in all_folder_to_remove: shutil.rmtree(folder) return len(all_folder_to_remove) # Zip the folder in a way that blender can recognize it as an addon. def zip_folder(target_root, output_zip_file, is_extension): if is_extension: shutil.make_archive(output_zip_file, 'zip', Path(target_root)) else: shutil.make_archive(output_zip_file, 'zip', Path(target_root).parent, base_dir=os.path.basename(target_root)) def find_imported_modules(file_path): root = ast.parse(read_utf8(file_path), filename=file_path) imported_modules = set() for node in ast.walk(root): if isinstance(node, ast.Import): for alias in node.names: imported_modules.add(alias.name) elif isinstance(node, ast.ImportFrom): if node.module: module_name = node.module imported_modules.add(module_name) for alias in node.names: if node.module: imported_modules.add(f"{node.module}.{alias.name}") else: imported_modules.add(alias.name) return imported_modules def resolve_module_path(module_name, base_path, project_root): if not module_name.endswith(".*"): # Handle import all module_path = module_name.replace('.', '/') module_path = os.path.join(project_root, module_path) if os.path.isdir(module_path): module_path = os.path.join(module_path, '__init__.py') return [module_path] elif os.path.isfile(module_path + '.py'): module_path = module_path + '.py' return [module_path] else: if "." not in module_name: # most likely a standard library module # 有一种可能是相对导入 from . import xxx, from .. import xxx 等 # 这种情况下需要根据当前文件的路径来解析 看module_name.py是否存在于当前文件的同级目录或者父级目录 # 从base_path开始向上查找,直到找到module_name.py或者到达project_root search_path = os.path.dirname(base_path) potential_result = [] while is_subdirectory(search_path, project_root): possible_path = os.path.join(search_path, module_name + '.py') if os.path.isfile(possible_path): potential_result.append(possible_path) search_path = os.path.dirname(search_path) return potential_result current_search_dir = os.path.dirname(base_path) while is_subdirectory(current_search_dir, project_root): module_path = module_name.replace('.', '/') module_path = os.path.join(current_search_dir, module_path) if os.path.isdir(module_path): module_path = os.path.join(module_path, '__init__.py') return [module_path] elif os.path.isfile(module_path + '.py'): module_path = module_path + '.py' return [module_path] current_search_dir = os.path.dirname(current_search_dir) return [] else: module_name = module_name[:-2] module_path = module_name.replace('.', '/') possible_root_path = os.path.join(project_root, module_path) if os.path.isdir(possible_root_path): possible_root_path = os.path.join(possible_root_path, '__init__.py') return [possible_root_path] elif os.path.isfile(possible_root_path + '.py'): possible_root_path = possible_root_path + '.py' return [possible_root_path] else: current_search_dir = os.path.dirname(base_path) while is_subdirectory(current_search_dir, project_root): possible_root_path = os.path.join(current_search_dir, module_path) if os.path.isdir(possible_root_path): possible_root_path = os.path.join(possible_root_path, '__init__.py') return [possible_root_path] elif os.path.isfile(possible_root_path + '.py'): possible_root_path = possible_root_path + '.py' return [possible_root_path] current_search_dir = os.path.dirname(current_search_dir) return [] def find_all_dependencies(file_paths: list, project_root: str): dependencies = set() to_process = file_paths.copy() processed = set() while to_process: current_file = os.path.abspath(to_process.pop()) if current_file in processed: continue processed.add(current_file) dependencies.add(current_file) try: imported_modules = find_imported_modules(current_file) except SyntaxError as e: raise SyntaxError(f"Syntax error in file {current_file}: {e}") # 以下代码会将除了当前目标插件文件夹以外的其他被引用的文件夹中的__init__.py文件也加入到依赖中,使之成为有效的模块,从而将其中的Blender # 类也加入到自动注册的范围中,一般来说,我们引用外部文件夹的目的是复用其内部函数,而非将插件外部模块中定义的Operator,Panel等元素 # 直接加到当前插件中(如果需要使用其他插件的这些元素,更好的做法是将其直接存放到你的插件文件夹内),因此注释掉,如果您有特殊需求,可以取消注释 # The following code will add __init__.py files in other # referenced folders to the dependencies, in addition to the current ACTIVE ADDON ,making those folders valid # modules and thus classes in them will be added the scope of automatic class registration. (The # auto_load.py) It is commented out because usually we just want to reference reusable functions from # modules outside the current addon Instead of directly adding their Operator's Panels into your own addon. ( # If you really want to do that, include them as sub package of your own addon would be a better option). But # If you have special requirements, you can uncomment it. # potential_init_file = os.path.abspath(os.path.join(os.path.dirname(current_file), '__init__.py')) # while is_subdirectory(os.path.dirname(potential_init_file), # project_root) and potential_init_file != os.path.abspath( # os.path.join(project_root, "__init__.py")): # if os.path.exists(potential_init_file) and potential_init_file not in processed: # to_process.append(potential_init_file) # dependencies.add(potential_init_file) # potential_init_file = os.path.abspath( # os.path.join(os.path.dirname(os.path.dirname(potential_init_file)), '__init__.py')) for module in imported_modules: module_path = resolve_module_path(module, current_file, project_root) if len(module_path) > 0: for each_module_path in module_path: each_module_path = os.path.abspath(each_module_path) if each_module_path not in processed: to_process.append(each_module_path) return dependencies def enhance_import_for_py_files(addon_dir: str): namespace = os.path.basename(addon_dir) all_py_modules = find_all_py_modules(addon_dir) all_py_file = search_files(addon_dir, {".py"}) for py_file in all_py_file: hasUpdated = False content = read_utf8(py_file) for module_path in _import_module_pattern.finditer(content): original_module_path = module_path.groups()[0] if original_module_path in all_py_modules: hasUpdated = True content = content.replace("from " + original_module_path + " import", "from " + namespace + "." + original_module_path + " import") if hasUpdated: write_utf8(py_file, content) def convert_absolute_to_relative(file_path: str, project_root: str): """ Convert all absolute imports to relative imports in a Python file. Notice this does not handle import like import xxx.yyy.zzz as zzz 在开发扩展时,不要用这种方式导入项目内的模块,这种方式导入的模块无法被转换为相对导入 Args: file_path (str): Path to the Python file to modify. project_root (str): Root directory of the project. """ # Normalize paths file_path = os.path.abspath(file_path) project_root = os.path.abspath(project_root) lines = read_utf8_in_lines(file_path) modified_lines = [] changed = False for line in lines: # help skipping expensive path check stripped_line = line.strip() if (not stripped_line.startswith("from ")) or stripped_line.startswith("from ."): # Leave non-import lines unchanged modified_lines.append(line) continue match = _absolute_import_pattern.match(line) if match: # get whitespace before the import statement leading_space = line[:line.index("from")] absolute_module = match.group(1) import_items = match.group(2) # Check if the absolute module is within the project absolute_module_path = absolute_module.replace('.', os.sep) full_module_path = os.path.join(project_root, absolute_module_path) if os.path.exists(full_module_path) or os.path.exists(f"{full_module_path}.py"): # Calculate the relative import path target_relative_path = os.path.relpath( os.path.join(project_root, absolute_module_path), os.path.dirname(file_path) ) # Count the levels for leading dots levels_up = target_relative_path.count("..") + 1 leading_dots = '.' * levels_up # Build the relative import line target_relative_path = target_relative_path.strip("." + os.sep) relative_import_line = leading_space + f"from {leading_dots}{target_relative_path.replace(os.sep, '.')} import {import_items}\n" if relative_import_line != line: modified_lines.append(relative_import_line) changed = True continue else: # Leave non-matching lines unchanged modified_lines.append(line) else: # Leave non-matching lines unchanged modified_lines.append(line) # print(f"not match {line} in {timer() - start3} seconds") # Write the modified content back to the file if changes were made if changed: write_utf8_in_lines(file_path, modified_lines) def find_all_py_modules(root_dir: str) -> set: all_py_modules = set() all_py_file = search_files(root_dir, {".py"}) for py_file in all_py_file: rel_path = str(os.path.relpath(py_file, root_dir)) modules = rel_path.replace("__init__.py", "").replace(".py", "").split(os.path.sep) if len(modules[-1]) == 0: modules = modules[0:-1] module_name = "" for i in range(len(modules)): module_name += modules[i] + "." all_py_modules.add(module_name[0:-1]) return all_py_modules def start_watch_for_update(init_file, addon_name, stop_event: threading.Event): install_if_missing("watchdog") from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer class FileUpdateHandler(FileSystemEventHandler): def __init__(self): super(FileUpdateHandler, self).__init__() self.has_update = False def on_any_event(self, event): source_path = event.src_path if source_path.endswith(".py"): self.has_update = True def clear_update(self): self.has_update = False path = PROJECT_ROOT event_handler = FileUpdateHandler() observer = Observer() observer.schedule(event_handler, path, recursive=True) observer.start() try: while not stop_event.is_set(): time.sleep(1) if event_handler.has_update: try: update_addon_for_test(init_file, addon_name) event_handler.clear_update() except Exception as e: print(e) print( "Addon updated failed: Please make sure no other process is" " using the addon folder. You might need to restart the test to update the addon in Blender.") print("Stop watching for update...") except KeyboardInterrupt: observer.stop() observer.join() def update_addon_for_test(init_file, addon_name): if BLENDER_ADDON_PATH is None: # 无法得到Blender插件路径 请检查在main.py或config.ini中的配置 raise ValueError( "Could not find Blender addon installation path. Please check the configuration in main.py or config.ini") addon_path = release_addon(init_file, addon_name, with_timestamp=False, is_extension=IS_EXTENSION, release_dir=TEST_RELEASE_DIR, need_zip=False) executable_path = os.path.join(os.path.dirname(addon_path), addon_name) test_addon_path = os.path.join(BLENDER_ADDON_PATH, addon_name) if os.path.exists(test_addon_path): shutil.rmtree(test_addon_path) shutil.copytree(executable_path, test_addon_path) # write an MD5 to the addon folder to inform the addon content has been changed addon_md5 = get_md5_folder(executable_path) write_utf8(os.path.join(test_addon_path, _addon_md5__signature), addon_md5) ================================================ FILE: main.py ================================================ # BlenderAddonPackageTool - A framework for developing multiple blender addons in a single workspace # Copyright (C) 2024 Xinyu Zhu import os from configparser import ConfigParser from common.class_loader.module_installer import default_blender_addon_path, normalize_blender_path_by_system # The name of current active addon to be created, tested or released # 要创建、测试或发布的当前活动插件的名称 ACTIVE_ADDON = "sample_addon" # The path of the blender executable. Blender2.93 is the minimum version required # Blender可执行文件的路径,Blender2.93是所需的最低版本 BLENDER_EXE_PATH = "C:/software/general/Blender/blender-3.6.0-windows-x64/blender.exe" # Linux example Linux示例 # BLENDER_EXE_PATH = "/usr/local/blender/blender-3.6.0-linux-x64/blender" # MacOS examplenotice "/Contents/MacOS/Blender" will be appended automatically if you didn't write it explicitly # MacOS示例 框架会自动附加"/Contents/MacOS/Blender" 所以您不必写出 # BLENDER_EXE_PATH = "/Applications/Blender/blender-3.6.0-macOS/Blender.app" # Are you developing an extension(for Blender4.2) instead of legacy addon? # https://docs.blender.org/manual/en/latest/advanced/extensions/addons.html # The framework will convert absolute import to relative import when packaging the extension. # Make sure to update __addon_name__ in config.py if you are migrating from legacy addon to extension. # 是否是面向Blender4.2以后的扩展而不是传统插件? # https://docs.blender.org/manual/en/latest/advanced/extensions/addons.html # 在打包扩展时,框架会将绝对导入转换为相对导入。如果你从传统插件迁移到扩展,请确保更新config.py中的__addon_name__ IS_EXTENSION = False # You can override the default path by setting the path manually # 您可以通过手动设置路径来覆盖默认插件安装路径 或者在config.ini中设置 # BLENDER_ADDON_PATH = "C:/software/general/Blender/Blender3.5/3.5/scripts/addons/" BLENDER_ADDON_PATH = None if os.path.exists(BLENDER_EXE_PATH): BLENDER_ADDON_PATH = default_blender_addon_path(BLENDER_EXE_PATH) PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__)) # 若存在config.ini则从其中中读取配置 CONFIG_FILEPATH = os.path.join(PROJECT_ROOT, 'config.ini') # The default release dir. Must not within the current workspace # 插件发布的默认目录,不能在当前工作空间内 DEFAULT_RELEASE_DIR = os.path.join(PROJECT_ROOT, "../addon_release/") # The default test release dir. Must not within the current workspace # 测试插件发布的默认目录,不能在当前工作空间内 TEST_RELEASE_DIR = os.path.join(PROJECT_ROOT, "../addon_test/") if os.path.isfile(CONFIG_FILEPATH): configParser = ConfigParser() configParser.read(CONFIG_FILEPATH, encoding='utf-8') if configParser.has_option('blender', 'exe_path'): BLENDER_EXE_PATH = configParser.get('blender', 'exe_path') # The path of the blender addon folder # 同时更改Blender插件文件夹的路径 BLENDER_ADDON_PATH = default_blender_addon_path(BLENDER_EXE_PATH) if configParser.has_option('blender', 'addon_path') and configParser.get('blender', 'addon_path'): BLENDER_ADDON_PATH = configParser.get('blender', 'addon_path') if configParser.has_option('default', 'addon') and configParser.get('default', 'addon'): ACTIVE_ADDON = configParser.get('default', 'addon') if configParser.has_option('default', 'is_extension') and configParser.get('default', 'is_extension'): IS_EXTENSION = configParser.getboolean('default', 'is_extension') if configParser.has_option('default', 'release_dir') and configParser.get('default', 'release_dir'): DEFAULT_RELEASE_DIR = configParser.get('default', 'release_dir') if configParser.has_option('default', 'test_release_dir') and configParser.get('default', 'test_release_dir'): TEST_RELEASE_DIR = configParser.get('default', 'test_release_dir') BLENDER_EXE_PATH = normalize_blender_path_by_system(BLENDER_EXE_PATH) # If you want to override theBLENDER_ADDON_PATH(the path to install addon during testing), uncomment the following line and set the path manually. # 如果要覆盖BLENDER_ADDON_PATH(测试插件安装路径),请取消下一行的注释并手动设置路径 # BLENDER_ADDON_PATH = "" # Could not find the blender addon path, raise error. Please set BLENDER_ADDON_PATH manually. # 未找到Blender插件路径,引发错误 请手动设置BLENDER_ADDON_PATH if os.path.exists(BLENDER_EXE_PATH) and (not BLENDER_ADDON_PATH or not os.path.exists(BLENDER_ADDON_PATH)): raise ValueError("Blender addon path not found: " + BLENDER_ADDON_PATH, "Please set the correct path in config.ini") ================================================ FILE: release.py ================================================ from framework import get_init_file_path, release_addon from main import ACTIVE_ADDON, IS_EXTENSION # 发布前请修改ACTIVE_ADDON参数 # The name of the addon to be released, this name is defined in the config.py of the addon as __addon_name__ # 插件的config.py文件中定义的插件名称 __addon_name__ if __name__ == '__main__': import argparse parser = argparse.ArgumentParser() parser.add_argument('addon', default=ACTIVE_ADDON, nargs='?', help='addon name') parser.add_argument('--is_extension', default=IS_EXTENSION, action='store_true', help='If true, package the addon ' 'as extension, framework ' 'will convert absolute ' 'import to relative import ' 'for you and will take care ' 'of packaging the ' 'extension. Default is the ' 'value of IS_EXTENSION') parser.add_argument('--disable_zip', default=False, action='store_true', help='If true, release the addon into a ' 'plain folder and do not zip it ' 'into an installable package, ' 'useful if you want to add more ' 'files and zip by yourself.') parser.add_argument('--with_version', default=False, action='store_true', help='Append the addon version number (' 'as specified in bl_info) to the ' 'released zip file name.') parser.add_argument('--with_timestamp', default=False, action='store_true', help='Append a timestamp to the zip ' 'file name.') args = parser.parse_args() release_addon(target_init_file=get_init_file_path(args.addon), addon_name=args.addon, need_zip=not args.disable_zip, is_extension=args.is_extension, with_timestamp=args.with_timestamp, with_version=args.with_version, ) ================================================ FILE: test.py ================================================ from framework import test_addon from main import ACTIVE_ADDON # 测试前请修改ACTIVE_ADDON参数 # The name of the addon to be tested, this name is defined in the config.py of the addon as __addon_name__ # 插件的config.py文件中定义的插件名称 __addon_name__ if __name__ == '__main__': import argparse parser = argparse.ArgumentParser() parser.add_argument('addon', default=ACTIVE_ADDON, nargs='?', help='addon name') parser.add_argument('--disable_watch', default=False, action='store_true', help='Do not reload addon when file ' 'changed') args = parser.parse_args() test_addon(args.addon, enable_watch=not args.disable_watch) ================================================ FILE: wheels/README.md ================================================ # How to include Python wheels for addons starting from Blender 4.2 Please read https://docs.blender.org/manual/en/latest/advanced/extensions/python_wheels.html for context. To bundle Python wheels with your addon, you need to download to this `wheels` directory. And In blender_manifest.toml include the wheels as a list of paths, e.g.: ``` wheels = [ "./wheels/pillow-10.3.0-cp311-cp311-macosx_11_0_arm64.whl", "./wheels/pillow-10.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", "./wheels/pillow-10.3.0-cp311-cp311-win_amd64.whl", ] ``` When you release your addon, the framework will copy these wheels into the zip file according to the toml configuration. This way you don't have to maintain wheels in your addon directory which could lead to duplication. Noticed that when testing your addon, the framework will not automatically include these wheels to avoid overhead. You might see `ModuleNotFoundError` when testing your addon even though you have included the wheels in the project. To solve that, you need to install them manually using `pip install` or `python -m pip install` for your testing Blender python environment. This could also be done in the IDE if you choose to use the same python environment for developing. []: # 如何给Blender 4.2及以后的插件添加 Python wheels 请参考 https://docs.blender.org/manual/en/latest/advanced/extensions/python_wheels.html 了解背景知识。 给插件添加 Python wheels ,你需要将.whl文件下载到这个 `wheels` 文件夹中. 并且在插件配置文件 blender_manifest.toml 中声明插件所需的whl文件路径,例如: ``` wheels = [ "./wheels/pillow-10.3.0-cp311-cp311-macosx_11_0_arm64.whl", "./wheels/pillow-10.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", "./wheels/pillow-10.3.0-cp311-cp311-win_amd64.whl", ] ``` 当你打包插件时,框架会根据toml将这些whl文件复制到zip文件中。因此你不需要在插件的目录中存放这些whl文件导致重复。 注意,当你测试插件时,框架不会自动包含这些whl文件以避免额外的开销。你可能会在测试插件时看到 `ModuleNotFoundError` 错误,即使你已经在项目中包含了这些whl文件。 为此你需要手动使用 `pip install` 或 `python -m pip install` 命令 将这些whl文件安装到你进行测试的Blender python环境中。如果你恰巧选择在IDE中使用相同的python环境进行开发,你也可以通过IDE中安装这些whl文件。 ================================================ FILE: workflows/addon_release.yml ================================================ # Put this file in the .github/workflows directory of your repository to take effect. Modify the addon name that needs to be automatically released according to your project. (At Step 3) # Notice you need to change your project setting: Settings -> Actions -> General -> Workflow permissions -> Read and write permission for this workflow to work. # 将此文件放在您的项目目录 .github/workflows 中即可生效。请根据您的项目需要,修改自动发布的插件名称。(在 Step 3中) # 注意您需要在Github项目中 Settings -> Actions -> General -> Workflow permissions -> 为此工作流程添加读写权限才能使其正常工作。 name: Automatic Release Addon Action on: push: branches: - main # Change this to the branch you want to trigger the workflow, e.g., main, master, or develop jobs: release: runs-on: ubuntu-latest steps: # Step 1: Checkout the code - name: Checkout code uses: actions/checkout@v3 # Step 2: Set up Python environment - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.11' # Step 3: Run the release command for each addon. Please update this step according to your project. - name: Run release command for sample_addon run: | python release.py sample_addon --with_version # Add additional steps for each addon as needed... 按如下格式添加其他插件的发布命令 # - name: Run release command for addon1 as extension # run: | # python release.py addon1 --is_extension --with_version # # - name: Run release command for addon2 # run: | # python release.py addon2 --is_extension --with_version --with_timestamp # Add additional steps for each addon as needed... # You can add more steps as needed for each addon. # Step 4: List files in addon_release directory - name: List files in addon_release directory run: | echo "Files in addon_release directory:" ls ../addon_release # Step 5: Create GitHub Release - name: Create GitHub Release id: create_release uses: actions/create-release@v1 with: tag_name: "release-${{ github.run_id }}" release_name: "Release ${{ github.run_id }}" draft: false prerelease: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Step 6: Upload release assets - name: Upload release assets run: | for file in ../addon_release/*.zip; do echo "Uploading $file..." gh release upload "release-${{ github.run_id }}" "$file" done env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}