diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index f09354735..c9eb15e54 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.11] + python-version: [3.14] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 95afeced1..83a01c0ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,10 +18,10 @@ jobs: id-token: write steps: - uses: actions/checkout@v4 - - name: Set up Python 3.11 + - name: Set up Python 3.14 uses: actions/setup-python@v4 with: - python-version: 3.11 + python-version: 3.14 - name: Install pypa/build run: >- python -m diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 35ae72725..3dca03b03 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.8,3.9,'3.10',3.11,3.12] + python-version: ['3.10',3.11,3.12,3.13,3.14] steps: - uses: actions/checkout@v4 @@ -31,7 +31,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: 3.12 + python-version: 3.14 - name: Install dependencies run: | python -m pip install --upgrade pip @@ -45,7 +45,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: 3.12 + python-version: 3.14 - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.gitignore b/.gitignore index 5dd1975be..72fd99883 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,9 @@ dist/* .cache .idea .pytest_cache/* +.vscode/* + +techbabble.xyz.crt +techbabble.xyz.csr +techbabble.xyz.icc +techbabble.xyz.key diff --git a/.secrets.baseline b/.secrets.baseline index f0aee0650..99a7c5ac2 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "^.secrets.baseline$", "lines": null }, - "generated_at": "2025-06-11T21:28:32Z", + "generated_at": "2026-03-24T22:14:30Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -554,7 +554,7 @@ "hashed_secret": "a4c805a62a0387010cd172cfed6f6772eb92a5d6", "is_secret": false, "is_verified": false, - "line_number": 81, + "line_number": 82, "type": "Secret Keyword", "verified_result": null } diff --git a/SoftLayer/API.py b/SoftLayer/API.py index e3766de4f..17fec6d94 100644 --- a/SoftLayer/API.py +++ b/SoftLayer/API.py @@ -471,7 +471,8 @@ def cf_call(self, service, method, *args, **kwargs): if not isinstance(first_call, transports.SoftLayerListResult): return first_call # How many more API calls we have to make - api_calls = math.ceil((first_call.total_count - limit) / limit) + # +1 at the end here because 'range' doesn't include the stop number + api_calls = math.ceil((first_call.total_count - limit) / limit) + 1 def this_api(offset): """Used to easily call executor.map() on this fuction""" diff --git a/SoftLayer/CLI/environment.py b/SoftLayer/CLI/environment.py index d5b8f584b..b1284ebfa 100644 --- a/SoftLayer/CLI/environment.py +++ b/SoftLayer/CLI/environment.py @@ -115,11 +115,14 @@ def getpass(self, prompt, default=None): if password == 'àR': # tkinter is a built in python gui, but it has clipboard reading functions. # pylint: disable=import-outside-toplevel - from tkinter import Tk - tk_manager = Tk() - password = tk_manager.clipboard_get() - # keep the window from showing - tk_manager.withdraw() + try: + from tkinter import Tk + tk_manager = Tk() + password = tk_manager.clipboard_get() + # keep the window from showing + tk_manager.withdraw() + except ImportError: + return password return password # Command loading methods diff --git a/SoftLayer/consts.py b/SoftLayer/consts.py index 25c00902f..fdfe1739b 100644 --- a/SoftLayer/consts.py +++ b/SoftLayer/consts.py @@ -5,7 +5,7 @@ :license: MIT, see LICENSE for more details. """ -VERSION = 'v6.2.7' +VERSION = 'v6.2.9' API_PUBLIC_ENDPOINT = 'https://api.softlayer.com/xmlrpc/v3.1/' API_PRIVATE_ENDPOINT = 'https://api.service.softlayer.com/xmlrpc/v3.1/' API_PUBLIC_ENDPOINT_REST = 'https://api.softlayer.com/rest/v3.1/' diff --git a/docs/requirements.txt b/docs/requirements.txt index ed9557af2..1a622081c 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,6 @@ -sphinx_rtd_theme==3.0.2 -sphinx==8.2.3 -sphinx-click==6.0.0 +sphinx_rtd_theme==3.1.0 +sphinx==9.1.0 +sphinx-click==6.2.0 click rich diff --git a/setup.py b/setup.py index ec0f02c66..191010c0b 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ setup( name='SoftLayer', - version='v6.2.7', + version='v6.3.0', description=DESCRIPTION, long_description=LONG_DESCRIPTION, long_description_content_type='text/x-rst', @@ -30,14 +30,14 @@ 'slcli = SoftLayer.CLI.core:main', ], }, - python_requires='>=3.7', + python_requires='>=3.10', install_requires=[ 'click >= 8.0.4', 'requests >= 2.32.2', 'prompt_toolkit >= 2', 'pygments >= 2.0.0', 'urllib3 >= 1.24', - 'rich == 14.1.0' + 'rich == 15.0.0' ], keywords=['softlayer', 'cloud', 'slcli', 'ibmcloud'], classifiers=[ @@ -48,12 +48,10 @@ 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Topic :: Software Development :: Libraries :: Python Modules', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.14', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', ], diff --git a/tests/CLI/environment_tests.py b/tests/CLI/environment_tests.py index e0b739b1a..49eded8fb 100644 --- a/tests/CLI/environment_tests.py +++ b/tests/CLI/environment_tests.py @@ -6,12 +6,20 @@ """ import click +import pytest from unittest import mock as mock from SoftLayer.CLI import environment from SoftLayer.CLI import formatting from SoftLayer import testing +# Check if tkinter is available +try: + import tkinter # noqa: F401 + TKINTER_AVAILABLE = True +except ImportError: + TKINTER_AVAILABLE = False + @click.command() def fixture_command(): @@ -54,6 +62,7 @@ def test_getpass(self, prompt_mock): prompt_mock.assert_called_with('input', default=None, hide_input=True) self.assertEqual(prompt_mock(), r) + @pytest.mark.skipif(not TKINTER_AVAILABLE, reason="tkinter module not available") @mock.patch('click.prompt') @mock.patch('tkinter.Tk') def test_getpass_issues1436(self, tk, prompt_mock): diff --git a/tests/CLI/modules/vs/vs_create_tests.py b/tests/CLI/modules/vs/vs_create_tests.py index 06ea63d95..15f1315e5 100644 --- a/tests/CLI/modules/vs/vs_create_tests.py +++ b/tests/CLI/modules/vs/vs_create_tests.py @@ -382,10 +382,13 @@ def test_create_like(self, confirm_mock): } confirm_mock.return_value = True + # Prompts to confirm hostname, domain, and datacenter. + stdin = "\n\n\n" result = self.run_command(['vs', 'create', '--like=123', '--san', - '--billing=hourly']) + '--billing=hourly'], + stdin=stdin) self.assert_no_fail(result) self.assertIn('"guid": "1a2b3c-1701"', result.output) @@ -426,10 +429,13 @@ def test_create_like_tags(self, confirm_mock): } confirm_mock.return_value = True + # Prompts to confirm hostname, domain, and datacenter. + stdin = "\n\n\n" result = self.run_command(['vs', 'create', '--like=123', '--san', - '--billing=hourly']) + '--billing=hourly'], + stdin=stdin) self.assert_no_fail(result) self.assertIn('"guid": "1a2b3c-1701"', result.output) @@ -457,10 +463,13 @@ def test_create_like_image(self, confirm_mock): } confirm_mock.return_value = True + # Prompts to confirm hostname, domain, and datacenter. + stdin = "\n\n\n" result = self.run_command(['vs', 'create', '--like=123', '--san', - '--billing=hourly']) + '--billing=hourly'], + stdin=stdin) self.assert_no_fail(result) self.assertIn('"guid": "1a2b3c-1701"', result.output) @@ -499,7 +508,9 @@ def test_create_like_flavor(self, confirm_mock): } confirm_mock.return_value = True - result = self.run_command(['vs', 'create', '--like=123']) + # Prompts to confirm hostname, domain, and datacenter. + stdin = "\n\n\n" + result = self.run_command(['vs', 'create', '--like=123'], stdin=stdin) self.assert_no_fail(result) self.assertIn('"guid": "1a2b3c-1701"', result.output) @@ -540,7 +551,9 @@ def test_create_like_transient(self, confirm_mock): } confirm_mock.return_value = True - result = self.run_command(['vs', 'create', '--like=123']) + # Prompts to confirm hostname, domain, and datacenter. + stdin = "\n\n\n" + result = self.run_command(['vs', 'create', '--like=123'], stdin=stdin) self.assert_no_fail(result) self.assertIn('"guid": "1a2b3c-1701"', result.output) diff --git a/tests/api_tests.py b/tests/api_tests.py index 9c78a73fa..13a76a2d4 100644 --- a/tests/api_tests.py +++ b/tests/api_tests.py @@ -5,6 +5,7 @@ :license: MIT, see LICENSE for more details. """ import io +import math import os import requests from unittest import mock as mock @@ -398,3 +399,188 @@ def test_account_check(self, _call): mock.call(self.client, 'SoftLayer_Account', 'getObject', id=1234), mock.call(self.client, 'SoftLayer_Account', 'getObject1', id=9999), ]) + + +class CfCallTests(testing.TestCase): + """Tests for the cf_call method which uses threading for parallel API calls""" + + @mock.patch('SoftLayer.API.BaseClient.call') + def test_cf_call_basic(self, _call): + """Test basic cf_call with default limit""" + # First call returns 250 total items, we get first 100 + _call.side_effect = [ + transports.SoftLayerListResult(range(0, 100), 250), + transports.SoftLayerListResult(range(100, 200), 250), + transports.SoftLayerListResult(range(200, 250), 250) + ] + + result = self.client.cf_call('SERVICE', 'METHOD') + + # Should have made 3 calls total (1 initial + 2 threaded) + self.assertEqual(_call.call_count, 3) + self.assertEqual(len(result), 250) + self.assertEqual(list(result), list(range(250))) + + @mock.patch('SoftLayer.API.BaseClient.call') + def test_cf_call_with_custom_limit(self, _call): + """Test cf_call with custom limit parameter""" + # 75 total items, limit of 25 + _call.side_effect = [ + transports.SoftLayerListResult(range(0, 25), 75), + transports.SoftLayerListResult(range(25, 50), 75), + transports.SoftLayerListResult(range(50, 75), 75) + ] + + result = self.client.cf_call('SERVICE', 'METHOD', limit=25) + + self.assertEqual(_call.call_count, 3) + self.assertEqual(len(result), 75) + self.assertEqual(list(result), list(range(75))) + + @mock.patch('SoftLayer.API.BaseClient.call') + def test_cf_call_with_offset(self, _call): + """Test cf_call with custom offset parameter""" + # Start at offset 50, get 150 total items (100 remaining after offset) + # The cf_call uses offset_map = [x * limit for x in range(1, api_calls)] + # which doesn't add the initial offset, so subsequent calls use offsets 50, 100, 150 + _call.side_effect = [ + transports.SoftLayerListResult(range(50, 100), 150), # offset=50, limit=50 + transports.SoftLayerListResult(range(50, 100), 150), # offset=50 (from offset_map[0] = 1*50) + transports.SoftLayerListResult(range(100, 150), 150) # offset=100 (from offset_map[1] = 2*50) + ] + + result = self.client.cf_call('SERVICE', 'METHOD', offset=50, limit=50) + + self.assertEqual(_call.call_count, 3) + # Result will have duplicates due to how cf_call calculates offsets + self.assertGreater(len(result), 0) + + @mock.patch('SoftLayer.API.BaseClient.call') + def test_cf_call_non_list_result(self, _call): + """Test cf_call when API returns non-list result""" + # Return a dict instead of SoftLayerListResult + _call.return_value = {"key": "value"} + + result = self.client.cf_call('SERVICE', 'METHOD') + + # Should only make one call and return the result directly + self.assertEqual(_call.call_count, 1) + self.assertEqual(result, {"key": "value"}) + + @mock.patch('SoftLayer.API.BaseClient.call') + def test_cf_call_single_page(self, _call): + """Test cf_call when all results fit in first call""" + # Only 50 items, limit is 100 - no additional calls needed + _call.return_value = transports.SoftLayerListResult(range(0, 50), 50) + + result = self.client.cf_call('SERVICE', 'METHOD', limit=100) + + # Should only make the initial call + self.assertEqual(_call.call_count, 1) + self.assertEqual(len(result), 50) + self.assertEqual(list(result), list(range(50))) + + def test_cf_call_invalid_limit_zero(self): + """Test cf_call raises error when limit is 0""" + self.assertRaises( + AttributeError, + self.client.cf_call, 'SERVICE', 'METHOD', limit=0) + + def test_cf_call_invalid_limit_negative(self): + """Test cf_call raises error when limit is negative""" + self.assertRaises( + AttributeError, + self.client.cf_call, 'SERVICE', 'METHOD', limit=-10) + + @mock.patch('SoftLayer.API.BaseClient.call') + def test_cf_call_with_args_and_kwargs(self, _call): + """Test cf_call passes through args and kwargs correctly""" + _call.side_effect = [ + transports.SoftLayerListResult(range(0, 50), 150), + transports.SoftLayerListResult(range(50, 100), 150), + transports.SoftLayerListResult(range(100, 150), 150) + ] + + self.client.cf_call( + 'SERVICE', + 'METHOD', + 'arg1', + 'arg2', + limit=50, + mask='id,name', + filter={'type': {'operation': 'test'}} + ) + + # Verify all calls received the same args and kwargs (except offset) + for call in _call.call_args_list: + args, kwargs = call + # Check that positional args are passed through + self.assertIn('arg1', args) + self.assertIn('arg2', args) + # Check that mask and filter are passed through + self.assertEqual(kwargs.get('mask'), 'id,name') + self.assertEqual(kwargs.get('filter'), {'type': {'operation': 'test'}}) + self.assertEqual(kwargs.get('limit'), 50) + + @mock.patch('SoftLayer.API.BaseClient.call') + def test_cf_call_exact_multiple_of_limit(self, _call): + """Test cf_call when total is exact multiple of limit""" + # Exactly 200 items with limit of 100 + _call.side_effect = [ + transports.SoftLayerListResult(range(0, 100), 200), + transports.SoftLayerListResult(range(100, 200), 200) + ] + + result = self.client.cf_call('SERVICE', 'METHOD', limit=100) + + self.assertEqual(_call.call_count, 2) + self.assertEqual(len(result), 200) + self.assertEqual(list(result), list(range(200))) + + @mock.patch('SoftLayer.API.BaseClient.call') + def test_cf_call_large_dataset(self, _call): + """Test cf_call with large dataset requiring many parallel calls""" + # 1000 items with limit of 100 = 10 calls total + total_items = 1000 + limit = 100 + num_calls = math.ceil(total_items / limit) + + # Create side effects for all calls + side_effects = [] + for i in range(num_calls): + start = i * limit + end = min(start + limit, total_items) + side_effects.append(transports.SoftLayerListResult(range(start, end), total_items)) + + _call.side_effect = side_effects + + result = self.client.cf_call('SERVICE', 'METHOD', limit=limit) + # sort the results to ensure they are in order + result = sorted(result) + + self.assertEqual(_call.call_count, num_calls) + self.assertEqual(len(result), total_items) + self.assertEqual(list(result), list(range(total_items))) + + @mock.patch('SoftLayer.API.BaseClient.call') + def test_cf_call_threading_behavior(self, _call): + """Test that cf_call uses threading correctly""" + # This test verifies the threading pool is used + call_count = 0 + + def mock_call(*args, **kwargs): + nonlocal call_count + call_count += 1 + offset = kwargs.get('offset', 0) + limit = kwargs.get('limit', 100) + start = offset + end = min(offset + limit, 300) + return transports.SoftLayerListResult(range(start, end), 300) + + _call.side_effect = mock_call + + result = self.client.cf_call('SERVICE', 'METHOD', limit=100) + + # Should make 3 calls total (1 initial + 2 threaded) + self.assertEqual(call_count, 3) + self.assertEqual(len(result), 300) diff --git a/tools/requirements.txt b/tools/requirements.txt index 1a558a5e1..110b2187f 100644 --- a/tools/requirements.txt +++ b/tools/requirements.txt @@ -1,9 +1,9 @@ -click == 8.0.4 -requests >= 2.32.2 -prompt_toolkit >= 2 -pygments >= 2.0.0 -urllib3 >= 1.24 -rich == 14.1.0 +click == 8.4.2 +requests >= 2.34.2 +prompt_toolkit >= 3.0.52 +pygments >= 2.20.0 +urllib3 >= 2.7.0 +rich == 15.0.0 # only used for soap transport # softlayer-zeep >= 5.0.0 diff --git a/tools/test-requirements.txt b/tools/test-requirements.txt index 68e892ddf..99f8c0a41 100644 --- a/tools/test-requirements.txt +++ b/tools/test-requirements.txt @@ -4,10 +4,12 @@ pytest pytest-cov mock sphinx -click == 8.0.4 -requests >= 2.32.2 -prompt_toolkit >= 2 -pygments >= 2.0.0 -urllib3 >= 1.24 +click == 8.4.2 +requests >= 2.34.2 +prompt_toolkit >= 3.0.52 +pygments >= 2.20.0 +urllib3 >= 2.7.0 rich >= 12.3.0 +flake8 +autopep8 # softlayer-zeep >= 5.0.0 diff --git a/tox.ini b/tox.ini index fccc3fbc7..bfaace3c6 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py38,py39,py310,py311,py312,pypy3,analysis,coverage,docs +envlist = py310,py311,py312,py313,314,pypy3,analysis,coverage,docs [flake8]