Coverage for src/edwh_uptime_plugin/uptimerobot.py: 81%

1 import enum

2 import json

3 import sys

4 import typing

5 from typing import Any, Optional

6

7 import edwh

8 import requests

9 from edwh import check_env

10 from typing_extensions import NotRequired, Required

11 from yayarl import URL

12

13 AnyDict: typing.TypeAlias = dict[str, Any]

14

15

16 class UptimeRobotException(Exception):

17 status_code: int

18 message: str

19 response: requests.Response

20 extra: Optional[Any]

21

22 def __init__(self, response: requests.Response, extra: Any = None):

23 self.response = response

24 self.status_code = response.status_code

25 self.message = response.text

26 self.extra = extra

27

28

29 class UptimeRobotErrorResponse(typing.TypedDict):

30 type: str

31 message: str

32

33 parameter_name: NotRequired[str]

34 passed_value: NotRequired[str]

35

36

37 class UptimeRobotPagination(typing.TypedDict):

38 offset: int

39 limit: int

40 total: int

41

42

43 class UptimeRobotAccount(typing.TypedDict, total=False):

44 email: str

45 user_id: int

46 firstname: str

47 # ...

48

49

50 class UptimeRobotResponse(typing.TypedDict, total=False):

51 stat: typing.Literal["ok", "fail"]

52 error: NotRequired[UptimeRobotErrorResponse]

53 pagination: NotRequired[UptimeRobotPagination]

54 # actual data differs per endpoint:

55 monitor: NotRequired["UptimeRobotMonitor"]

56 monitors: NotRequired[list["UptimeRobotMonitor"]]

57 account: NotRequired[UptimeRobotAccount]

58

59

60 class UptimeRobotMonitor(typing.TypedDict, total=False):

61 id: Required[int]

62

63 # may or may not be there:

64 friendly_name: NotRequired[str]

65 url: NotRequired[str]

66 type: NotRequired[int]

67 sub_type: NotRequired[str]

68 keyword_type: NotRequired[Optional[str]]

69 keyword_case_type: NotRequired[Optional[str]]

70 keyword_value: NotRequired[str]

71 http_username: NotRequired[str]

72 http_password: NotRequired[str]

73 port: NotRequired[str]

74 interval: NotRequired[int]

75 timeout: NotRequired[int]

76 status: NotRequired[int]

77 create_datetime: NotRequired[int]

78

79

80 class MonitorType(enum.Enum):

81 HTTP = 1

82 KEYWORD = 2

83 PING = 3

84 PORT = 4

85 HEARTBEAT = 5

86

87

88 class UptimeRobot:

89 base = URL("https://api.uptimerobot.com/v2/")

90

91 _api_key: str = "" # cached version from .env

92 _verbose: bool = False

93

94 @property

95 def api_key(self) -> str:

96 if not self._api_key:

97 self._api_key = check_env(

98 "UPTIMEROBOT_APIKEY",

99 default=None,

100 comment="The API key used to manage UptimeRobot monitors.",

101 )

102

103 return self._api_key

104

105 def set_verbosity(self, verbose: bool = None) -> None:

106 if verbose is None: 106 ↛ 109line 106 didn't jump to line 109, because the condition on line 106 was never false

107 verbose = edwh.get_env_value("IS_DEBUG", "0") == "1"

108

109 self._verbose = verbose

110

111 def _log(self, *args: Any) -> None:

112 if not self._verbose:

113 return

114

115 print(*args, file=sys.stderr)

116

117 def _post(self, endpoint: str, **input_data: Any) -> UptimeRobotResponse:

118 """

119 :raise UptimeRobotError: if the request returns an error status code

120 """

121 input_data.setdefault("format", "json")

122 input_data["api_key"] = self.api_key

123

124 self._log("POST", self.base / endpoint, input_data)

125

126 resp = (self.base / endpoint).post(json=input_data)

127

