Skip to content

Commit 6ac0643

Browse files
authored
Merge pull request #1428 from compas-dev/code-reloader
Added `DevTools` with support for automatic reloading of local python modules
2 parents fedf86e + 7a1cad1 commit 6ac0643

File tree

4 files changed

+190
-29
lines changed

4 files changed

+190
-29
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
* Added `DevTools` with support for automatic reloading of local python modules.
1213
* Added implementation for `compas_rhino.geometry.RhinoBrep.from_step`.
1314

1415
### Changed
1516

17+
* Moved `unload_modules` to be a static method of `DevTools`. The `unload_modules` function is an alias to this.
1618
* Fixed unexpected behavior in `compas.geometry.bbox_numpy.minimum_area_rectangle_xy`.
1719
* Changed `requirements.txt` to allow `numpy>=2`.
1820
* Fixed bug in `compas.geometry.Polygon.points` setter by removing duplicate points if they exist.

docs/userguide/cad.grasshopper.rst

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,14 @@ Reloading changed libraries
4949
If you change a Python library during a running Rhino application, which is
5050
imported in a GhPython component (e.g. via ``import compas_fab``),
5151
it is necessary to reload the library so that the GhPython interpreter
52-
recognizes the changes. To avoid restarting Rhino, you can use the function
53-
``unload_modules``. The following example reloads the library ``compas_fab``.
52+
recognizes the changes. To avoid restarting Rhino, you can use the method
53+
``unload_modules`` of ``DevTools``. The following example reloads the library ``compas_fab``.
5454

5555
.. code-block:: python
5656
57-
from compas_ghpython import unload_modules
57+
from compas_rhino import DevTools
5858
59-
unload_modules('compas_fab')
59+
DevTools.unload_modules('compas_fab')
6060
6161
.. note::
6262

@@ -65,3 +65,50 @@ recognizes the changes. To avoid restarting Rhino, you can use the function
6565
in COMPAS not being able to find a `SceneObject` as well as other issues
6666
related to a mid-workflow re-definition of Python types.
6767

68+
69+
Python Scripting Outside Rhino/Grasshopper with Auto-Reloading
70+
==============================================================
71+
72+
Developing Python scripts outside of Rhino/Grasshopper allows you to take advantage of
73+
modern code editors. However, this workflow requires two key steps: ensuring the Python
74+
interpreter can access your script's location and enabling automatic reloading of the
75+
script when changes are made in your external editor.
76+
If the scripts or modules you are working on are located in the same folder as the Rhino/Grasshopper file you are editing, the ``DevTools`` class can be used to make them importable and reload them automatically when modified.
77+
78+
This approach provides a seamless workflow for developing Python scripts in modern IDEs,
79+
such as Visual Studio Code, while running and testing the code inside Rhino/Grasshopper
80+
with minimal interruptions.
81+
82+
Enabling Auto-Reloading
83+
-----------------------
84+
85+
To enable this feature, use the ``enable_reloader`` method of the ``DevTools`` class.
86+
This makes all Python scripts in the same folder as the Grasshopper file importable
87+
and ensures they automatically reload when changes are applied.
88+
89+
.. code-block:: python
90+
91+
from compas_rhino import DevTools
92+
DevTools.enable_reloader()
93+
94+
.. note::
95+
96+
Call this method early in your script to start the monitoring service immediately.
97+
98+
Importing Local Modules
99+
-----------------------
100+
101+
Once auto-reloading is enabled, any script component that needs to use local modules can include the following at the top of the script:
102+
103+
.. code-block:: python
104+
105+
from compas_rhino import DevTools
106+
DevTools.ensure_path()
107+
108+
This ensures local modules are accessible. For instance, if a file named ``my_module.py`` is in
109+
the same folder as your Grasshopper file, you can import it in a script component like this:
110+
111+
.. code-block:: python
112+
113+
import my_module
114+

src/compas_rhino/__init__.py

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
import io
44
import os
5-
import sys
65

76
import compas
87
import compas._os
8+
from compas_rhino.devtools import DevTools
99

1010
__version__ = "2.8.1"
1111

@@ -23,6 +23,7 @@
2323
GRASSHOPPER_PLUGIN_GUID = "b45a29b1-4343-4035-989e-044e8580d9cf"
2424
RHINOCYCLES_PLUGIN_GUID = "9bc28e9e-7a6c-4b8f-a0c6-3d05e02d1b97"
2525

26+
unload_modules = DevTools.unload_modules
2627

2728
__all__ = [
2829
"PURGE_ON_DELETE",
@@ -35,6 +36,7 @@
3536
"RHINOCYCLES_PLUGIN_GUID",
3637
"clear",
3738
"redraw",
39+
"unload_modules",
3840
]
3941

