Skip to content

Commit ef6826d

Browse files
committed
Monitor.list()
1 parent de0c73c commit ef6826d

File tree

4 files changed

+555
-1
lines changed

4 files changed

+555
-1
lines changed

README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,55 @@ monitors = cronitor.Monitor.put([
233233
])
234234
```
235235

236+
### Listing and Inspecting Monitors
237+
238+
You can fetch multiple monitors using `Monitor.list()` with optional filtering and pagination:
239+
240+
```python
241+
import cronitor
242+
243+
# Fetch specific monitors by key
244+
monitors = cronitor.Monitor.list(['backup-job', 'health-check', 'send-invoices'])
245+
246+
# Fetch all job monitors (first page, 100 results by default)
247+
monitors = cronitor.Monitor.list(type='job')
248+
249+
# Fetch monitors with filters
250+
monitors = cronitor.Monitor.list(type='check', group='production', state='failing')
251+
252+
# Fetch a specific page
253+
monitors = cronitor.Monitor.list(type='job', page=2, pageSize=50)
254+
255+
# Fetch all pages automatically
256+
monitors = cronitor.Monitor.list(type='job', auto_paginate=True)
257+
258+
# Search monitors
259+
monitors = cronitor.Monitor.list(search='backup')
260+
```
261+
262+
After fetching a monitor, access its data using the `.data` property. Nested data is automatically accessible via attributes:
263+
264+
```python
265+
import cronitor
266+
267+
# Fetch an existing monitor
268+
monitor = cronitor.Monitor('send-invoices')
269+
270+
# Access monitor attributes
271+
print(monitor.data.name)
272+
print(monitor.data.type)
273+
print(monitor.data.schedule)
274+
275+
# Access nested data
276+
print(monitor.data.attributes.code)
277+
if monitor.data.latest_event:
278+
print(monitor.data.latest_event.stamp)
279+
print(monitor.data.latest_event.event)
280+
281+
# Pretty print the entire monitor as JSON
282+
print(monitor.data)
283+
```
284+
236285
### Pausing, Reseting, and Deleting
237286

238287
```python
@@ -297,11 +346,17 @@ Fork, then clone the repo:
297346
Set up your machine:
298347

299348
pip install -r requirements
349+
pip install pytest
300350

301351
Make sure the tests pass:
302352

303353
pytest
304354

355+
**Optional:** Run integration tests against the real Cronitor API:
356+
357+
export CRONITOR_TEST_API_KEY=your_api_key_here
358+
pytest cronitor/tests/test_integration.py -v
359+
305360
Make your change. Add tests for your change. Make the tests pass:
306361

307362
pytest

cronitor/monitor.py

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,16 @@ def __init__(self, key, api_key=None, api_version=None, env=None):
124124

125125
@property
126126
def data(self):
127+
"""
128+
Monitor data with attribute access. Nested dicts are automatically
129+
converted to Structs.
130+
131+
Example:
132+
>>> monitor = Monitor('my-monitor')
133+
>>> print(monitor.data.name)
134+
>>> print(monitor.data.request.url)
135+
>>> print(monitor.data) # Pretty JSON output
136+
"""
127137
if self._data and type(self._data) is not Struct:
128138
self._data = Struct(**self._data)
129139
elif not self._data:
@@ -199,6 +209,86 @@ def _clean_params(self, params):
199209
def _ping_api_url(self):
200210
return "https://cronitor.link/p/{}/{}".format(self.api_key, self.key)
201211

