Python: Support multiple custom script directories in Preferences

Makes it possible to select multiple custom script directories in Preferences >
File Paths, replacing the single Scripts path option. Each of these directories
supports the regular script directory layout with a startup file (or files?),
add-ons, modules and presets.
When installing an add-on, the script directory can be chosen.

NOTE: Deprecates the `bpy.types.PreferencesFilePaths.script_directory`
property, and replaces `bpy.utils.script_path_pref` with
`bpy.utils.script_paths_pref`.

Pull Request: https://projects.blender.org/blender/blender/pulls/104876
This commit is contained in:
Julian Eisel 2023-04-11 15:20:52 +02:00
parent dc402a8b96
commit ba25023d22
14 changed files with 292 additions and 46 deletions

View File

@ -34,7 +34,7 @@ const UserDef U_default = {
.renderdir = "//",
.render_cachedir = "",
.textudir = "//",
.pythondir = "",
.script_directories = {NULL, NULL},
.sounddir = "//",
.i18ndir = "",
.image_editor = "",

View File

@ -30,7 +30,6 @@ __all__ = (
"previews",
"resource_path",
"script_path_user",
"script_path_pref",
"script_paths",
"smpte_from_frame",
"smpte_from_seconds",
@ -340,10 +339,14 @@ def script_path_user():
return _os.path.normpath(path) if path else None
def script_path_pref():
"""returns the user preference or None"""
path = _preferences.filepaths.script_directory
return _os.path.normpath(path) if path else None
def script_paths_pref():
"""Returns a list of user preference script directories."""
paths = []
for script_directory in _preferences.filepaths.script_directories:
directory = script_directory.directory
if directory:
paths.append(_os.path.normpath(directory))
return paths
def script_paths(*, subdir=None, user_pref=True, check_all=False, use_user=True):
@ -384,9 +387,6 @@ def script_paths(*, subdir=None, user_pref=True, check_all=False, use_user=True)
if use_user:
base_paths.append(path_user)
if user_pref:
base_paths.append(script_path_pref())
scripts = []
for path in base_paths:
if not path:

View File

@ -88,7 +88,9 @@ def write_sysinfo(filepath):
for p in bpy.utils.script_paths():
output.write("\t%r\n" % p)
output.write("user scripts: %r\n" % (bpy.utils.script_path_user()))
output.write("pref scripts: %r\n" % (bpy.utils.script_path_pref()))
output.write("pref scripts:\n")
for p in bpy.utils.script_paths_pref():
output.write("\t%r\n" % p)
output.write("datafiles: %r\n" % (bpy.utils.user_resource('DATAFILES')))
output.write("config: %r\n" % (bpy.utils.user_resource('CONFIG')))
output.write("scripts : %r\n" % (bpy.utils.user_resource('SCRIPTS')))

View File

@ -587,12 +587,18 @@ class PREFERENCES_OT_addon_install(Operator):
description="Remove existing add-ons with the same ID",
default=True,
)
def _target_path_items(_self, context):
paths = context.preferences.filepaths
return (
('DEFAULT', "Default", ""),
None,
*[(item.name, item.name, "") for index, item in enumerate(paths.script_directories) if item.directory],
)
target: EnumProperty(
name="Target Path",
items=(
('DEFAULT', "Default", ""),
('PREFS', "Preferences", ""),
),
items=_target_path_items,
)
filepath: StringProperty(
@ -626,9 +632,11 @@ class PREFERENCES_OT_addon_install(Operator):
# Don't use `bpy.utils.script_paths(path="addons")` because we may not be able to write to it.
path_addons = bpy.utils.user_resource('SCRIPTS', path="addons", create=True)
else:
path_addons = context.preferences.filepaths.script_directory
if path_addons:
path_addons = os.path.join(path_addons, "addons")
paths = context.preferences.filepaths
for script_directory in paths.script_directories:
if script_directory.name == self.target:
path_addons = os.path.join(script_directory.directory, "addons")
break
if not path_addons:
self.report({'ERROR'}, "Failed to get add-ons path")
@ -1139,6 +1147,60 @@ class PREFERENCES_OT_studiolight_show(Operator):
return {'FINISHED'}
class PREFERENCES_OT_script_directory_new(Operator):
bl_idname = "preferences.script_directory_add"
bl_label = "Add Python Script Directory"
directory: StringProperty(
subtype='DIR_PATH',
)
filter_folder: BoolProperty(
name="Filter Folders",
default=True,
options={'HIDDEN'},
)
def execute(self, context):
import os
script_directories = context.preferences.filepaths.script_directories
new_dir = script_directories.new()
# Assign path selected via file browser.
new_dir.directory = self.directory
new_dir.name = os.path.basename(self.directory.rstrip(os.sep))
assert context.preferences.is_dirty == True
return {'FINISHED'}
def invoke(self, context, _event):
wm = context.window_manager
wm.fileselect_add(self)
return {'RUNNING_MODAL'}
class PREFERENCES_OT_script_directory_remove(Operator):
bl_idname = "preferences.script_directory_remove"
bl_label = "Remove Python Script Directory"
index: IntProperty(
name="Index",
description="Index of the script directory to remove",
)
def execute(self, context):
script_directories = context.preferences.filepaths.script_directories
for search_index, script_directory in enumerate(script_directories):
if search_index == self.index:
script_directories.remove(script_directory)
break
assert context.preferences.is_dirty == True
return {'FINISHED'}
classes = (
PREFERENCES_OT_addon_disable,
PREFERENCES_OT_addon_enable,
@ -1164,4 +1226,6 @@ classes = (
PREFERENCES_OT_studiolight_uninstall,
PREFERENCES_OT_studiolight_copy_settings,
PREFERENCES_OT_studiolight_show,
PREFERENCES_OT_script_directory_new,
PREFERENCES_OT_script_directory_remove,
)

View File

@ -1333,11 +1333,52 @@ class USERPREF_PT_file_paths_data(FilePathsPanel, Panel):
col = self.layout.column()
col.prop(paths, "font_directory", text="Fonts")
col.prop(paths, "texture_directory", text="Textures")
col.prop(paths, "script_directory", text="Scripts")
col.prop(paths, "sound_directory", text="Sounds")
col.prop(paths, "temporary_directory", text="Temporary Files")
class USERPREF_PT_file_paths_script_directories(FilePathsPanel, Panel):
bl_label = "Script Directories"
def draw(self, context):
layout = self.layout
paths = context.preferences.filepaths
if len(paths.script_directories) == 0:
layout.operator("preferences.script_directory_add", text="Add", icon='ADD')
return
layout.use_property_split = False
layout.use_property_decorate = False
box = layout.box()
split = box.split(factor=0.35)
name_col = split.column()
path_col = split.column()
row = name_col.row(align=True) # Padding
row.separator()
row.label(text="Name")
row = path_col.row(align=True) # Padding
row.separator()
row.label(text="Path")
row.operator("preferences.script_directory_add", text="", icon='ADD', emboss=False)
for i, script_directory in enumerate(paths.script_directories):
row = name_col.row()
row.alert = not script_directory.name
row.prop(script_directory, "name", text="")
row = path_col.row()
subrow = row.row()
subrow.alert = not script_directory.directory
subrow.prop(script_directory, "directory", text="")
row.operator("preferences.script_directory_remove", text="", icon='X', emboss=False).index = i
class USERPREF_PT_file_paths_render(FilePathsPanel, Panel):
bl_label = "Render"
@ -1878,7 +1919,7 @@ class USERPREF_PT_addons(AddOnPanel, Panel):
if not user_addon_paths:
for path in (
bpy.utils.script_path_user(),
bpy.utils.script_path_pref(),
*bpy.utils.script_paths_pref(),
):
if path is not None:
user_addon_paths.append(os.path.join(path, "addons"))
@ -1910,7 +1951,7 @@ class USERPREF_PT_addons(AddOnPanel, Panel):
addon_user_dirs = tuple(
p for p in (
os.path.join(prefs.filepaths.script_directory, "addons"),
*[os.path.join(pref_p, "addons") for pref_p in bpy.utils.script_path_user()],
bpy.utils.user_resource('SCRIPTS', path="addons"),
)
if p
@ -2457,6 +2498,7 @@ classes = (
USERPREF_PT_theme_strip_colors,
USERPREF_PT_file_paths_data,
USERPREF_PT_file_paths_script_directories,
USERPREF_PT_file_paths_render,
USERPREF_PT_file_paths_applications,
USERPREF_PT_file_paths_development,

View File

@ -25,7 +25,7 @@ extern "C" {
/* Blender file format version. */
#define BLENDER_FILE_VERSION BLENDER_VERSION
#define BLENDER_FILE_SUBVERSION 4
#define BLENDER_FILE_SUBVERSION 5
/* Minimum Blender version that supports reading file written with the current
* version. Older Blender versions will test this and show a warning if the file

View File

@ -296,6 +296,7 @@ void BKE_blender_userdef_data_free(UserDef *userdef, bool clear_fonts)
}
BLI_freelistN(&userdef->autoexec_paths);
BLI_freelistN(&userdef->script_directories);
BLI_freelistN(&userdef->asset_libraries);
BLI_freelistN(&userdef->uistyles);

View File

@ -3700,6 +3700,7 @@ static BHead *read_userdef(BlendFileData *bfd, FileData *fd, BHead *bhead)
BLO_read_list(reader, &user->user_menus);
BLO_read_list(reader, &user->addons);
BLO_read_list(reader, &user->autoexec_paths);
BLO_read_list(reader, &user->script_directories);
BLO_read_list(reader, &user->asset_libraries);
LISTBASE_FOREACH (wmKeyMap *, keymap, &user->user_keymaps) {

View File

@ -31,8 +31,12 @@
#include "BLO_readfile.h"
#include "BLT_translation.h"
#include "GPU_platform.h"
#include "MEM_guardedalloc.h"
#include "readfile.h" /* Own include. */
#include "WM_types.h"
@ -798,6 +802,17 @@ void blo_do_versions_userdef(UserDef *userdef)
}
}
if (!USER_VERSION_ATLEAST(306, 5)) {
if (userdef->pythondir_legacy[0]) {
bUserScriptDirectory *script_dir = MEM_callocN(sizeof(*script_dir),
"Versioning user script path");
STRNCPY(script_dir->dir_path, userdef->pythondir_legacy);
STRNCPY(script_dir->name, DATA_("Untitled"));
BLI_addhead(&userdef->script_directories, script_dir);
}
}
/**
* Versioning code until next subversion bump goes here.
*

View File

@ -922,6 +922,10 @@ static void write_userdef(BlendWriter *writer, const UserDef *userdef)
BLO_write_struct(writer, bPathCompare, path_cmp);
}
LISTBASE_FOREACH (const bUserScriptDirectory *, script_dir, &userdef->script_directories) {
BLO_write_struct(writer, bUserScriptDirectory, script_dir);
}
LISTBASE_FOREACH (const bUserAssetLibrary *, asset_library_ref, &userdef->asset_libraries) {
BLO_write_struct(writer, bUserAssetLibrary, asset_library_ref);
}

View File

@ -1033,7 +1033,14 @@ void fsmenu_read_system(struct FSMenu *fsmenu, int read_bookmarks)
FS_UDIR_PATH(U.fontdir, ICON_FILE_FONT)
FS_UDIR_PATH(U.textudir, ICON_FILE_IMAGE)
FS_UDIR_PATH(U.pythondir, ICON_FILE_SCRIPT)
LISTBASE_FOREACH (bUserScriptDirectory *, script_dir, &U.script_directories) {
fsmenu_insert_entry(fsmenu,
FS_CATEGORY_OTHER,
script_dir->dir_path,
script_dir->name,
ICON_FILE_SCRIPT,
FS_INSERT_LAST);
}
FS_UDIR_PATH(U.sounddir, ICON_FILE_SOUND)
FS_UDIR_PATH(U.tempdir, ICON_TEMP)

View File

@ -679,6 +679,17 @@ typedef struct UserDef_Experimental {
#define USER_EXPERIMENTAL_TEST(userdef, member) \
(((userdef)->flag & USER_DEVELOPER_UI) && ((userdef)->experimental).member)
/**
* Container to store multiple directory paths and a name for each as a #ListBase.
*/
typedef struct bUserScriptDirectory {
struct bUserScriptDirectory *next, *prev;
/** Name must be unique. */
char name[64]; /* MAX_NAME */
char dir_path[768]; /* FILE_MAXDIR */
} bUserScriptDirectory;
typedef struct UserDef {
DNA_DEFINE_CXX_METHODS(UserDef)
@ -703,22 +714,8 @@ typedef struct UserDef {
/** 768 = FILE_MAXDIR. */
char render_cachedir[768];
char textudir[768];
/**
* Optional user location for scripts.
*
* This supports the same layout as Blender's scripts directory `scripts`.
*
* \note Unlike most paths, changing this is not fully supported at run-time,
* requiring a restart to properly take effect. Supporting this would cause complications as
* the script path can contain `startup`, `addons` & `modules` etc. properly unwinding the
* Python environment to the state it _would_ have been in gets complicated.
*
* Although this is partially supported as the `sys.path` is refreshed when loading preferences.
* This is done to support #PREFERENCES_OT_copy_prev which is available to the user when they
* launch with a new version of Blender. In this case setting the script path on top of
* factory settings will work without problems.
*/
char pythondir[768];
/* Deprecated, use #UserDef.script_directories instead. */
char pythondir_legacy[768] DNA_DEPRECATED;
char sounddir[768];
char i18ndir[768];
/** 1024 = FILE_MAX. */
@ -790,6 +787,22 @@ typedef struct UserDef {
struct ListBase user_keyconfig_prefs;
struct ListBase addons;
struct ListBase autoexec_paths;
/**
* Optional user locations for Python scripts.
*
* This supports the same layout as Blender's scripts directory `scripts`.
*
* \note Unlike most paths, changing this is not fully supported at run-time,
* requiring a restart to properly take effect. Supporting this would cause complications as
* the script path can contain `startup`, `addons` & `modules` etc. properly unwinding the
* Python environment to the state it _would_ have been in gets complicated.
*
* Although this is partially supported as the `sys.path` is refreshed when loading preferences.
* This is done to support #PREFERENCES_OT_copy_prev which is available to the user when they
* launch with a new version of Blender. In this case setting the script path on top of
* factory settings will work without problems.
*/
ListBase script_directories; /* #bUserScriptDirectory */
/** #bUserMenu. */
struct ListBase user_menus;
/** #bUserAssetLibrary */

View File

@ -148,6 +148,7 @@ DNA_STRUCT_RENAME_ELEM(ThemeSpace, scrubbing_background, time_scrub_background)
DNA_STRUCT_RENAME_ELEM(ThemeSpace, show_back_grad, background_type)
DNA_STRUCT_RENAME_ELEM(UVProjectModifierData, num_projectors, projectors_num)
DNA_STRUCT_RENAME_ELEM(UserDef, gp_manhattendist, gp_manhattandist)
DNA_STRUCT_RENAME_ELEM(UserDef, pythondir, pythondir_legacy)
DNA_STRUCT_RENAME_ELEM(VFont, name, filepath)
DNA_STRUCT_RENAME_ELEM(View3D, far, clip_end)
DNA_STRUCT_RENAME_ELEM(View3D, near, clip_start)

View File

@ -150,6 +150,7 @@ static const EnumPropertyItem rna_enum_preference_gpu_backend_items[] = {
#ifdef RNA_RUNTIME
# include "BLI_math_vector.h"
# include "BLI_string_utils.h"
# include "DNA_object_types.h"
# include "DNA_screen_types.h"
@ -344,6 +345,52 @@ static void rna_userdef_script_autoexec_update(Main *UNUSED(bmain),
USERDEF_TAG_DIRTY;
}
static void rna_userdef_script_directory_name_set(PointerRNA *ptr, const char *value)
{
bUserScriptDirectory *script_dir = ptr->data;
bool value_invalid = false;
if (!value[0]) {
value_invalid = true;
}
if (STREQ(value, "DEFAULT")) {
value_invalid = true;
}
if (value_invalid) {
value = DATA_("Untitled");
}
BLI_strncpy_utf8(script_dir->name, value, sizeof(script_dir->name));
BLI_uniquename(&U.script_directories,
script_dir,
value,
'.',
offsetof(bUserScriptDirectory, name),
sizeof(script_dir->name));
}
static bUserScriptDirectory *rna_userdef_script_directory_new(void)
{
bUserScriptDirectory *script_dir = MEM_callocN(sizeof(*script_dir), __func__);
BLI_addtail(&U.script_directories, script_dir);
USERDEF_TAG_DIRTY;
return script_dir;
}
static void rna_userdef_script_directory_remove(ReportList *reports, PointerRNA *ptr)
{
bUserScriptDirectory *script_dir = ptr->data;
if (BLI_findindex(&U.script_directories, script_dir) == -1) {
BKE_report(reports, RPT_ERROR, "Script directory not found");
return;
}
BLI_freelinkN(&U.script_directories, script_dir);
RNA_POINTER_INVALIDATE(ptr);
USERDEF_TAG_DIRTY;
}
static void rna_userdef_load_ui_update(Main *UNUSED(bmain), Scene *UNUSED(scene), PointerRNA *ptr)
{
UserDef *userdef = (UserDef *)ptr->data;
@ -6212,6 +6259,57 @@ static void rna_def_userdef_filepaths_asset_library(BlenderRNA *brna)
RNA_def_property_update(prop, 0, "rna_userdef_update");
}
static void rna_def_userdef_script_directory(BlenderRNA *brna)
{
StructRNA *srna = RNA_def_struct(brna, "ScriptDirectory", NULL);
RNA_def_struct_sdna(srna, "bUserScriptDirectory");
RNA_def_struct_clear_flag(srna, STRUCT_UNDO);
RNA_def_struct_ui_text(srna, "Python Scripts Directory", "");
PropertyRNA *prop;
prop = RNA_def_property(srna, "name", PROP_STRING, PROP_NONE);
RNA_def_property_ui_text(prop, "Name", "Identifier for the Python scripts directory");
RNA_def_property_string_funcs(prop, NULL, NULL, "rna_userdef_script_directory_name_set");
RNA_def_struct_name_property(srna, prop);
RNA_def_property_update(prop, 0, "rna_userdef_update");
prop = RNA_def_property(srna, "directory", PROP_STRING, PROP_DIRPATH);
RNA_def_property_string_sdna(prop, NULL, "dir_path");
RNA_def_property_ui_text(
prop,
"Python Scripts Directory",
"Alternate script path, matching the default layout with sub-directories: startup, add-ons, "
"modules, and presets (requires restart)");
/* TODO: editing should reset sys.path! */
}
static void rna_def_userdef_script_directory_collection(BlenderRNA *brna, PropertyRNA *cprop)
{
StructRNA *srna;
FunctionRNA *func;
PropertyRNA *parm;
RNA_def_property_srna(cprop, "ScriptDirectoryCollection");
srna = RNA_def_struct(brna, "ScriptDirectoryCollection", NULL);
RNA_def_struct_clear_flag(srna, STRUCT_UNDO);
RNA_def_struct_ui_text(srna, "Python Scripts Directories", "");
func = RNA_def_function(srna, "new", "rna_userdef_script_directory_new");
RNA_def_function_flag(func, FUNC_NO_SELF);
RNA_def_function_ui_description(func, "Add a new python script directory");
/* return type */
parm = RNA_def_pointer(func, "script_directory", "ScriptDirectory", "", "");
RNA_def_function_return(func, parm);
func = RNA_def_function(srna, "remove", "rna_userdef_script_directory_remove");
RNA_def_function_flag(func, FUNC_NO_SELF | FUNC_USE_REPORTS);
RNA_def_function_ui_description(func, "Remove a python script directory");
parm = RNA_def_pointer(func, "script_directory", "ScriptDirectory", "", "");
RNA_def_parameter_flags(parm, PROP_NEVER_NULL, PARM_REQUIRED | PARM_RNAPTR);
RNA_def_parameter_clear_flags(parm, PROP_THICK_WRAP, 0);
}
static void rna_def_userdef_filepaths(BlenderRNA *brna)
{
PropertyRNA *prop;
@ -6311,14 +6409,12 @@ static void rna_def_userdef_filepaths(BlenderRNA *brna)
"Render Output Directory",
"The default directory for rendering output, for new scenes");
prop = RNA_def_property(srna, "script_directory", PROP_STRING, PROP_DIRPATH);
RNA_def_property_string_sdna(prop, NULL, "pythondir");
RNA_def_property_ui_text(
prop,
"Python Scripts Directory",
"Alternate script path, matching the default layout with subdirectories: "
"`startup`, `addons`, `modules`, and `presets` (requires restart)");
/* TODO: editing should reset sys.path! */
rna_def_userdef_script_directory(brna);
prop = RNA_def_property(srna, "script_directories", PROP_COLLECTION, PROP_NONE);
RNA_def_property_struct_type(prop, "ScriptDirectory");
RNA_def_property_ui_text(prop, "Python Scripts Directory", "");
rna_def_userdef_script_directory_collection(brna, prop);
prop = RNA_def_property(srna, "i18n_branches_directory", PROP_STRING, PROP_DIRPATH);
RNA_def_property_string_sdna(prop, NULL, "i18ndir");