-
Notifications
You must be signed in to change notification settings - Fork 745
Expand file tree
/
Copy pathdebugging.py
More file actions
366 lines (306 loc) · 13 KB
/
debugging.py
File metadata and controls
366 lines (306 loc) · 13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
# Copyright 2018 The dm_control Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ============================================================================
"""Implements PyMJCF debug mode.
PyMJCF debug mode stores a stack trace each time the MJCF object is modified.
If Mujoco raises a compile error on the generated XML model, we would then be
able to find the original source line that created the offending element.
"""
import collections
import contextlib
import copy
import os
import re
import sys
import traceback
from absl import flags
from lxml import etree
FLAGS = flags.FLAGS
flags.DEFINE_boolean(
'pymjcf_debug', False,
'Enables PyMJCF debug mode (SLOW!). In this mode, a stack trace is logged '
'each the MJCF object is modified. This may be helpful in locating the '
'Python source line corresponding to a problematic element in the '
'generated XML.')
flags.DEFINE_string(
'pymjcf_debug_full_dump_dir', '',
'Path to dump full debug info when Mujoco error is encountered.')
StackTraceEntry = collections.namedtuple(
'StackTraceEntry', ('filename', 'line_number', 'function_name', 'text'))
ElementDebugInfo = collections.namedtuple(
'ElementDebugInfo', ('element', 'init_stack', 'attribute_stacks'))
MODULE_PATH = os.path.dirname(sys.modules[__name__].__file__)
DEBUG_METADATA_PREFIX = 'pymjcfdebug'
_DEBUG_METADATA_TAG_PREFIX = '<!--' + DEBUG_METADATA_PREFIX
_DEBUG_METADATA_SEARCH_PATTERN = re.compile(
r'<!--{}:(\d+)-->'.format(DEBUG_METADATA_PREFIX))
# Modified by `freeze_current_stack_trace`.
_CURRENT_FROZEN_STACK = None
# These globals will take their default values from the `--pymjcf_debug` and
# `--pymjcf_debug_full_dump_dir` flags respectively. We cannot use `FLAGS` as
# global variables because flag parsing might not have taken place (e.g. when
# running `nosetests`).
_DEBUG_MODE_ENABLED = None
_DEBUG_FULL_DUMP_DIR = None
def debug_mode():
"""Returns a boolean that indicates whether PyMJCF debug mode is enabled."""
global _DEBUG_MODE_ENABLED
if _DEBUG_MODE_ENABLED is None:
if FLAGS.is_parsed():
_DEBUG_MODE_ENABLED = FLAGS.pymjcf_debug
else:
_DEBUG_MODE_ENABLED = FLAGS['pymjcf_debug'].default
return _DEBUG_MODE_ENABLED
def enable_debug_mode():
"""Enables PyMJCF debug mode."""
global _DEBUG_MODE_ENABLED
_DEBUG_MODE_ENABLED = True
def disable_debug_mode():
"""Disables PyMJCF debug mode."""
global _DEBUG_MODE_ENABLED
_DEBUG_MODE_ENABLED = False
def get_full_dump_dir():
"""Gets the directory to dump full debug info files."""
global _DEBUG_FULL_DUMP_DIR
if _DEBUG_FULL_DUMP_DIR is None:
if FLAGS.is_parsed():
_DEBUG_FULL_DUMP_DIR = FLAGS.pymjcf_debug_full_dump_dir
else:
_DEBUG_FULL_DUMP_DIR = FLAGS['pymjcf_debug_full_dump_dir'].default
return _DEBUG_FULL_DUMP_DIR
def set_full_dump_dir(dump_path):
"""Sets the directory to dump full debug info files."""
global _DEBUG_FULL_DUMP_DIR
_DEBUG_FULL_DUMP_DIR = dump_path
def get_current_stack_trace():
"""Returns the stack trace of the current execution frame.
Returns:
A list of `StackTraceEntry` named tuples corresponding to the current stack
trace of the process, truncated to immediately before entry into
PyMJCF internal code.
"""
if _CURRENT_FROZEN_STACK:
return copy.deepcopy(_CURRENT_FROZEN_STACK)
else:
return _get_actual_current_stack_trace()
def _get_actual_current_stack_trace():
"""Returns the stack trace of the current execution frame.
Returns:
A list of `StackTraceEntry` named tuples corresponding to the current stack
trace of the process, truncated to immediately before entry into
PyMJCF internal code.
"""
raw_stack = traceback.extract_stack()
processed_stack = []
for raw_stack_item in raw_stack:
stack_item = StackTraceEntry(*raw_stack_item)
if (stack_item.filename.startswith(MODULE_PATH)
and not stack_item.filename.endswith('_test.py')):
break
else:
processed_stack.append(stack_item)
return processed_stack
@contextlib.contextmanager
def freeze_current_stack_trace():
"""A context manager that freezes the stack trace.
AVOID USING THIS CONTEXT MANAGER OUTSIDE OF INTERNAL PYMJCF IMPLEMENTATION,
AS IT REDUCES THE USEFULNESS OF DEBUG MODE.
If PyMJCF debug mode is enabled, calls to `debugging.get_current_stack_trace`
within this context will always return the stack trace from when this context
was entered.
The frozen stack is global to this debugging module. That is, if the context
is entered while another one is still active, then the stack trace of the
outermost one is returned.
This context significantly speeds up bulk operations in debug mode, e.g.
parsing an existing XML string or creating a deeply-nested element, as it
prevents the same stack trace from being repeatedly constructed.
Yields:
`None`
"""
global _CURRENT_FROZEN_STACK
if debug_mode() and _CURRENT_FROZEN_STACK is None:
_CURRENT_FROZEN_STACK = _get_actual_current_stack_trace()
yield
_CURRENT_FROZEN_STACK = None
else:
yield
class DebugContext:
"""A helper object to store debug information for a generated XML string.
This class is intended for internal use within the PyMJCF implementation.
"""
def __init__(self):
self._xml_string = None
self._debug_info_for_element_ids = {}
def register_element_for_debugging(self, elem):
"""Registers an `Element` and returns debugging metadata for the XML.
Args:
elem: An `mjcf.Element`.
Returns:
An `lxml.etree.Comment` that represents debugging metadata in the
generated XML.
"""
if not debug_mode():
return None
else:
self._debug_info_for_element_ids[id(elem)] = ElementDebugInfo(
elem,
copy.deepcopy(elem.get_init_stack()),
copy.deepcopy(elem.get_last_modified_stacks_for_all_attributes()))
return etree.Comment('{}:{}'.format(DEBUG_METADATA_PREFIX, id(elem)))
def commit_xml_string(self, xml_string):
"""Commits the XML string associated with this debug context.
This function also formats the XML string to make sure that the debugging
metadata appears on the same line as the corresponding XML element.
Args:
xml_string: A pretty-printed XML string.
Returns:
A reformatted XML string where all debugging metadata appears on the same
line as the corresponding XML element.
"""
formatted = re.sub(r'\n\s*' + _DEBUG_METADATA_TAG_PREFIX,
_DEBUG_METADATA_TAG_PREFIX, xml_string)
self._xml_string = formatted
return formatted
def process_and_raise_last_exception(self):
"""Processes and re-raises the last ValueError caught.
This function will insert the relevant line from the source XML to the error
message. If debug mode is enabled, additional debugging information is
appended to the error message. If debug mode is not enabled, the error
message instructs the user to enable it by rerunning the executable with an
appropriate flag.
"""
err_type, err, tb = sys.exc_info()
line_number_match = re.search(r'[Ll][Ii][Nn][Ee]\s*[:=]?\s*(\d+)', str(err))
if line_number_match:
xml_line_number = int(line_number_match.group(1))
xml_line = self._xml_string.split('\n')[xml_line_number - 1]
stripped_xml_line = xml_line.strip()
comment_match = re.search(_DEBUG_METADATA_TAG_PREFIX, stripped_xml_line)
if comment_match:
stripped_xml_line = stripped_xml_line[:comment_match.start()]
else:
xml_line = ''
stripped_xml_line = ''
message_lines = []
if debug_mode():
if get_full_dump_dir():
self.dump_full_debug_info_to_disk()
message_lines.extend([
'Compile error raised by Mujoco.',
str(err)])
if xml_line:
message_lines.extend([
stripped_xml_line,
self._generate_debug_message_from_xml_line(xml_line)])
else:
message_lines.extend([
'Compile error raised by Mujoco; ' +
'run again with --pymjcf_debug for additional debug information.',
str(err)
])
if xml_line:
message_lines.append(stripped_xml_line)
raise err_type('\n'.join(message_lines)).with_traceback(tb)
@property
def default_dump_dir(self):
return get_full_dump_dir()
@property
def debug_mode(self):
return debug_mode()
def dump_full_debug_info_to_disk(self, dump_dir=None):
"""Dumps full debug information to disk.
Full debug information consists of an XML file whose elements are tagged
with a unique ID, and a stack trace file for each element ID. Each stack
trace file consists of a stack trace for when the element was created, and
when each attribute was last modified.
Args:
dump_dir: Full path to the directory in which dump files are created.
Raises:
ValueError: If neither `dump_dir` nor the global dump path is given. The
global dump path can be specified either via the
--pymjcf_debug_full_dump_dir flag or via `debugging.set_full_dump_dir`.
"""
dump_dir = dump_dir or self.default_dump_dir
if not dump_dir:
raise ValueError('`dump_dir` is not specified')
section_separator = '\n' + ('=' * 80) + '\n'
def dump_stack(header, stack, f):
indent = ' '
f.write(header + '\n')
for stack_entry in stack:
f.write(indent + '`{}` at {}:{}\n'
.format(stack_entry.function_name,
stack_entry.filename, stack_entry.line_number))
f.write((indent * 2) + str(stack_entry.text) + '\n')
f.write(section_separator)
with open(os.path.join(dump_dir, 'model.xml'), 'w') as f:
f.write(self._xml_string)
for elem_id, debug_info in self._debug_info_for_element_ids.items():
with open(os.path.join(dump_dir, str(elem_id) + '.dump'), 'w') as f:
f.write('{}:{}\n'.format(DEBUG_METADATA_PREFIX, elem_id))
f.write(str(debug_info.element) + '\n')
dump_stack('Element creation', debug_info.init_stack, f)
for attrib_name, stack in debug_info.attribute_stacks.items():
attrib_value = (
debug_info.element.get_attribute_xml_string(attrib_name))
if stack[-1] == debug_info.init_stack[-1]:
if attrib_value is not None:
f.write('Attribute {}="{}"\n'.format(attrib_name, attrib_value))
f.write(' was set when the element was created\n')
f.write(section_separator)
else:
if attrib_value is not None:
dump_stack('Attribute {}="{}"'.format(attrib_name, attrib_value),
stack, f)
else:
dump_stack(
'Attribute {} was CLEARED'.format(attrib_name), stack, f)
def _generate_debug_message_from_xml_line(self, xml_line):
"""Generates a debug message by parsing the metadata on an XML line."""
metadata_match = _DEBUG_METADATA_SEARCH_PATTERN.search(xml_line)
if metadata_match:
elem_id = int(metadata_match.group(1))
return self._generate_debug_message_from_element_id(elem_id)
else:
return ''
def _generate_debug_message_from_element_id(self, elem_id):
"""Generates a debug message for the specified Element."""
out = []
debug_info = self._debug_info_for_element_ids[elem_id]
out.append('Debug summary for element:')
if not get_full_dump_dir():
out.append(
' * Full debug info can be dumped to disk by setting the '
'flag --pymjcf_debug_full_dump_dir=path/to/dump>')
out.append(' * Element object was created by `{}` at {}:{}'
.format(debug_info.init_stack[-1].function_name,
debug_info.init_stack[-1].filename,
debug_info.init_stack[-1].line_number))
for attrib_name, stack in debug_info.attribute_stacks.items():
attrib_value = debug_info.element.get_attribute_xml_string(attrib_name)
if stack[-1] == debug_info.init_stack[-1]:
if attrib_value is not None:
out.append(' * {}="{}" was set when the element was created'
.format(attrib_name, attrib_value))
else:
if attrib_value is not None:
out.append(' * {}="{}" was set by `{}` at `{}:{}`'
.format(attrib_name, attrib_value,
stack[-1].function_name, stack[-1].filename,
stack[-1].line_number))
else:
out.append(' * {} was CLEARED by `{}` at {}:{}'
.format(attrib_name, stack[-1].function_name,
stack[-1].filename, stack[-1].line_number))
return '\n'.join(out)