Repository: netbox-community/netbox

Issue:
Custom scripts in sub packages should not be cached
### NetBox version

v3.4.1

### Python version

3.10

### Steps to Reproduce

Create the structure described in the following issue:

https://github.com/netbox-community/netbox/issues/10258

Change the `Script` class name or the variable name of any `ScriptVariable`.

### Expected Behavior

The new code should be dynamically reloaded.

### Observed Behavior

Code is not reloaded.

We have had discussions about this at the last maintainers meeting. Dynamically loading code in python is not a pleasent experience, and as such there is a bunch of pitfalls. Currently we delete the module entry in `sys.modules` when loading the script list, this prevent caching.  This doesn't work for nested packages, which this issue seeks to address.

In addition to this, there's other issues that might be also be addressed:

* The way we currently load scripts is to load any module/package in the `SCRIPTS_ROOT` folder in a way that makes the module path be in the root path. Essentially this means that if we load a file called ipam.py in the scripts folder, it will clash with the already existing ipam module.
* `load_module` is to be deprecated in python 3.12, so we need to refactor the script/report loading code at some point soon regardless.

I'll be looking into solutions to these problems, but if anyone has experience with dynamically loading python code in a long running python process, please feel free to give input.

For reference, the loading code is here:

https://github.com/netbox-community/netbox/blob/3675ad2539400038af6797140ddca3679b8bca30/netbox/extras/scripts.py#L513-L543


Discussion:

Resolving patch:
diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py
--- a/netbox/extras/scripts.py
+++ b/netbox/extras/scripts.py
@@ -524,27 +524,39 @@ def get_scripts(use_names=False):
     defined name in place of the actual module name.
     """
     scripts = {}
-    # Iterate through all modules within the scripts path. These are the user-created files in which reports are
+
+    # Get all modules within the scripts path. These are the user-created files in which scripts are
     # defined.
-    for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
-        # Use a lock as removing and loading modules is not thread safe
-        with lock:
-            # Remove cached module to ensure consistency with filesystem
-            if module_name in sys.modules:
+    modules = list(pkgutil.iter_modules([settings.SCRIPTS_ROOT]))
+    modules_bases = set([name.split(".")[0] for _, name, _ in modules])
+
+    # Deleting from sys.modules needs to done behind a lock to prevent race conditions where a module is
+    # removed from sys.modules while another thread is importing
+    with lock:
+        for module_name in list(sys.modules.keys()):
+            # Everything sharing a base module path with a module in the script folder is removed.
+            # We also remove all modules with a base module called "scripts". This allows modifying imported
+            # non-script modules without having to reload the RQ worker.
+            module_base = module_name.split(".")[0]
+            if module_base == "scripts" or module_base in modules_bases:
                 del sys.modules[module_name]
 
-            module = importer.find_module(module_name).load_module(module_name)
+    for importer, module_name, _ in modules:
+        module = importer.find_module(module_name).load_module(module_name)
 
         if use_names and hasattr(module, 'name'):
             module_name = module.name
+
         module_scripts = {}
         script_order = getattr(module, "script_order", ())
         ordered_scripts = [cls for cls in script_order if is_script(cls)]
         unordered_scripts = [cls for _, cls in inspect.getmembers(module, is_script) if cls not in script_order]
+
         for cls in [*ordered_scripts, *unordered_scripts]:
             # For scripts in submodules use the full import path w/o the root module as the name
             script_name = cls.full_name.split(".", maxsplit=1)[1]
             module_scripts[script_name] = cls
+
         if module_scripts:
             scripts[module_name] = module_scripts
 


Test patch: