t-bug-catcher 0.5.1__tar.gz → 0.5.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/PKG-INFO +1 -1
  2. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/setup.cfg +1 -1
  3. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/setup.py +1 -1
  4. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/t_bug_catcher/__init__.py +1 -1
  5. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/t_bug_catcher/bug_catcher.py +5 -2
  6. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/t_bug_catcher/config.py +13 -4
  7. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/t_bug_catcher/jira.py +137 -14
  8. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/t_bug_catcher/stack_saver.py +0 -2
  9. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/t_bug_catcher/utils/common.py +15 -4
  10. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/t_bug_catcher.egg-info/PKG-INFO +1 -1
  11. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/tests/test_t_bug_catcher.py +2 -2
  12. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/MANIFEST.in +0 -0
  13. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/README.rst +0 -0
  14. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/pyproject.toml +0 -0
  15. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/requirements.txt +0 -0
  16. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/t_bug_catcher/bug_snag.py +0 -0
  17. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/t_bug_catcher/exceptions.py +0 -0
  18. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/t_bug_catcher/resources/whispers_config.yml +0 -0
  19. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/t_bug_catcher/utils/__init__.py +0 -0
  20. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/t_bug_catcher/utils/logger.py +0 -0
  21. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/t_bug_catcher/validation.py +0 -0
  22. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/t_bug_catcher/workitems.py +0 -0
  23. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/t_bug_catcher.egg-info/SOURCES.txt +0 -0
  24. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/t_bug_catcher.egg-info/dependency_links.txt +0 -0
  25. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/t_bug_catcher.egg-info/not-zip-safe +0 -0
  26. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/t_bug_catcher.egg-info/requires.txt +0 -0
  27. {t_bug_catcher-0.5.1 → t_bug_catcher-0.5.3}/t_bug_catcher.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: t_bug_catcher
3
- Version: 0.5.1
3
+ Version: 0.5.3
4
4
  Summary: Bug catcher
5
5
  Home-page: https://www.thoughtful.ai/
6
6
  Author: Thoughtful
@@ -1,5 +1,5 @@
1
1
  [bumpversion]
2
- current_version = 0.5.1
2
+ current_version = 0.5.3
3
3
  commit = True
4
4
  tag = False
5
5
 
