Skip to content

Commit 1f8582a

Browse files
committed
Added DevTools with support for automatic reloading of local python modules.
1 parent fedf86e commit 1f8582a

File tree

4 files changed

+192
-30
lines changed

4 files changed

+192
-30
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 & 26 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__ = [
@@ -58,31 +60,6 @@
5860
# =============================================================================
5961
# =============================================================================
6062

61-
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-
8663
def clear(guids=None):
8764
import compas_rhino.objects
8865

src/compas_rhino/devtools.py

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

0 commit comments

Comments
 (0)