4042
__all_plugins__ = [
@@ -59,30 +61,6 @@
5961
# =============================================================================
6062

6163

62-
def unload_modules(top_level_module_name):
63-
"""Unloads all modules named starting with the specified string.
64-
65-
This function eases the development workflow when editing a library that is
66-
used from Rhino/Grasshopper.
67-
68-
Parameters
69-
----------
70-
top_level_module_name : :obj:`str`
71-
Name of the top-level module to unload.
72-
73-
Returns
74-
-------
75-
list
76-
List of unloaded module names.
77-
"""
78-
to_remove = [name for name in sys.modules if name.startswith(top_level_module_name)]
79-
80-
for module in to_remove:
81-
sys.modules.pop(module)
82-
83-
return to_remove
84-
85-
8664
def clear(guids=None):
8765
import compas_rhino.objects
8866

src/compas_rhino/devtools.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import os
2+
import sys
3+
4+
__all__ = [
5+
"DevTools",
6+
]
7+
8+
9+
class DevTools(object):
10+
"""Tools for working with Python code in development mode, unloading, and reloading code."""
11+
12+
def __init__(self):
13+
"""Initializes a new instance of the DevTools class."""
14+
self.watcher = None
15+
16+
@staticmethod
17+
def unload_modules(top_level_module_name):
18+
"""Unloads all modules named starting with the specified string.
19+
20+
This function eases the development workflow when editing a library that is
21+
used from Rhino/Grasshopper.
22+
23+
Parameters
24+
----------
25+
top_level_module_name : :obj:`str`
26+
Name of the top-level module to unload.
27+
28+
Returns
29+
-------
30+
list
31+
List of unloaded module names.
32+
"""
33+
to_remove = [name for name in sys.modules if name.startswith(top_level_module_name)]
34+
35+
for module in to_remove:
36+
sys.modules.pop(module)
37+
38+
return to_remove
39+
40+
@classmethod
41+
def enable_reloader(cls):
42+
"""Enables the code reload on the current folder.
43+
44+
The file must have been saved already in order for this to work."""
45+
cls._manage_reloader(enable=True)
46+
47+
@classmethod
48+
def disable_reloader(cls):
49+
"""Disables the code reload on the current folder.
50+
51+
The file must have been saved already in order for this to work."""
52+
cls._manage_reloader(enable=False)
53+
54+
@classmethod
55+
def _manage_reloader(cls, enable):
56+
import scriptcontext
57+
58+
doc_id = scriptcontext.doc.Component.OnPingDocument().DocumentID.ToString()
59+
key = "__compas_devtools_{}__".format(doc_id)
60+
if key in scriptcontext.sticky:
61+
reloader = scriptcontext.sticky[key]
62+
reloader.stop_watcher()
63+
reloader = None
64+
del reloader
65+
66+
if enable:
67+
reloader = DevTools()
68+
reloader.start_watcher()
69+
scriptcontext.sticky[key] = reloader
70+
71+
@staticmethod
72+
def ensure_path():
73+
"""Ensures the current folder is in the system path."""
74+
# Not sure why we need to import sys inside this method but GH complains otherwise
75+
import scriptcontext
76+
77+
# First ensure the current folder is in the system path
78+
filepath = scriptcontext.doc.Component.OnPingDocument().FilePath
79+
80+
if not filepath:
81+
raise Exception("It seems this file is not saved, cannot reload files without knowning where to search for them!")
82+
83+
dirname = os.path.dirname(filepath)
84+
if dirname not in sys.path:
85+
sys.path.append(dirname)
86+
87+
return dirname
88+
89+
def start_watcher(self):
90+
try:
91+
from System.IO import FileSystemWatcher # noqa : F401
92+
from System.IO import NotifyFilters # noqa : F401
93+
except ImportError:
94+
raise Exception("This component requires the System.IO.FileSystemWatcher class to work")
95+
96+
dirname = self.ensure_path()
97+
98+
# Disable previous watchers if any
99+
if self.watcher:
100+
self.disable()
101+
102+
# Setup file system watcher on python files
103+
self.watcher = FileSystemWatcher()
104+
self.watcher.Path = dirname
105+
self.watcher.NotifyFilter = NotifyFilters.LastWrite
106+
self.watcher.IncludeSubdirectories = False
107+
self.watcher.Filter = "*.py"
108+
self.watcher.Changed += self.on_changed
109+
self.watcher.EnableRaisingEvents = True
110+
111+
def on_changed(self, sender, args):
112+
try:
113+
# Get module name from full path
114+
filename = os.path.basename(args.FullPath)
115+
if filename.lower().endswith(".py"):
116+
module_name = filename.split(".")[0]
117+
118+
# Unload the modified module
119+
self.unload_modules(module_name)
120+
except Exception:
121+
print("Something failed during unload, this message will not be printed anywhere thou, deal with it")
122+
123+
def stop_watcher(self):
124+
try:
125+
if self.watcher:
126+
self.watcher.Changed
127+
self.watcher.Dispose()
128+
del self.watcher
129+
except: # noqa : E722
130+
pass
131+
self.watcher = None
132+
133+
134+
unload_modules = DevTools.unload_modules

0 commit comments

Comments
 (0)