@@ -26,7 +26,7 @@ setup(
26
26
  packages=find_packages(include=["t_bug_catcher", "t_bug_catcher.*"]),
27
27
  test_suite="tests",
28
28
  url="https://www.thoughtful.ai/",
29
- version="0.5.1",
29
+ version="0.5.3",
30
30
  zip_safe=False,
31
31
  install_requires=install_requirements,
32
32
  include_package_data=True,
@@ -3,7 +3,7 @@
3
3
  __author__ = """Thoughtful"""
4
4
  __email__ = "support@thoughtful.ai"
5
5
  # fmt: off
6
- __version__ = '0.5.1'
6
+ __version__ = '0.5.3'
7
7
  # fmt: on
8
8
 
9
9
  from .bug_catcher import (
@@ -334,5 +334,8 @@ report_error_to_bugsnag = __bug_catcher.report_error_to_bugsnag
334
334
  install_sys_hook = __bug_catcher.install_sys_hook
335
335
  uninstall_sys_hook = __bug_catcher.uninstall_sys_hook
336
336
 
337
- if CONFIG.STAGE.lower() == "delivery":
338
- pre_run_validation = PRE_RUN_VALIDATION
337
+ if not CONFIG.STAGE:
338
+ logger.warning("Implementation Stage is not configured. Please configure it before running.")
339
+ else:
340
+ if CONFIG.STAGE.lower() == "delivery":
341
+ pre_run_validation = PRE_RUN_VALIDATION
@@ -14,6 +14,8 @@ class Config:
14
14
  MAX_DESCRIPTION_LENGTH: int = 250
15
15
  SUMMARY_LENGTH: int = 120
16
16
  STACK_SCOPE: int = 3
17
+ STACK_ITEM_LENGTH: int = 100
18
+ STACK_TEXT_LENGTH: int = 10000
17
19
 
18
20
  class TICKET_PRIORITIES:
19
21
  """Priorities class for configuring the application."""
@@ -25,6 +27,7 @@ class Config:
25
27
  LOWEST: str = "5"
26
28
 
27
29
  SUPPORT_BOARD = "AB"
30
+ BC_BOARD = "BC"
28
31
 
29
32
  KEYS_TO_REMOVE = ["credential", "password"]
30
33
  BUILD_INFO_FILE = "commit_info.json"
@@ -42,10 +45,16 @@ class Config:
42
45
  else variables.get("environment", "local")
43
46
  )
44
47
 
45
- STAGE = metadata.get("process", dict()).get("implementationStage", "")
46
- ADMIN_CODE = metadata.get("process", dict()).get("adminCode", "")
47
- WORKER_NAME = metadata.get("process", dict()).get("name", "")
48
- EMPOWER_URL = metadata.get("process", dict()).get("processRunUrl") or variables.get("processRunUrl")
48
+ if ENVIRONMENT != "local":
49
+ STAGE = metadata.get("process", dict()).get("implementationStage", "")
50
+ ADMIN_CODE = metadata.get("process", dict()).get("adminCode", "")
51
+ WORKER_NAME = metadata.get("process", dict()).get("name", "")
52
+ EMPOWER_URL = metadata.get("process", dict()).get("processRunUrl") or variables.get("processRunUrl")
53
+ else:
54
+ STAGE = "test"
55
+ ADMIN_CODE = ""
56
+ WORKER_NAME = ""
57
+ EMPOWER_URL = ""
49
58
 
50
59
 
51
60
  CONFIG = Config()
@@ -150,13 +150,14 @@ class Jira:
150
150
  }
151
151
 
152
152
  @retry_if_bad_request
153
- def get_issues(self) -> dict:
153
+ def get_issues(self, project_key: Optional[str] = None) -> dict:
154
154
  """A function to get the issues using a Jira API.
155
155
 
156
156
  It updates the headers, sets up a JQL query, specifies additional query parameters,
157
157
  makes a GET request to the Jira API, and returns the JSON response.
158
158
  """
159
- jql_query = f'project = "{self._project_key}"'
159
+ project_key = project_key or self._project_key
160
+ jql_query = f'project = "{project_key}"'
160
161
 
161
162
  # Specify additional query parameters if needed
162
163
  query_params = {"jql": jql_query, "maxResults": 100} # Adjust as needed
@@ -210,6 +211,7 @@ class Jira:
210
211
  summary: str,
211
212
  description: dict,
212
213
  issue_type: str,
214
+ project_key: Optional[str] = None,
213
215
  assignee: Optional[str] = None,
214
216
  labels: Optional[list] = None,
215
217
  priority: Optional[str] = None,
@@ -227,18 +229,19 @@ class Jira:
227
229
  Returns:
228
230
  The JSON payload for creating a new issue.
229
231
  """
232
+ project_key = project_key or self._project_key
230
233
  fields = {
231
234
  "fields": {
232
235
  "assignee": {"id": assignee if assignee else "-1"},
233
236
  "description": description,
234
237
  "issuetype": {"id": issue_type},
235
- "project": {"key": self._project_key},
238
+ "project": {"key": project_key},
236
239
  "summary": summary,
237
240
  },
238
241
  }
239
242
  if labels:
240
243
  fields["fields"]["labels"] = labels
241
- if self._project_key == CONFIG.SUPPORT_BOARD and CONFIG.ADMIN_CODE:
244
+ if project_key == CONFIG.SUPPORT_BOARD and CONFIG.ADMIN_CODE:
242
245
  fields["fields"]["customfield_10077"] = [CONFIG.ADMIN_CODE]
243
246
  if priority:
244
247
  fields["fields"]["priority"] = {"id": priority}
@@ -264,7 +267,7 @@ class Jira:
264
267
  )
265
268
 
266
269
  @retry_if_bad_request
267
- def __get_issue_types(self) -> dict:
270
+ def __get_issue_types(self, project_key: Optional[str] = None) -> dict:
268
271
  """Get the board information.
