blob: c859f711ca89c3298ef75f5c25fd82884eb65f81 [file] [log] [blame]
Srikrishna Iyer86169d02021-05-10 09:35:52 -07001# Copyright lowRISC contributors.
2# Licensed under the Apache License, Version 2.0, see LICENSE for details.
3# SPDX-License-Identifier: Apache-2.0
4r"""Testpoint and Testplan classes for maintaining the testplan
5"""
6
7import os
8import re
9import sys
10from collections import defaultdict
11
12import hjson
13import mistletoe
14from tabulate import tabulate
15
16
17class Result:
18 '''The results for a single test'''
19 def __init__(self, name, passing=0, total=0):
20 self.name = name
21 self.passing = passing
22 self.total = total
23 self.mapped = False
24
25
26class Element():
27 """An element of the testplan.
28
29 This is either a testpoint or a covergroup.
30 """
31 # Type of the testplan element. Must be set by the extended class.
32 kind = None
33
34 # Mandatory fields in a testplan element.
35 fields = ("name", "desc")
36
37 def __init__(self, raw_dict):
38 """Initialize the testplan element.
39
40 raw_dict is the dictionary parsed from the HJSon file.
41 """
42 for field in self.fields:
43 try:
44 setattr(self, field, raw_dict.pop(field))
45 except KeyError as e:
46 raise KeyError(f"Error: {self.kind} does not contain all of "
47 f"the required fields:\n{raw_dict}\nRequired:\n"
48 f"{self.fields}\n{e}")
49
50 # Set the remaining k-v pairs in raw_dict as instance attributes.
51 for k, v in raw_dict:
52 setattr(self, k, v)
53
54 # Verify things are in order.
55 self._validate()
56
57 def __str__(self):
58 # Reindent the multiline desc with 4 spaces.
59 desc = "\n".join(
60 [" " + line.lstrip() for line in self.desc.split("\n")])
61 return (f" {self.kind.capitalize()}: {self.name}\n"
62 f" Description:\n{desc}\n")
63
64 def _validate(self):
65 """Runs some basic consistency checks."""
66 if not self.name:
67 raise ValueError(f"Error: {self.kind.capitalize()} name cannot "
68 f"be empty:\n{self}")
69
70
71class Covergroup(Element):
72 """A coverage model item.
73
74 The list of covergroups defines the coverage model for the design. Each
75 entry captures the name of the covergroup (suffixed with _cg) and a brief
76 description describing what functionality is covered. It is recommended to
77 include individual coverpoints and crosses in the description.
78 """
79 kind = "covergroup"
80
81 def _validate(self):
82 super()._validate()
83 if not self.name.endswith("_cg"):
84 raise ValueError(f"Error: Covergroup name {self.name} needs to "
85 "end with suffix \"_cg\".")
86
87
88class Testpoint(Element):
89 """An testcase entry in the testplan.
90
91 A testpoint maps to a unique design feature that is planned to be verified.
92 It captures following information:
93 - name of the planned test
94 - a brief description indicating intent, stimulus and checking procedure
95 - the targeted milestone
96 - the list of actual developed tests that verify it
97 """
98 kind = "testpoint"
99 fields = Element.fields + ("milestone", "tests")
100
101 # Verification milestones.
102 milestones = ("N.A.", "V1", "V2", "V3")
103
104 def __init__(self, raw_dict):
105 super().__init__(raw_dict)
106
107 # List of Result objects indicating test results mapped to this
108 # testpoint.
109 self.test_results = []
110
111 def __str__(self):
112 return super().__str__() + (f" Milestone: {self.milestone}\n"
113 f" Tests: {self.tests}\n")
114
115 def _validate(self):
116 super()._validate()
117 if self.milestone not in Testpoint.milestones:
118 raise ValueError(f"Testpoint milestone {self.milestone} is "
119 f"invalid:\n{self}\nLegal values: "
120 f"Testpoint.milestones")
121
122 def do_substitutions(self, substitutions):
123 '''Substitute {wildcards} in tests
124
125 If tests have {wildcards}, they are substituted with the 'correct'
126 values using the key=value pairs provided by the substitutions arg.
127 Wildcards with no substitution arg are replaced by an empty string.
128
129 substitutions is a dictionary of wildcard-replacement pairs.
130 '''
131 resolved_tests = []
132 for test in self.tests:
133 match = re.findall(r"{([A-Za-z0-9\_]+)}", test)
134 if not match:
135 resolved_tests.append(test)
136 continue
137
138 # 'match' is a list of wildcards used in the test. Get their
139 # corresponding values.
140 subst = {item: substitutions.get(item, "") for item in match}
141
142 resolved = [test]
143 for item, value in subst.items():
144 values = value if isinstance(value, list) else [value]
145 resolved = [
146 t.replace(f"{{{item}}}", v) for t in resolved
147 for v in values
148 ]
149 resolved_tests.extend(resolved)
150
151 self.tests = resolved_tests
152
153 def map_test_results(self, test_results):
154 """Map test results to tests against this testpoint.
155
156 Given a list of test results find the ones that match the tests listed
157 in this testpoint and buiild a structure. If no match is found, or if
158 self.tests is an empty list, indicate 0/1 passing so that it is
159 factored into the final total.
160 """
161 for tr in test_results:
162 assert isinstance(tr, Result)
163 if tr.name in self.tests:
164 tr.mapped = True
165 self.test_results.append(tr)
166
167 # Did we map all tests in this testpoint? If we are mapping the full
168 # testplan, then count the ones not found as "not run", i.e. 0 / 0.
169 tests_mapped = [tr.name for tr in self.test_results]
170 for test in self.tests:
171 if test not in tests_mapped:
172 self.test_results.append(Result(name=test, passing=0, total=0))
173
174 # If no written tests were indicated for this testpoint, then reuse
175 # the testpoint name to count towards "not run".
176 if not self.tests:
177 self.test_results = [Result(name=self.name, passing=0, total=0)]
178
179
180class Testplan():
181 """The full testplan
182
183 The list of Testpoints and Covergroups make up the testplan.
184 """
185
186 rsvd_keywords = ["import_testplans", "testpoints", "covergroups"]
187 element_cls = {'testpoint': Testpoint, 'covergroup': Covergroup}
188
189 @staticmethod
190 def _parse_hjson(filename):
191 """Parses an input file with HJson and returns a dict."""
192 try:
193 return hjson.load(open(filename, 'rU'))
194 except IOError as e:
195 print(f"IO Error when opening fie {filename}\n{e}")
196 except hjson.scanner.HjsonDecodeError as e:
197 print(f"Error: Unable to decode HJSON with file {filename}:\n{e}")
198 sys.exit(1)
199
200 @staticmethod
201 def _create_testplan_elements(kind, raw_dicts_list):
202 """Creates testplan elements from the list of raw dicts.
203
204 kind is either 'testpoint' or 'covergroup'.
205 raw_dicts_list is a list of dictionaries extracted from the HJson file.
206 """
207 items = []
208 item_names = set()
209 for dict_entry in raw_dicts_list:
210 try:
211 item = Testplan.element_cls[kind](dict_entry)
212 except KeyError as e:
213 print(f"Error: {kind} arg is invalid.\n{e}")
214 sys.exit(1)
215 except ValueError as e:
216 print(e)
217 sys.exit(1)
218
219 if item.name in item_names:
220 print(f"Error: Duplicate {kind} item found with name: "
221 f"{item.name}")
222 sys.exit(1)
223 items.append(item)
224 item_names.add(item.name)
225 return items
226
227 @staticmethod
228 def _get_percentage(value, total):
229 """Returns a string representing percentage upto 2 decimal places."""
230 if total == 0:
231 return "-- %"
232 perc = value / total * 100 * 1.0
233 return "{0:.2f} %".format(round(perc, 2))
234
235 @staticmethod
236 def get_dv_style_css():
237 """Returns text with HTML CSS style for a table."""
238 return ("<style>\n"
239 "table.dv {\n"
240 " border: 1px solid black;\n"
241 " border-collapse: collapse;\n"
242 " width: 100%;\n"
243 " text-align: left;\n"
244 " vertical-align: middle;\n"
245 " display: table;\n"
246 " font-size: smaller;\n"
247 "}\n"
248 "table.dv th, td {\n"
249 " border: 1px solid black;\n"
250 "}\n"
251 "</style>\n")
252
253 def __str__(self):
254 lines = [f"Name: {self.name}\n"]
255 lines += ["Testpoints:"]
256 lines += [f"{t}" for t in self.testpoints]
257 lines += ["Covergroups:"]
258 lines += [f"{c}" for c in self.covergroups]
259 return "\n".join(lines)
260
261 def __init__(self, filename, repo_top=None, name=None):
262 """Initialize the testplan.
263
264 filename is the HJson file that captures the testplan.
265 repo_top is an optional argument indicating the path to top level repo
266 / project directory. It is used with filename arg.
267 name is an optional argument indicating the name of the testplan / DUT.
268 It overrides the name set in the testplan HJson.
269 """
270 self.name = None
271 self.testpoints = []
272 self.covergroups = []
273 self.test_results_mapped = False
274
275 if filename:
276 self._parse_testplan(filename, repo_top)
277
278 if name:
279 self.name = name
280
281 if not self.name:
282 print("Error: the testplan 'name' is not set!")
283 sys.exit(1)
284
285 # Represents current progress towards each milestone. Milestone = N.A.
286 # is used to indicate the unmapped tests.
287 self.progress = {}
288 for key in Testpoint.milestones:
289 self.progress[key] = {
290 "written": 0,
291 "total": 0,
292 "progress": 0.0,
293 }
294
295 def _parse_testplan(self, filename, repo_top=None):
296 '''Parse testplan Hjson file and create the testplan elements.
297
298 It creates the list of testpoints and covergroups extracted from the
299 file.
300
301 filename is the path to the testplan file written in HJson format.
302 repo_top is an optional argument indicating the path to repo top.
303 '''
304 if repo_top is None:
305 # Assume dvsim's original location: $REPO_TOP/util/dvsim.
306 self_path = os.path.dirname(os.path.realpath(__file__))
307 repo_top = os.path.abspath(
308 os.path.join(self_path, os.pardir, os.pardir))
309
310 obj = Testplan._parse_hjson(filename)
Srikrishna Iyer0f910ed2021-05-26 14:13:14 -0700311
312 parsed = set()
Srikrishna Iyer86169d02021-05-10 09:35:52 -0700313 imported_testplans = obj.get("import_testplans", [])
Srikrishna Iyer0f910ed2021-05-26 14:13:14 -0700314 while imported_testplans:
315 testplan = imported_testplans.pop(0)
316 if testplan in parsed:
317 print(f"Error: encountered the testplan {testplan} again, "
318 "which was already parsed. Please check for circular "
319 "dependencies.")
320 sys.exit(1)
321 parsed.add(testplan)
322 data = self._parse_hjson(os.path.join(repo_top, testplan))
323 imported_testplans.extend(data.get("import_testplans", []))
324 obj = _merge_dicts(obj, data)
Srikrishna Iyer86169d02021-05-10 09:35:52 -0700325
326 self.name = obj.get("name")
327
328 testpoints = obj.get("testpoints", [])
329 self.testpoints = self._create_testplan_elements(
330 'testpoint', testpoints)
331
332 covergroups = obj.get("covergroups", [])
333 self.covergroups = self._create_testplan_elements(
334 'covergroup', covergroups)
335
336 if not testpoints and not covergroups:
337 print(f"Error: No testpoints or covergroups found in {filename}")
338 sys.exit(1)
339
340 # Any variable in the testplan that is not a recognized HJson field can
341 # be used as a substitution variable.
342 substitutions = {
343 k: v
344 for k, v in obj.items() if k not in self.rsvd_keywords
345 }
346 for tp in self.testpoints:
347 tp.do_substitutions(substitutions)
348
349 self._sort()
350
351 def _sort(self):
352 """Sort testpoints by milestone and covergroups by name."""
353 self.testpoints.sort(key=lambda x: x.milestone)
354 self.covergroups.sort(key=lambda x: x.name)
355
356 def get_milestone_regressions(self):
357 regressions = defaultdict(set)
358 for tp in self.testpoints:
359 if tp.milestone in tp.milestones[1:]:
Srikrishna Iyerbc7789d2021-05-24 17:47:17 -0700360 regressions[tp.milestone].update({t for t in tp.tests if t})
Srikrishna Iyer86169d02021-05-10 09:35:52 -0700361
362 # Build regressions dict into a hjson like data structure
363 return [{
364 "name": ms,
365 "tests": list(regressions[ms])
366 } for ms in regressions]
367
Srikrishna Iyerba482312021-05-21 02:09:54 -0700368 def get_testplan_table(self, fmt="pipe"):
Srikrishna Iyer86169d02021-05-10 09:35:52 -0700369 """Generate testplan table from hjson testplan.
370
Srikrishna Iyerba482312021-05-21 02:09:54 -0700371 fmt is either 'pipe' (markdown) or 'html'. 'pipe' is the name used by
372 tabulate to generate a markdown formatted table.
Srikrishna Iyer86169d02021-05-10 09:35:52 -0700373 """
Srikrishna Iyerba482312021-05-21 02:09:54 -0700374 assert fmt in ["pipe", "html"]
375
376 def _fmt_text(text, fmt):
377 return mistletoe.markdown(text) if fmt == "html" else text
Srikrishna Iyer86169d02021-05-10 09:35:52 -0700378
379 if self.testpoints:
Srikrishna Iyerba482312021-05-21 02:09:54 -0700380 lines = [_fmt_text("\n### Testpoints\n", fmt)]
Srikrishna Iyer86169d02021-05-10 09:35:52 -0700381 header = ["Milestone", "Name", "Tests", "Description"]
382 colalign = ("center", "center", "left", "left")
383 table = []
384 for tp in self.testpoints:
Srikrishna Iyerba482312021-05-21 02:09:54 -0700385 desc = _fmt_text(tp.desc.strip(), fmt)
386 # TODO(astanin/python-tabulate#126): Tabulate does not
387 # convert \n's to line-breaks.
388 tests = "<br>\n".join(tp.tests)
Srikrishna Iyer86169d02021-05-10 09:35:52 -0700389 table.append([tp.milestone, tp.name, tests, desc])
Srikrishna Iyerba482312021-05-21 02:09:54 -0700390 lines += [
391 tabulate(table,
392 headers=header,
393 tablefmt=fmt,
394 colalign=colalign)
395 ]
Srikrishna Iyer86169d02021-05-10 09:35:52 -0700396
397 if self.covergroups:
Srikrishna Iyerba482312021-05-21 02:09:54 -0700398 lines += [_fmt_text("\n### Covergroups\n", fmt)]
Srikrishna Iyer86169d02021-05-10 09:35:52 -0700399 header = ["Name", "Description"]
400 colalign = ("center", "left")
401 table = []
402 for covergroup in self.covergroups:
Srikrishna Iyerbc7789d2021-05-24 17:47:17 -0700403 desc = _fmt_text(covergroup.desc.strip(), fmt)
Srikrishna Iyer86169d02021-05-10 09:35:52 -0700404 table.append([covergroup.name, desc])
Srikrishna Iyerba482312021-05-21 02:09:54 -0700405 lines += [
406 tabulate(table,
407 headers=header,
408 tablefmt=fmt,
409 colalign=colalign)
410 ]
Srikrishna Iyer86169d02021-05-10 09:35:52 -0700411
Srikrishna Iyerba482312021-05-21 02:09:54 -0700412 text = "\n".join(lines)
Srikrishna Iyer86169d02021-05-10 09:35:52 -0700413 if fmt == "html":
Srikrishna Iyerba482312021-05-21 02:09:54 -0700414 text = self.get_dv_style_css() + text
Srikrishna Iyer86169d02021-05-10 09:35:52 -0700415 text = text.replace("<table>", "<table class=\"dv\">")
416
Srikrishna Iyerba482312021-05-21 02:09:54 -0700417 # Tabulate does not support HTML tags.
418 text = text.replace("&lt;", "<").replace("&gt;", ">")
Srikrishna Iyer86169d02021-05-10 09:35:52 -0700419 return text
420
421 def map_test_results(self, test_results):
422 """Map test results to testpoints."""
423 # Maintain a list of tests we already counted.
424 tests_seen = set()
425
426 def _process_testpoint(testpoint, totals):
427 """Computes the testplan progress and the sim footprint.
428
429 totals is a list of Testpoint items that represent the total number
430 of tests passing for each milestone. The sim footprint is simply
431 the sum total of all tests run in the simulation, counted for each
432 milestone and also the grand total.
433 """
434 ms = testpoint.milestone
435 for tr in testpoint.test_results:
436 if tr.name in tests_seen:
437 continue
438
439 tests_seen.add(tr.name)
440 # Compute the testplan progress.
441 self.progress[ms]["total"] += 1
442 if tr.total != 0:
443 self.progress[ms]["written"] += 1
444
445 # Compute the milestone total & the grand total.
446 totals[ms].test_results[0].passing += tr.passing
447 totals[ms].test_results[0].total += tr.total
448 if ms != "N.A.":
449 totals["N.A."].test_results[0].passing += tr.passing
450 totals["N.A."].test_results[0].total += tr.total
451
452 totals = {}
453 # Create testpoints to represent the total for each milestone & the
454 # grand total.
455 for ms in Testpoint.milestones:
456 arg = {
457 "name": "N.A.",
458 "desc": f"Total {ms} tests",
459 "milestone": ms,
460 "tests": [],
461 }
462 totals[ms] = Testpoint(arg)
463 totals[ms].test_results = [Result("**TOTAL**")]
464
465 # Create unmapped as a testpoint to represent tests from the simulation
466 # results that could not be mapped to the testpoints.
467 arg = {
468 "name": "Unmapped tests",
469 "desc": "Unmapped tests",
470 "milestone": "N.A.",
471 "tests": [],
472 }
473 unmapped = Testpoint(arg)
474
475 # Now, map the simulation results to each testpoint.
476 for tp in self.testpoints:
477 tp.map_test_results(test_results)
478 _process_testpoint(tp, totals)
479
480 # If we do have unmapped tests, then count that too.
481 unmapped.test_results = [tr for tr in test_results if not tr.mapped]
482 _process_testpoint(unmapped, totals)
483
484 # Add milestone totals back into 'testpoints' and sort.
485 for ms in Testpoint.milestones[1:]:
486 self.testpoints.append(totals[ms])
487 self._sort()
488
489 # Append unmapped and the grand total at the end.
490 if unmapped.test_results:
491 self.testpoints.append(unmapped)
492 self.testpoints.append(totals["N.A."])
493
494 # Compute the progress rate fpr each milestone.
495 for ms in Testpoint.milestones:
496 stat = self.progress[ms]
497
498 # Remove milestones that are not targeted.
499 if stat["total"] == 0:
500 self.progress.pop(ms)
501 continue
502
503 stat["progress"] = self._get_percentage(stat["written"],
504 stat["total"])
505
506 self.test_results_mapped = True
507
508 def map_covergroups(self, cgs_found):
509 """Map the covergroups found from simulation to the testplan.
510
511 For now, this does nothing more than 'check off' the covergroup
512 found from the simulation results with the coverage model in the
513 testplan by updating the progress dict.
514
515 cgs_found is a list of covergroup names extracted from the coverage
516 database after the simulation is run with coverage enabled.
517 """
518
519 if not self.covergroups:
520 return
521
522 written = 0
523 total = 0
524 for cg in self.covergroups:
525 total += 1
526 if cg.name in cgs_found:
527 written += 1
528
529 self.progress["Covergroups"] = {
530 "written": written,
531 "total": total,
532 "progress": self._get_percentage(written, total),
533 }
534
535 def get_test_results_table(self, map_full_testplan=True):
536 """Return the mapped test results into a markdown table."""
537
538 assert self.test_results_mapped, "Have you invoked map_test_results()?"
539 header = [
540 "Milestone", "Name", "Tests", "Passing", "Total", "Pass Rate"
541 ]
542 colalign = ("center", "center", "left", "center", "center", "center")
543 table = []
544 for tp in self.testpoints:
545 milestone = "" if tp.milestone == "N.A." else tp.milestone
546 tp_name = "" if tp.name == "N.A." else tp.name
547 for tr in tp.test_results:
548 if tr.total == 0 and not map_full_testplan:
549 continue
550 pass_rate = self._get_percentage(tr.passing, tr.total)
551 table.append([
552 milestone, tp_name, tr.name, tr.passing, tr.total,
553 pass_rate
554 ])
555 milestone = ""
556 tp_name = ""
557
558 text = "\n### Test Results\n"
559 text += tabulate(table,
560 headers=header,
561 tablefmt="pipe",
562 colalign=colalign)
563 text += "\n"
564 return text
565
566 def get_progress_table(self):
567 """Returns the current progress of the effort towards the testplan."""
568
569 assert self.test_results_mapped, "Have you invoked map_test_results()?"
570 header = []
571 table = []
572 for key in self.progress:
573 stat = self.progress[key]
574 values = [v for v in stat.values()]
575 if not header:
576 header = ["Items"] + [k.capitalize() for k in stat]
577 table.append([key] + values)
578
579 text = "\n### Testplan Progress\n"
580 colalign = (("center", ) * len(header))
581 text += tabulate(table,
582 headers=header,
583 tablefmt="pipe",
584 colalign=colalign)
585 text += "\n"
586 return text
587
588 def get_cov_results_table(self, cov_results):
589 """Returns the coverage in a table format.
590
591 cov_results is a list of dicts with name and result keys, representing
592 the name of the coverage metric and the result in decimal / fp value.
593 """
594
595 if not cov_results:
596 return ""
597
598 try:
599 cov_header = [c["name"].capitalize() for c in cov_results]
600 cov_values = [c["result"] for c in cov_results]
601 except KeyError as e:
602 print(f"Malformed cov_results:\n{cov_results}\n{e}")
603 sys.exit(1)
604
605 colalign = (("center", ) * len(cov_header))
606 text = "\n### Coverage Results\n"
607 text += tabulate([cov_values],
608 headers=cov_header,
609 tablefmt="pipe",
610 colalign=colalign)
611 text += "\n"
612 return text
613
614 def get_test_results_summary(self):
615 """Returns the final total as a summary."""
616 assert self.test_results_mapped, "Have you invoked map_test_results()?"
617
618 # The last item in tespoints is the final sum total. We use that to
619 # return the results summary as a dict.
620 total = self.testpoints[-1]
621 assert total.name == "N.A."
622 assert total.milestone == "N.A."
623
624 tr = total.test_results[0]
625
626 result = {}
627 result["Name"] = self.name.upper()
628 result["Passing"] = tr.passing
629 result["Total"] = tr.total
630 result["Pass Rate"] = self._get_percentage(tr.passing, tr.total)
631 return result
632
633 def get_sim_results(self, sim_results_file, fmt="md"):
634 """Returns the mapped sim result tables in HTML formatted text.
635
636 The data extracted from the sim_results table HJson file is mapped into
637 a test results, test progress, covergroup progress and coverage tables.
Srikrishna Iyerba482312021-05-21 02:09:54 -0700638
639 fmt is either 'md' (markdown) or 'html'.
Srikrishna Iyer86169d02021-05-10 09:35:52 -0700640 """
641 assert fmt in ["md", "html"]
642 sim_results = Testplan._parse_hjson(sim_results_file)
643 test_results_ = sim_results.get("test_results", None)
644
645 test_results = []
646 for item in test_results_:
647 try:
648 tr = Result(item["name"], item["passing"], item["total"])
649 test_results.append(tr)
650 except KeyError as e:
651 print(f"Error: data in {sim_results_file} is malformed!\n{e}")
652 sys.exit(1)
653
654 self.map_test_results(test_results)
655 self.map_covergroups(sim_results.get("covergroups", []))
656
657 text = "# Simulation Results\n"
658 text += "## Run on {}\n".format(sim_results["timestamp"])
659 text += self.get_test_results_table()
660 text += self.get_progress_table()
661
662 cov_results = sim_results.get("cov_results", [])
663 text += self.get_cov_results_table(cov_results)
664
665 if fmt == "html":
666 text = self.get_dv_style_css() + mistletoe.markdown(text)
667 text = text.replace("<table>", "<table class=\"dv\">")
668 return text
669
670
671def _merge_dicts(list1, list2, use_list1_for_defaults=True):
672 '''Merge 2 dicts into one
673
674 This function takes 2 dicts as args list1 and list2. It recursively merges
675 list2 into list1 and returns list1. The recursion happens when the the
676 value of a key in both lists is a dict. If the values of the same key in
677 both lists (at the same tree level) are of dissimilar type, then there is a
678 conflict and an error is thrown. If they are of the same scalar type, then
679 the third arg "use_list1_for_defaults" is used to pick the final one.
680 '''
681 for key, item2 in list2.items():
682 item1 = list1.get(key)
683 if item1 is None:
684 list1[key] = item2
685 continue
686
687 # Both dictionaries have an entry for this key. Are they both lists? If
688 # so, append.
689 if isinstance(item1, list) and isinstance(item2, list):
690 list1[key] = item1 + item2
691 continue
692
693 # Are they both dictionaries? If so, recurse.
694 if isinstance(item1, dict) and isinstance(item2, dict):
695 _merge_dicts(item1, item2)
696 continue
697
698 # We treat other types as atoms. If the types of the two items are
699 # equal pick one or the other (based on use_list1_for_defaults).
700 if isinstance(item1, type(item2)) and isinstance(item2, type(item1)):
701 list1[key] = item1 if use_list1_for_defaults else item2
702 continue
703
704 # Oh no! We can't merge this.
705 print("ERROR: Cannot merge dictionaries at key {!r} because items "
706 "have conflicting types ({} in 1st; {} in 2nd).".format(
707 key, type(item1), type(item2)))
708 sys.exit(1)
709
710 return list1