Skip to content

Commit 3407a12

Browse files
authored
fix: stomp-py 6.x callback arg-shape + DRY consolidation across gridappsd-python (#210)
Root cause: stomp-py 6.0.0 (the pinned/installed version) invokes raw stomp listener callbacks with two positional args (headers, body), but several goss.py call sites assumed the stomp-py 8.x single Frame object shape (frame.headers / frame.body). At argument unpack time this called .headers on the headers dict, raising AttributeError: 'dict' object has no attribute 'headers'. This broke the simulation integration tests, surfacing downstream as a 30 second query_model_info TimeoutError, and was masked in CI by continue-on-error on the sim step. Fix: a single _unpack_stomp_args() helper now handles both the stomp 6 two-arg shape and the stomp 8 Frame shape, applied at all 6 former call sites in goss.py. A companion _serialize_message() helper centralizes dict/list to JSON serialization. Additional DRY consolidations included in this PR: - simulation.py: _send_simulation_command() consolidates the pause, stop, and resume command methods into one helper. - gridappsd.py: _set_status() consolidates the application and service status setter methods. - timeseries.py: a single query filter helper replaces duplicated filter methods. - app_registration.py: ApplicationStatusEnum replaces duplicated environment writes for GRIDAPPSD_APPLICATION_STATUS. Test results: 51 pytest tests passed, ruff clean. Two pre-existing mypy errors remain in simulation.py (SimulationArgs.publish_period and interval typed int but defaulted None); these are unrelated to this change and are tracked in #208. Closes #207 Closes #208. Related follow-up: #209 tracks sharing the _unpack_stomp_args helper with gridappsd-field-bus-lib, which has its own duplicate of the same stomp arg-shape branch. That work is out of scope for this PR. Related: #208 (pre-existing mypy type issue in simulation.py, not fixed by this PR).
2 parents 14861fc + 3416912 commit 3407a12

10 files changed

Lines changed: 562 additions & 323 deletions

File tree

gridappsd-python-lib/gridappsd/app_registration.py

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# import json
22
import logging
33
import os
4+
from enum import Enum
45
from queue import Queue
56
import time
67
import subprocess
@@ -14,6 +15,30 @@
1415

1516
_log = logging.getLogger(__name__)
1617

18+
GRIDAPPSD_APPLICATION_STATUS = "GRIDAPPSD_APPLICATION_STATUS"
19+
20+
21+
class ApplicationStatusEnum(Enum):
22+
"""Values this module writes to the GRIDAPPSD_APPLICATION_STATUS environment variable.
23+
24+
This is a distinct enum from gridappsd.utils.ProcessStatusEnum: this module's
25+
STOPPED value has no equivalent in ProcessStatusEnum (which has CLOSED
26+
instead), so reusing that enum here would either drop STOPPED or introduce
27+
a mismatch between the value written and the value a reader expects.
28+
"""
29+
30+
STARTING = "STARTING"
31+
STOPPING = "STOPPING"
32+
RUNNING = "RUNNING"
33+
STOPPED = "STOPPED"
34+
ERROR = "ERROR"
35+
36+
37+
def _set_application_status(status: ApplicationStatusEnum) -> None:
38+
"""Write status to the GRIDAPPSD_APPLICATION_STATUS environment variable."""
39+
os.environ[GRIDAPPSD_APPLICATION_STATUS] = status.value
40+
41+
1742
# determine OS type
1843
posix = False
1944
if os.name == "posix":
@@ -35,20 +60,20 @@ def shutdown(self):
3560
def run(self):
3661
try:
3762
self.running = True
38-
os.environ["GRIDAPPSD_APPLICATION_STATUS"] = "RUNNING"
63+
_set_application_status(ApplicationStatusEnum.RUNNING)
3964

4065
p = subprocess.Popen(args=self._args, shell=False, stdout=self._out, stderr=self._err)
4166

4267
# Loop while process is executing
4368
while p.poll() is None and self.running:
44-
os.environ["GRIDAPPSD_APPLICATION_STATUS"] = "RUNNING"
69+
_set_application_status(ApplicationStatusEnum.RUNNING)
4570
time.sleep(1)
4671

4772
except Exception as e:
48-
os.environ["GRIDAPPSD_APPLICATION_STATUS"] = "ERROR"
73+
_set_application_status(ApplicationStatusEnum.ERROR)
4974
_log.error(repr(e))
5075
else:
51-
os.environ["GRIDAPPSD_APPLICATION_STATUS"] = "STOPPED"
76+
_set_application_status(ApplicationStatusEnum.STOPPED)
5277

5378

5479
class ApplicationController(object):
@@ -58,7 +83,7 @@ def __init__(self, config, gridappsd=None, heatbeat_period=10):
5883
if not isinstance(gridappsd, GridAPPSD):
5984
raise ValueError("Invalid gridappsd instance passed.")
6085

61-
os.environ["GRIDAPPSD_APPLICATION_STATUS"] = "STOPPED"
86+
_set_application_status(ApplicationStatusEnum.STOPPED)
6287
self._configDict = config.copy()
6388
self._validate_config()
6489
self._gapd = gridappsd
@@ -80,7 +105,7 @@ def __init__(self, config, gridappsd=None, heatbeat_period=10):
80105
self._end_callback = None
81106
self._print_queue = Queue()
82107
self._heartbeat_thread = None
83-
os.environ["GRIDAPPSD_APPLICATION_STATUS"] = "STOPPED"
108+
_set_application_status(ApplicationStatusEnum.STOPPED)
84109

85110
if "type" not in self._configDict or self._configDict["type"] != "REMOTE":
86111
_log.warning(
@@ -119,7 +144,7 @@ def register_app(self, end_callback):
119144
self._stop_control_topic = response.get("stopControlTopic")
120145

121146
os.environ["GRIDAPPSD_APPLICATION_ID"] = self._application_id
122-
os.environ["GRIDAPPSD_APPLICATION_STATUS"] = "STOPPED"
147+
_set_application_status(ApplicationStatusEnum.STOPPED)
123148

124149
self._gapd.subscribe(self._stop_control_topic, self.__handle_stop)
125150
self._gapd.subscribe(self._start_control_topic, self.__handle_start)
@@ -160,7 +185,7 @@ def __handle_start(self, headers, message):
160185
obj = json.loads(message)
161186
else:
162187
obj = message
163-
os.environ["GRIDAPPSD_APPLICATION_STATUS"] = "STARTING"
188+
_set_application_status(ApplicationStatusEnum.STARTING)
164189
self._gapd.get_logger().debug("Handling Start: {}\ndict:\n{}".format(headers, obj))
165190

166191
if "command" not in obj:
@@ -176,12 +201,12 @@ def __handle_start(self, headers, message):
176201

177202
def __handle_stop(self, headers, message):
178203
print("Handling Stop: {} {}".format(headers, message))
179-
os.environ["GRIDAPPSD_APPLICATION_STATUS"] = "STOPPING"
204+
_set_application_status(ApplicationStatusEnum.STOPPING)
180205
if self._thread:
181206
self._thread.join()
182207
if self._end_callback is not None:
183208
self._end_callback()
184-
os.environ["GRIDAPPSD_APPLICATION_STATUS"] = "STOPPED"
209+
_set_application_status(ApplicationStatusEnum.STOPPED)
185210

186211
def shutdown(self):
187212
self._shutting_down = True

gridappsd-python-lib/gridappsd/goss.py

Lines changed: 47 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@
5757
from logging import Logger
5858
from queue import Queue
5959

60-
import stomp as _stomp_module
6160
from stomp import Connection12 as Connection
6261
from stomp.exception import NotConnectedException
6362
from time import sleep
@@ -66,19 +65,41 @@
6665

6766
_log: Logger = logging.getLogger(inspect.getmodulename(__file__))
6867

69-
# stomp.py 8.x changed listener callbacks from (headers, body) to (frame)
70-
_stomp_major = (
71-
int(getattr(_stomp_module, "__version__", (0,))[0])
72-
if isinstance(getattr(_stomp_module, "__version__", None), tuple)
73-
else 0
74-
)
75-
try:
76-
from importlib.metadata import version as _pkg_version
77-
78-
_stomp_major = int(_pkg_version("stomp-py").split(".")[0])
79-
except Exception:
80-
pass
81-
_STOMP_V8 = _stomp_major >= 8
68+
69+
def _unpack_stomp_args(*args):
70+
"""Return (headers, body) from a stomp listener callback's arguments.
71+
72+
stomp-py pre 8.x, and this module's own CallbackRouter dispatch, call
73+
on_message/on_error with two positional arguments: headers and body.
74+
stomp-py 8.x's raw listener protocol calls with a single Frame object
75+
exposing .headers and .body instead. A listener reached through
76+
CallbackRouter (any listener registered via subscribe()) always
77+
receives the two argument shape, regardless of the installed stomp-py
78+
version, because CallbackRouter.run_callbacks is the caller, not
79+
stomp itself. Only a listener registered directly on a raw stomp
80+
Connection (CallbackRouter itself, TokenResponseListener) is actually
81+
invoked by stomp's own version dependent dispatch. Detecting the
82+
argument shape at each call, instead of trusting a single global
83+
version sniffed flag, is correct for both callers.
84+
"""
85+
if len(args) >= 2:
86+
return args[0], args[1]
87+
frame = args[0]
88+
if hasattr(frame, "headers") and hasattr(frame, "body"):
89+
return frame.headers, frame.body
90+
headers, body = frame
91+
return headers, body
92+
93+
94+
def _serialize_message(message):
95+
"""Return message ready to send on the wire.
96+
97+
A list or dict body is serialized to a JSON string; any other body
98+
(already a string, bytes, etc.) is passed through unchanged.
99+
"""
100+
if isinstance(message, (list, dict)):
101+
return json.dumps(message)
102+
return message
82103

83104

84105
class GRIDAPPSD_ENV_ENUM(Enum):
@@ -168,8 +189,7 @@ def override_threading(self, callback):
168189

169190
def send(self, topic, message):
170191
self._make_connection()
171-
if isinstance(message, list) or isinstance(message, dict):
172-
message = json.dumps(message)
192+
message = _serialize_message(message)
173193
_log.debug("Sending topic: {} body: {}".format(topic, message))
174194
self._conn.send(
175195
body=message, destination=topic, headers={"GOSS_HAS_SUBJECT": True, "GOSS_SUBJECT": self.__token}
@@ -185,11 +205,8 @@ def get_response(self, topic, message, timeout=5):
185205
if "resultFormat" in message:
186206
self.result_format = message["resultFormat"]
187207

188-
# Change message to string if we have a dictionary.
189-
if isinstance(message, dict):
190-
message = json.dumps(message)
191-
elif isinstance(message, list):
192-
message = json.dumps(message)
208+
# Change message to string if we have a dictionary or list.
209+
message = _serialize_message(message)
193210

194211
class ResponseListener(object):
195212
def __init__(self, topic, result_format):
@@ -198,11 +215,7 @@ def __init__(self, topic, result_format):
198215
self.result_format = result_format
199216

200217
def on_message(self, *args):
201-
if _STOMP_V8:
202-
frame = args[0]
203-
header, message = frame.headers, frame.body
204-
else:
205-
header, message = args[0], args[1]
218+
header, message = _unpack_stomp_args(*args)
206219
_log.debug("Internal on message is: {} {}".format(header, message))
207220
try:
208221
if self.result_format == "JSON":
@@ -216,11 +229,7 @@ def on_message(self, *args):
216229
self.response = dict(error="Invalid json returned", header=header, message=message)
217230

218231
def on_error(self, *args):
219-
if _STOMP_V8:
220-
frame = args[0]
221-
headers, message = frame.headers, frame.body
222-
else:
223-
headers, message = args[0], args[1]
232+
headers, message = _unpack_stomp_args(*args)
224233
_log.error("ERR: {}".format(headers))
225234
_log.error("OUR ERROR: {}".format(message))
226235

@@ -337,21 +346,13 @@ def get_token(self):
337346
return self.__token
338347

339348
def on_message(self, *args):
340-
if _STOMP_V8:
341-
frame = args[0]
342-
header, message = frame.headers, frame.body
343-
else:
344-
header, message = args[0], args[1]
349+
header, message = _unpack_stomp_args(*args)
345350
_log.debug("Internal on message is: {} {}".format(header, message))
346351

347352
self.__token = str(message)
348353

349354
def on_error(self, *args):
350-
if _STOMP_V8:
351-
frame = args[0]
352-
headers, message = frame.headers, frame.body
353-
else:
354-
headers, message = args[0], args[1]
355+
headers, message = _unpack_stomp_args(*args)
355356
_log.error("ERR: {}".format(headers))
356357
_log.error("OUR ERROR: {}".format(message))
357358

@@ -404,9 +405,10 @@ def run_callbacks(self):
404405
cb, hdrs, msg = self._queue_callerback.get()
405406
try:
406407
msg = json.loads(msg)
407-
except:
408+
except (TypeError, ValueError):
409+
# msg was not JSON text (already a dict, or plain string body);
410+
# pass it through unchanged rather than as a decode failure.
408411
pass
409-
# msg = message
410412

411413
for c in cb:
412414
c(hdrs, msg)
@@ -429,11 +431,7 @@ def remove_callback(self, topic, callback):
429431
pass
430432

431433
def on_message(self, *args):
432-
if _STOMP_V8:
433-
frame = args[0]
434-
headers, message = frame.headers, frame.body
435-
else:
436-
headers, message = args[0], args[1]
434+
headers, message = _unpack_stomp_args(*args)
437435
destination = headers["destination"]
438436
# _log.debug("Topic map keys are: {keys}".format(keys=self._topics_callback_map.keys()))
439437
if destination in self._topics_callback_map:
@@ -442,11 +440,7 @@ def on_message(self, *args):
442440
_log.error("INVALID DESTINATION {destination}".format(destination=destination))
443441

444442
def on_error(self, *args):
445-
if _STOMP_V8:
446-
frame = args[0]
447-
header, message = frame.headers, frame.body
448-
else:
449-
header, message = args[0], args[1]
443+
header, message = _unpack_stomp_args(*args)
450444
_log.error("Error in callback router")
451445
_log.error(header)
452446
_log.error(message)

gridappsd-python-lib/gridappsd/gridappsd.py

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -116,29 +116,32 @@ def get_simulation_id(self):
116116
self._simulation_log_topic = t.simulation_log_topic(self._simulation_id)
117117
return self._simulation_id
118118

119-
def set_application_status(self, status):
120-
"""
121-
Set the application status.
122-
:param status:
119+
def _set_status(self, status, kind):
120+
"""Set self._process_status, warning instead of raising on an invalid value.
121+
122+
:param status: candidate value for ProcessStatusEnum
123+
:param kind: human readable label for the warning message, e.g. "application"
123124
"""
124125
try:
125126
self._process_status = ProcessStatusEnum(status)
126127
except ValueError:
127128
self.get_logger().warning(
128-
"Unsuccessful change of application status." + f"Valid statuses are {ProcessStatusEnum.__members__}."
129+
f"Unsuccessful change of {kind} status." + f"Valid statuses are {ProcessStatusEnum.__members__}."
129130
)
130131

132+
def set_application_status(self, status):
133+
"""
134+
Set the application status.
135+
:param status:
136+
"""
137+
self._set_status(status, "application")
138+
131139
def set_service_status(self, status):
132140
"""
133141
Set the service status.
134142
:param status:
135143
"""
136-
try:
137-
self._process_status = ProcessStatusEnum(status)
138-
except ValueError:
139-
self.get_logger().warning(
140-
"Unsuccessful change of service status." + f"Valid statuses are {ProcessStatusEnum.__members__}."
141-
)
144+
self._set_status(status, "service")
142145

143146
def set_simulation_id(self, simulation_id):
144147
if simulation_id is None:
@@ -148,12 +151,16 @@ def set_simulation_id(self, simulation_id):
148151
self._simulation_id = simulation_id
149152
self._simulation_log_topic = t.simulation_log_topic(self._simulation_id)
150153

154+
def _get_status(self):
155+
"""Return self._process_status as its plain string value."""
156+
return self._process_status.value
157+
151158
def get_application_status(self):
152159
"""
153160
Return the application status
154161
:return:
155162
"""
156-
return self._process_status.value
163+
return self._get_status()
157164

158165
def get_application_id(self):
159166
return utils.get_gridappsd_application_id()
@@ -163,7 +170,7 @@ def get_service_status(self):
163170
Return the service status
164171
:return:
165172
"""
166-
return self._process_status.value
173+
return self._get_status()
167174

168175
def query_object_types(self, model_id=None):
169176
"""Allows the caller to query the different object types.

0 commit comments

Comments
 (0)