269
272
 
270
273
  Args:
@@ -273,9 +276,10 @@ class Jira:
273
276
  Returns:
274
277
  dict: The board information
275
278
  """
279
+ project_key = project_key or self._project_key
276
280
  response = requests.request(
277
281
  "GET",
278
- url=self._base_url + f"/rest/api/3/project/{self._project_key}",
282
+ url=self._base_url + f"/rest/api/3/project/{project_key}",
279
283
  headers=self.__get_headers(),
280
284
  auth=self._auth,
281
285
  )
@@ -850,6 +854,54 @@ class Jira:
850
854
  + self.__error_markup(warning_id),
851
855
  }
852
856
 
857
+ def __create_internal_error_markup(
858
+ self,
859
+ exc_type: type,
860
+ exc_value: Union[Exception, str],
861
+ exc_traceback: TracebackType,
862
+ error_id: str,
863
+ additional_info: Optional[str] = None,
864
+ metadata: Optional[dict] = None,
865
+ ) -> dict:
866
+ """Create a description with the given trace_back and additional_info.
867
+
868
+ Args:
869
+ exc_type (type): The type of the exception.
870
+ exc_value (Exception): The exception.
871
+ exc_traceback (TracebackType): The trace_back.
872
+ error_id (str): The error ID.
873
+ additional_info (str, optional): Additional information. Defaults to "".
874
+ metadata (dict, optional): Additional metadata. Defaults to None.
875
+
876
+ Returns:
877
+ dict: A dictionary containing the version, type, and content.
878
+ """
879
+ exc_info = f"{exc_type.__name__}: {exc_value}"
880
+ frames = get_frames(exc_traceback)
881
+ error_string: str = frames[-1].line
882
+ exc_traceback_info: str = (
883
+ f"Traceback (most recent call last):\n{''.join(traceback.format_tb(exc_traceback))}{exc_info}"
884
+ )
885
+ if len(exc_traceback_info) > 30000:
886
+ exc_traceback_info: str = (
887
+ f"Traceback (most recent call last):\n{''.join(traceback.format_tb(exc_traceback)[-1])}{exc_info}"
888
+ )
889
+
890
+ return {
891
+ "version": 1,
892
+ "type": "doc",
893
+ "content": []
894
+ + (self.__error_string_markup(error_string, exc_info) if error_string else [])
895
+ + self.__bot_name_markup()
896
+ + self.__date_markup()
897
+ + self.__runlink_markup()
898
+ + self.__environment_markup()
899
+ + (self.__description_markup(additional_info) if additional_info else [])
900
+ + self.__traceback_markup(exc_traceback_info)
901
+ + (self.__metadata_markup(metadata) if metadata else [])
902
+ + self.__error_markup(error_id),
903
+ }
904
+
853
905
  def __create_transtion_markup(self, issue_status: str) -> dict:
854
906
  """Create a transition markup.
855
907
 
@@ -1070,6 +1122,7 @@ class Jira:
1070
1122
  self,
1071
1123
  summary: str,
1072
1124
  description: dict,
1125
+ project_key: Optional[str] = None,
1073
1126
  assignee_id: Optional[str] = None,
1074
1127
  attachments: Optional[List] = None,
1075
1128
  labels: Optional[list] = None,
@@ -1088,11 +1141,14 @@ class Jira:
1088
1141
  Returns:
1089
1142
  The response from creating the ticket.
