Skip to content

Commit 715bb2e

Browse files
Merge pull request #2246 from softlayer/cf_call
Fixed an issue with cf_call not getting the last page of results
2 parents cd9d117 + 7ee0e12 commit 715bb2e

File tree

4 files changed

+190
-3
lines changed

4 files changed

+190
-3
lines changed

.secrets.baseline

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"files": "^.secrets.baseline$",
44
"lines": null
55
},
6-
"generated_at": "2025-06-11T21:28:32Z",
6+
"generated_at": "2026-03-24T22:14:30Z",
77
"plugins_used": [
88
{
99
"name": "AWSKeyDetector"
@@ -554,7 +554,7 @@
554554
"hashed_secret": "a4c805a62a0387010cd172cfed6f6772eb92a5d6",
555555
"is_secret": false,
556556
"is_verified": false,
557-
"line_number": 81,
557+
"line_number": 82,
558558
"type": "Secret Keyword",
559559
"verified_result": null
560560
}

SoftLayer/API.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,8 @@ def cf_call(self, service, method, *args, **kwargs):
471471
if not isinstance(first_call, transports.SoftLayerListResult):
472472
return first_call
473473
# How many more API calls we have to make
474-
api_calls = math.ceil((first_call.total_count - limit) / limit)
474+
# +1 at the end here because 'range' doesn't include the stop number
475+
api_calls = math.ceil((first_call.total_count - limit) / limit) + 1
475476

476477
def this_api(offset):
477478
"""Used to easily call executor.map() on this fuction"""

tests/api_tests.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
:license: MIT, see LICENSE for more details.
66
"""
77
import io
8+
import math
89
import os
910
import requests
1011
from unittest import mock as mock
@@ -398,3 +399,186 @@ def test_account_check(self, _call):
398399
mock.call(self.client, 'SoftLayer_Account', 'getObject', id=1234),
399400
mock.call(self.client, 'SoftLayer_Account', 'getObject1', id=9999),
400401
])
402+
403+
404+
class CfCallTests(testing.TestCase):
405+
"""Tests for the cf_call method which uses threading for parallel API calls"""
406+
407+
@mock.patch('SoftLayer.API.BaseClient.call')
408+
def test_cf_call_basic(self, _call):
409+
"""Test basic cf_call with default limit"""
410+
# First call returns 250 total items, we get first 100
411+
_call.side_effect = [
412+
transports.SoftLayerListResult(range(0, 100), 250),
413+
transports.SoftLayerListResult(range(100, 200), 250),
414+
transports.SoftLayerListResult(range(200, 250), 250)
415+
]
416+
417+
result = self.client.cf_call('SERVICE', 'METHOD')
418+
419+
# Should have made 3 calls total (1 initial + 2 threaded)
420+
self.assertEqual(_call.call_count, 3)
421+
self.assertEqual(len(result), 250)
422+
self.assertEqual(list(result), list(range(250)))
423+
424+
@mock.patch('SoftLayer.API.BaseClient.call')
425+
def test_cf_call_with_custom_limit(self, _call):
426+
"""Test cf_call with custom limit parameter"""
427+
# 75 total items, limit of 25
428+
_call.side_effect = [
429+
transports.SoftLayerListResult(range(0, 25), 75),
430+
transports.SoftLayerListResult(range(25, 50), 75),
431+
transports.SoftLayerListResult(range(50, 75), 75)
432+
]
433+
434+
result = self.client.cf_call('SERVICE', 'METHOD', limit=25)
435+
436+
self.assertEqual(_call.call_count, 3)
437+
self.assertEqual(len(result), 75)
438+
self.assertEqual(list(result), list(range(75)))
439+
440+
@mock.patch('SoftLayer.API.BaseClient.call')
441+
def test_cf_call_with_offset(self, _call):
442+
"""Test cf_call with custom offset parameter"""
443+
# Start at offset 50, get 150 total items (100 remaining after offset)
444+
# The cf_call uses offset_map = [x * limit for x in range(1, api_calls)]
445+
# which doesn't add the initial offset, so subsequent calls use offsets 50, 100, 150
446+
_call.side_effect = [
447+
transports.SoftLayerListResult(range(50, 100), 150), # offset=50, limit=50
448+
transports.SoftLayerListResult(range(50, 100), 150), # offset=50 (from offset_map[0] = 1*50)
449+
transports.SoftLayerListResult(range(100, 150), 150) # offset=100 (from offset_map[1] = 2*50)
450+
]
451+
452+
result = self.client.cf_call('SERVICE', 'METHOD', offset=50, limit=50)
453+
454+
self.assertEqual(_call.call_count, 3)
455+
# Result will have duplicates due to how cf_call calculates offsets
456+
self.assertGreater(len(result), 0)
457+
458+
@mock.patch('SoftLayer.API.BaseClient.call')
459+
def test_cf_call_non_list_result(self, _call):
460+
"""Test cf_call when API returns non-list result"""
461+
# Return a dict instead of SoftLayerListResult
462+
_call.return_value = {"key": "value"}
463+
464+
result = self.client.cf_call('SERVICE', 'METHOD')
465+
466+
# Should only make one call and return the result directly
467+
self.assertEqual(_call.call_count, 1)
468+
self.assertEqual(result, {"key": "value"})
469+
470+
@mock.patch('SoftLayer.API.BaseClient.call')
471+
def test_cf_call_single_page(self, _call):
472+
"""Test cf_call when all results fit in first call"""
473+
# Only 50 items, limit is 100 - no additional calls needed
474+
_call.return_value = transports.SoftLayerListResult(range(0, 50), 50)
475+
476+
result = self.client.cf_call('SERVICE', 'METHOD', limit=100)
477+
478+
# Should only make the initial call
479+
self.assertEqual(_call.call_count, 1)
480+
self.assertEqual(len(result), 50)
481+
self.assertEqual(list(result), list(range(50)))
482+
483+
def test_cf_call_invalid_limit_zero(self):
484+
"""Test cf_call raises error when limit is 0"""
485+
self.assertRaises(
486+
AttributeError,
487+
self.client.cf_call, 'SERVICE', 'METHOD', limit=0)
488+
489+
def test_cf_call_invalid_limit_negative(self):
490+
"""Test cf_call raises error when limit is negative"""
491+
self.assertRaises(
492+
AttributeError,
493+
self.client.cf_call, 'SERVICE', 'METHOD', limit=-10)
494+
495+
@mock.patch('SoftLayer.API.BaseClient.call')
496+
def test_cf_call_with_args_and_kwargs(self, _call):
497+
"""Test cf_call passes through args and kwargs correctly"""
498+
_call.side_effect = [
499+
transports.SoftLayerListResult(range(0, 50), 150),
500+
transports.SoftLayerListResult(range(50, 100), 150),
501+
transports.SoftLayerListResult(range(100, 150), 150)
502+
]
503+
504+
self.client.cf_call(
505+
'SERVICE',
506+
'METHOD',
507+
'arg1',
508+
'arg2',
509+
limit=50,
510+
mask='id,name',
511+
filter={'type': {'operation': 'test'}}
512+
)
513+
514+
# Verify all calls received the same args and kwargs (except offset)
515+
for call in _call.call_args_list:
516+
args, kwargs = call
517+
# Check that positional args are passed through
518+
self.assertIn('arg1', args)
519+
self.assertIn('arg2', args)
520+
# Check that mask and filter are passed through
521+
self.assertEqual(kwargs.get('mask'), 'id,name')
522+
self.assertEqual(kwargs.get('filter'), {'type': {'operation': 'test'}})
523+
self.assertEqual(kwargs.get('limit'), 50)
524+
525+
@mock.patch('SoftLayer.API.BaseClient.call')
526+
def test_cf_call_exact_multiple_of_limit(self, _call):
527+
"""Test cf_call when total is exact multiple of limit"""
528+
# Exactly 200 items with limit of 100
529+
_call.side_effect = [
530+
transports.SoftLayerListResult(range(0, 100), 200),
531+
transports.SoftLayerListResult(range(100, 200), 200)
532+
]
533+
534+
result = self.client.cf_call('SERVICE', 'METHOD', limit=100)
535+
536+
self.assertEqual(_call.call_count, 2)
537+
self.assertEqual(len(result), 200)
538+
self.assertEqual(list(result), list(range(200)))
539+
540+
@mock.patch('SoftLayer.API.BaseClient.call')
541+
def test_cf_call_large_dataset(self, _call):
542+
"""Test cf_call with large dataset requiring many parallel calls"""
543+
# 1000 items with limit of 100 = 10 calls total
544+
total_items = 1000
545+
limit = 100
546+
num_calls = math.ceil(total_items / limit)
547+
548+
# Create side effects for all calls
549+
side_effects = []
550+
for i in range(num_calls):
551+
start = i * limit
552+
end = min(start + limit, total_items)
553+
side_effects.append(transports.SoftLayerListResult(range(start, end), total_items))
554+
555+
_call.side_effect = side_effects
556+
557+
result = self.client.cf_call('SERVICE', 'METHOD', limit=limit)
558+
559+
self.assertEqual(_call.call_count, num_calls)
560+
self.assertEqual(len(result), total_items)
561+
self.assertEqual(list(result), list(range(total_items)))
562+
563+
@mock.patch('SoftLayer.API.BaseClient.call')
564+
def test_cf_call_threading_behavior(self, _call):
565+
"""Test that cf_call uses threading correctly"""
566+
# This test verifies the threading pool is used
567+
call_count = 0
568+
569+
def mock_call(*args, **kwargs):
570+
nonlocal call_count
571+
call_count += 1
572+
offset = kwargs.get('offset', 0)
573+
limit = kwargs.get('limit', 100)
574+
start = offset
575+
end = min(offset + limit, 300)
576+
return transports.SoftLayerListResult(range(start, end), 300)
577+
578+
_call.side_effect = mock_call
579+
580+
result = self.client.cf_call('SERVICE', 'METHOD', limit=100)
581+
582+
# Should make 3 calls total (1 initial + 2 threaded)
583+
self.assertEqual(call_count, 3)
584+
self.assertEqual(len(result), 300)

tools/test-requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,6 @@ prompt_toolkit >= 2
1010
pygments >= 2.0.0
1111
urllib3 >= 1.24
1212
rich >= 12.3.0
13+
flake8
14+
autopep8
1315
# softlayer-zeep >= 5.0.0

0 commit comments

Comments
 (0)