Python: Support multiple custom script directories in Preferences #104876

Merged
Julian Eisel merged 9 commits from JulianEisel/blender:temp-multiple-script-dirs into main 2023-04-11 15:21:06 +02:00
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 = []
JulianEisel marked this conversation as resolved
Review

Prefer this be removed as scripts that use it will have incorrect behavior.

Prefer this be removed as scripts that use it will have incorrect behavior.
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)) {
JulianEisel marked this conversation as resolved Outdated

The name should be initialized (suggest "Untitled" ?) - otherwise no strong preference, just don't leave it blank.

The name should be initialized (suggest "Untitled" ?) - otherwise no strong preference, just don't leave it blank.

Good catch, missed that when introducing the name option.

Good catch, missed that when introducing the name option.
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)
JulianEisel marked this conversation as resolved
Review

If DEFAULT or an empty string is passed in, use a fallback name such as "Untitled" or "Path", this avoids having to account for unlikely corner cases - maybe the user has a points to a script dir called DEFAULT and gets an error in the operator.

Disallowing empty strings is just a convention from data-block naming, which I think would be good to enforce here too since it's not expected and means the enum identifier for e.g. would be an empty string - probably it works for the most-part but could cause issues (empty strings have a special meaning for enum separators .. for e.g.). Python scripts may do truth checks on a value without realizing an empty string is a valid value... so we can avoid all this with a default name.

If `DEFAULT` or an empty string is passed in, use a fallback name such as "Untitled" or "Path", this avoids having to account for unlikely corner cases - maybe the user has a points to a script dir called `DEFAULT` and gets an error in the operator. Disallowing empty strings is just a convention from data-block naming, which I think would be good to enforce here too since it's not expected and means the enum identifier for e.g. would be an empty string - probably it works for the most-part but could cause issues (empty strings have a special meaning for enum separators .. for e.g.). Python scripts may do truth checks on a value without realizing an empty string is a valid value... so we can avoid all this with a default name.
{
bUserScriptDirectory *script_dir = ptr->data;
bool value_invalid = false;
if (!value[0]) {
value_invalid = true;
}
if (STREQ(value, "DEFAULT")) {
value_invalid = true;
}
JulianEisel marked this conversation as resolved
Review

Rather not warn as in the rare case a user runs into this - it's not as if there is anything to "fix", besides the script author adding explicit checks for "DEFAULT" which isn't useful.

Over long names will also be clipped for e.g. which doesn't warn. In general it's possible the name requested in Blender is manipulated. It can't be assumed a string literal will be used verbatim.

Rather not warn as in the rare case a user runs into this - it's not as if there is anything to "fix", besides the script author adding explicit checks for "DEFAULT" which isn't useful. Over long names will also be clipped for e.g. which doesn't warn. In general it's possible the name requested in Blender is manipulated. It can't be assumed a string literal will be used verbatim.
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");