1090
1143
  """
1091
- if CONFIG.STAGE.lower() == "hypercare":
1144
+ project_key = project_key or self._project_key
1145
+ if project_key == CONFIG.BC_BOARD:
1146
+ issue_type = self.__get_issue_types(project_key=project_key).get("task")
1147
+ elif CONFIG.STAGE and CONFIG.STAGE.lower() == "hypercare":
1092
1148
  issue_type = self._issue_types.get("hypercare") or self._issue_types.get("epic")
1093
- elif CONFIG.STAGE.lower() == "support":
1149
+ elif CONFIG.STAGE and CONFIG.STAGE.lower() == "support":
1094
1150
  issue_type = self._issue_types.get("support") or self._issue_types.get("epic")
1095
- elif CONFIG.STAGE.lower() == "delivery" and self._project_key == CONFIG.SUPPORT_BOARD:
1151
+ elif CONFIG.STAGE and CONFIG.STAGE.lower() == "delivery" and project_key == CONFIG.SUPPORT_BOARD:
1096
1152
  issue_type = self._issue_types.get("development") or self._issue_types.get("epic")
1097
1153
  else:
1098
1154
  issue_type = (
@@ -1107,6 +1163,7 @@ class Jira:
1107
1163
  description=description,
1108
1164
  assignee=assignee_id,
1109
1165
  issue_type=issue_type,
1166
+ project_key=project_key,
1110
1167
  labels=labels,
1111
1168
  priority=priority,
1112
1169
  )
@@ -1118,6 +1175,7 @@ class Jira:
1118
1175
  description=description,
1119
1176
  assignee=assignee_id,
1120
1177
  issue_type=issue_type,
1178
+ project_key=project_key,
1121
1179
  priority=priority,
1122
1180
  )
1123
1181
  response = self.post_ticket(issue=issue)
@@ -1137,7 +1195,7 @@ class Jira:
1137
1195
  for attachment in attachments:
1138
1196
  if os.path.exists(str(attachment)):
1139
1197
  self.add_attachment(attachment, ticket_id)
1140
- if self._webhook_url and self._project_key != CONFIG.SUPPORT_BOARD:
1198
+ if self._webhook_url and project_key != CONFIG.SUPPORT_BOARD:
1141
1199
  self.move_ticket_to_board(ticket_id)
1142
1200
  return response
1143
1201
 
@@ -1248,6 +1306,8 @@ class Jira:
1248
1306
  additional_info=additional_info,
1249
1307
  metadata=metadata,
1250
1308
  )
1309
+ if stack_trace and os.path.exists(stack_trace):
1310
+ os.remove(stack_trace)
1251
1311
  return existing_ticket
1252
1312
 
1253
1313
  if stack_trace:
@@ -1270,7 +1330,7 @@ class Jira:
1270
1330
  metadata=metadata,
1271
1331
  )
1272
1332
 
1273
- priority = CONFIG.TICKET_PRIORITIES.HIGH if CONFIG.STAGE.lower() == "hypercare" else None
1333
+ priority = CONFIG.TICKET_PRIORITIES.HIGH if CONFIG.STAGE and CONFIG.STAGE.lower() == "hypercare" else None
1274
1334
 
1275
1335
  response = self.__create_new_ticket(
1276
1336
  summary=summary,
@@ -1284,7 +1344,8 @@ class Jira:
1284
1344
  os.remove(stack_trace)
1285
1345
  return response
1286
1346
  except Exception as ex:
1287
- logger.warning(f"Failed to create Jira issue due to: {ex.__class__.__name__}: {ex}")
1347
+ logger.warning(f"Failed to create Jira issue due to: {type(ex)}: {ex}")
1348
+ self.report_internal_error(exception=ex, additional_info="Failed to report error.")
1288
1349
  return False
1289
1350
 
1290
1351
  def report_unhandled_error(
@@ -1314,6 +1375,8 @@ class Jira:
1314
1375
  existing_ticket=existing_ticket,
1315
1376
  summary=summary,
1316
1377
  )
1378
+ if os.path.exists(stack_trace):
1379
+ os.remove(stack_trace)
1317
1380
  return existing_ticket
1318
1381
 
1319
1382
  assignee_id = None
@@ -1330,7 +1393,9 @@ class Jira:
1330
1393
  error_id=error_id,
1331
1394
  )
1332
1395
 
1333
- priority = CONFIG.TICKET_PRIORITIES.HIGHEST if CONFIG.STAGE.lower() == "hypercare" else None
1396
+ priority = (
1397
+ CONFIG.TICKET_PRIORITIES.HIGHEST if CONFIG.STAGE and CONFIG.STAGE.lower() == "hypercare" else None
1398
+ )
1334
1399
 
1335
1400
  response = self.__create_new_ticket(
1336
1401
  summary=summary,
@@ -1344,9 +1409,67 @@ class Jira:
1344
1409
  os.remove(stack_trace)
1345
1410
  return response
1346
1411
  except Exception as ex:
1347
- logger.warning(f"Failed to create Jira issue due to: {ex.__class__.__name__}: {ex}")
1412
+ logger.warning(f"Failed to create Jira issue due to: {type(ex)}: {ex}")
1413
+ self.report_internal_error(exception=ex, additional_info="Failed to report unhandled error.")
1348
1414
  return False
1349
1415
 
1416
+ def report_internal_error(self, exception: Exception, metadata: dict = None, additional_info: str = None):
1417
+ """Report an internal error to Jira.
1418
+
1419
+ Args:
1420
+ exception (Exception): The exception to be added to the Jira issue.
1421
+
1422
+ Returns:
1423
+ The response from creating the Jira issue.
1424
+ """
1425
+ try:
1426
+ if not exception:
1427
+ _, exception, _ = sys.exc_info()
1428
+
1429
+ attachments = [str(file) for file in Path().cwd().glob("stack_details_*.json")]
1430
+
1431
+ summary = self.__create_summary(type(exception), exception, exception.__traceback__)
1432
+ error_id = self.__generate_error_id(exc_type=type(exception), exc_traceback=exception.__traceback__)
1433
+
1434
+ existing_ticket = self.filter_tickets(
1435
+ all_tickets=self.get_issues(project_key=CONFIG.BC_BOARD)["issues"],
1436
+ error_id=error_id,
1437
+ )
1438
+ if existing_ticket:
1439
+ self.__update_existing_ticket(
1440
+ existing_ticket=existing_ticket,
1441
+ attachments=attachments,
1442
+ summary=summary,
1443
+ additional_info=additional_info,
1444
+ metadata=metadata,
1445
+ )
1446
+ for file in attachments:
1447
+ os.remove(file)
1448
+ return existing_ticket
1449
+
1450
+ description = self.__create_internal_error_markup(
1451
+ exc_type=type(exception),
1452
+ exc_value=exception,
1453
+ exc_traceback=exception.__traceback__,
1454
+ error_id=error_id,
1455
+ additional_info=additional_info,
1456
+ metadata=metadata,
1457
+ )
1458
+
1459
+ response = self.__create_new_ticket(
1460
+ summary=summary,
1461
+ description=description,
1462
+ project_key=CONFIG.BC_BOARD,
1463
+ attachments=attachments,
1464
+ labels=["bug_catcher"],
1465
+ )
1466
+ for file in attachments:
1467
+ os.remove(file)
1468
+ logger.info("Created Bug Catcher issue.")
1469
+ return response
1470
+ except Exception as ex:
1471
+ logger.warning(f"Failed to report Bug Catcher issue due to: {type(ex)}: {ex}")
1472
+
1350
1473
  @retry_if_bad_request
1351
1474
  def add_attachment(self, attachment: str, ticket_id: str) -> Optional[dict]:
1352
1475
  """Uploads an attachment to a Jira ticket.
