Skip to content

Commit 9052853

Browse files
committed
Add initial remote debugging support and update related components
- Introduced remote debugging capabilities with debugpy. - Updated changelog to reflect new feature. - Added debugpy dependency in pyproject.toml and requirements-dev.txt. - Integrated debugpy initialization in business operation, process, and service classes. - Enhanced test cases to accommodate debugging features.
1 parent cd96557 commit 9052853

12 files changed

+233
-15
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111
- Add namespace selection in the `iop` command
1212
- `-n` or `--namespace` option to select a namespace
13+
- Initial remote debugging support
1314

1415
### Changed
1516
- Change how `iris` module is loaded

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ dependencies = [
3131
"xmltodict>=0.12.0",
3232
"iris-embedded-python-wrapper>=0.0.6",
3333
"setuptools>=40.8.0",
34-
"jsonpath-ng>=1.7.0"
34+
"jsonpath-ng>=1.7.0",
35+
"debugpy>=1.8.0",
3536
]
3637

3738
license = { file = "LICENSE" }

requirements-dev.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ iris-embedded-python-wrapper
99
jsonpath-ng
1010
pydantic>=2.0.0
1111
mkdocs
12-
pymdown-extensions
12+
pymdown-extensions
13+
debugpy

src/iop/_business_operation.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def _set_iris_handles(self, handle_current: Any, handle_partner: Any) -> None:
4747

4848
def _dispatch_on_init(self, host_object: Any) -> None:
4949
"""For internal use only."""
50+
self._debugpy(host_object=host_object)
5051
create_dispatch(self)
5152
self.on_init()
5253
return

src/iop/_business_process.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ def _dispatch_on_connected(self, host_object: Any) -> None:
141141
def _dispatch_on_init(self, host_object: Any) -> None:
142142
"""For internal use only."""
143143
self._restore_persistent_properties(host_object)
144+
self._debugpy(host_object=host_object)
144145
create_dispatch(self)
145146
self.on_init()
146147
self._save_persistent_properties(host_object)

src/iop/_business_service.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ class _BusinessService(_BusinessHost):
1616
Adapter = adapter = None
1717
_wait_for_next_call_interval = False
1818