128 self._log("RESP", resp.__dict__)

129

130 if not resp.ok: 130 ↛ 131line 130 didn't jump to line 131, because the condition on line 130 was never true

131 raise UptimeRobotException(resp)

132

133 try:

134 output_data = resp.json() # type: UptimeRobotResponse

135 except json.JSONDecodeError as e:

136 raise UptimeRobotException(resp, str(e)) from e

137

138 if output_data.get("stat") == "fail":

139 raise UptimeRobotException(resp, output_data.get("error", output_data))

140

141 return output_data

142

143 def get_account_details(self) -> Optional[UptimeRobotAccount]:

144 resp = self._post("getAccountDetails")

145

146 return resp.get("account", {})

147

148 def get_monitors(self, search: str = "") -> list[UptimeRobotMonitor]:

149 data = {}

150 if search:

151 data["search"] = search

152 result = self._post("getMonitors", **data).get("monitors")

153 if result is None:

154 return []

155

156 return result

157 # return typing.cast(list[UptimeRobotMonitor], result)

158

159 def new_monitor(self, friendly_name: str, url: str, monitor_type: MonitorType = MonitorType.HTTP) -> Optional[int]:

160 data = {

161 "friendly_name": friendly_name,

162 "url": url,

163 "type": monitor_type.value,

164 }

165 response = self._post("newMonitor", **data)

166

167 return response.get("monitor", {}).get("id")

168

169 def edit_monitor(self, monitor_id: int, new_data: AnyDict) -> bool:

170 resp = self._post("editMonitor", id=monitor_id, **new_data)

171

172 return resp.get("monitor", {}).get("id") == monitor_id

173

174 def delete_monitor(self, monitor_id: int) -> bool:

175 resp = self._post("deleteMonitor", id=monitor_id)

176

177 return resp.get("monitor", {}).get("id") == monitor_id

178

179 def reset_monitor(self, monitor_id: int) -> bool:

180 resp = self._post("resetMonitor", id=monitor_id)

181

182 return resp.get("monitor", {}).get("id") == monitor_id

183

184 # def get_alert_contacts(self):

185 # return self._post("getAlertContacts")

186 #

187 # def new_alert_contact(self, contact_data):

188 # return self._post("newAlertContact", input_data=contact_data)

189 #

190 # def edit_alert_contact(self, contact_id, new_data):

191 # return self._post("editAlertContact", input_data={"contact_id": contact_id, "new_data": new_data})

192 #

193 # def delete_alert_contact(self, contact_id):

194 # return self._post("deleteAlertContact", input_data={"contact_id": contact_id})

195 #

196 # def get_m_windows(self):

197 # return self._post("getMWindows")

198 #

199 # def new_m_window(self, window_data):

200 # return self._post("newMWindow", input_data=window_data)

201 #

202 # def edit_m_window(self, window_id, new_data):

203 # return self._post("editMWindow", input_data={"window_id": window_id, "new_data": new_data})

204 #

205 # def delete_m_window(self, window_id):

206 # return self._post("deleteMWindow", input_data={"window_id": window_id})

207 #

208 # def get_psps(self):

209 # return self._post("getPSPs")

210 #

211 # def new_psp(self, psp_data):

212 # return self._post("newPSP", input_data=psp_data)

213 #

214 # def edit_psp(self, psp_id, new_data):

215 # return self._post("editPSP", input_data={"psp_id": psp_id, "new_data": new_data})

216 #

217 # def delete_psp(self, psp_id):

218 # return self._post("deletePSP", input_data={"psp_id": psp_id})

219

220 @staticmethod

221 def format_status(status_code: int) -> str:

222 return {

223 0: "paused",

224 1: "not checked yet",

225 2: "up",

226 8: "seems down",

227 9: "down",

228 }.get(status_code, f"Unknown status '{status_code}'!")

229

230

231 uptime_robot = UptimeRobot()

232 uptime_robot.set_verbosity()