@@ -1,7 +1,6 @@
1
1
  import inspect
2
2
  import json
3
3
  import linecache
4
- import os
5
4
  import re
6
5
  import sys
7
6
  from datetime import datetime
@@ -99,7 +98,6 @@ class StackSaver:
99
98
 
100
99
  if secrets:
101
100
  logger.warning("Failed to mask credentials")
102
- os.remove(file_path)
103
101
  raise Exception("Failed to mask credentials")
104
102
 
105
103
  with open(file_path, "w") as file:
@@ -26,7 +26,7 @@ class Encoder(json.JSONEncoder):
26
26
  try:
27
27
  object_copy = copy.deepcopy(o)
28
28
  except (TypeError, AttributeError):
29
- return str(o)
29
+ return str(o)[: CONFIG.LIMITS.STACK_TEXT_LENGTH]
30
30
  try:
31
31
  if hasattr(object_copy, "__dict__"):
32
32
  keys_to_remove = [
@@ -36,14 +36,17 @@ class Encoder(json.JSONEncoder):
36
36
  ]
37
37
  for key in keys_to_remove:
38
38
  del object_copy.__dict__[key]
39
- return {str(key): str(value) for key, value in object_copy.__dict__.items()}
39
+ return {
40
+ str(key): str(value)[: CONFIG.LIMITS.STACK_TEXT_LENGTH]
41
+ for key, value in object_copy.__dict__.items()
42
+ }
40
43
  if isinstance(object_copy, (datetime, date)):