19+
def _dispatch_on_init(self, host_object) -> None:
20+
"""For internal use only."""
21+
22+
self._debugpy(host_object=host_object)
23+
24+
self.on_init()
25+
26+
return
27+
1928
def on_process_input(self, message_input):
2029
""" Receives the message from the inbond adapter via the PRocessInput method and is responsible for forwarding it to target business processes or operations.
2130
If the business service does not specify an adapter, then the default adapter calls this method with no message

src/iop/_common.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import abc
22
import inspect
33
import traceback
4+
45
from typing import Any, ClassVar, List, Optional, Tuple
56

67
from . import _iris
@@ -57,8 +58,79 @@ def _dispatch_on_connected(self, host_object: Any) -> None:
5758
self.on_connected()
5859

5960
def _dispatch_on_init(self, host_object: Any) -> None:
61+
"""Initialize component when started."""
62+
self._debugpy(host_object)
6063
self.on_init()
6164

65+
def _debugpy(self, host_object: Any) -> None:
66+
"""Enable debugpy for debugging purposes."""
67+
# iris.cls('%SYS.Python').Debugging(1)
68+
from iop._debugpy import enable_debugpy, wait_for_debugpy_connected, find_free_port, is_debugpy_installed
69+
import sys, os
70+
71+
# hack to set __file__ for os module in debugpy
72+
# This is a workaround for the issue where debugpy cannot find the __file__ attribute of the os module.
73+
if not hasattr(os, '__file__'):
74+
setattr(os, '__file__', __file__)
75+
76+
if host_object is not None:
77+
port = host_object.port
78+
timeout = host_object.timeout
79+
enabled = host_object.enable
80+
python_interpreter_path = host_object.PythonInterpreterPath
81+
else:
82+
self.log_alert("No host object found, cannot enable debugpy.")
83+
return
84+
85+
if python_interpreter_path != "":
86+
# the user has set a specific python interpreter path, so we need to set the path to the python executable
87+
# to the one that is running this script
88+
if os.path.exists(python_interpreter_path):
89+
sys.executable = python_interpreter_path
90+
else:
91+
self.log_alert(f"Python path {python_interpreter_path} does not exist, cannot set python path for debugpy.")
92+
return
93+
elif sys.executable.find('irisdb') > 0:
94+
# the executable is the IRIS executable, so we need to set the path to the python executable
95+
# to the one that is running this script
96+
installdir = os.environ.get('IRISINSTALLDIR') or os.environ.get('ISC_PACKAGE_INSTALLDIR')
97+
if installdir is not None:
98+
if sys.platform == 'win32':
99+
python_path = os.path.join(installdir, 'bin', 'irispython.exe')
100+
else:
101+
python_path = os.path.join(installdir, 'bin', 'irispython')
102+
if os.path.exists(python_path):
103+
sys.executable = python_path
104+
else:
105+
self.log_alert(f"Python path {python_path} does not exist, cannot set python path for debugpy.")
106+
return
107+
else:
108+
self.log_alert("IRISINSTALLDIR or ISC_PACKAGE_INSTALLDIR not set, cannot set python path for debugpy.")
109+
return
110+
111+
if enabled:
112+
self.log_info(f"Debugpy is running in {sys.executable}.")
113+
if is_debugpy_installed():
114+
if port is None or port <= 0:
115+
port = find_free_port()
116+
self.log_info(f"Debugpy enabled.")
117+
try:
118+
enable_debugpy(port=port, address=None)
119+
except Exception as e:
120+
self.log_alert(f"Error enabling debugpy: {e}")
121+
return
122+
123+
self.trace(f"Waiting for {timeout} sec to debugpy connection on port {port}...")
124+
if wait_for_debugpy_connected(timeout=timeout, port=port):
125+
self.log_info("Debugpy connected.")
126+
else:
127+
self.log_alert(f"Debugpy connection timed out after {timeout} seconds.")
128+
else:
129+
self.log_alert("Debugpy is not installed.")
130+
else:
131+
self.log_info("Debugpy is not enabled.")
132+
133+
62134
def _dispatch_on_tear_down(self, host_object: Any) -> None:
63135
self.on_tear_down()
64136

src/iop/_debugpy.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
2+
import threading
3+
import time
4+
import contextlib
5+
import socket
6+
from contextlib import closing
7+
8+
from typing import Optional, Tuple, Union, Sequence, cast, Callable, TypeVar, Dict, Any
9+
10+
def find_free_port(start: Optional[int] = None, end: Optional[int] = None) -> int:
11+
port = start
12+
if port is None:
13+
port = 0
14+
if end is None:
15+
end = port
16+
17+
try:
18+
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
19+
with contextlib.suppress(Exception):
20+
s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
21+
22+
s.bind(("0.0.0.0", port))
23+
24+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
25+
return cast(int, s.getsockname()[1])
26+
except (SystemExit, KeyboardInterrupt):
27+
raise
28+
except BaseException:
29+
if port and end > port:
30+
return find_free_port(port + 1, end)
31+
if start and start > 0:
32+
return find_free_port(None)
33+
raise
34+
35+
36+
def is_debugpy_installed() -> bool:
37+
try:
38+
__import__("debugpy")
39+
except ImportError:
40+
return False
41+
return True
42+
43+
44+
def wait_for_debugpy_connected(timeout: float = 30,port=0) -> bool:
45+
import debugpy # noqa: T100
46+
47+
if not is_debugpy_installed():
48+
return False
49+
50+
class T(threading.Thread):
51+
daemon = True
52+
def run(self):
53+
time.sleep(timeout)
54+
debugpy.wait_for_client.cancel()
55+
T().start()
56+
debugpy.wait_for_client()
57+
if debugpy.is_client_connected():
58+
return True
59+
60+
return False
61+
62+
def enable_debugpy(port: int, address = None) -> bool:
63+
64+
import debugpy # noqa: T100
65+
66+
if address is None:
67+
address = "0.0.0.0"
68+
69+
debugpy.listen((address, port))

src/iop/cls/IOP/Utils.cls

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ ClassMethod DeleteComponentProxy(pClassname As %String = "") As %Status
6161
{
6262
#dim tSC As %Status = $$$OK
6363
#dim ex As %Exception.AbstractException
64-
#dim tIsPEX As %Boolean = 0
64+
#dim tIsIOP As %Boolean = 0
6565
#dim tClass As %Dictionary.CompiledClass
6666

6767
Quit:(""=pClassname) $$$ERROR($$$EnsErrGeneral,"Remote class name must be specified.")
@@ -79,16 +79,16 @@ ClassMethod DeleteComponentProxy(pClassname As %String = "") As %Status
7979
Set tSC = $$$ERROR($$$EnsErrGeneral,$$$FormatText("Proxy class for remote class '%1' could not be opened.",pClassname))
8080
Quit
8181
}
82-
Set tIsPEX = ("IOP.Utils" = tClass.GeneratedBy)
82+
Set tIsIOP = ("IOP.Utils" = tClass.GeneratedBy)
8383
}
84-
If tIsPEX {
84+
If tIsIOP {
8585
Set tSC = ##class(%Dictionary.ClassDefinition).%DeleteId(pClassname)
8686
If $$$ISERR(tSC) {
8787
Set tSC = $$$ERROR($$$EnsErrGeneral,$$$FormatText("Unable to delete proxy class for remote class '%1' : '%2'.",pClassname,$System.Status.GetErrorText(tSC)))
8888
Quit
8989
}
9090
} Else {
91-
Set tSC = $$$ERROR($$$EnsErrGeneral,$$$FormatText("Cannot delete class '%1' because it is not a PEX proxy class.",pClassname))
91+
Set tSC = $$$ERROR($$$EnsErrGeneral,$$$FormatText("Cannot delete class '%1' because it is not a IOP proxy class.",pClassname))
9292
Quit
9393
}
9494

@@ -179,23 +179,23 @@ ClassMethod GenerateProxyClass(
179179
Set tSC = $$$ERROR($$$EnsErrGeneral,$$$FormatText("Proxy class '%1' already exists.",pClassname))
180180
Quit
181181
} Else {
182-
#dim tIsPEX As %Boolean = 0
182+
#dim tIsIOP As %Boolean = 0
183183
If $classmethod(pClassname,"%Extends","IOP.Common") {
184184
#dim tClass As %Dictionary.CompiledClass = ##class(%Dictionary.CompiledClass).%OpenId(pClassname)
185185
If '$IsObject(tClass) {
186186
Set tSC = $$$ERROR($$$EnsErrGeneral,"Class not found")
187187
Quit
188188
}
189-
Set tIsPEX = ("IOP.Utils" = tClass.GeneratedBy)
189+
Set tIsIOP = ("IOP.Utils" = tClass.GeneratedBy)
190190
}
191-
If tIsPEX {
191+
If tIsIOP {
192192
Set tSC = ##class(%Dictionary.ClassDefinition).%DeleteId(pClassname)
193193
If $$$ISERR(tSC) {
194194
Set tSC = $$$ERROR($$$EnsErrGeneral,$$$FormatText("Unable to delete existing proxy class '%1' : '%2'.",pClassname,$System.Status.GetErrorText(tSC)))
195195
Quit
196196
}
197197
} Else {
198-
Set tSC = $$$ERROR($$$EnsErrGeneral,$$$FormatText("Cannot overwrite class '%1' because it is not a PEX proxy class.",pClassname))
198+
Set tSC = $$$ERROR($$$EnsErrGeneral,$$$FormatText("Cannot overwrite class '%1' because it is not a IOP proxy class.",pClassname))
199199
Quit
200200
}
201201
}
@@ -208,7 +208,7 @@ ClassMethod GenerateProxyClass(
208208

209209
#dim tSuperClass As %String = pClassDetails."__getitem__"(0)
210210
If (""=tSuperClass) {
211-
Set tSC = $$$ERROR($$$EnsErrGeneral,"No PEX superclass found.")
211+
Set tSC = $$$ERROR($$$EnsErrGeneral,"No IOP superclass found.")
212212
Quit
213213
}
214214
If '$Case($P(tSuperClass,".",*),"DuplexProcess":1,"DuplexService":1,"DuplexOperation":1,"InboundAdapter":1,"OutboundAdapter":1,"BusinessService":1,"BusinessProcess":1,"BusinessOperation":1,:0) {
@@ -323,6 +323,56 @@ ClassMethod GenerateProxyClass(
323323
}
324324
Quit:$$$ISERR(tSC)
325325

326+
#; Add debug settings, three settings are always available debug a boolean, port an integer and a timeout an integer
327+
Set tPropCat = "Python Debug"
328+
#; Debug property
329+
Set tPropDebug = ##class(%Dictionary.PropertyDefinition).%New()
330+
Set tPropDebug.Name = "enable"
331+
Set tPropDebug.Type = "%Boolean"
332+
Set tPropDebug.InitialExpression = $$$quote(0)
333+
Set tPropDebug.Description = "Enable or disable debug"
334+
335+
Set tSC = tCOSClass.Properties.Insert(tPropDebug)
336+
Quit:$$$ISERR(tSC)
337+
338+
#; Add the settings parameter
339+
Set tSETTINGSParamValue = tSETTINGSParamValue_",enable:"_tPropCat
340+
341+
#; Port property
342+
Set tPropPort = ##class(%Dictionary.PropertyDefinition).%New()
343+
Set tPropPort.Name = "port"
344+
Set tPropPort.Type = "%Integer"
345+
Set tPropPort.InitialExpression = $$$quote(0)
346+
Set tPropPort.Description = "Port to use for the debug connection, if 0 an random port will be used"
347+
Set tSC = tCOSClass.Properties.Insert(tPropPort)
348+
Quit:$$$ISERR(tSC)
349+
350+
#; Add the settings parameter
351+
Set tSETTINGSParamValue = tSETTINGSParamValue_",port:"_tPropCat
352+
353+
#; Timeout property
354+
Set tPropTimeout = ##class(%Dictionary.PropertyDefinition).%New()
355+
Set tPropTimeout.Name = "timeout"
356+
Set tPropTimeout.Type = "%Integer"
357+
Set tPropTimeout.InitialExpression = $$$quote(30)
358+
Set tSC = tCOSClass.Properties.Insert(tPropTimeout)
359+
Quit:$$$ISERR(tSC)
360+
361+
#; Add the settings parameter
362+
Set tSETTINGSParamValue = tSETTINGSParamValue_",timeout:"_tPropCat
363+
364+
#; PythonInterpreterPath property
365+
Set tPropInterpre = ##class(%Dictionary.PropertyDefinition).%New()
366+
Set tPropInterpre.Name = "PythonInterpreterPath"
367+
Set tPropInterpre.Type = "%String"
368+
Set tPropInterpre.Description = "Path to the Python interpreter, if not set the default one will be used"
369+
Do tPropInterpre.Parameters.SetAt("255","MAXLEN")
370+
Set tSC = tCOSClass.Properties.Insert(tPropInterpre)
371+
Quit:$$$ISERR(tSC)
372+
373+
#; Add the settings parameter
374+
Set tSETTINGSParamValue = tSETTINGSParamValue_",PythonInterpreterPath:"_tPropCat
375+
326376
#dim tSETTINGSParam As %Dictionary.ParameterDefinition = ##class(%Dictionary.ParameterDefinition).%New()
327377
Set tSETTINGSParam.Name = "SETTINGS"
328378
Set tSETTINGSParam.Default = tSETTINGSParamValue

src/tests/bench/bench_bo.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from iop import BusinessOperation
22

33
class BenchIoPOperation(BusinessOperation):
4+
5+
def on_init(self):
6+
self.log_info("BenchIoPOperation initialized")
7+
48
def on_message(self, request):
59

610
return request
7-
8-

src/tests/test_business_operation.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ def test_dispatch_methods(operation):
4444
# Test dispatch initialization
4545
operation.DISPATCH = [("MessageType1", "handle_type1")]
4646
mock_host = MagicMock()
47+
mock_host.port=0
48+
mock_host.enable=False
4749

4850
operation._dispatch_on_init(mock_host)
4951

@@ -63,7 +65,10 @@ def handle_simple(self, request: SimpleMessage):
6365
request.json = '{"integer": 1, "string": "test"}'
6466
request.classname = 'registerFilesIop.message.SimpleMessage'
6567
operation = CustomOperation()
66-
operation._dispatch_on_init(MagicMock())
68+
mock_host = MagicMock()
69+
mock_host.port=0
70+
mock_host.enable=False
71+
operation._dispatch_on_init(mock_host)
6772
response = operation._dispatch_on_message(request)
6873
excepted_response = dispatch_serializer(SimpleMessage(integer=2, string='handled'))
6974

@@ -75,7 +80,10 @@ def handle_simple(self, request: SimpleMessage):
7580
return SimpleMessage(integer=request.integer + 1, string="handled")
7681

7782
operation = CustomOperation()
78-
operation._dispatch_on_init(MagicMock())
83+
mock_host = MagicMock()
84+
mock_host.port=0
85+
mock_host.enable=False
86+
operation._dispatch_on_init(mock_host)
7987
operation.iris_handle = MagicMock()
8088

8189
request = SimpleMessage(integer=1, string='test')

src/tests/test_business_process.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ def __init__(self):
6868

6969
def test_dispatch_methods(process):
7070
mock_host = MagicMock()
71+
mock_host.port=0
72+
mock_host.enable=False
73+
7174
request = SimpleMessage(integer=1, string='test')
7275
response = SimpleMessage(integer=2, string='response')
7376

0 commit comments

Comments
 (0)