212+
@classmethod
213+
def list(cls, keys=None, page=1, pageSize=100, auto_paginate=False, **filters):
214+
"""
215+
Fetch monitors with optional filtering and pagination.
216+
217+
Args:
218+
keys: Optional list of monitor keys to fetch specifically
219+
page: Page number (default: 1)
220+
pageSize: Results per page (default: 100)
221+
auto_paginate: If True, automatically fetch all pages (default: False)
222+
**filters: type, group, tag, state, env, search, sort
223+
224+
Returns:
225+
List of Monitor instances
226+
227+
Examples:
228+
# Fetch specific monitors
229+
monitors = Monitor.list(['key1', 'key2'])
230+
231+
# Fetch first page of job monitors
232+
monitors = Monitor.list(type='job')
233+
234+
# Fetch specific page
235+
monitors = Monitor.list(type='job', page=2, pageSize=50)
236+
237+
# Fetch all pages automatically
238+
monitors = Monitor.list(type='job', auto_paginate=True)
239+
"""
240+
if keys:
241+
# Fetch specific monitors individually
242+
monitors = [cls(key) for key in keys]
243+
# Populate data immediately
244+
for m in monitors:
245+
_ = m.data # Triggers fetch
246+
return monitors
247+
248+
# Fetch from API with filters
249+
monitors = []
250+
current_page = page
251+
252+
while True:
253+
result = cls._fetch_page(current_page, pageSize, **filters)
254+
monitors.extend(result)
255+
256+
if not auto_paginate or len(result) < pageSize:
257+
# Either not auto-paginating or no more results
258+
break
259+
260+
current_page += 1
261+
262+
return monitors
263+
264+
@classmethod
265+
def _fetch_page(cls, page, pageSize, **filters):
266+
"""Fetch a single page of monitors from the API"""
267+
api_key = filters.pop('api_key', None) or cronitor.api_key
268+
api_version = filters.pop('api_version', None) or cronitor.api_version
269+
timeout = cronitor.timeout or 10
270+
271+
params = dict(filters, page=page, pageSize=pageSize)
272+
273+
resp = cls._req.get(
274+
cls._monitor_api_url(),
275+
auth=(api_key, ''),
276+
params=params,
277+
headers=dict(cls._headers, **{'Cronitor-Version': api_version}),
278+
timeout=timeout
279+
)
280+
281+
if resp.status_code == 200:
282+
data = resp.json()
283+
monitors = []
284+
for monitor_data in data.get('monitors', []):
285+
m = cls(monitor_data['key'])
286+
m.data = monitor_data
287+
monitors.append(m)
288+
return monitors
289+
else:
290+
raise cronitor.APIError("Unexpected error %s" % resp.text)
291+
202292
@classmethod
203293
def _monitor_api_url(cls, key=None):
204294
if not key: return "https://cronitor.io/api/monitors"
@@ -217,4 +307,27 @@ def _prepare_payload(monitors, rollback=False, request_format=JSON):
217307

218308
class Struct(object):
219309
def __init__(self, **kwargs):
220-
self.__dict__.update(kwargs)
310+
for key, value in kwargs.items():
311+
if isinstance(value, dict):
312+
value = Struct(**value)
313+
elif isinstance(value, list):
314+
value = [Struct(**item) if isinstance(item, dict) else item for item in value]
315+
setattr(self, key, value)
316+
317+
def __repr__(self):
318+
items = ', '.join(f'{k}={v!r}' for k, v in sorted(self.__dict__.items()))
319+
return f"Struct({items})"
320+
321+
def __str__(self):
322+
return json.dumps(self._to_dict(), indent=2, sort_keys=True, default=str)
323+
324+
def _to_dict(self):
325+
result = {}
326+
for key, value in self.__dict__.items():
327+
if isinstance(value, Struct):
328+
result[key] = value._to_dict()
329+
elif isinstance(value, list):
330+
result[key] = [item._to_dict() if isinstance(item, Struct) else item for item in value]
331+
else:
332+
result[key] = value
333+
return result