41
44
  return object_copy.isoformat()
42
45
  if isinstance(object_copy, Path):
43
46
  return str(object_copy)
44
47
  return super().default(self, object_copy)
45
48
  except TypeError:
46
- return str(object_copy)
49
+ return str(object_copy)[: CONFIG.LIMITS.STACK_TEXT_LENGTH]
47
50
 
48
51
 
49
52
  def get_frames(exc_traceback: TracebackType) -> List:
@@ -55,9 +58,12 @@ def get_frames(exc_traceback: TracebackType) -> List:
55
58
  Returns:
56
59
  List: The frames of the exception.
57
60
  """
58
- return [
61
+ frames = [
59
62
  frame for frame in traceback.extract_tb(exc_traceback) if "site-packages" not in str(frame.filename).lower()
60
63
  ]
64
+ if not frames:
65
+ frames = [frame for frame in traceback.extract_tb(exc_traceback)]
66
+ return frames
61
67
 
62
68
 
63
69
  def convert_keys_to_primitives(data: dict) -> dict:
@@ -71,7 +77,12 @@ def convert_keys_to_primitives(data: dict) -> dict:
71
77
  """
72
78
  new_dict = {}
73
79
  for key, value in data.items():
80
+ if isinstance(value, list) or isinstance(value, tuple):
81
+ value = value[: CONFIG.LIMITS.STACK_ITEM_LENGTH]
74
82
  if isinstance(value, dict):
83
+ items_list = list(value.items())
84
+ sliced_list = items_list[: CONFIG.LIMITS.STACK_ITEM_LENGTH]
85
+ value = dict(sliced_list)
75
86
  new_dict[str(key)] = convert_keys_to_primitives(value)
76
87
  else:
77
88
  new_dict[str(key)] = value
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: t_bug_catcher
3
- Version: 0.5.1
3
+ Version: 0.5.3
4
4
  Summary: Bug catcher
5
5
  Home-page: https://www.thoughtful.ai/
6
6
  Author: Thoughtful
@@ -64,8 +64,8 @@ class TestBugCatcher(unittest.TestCase):
64
64
  self.bug_catcher.report_error(exception=ex)
65
65
  actual_call = mock_warning.call_args
66
66
  warning_message = actual_call[0][0]
67
- self.assertTrue(warning_message.startswith("Failed to create Jira issue due to"))
68
- self.assertEqual(mock_warning.call_count, 1)
67
+ self.assertTrue(warning_message.startswith("Failed to report Bug Catcher issue due to"))
68
+ self.assertEqual(mock_warning.call_count, 2)
69
69
 
70
70
 
71
71
  if __name__ == "__main__":
File without changes
File without changes