Skip to content

Commit 7912dcd

Browse files
markuslfebuerki-lf
authored andcommitted
refactor(plugins): unify uptimerobot family and add unit tests (#268)
Bring the uptimerobot module_util and all nine uptimerobot_* modules to the standard plugin style: standard file header (also unifies the module_util copyright line) and f-strings throughout, replacing every str.format() call. No behavior change from the formatting. Safe fixes: - module_util: the four get_* functions now pass a non-list API response (the stat-ok message fallback) straight through instead of iterating it and crashing - module_util: drop the no-op ternary and the now-unused is_paginated_field bookkeeping in _request_uncached Add unit tests for the pure helpers: the module_util wire builders, secret redaction, cache-key hashing, friendly-name resolution and the read-direction response translators; the monitor alert_contacts/mwindows normalizers; and the mwindow time helpers (incl. the midnight wrap).
1 parent bd178b7 commit 7912dcd

14 files changed

Lines changed: 426 additions & 187 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4242

4343
### Fixed
4444

45+
* **plugin:uptimerobot_\***: The modules no longer crash when the UptimeRobot API returns a non-list response for a list endpoint; the response is passed through instead.
4546
* **plugin:nextcloud_occ_app_config, plugin:nextcloud_occ_system_config, plugin:uptimerobot_monitor, plugin:uptimerobot_psp**: Fixed their documentation so `ansible-doc` renders them again. A unit-test guard now catches this class of error for every in-house plugin.
4647
* **plugin:bitwarden_item**: Fixed the lookup's documentation so `ansible-doc` renders it again.
4748
* **plugin:combine_lod**: The `combine_lod` filter now reports an error when an item is missing part of a composite `unique_key` (a list of keys), instead of silently grouping such items together. Inventories with incomplete composite keys that previously merged by accident now fail loudly and must be corrected. Also fixed its documentation so `ansible-doc` renders it again.

plugins/module_utils/uptimerobot.py

Lines changed: 46 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
#!/usr/bin/python
2-
# -*- coding: utf-8 -*-
3-
4-
# Copyright: (c) 2026, Linuxfabrik, Zurich, Switzerland, https://www.linuxfabrik.ch
5-
# The Unlicense (see LICENSE or https://unlicense.org/)
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8; py-indent-offset: 4 -*-
3+
#
4+
# Author: Linuxfabrik GmbH, Zurich, Switzerland
5+
# Contact: info (at) linuxfabrik (dot) ch
6+
# https://www.linuxfabrik.ch/
7+
# License: The Unlicense, see LICENSE file.
68

79
"""Shared client for the UptimeRobot API used by the linuxfabrik.lfops.uptimerobot_*
810
modules.
@@ -137,21 +139,21 @@ def resolve_api_key(module, api_key, api_key_file):
137139
with open(path) as fh:
138140
value = fh.read().strip()
139141
if value:
140-
module.log('uptimerobot: api_key resolved from file {0}'.format(path))
142+
module.log(f'uptimerobot: api_key resolved from file {path}')
141143
return value
142144
except OSError:
143145
pass
144146

145147
env = os.environ.get(ENV_API_KEY, '').strip()
146148
if env:
147-
module.log('uptimerobot: api_key resolved from env {0}'.format(ENV_API_KEY))
149+
module.log(f'uptimerobot: api_key resolved from env {ENV_API_KEY}')
148150
return env
149151

150152
module.fail_json(msg=(
151153
'No UptimeRobot API key found. Provide one via the `api_key` parameter, '
152-
'an `api_key_file` (default: {default}), or the {env} environment '
153-
'variable.'
154-
).format(default=DEFAULT_API_KEY_FILE, env=ENV_API_KEY))
154+
f'an `api_key_file` (default: {DEFAULT_API_KEY_FILE}), or the {ENV_API_KEY} '
155+
'environment variable.'
156+
))
155157

156158

157159
# --- Wire format helpers -----------------------------------------------------
@@ -166,11 +168,9 @@ def alert_contacts_wire(items):
166168
"""
167169
parts = []
168170
for item in items:
169-
parts.append('{id}_{t}_{r}'.format(
170-
id=item['id'],
171-
t=item.get('threshold', 0),
172-
r=item.get('recurrence', 0),
173-
))
171+
parts.append(
172+
f"{item['id']}_{item.get('threshold', 0)}_{item.get('recurrence', 0)}"
173+
)
174174
return '-'.join(parts)
175175

176176

@@ -202,17 +202,15 @@ def _safe_keys(params):
202202
without leaking secrets to syslog.
203203
"""
204204
return sorted(
205-
'{0}=<redacted>'.format(k) if k in _SENSITIVE_KEYS else k
205+
f'{k}=<redacted>' if k in _SENSITIVE_KEYS else k
206206
for k in params
207207
)
208208

209209

210210
def _cache_key(endpoint, api_key, params):
211211
"""Stable hash of endpoint + api_key + params; truncated for filename use."""
212-
blob = '{e}|{k}|{p}'.format(
213-
e=endpoint, k=api_key,
214-
p=json.dumps(params or {}, sort_keys=True, default=str),
215-
)
212+
serialized = json.dumps(params or {}, sort_keys=True, default=str)
213+
blob = f'{endpoint}|{api_key}|{serialized}'
216214
return hashlib.sha256(blob.encode('utf-8')).hexdigest()[:16]
217215

218216

@@ -221,9 +219,10 @@ def _cache_path(endpoint, api_key, params):
221219
os.makedirs(CACHE_DIR, exist_ok=True)
222220
except OSError:
223221
return None
224-
return os.path.join(CACHE_DIR, '{e}-{h}.json'.format(
225-
e=endpoint, h=_cache_key(endpoint, api_key, params),
226-
))
222+
return os.path.join(
223+
CACHE_DIR,
224+
f'{endpoint}-{_cache_key(endpoint, api_key, params)}.json',
225+
)
227226

228227

229228
def _cache_read(endpoint, api_key, params):
@@ -286,11 +285,8 @@ def _request(module, api_key, endpoint, params, result_key):
286285
if endpoint in _CACHEABLE_GETS:
287286
cached = _cache_read(endpoint, api_key, params)
288287
if cached is not None:
289-
module.log('uptimerobot: cache HIT {endpoint} ({n} items, ttl={ttl}s)'.format(
290-
endpoint=endpoint,
291-
n=len(cached) if isinstance(cached, list) else 1,
292-
ttl=CACHE_TTL_SECONDS,
293-
))
288+
n = len(cached) if isinstance(cached, list) else 1
289+
module.log(f'uptimerobot: cache HIT {endpoint} ({n} items, ttl={CACHE_TTL_SECONDS}s)')
294290
return True, cached
295291

296292
success, result = _request_uncached(module, api_key, endpoint, params, result_key)
@@ -310,13 +306,10 @@ def _request_uncached(module, api_key, endpoint, params, result_key):
310306
body['api_key'] = api_key
311307
body['format'] = 'json'
312308

313-
module.log('uptimerobot: POST {endpoint} keys={keys}'.format(
314-
endpoint=endpoint, keys=_safe_keys(params),
315-
))
309+
module.log(f'uptimerobot: POST {endpoint} keys={_safe_keys(params)}')
316310

317311
aggregated = []
318312
offset = 0
319-
is_paginated_field = None
320313
pages = 0
321314

322315
while True:
@@ -341,9 +334,7 @@ def _request_uncached(module, api_key, endpoint, params, result_key):
341334
retry_after = int(info.get('retry-after') or DEFAULT_RATE_LIMIT_RETRY_SECONDS)
342335
sleep_for = min(retry_after, 60)
343336
module.warn(
344-
'uptimerobot: rate limited on {endpoint} (HTTP 429); sleeping {sec}s and retrying once'.format(
345-
endpoint=endpoint, sec=sleep_for,
346-
),
337+
f'uptimerobot: rate limited on {endpoint} (HTTP 429); sleeping {sleep_for}s and retrying once',
347338
)
348339
time.sleep(sleep_for)
349340
resp, info = fetch_url(
@@ -357,46 +348,31 @@ def _request_uncached(module, api_key, endpoint, params, result_key):
357348
status = info.get('status', -1)
358349

359350
if status < 200 or status >= 300:
360-
module.log('uptimerobot: POST {endpoint} -> HTTP {status}'.format(
361-
endpoint=endpoint, status=status,
362-
))
363-
return False, 'HTTP {status} from {url}: {msg}'.format(
364-
status=status,
365-
url=url,
366-
msg=info.get('msg') or info.get('body') or '',
367-
)
351+
module.log(f'uptimerobot: POST {endpoint} -> HTTP {status}')
352+
msg = info.get('msg') or info.get('body') or ''
353+
return False, f'HTTP {status} from {url}: {msg}'
368354

369355
try:
370356
payload = json.loads(resp.read().decode('utf-8'))
371357
except (ValueError, AttributeError) as exc:
372-
return False, 'Could not parse JSON from {url}: {exc}'.format(url=url, exc=exc)
358+
return False, f'Could not parse JSON from {url}: {exc}'
373359

374360
if payload.get('stat') != 'ok':
375361
err = payload.get('error') or {}
376-
module.log('uptimerobot: POST {endpoint} stat=fail type={type}'.format(
377-
endpoint=endpoint, type=err.get('type', 'unknown'),
378-
))
379-
return False, '{type}: {message}'.format(
380-
type=err.get('type', 'unknown'),
381-
message=err.get('message', payload),
382-
)
362+
module.log(f"uptimerobot: POST {endpoint} stat=fail type={err.get('type', 'unknown')}")
363+
return False, f"{err.get('type', 'unknown')}: {err.get('message', payload)}"
383364

384365
if payload.get(result_key) is None:
385366
# Some endpoints return only `stat: 'ok'` (e.g. delete, edit when no
386367
# detail is included). Fall back to the message field if present.
387-
module.log('uptimerobot: POST {endpoint} stat=ok (no {key} in payload)'.format(
388-
endpoint=endpoint, key=result_key,
389-
))
368+
module.log(f'uptimerobot: POST {endpoint} stat=ok (no {result_key} in payload)')
390369
return True, payload.get('message', payload)
391370

392371
item = payload[result_key]
393372
if isinstance(item, list):
394373
aggregated += item
395-
is_paginated_field = True
396374
else:
397-
module.log('uptimerobot: POST {endpoint} stat=ok pages={pages}'.format(
398-
endpoint=endpoint, pages=pages,
399-
))
375+
module.log(f'uptimerobot: POST {endpoint} stat=ok pages={pages}')
400376
return True, item
401377

402378
pagination = payload.get('pagination') or {}
@@ -407,10 +383,8 @@ def _request_uncached(module, api_key, endpoint, params, result_key):
407383
break
408384
offset += PAGE_SIZE
409385

410-
module.log('uptimerobot: POST {endpoint} stat=ok pages={pages} items={n}'.format(
411-
endpoint=endpoint, pages=pages, n=len(aggregated),
412-
))
413-
return True, aggregated if is_paginated_field else aggregated
386+
module.log(f'uptimerobot: POST {endpoint} stat=ok pages={pages} items={len(aggregated)}')
387+
return True, aggregated
414388

415389

416390
def _filter_keys(params, allowed):
@@ -481,6 +455,9 @@ def get_monitors(module, api_key, search=None):
481455
success, monitors = _request(module, api_key, 'getMonitors', params, 'monitors')
482456
if not success:
483457
return success, monitors
458+
if not isinstance(monitors, list):
459+
# an endpoint that returned only `stat: ok` yields the message fallback
460+
return True, monitors
484461
for item in monitors:
485462
_translate_monitor_response(item)
486463
return True, monitors
@@ -552,6 +529,8 @@ def get_mwindows(module, api_key):
552529
success, mwindows = _request(module, api_key, 'getMWindows', {}, 'mwindows')
553530
if not success:
554531
return success, mwindows
532+
if not isinstance(mwindows, list):
533+
return True, mwindows
555534
for item in mwindows:
556535
_translate_mwindow_response(item)
557536
return True, mwindows
@@ -616,6 +595,8 @@ def get_psps(module, api_key):
616595
success, psps = _request(module, api_key, 'getPSPs', {}, 'psps')
617596
if not success:
618597
return success, psps
598+
if not isinstance(psps, list):
599+
return True, psps
619600
for item in psps:
620601
_translate_psp_response(item)
621602
return True, psps
@@ -676,6 +657,8 @@ def get_alert_contacts(module, api_key):
676657
success, contacts = _request(module, api_key, 'getAlertContacts', {}, 'alert_contacts')
677658
if not success:
678659
return success, contacts
660+
if not isinstance(contacts, list):
661+
return True, contacts
679662
for item in contacts:
680663
_translate_alert_contact_response(item)
681664
return True, contacts
@@ -728,9 +711,7 @@ def resolve_friendly_names(items, names, kind):
728711
for name in names:
729712
if name not in by_name:
730713
raise ValueError(
731-
'{kind} with friendly_name {name!r} not found on UptimeRobot'.format(
732-
kind=kind, name=name,
733-
),
714+
f'{kind} with friendly_name {name!r} not found on UptimeRobot',
734715
)
735716
out.append(int(by_name[name]['id']))
736717
return out

plugins/modules/uptimerobot_account_info.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
#!/usr/bin/python
2-
# -*- coding: utf-8 -*-
3-
4-
# Copyright: (c) 2026, Linuxfabrik GmbH, Zurich, Switzerland, https://www.linuxfabrik.ch
5-
# The Unlicense (see LICENSE or https://unlicense.org/)
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8; py-indent-offset: 4 -*-
3+
#
4+
# Author: Linuxfabrik GmbH, Zurich, Switzerland
5+
# Contact: info (at) linuxfabrik (dot) ch
6+
# https://www.linuxfabrik.ch/
7+
# License: The Unlicense, see LICENSE file.
68

79
from __future__ import absolute_import, division, print_function
810

@@ -100,11 +102,12 @@ def main():
100102
module.log('uptimerobot_account_info: fetching account details')
101103
success, account = ur.get_account_details(module, api_key)
102104
if not success:
103-
module.fail_json(msg='Could not fetch UptimeRobot account details: {0}'.format(account))
104-
module.log('uptimerobot_account_info: monitor_limit={0} up={1} down={2} paused={3}'.format(
105-
account.get('monitor_limit'), account.get('up_monitors'),
106-
account.get('down_monitors'), account.get('paused_monitors'),
107-
))
105+
module.fail_json(msg=f'Could not fetch UptimeRobot account details: {account}')
106+
module.log(
107+
f"uptimerobot_account_info: monitor_limit={account.get('monitor_limit')} "
108+
f"up={account.get('up_monitors')} down={account.get('down_monitors')} "
109+
f"paused={account.get('paused_monitors')}"
110+
)
108111
module.exit_json(changed=False, account=account, debug={
109112
'operation': 'read',
110113
'fields': sorted(account.keys()) if isinstance(account, dict) else None,

plugins/modules/uptimerobot_alert_contact.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
#!/usr/bin/python
2-
# -*- coding: utf-8 -*-
3-
4-
# Copyright: (c) 2026, Linuxfabrik GmbH, Zurich, Switzerland, https://www.linuxfabrik.ch
5-
# The Unlicense (see LICENSE or https://unlicense.org/)
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8; py-indent-offset: 4 -*-
3+
#
4+
# Author: Linuxfabrik GmbH, Zurich, Switzerland
5+
# Contact: info (at) linuxfabrik (dot) ch
6+
# https://www.linuxfabrik.ch/
7+
# License: The Unlicense, see LICENSE file.
68

79
from __future__ import absolute_import, division, print_function
810

@@ -120,15 +122,13 @@ def main():
120122
contact_id = module.params.get('id')
121123
friendly_name = module.params.get('friendly_name')
122124

123-
module.log('uptimerobot_alert_contact: looking up id={0} friendly_name={1!r}'.format(
124-
contact_id, friendly_name,
125-
))
125+
module.log(f'uptimerobot_alert_contact: looking up id={contact_id} friendly_name={friendly_name!r}')
126126

127127
target = None
128128
if contact_id is None:
129129
success, contacts = ur.get_alert_contacts(module, api_key)
130130
if not success:
131-
module.fail_json(msg='Could not list alert contacts: {0}'.format(contacts))
131+
module.fail_json(msg=f'Could not list alert contacts: {contacts}')
132132
target = ur.find_by_friendly_name(contacts, friendly_name)
133133
if target is None:
134134
module.exit_json(changed=False, alert_contact={}, debug={
@@ -162,12 +162,13 @@ def main():
162162
'friendly_name': target.get('friendly_name'),
163163
})
164164

165-
module.log('uptimerobot_alert_contact: deleting id={0} friendly_name={1!r}'.format(
166-
contact_id, target.get('friendly_name'),
167-
))
165+
module.log(
166+
f"uptimerobot_alert_contact: deleting id={contact_id} "
167+
f"friendly_name={target.get('friendly_name')!r}"
168+
)
168169
success, result = ur.delete_alert_contact(module, api_key, contact_id)
169170
if not success:
170-
module.fail_json(msg='Could not delete alert contact {0!r}: {1}'.format(target, result))
171+
module.fail_json(msg=f'Could not delete alert contact {target!r}: {result}')
171172
module.exit_json(changed=True, alert_contact=target, debug={
172173
'operation': 'delete',
173174
'contact_id': contact_id,

plugins/modules/uptimerobot_alert_contact_info.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
#!/usr/bin/python
2-
# -*- coding: utf-8 -*-
3-
4-
# Copyright: (c) 2026, Linuxfabrik GmbH, Zurich, Switzerland, https://www.linuxfabrik.ch
5-
# The Unlicense (see LICENSE or https://unlicense.org/)
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8; py-indent-offset: 4 -*-
3+
#
4+
# Author: Linuxfabrik GmbH, Zurich, Switzerland
5+
# Contact: info (at) linuxfabrik (dot) ch
6+
# https://www.linuxfabrik.ch/
7+
# License: The Unlicense, see LICENSE file.
68

79
from __future__ import absolute_import, division, print_function
810

@@ -103,7 +105,7 @@ def main():
103105
module.log('uptimerobot_alert_contact_info: fetching alert contacts')
104106
success, contacts = ur.get_alert_contacts(module, api_key)
105107
if not success:
106-
module.fail_json(msg='Could not list alert contacts: {0}'.format(contacts))
108+
module.fail_json(msg=f'Could not list alert contacts: {contacts}')
107109

108110
if friendly_name:
109111
match = ur.find_by_friendly_name(contacts, friendly_name)

0 commit comments

Comments
 (0)