From bbb41e272adafc4a070a1688e21310ecf385f26c Mon Sep 17 00:00:00 2001 From: Marcel Mamula Date: Tue, 7 Oct 2025 10:02:17 +0200 Subject: [PATCH 01/11] update workflows for 2.19 --- .ansible-lint | 22 +++++- .github/workflows/.ansible-lint | 10 --- .../ansible-lint-sap_software_download.yml | 36 ++++++++++ .github/workflows/ansible-lint.yml | 22 ++++-- .github/workflows/ansible-test-sanity.yml | 67 +++++++++++++++++++ 5 files changed, 138 insertions(+), 19 deletions(-) delete mode 100644 .github/workflows/.ansible-lint create mode 100644 .github/workflows/ansible-lint-sap_software_download.yml create mode 100644 .github/workflows/ansible-test-sanity.yml diff --git a/.ansible-lint b/.ansible-lint index ff93a8f..49b8fbe 100644 --- a/.ansible-lint +++ b/.ansible-lint @@ -1,16 +1,24 @@ --- -# Collection wide lint-file -# DO NOT CHANGE +## Collection wide ansible-lint configuration file. +# Changes for ansible-lint v25.7.0+ +# - Always executed from collection root using collection configuration. +# - .ansible-lint-ignore can be used to ignore files, not folders. +## Execution examples: +# ansible-lint +# ansible-lint roles/sap_swpm +# ansible-lint roles/sap_install_media_detect -c roles/sap_install_media_detect/.ansible-lint + exclude_paths: - .ansible/ - .cache/ - .github/ - # - docs/ - changelogs/ - playbooks/ - tests/ + enable_list: - yaml + skip_list: # We don't want to enforce new Ansible versions for Galaxy: - meta-runtime[unsupported-version] @@ -22,3 +30,11 @@ skip_list: - schema # Allow templating inside name because it creates more detailed output: - name[template] + + # - command-instead-of-module + # - command-instead-of-shell + # - line-length + # - risky-shell-pipe + # - no-changed-when + # - no-handler + # - ignore-errors diff --git a/.github/workflows/.ansible-lint b/.github/workflows/.ansible-lint deleted file mode 100644 index 69435ba..0000000 --- a/.github/workflows/.ansible-lint +++ /dev/null @@ -1,10 +0,0 @@ ---- - -skip_list: - - command-instead-of-module - - command-instead-of-shell - - line-length - - risky-shell-pipe - - no-changed-when - - no-handler - - ignore-errors diff --git a/.github/workflows/ansible-lint-sap_software_download.yml b/.github/workflows/ansible-lint-sap_software_download.yml new file mode 100644 index 0000000..76eee1e --- /dev/null +++ b/.github/workflows/ansible-lint-sap_software_download.yml @@ -0,0 +1,36 @@ +--- +name: Ansible Lint - sap_software_download + +on: + push: + branches: + - main + - dev + paths: + - 'roles/sap_software_download/**' + pull_request: + branches: + - main + - dev + paths: + - 'roles/sap_software_download/**' + + workflow_dispatch: + +jobs: + ansible-lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + # Use @v25 to automatically track the latest release from the year 2025. + # ansible-lint uses Calendar Versioning (e.g., v25.9.0 -> YYYY.MM.PATCH). + # Avoid using @main, which can introduce breaking changes unexpectedly. + - uses: ansible/ansible-lint@v25 + with: + # v25.7.0 no longer uses 'working_directory' and role path is set in 'args'. + # Role specific .ansible-lint can be added with argument '-c'. + args: roles/sap_software_download + # Use the shared requirements file from the collection root for dependency context. + requirements_file: ./requirements.yml diff --git a/.github/workflows/ansible-lint.yml b/.github/workflows/ansible-lint.yml index b55e812..ba2473f 100644 --- a/.github/workflows/ansible-lint.yml +++ b/.github/workflows/ansible-lint.yml @@ -1,14 +1,24 @@ -name: Ansible Lint +--- +name: Ansible Lint - Collection -on: [push, pull_request] +on: + schedule: + # This is 03:05 UTC, which is 5:05 AM in Prague/CEST. + - cron: '5 3 * * 1' + + workflow_dispatch: jobs: ansible-lint: - runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - name: Ansible Lint Action - uses: ansible/ansible-lint@v6 + # Use @v25 to automatically track the latest release from the year 2025. + # ansible-lint uses Calendar Versioning (e.g., v25.9.0 -> YYYY.MM.PATCH). + # Avoid using @main, which can introduce breaking changes unexpectedly. + - uses: ansible/ansible-lint@v25 + with: + # Use the shared requirements file from the collection root for dependency context. + requirements_file: ./requirements.yml diff --git a/.github/workflows/ansible-test-sanity.yml b/.github/workflows/ansible-test-sanity.yml new file mode 100644 index 0000000..96c9bf9 --- /dev/null +++ b/.github/workflows/ansible-test-sanity.yml @@ -0,0 +1,67 @@ +--- +# Always check ansible-core support matrix before configuring units matrix. +# https://docs.ansible.com/ansible/latest/reference_appendices/release_and_maintenance.html#ansible-core-support-matrix + +name: Ansible Test - Sanity + +on: + schedule: + # This is 01:05 UTC, which is 3:05 AM in Prague/CEST + - cron: '5 3 * * 1' + + pull_request: + branches: + - main + - dev + + workflow_dispatch: + +jobs: + sanity-supported: + runs-on: ubuntu-latest + name: Sanity (Supported Ⓐ${{ matrix.ansible }}) + strategy: + fail-fast: false # Disabled so we can see all failed combinations. + # Define a build matrix to test compatibility across multiple Ansible versions. + # Each version listed below will spawn a separate job that runs in parallel. + matrix: + ansible: + # Supported versions (must pass) + - 'stable-2.18' # Python 3.11 - 3.13 + - 'stable-2.19' # Python 3.11 - 3.13 + - 'devel' # Test against the upcoming development version. + steps: + - uses: actions/checkout@v5 + + - name: ansible-test - sanity + uses: ansible-community/ansible-test-gh-action@release/v1 + with: + ansible-core-version: ${{ matrix.ansible }} + testing-type: sanity + + sanity-eol: + runs-on: ubuntu-latest + # This job only runs if the supported tests pass + needs: sanity-supported + name: Sanity (EOL Ⓐ${{ matrix.ansible }}) + continue-on-error: true # This entire job is allowed to fail + strategy: + fail-fast: false # Disabled so we can see all failed combinations. + # Define a build matrix to test compatibility across multiple Ansible versions. + # Each version listed below will spawn a separate job that runs in parallel. + matrix: + ansible: + # EOL versions (allowed to fail) + # NOTE: Ensure that meta/runtime.yml `requires_ansible` version is aligned with tested versions. + - 'stable-2.14' # Python 3.9 - 3.11 + - 'stable-2.15' # Python 3.9 - 3.11 + - 'stable-2.16' # Python 3.10 - 3.12 + - 'stable-2.17' # Python 3.10 - 3.12 + steps: + - uses: actions/checkout@v5 + + - name: ansible-test - sanity + uses: ansible-community/ansible-test-gh-action@release/v1 + with: + ansible-core-version: ${{ matrix.ansible }} + testing-type: sanity From 463de66fc9ea4a815872de2c188b0a89dd226f21 Mon Sep 17 00:00:00 2001 From: Marcel Mamula Date: Tue, 7 Oct 2025 11:10:24 +0200 Subject: [PATCH 02/11] fix ansible-test sanity errors, imports, pep8 --- README.md | 2 +- docs/FAQ.md | 2 +- plugins/module_utils/auth.py | 14 +++++++------- plugins/module_utils/client.py | 3 ++- plugins/module_utils/exceptions.py | 1 - .../module_utils/maintenance_planner/__init__.py | 2 +- plugins/module_utils/maintenance_planner/api.py | 2 +- plugins/module_utils/maintenance_planner/main.py | 2 +- plugins/module_utils/software_center/__init__.py | 2 +- plugins/module_utils/software_center/main.py | 2 +- plugins/module_utils/systems/api.py | 8 ++++++-- plugins/module_utils/systems/main.py | 11 +++++++++-- plugins/modules/license_keys.py | 16 ++++++++-------- plugins/modules/maintenance_planner_files.py | 3 ++- .../maintenance_planner_stack_xml_download.py | 4 +++- plugins/modules/software_center_download.py | 13 +++++++------ plugins/modules/systems_info.py | 4 ++-- tests/.gitkeep | 0 tests/sanity/ignore-2.14.txt | 5 +++++ tests/sanity/ignore-2.15.txt | 5 +++++ tests/sanity/ignore-2.16.txt | 5 +++++ tests/sanity/ignore-2.17.txt | 5 +++++ tests/sanity/ignore-2.18.txt | 5 +++++ tests/sanity/ignore-2.19.txt | 5 +++++ tests/sanity/ignore-2.20.txt | 5 +++++ tests/sanity/requirements.txt | 3 +++ 26 files changed, 91 insertions(+), 38 deletions(-) delete mode 100644 tests/.gitkeep create mode 100644 tests/sanity/ignore-2.14.txt create mode 100644 tests/sanity/ignore-2.15.txt create mode 100644 tests/sanity/ignore-2.16.txt create mode 100644 tests/sanity/ignore-2.17.txt create mode 100644 tests/sanity/ignore-2.18.txt create mode 100644 tests/sanity/ignore-2.19.txt create mode 100644 tests/sanity/ignore-2.20.txt create mode 100644 tests/sanity/requirements.txt diff --git a/README.md b/README.md index 52724e5..536d26c 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ When an SAP User ID (e.g. S-User) is enabled with and part of an SAP Universal I - the SAP User ID - the password for login with the SAP Universal ID -In addition, if a SAP Universal ID is used then the recommendation is to check and reset the SAP User ID ‘Account Password’ in the [SAP Universal ID Account Manager](https://account.sap.com/manage/accounts), which will help to avoid any potential conflicts. +In addition, if a SAP Universal ID is used then the recommendation is to check and reset the SAP User ID `Account Password` in the [SAP Universal ID Account Manager](https://account.sap.com/manage/accounts), which will help to avoid any potential conflicts. For further information regarding connection errors, please see the FAQ section [Errors with prefix 'SAP SSO authentication failed - '](./docs/FAQ.md#errors-with-prefix-sap-sso-authentication-failed---). diff --git a/docs/FAQ.md b/docs/FAQ.md index ca093b9..bbe4c84 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -20,7 +20,7 @@ The error HTTP 401 refers to either: - Unauthorized, the SAP User ID being used belongs to an SAP Company Number (SCN) with one or more Installation Number/s which do not have license agreements for these files - Unauthorized, the SAP User ID being used does not have SAP Download authorizations - Unauthorized, the SAP User ID is part of an SAP Universal ID and must use the password of the SAP Universal ID - - In addition, if a SAP Universal ID is used then the recommendation is to check and reset the SAP User ID ‘Account Password’ in the [SAP Universal ID Account Manager](https://account.sap.com/manage/accounts), which will help to avoid any potential conflicts. + - In addition, if a SAP Universal ID is used then the recommendation is to check and reset the SAP User ID `Account Password` in the [SAP Universal ID Account Manager](https://account.sap.com/manage/accounts), which will help to avoid any potential conflicts. This is documented under [Execution - Credentials](https://github.com/sap-linuxlab/community.sap_launchpad#requirements-dependencies-and-testing). diff --git a/plugins/module_utils/auth.py b/plugins/module_utils/auth.py index 5c26609..02d6767 100644 --- a/plugins/module_utils/auth.py +++ b/plugins/module_utils/auth.py @@ -57,14 +57,14 @@ def login(client, username, password): 'samlContext': params['samlContext'] } endpoint, meta = get_sso_endpoint_meta(client, idp_endpoint, - params=context, - allow_redirects=False) + params=context, + allow_redirects=False) while (endpoint != C.URL_LAUNCHPAD + '/'): endpoint, meta = get_sso_endpoint_meta(client, endpoint, - data=meta, - headers=C.GIGYA_HEADERS, - allow_redirects=False) + data=meta, + headers=C.GIGYA_HEADERS, + allow_redirects=False) client.post(endpoint, data=meta, headers=C.GIGYA_HEADERS) @@ -120,8 +120,8 @@ def _gigya_websdk_bootstrap(client, params): }) client.get(C.URL_ACCOUNT_CDC_API + '/accounts.webSdkBootstrap', - params=params, - headers=C.GIGYA_HEADERS) + params=params, + headers=C.GIGYA_HEADERS) def _gigya_login(client, username, password, api_key): diff --git a/plugins/module_utils/client.py b/plugins/module_utils/client.py index 9bd19e1..0301e53 100644 --- a/plugins/module_utils/client.py +++ b/plugins/module_utils/client.py @@ -22,6 +22,7 @@ def rebuild_auth(self, prepared_request, response): if not re.match(r'.*sap.com$', request_hostname): del prepared_request.headers['Authorization'] + def _is_updated_urllib3(): # `method_whitelist` argument for Retry is deprecated since 1.26.0, # and will be removed in v2.0.0. @@ -114,4 +115,4 @@ def head(self, url, **kwargs): return self.request('HEAD', url, **kwargs) def get_cookies(self): - return self.session.cookies \ No newline at end of file + return self.session.cookies diff --git a/plugins/module_utils/exceptions.py b/plugins/module_utils/exceptions.py index a50054b..b5cf5a5 100644 --- a/plugins/module_utils/exceptions.py +++ b/plugins/module_utils/exceptions.py @@ -24,4 +24,3 @@ class DownloadError(SapLaunchpadError): class FileNotFoundError(SapLaunchpadError): # Raised when a searched file cannot be found. pass - diff --git a/plugins/module_utils/maintenance_planner/__init__.py b/plugins/module_utils/maintenance_planner/__init__.py index 9b5afe6..8724d09 100644 --- a/plugins/module_utils/maintenance_planner/__init__.py +++ b/plugins/module_utils/maintenance_planner/__init__.py @@ -1 +1 @@ -# This file makes the `maintenance_planner` directory into a Python package. \ No newline at end of file +# This file makes the `maintenance_planner` directory into a Python package. diff --git a/plugins/module_utils/maintenance_planner/api.py b/plugins/module_utils/maintenance_planner/api.py index 29e871e..c587b26 100644 --- a/plugins/module_utils/maintenance_planner/api.py +++ b/plugins/module_utils/maintenance_planner/api.py @@ -199,4 +199,4 @@ def _clear_mp_cookies(client, startswith): # Clears cookies for a specific domain prefix from the client session. for cookie in client.session.cookies: if cookie.domain.startswith(startswith): - client.session.cookies.clear(domain=cookie.domain) \ No newline at end of file + client.session.cookies.clear(domain=cookie.domain) diff --git a/plugins/module_utils/maintenance_planner/main.py b/plugins/module_utils/maintenance_planner/main.py index 198de8b..6dd6875 100644 --- a/plugins/module_utils/maintenance_planner/main.py +++ b/plugins/module_utils/maintenance_planner/main.py @@ -98,4 +98,4 @@ def run_stack_xml_download(params): result['failed'] = True result['msg'] = f"An unexpected error occurred: {e}" - return result \ No newline at end of file + return result diff --git a/plugins/module_utils/software_center/__init__.py b/plugins/module_utils/software_center/__init__.py index 6a9cf69..a55ae46 100644 --- a/plugins/module_utils/software_center/__init__.py +++ b/plugins/module_utils/software_center/__init__.py @@ -1 +1 @@ -# This file makes the `software_center` directory into a Python package. \ No newline at end of file +# This file makes the `software_center` directory into a Python package. diff --git a/plugins/module_utils/software_center/main.py b/plugins/module_utils/software_center/main.py index 539b40b..75ac623 100644 --- a/plugins/module_utils/software_center/main.py +++ b/plugins/module_utils/software_center/main.py @@ -165,4 +165,4 @@ def run_software_download(params): finally: download.clear_download_key_cookie(client) - return result \ No newline at end of file + return result diff --git a/plugins/module_utils/systems/api.py b/plugins/module_utils/systems/api.py index 0de1dae..f26b2ef 100644 --- a/plugins/module_utils/systems/api.py +++ b/plugins/module_utils/systems/api.py @@ -49,7 +49,9 @@ def __init__(self, scope, unknown_fields, missing_required_fields, fields_with_i self.unknown_fields = unknown_fields self.missing_required_fields = missing_required_fields self.fields_with_invalid_option = fields_with_invalid_option - super().__init__(f"Invalid data for {scope}: Unknown fields: {unknown_fields}, Missing required fields: {missing_required_fields}, Invalid options: {fields_with_invalid_option}") + message = (f"Invalid data for {scope}: Unknown fields: {unknown_fields}, " + f"Missing required fields: {missing_required_fields}, Invalid options: {fields_with_invalid_option}") + super().__init__(message) def get_systems(client, filter_str): @@ -76,7 +78,9 @@ def get_system(client, system_nr, installation_nr, username): system = systems[0] if 'Prodver' not in system and 'Version' not in system: - raise exceptions.SapLaunchpadError(f"System {system_nr} was found, but it is missing a required Product Version ID (checked for 'Prodver' and 'Version' keys). System details: {system}") + message = (f"System {system_nr} was found, but it is missing a required Product Version ID " + f"(checked for 'Prodver' and 'Version' keys). System details: {system}") + raise exceptions.SapLaunchpadError(message) return system diff --git a/plugins/module_utils/systems/main.py b/plugins/module_utils/systems/main.py index 0060d8a..d06f44f 100644 --- a/plugins/module_utils/systems/main.py +++ b/plugins/module_utils/systems/main.py @@ -109,7 +109,10 @@ def run_license_keys(params): return result license_data = api.validate_licenses(client, user_licenses, version_id, installation_nr, username) - new_or_changed = [l for l in license_data if not any(l['HWKEY'] == el['HWKEY'] and l['LICENSETYPE'] == el['LICENSETYPE'] for el in existing_licenses)] + new_or_changed = [ + l for l in license_data + if not any(l['HWKEY'] == el['HWKEY'] and l['LICENSETYPE'] == el['LICENSETYPE'] for el in existing_licenses) + ] if not new_or_changed: result['msg'] = "System and licenses are already in the desired state." @@ -126,7 +129,11 @@ def run_license_keys(params): licenses_to_delete = existing_licenses else: validated_to_keep = api.validate_licenses(client, user_licenses_to_keep, version_id, installation_nr, username) - key_nrs_to_keep = [l['KEYNR'] for l in existing_licenses if any(k['HWKEY'] == l['HWKEY'] and k['LICENSETYPE'] == l['LICENSETYPE'] for k in validated_to_keep)] + key_nrs_to_keep = [ + l['KEYNR'] for l in existing_licenses if any( + k['HWKEY'] == l['HWKEY'] and k['LICENSETYPE'] == l['LICENSETYPE'] for k in validated_to_keep + ) + ] licenses_to_delete = [l for l in existing_licenses if l['KEYNR'] not in key_nrs_to_keep] if not licenses_to_delete: diff --git a/plugins/modules/license_keys.py b/plugins/modules/license_keys.py index 344b976..df9ae9e 100644 --- a/plugins/modules/license_keys.py +++ b/plugins/modules/license_keys.py @@ -12,7 +12,7 @@ description: - This ansible module creates and updates systems and their license keys using the Launchpad API. - - It is closely modeled after the interactions in the portal U(https://me.sap.com/licensekey): + - It is closely modeled after the interactions in the portal U(https://me.sap.com/licensekey) - First, a SAP system is defined by its SID, product, version and other data. - Then, for this system, license keys are defined by license type, HW key and potential other attributes. - The system and license data is then validated and submitted to the Launchpad API and the license key files returned to the caller. @@ -31,9 +31,8 @@ - SAP S-User Password. required: true type: str - no_log: true installation_nr: - description: + description: - Number of the Installation for which the system should be created/updated required: true type: str @@ -59,7 +58,7 @@ required: true type: str data: - description: + description: - The data attributes of the system. The possible attributes are defined by product and version. - Running the module without any data attributes will return in the error message which attributes are supported/required. required: true @@ -80,13 +79,13 @@ required: true type: str data: - description: + description: - The data attributes of the licenses. The possible attributes are defined by product and version. - Running the module without any data attributes will return in the error message which attributes are supported/required - In practice, most license types require at least a hardware key (hwkey) and expiry date (expdate) required: true type: dict - + delete_other_licenses: description: - Whether licenses other than the ones specified in the licenses attributes should be deleted. @@ -100,7 +99,8 @@ type: path author: - - Lab for SAP Solutions + - Matthias Winzeler (@MatthiasWinzeler) + - Marcel Mamula (@marcelmamula) ''' @@ -169,7 +169,7 @@ SWPRODUCTLIMIT=2147483647 SYSTEM-NR=00000000023456789 system_nr: - description: The number of the system which was created/updated. + description: The number of the system which was created/updated. returned: on success type: str sample: "0000123456" diff --git a/plugins/modules/maintenance_planner_files.py b/plugins/modules/maintenance_planner_files.py index a2bd3f4..ccd15eb 100644 --- a/plugins/modules/maintenance_planner_files.py +++ b/plugins/modules/maintenance_planner_files.py @@ -32,7 +32,8 @@ required: true type: str author: - - SAP LinuxLab + - Matthias Winzeler (@MatthiasWinzeler) + - Marcel Mamula (@marcelmamula) ''' diff --git a/plugins/modules/maintenance_planner_stack_xml_download.py b/plugins/modules/maintenance_planner_stack_xml_download.py index 4473078..cd81926 100644 --- a/plugins/modules/maintenance_planner_stack_xml_download.py +++ b/plugins/modules/maintenance_planner_stack_xml_download.py @@ -37,7 +37,9 @@ required: true type: str author: - - SAP LinuxLab + - Matthias Winzeler (@MatthiasWinzeler) + - Sean Freeman (@sean-freeman) + - Marcel Mamula (@marcelmamula) ''' diff --git a/plugins/modules/software_center_download.py b/plugins/modules/software_center_download.py index c0bb800..f5acb4f 100644 --- a/plugins/modules/software_center_download.py +++ b/plugins/modules/software_center_download.py @@ -33,9 +33,6 @@ - "Deprecated. Use 'search_query' instead." required: false type: str - deprecated: - alternative: search_query - removed_in: "1.2.0" search_query: description: - Filename of the SAP software to download. @@ -58,7 +55,8 @@ type: str deduplicate: description: - - "Specifies how to handle multiple search results for the same filename. Choices are `first` (oldest) or `last` (newest)." + - "Specifies how to handle multiple search results for the same filename. + - Choices are `first` (oldest) or `last` (newest)." choices: [ 'first', 'last' ] required: false type: str @@ -74,11 +72,14 @@ type: bool validate_checksum: description: - - If a file with the same name already exists at the destination, validate its checksum against the remote file. If the checksum is invalid, the local file will be removed and re-downloaded. + - If a file with the same name already exists at the destination, validate its checksum against the remote file. + - If the checksum is invalid, the local file will be removed and re-downloaded. required: false type: bool author: - - SAP LinuxLab + - Matthias Winzeler (@MatthiasWinzeler) + - Sean Freeman (@sean-freeman) + - Marcel Mamula (@marcelmamula) ''' diff --git a/plugins/modules/systems_info.py b/plugins/modules/systems_info.py index be25d5f..d660a95 100644 --- a/plugins/modules/systems_info.py +++ b/plugins/modules/systems_info.py @@ -25,14 +25,14 @@ - SAP S-User Password. required: true type: str - no_log: true filter: description: - An ODATA filter expression to query the systems. required: true type: str author: - - SAP LinuxLab + - Matthias Winzeler (@MatthiasWinzeler) + - Marcel Mamula (@marcelmamula) ''' diff --git a/tests/.gitkeep b/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/sanity/ignore-2.14.txt b/tests/sanity/ignore-2.14.txt new file mode 100644 index 0000000..ce5c04e --- /dev/null +++ b/tests/sanity/ignore-2.14.txt @@ -0,0 +1,5 @@ +plugins/modules/license_keys.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_files.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_stack_xml_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/software_center_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/systems_info.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 \ No newline at end of file diff --git a/tests/sanity/ignore-2.15.txt b/tests/sanity/ignore-2.15.txt new file mode 100644 index 0000000..ce5c04e --- /dev/null +++ b/tests/sanity/ignore-2.15.txt @@ -0,0 +1,5 @@ +plugins/modules/license_keys.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_files.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_stack_xml_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/software_center_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/systems_info.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 \ No newline at end of file diff --git a/tests/sanity/ignore-2.16.txt b/tests/sanity/ignore-2.16.txt new file mode 100644 index 0000000..ce5c04e --- /dev/null +++ b/tests/sanity/ignore-2.16.txt @@ -0,0 +1,5 @@ +plugins/modules/license_keys.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_files.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_stack_xml_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/software_center_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/systems_info.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 \ No newline at end of file diff --git a/tests/sanity/ignore-2.17.txt b/tests/sanity/ignore-2.17.txt new file mode 100644 index 0000000..ce5c04e --- /dev/null +++ b/tests/sanity/ignore-2.17.txt @@ -0,0 +1,5 @@ +plugins/modules/license_keys.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_files.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_stack_xml_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/software_center_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/systems_info.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 \ No newline at end of file diff --git a/tests/sanity/ignore-2.18.txt b/tests/sanity/ignore-2.18.txt new file mode 100644 index 0000000..ce5c04e --- /dev/null +++ b/tests/sanity/ignore-2.18.txt @@ -0,0 +1,5 @@ +plugins/modules/license_keys.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_files.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_stack_xml_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/software_center_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/systems_info.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 \ No newline at end of file diff --git a/tests/sanity/ignore-2.19.txt b/tests/sanity/ignore-2.19.txt new file mode 100644 index 0000000..ce5c04e --- /dev/null +++ b/tests/sanity/ignore-2.19.txt @@ -0,0 +1,5 @@ +plugins/modules/license_keys.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_files.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_stack_xml_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/software_center_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/systems_info.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 \ No newline at end of file diff --git a/tests/sanity/ignore-2.20.txt b/tests/sanity/ignore-2.20.txt new file mode 100644 index 0000000..ce5c04e --- /dev/null +++ b/tests/sanity/ignore-2.20.txt @@ -0,0 +1,5 @@ +plugins/modules/license_keys.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_files.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/maintenance_planner_stack_xml_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/software_center_download.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 +plugins/modules/systems_info.py validate-modules:missing-gplv3-license # Licensed under Apache 2.0 \ No newline at end of file diff --git a/tests/sanity/requirements.txt b/tests/sanity/requirements.txt new file mode 100644 index 0000000..75b68f4 --- /dev/null +++ b/tests/sanity/requirements.txt @@ -0,0 +1,3 @@ +# Requirements for ansible-test sanity +requests +beautifulsoup4 \ No newline at end of file From c1ed2187e305d86156b3d4a02afa9071ee501c24 Mon Sep 17 00:00:00 2001 From: Marcel Mamula Date: Tue, 7 Oct 2025 12:45:42 +0200 Subject: [PATCH 03/11] add decorators for imports and fix pylint errors --- plugins/module_utils/auth.py | 58 +++++++++++++++++-- plugins/module_utils/client.py | 44 +++++++++++--- .../module_utils/maintenance_planner/api.py | 41 ++++++++++++- .../module_utils/maintenance_planner/main.py | 17 +++++- .../module_utils/software_center/download.py | 27 ++++++++- plugins/module_utils/software_center/main.py | 3 +- .../module_utils/software_center/search.py | 6 +- plugins/module_utils/systems/__init__.py | 2 +- plugins/module_utils/systems/api.py | 39 ++++++++++++- plugins/module_utils/systems/main.py | 6 +- plugins/modules/maintenance_planner_files.py | 1 - .../maintenance_planner_stack_xml_download.py | 1 - tests/sanity/requirements.txt | 3 - 13 files changed, 216 insertions(+), 32 deletions(-) delete mode 100644 tests/sanity/requirements.txt diff --git a/plugins/module_utils/auth.py b/plugins/module_utils/auth.py index 02d6767..6e086d9 100644 --- a/plugins/module_utils/auth.py +++ b/plugins/module_utils/auth.py @@ -1,16 +1,54 @@ import json import re +import traceback +from functools import wraps from urllib.parse import parse_qs, quote_plus, urljoin -from bs4 import BeautifulSoup -from requests.models import HTTPError from . import constants as C from . import exceptions +try: + from bs4 import BeautifulSoup + HAS_BS4 = True +except ImportError: + HAS_BS4 = False + BS4_IMPORT_ERROR = traceback.format_exc() + BeautifulSoup = None + +try: + from requests.models import HTTPError + HAS_REQUESTS = True +except ImportError: + HAS_REQUESTS = False + REQUESTS_IMPORT_ERROR = traceback.format_exc() + HTTPError = None + _GIGYA_SDK_BUILD_NUMBER = None +def require_bs4(func): + # A decorator to check for the 'beautifulsoup4' library before executing a function. + @wraps(func) + def wrapper(*args, **kwargs): + if not HAS_BS4: + raise exceptions.SapLaunchpadError(f"The 'beautifulsoup4' library is required. Error: {BS4_IMPORT_ERROR}") + return func(*args, **kwargs) + return wrapper + + +def require_requests(func): + # A decorator to check for the 'requests' library before executing a function. + @wraps(func) + def wrapper(*args, **kwargs): + if not HAS_REQUESTS: + raise exceptions.SapLaunchpadError(f"The 'requests' library is required. Error: {REQUESTS_IMPORT_ERROR}") + return func(*args, **kwargs) + return wrapper + + +@require_requests +@require_bs4 def login(client, username, password): # Main authentication function. # @@ -69,6 +107,8 @@ def login(client, username, password): client.post(endpoint, data=meta, headers=C.GIGYA_HEADERS) +@require_requests +@require_bs4 def get_sso_endpoint_meta(client, url, **kwargs): # Scrapes an HTML page to find the next SSO form action URL and its input fields. method = 'POST' if kwargs.get('data') or kwargs.get('json') else 'GET' @@ -100,6 +140,7 @@ def get_sso_endpoint_meta(client, url, **kwargs): return (endpoint, metadata) +@require_requests def _get_gigya_login_params(client, url, data): # Follows a redirect and extracts parameters from the resulting URL's query string. gigya_idp_res = client.post(url, data=data) @@ -109,9 +150,10 @@ def _get_gigya_login_params(client, url, data): return params +@require_requests def _gigya_websdk_bootstrap(client, params): # Performs the initial bootstrap call to the Gigya WebSDK. - page_url = f'{C.URL_ACCOUNT_SAML_PROXY}?apiKey=' + params['apiKey'], + page_url = f'{C.URL_ACCOUNT_SAML_PROXY}?apiKey=' + params['apiKey'] params.update({ 'pageURL': page_url, 'sdk': 'js_latest', @@ -124,6 +166,7 @@ def _gigya_websdk_bootstrap(client, params): headers=C.GIGYA_HEADERS) +@require_requests def _gigya_login(client, username, password, api_key): # Performs a login using the standard Gigya accounts.login API. # This avoids a custom SAP endpoint that triggers password change notifications. @@ -154,6 +197,7 @@ def _gigya_login(client, username, password, api_key): return login_response.get('login_token') +@require_requests def _get_id_token(client, saml_params, login_token): # Exchanges a Gigya login token for a JWT ID token. query_params = { @@ -166,6 +210,7 @@ def _get_id_token(client, saml_params, login_token): return token +@require_requests def _get_uid(client, saml_params, login_token): # Retrieves the user's unique ID (UID) using the login token. query_params = { @@ -177,6 +222,7 @@ def _get_uid(client, saml_params, login_token): return uid +@require_requests def _get_uid_details(client, uid, id_token): # Fetches detailed account information for a given UID. url = f'{C.URL_ACCOUNT_CORE_API}/accounts/{uid}' @@ -187,16 +233,18 @@ def _get_uid_details(client, uid, id_token): return uid_details_response +@require_requests def _is_uid_linked_multiple_sids(uid_details): # Checks if a Universal ID (UID) is linked to more than one S-User ID. accounts = uid_details['accounts'] linked = [] - for _, v in accounts.items(): + for _account_type, v in accounts.items(): linked.extend(v['linkedAccounts']) return len(linked) > 1 +@require_requests def _select_account(client, uid, sid, id_token): # Selects a specific S-User ID when a Universal ID is linked to multiple accounts. url = f'{C.URL_ACCOUNT_CORE_API}/accounts/{uid}/selectedAccount' @@ -207,6 +255,7 @@ def _select_account(client, uid, sid, id_token): return client.request('PUT', url, headers=headers, json=data) +@require_requests def _get_sdk_build_number(client, api_key): # Fetches the gigya.js file to extract and cache the SDK build number. global _GIGYA_SDK_BUILD_NUMBER @@ -224,6 +273,7 @@ def _get_sdk_build_number(client, api_key): return build_number +@require_requests def _cdc_api_request(client, endpoint, saml_params, query_params): # Helper to make requests to the Gigya/CDC API, handling common parameters and errors. url = '/'.join((C.URL_ACCOUNT_CDC_API, endpoint)) diff --git a/plugins/module_utils/client.py b/plugins/module_utils/client.py index 0301e53..34e0a2d 100644 --- a/plugins/module_utils/client.py +++ b/plugins/module_utils/client.py @@ -1,14 +1,35 @@ -import requests import re -import urllib3 +import traceback from urllib.parse import urlparse -from requests.adapters import HTTPAdapter from .constants import COMMON_HEADERS - - -class _SessionAllowBasicAuthRedirects(requests.Session): +from . import exceptions + +try: + import requests + from requests.adapters import HTTPAdapter + _RequestsSession = requests.Session + HAS_REQUESTS = True +except ImportError: + HAS_REQUESTS = False + REQUESTS_IMPORT_ERROR = traceback.format_exc() + # Placeholders to prevent errors on module load + requests = None + HTTPAdapter = object + _RequestsSession = object + +try: + import urllib3 + HAS_URLLIB3 = True +except ImportError: + HAS_URLLIB3 = False + URLLIB3_IMPORT_ERROR = traceback.format_exc() + # Placeholder to prevent errors on module load + urllib3 = None + + +class _SessionAllowBasicAuthRedirects(_RequestsSession): # By default, the `Authorization` header for Basic Auth will be removed # if the redirect is to a different host. # In our case, the DirectDownloadLink with `softwaredownloads.sap.com` domain @@ -17,7 +38,8 @@ class _SessionAllowBasicAuthRedirects(requests.Session): # for sap.com domains. # This is only required for legacy API. def rebuild_auth(self, prepared_request, response): - if 'Authorization' in prepared_request.headers: + # The parent class might not be a real requests.Session if requests is not installed. + if HAS_REQUESTS and 'Authorization' in prepared_request.headers: request_hostname = urlparse(prepared_request.url).hostname if not re.match(r'.*sap.com$', request_hostname): del prepared_request.headers['Authorization'] @@ -28,6 +50,9 @@ def _is_updated_urllib3(): # and will be removed in v2.0.0. # Typically, the default version on RedHat 8.2 is 1.24.2, # so we need to check the version of urllib3 to see if it's updated. + if not HAS_URLLIB3: + return False + urllib3_version = urllib3.__version__.split('.') if len(urllib3_version) == 2: urllib3_version.append('0') @@ -44,6 +69,11 @@ class ApiClient: # object-oriented interface for making API requests, replacing the # previous global session and request functions. def __init__(self): + if not HAS_REQUESTS: + raise exceptions.SapLaunchpadError(f"The 'requests' library is required. Error: {REQUESTS_IMPORT_ERROR}") + if not HAS_URLLIB3: + raise exceptions.SapLaunchpadError(f"The 'urllib3' library is required. Error: {URLLIB3_IMPORT_ERROR}") + self.session = _SessionAllowBasicAuthRedirects() # Configure retry logic for the session. diff --git a/plugins/module_utils/maintenance_planner/api.py b/plugins/module_utils/maintenance_planner/api.py index c587b26..8cc5290 100644 --- a/plugins/module_utils/maintenance_planner/api.py +++ b/plugins/module_utils/maintenance_planner/api.py @@ -1,20 +1,54 @@ import re import time +import traceback from html import unescape +from functools import wraps from urllib.parse import urljoin -from bs4 import BeautifulSoup -from lxml import etree from .. import constants as C from .. import exceptions from ..auth import get_sso_endpoint_meta +try: + from bs4 import BeautifulSoup + HAS_BS4 = True +except ImportError: + HAS_BS4 = False + BS4_IMPORT_ERROR = traceback.format_exc() + +try: + from lxml import etree + HAS_LXML = True +except ImportError: + HAS_LXML = False + LXML_IMPORT_ERROR = traceback.format_exc() + # Module-level cache _MP_XSRF_TOKEN = None _MP_TRANSACTIONS = None _MP_NAMESPACE = 'http://xml.sap.com/2012/01/mnp' +def require_bs4(func): + # A decorator to check for the 'beautifulsoup4' library before executing a function. + @wraps(func) + def wrapper(*args, **kwargs): + if not HAS_BS4: + raise exceptions.SapLaunchpadError(f"The 'beautifulsoup4' library is required. Error: {BS4_IMPORT_ERROR}") + return func(*args, **kwargs) + return wrapper + + +def require_lxml(func): + # A decorator to check for the 'lxml' library before executing a function. + @wraps(func) + def wrapper(*args, **kwargs): + if not HAS_LXML: + raise exceptions.SapLaunchpadError(f"The 'lxml' library is required. Error: {LXML_IMPORT_ERROR}") + return func(*args, **kwargs) + return wrapper + + def auth_userapps(client): # Authenticates against userapps.support.sap.com to establish a session. _clear_mp_cookies(client, 'userapps') @@ -32,6 +66,7 @@ def auth_userapps(client): client.post(endpoint, data=meta) +@require_bs4 def get_transactions(client): # Retrieves a list of all available Maintenance Planner transactions. global _MP_TRANSACTIONS @@ -67,6 +102,7 @@ def get_transaction_id(client, name): raise exceptions.FileNotFoundError(f"Transaction '{name}' not found by name or display ID.") +@require_lxml def get_transaction_filename_url(client, trans_id): # Parses the files XML to get a list of (URL, Filename) tuples. xml = _get_download_files_xml(client, trans_id) @@ -175,6 +211,7 @@ def _get_transaction(client, key, value): raise exceptions.FileNotFoundError(f"Transaction with {key}='{value}' not found.") +@require_lxml def _build_mnp_xml(**params): # Constructs the MNP XML payload for API requests. mnp = f'{{{_MP_NAMESPACE}}}' diff --git a/plugins/module_utils/maintenance_planner/main.py b/plugins/module_utils/maintenance_planner/main.py index 6dd6875..a2a602e 100644 --- a/plugins/module_utils/maintenance_planner/main.py +++ b/plugins/module_utils/maintenance_planner/main.py @@ -3,8 +3,13 @@ from .. import auth, exceptions from ..client import ApiClient from . import api -from requests.exceptions import HTTPError +try: + from requests.exceptions import HTTPError + HAS_REQUESTS = True +except ImportError: + HAS_REQUESTS = False + HTTPError = None def run_files(params): # Runner for maintenance_planner_files module. @@ -14,6 +19,11 @@ def run_files(params): msg='' ) + if not HAS_REQUESTS: + result['failed'] = True + result['msg'] = "The 'requests' library is required for this module." + return result + client = ApiClient() username = params['suser_id'] password = params['suser_password'] @@ -56,6 +66,11 @@ def run_stack_xml_download(params): msg='' ) + if not HAS_REQUESTS: + result['failed'] = True + result['msg'] = "The 'requests' library is required for this module." + return result + client = ApiClient() username = params['suser_id'] password = params['suser_password'] diff --git a/plugins/module_utils/software_center/download.py b/plugins/module_utils/software_center/download.py index 8962bcd..b280eb3 100644 --- a/plugins/module_utils/software_center/download.py +++ b/plugins/module_utils/software_center/download.py @@ -2,17 +2,36 @@ import hashlib import os import time - -from requests.exceptions import ConnectionError, HTTPError +import traceback +from functools import wraps from .. import auth from .. import constants as C from .. import exceptions from . import search +try: + from requests.exceptions import ConnectionError, HTTPError + HAS_REQUESTS = True +except ImportError: + HAS_REQUESTS = False + REQUESTS_IMPORT_ERROR = traceback.format_exc() + ConnectionError, HTTPError = None, None + _HAS_DOWNLOAD_AUTHORIZATION = None +def require_requests(func): + # A decorator to check for the 'requests' library before executing a function. + @wraps(func) + def wrapper(*args, **kwargs): + if not HAS_REQUESTS: + raise exceptions.SapLaunchpadError(f"The 'requests' library is required. Error: {REQUESTS_IMPORT_ERROR}") + return func(*args, **kwargs) + return wrapper + + +@require_requests def validate_local_file_checksum(client, local_filepath, query=None, download_link=None, deduplicate=None, search_alternatives=False): # Validates a local file against the remote checksum from the server. # Returns a dictionary with the validation status and additional context. @@ -74,6 +93,7 @@ def check_similar_files(dest, filename): return False, [] +@require_requests def _check_download_authorization(client): # Verifies that the authenticated user has the "Software Download" authorization. # Caches the result to avoid repeated API calls. @@ -102,6 +122,7 @@ def _check_download_authorization(client): ) +@require_requests def is_download_link_available(client, url, retry=0): # Verifies if a download link is active and returns the final, resolved URL. # Returns None if the link is not available. @@ -119,6 +140,7 @@ def is_download_link_available(client, url, retry=0): return None +@require_requests def _resolve_download_link(client, url, retry=0): # Resolves a tokengen URL to the final, direct download URL. # This encapsulates the SAML token exchange logic and includes retries. @@ -150,6 +172,7 @@ def _resolve_download_link(client, url, retry=0): return endpoint +@require_requests def stream_file_to_disk(client, url, filepath, retry=0, **kwargs): # Streams a large file to disk and verifies its checksum. kwargs.update({'stream': True}) diff --git a/plugins/module_utils/software_center/main.py b/plugins/module_utils/software_center/main.py index 75ac623..5885469 100644 --- a/plugins/module_utils/software_center/main.py +++ b/plugins/module_utils/software_center/main.py @@ -149,7 +149,8 @@ def run_software_download(params): if validation_result and validation_result.get('validated') is False: result['msg'] = f"Successfully re-downloaded {download_filename} due to an invalid checksum." elif alternative_found: - result['msg'] = f"Successfully downloaded alternative SAP software: {download_filename} - original file {query} is not available to download" + result['msg'] = (f"Successfully downloaded alternative SAP software: {download_filename} " + f"- original file {query} is not available to download") else: result['msg'] = f"Successfully downloaded SAP software: {download_filename}" else: diff --git a/plugins/module_utils/software_center/search.py b/plugins/module_utils/software_center/search.py index 535e8ac..c54052c 100644 --- a/plugins/module_utils/software_center/search.py +++ b/plugins/module_utils/software_center/search.py @@ -1,4 +1,4 @@ -import csv + import json import os import re @@ -168,7 +168,7 @@ def _prepare_search_filename_specific(filename): if filename_base.startswith(swpm_version): return swpm_version - # Example: SUM11SP04_2-80006858.SAR returns SUM11SP04 + # Example: SUM11SP04_2-80006858.SAR returns SUM11SP04 if filename_base.startswith('SUM'): return filename.split('-')[0].split('_')[0] @@ -294,7 +294,7 @@ def _get_next_page_query(desc): # Extracts the next page query URL for paginated search results. if '|' not in desc: return None - _, url = desc.split('|') + _prefix, url = desc.split('|') return url.strip() diff --git a/plugins/module_utils/systems/__init__.py b/plugins/module_utils/systems/__init__.py index 67a78bd..15edc0b 100644 --- a/plugins/module_utils/systems/__init__.py +++ b/plugins/module_utils/systems/__init__.py @@ -1 +1 @@ -# This file makes the `systems` directory into a Python package. \ No newline at end of file +# This file makes the `systems` directory into a Python package. diff --git a/plugins/module_utils/systems/api.py b/plugins/module_utils/systems/api.py index f26b2ef..896fb66 100644 --- a/plugins/module_utils/systems/api.py +++ b/plugins/module_utils/systems/api.py @@ -1,13 +1,13 @@ import json import time +import traceback +from functools import wraps from urllib.parse import urljoin -from requests.exceptions import HTTPError from .. import constants as C from .. import exceptions - class InstallationNotFoundError(Exception): def __init__(self, installation_nr, available_installations): self.installation_nr = installation_nr @@ -54,12 +54,33 @@ def __init__(self, scope, unknown_fields, missing_required_fields, fields_with_i super().__init__(message) +try: + from requests.exceptions import HTTPError + HAS_REQUESTS = True +except ImportError: + HAS_REQUESTS = False + REQUESTS_IMPORT_ERROR = traceback.format_exc() + HTTPError = None + + +def require_requests(func): + # A decorator to check for the 'requests' library before executing a function. + @wraps(func) + def wrapper(*args, **kwargs): + if not HAS_REQUESTS: + raise exceptions.SapLaunchpadError(f"The 'requests' library is required. Error: {REQUESTS_IMPORT_ERROR}") + return func(*args, **kwargs) + return wrapper + + +@require_requests def get_systems(client, filter_str): # Retrieves a list of systems based on an OData filter string. query_path = f"Systems?$filter={filter_str}" return client.get(_url(query_path), headers=_headers({})).json()['d']['results'] +@require_requests def get_system(client, system_nr, installation_nr, username): # Retrieves details for a single, specific system. filter_str = f"Uname eq '{username}' and Insnr eq '{installation_nr}' and Sysnr eq '{system_nr}'" @@ -85,6 +106,7 @@ def get_system(client, system_nr, installation_nr, username): return system +@require_requests def get_product_id(client, product_name, installation_nr, username): # Finds the internal product ID for a given product name. query_path = f"SysProducts?$filter=Uname eq '{username}' and Insnr eq '{installation_nr}' and Sysnr eq '' and Nocheck eq ''" @@ -95,6 +117,7 @@ def get_product_id(client, product_name, installation_nr, username): return product['Product'] +@require_requests def get_version_id(client, version_name, product_id, installation_nr, username): # Finds the internal version ID for a given product version name. query_path = f"SysVersions?$filter=Uname eq '{username}' and Insnr eq '{installation_nr}' and Product eq '{product_id}' and Nocheck eq ''" @@ -105,6 +128,7 @@ def get_version_id(client, version_name, product_id, installation_nr, username): return version['Version'] +@require_requests def validate_installation(client, installation_nr, username): # Checks if the user has access to the specified installation number. query_path = f"Installations?$filter=Ubname eq '{username}' and ValidateOnly eq ''" @@ -113,6 +137,7 @@ def validate_installation(client, installation_nr, username): raise InstallationNotFoundError(installation_nr, [i['Insnr'] for i in installations]) +@require_requests def validate_system_data(client, data, version_id, system_nr, installation_nr, username): # Validates user-provided system data against the fields supported by the API for a given product version. query_path = f"SystData?$filter=Pvnr eq '{version_id}' and Insnr eq '{installation_nr}'" @@ -136,6 +161,7 @@ def validate_system_data(client, data, version_id, system_nr, installation_nr, u return final_fields_lower, warning +@require_requests def validate_licenses(client, licenses, version_id, installation_nr, username): # Validates user-provided license data against the license types and fields supported by the API. query_path = f"LicenseType?$filter=PRODUCT eq '{version_id}' and INSNR eq '{installation_nr}' and Uname eq '{username}' and Nocheck eq 'X'" @@ -156,6 +182,7 @@ def validate_licenses(client, licenses, version_id, installation_nr, username): return license_data +@require_requests def get_existing_licenses(client, system_nr, username): # Retrieves all existing license keys for a given system. # When updating the licenses based on the results here, the backend expects a completely different format. @@ -172,6 +199,7 @@ def get_existing_licenses(client, system_nr, username): ] +@require_requests def generate_licenses(client, license_data, existing_licenses, version_id, installation_nr, username): # Generates new license keys for a system. body = { @@ -187,6 +215,7 @@ def generate_licenses(client, license_data, existing_licenses, version_id, insta return json.loads(response['d']['Result']) +@require_requests def submit_system(client, is_new, system_data, generated_licenses, username): # Submits all system and license data to create or update a system. # The SAP Backend requires a completely different format for the license data (`matdata`) @@ -216,6 +245,7 @@ def submit_system(client, is_new, system_data, generated_licenses, username): return licdata[0]['VALUE'] +@require_requests def get_license_key_numbers(client, license_data, system_nr, username): # Retrieves the unique key numbers for a list of recently created licenses. key_nrs = [] @@ -240,12 +270,14 @@ def get_license_key_numbers(client, license_data, system_nr, username): return key_nrs +@require_requests def download_licenses(client, key_nrs): # Downloads the license key file content for a list of key numbers. keys_json = json.dumps([{"Keynr": key_nr} for key_nr in key_nrs]) return client.get(_url(f"FileContent(Keynr='{keys_json}')/$value")).content +@require_requests def delete_licenses(client, licenses_to_delete, existing_licenses, version_id, installation_nr, username): # Deletes a list of specified licenses from a system. body = { @@ -271,6 +303,7 @@ def _headers(additional_headers): return {**{'Accept': 'application/json'}, **additional_headers} +@require_requests def _get_csrf_token(client): # Fetches the CSRF token required for POST/write operations. # Add Origin and a more specific Referer header, as the service may require them to issue a CSRF token. @@ -322,4 +355,4 @@ def _validate_user_data_against_supported_fields(scope, user_data, possible_fiel if len(unknown_fields) > 0 or len(missing_required_fields) > 0 or len(fields_with_invalid_option) > 0: raise DataInvalidError(scope, unknown_fields, missing_required_fields, fields_with_invalid_option) - return final_fields \ No newline at end of file + return final_fields diff --git a/plugins/module_utils/systems/main.py b/plugins/module_utils/systems/main.py index d06f44f..0ce13bb 100644 --- a/plugins/module_utils/systems/main.py +++ b/plugins/module_utils/systems/main.py @@ -1,6 +1,4 @@ -import os import pathlib -from requests.exceptions import HTTPError from .. import auth, exceptions from ..client import ApiClient @@ -10,6 +8,7 @@ def run_systems_info(params): # Main runner function for the systems_info module. result = {'changed': False, 'failed': False, 'systems': []} + client = ApiClient() try: auth.login(client, params['suser_id'], params['suser_password']) @@ -23,6 +22,7 @@ def run_systems_info(params): def run_license_keys(params): # Main runner function for the license_keys module. result = {'changed': False, 'failed': False, 'warnings': []} + client = ApiClient() username = params['suser_id'] password = params['suser_password'] @@ -189,4 +189,4 @@ def run_license_keys(params): result['failed'] = True result['msg'] = f"An unexpected error occurred: {type(e).__name__} - {e}" - return result \ No newline at end of file + return result diff --git a/plugins/modules/maintenance_planner_files.py b/plugins/modules/maintenance_planner_files.py index ccd15eb..ef7e68b 100644 --- a/plugins/modules/maintenance_planner_files.py +++ b/plugins/modules/maintenance_planner_files.py @@ -71,7 +71,6 @@ sample: "SAPCAR_1324-80000936.EXE" ''' -import requests from ansible.module_utils.basic import AnsibleModule from ..module_utils.maintenance_planner import main as maintenance_planner_runner diff --git a/plugins/modules/maintenance_planner_stack_xml_download.py b/plugins/modules/maintenance_planner_stack_xml_download.py index cd81926..3f41bdf 100644 --- a/plugins/modules/maintenance_planner_stack_xml_download.py +++ b/plugins/modules/maintenance_planner_stack_xml_download.py @@ -64,7 +64,6 @@ sample: "SAP Maintenance Planner Stack XML successfully downloaded to /tmp/MP_STACK_20211015_044854.xml" ''' -import requests from ansible.module_utils.basic import AnsibleModule from ..module_utils.maintenance_planner import main as maintenance_planner_runner diff --git a/tests/sanity/requirements.txt b/tests/sanity/requirements.txt deleted file mode 100644 index 75b68f4..0000000 --- a/tests/sanity/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -# Requirements for ansible-test sanity -requests -beautifulsoup4 \ No newline at end of file From 27e8f06f5df09714661b9c690990a8cbc848207a Mon Sep 17 00:00:00 2001 From: Marcel Mamula Date: Tue, 7 Oct 2025 13:22:27 +0200 Subject: [PATCH 04/11] add try else for imports and add missing module parameters --- plugins/module_utils/auth.py | 10 ++++++---- plugins/module_utils/client.py | 9 ++++++--- plugins/module_utils/maintenance_planner/api.py | 8 ++++++-- plugins/module_utils/maintenance_planner/main.py | 1 + plugins/module_utils/software_center/download.py | 4 +++- plugins/module_utils/systems/api.py | 5 ++++- plugins/modules/maintenance_planner_files.py | 5 +++++ plugins/modules/software_center_download.py | 10 +++++++++- 8 files changed, 40 insertions(+), 12 deletions(-) diff --git a/plugins/module_utils/auth.py b/plugins/module_utils/auth.py index 6e086d9..90f9829 100644 --- a/plugins/module_utils/auth.py +++ b/plugins/module_utils/auth.py @@ -10,21 +10,23 @@ try: from bs4 import BeautifulSoup - HAS_BS4 = True except ImportError: HAS_BS4 = False BS4_IMPORT_ERROR = traceback.format_exc() BeautifulSoup = None +else: + HAS_BS4 = True + BS4_IMPORT_ERROR = None try: from requests.models import HTTPError - HAS_REQUESTS = True except ImportError: HAS_REQUESTS = False REQUESTS_IMPORT_ERROR = traceback.format_exc() HTTPError = None - -_GIGYA_SDK_BUILD_NUMBER = None +else: + HAS_REQUESTS = True + REQUESTS_IMPORT_ERROR = None def require_bs4(func): diff --git a/plugins/module_utils/client.py b/plugins/module_utils/client.py index 34e0a2d..7b7b563 100644 --- a/plugins/module_utils/client.py +++ b/plugins/module_utils/client.py @@ -10,7 +10,6 @@ import requests from requests.adapters import HTTPAdapter _RequestsSession = requests.Session - HAS_REQUESTS = True except ImportError: HAS_REQUESTS = False REQUESTS_IMPORT_ERROR = traceback.format_exc() @@ -18,16 +17,20 @@ requests = None HTTPAdapter = object _RequestsSession = object +else: + HAS_REQUESTS = True + REQUESTS_IMPORT_ERROR = None try: import urllib3 - HAS_URLLIB3 = True except ImportError: HAS_URLLIB3 = False URLLIB3_IMPORT_ERROR = traceback.format_exc() # Placeholder to prevent errors on module load urllib3 = None - +else: + HAS_URLLIB3 = True + URLLIB3_IMPORT_ERROR = None class _SessionAllowBasicAuthRedirects(_RequestsSession): # By default, the `Authorization` header for Basic Auth will be removed diff --git a/plugins/module_utils/maintenance_planner/api.py b/plugins/module_utils/maintenance_planner/api.py index 8cc5290..8ba8bc8 100644 --- a/plugins/module_utils/maintenance_planner/api.py +++ b/plugins/module_utils/maintenance_planner/api.py @@ -11,17 +11,21 @@ try: from bs4 import BeautifulSoup - HAS_BS4 = True except ImportError: HAS_BS4 = False BS4_IMPORT_ERROR = traceback.format_exc() +else: + HAS_BS4 = True + BS4_IMPORT_ERROR = None try: from lxml import etree - HAS_LXML = True except ImportError: HAS_LXML = False LXML_IMPORT_ERROR = traceback.format_exc() +else: + HAS_LXML = True + LXML_IMPORT_ERROR = None # Module-level cache _MP_XSRF_TOKEN = None diff --git a/plugins/module_utils/maintenance_planner/main.py b/plugins/module_utils/maintenance_planner/main.py index a2a602e..ec04458 100644 --- a/plugins/module_utils/maintenance_planner/main.py +++ b/plugins/module_utils/maintenance_planner/main.py @@ -11,6 +11,7 @@ HAS_REQUESTS = False HTTPError = None + def run_files(params): # Runner for maintenance_planner_files module. result = dict( diff --git a/plugins/module_utils/software_center/download.py b/plugins/module_utils/software_center/download.py index b280eb3..290175d 100644 --- a/plugins/module_utils/software_center/download.py +++ b/plugins/module_utils/software_center/download.py @@ -12,11 +12,13 @@ try: from requests.exceptions import ConnectionError, HTTPError - HAS_REQUESTS = True except ImportError: HAS_REQUESTS = False REQUESTS_IMPORT_ERROR = traceback.format_exc() ConnectionError, HTTPError = None, None +else: + HAS_REQUESTS = True + REQUESTS_IMPORT_ERROR = None _HAS_DOWNLOAD_AUTHORIZATION = None diff --git a/plugins/module_utils/systems/api.py b/plugins/module_utils/systems/api.py index 896fb66..d4eb3ee 100644 --- a/plugins/module_utils/systems/api.py +++ b/plugins/module_utils/systems/api.py @@ -8,6 +8,7 @@ from .. import constants as C from .. import exceptions + class InstallationNotFoundError(Exception): def __init__(self, installation_nr, available_installations): self.installation_nr = installation_nr @@ -56,11 +57,13 @@ def __init__(self, scope, unknown_fields, missing_required_fields, fields_with_i try: from requests.exceptions import HTTPError - HAS_REQUESTS = True except ImportError: HAS_REQUESTS = False REQUESTS_IMPORT_ERROR = traceback.format_exc() HTTPError = None +else: + HAS_REQUESTS = True + REQUESTS_IMPORT_ERROR = None def require_requests(func): diff --git a/plugins/modules/maintenance_planner_files.py b/plugins/modules/maintenance_planner_files.py index ef7e68b..c649ca9 100644 --- a/plugins/modules/maintenance_planner_files.py +++ b/plugins/modules/maintenance_planner_files.py @@ -31,6 +31,11 @@ - Transaction Name or Transaction Display ID from Maintenance Planner. required: true type: str + validate_url: + description: + - Validates if the download URLs are accessible before returning them. + type: bool + default: false author: - Matthias Winzeler (@MatthiasWinzeler) - Marcel Mamula (@marcelmamula) diff --git a/plugins/modules/software_center_download.py b/plugins/modules/software_center_download.py index f5acb4f..84b4178 100644 --- a/plugins/modules/software_center_download.py +++ b/plugins/modules/software_center_download.py @@ -32,21 +32,25 @@ description: - "Deprecated. Use 'search_query' instead." required: false + default: '' type: str search_query: description: - Filename of the SAP software to download. required: false + default: '' type: str download_link: description: - Direct download link to the SAP software. required: false + default: '' type: str download_filename: description: - Download filename of the SAP software. required: false + default: '' type: str dest: description: @@ -57,24 +61,28 @@ description: - "Specifies how to handle multiple search results for the same filename. - Choices are `first` (oldest) or `last` (newest)." - choices: [ 'first', 'last' ] + choices: [ 'first', 'last', '' ] required: false + default: '' type: str search_alternatives: description: - Enable search for alternative packages, when filename is not available. required: false + default: false type: bool dry_run: description: - Check availability of SAP Software without downloading. required: false + default: false type: bool validate_checksum: description: - If a file with the same name already exists at the destination, validate its checksum against the remote file. - If the checksum is invalid, the local file will be removed and re-downloaded. required: false + default: false type: bool author: - Matthias Winzeler (@MatthiasWinzeler) From 63a6d41a9f0e86bf3cbe8dc88903b16490dcb54a Mon Sep 17 00:00:00 2001 From: Marcel Mamula Date: Tue, 7 Oct 2025 13:26:35 +0200 Subject: [PATCH 05/11] fix pylint errors for pep8 and constant defaults --- plugins/module_utils/auth.py | 2 ++ plugins/module_utils/client.py | 1 + 2 files changed, 3 insertions(+) diff --git a/plugins/module_utils/auth.py b/plugins/module_utils/auth.py index 90f9829..3923b76 100644 --- a/plugins/module_utils/auth.py +++ b/plugins/module_utils/auth.py @@ -28,6 +28,8 @@ HAS_REQUESTS = True REQUESTS_IMPORT_ERROR = None +_GIGYA_SDK_BUILD_NUMBER = None + def require_bs4(func): # A decorator to check for the 'beautifulsoup4' library before executing a function. diff --git a/plugins/module_utils/client.py b/plugins/module_utils/client.py index 7b7b563..ca52f1e 100644 --- a/plugins/module_utils/client.py +++ b/plugins/module_utils/client.py @@ -32,6 +32,7 @@ HAS_URLLIB3 = True URLLIB3_IMPORT_ERROR = None + class _SessionAllowBasicAuthRedirects(_RequestsSession): # By default, the `Authorization` header for Basic Auth will be removed # if the redirect is to a different host. From 313cd924d8236f79c169b6509185ba8c8632869c Mon Sep 17 00:00:00 2001 From: Marcel Mamula Date: Tue, 7 Oct 2025 16:02:38 +0200 Subject: [PATCH 06/11] update python2 compatibility and add import fail function --- plugins/module_utils/auth.py | 28 +++--- plugins/module_utils/client.py | 9 +- .../maintenance_planner/__init__.py | 1 - .../module_utils/maintenance_planner/api.py | 53 ++++++++--- .../module_utils/maintenance_planner/main.py | 89 +++++++++---------- .../module_utils/software_center/__init__.py | 1 - .../module_utils/software_center/download.py | 16 ++-- plugins/module_utils/software_center/main.py | 42 +++++---- .../module_utils/software_center/search.py | 16 ++-- plugins/module_utils/systems/__init__.py | 1 - plugins/module_utils/systems/api.py | 69 +++++++------- plugins/module_utils/systems/main.py | 68 +++++++++----- plugins/modules/license_keys.py | 4 +- plugins/modules/maintenance_planner_files.py | 4 +- .../maintenance_planner_stack_xml_download.py | 4 +- plugins/modules/software_center_download.py | 4 +- plugins/modules/systems_info.py | 4 +- 17 files changed, 234 insertions(+), 179 deletions(-) diff --git a/plugins/module_utils/auth.py b/plugins/module_utils/auth.py index 3923b76..7ce6f38 100644 --- a/plugins/module_utils/auth.py +++ b/plugins/module_utils/auth.py @@ -1,6 +1,5 @@ import json import re -import traceback from functools import wraps from urllib.parse import parse_qs, quote_plus, urljoin @@ -12,21 +11,17 @@ from bs4 import BeautifulSoup except ImportError: HAS_BS4 = False - BS4_IMPORT_ERROR = traceback.format_exc() BeautifulSoup = None else: HAS_BS4 = True - BS4_IMPORT_ERROR = None try: from requests.models import HTTPError except ImportError: HAS_REQUESTS = False - REQUESTS_IMPORT_ERROR = traceback.format_exc() HTTPError = None else: HAS_REQUESTS = True - REQUESTS_IMPORT_ERROR = None _GIGYA_SDK_BUILD_NUMBER = None @@ -36,7 +31,7 @@ def require_bs4(func): @wraps(func) def wrapper(*args, **kwargs): if not HAS_BS4: - raise exceptions.SapLaunchpadError(f"The 'beautifulsoup4' library is required. Error: {BS4_IMPORT_ERROR}") + raise ImportError("The 'beautifulsoup4' library is required but was not found.") return func(*args, **kwargs) return wrapper @@ -46,7 +41,7 @@ def require_requests(func): @wraps(func) def wrapper(*args, **kwargs): if not HAS_REQUESTS: - raise exceptions.SapLaunchpadError(f"The 'requests' library is required. Error: {REQUESTS_IMPORT_ERROR}") + raise ImportError("The 'requests' library is required but was not found.") return func(*args, **kwargs) return wrapper @@ -131,8 +126,7 @@ def get_sso_endpoint_meta(client, url, **kwargs): form = soup.find('form') if not form: - raise ValueError( - f'Unable to find form: {res.url}\nContent:\n{res.text}') + raise ValueError('Unable to find form: {0}\nContent:\n{1}'.format(res.url, res.text)) inputs = form.find_all('input') endpoint = urljoin(res.url, form['action']) @@ -157,7 +151,7 @@ def _get_gigya_login_params(client, url, data): @require_requests def _gigya_websdk_bootstrap(client, params): # Performs the initial bootstrap call to the Gigya WebSDK. - page_url = f'{C.URL_ACCOUNT_SAML_PROXY}?apiKey=' + params['apiKey'] + page_url = C.URL_ACCOUNT_SAML_PROXY + '?apiKey=' + params['apiKey'] params.update({ 'pageURL': page_url, 'sdk': 'js_latest', @@ -182,7 +176,7 @@ def _gigya_login(client, username, password, api_key): 'include': 'login_token' } - login_url = f"{C.URL_ACCOUNT_CDC_API}/accounts.login" + login_url = "{0}/accounts.login".format(C.URL_ACCOUNT_CDC_API) res = client.post(login_url, data=login_payload) login_response = res.json() @@ -196,7 +190,7 @@ def _gigya_login(client, username, password, api_key): 'Please log in to https://account.sap.com manually to reset it.' ) error_message = login_response.get('errorDetails', 'Unknown authentication error') - raise exceptions.AuthenticationError(f"Gigya authentication failed: {error_message} (errorCode: {error_code})") + raise exceptions.AuthenticationError("Gigya authentication failed: {0} (errorCode: {1})".format(error_message, error_code)) return login_response.get('login_token') @@ -229,9 +223,9 @@ def _get_uid(client, saml_params, login_token): @require_requests def _get_uid_details(client, uid, id_token): # Fetches detailed account information for a given UID. - url = f'{C.URL_ACCOUNT_CORE_API}/accounts/{uid}' + url = '{0}/accounts/{1}'.format(C.URL_ACCOUNT_CORE_API, uid) headers = C.GIGYA_HEADERS.copy() - headers['Authorization'] = f'Bearer {id_token}' + headers['Authorization'] = 'Bearer {0}'.format(id_token) uid_details_response = client.get(url, headers=headers).json() return uid_details_response @@ -251,11 +245,11 @@ def _is_uid_linked_multiple_sids(uid_details): @require_requests def _select_account(client, uid, sid, id_token): # Selects a specific S-User ID when a Universal ID is linked to multiple accounts. - url = f'{C.URL_ACCOUNT_CORE_API}/accounts/{uid}/selectedAccount' + url = '{0}/accounts/{1}/selectedAccount'.format(C.URL_ACCOUNT_CORE_API, uid) data = {'idsName': sid, 'automatic': 'false'} headers = C.GIGYA_HEADERS.copy() - headers['Authorization'] = f'Bearer {id_token}' + headers['Authorization'] = 'Bearer {0}'.format(id_token) return client.request('PUT', url, headers=headers, json=data) @@ -282,7 +276,7 @@ def _cdc_api_request(client, endpoint, saml_params, query_params): # Helper to make requests to the Gigya/CDC API, handling common parameters and errors. url = '/'.join((C.URL_ACCOUNT_CDC_API, endpoint)) - query = '&'.join([f'{k}={v}' for k, v in saml_params.items()]) + query = '&'.join(['{0}={1}'.format(k, v) for k, v in saml_params.items()]) page_url = quote_plus('?'.join((C.URL_ACCOUNT_SAML_PROXY, query))) api_key = saml_params['apiKey'] diff --git a/plugins/module_utils/client.py b/plugins/module_utils/client.py index ca52f1e..80279d1 100644 --- a/plugins/module_utils/client.py +++ b/plugins/module_utils/client.py @@ -1,5 +1,4 @@ import re -import traceback from urllib.parse import urlparse @@ -12,25 +11,21 @@ _RequestsSession = requests.Session except ImportError: HAS_REQUESTS = False - REQUESTS_IMPORT_ERROR = traceback.format_exc() # Placeholders to prevent errors on module load requests = None HTTPAdapter = object _RequestsSession = object else: HAS_REQUESTS = True - REQUESTS_IMPORT_ERROR = None try: import urllib3 except ImportError: HAS_URLLIB3 = False - URLLIB3_IMPORT_ERROR = traceback.format_exc() # Placeholder to prevent errors on module load urllib3 = None else: HAS_URLLIB3 = True - URLLIB3_IMPORT_ERROR = None class _SessionAllowBasicAuthRedirects(_RequestsSession): @@ -74,9 +69,9 @@ class ApiClient: # previous global session and request functions. def __init__(self): if not HAS_REQUESTS: - raise exceptions.SapLaunchpadError(f"The 'requests' library is required. Error: {REQUESTS_IMPORT_ERROR}") + raise ImportError("The 'requests' library is required but was not found.") if not HAS_URLLIB3: - raise exceptions.SapLaunchpadError(f"The 'urllib3' library is required. Error: {URLLIB3_IMPORT_ERROR}") + raise ImportError("The 'urllib3' library is required but was not found.") self.session = _SessionAllowBasicAuthRedirects() diff --git a/plugins/module_utils/maintenance_planner/__init__.py b/plugins/module_utils/maintenance_planner/__init__.py index 8724d09..e69de29 100644 --- a/plugins/module_utils/maintenance_planner/__init__.py +++ b/plugins/module_utils/maintenance_planner/__init__.py @@ -1 +0,0 @@ -# This file makes the `maintenance_planner` directory into a Python package. diff --git a/plugins/module_utils/maintenance_planner/api.py b/plugins/module_utils/maintenance_planner/api.py index 8ba8bc8..fab0ec3 100644 --- a/plugins/module_utils/maintenance_planner/api.py +++ b/plugins/module_utils/maintenance_planner/api.py @@ -1,6 +1,5 @@ import re import time -import traceback from html import unescape from functools import wraps from urllib.parse import urljoin @@ -13,19 +12,25 @@ from bs4 import BeautifulSoup except ImportError: HAS_BS4 = False - BS4_IMPORT_ERROR = traceback.format_exc() + BeautifulSoup = None else: HAS_BS4 = True - BS4_IMPORT_ERROR = None try: from lxml import etree except ImportError: HAS_LXML = False - LXML_IMPORT_ERROR = traceback.format_exc() + etree = None else: HAS_LXML = True - LXML_IMPORT_ERROR = None + +try: + from requests.exceptions import HTTPError +except ImportError: + HAS_REQUESTS = False + HTTPError = None +else: + HAS_REQUESTS = True # Module-level cache _MP_XSRF_TOKEN = None @@ -38,7 +43,7 @@ def require_bs4(func): @wraps(func) def wrapper(*args, **kwargs): if not HAS_BS4: - raise exceptions.SapLaunchpadError(f"The 'beautifulsoup4' library is required. Error: {BS4_IMPORT_ERROR}") + raise ImportError("The 'beautifulsoup4' library is required but was not found.") return func(*args, **kwargs) return wrapper @@ -48,7 +53,17 @@ def require_lxml(func): @wraps(func) def wrapper(*args, **kwargs): if not HAS_LXML: - raise exceptions.SapLaunchpadError(f"The 'lxml' library is required. Error: {LXML_IMPORT_ERROR}") + raise ImportError("The 'lxml' library is required but was not found.") + return func(*args, **kwargs) + return wrapper + + +def require_requests(func): + # A decorator to check for the 'requests' library before executing a function. + @wraps(func) + def wrapper(*args, **kwargs): + if not HAS_REQUESTS: + raise ImportError("The 'requests' library is required but was not found.") return func(*args, **kwargs) return wrapper @@ -103,11 +118,12 @@ def get_transaction_id(client, name): if t.get('trans_display_id') == name: return t['trans_id'] - raise exceptions.FileNotFoundError(f"Transaction '{name}' not found by name or display ID.") + raise exceptions.FileNotFoundError("Transaction '{0}' not found by name or display ID.".format(name)) @require_lxml -def get_transaction_filename_url(client, trans_id): +@require_requests +def get_transaction_filename_url(client, trans_id, validate_url=False): # Parses the files XML to get a list of (URL, Filename) tuples. xml = _get_download_files_xml(client, trans_id) e = etree.fromstring(xml.encode('utf-16')) @@ -116,13 +132,22 @@ def get_transaction_filename_url(client, trans_id): namespaces={'mnp': _MP_NAMESPACE} ) if not stack_files: - raise exceptions.FileNotFoundError(f"No stack files found in transaction ID {trans_id}.") + raise exceptions.FileNotFoundError("No stack files found in transaction ID {0}.".format(trans_id)) files = [] for f in stack_files: file_id = urljoin(C.URL_SOFTWARE_DOWNLOAD, '/file/' + f.get('id')) file_name = f.get('label') files.append((file_id, file_name)) + + if validate_url: + for pair in files: + url = pair[0] + try: + client.head(url) + except HTTPError: + raise exceptions.DownloadError('Download link is not available: {0}'.format(url)) + return files @@ -212,13 +237,13 @@ def _get_transaction(client, key, value): for t in transactions: if t.get(key) == value: return t - raise exceptions.FileNotFoundError(f"Transaction with {key}='{value}' not found.") + raise exceptions.FileNotFoundError("Transaction with {0}='{1}' not found.".format(key, value)) @require_lxml def _build_mnp_xml(**params): # Constructs the MNP XML payload for API requests. - mnp = f'{{{_MP_NAMESPACE}}}' + mnp = '{{{0}}}'.format(_MP_NAMESPACE) request_keys = ['action', 'trans_name', 'sub_action', 'navigation'] request_attrs = {k: params.get(k, '') for k in request_keys} @@ -226,8 +251,8 @@ def _build_mnp_xml(**params): entity_keys = ['call_for', 'sessionid'] entity_attrs = {k: params.get(k, '') for k in entity_keys} - request = etree.Element(f'{mnp}request', nsmap={"mnp": _MP_NAMESPACE}, attrib=request_attrs) - entity = etree.SubElement(request, f'{mnp}entity', attrib=entity_attrs) + request = etree.Element('{0}request'.format(mnp), nsmap={"mnp": _MP_NAMESPACE}, attrib=request_attrs) + entity = etree.SubElement(request, '{0}entity'.format(mnp), attrib=entity_attrs) entity.text = '' if 'entities' in params and isinstance(params['entities'], etree._Element): diff --git a/plugins/module_utils/maintenance_planner/main.py b/plugins/module_utils/maintenance_planner/main.py index ec04458..2578650 100644 --- a/plugins/module_utils/maintenance_planner/main.py +++ b/plugins/module_utils/maintenance_planner/main.py @@ -4,13 +4,6 @@ from ..client import ApiClient from . import api -try: - from requests.exceptions import HTTPError - HAS_REQUESTS = True -except ImportError: - HAS_REQUESTS = False - HTTPError = None - def run_files(params): # Runner for maintenance_planner_files module. @@ -20,42 +13,41 @@ def run_files(params): msg='' ) - if not HAS_REQUESTS: - result['failed'] = True - result['msg'] = "The 'requests' library is required for this module." - return result - - client = ApiClient() - username = params['suser_id'] - password = params['suser_password'] - transaction_name = params['transaction_name'] - validate_url = params['validate_url'] - try: + client = ApiClient() + username = params['suser_id'] + password = params['suser_password'] + transaction_name = params['transaction_name'] + validate_url = params['validate_url'] + auth.login(client, username, password) api.auth_userapps(client) transaction_id = api.get_transaction_id(client, transaction_name) - download_basket_details = api.get_transaction_filename_url(client, transaction_id) - - if validate_url: - for pair in download_basket_details: - url = pair[0] - try: - client.head(url) - except HTTPError: - raise exceptions.DownloadError(f'Download link is not available: {url}') + download_basket_details = api.get_transaction_filename_url(client, transaction_id, validate_url) result['download_basket'] = [{'DirectLink': i[0], 'Filename': i[1]} for i in download_basket_details] result['changed'] = True result['msg'] = "Successfully retrieved file list from SAP Maintenance Planner." - except (exceptions.SapLaunchpadError, HTTPError) as e: + except ImportError as e: + result['failed'] = True + if 'requests' in str(e): + result['missing_dependency'] = 'requests' + elif 'urllib3' in str(e): + result['missing_dependency'] = 'urllib3' + elif 'beautifulsoup4' in str(e): + result['missing_dependency'] = 'beautifulsoup4' + elif 'lxml' in str(e): + result['missing_dependency'] = 'lxml' + else: + result['msg'] = "An unexpected import error occurred: {0}".format(e) + except exceptions.SapLaunchpadError as e: result['failed'] = True result['msg'] = str(e) except Exception as e: result['failed'] = True - result['msg'] = f"An unexpected error occurred: {e}" + result['msg'] = 'An unexpected error occurred: {0}'.format(e) return result @@ -67,18 +59,13 @@ def run_stack_xml_download(params): msg='' ) - if not HAS_REQUESTS: - result['failed'] = True - result['msg'] = "The 'requests' library is required for this module." - return result - - client = ApiClient() - username = params['suser_id'] - password = params['suser_password'] - transaction_name = params['transaction_name'] - dest = params['dest'] - try: + client = ApiClient() + username = params['suser_id'] + password = params['suser_password'] + transaction_name = params['transaction_name'] + dest = params['dest'] + auth.login(client, username, password) api.auth_userapps(client) @@ -86,12 +73,12 @@ def run_stack_xml_download(params): xml_content, filename = api.get_transaction_stack_xml_content(client, transaction_id) if not filename: - filename = f"{transaction_name}_stack.xml" + filename = "{0}_stack.xml".format(transaction_name) dest_path = pathlib.Path(dest) if not dest_path.is_dir(): result['failed'] = True - result['msg'] = f"Destination directory does not exist: {dest}" + result['msg'] = "Destination directory does not exist: {0}".format(dest) return result output_file = dest_path / filename @@ -101,17 +88,27 @@ def run_stack_xml_download(params): f.write(xml_content) except IOError as e: result['failed'] = True - result['msg'] = f"Failed to write to destination file {output_file}: {e}" + result['msg'] = "Failed to write to destination file {0}: {1}".format(output_file, e) return result result['changed'] = True - result['msg'] = f"SAP Maintenance Planner Stack XML successfully downloaded to {output_file}" + result['msg'] = "SAP Maintenance Planner Stack XML successfully downloaded to {0}".format(output_file) - except (exceptions.SapLaunchpadError, HTTPError) as e: + except ImportError as e: + result['failed'] = True + if 'requests' in str(e): + result['missing_dependency'] = 'requests' + elif 'urllib3' in str(e): + result['missing_dependency'] = 'urllib3' + elif 'beautifulsoup4' in str(e) or 'lxml' in str(e): + result['missing_dependency'] = 'beautifulsoup4 and/or lxml' + else: + result['msg'] = "An unexpected import error occurred: {0}".format(e) + except exceptions.SapLaunchpadError as e: result['failed'] = True result['msg'] = str(e) except Exception as e: result['failed'] = True - result['msg'] = f"An unexpected error occurred: {e}" + result['msg'] = 'An unexpected error occurred: {0}'.format(e) return result diff --git a/plugins/module_utils/software_center/__init__.py b/plugins/module_utils/software_center/__init__.py index a55ae46..e69de29 100644 --- a/plugins/module_utils/software_center/__init__.py +++ b/plugins/module_utils/software_center/__init__.py @@ -1 +0,0 @@ -# This file makes the `software_center` directory into a Python package. diff --git a/plugins/module_utils/software_center/download.py b/plugins/module_utils/software_center/download.py index 290175d..fa87152 100644 --- a/plugins/module_utils/software_center/download.py +++ b/plugins/module_utils/software_center/download.py @@ -2,7 +2,6 @@ import hashlib import os import time -import traceback from functools import wraps from .. import auth @@ -14,11 +13,9 @@ from requests.exceptions import ConnectionError, HTTPError except ImportError: HAS_REQUESTS = False - REQUESTS_IMPORT_ERROR = traceback.format_exc() ConnectionError, HTTPError = None, None else: HAS_REQUESTS = True - REQUESTS_IMPORT_ERROR = None _HAS_DOWNLOAD_AUTHORIZATION = None @@ -28,7 +25,7 @@ def require_requests(func): @wraps(func) def wrapper(*args, **kwargs): if not HAS_REQUESTS: - raise exceptions.SapLaunchpadError(f"The 'requests' library is required. Error: {REQUESTS_IMPORT_ERROR}") + raise ImportError("The 'requests' library is required but was not found.") return func(*args, **kwargs) return wrapper @@ -63,7 +60,8 @@ def validate_local_file_checksum(client, local_filepath, query=None, download_li remote_etag = headers.get('ETag') if not remote_etag: - result['message'] = f"Checksum validation skipped: ETag header not found for URL '{download_link_final}'. Headers received: {headers}" + result['message'] = ("Checksum validation skipped: ETag header not found for URL '{0}'. Headers received: {1}" + .format(download_link_final, headers)) return result if _is_checksum_matched(local_filepath, remote_etag): @@ -74,7 +72,7 @@ def validate_local_file_checksum(client, local_filepath, query=None, download_li result['message'] = 'Local file checksum is invalid.' except exceptions.SapLaunchpadError as e: - result['message'] = f'Checksum validation skipped: {e}' + result['message'] = 'Checksum validation skipped: {0}'.format(e) return result @@ -165,7 +163,7 @@ def _resolve_download_link(client, url, retry=0): client.session.cookies.clear(domain='.softwaredownloads.sap.com') # Retry on 403 (Forbidden) as it can be a temporary token issue. if (isinstance(e, HTTPError) and e.response.status_code != 403) or retry >= C.MAX_RETRY_TIMES: - raise exceptions.DownloadError(f"Could not resolve download URL after {C.MAX_RETRY_TIMES} retries: {e}") + raise exceptions.DownloadError("Could not resolve download URL after {0} retries: {1}".format(C.MAX_RETRY_TIMES, e)) time.sleep(60 * (retry + 1)) return _resolve_download_link(client, url, retry + 1) @@ -187,7 +185,7 @@ def stream_file_to_disk(client, url, filepath, retry=0, **kwargs): if os.path.exists(filepath): os.remove(filepath) if retry >= C.MAX_RETRY_TIMES: - raise exceptions.DownloadError(f"Connection failed after {C.MAX_RETRY_TIMES} retries: {e}") + raise exceptions.DownloadError("Connection failed after {0} retries: {1}".format(C.MAX_RETRY_TIMES, e)) time.sleep(60 * (retry + 1)) return stream_file_to_disk(client, url, filepath, retry + 1, **kwargs) @@ -202,7 +200,7 @@ def stream_file_to_disk(client, url, filepath, retry=0, **kwargs): os.remove(filepath) if retry >= C.MAX_RETRY_TIMES: - raise exceptions.DownloadError(f'Failed to download {url}: checksum mismatch after {C.MAX_RETRY_TIMES} retries') + raise exceptions.DownloadError('Failed to download {0}: checksum mismatch after {1} retries'.format(url, C.MAX_RETRY_TIMES)) return stream_file_to_disk(client, url, filepath, retry + 1, **kwargs) diff --git a/plugins/module_utils/software_center/main.py b/plugins/module_utils/software_center/main.py index 5885469..981d222 100644 --- a/plugins/module_utils/software_center/main.py +++ b/plugins/module_utils/software_center/main.py @@ -55,17 +55,17 @@ def run_software_download(params): if not validate_checksum: if os.path.exists(filepath): result['skipped'] = True - result['msg'] = f"File already exists: {filename}" + result['msg'] = "File already exists: {0}".format(filename) return result filename_similar_exists, filename_similar_names = download.check_similar_files(dest, filename) if filename_similar_exists: result['skipped'] = True - result['msg'] = f"Similar file(s) already exist: {', '.join(filename_similar_names)}" + result['msg'] = "Similar file(s) already exist: {0}".format(', '.join(filename_similar_names)) return result - client = ApiClient() try: + client = ApiClient() auth.login(client, username, password) validation_result = None @@ -91,7 +91,7 @@ def run_software_download(params): if is_valid is True: result['skipped'] = True - result['msg'] = f"File already exists and checksum is valid: {filename}" + result['msg'] = "File already exists and checksum is valid: {0}".format(filename) return result elif is_valid is False: # The existing file is invalid, remove it to allow for re-download. @@ -99,7 +99,7 @@ def run_software_download(params): os.remove(filepath) else: # Validation could not be performed result['skipped'] = True - result['msg'] = f"File already exists: {filename}. {validation_result['message']}" + result['msg'] = "File already exists: {0}. {1}".format(filename, validation_result['message']) return result alternative_found = False @@ -119,18 +119,18 @@ def run_software_download(params): validation_result = download.validate_local_file_checksum(client, alt_filepath, download_link=download_link) if validation_result['validated'] is True: result['skipped'] = True - result['msg'] = f"Alternative file {download_filename} already exists and checksum is valid." + result['msg'] = "Alternative file {0} already exists and checksum is valid.".format(download_filename) return result elif validation_result['validated'] is False: # The existing alternative file is invalid, remove it to allow for re-download. os.remove(alt_filepath) else: # Validation could not be performed result['skipped'] = True - result['msg'] = f"Alternative file {download_filename} already exists. {validation_result['message']}" + result['msg'] = "Alternative file {0} already exists. {1}".format(download_filename, validation_result['message']) return result else: result['skipped'] = True - result['msg'] = f"File with correct/alternative name already exists: {download_filename}" + result['msg'] = "File with correct/alternative name already exists: {0}".format(download_filename) return result final_url = download.is_download_link_available(client, download_link) @@ -138,7 +138,7 @@ def run_software_download(params): if dry_run: msg = f"SAP Software is available to download: {download_filename}" if alternative_found: - msg = f"Alternative SAP Software is available to download: {download_filename} - original file {query} is not available" + msg = "Alternative SAP Software is available to download: {0} - original file {1} is not available".format(download_filename, query) result['msg'] = msg else: # The link is already resolved, just download it. @@ -147,22 +147,34 @@ def run_software_download(params): result['changed'] = True if validation_result and validation_result.get('validated') is False: - result['msg'] = f"Successfully re-downloaded {download_filename} due to an invalid checksum." + result['msg'] = "Successfully re-downloaded {0} due to an invalid checksum.".format(download_filename) elif alternative_found: - result['msg'] = (f"Successfully downloaded alternative SAP software: {download_filename} " - f"- original file {query} is not available to download") + result['msg'] = ("Successfully downloaded alternative SAP software: {0} " + "- original file {1} is not available to download".format(download_filename, query)) else: - result['msg'] = f"Successfully downloaded SAP software: {download_filename}" + result['msg'] = "Successfully downloaded SAP software: {0}".format(download_filename) else: result['failed'] = True - result['msg'] = f"Download link for {download_filename} is not available." + result['msg'] = "Download link for {0} is not available.".format(download_filename) + except ImportError as e: + result['failed'] = True + if 'requests' in str(e): + result['missing_dependency'] = 'requests' + elif 'urllib3' in str(e): + result['missing_dependency'] = 'urllib3' + elif 'beautifulsoup4' in str(e): + result['missing_dependency'] = 'beautifulsoup4' + elif 'lxml' in str(e): + result['missing_dependency'] = 'lxml' + else: + result['msg'] = "An unexpected import error occurred: {0}".format(e) except exceptions.SapLaunchpadError as e: result['failed'] = True result['msg'] = str(e) except Exception as e: result['failed'] = True - result['msg'] = f"An unexpected error occurred: {type(e).__name__} - {e}" + result['msg'] = "An unexpected error occurred: {0} - {1}".format(type(e).__name__, e) finally: download.clear_download_key_cookie(client) diff --git a/plugins/module_utils/software_center/search.py b/plugins/module_utils/software_center/search.py index c54052c..97f227f 100644 --- a/plugins/module_utils/software_center/search.py +++ b/plugins/module_utils/software_center/search.py @@ -21,12 +21,12 @@ def find_file(client, name, deduplicate, search_alternatives): if files_count == 0: # If no exact match is found, and alternatives are requested, perform a fuzzy search. if not search_alternatives: - raise FileNotFoundError(f'File "{name}" is not available. To find a replacement, enable "search_alternatives".') + raise FileNotFoundError('File "{0}" is not available. To find a replacement, enable "search_alternatives".'.format(name)) software_fuzzy_found = _search_software_fuzzy(client, name) software_fuzzy_filtered, suggested_filename = _filter_fuzzy_search(software_fuzzy_found, name) if len(software_fuzzy_filtered) == 0: - raise FileNotFoundError(f'File "{name}" is not available and no alternatives could be found.') + raise FileNotFoundError('File "{0}" is not available and no alternatives could be found.'.format(name)) software_fuzzy_alternatives = software_fuzzy_filtered[0].get('Title') @@ -41,10 +41,10 @@ def find_file(client, name, deduplicate, search_alternatives): alternatives_count = len(software_search_alternatives_filtered) if alternatives_count == 0: - raise FileNotFoundError(f'File "{name}" is not available and no alternatives could be found.') + raise FileNotFoundError('File "{0}" is not available and no alternatives could be found.'.format(name)) elif alternatives_count > 1 and deduplicate == '': names = [s['Title'] for s in software_search_alternatives_filtered] - raise FileNotFoundError(f'More than one alternative was found: {", ".join(names)}. Please use a more specific filename.') + raise FileNotFoundError('More than one alternative was found: {0}. Please use a more specific filename.'.format(", ".join(names))) elif alternatives_count > 1 and deduplicate == 'first': software_found = software_search_alternatives_filtered[0] alternative_found = True @@ -59,7 +59,7 @@ def find_file(client, name, deduplicate, search_alternatives): elif files_count > 1 and deduplicate == '': # Handle cases where the direct search returns multiple exact matches. names = [s['Title'] for s in software_filtered] - raise FileNotFoundError(f'More than one result was found: {", ".join(names)}. Please use the correct full filename.') + raise FileNotFoundError('More than one result was found: {0}. Please use the correct full filename.'.format(", ".join(names))) elif files_count > 1 and deduplicate == 'first': software_found = software_filtered[0] elif files_count > 1 and deduplicate == 'last': @@ -188,17 +188,17 @@ def _prepare_search_filename_specific(filename): # Example: IMDB_LCAPPS_122P_3300-20010426.SAR returns IMDB_LCAPPS_122 elif filename_base.startswith('IMDB_LCAPPS_1'): filename_parts = filename.split('-')[0].rsplit('_', 2) - return f"{filename_parts[0]}_{filename_parts[1][:3]}" + return "{0}_{1}".format(filename_parts[0], filename_parts[1][:3]) # Example: IMDB_LCAPPS_2067P_400-80002183.SAR returns IMDB_LCAPPS_206 elif filename_base.startswith('IMDB_LCAPPS_2'): filename_parts = filename.split('-')[0].rsplit('_', 2) - return f"{filename_parts[0]}_{filename_parts[1][:3]}" + return "{0}_{1}".format(filename_parts[0], filename_parts[1][:3]) # Example: IMDB_SERVER20_067_4-80002046.SAR returns IMDB_SERVER20_06 (SPS06) elif filename_base.startswith('IMDB_SERVER'): filename_parts = filename.split('-')[0].rsplit('_', 2) - return f"{filename_parts[0]}_{filename_parts[1][:2]}" + return "{0}_{1}".format(filename_parts[0], filename_parts[1][:2]) # Example: SAPEXE_100-80005374.SAR returns SAPEXE_100 elif filename_base.startswith('SAPEXE'): diff --git a/plugins/module_utils/systems/__init__.py b/plugins/module_utils/systems/__init__.py index 15edc0b..e69de29 100644 --- a/plugins/module_utils/systems/__init__.py +++ b/plugins/module_utils/systems/__init__.py @@ -1 +0,0 @@ -# This file makes the `systems` directory into a Python package. diff --git a/plugins/module_utils/systems/api.py b/plugins/module_utils/systems/api.py index d4eb3ee..a720162 100644 --- a/plugins/module_utils/systems/api.py +++ b/plugins/module_utils/systems/api.py @@ -1,6 +1,5 @@ import json import time -import traceback from functools import wraps from urllib.parse import urljoin @@ -13,35 +12,37 @@ class InstallationNotFoundError(Exception): def __init__(self, installation_nr, available_installations): self.installation_nr = installation_nr self.available_installations = available_installations - super().__init__(f"Installation number '{installation_nr}' not found. Available installations: {available_installations}") + super(InstallationNotFoundError, self).__init__( + "Installation number '{0}' not found. Available installations: {1}".format(installation_nr, available_installations) + ) class SystemNotFoundError(Exception): def __init__(self, system_nr, details): self.system_nr = system_nr self.details = details - super().__init__(f"System with number '{system_nr}' not found. Details: {details}") + super(SystemNotFoundError, self).__init__("System with number '{0}' not found. Details: {1}".format(system_nr, details)) class ProductNotFoundError(Exception): def __init__(self, product, available_products): self.product = product self.available_products = available_products - super().__init__(f"Product '{product}' not found. Available products: {available_products}") + super(ProductNotFoundError, self).__init__("Product '{0}' not found. Available products: {1}".format(product, available_products)) class VersionNotFoundError(Exception): def __init__(self, version, available_versions): self.version = version self.available_versions = available_versions - super().__init__(f"Version '{version}' not found. Available versions: {available_versions}") + super(VersionNotFoundError, self).__init__("Version '{0}' not found. Available versions: {1}".format(version, available_versions)) class LicenseTypeInvalidError(Exception): def __init__(self, license_type, available_license_types): self.license_type = license_type self.available_license_types = available_license_types - super().__init__(f"License type '{license_type}' is invalid. Available types: {available_license_types}") + super(LicenseTypeInvalidError, self).__init__("License type '{0}' is invalid. Available types: {1}".format(license_type, available_license_types)) class DataInvalidError(Exception): @@ -50,20 +51,19 @@ def __init__(self, scope, unknown_fields, missing_required_fields, fields_with_i self.unknown_fields = unknown_fields self.missing_required_fields = missing_required_fields self.fields_with_invalid_option = fields_with_invalid_option - message = (f"Invalid data for {scope}: Unknown fields: {unknown_fields}, " - f"Missing required fields: {missing_required_fields}, Invalid options: {fields_with_invalid_option}") - super().__init__(message) + message = ("Invalid data for {0}: Unknown fields: {1}, Missing required fields: {2}, " + "Invalid options: {3}".format(scope, unknown_fields, missing_required_fields, + fields_with_invalid_option)) + super(DataInvalidError, self).__init__(message) try: from requests.exceptions import HTTPError except ImportError: HAS_REQUESTS = False - REQUESTS_IMPORT_ERROR = traceback.format_exc() HTTPError = None else: HAS_REQUESTS = True - REQUESTS_IMPORT_ERROR = None def require_requests(func): @@ -71,7 +71,7 @@ def require_requests(func): @wraps(func) def wrapper(*args, **kwargs): if not HAS_REQUESTS: - raise exceptions.SapLaunchpadError(f"The 'requests' library is required. Error: {REQUESTS_IMPORT_ERROR}") + raise ImportError("The 'requests' library is required but was not found.") return func(*args, **kwargs) return wrapper @@ -79,14 +79,14 @@ def wrapper(*args, **kwargs): @require_requests def get_systems(client, filter_str): # Retrieves a list of systems based on an OData filter string. - query_path = f"Systems?$filter={filter_str}" + query_path = "Systems?$filter={0}".format(filter_str) return client.get(_url(query_path), headers=_headers({})).json()['d']['results'] @require_requests def get_system(client, system_nr, installation_nr, username): # Retrieves details for a single, specific system. - filter_str = f"Uname eq '{username}' and Insnr eq '{installation_nr}' and Sysnr eq '{system_nr}'" + filter_str = "Uname eq '{0}' and Insnr eq '{1}' and Sysnr eq '{2}'".format(username, installation_nr, system_nr) try: systems = get_systems(client, filter_str) except HTTPError as err: @@ -102,8 +102,8 @@ def get_system(client, system_nr, installation_nr, username): system = systems[0] if 'Prodver' not in system and 'Version' not in system: - message = (f"System {system_nr} was found, but it is missing a required Product Version ID " - f"(checked for 'Prodver' and 'Version' keys). System details: {system}") + message = ("System {0} was found, but it is missing a required Product Version ID " + "(checked for 'Prodver' and 'Version' keys). System details: {1}".format(system_nr, system)) raise exceptions.SapLaunchpadError(message) return system @@ -112,7 +112,7 @@ def get_system(client, system_nr, installation_nr, username): @require_requests def get_product_id(client, product_name, installation_nr, username): # Finds the internal product ID for a given product name. - query_path = f"SysProducts?$filter=Uname eq '{username}' and Insnr eq '{installation_nr}' and Sysnr eq '' and Nocheck eq ''" + query_path = "SysProducts?$filter=Uname eq '{0}' and Insnr eq '{1}' and Sysnr eq '' and Nocheck eq ''".format(username, installation_nr) products = client.get(_url(query_path), headers=_headers({})).json()['d']['results'] product = next((p for p in products if p['Description'] == product_name), None) if product is None: @@ -123,7 +123,7 @@ def get_product_id(client, product_name, installation_nr, username): @require_requests def get_version_id(client, version_name, product_id, installation_nr, username): # Finds the internal version ID for a given product version name. - query_path = f"SysVersions?$filter=Uname eq '{username}' and Insnr eq '{installation_nr}' and Product eq '{product_id}' and Nocheck eq ''" + query_path = "SysVersions?$filter=Uname eq '{0}' and Insnr eq '{1}' and Product eq '{2}' and Nocheck eq ''".format(username, installation_nr, product_id) versions = client.get(_url(query_path), headers=_headers({})).json()['d']['results'] version = next((v for v in versions if v['Description'] == version_name), None) if version is None: @@ -134,7 +134,7 @@ def get_version_id(client, version_name, product_id, installation_nr, username): @require_requests def validate_installation(client, installation_nr, username): # Checks if the user has access to the specified installation number. - query_path = f"Installations?$filter=Ubname eq '{username}' and ValidateOnly eq ''" + query_path = "Installations?$filter=Ubname eq '{0}' and ValidateOnly eq ''".format(username) installations = client.get(_url(query_path), headers=_headers({})).json()['d']['results'] if not any(i['Insnr'] == installation_nr for i in installations): raise InstallationNotFoundError(installation_nr, [i['Insnr'] for i in installations]) @@ -143,7 +143,7 @@ def validate_installation(client, installation_nr, username): @require_requests def validate_system_data(client, data, version_id, system_nr, installation_nr, username): # Validates user-provided system data against the fields supported by the API for a given product version. - query_path = f"SystData?$filter=Pvnr eq '{version_id}' and Insnr eq '{installation_nr}'" + query_path = "SystData?$filter=Pvnr eq '{0}' and Insnr eq '{1}'".format(version_id, installation_nr) results = client.get(_url(query_path), headers=_headers({})).json()['d']['results'][0] possible_fields = json.loads(results['Output']) final_fields = _validate_user_data_against_supported_fields("system", data, possible_fields) @@ -153,7 +153,7 @@ def validate_system_data(client, data, version_id, system_nr, installation_nr, u final_fields['Uname'] = username final_fields['Sysnr'] = system_nr final_fields_for_check = [{"name": k, "value": v} for k, v in final_fields.items()] - query_path = f"SystemDataCheck?$filter=Nocheck eq '' and Data eq '{json.dumps(final_fields_for_check)}'" + query_path = "SystemDataCheck?$filter=Nocheck eq '' and Data eq '{0}'".format(json.dumps(final_fields_for_check)) results = client.get(_url(query_path), headers=_headers({})).json()['d']['results'] warning = None @@ -167,7 +167,7 @@ def validate_system_data(client, data, version_id, system_nr, installation_nr, u @require_requests def validate_licenses(client, licenses, version_id, installation_nr, username): # Validates user-provided license data against the license types and fields supported by the API. - query_path = f"LicenseType?$filter=PRODUCT eq '{version_id}' and INSNR eq '{installation_nr}' and Uname eq '{username}' and Nocheck eq 'X'" + query_path = "LicenseType?$filter=PRODUCT eq '{0}' and INSNR eq '{1}' and Uname eq '{2}' and Nocheck eq 'X'".format(version_id, installation_nr, username) results = client.get(_url(query_path), headers=_headers({})).json()['d']['results'] available_license_types = {r["LICENSETYPE"] for r in results} license_data = [] @@ -177,7 +177,7 @@ def validate_licenses(client, licenses, version_id, installation_nr, username): if result is None: raise LicenseTypeInvalidError(lic['type'], available_license_types) - final_fields = _validate_user_data_against_supported_fields(f'license {lic["type"]}', lic['data'], json.loads(result["Selfields"])) + final_fields = _validate_user_data_against_supported_fields('license {0}'.format(lic["type"]), lic['data'], json.loads(result["Selfields"])) final_fields = {k.upper(): v for k, v in final_fields.items()} final_fields["LICENSETYPE"] = result['PRODID'] final_fields["LICENSETYPETEXT"] = result['LICENSETYPE'] @@ -190,7 +190,7 @@ def get_existing_licenses(client, system_nr, username): # Retrieves all existing license keys for a given system. # When updating the licenses based on the results here, the backend expects a completely different format. # This function transforms the response to the format the backend expects for subsequent update calls. - query_path = f"LicenseKeys?$filter=Uname eq '{username}' and Sysnr eq '{system_nr}'" + query_path = "LicenseKeys?$filter=Uname eq '{0}' and Sysnr eq '{1}'".format(username, system_nr) results = client.get(_url(query_path), headers=_headers({})).json()['d']['results'] return [ { @@ -242,8 +242,8 @@ def submit_system(client, is_new, system_data, generated_licenses, username): licdata = json.loads(response['d']['licdata']) if not licdata: raise exceptions.SapLaunchpadError( - "The API call to submit the system was successful, but the response did not contain the expected system number. " - f"The 'licdata' field in the API response was empty: {response['d']['licdata']}" + "The API call to submit the system was successful, but the response did not contain the expected system number. " + + "The 'licdata' field in the API response was empty: {0}".format(response['d']['licdata']) ) return licdata[0]['VALUE'] @@ -253,7 +253,9 @@ def get_license_key_numbers(client, license_data, system_nr, username): # Retrieves the unique key numbers for a list of recently created licenses. key_nrs = [] for lic in license_data: - query_path = f"LicenseKeys?$filter=Uname eq '{username}' and Sysnr eq '{system_nr}' and Prodid eq '{lic['LICENSETYPE']}' and Hwkey eq '{lic['HWKEY']}'" + query_path_template = ("LicenseKeys?$filter=Uname eq '{0}' and Sysnr eq '{1}' and " + "Prodid eq '{2}' and Hwkey eq '{3}'") + query_path = query_path_template.format(username, system_nr, lic['LICENSETYPE'], lic['HWKEY']) # Retry logic to handle potential replication delay in the backend API after a license is submitted. for attempt in range(9): @@ -266,8 +268,9 @@ def get_license_key_numbers(client, license_data, system_nr, username): time.sleep(10) # Wait 10 seconds before retrying else: # This 'else' belongs to the 'for' loop, it runs if the loop completes without a 'break' raise exceptions.SapLaunchpadError( - f"Could not find license key number for license type '{lic['LICENSETYPE']}' and HW key '{lic['HWKEY']}' " - f"on system '{system_nr}' after submitting the changes. There might be a replication delay in the SAP backend." + ("Could not find license key number for license type '{0}' and HW key '{1}' " + "on system '{2}' after submitting the changes. There might be a replication delay in the SAP backend.") + .format(lic['LICENSETYPE'], lic['HWKEY'], system_nr) ) return key_nrs @@ -277,7 +280,7 @@ def get_license_key_numbers(client, license_data, system_nr, username): def download_licenses(client, key_nrs): # Downloads the license key file content for a list of key numbers. keys_json = json.dumps([{"Keynr": key_nr} for key_nr in key_nrs]) - return client.get(_url(f"FileContent(Keynr='{keys_json}')/$value")).content + return client.get(_url("FileContent(Keynr='{0}')/$value".format(keys_json))).content @require_requests @@ -298,12 +301,14 @@ def delete_licenses(client, licenses_to_delete, existing_licenses, version_id, i def _url(query_path): # Helper to construct the full URL for the systems provisioning service. - return f'{C.URL_SYSTEMS_PROVISIONING}/{query_path}' + return '{0}/{1}'.format(C.URL_SYSTEMS_PROVISIONING, query_path) def _headers(additional_headers): # Helper to construct standard request headers. - return {**{'Accept': 'application/json'}, **additional_headers} + headers = {'Accept': 'application/json'} + headers.update(additional_headers) + return headers @require_requests diff --git a/plugins/module_utils/systems/main.py b/plugins/module_utils/systems/main.py index 0ce13bb..aa7cb0c 100644 --- a/plugins/module_utils/systems/main.py +++ b/plugins/module_utils/systems/main.py @@ -9,10 +9,20 @@ def run_systems_info(params): # Main runner function for the systems_info module. result = {'changed': False, 'failed': False, 'systems': []} - client = ApiClient() try: + client = ApiClient() auth.login(client, params['suser_id'], params['suser_password']) result['systems'] = api.get_systems(client, params['filter']) + except ImportError as e: + result['failed'] = True + if 'requests' in str(e): + result['missing_dependency'] = 'requests' + elif 'urllib3' in str(e): + result['missing_dependency'] = 'urllib3' + elif 'beautifulsoup4' in str(e): + result['missing_dependency'] = 'beautifulsoup4' + else: + result['msg'] = "An unexpected import error occurred: {0}".format(e) except (exceptions.SapLaunchpadError, api.SystemNotFoundError) as e: result['failed'] = True result['msg'] = str(e) @@ -23,14 +33,14 @@ def run_license_keys(params): # Main runner function for the license_keys module. result = {'changed': False, 'failed': False, 'warnings': []} - client = ApiClient() - username = params['suser_id'] - password = params['suser_password'] - installation_nr = params['installation_nr'] - system_nr = params['system_nr'] - state = params['state'] - try: + client = ApiClient() + username = params['suser_id'] + password = params['suser_password'] + installation_nr = params['installation_nr'] + system_nr = params['system_nr'] + state = params['state'] + auth.login(client, username, password) api.validate_installation(client, installation_nr, username) @@ -43,14 +53,15 @@ def run_license_keys(params): existing_systems = api.get_systems(client, filter_str) if len(existing_systems) == 1: system_nr = existing_systems[0]['Sysnr'] - result['warnings'].append(f"A system with SID '{sid}' already exists. Using system number {system_nr} for update.") + result['warnings'].append("A system with SID '{0}' already exists. Using system number {1} for update.".format(sid, system_nr)) elif len(existing_systems) > 1: # Ambiguous situation: multiple systems with the same SID. # Force user to provide system_nr to select one. system_nrs_found = [s['Sysnr'] for s in existing_systems] result['failed'] = True - result['msg'] = (f"Multiple systems with SID '{sid}' found under installation '{installation_nr}': " - f"{', '.join(system_nrs_found)}. Please provide a specific 'system_nr' to select which system to update.") + msg_template = ("Multiple systems with SID '{0}' found under installation '{1}': {2}. " + "Please provide a specific 'system_nr' to select which system to update.") + result['msg'] = msg_template.format(sid, installation_nr, ', '.join(system_nrs_found)) return result is_new_system = not system_nr @@ -73,7 +84,7 @@ def run_license_keys(params): result['changed'] = True result['system_nr'] = system_nr - result['msg'] = f"System {system_nr} created successfully." + result['msg'] = "System {0} created successfully.".format(system_nr) else: # Existing system system = api.get_system(client, system_nr, installation_nr, username) @@ -81,18 +92,18 @@ def run_license_keys(params): # We check for 'Version' first, then fall back to 'Prodver' for compatibility. version_id = system.get('Version') or system.get('Prodver') if not version_id: - raise exceptions.SapLaunchpadError(f"System {system_nr} is missing a required Product Version ID.") + raise exceptions.SapLaunchpadError("System {0} is missing a required Product Version ID.".format(system_nr)) existing_licenses = api.get_existing_licenses(client, system_nr, username) # The API requires a sysdata payload even for an edit operation. # It must contain at least the installation number, system number, product version, and system ID. sysid = system.get('sysid') if not sysid: - raise exceptions.SapLaunchpadError(f"System {system_nr} is missing a required System ID ('sysid').") + raise exceptions.SapLaunchpadError("System {0} is missing a required System ID ('sysid').".format(system_nr)) systype = system.get('systype') if not systype: - raise exceptions.SapLaunchpadError(f"System {system_nr} is missing a required System Type ('systype').") + raise exceptions.SapLaunchpadError("System {0} is missing a required System Type ('systype').".format(system_nr)) sysdata_for_edit = [ {"name": "insnr", "value": installation_nr}, @@ -121,7 +132,7 @@ def run_license_keys(params): generated = api.generate_licenses(client, new_or_changed, existing_licenses, version_id, installation_nr, username) api.submit_system(client, False, sysdata_for_edit, generated, username) result['changed'] = True - result['msg'] = f"System {system_nr} licenses updated successfully." + result['msg'] = "System {0} licenses updated successfully.".format(system_nr) elif state == 'absent': user_licenses_to_keep = params.get('licenses', []) @@ -143,7 +154,7 @@ def run_license_keys(params): deleted_licenses = api.delete_licenses(client, licenses_to_delete, existing_licenses, version_id, installation_nr, username) api.submit_system(client, False, sysdata_for_edit, deleted_licenses, username) result['changed'] = True - result['msg'] = f"Successfully deleted licenses from system {system_nr}." + result['msg'] = "Successfully deleted licenses from system {0}.".format(system_nr) # Download/return license file content if applicable if state == 'present': @@ -160,20 +171,31 @@ def run_license_keys(params): dest_path = pathlib.Path(params['download_path']) if not dest_path.is_dir(): result['failed'] = True - result['msg'] = f"Destination for license file does not exist or is not a directory: {dest_path}" + result['msg'] = "Destination for license file does not exist or is not a directory: {0}".format(dest_path) return result - output_file = dest_path / f"{system_nr}_licenses.txt" + output_file = dest_path / "{0}_licenses.txt".format(system_nr) try: with open(output_file, 'w', encoding='utf-8') as f: f.write(content_str) current_msg = result.get('msg', '') - download_msg = f"License file downloaded to {output_file}." - result['msg'] = f"{current_msg} {download_msg}".strip() + download_msg = "License file downloaded to {0}.".format(output_file) + result['msg'] = "{0} {1}".format(current_msg, download_msg).strip() except IOError as e: result['failed'] = True - result['msg'] = f"Failed to write license file: {e}" + result['msg'] = "Failed to write license file: {0}".format(e) + + except ImportError as e: + result['failed'] = True + if 'requests' in str(e): + result['missing_dependency'] = 'requests' + elif 'urllib3' in str(e): + result['missing_dependency'] = 'urllib3' + elif 'beautifulsoup4' in str(e): + result['missing_dependency'] = 'beautifulsoup4' + else: + result['msg'] = "An unexpected import error occurred: {0}".format(e) except (exceptions.SapLaunchpadError, api.InstallationNotFoundError, @@ -187,6 +209,6 @@ def run_license_keys(params): result['msg'] = str(e) except Exception as e: result['failed'] = True - result['msg'] = f"An unexpected error occurred: {type(e).__name__} - {e}" + result['msg'] = "An unexpected error occurred: {0} - {1}".format(type(e).__name__, e) return result diff --git a/plugins/modules/license_keys.py b/plugins/modules/license_keys.py index df9ae9e..25a4af3 100644 --- a/plugins/modules/license_keys.py +++ b/plugins/modules/license_keys.py @@ -175,7 +175,7 @@ sample: "0000123456" ''' -from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ..module_utils.systems import main as systems_runner @@ -231,6 +231,8 @@ def run_module(): result = systems_runner.run_license_keys(params) if result.get('failed'): + if result.get('missing_dependency'): + module.fail_json(msg=missing_required_lib(result['missing_dependency'])) module.fail_json(**result) else: module.exit_json(**result) diff --git a/plugins/modules/maintenance_planner_files.py b/plugins/modules/maintenance_planner_files.py index c649ca9..f07c4c6 100644 --- a/plugins/modules/maintenance_planner_files.py +++ b/plugins/modules/maintenance_planner_files.py @@ -76,7 +76,7 @@ sample: "SAPCAR_1324-80000936.EXE" ''' -from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ..module_utils.maintenance_planner import main as maintenance_planner_runner @@ -109,6 +109,8 @@ def run_module(): # The runner function indicates failure via a key in the result. if result.get('failed'): + if result.get('missing_dependency'): + module.fail_json(msg=missing_required_lib(result['missing_dependency'])) module.fail_json(**result) else: module.exit_json(**result) diff --git a/plugins/modules/maintenance_planner_stack_xml_download.py b/plugins/modules/maintenance_planner_stack_xml_download.py index 3f41bdf..5199e3f 100644 --- a/plugins/modules/maintenance_planner_stack_xml_download.py +++ b/plugins/modules/maintenance_planner_stack_xml_download.py @@ -64,7 +64,7 @@ sample: "SAP Maintenance Planner Stack XML successfully downloaded to /tmp/MP_STACK_20211015_044854.xml" ''' -from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ..module_utils.maintenance_planner import main as maintenance_planner_runner @@ -98,6 +98,8 @@ def run_module(): # The runner function indicates failure via a key in the result. if result.get('failed'): + if result.get('missing_dependency'): + module.fail_json(msg=missing_required_lib(result['missing_dependency'])) module.fail_json(**result) else: module.exit_json(**result) diff --git a/plugins/modules/software_center_download.py b/plugins/modules/software_center_download.py index 84b4178..fdd367c 100644 --- a/plugins/modules/software_center_download.py +++ b/plugins/modules/software_center_download.py @@ -141,7 +141,7 @@ type: bool ''' -from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ..module_utils.software_center import main as software_center_runner @@ -179,6 +179,8 @@ def run_module(): # The runner function indicates failure via a key in the result. if result.get('failed'): + if result.get('missing_dependency'): + module.fail_json(msg=missing_required_lib(result['missing_dependency'])) module.fail_json(**result) else: module.exit_json(**result) diff --git a/plugins/modules/systems_info.py b/plugins/modules/systems_info.py index d660a95..57f67ab 100644 --- a/plugins/modules/systems_info.py +++ b/plugins/modules/systems_info.py @@ -67,7 +67,7 @@ Version: "73554900100800000266" ''' -from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ..module_utils.systems import main as systems_runner @@ -86,6 +86,8 @@ def run_module(): result = systems_runner.run_systems_info(module.params) if result.get('failed'): + if result.get('missing_dependency'): + module.fail_json(msg=missing_required_lib(result['missing_dependency'])) module.fail_json(**result) else: module.exit_json(**result) From 71b267b8eb9d85da18933ec02ae4672080c6714b Mon Sep 17 00:00:00 2001 From: Marcel Mamula Date: Tue, 7 Oct 2025 16:06:02 +0200 Subject: [PATCH 07/11] remove unused import exceptions from client --- plugins/module_utils/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/module_utils/client.py b/plugins/module_utils/client.py index 80279d1..764a1b8 100644 --- a/plugins/module_utils/client.py +++ b/plugins/module_utils/client.py @@ -3,7 +3,6 @@ from urllib.parse import urlparse from .constants import COMMON_HEADERS -from . import exceptions try: import requests From 8c1f5776414dd6568b90460f1dd34bdcd0ea98b5 Mon Sep 17 00:00:00 2001 From: Marcel Mamula Date: Tue, 7 Oct 2025 16:13:22 +0200 Subject: [PATCH 08/11] test sanity matrix --- .github/workflows/ansible-test-sanity.yml | 27 ++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ansible-test-sanity.yml b/.github/workflows/ansible-test-sanity.yml index 96c9bf9..8cc0ab1 100644 --- a/.github/workflows/ansible-test-sanity.yml +++ b/.github/workflows/ansible-test-sanity.yml @@ -26,10 +26,13 @@ jobs: # Each version listed below will spawn a separate job that runs in parallel. matrix: ansible: - # Supported versions (must pass) - 'stable-2.18' # Python 3.11 - 3.13 - 'stable-2.19' # Python 3.11 - 3.13 - 'devel' # Test against the upcoming development version. + python: + - '3.11' + - '3.12' + - '3.13' steps: - uses: actions/checkout@v5 @@ -37,6 +40,7 @@ jobs: uses: ansible-community/ansible-test-gh-action@release/v1 with: ansible-core-version: ${{ matrix.ansible }} + target-python-version: ${{ matrix.python }} testing-type: sanity sanity-eol: @@ -51,12 +55,28 @@ jobs: # Each version listed below will spawn a separate job that runs in parallel. matrix: ansible: - # EOL versions (allowed to fail) - # NOTE: Ensure that meta/runtime.yml `requires_ansible` version is aligned with tested versions. - 'stable-2.14' # Python 3.9 - 3.11 - 'stable-2.15' # Python 3.9 - 3.11 - 'stable-2.16' # Python 3.10 - 3.12 - 'stable-2.17' # Python 3.10 - 3.12 + python: + - '3.9' + - '3.10' + - '3.11' + - '3.12' + exclude: + # Exclusions for incompatible Python versions. + - ansible: 'stable-2.14' + python: '3.12' + + - ansible: 'stable-2.15' + python: '3.12' + + - ansible: 'stable-2.16' + python: '3.9' + + - ansible: 'stable-2.17' + python: '3.9' steps: - uses: actions/checkout@v5 @@ -64,4 +84,5 @@ jobs: uses: ansible-community/ansible-test-gh-action@release/v1 with: ansible-core-version: ${{ matrix.ansible }} + target-python-version: ${{ matrix.python }} testing-type: sanity From d6de370e979ca269470bb73e65ce134a78456dbc Mon Sep 17 00:00:00 2001 From: Marcel Mamula Date: Tue, 7 Oct 2025 16:27:43 +0200 Subject: [PATCH 09/11] add future import --- .github/workflows/ansible-test-sanity.yml | 8 ++------ plugins/module_utils/auth.py | 3 +++ plugins/module_utils/client.py | 3 +++ plugins/module_utils/maintenance_planner/api.py | 3 +++ plugins/module_utils/maintenance_planner/main.py | 3 +++ plugins/module_utils/software_center/download.py | 3 +++ plugins/module_utils/software_center/main.py | 3 +++ plugins/module_utils/software_center/search.py | 2 ++ plugins/module_utils/systems/api.py | 3 +++ plugins/module_utils/systems/main.py | 3 +++ plugins/modules/maintenance_planner_files.py | 2 ++ plugins/modules/maintenance_planner_stack_xml_download.py | 2 ++ plugins/modules/software_center_download.py | 2 ++ plugins/modules/systems_info.py | 2 ++ 14 files changed, 36 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ansible-test-sanity.yml b/.github/workflows/ansible-test-sanity.yml index 8cc0ab1..aa249b9 100644 --- a/.github/workflows/ansible-test-sanity.yml +++ b/.github/workflows/ansible-test-sanity.yml @@ -29,10 +29,7 @@ jobs: - 'stable-2.18' # Python 3.11 - 3.13 - 'stable-2.19' # Python 3.11 - 3.13 - 'devel' # Test against the upcoming development version. - python: - - '3.11' - - '3.12' - - '3.13' + steps: - uses: actions/checkout@v5 @@ -40,14 +37,13 @@ jobs: uses: ansible-community/ansible-test-gh-action@release/v1 with: ansible-core-version: ${{ matrix.ansible }} - target-python-version: ${{ matrix.python }} testing-type: sanity sanity-eol: runs-on: ubuntu-latest # This job only runs if the supported tests pass needs: sanity-supported - name: Sanity (EOL Ⓐ${{ matrix.ansible }}) + name: Sanity (EOL Ⓐ${{ matrix.ansible }}+py${{ matrix.python }}) continue-on-error: true # This entire job is allowed to fail strategy: fail-fast: false # Disabled so we can see all failed combinations. diff --git a/plugins/module_utils/auth.py b/plugins/module_utils/auth.py index 7ce6f38..fc0eada 100644 --- a/plugins/module_utils/auth.py +++ b/plugins/module_utils/auth.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import, division, print_function +__metaclass__ = type + import json import re from functools import wraps diff --git a/plugins/module_utils/client.py b/plugins/module_utils/client.py index 764a1b8..ed37d7a 100644 --- a/plugins/module_utils/client.py +++ b/plugins/module_utils/client.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import, division, print_function +__metaclass__ = type + import re from urllib.parse import urlparse diff --git a/plugins/module_utils/maintenance_planner/api.py b/plugins/module_utils/maintenance_planner/api.py index fab0ec3..f3e1426 100644 --- a/plugins/module_utils/maintenance_planner/api.py +++ b/plugins/module_utils/maintenance_planner/api.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import, division, print_function +__metaclass__ = type + import re import time from html import unescape diff --git a/plugins/module_utils/maintenance_planner/main.py b/plugins/module_utils/maintenance_planner/main.py index 2578650..83e7d5d 100644 --- a/plugins/module_utils/maintenance_planner/main.py +++ b/plugins/module_utils/maintenance_planner/main.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import, division, print_function +__metaclass__ = type + import pathlib from .. import auth, exceptions diff --git a/plugins/module_utils/software_center/download.py b/plugins/module_utils/software_center/download.py index fa87152..416624f 100644 --- a/plugins/module_utils/software_center/download.py +++ b/plugins/module_utils/software_center/download.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import, division, print_function +__metaclass__ = type + import glob import hashlib import os diff --git a/plugins/module_utils/software_center/main.py b/plugins/module_utils/software_center/main.py index 981d222..1ecf1da 100644 --- a/plugins/module_utils/software_center/main.py +++ b/plugins/module_utils/software_center/main.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import, division, print_function +__metaclass__ = type + import os from .. import auth diff --git a/plugins/module_utils/software_center/search.py b/plugins/module_utils/software_center/search.py index 97f227f..8d2e3cf 100644 --- a/plugins/module_utils/software_center/search.py +++ b/plugins/module_utils/software_center/search.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import, division, print_function +__metaclass__ = type import json import os diff --git a/plugins/module_utils/systems/api.py b/plugins/module_utils/systems/api.py index a720162..a00fef1 100644 --- a/plugins/module_utils/systems/api.py +++ b/plugins/module_utils/systems/api.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import, division, print_function +__metaclass__ = type + import json import time from functools import wraps diff --git a/plugins/module_utils/systems/main.py b/plugins/module_utils/systems/main.py index aa7cb0c..ecd2216 100644 --- a/plugins/module_utils/systems/main.py +++ b/plugins/module_utils/systems/main.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import, division, print_function +__metaclass__ = type + import pathlib from .. import auth, exceptions diff --git a/plugins/modules/maintenance_planner_files.py b/plugins/modules/maintenance_planner_files.py index f07c4c6..58460f1 100644 --- a/plugins/modules/maintenance_planner_files.py +++ b/plugins/modules/maintenance_planner_files.py @@ -2,6 +2,8 @@ from __future__ import absolute_import, division, print_function +__metaclass__ = type + DOCUMENTATION = r''' --- module: maintenance_planner_files diff --git a/plugins/modules/maintenance_planner_stack_xml_download.py b/plugins/modules/maintenance_planner_stack_xml_download.py index 5199e3f..dd54760 100644 --- a/plugins/modules/maintenance_planner_stack_xml_download.py +++ b/plugins/modules/maintenance_planner_stack_xml_download.py @@ -2,6 +2,8 @@ from __future__ import absolute_import, division, print_function +__metaclass__ = type + DOCUMENTATION = r''' --- module: maintenance_planner_stack_xml_download diff --git a/plugins/modules/software_center_download.py b/plugins/modules/software_center_download.py index fdd367c..7268659 100644 --- a/plugins/modules/software_center_download.py +++ b/plugins/modules/software_center_download.py @@ -2,6 +2,8 @@ from __future__ import absolute_import, division, print_function +__metaclass__ = type + DOCUMENTATION = r''' --- module: software_center_download diff --git a/plugins/modules/systems_info.py b/plugins/modules/systems_info.py index 57f67ab..2817938 100644 --- a/plugins/modules/systems_info.py +++ b/plugins/modules/systems_info.py @@ -2,6 +2,8 @@ from __future__ import absolute_import, division, print_function +__metaclass__ = type + DOCUMENTATION = r''' --- module: systems_info From 66955db0a6980004264122b13e038d5c246cab8a Mon Sep 17 00:00:00 2001 From: Marcel Mamula Date: Tue, 7 Oct 2025 16:34:40 +0200 Subject: [PATCH 10/11] exceptions with future import --- plugins/module_utils/exceptions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/module_utils/exceptions.py b/plugins/module_utils/exceptions.py index b5cf5a5..5407b44 100644 --- a/plugins/module_utils/exceptions.py +++ b/plugins/module_utils/exceptions.py @@ -1,3 +1,6 @@ +from __future__ import absolute_import, division, print_function +__metaclass__ = type + # Custom exceptions for the sap_launchpad collection. From bce5741c5f7f97fbec02c6b5797e53b3482af402 Mon Sep 17 00:00:00 2001 From: Marcel Mamula Date: Tue, 7 Oct 2025 16:40:21 +0200 Subject: [PATCH 11/11] fix format issue --- plugins/module_utils/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/module_utils/auth.py b/plugins/module_utils/auth.py index fc0eada..cf78e47 100644 --- a/plugins/module_utils/auth.py +++ b/plugins/module_utils/auth.py @@ -302,7 +302,7 @@ def _cdc_api_request(client, endpoint, saml_params, query_params): error_code = json_response['errorCode'] if error_code != 0: - http_error_msg = '{} Error: {} for url: {}'.format( + http_error_msg = '{0} Error: {1} for url: {2}'.format( json_response['statusCode'], json_response['errorMessage'], res.url) raise HTTPError(http_error_msg, response=res) return json_response