cronitor/tests/test_integration.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""
2+
Integration tests that make real API calls to Cronitor.
3+
4+
These tests are skipped by default unless CRONITOR_TEST_API_KEY environment
5+
variable is set. They test against the real Cronitor API to verify behavior.
6+
7+
Usage:
8+
export CRONITOR_TEST_API_KEY=your_api_key_here
9+
python -m pytest cronitor/tests/test_integration.py -v
10+
# or
11+
python -m unittest cronitor.tests.test_integration -v
12+
"""
13+
14+
import os
15+
import unittest
16+
import cronitor
17+
18+
19+
# Check if integration tests should run
20+
INTEGRATION_API_KEY = os.getenv('CRONITOR_TEST_API_KEY')
21+
SKIP_INTEGRATION = not INTEGRATION_API_KEY
22+
SKIP_REASON = "Set CRONITOR_TEST_API_KEY environment variable to run integration tests"
23+
24+
25+
@unittest.skipIf(SKIP_INTEGRATION, SKIP_REASON)
26+
class MonitorListIntegrationTests(unittest.TestCase):
27+
"""Integration tests for Monitor.list() against real API"""
28+
29+
@classmethod
30+
def setUpClass(cls):
31+
"""Set up API key for all tests"""
32+
cronitor.api_key = INTEGRATION_API_KEY
33+
34+
def test_list_all_monitors(self):
35+
"""Test listing all monitors (first page)"""
36+
monitors = cronitor.Monitor.list()
37+
38+
# Should return a list (may be empty for new accounts)
39+
self.assertIsInstance(monitors, list)
40+
41+
# If there are monitors, verify structure
42+
if len(monitors) > 0:
43+
monitor = monitors[0]
44+
self.assertIsInstance(monitor, cronitor.Monitor)
45+
self.assertIsNotNone(monitor.data.key)
46+
self.assertIsNotNone(monitor.data.name)
47+
print(f"\n✓ Found {len(monitors)} monitors on first page (default pageSize=100)")
48+
print(f" First monitor: {monitor.data.name} ({monitor.data.key})")
49+
50+
def test_list_all_monitors_auto_paginate(self):
51+
"""Test listing all monitors with auto_paginate"""
52+
monitors_all = cronitor.Monitor.list(auto_paginate=True)
53+
54+
self.assertIsInstance(monitors_all, list)
55+
56+
# Check if there were multiple pages
57+
monitors_page1 = cronitor.Monitor.list(pageSize=100)
58+
if len(monitors_all) > 100:
59+
print(f"\n✓ Auto-paginate fetched all {len(monitors_all)} monitors across multiple pages")
60+
print(f" First page had {len(monitors_page1)}, total is {len(monitors_all)}")
61+
else:
62+
print(f"\n✓ Auto-paginate fetched {len(monitors_all)} monitors (all fit in one page)")
63+
64+
def test_list_with_pagination(self):
65+
"""Test listing monitors with specific page size"""
66+
monitors = cronitor.Monitor.list(pageSize=5)
67+
68+
self.assertIsInstance(monitors, list)
69+
# Should return at most 5 monitors
70+
self.assertLessEqual(len(monitors), 5)
71+
print(f"\n✓ Pagination works, got {len(monitors)} monitors (max 5)")
72+
73+
def test_list_with_filter(self):
74+
"""Test listing monitors with type filter"""
75+
monitors = cronitor.Monitor.list(type='job')
76+
77+
self.assertIsInstance(monitors, list)
78+
79+
# Verify all returned monitors are jobs
80+
for monitor in monitors:
81+
self.assertEqual(monitor.data.type, 'job')
82+
83+
print(f"\n✓ Filter works, got {len(monitors)} job monitors")
84+
85+
def test_list_with_search(self):
86+
"""Test listing monitors with search parameter"""
87+
monitors = cronitor.Monitor.list(search='test job')
88+
89+
self.assertIsInstance(monitors, list)
90+
91+
# Should return monitors matching search term
92+
if len(monitors) > 0:
93+
print(f"\n✓ Search works, found {len(monitors)} monitors matching 'test job'")
94+
for monitor in monitors[:3]: # Show first 3
95+
print(f" - {monitor.data.name} ({monitor.data.key})")
96+
else:
97+
print(f"\n✓ Search works, found 0 monitors matching 'test job'")
98+
99+
def test_list_specific_keys(self):
100+
"""Test listing specific monitors by key"""
101+
# First get some monitors to test with
102+
all_monitors = cronitor.Monitor.list(pageSize=2)
103+
104+
if len(all_monitors) == 0:
105+
self.skipTest("No monitors found in account")
106+
107+
# Get keys to fetch
108+
keys_to_fetch = [m.data.key for m in all_monitors[:min(2, len(all_monitors))]]
109+
110+
# Fetch them specifically
111+
monitors = cronitor.Monitor.list(keys_to_fetch)
112+
113+
self.assertEqual(len(monitors), len(keys_to_fetch))
114+
returned_keys = [m.data.key for m in monitors]
115+
self.assertEqual(set(returned_keys), set(keys_to_fetch))
116+
117+
print(f"\n✓ Fetched specific monitors: {', '.join(keys_to_fetch)}")
118+
119+
def test_monitor_data_structure(self):
120+
"""Test that monitor data structure is correct"""
121+
monitors = cronitor.Monitor.list(pageSize=1)
122+
123+
if len(monitors) == 0:
124+
self.skipTest("No monitors found in account")
125+
126+
monitor = monitors[0]
127+
128+
# Test basic fields exist
129+
self.assertIsNotNone(monitor.data.key)
130+
self.assertIsNotNone(monitor.data.name)
131+
self.assertIsNotNone(monitor.data.type)
132+
133+
# Test nested attribute access works
134+
self.assertIsNotNone(monitor.data.attributes)
135+
self.assertIsNotNone(monitor.data.attributes.code)
136+
137+
# Test pretty printing works
138+
json_str = str(monitor.data)
139+
self.assertIn(monitor.data.key, json_str)
140+
self.assertIn('\n', json_str) # Pretty formatted
141+
142+
print(f"\n✓ Monitor data structure correct")
143+
print(f" Key: {monitor.data.key}")
144+
print(f" Name: {monitor.data.name}")
145+
print(f" Type: {monitor.data.type}")
146+
147+
148+
if __name__ == '__main__':
149+
if SKIP_INTEGRATION:
150+
print(f"\n⚠️ {SKIP_REASON}\n")
151+
print("Example:")
152+
print(" export CRONITOR_TEST_API_KEY=your_api_key_here")
153+
print(" python -m unittest cronitor.tests.test_integration -v\n")
154+
else:
155+
print(f"\n🚀 Running integration tests against Cronitor API...\n")
156+
unittest.main()

0 commit comments

Comments
 (0)