From 5cbf6ad31652bc98852af13a8912746cb4090f99 Mon Sep 17 00:00:00 2001 From: MuHamza30 Date: Mon, 7 Jul 2025 11:22:47 +0500 Subject: [PATCH 1/3] ci(dependabot): slack alert (#96) --- .../workflows/dependabot-notifications.yml | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 .github/workflows/dependabot-notifications.yml diff --git a/.github/workflows/dependabot-notifications.yml b/.github/workflows/dependabot-notifications.yml new file mode 100644 index 0000000..e16a921 --- /dev/null +++ b/.github/workflows/dependabot-notifications.yml @@ -0,0 +1,126 @@ +name: Dependabot Notifications + +on: + workflow_run: + workflows: ["*"] + types: + - completed + +jobs: + notify-checks: + runs-on: ubuntu-latest + if: github.actor == 'dependabot[bot]' + steps: + - name: Get PR Information + if: github.actor == 'dependabot[bot]' + id: get-pr-info + uses: actions/github-script@v6 + with: + script: | + const { owner, repo } = context.repo; + const run = context.payload.workflow_run; + + // Get PR directly from the workflow run's head SHA + const response = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, + repo, + commit_sha: run.head_sha + }); + + const pr = response.data[0]; // Get the first associated PR + + if (pr) { + core.exportVariable('PR_TITLE', pr.title); + core.exportVariable('PR_AUTHOR', pr.user.login); + core.exportVariable('PR_LINK', pr.html_url); + core.exportVariable('PR_NUMBER', pr.number.toString()); + } else { + core.exportVariable('PR_TITLE', 'Unknown'); + core.exportVariable('PR_AUTHOR', context.actor); + core.exportVariable('PR_LINK', `https://github.com/${owner}/${repo}/pulls`); + core.exportVariable('PR_NUMBER', ''); + } + + // Get check runs for this commit + const checkRuns = await github.rest.checks.listForRef({ + owner, + repo, + ref: run.head_sha + }); + + // Count different check conclusions + const stats = checkRuns.data.check_runs.reduce((acc, check) => { + acc[check.conclusion] = (acc[check.conclusion] || 0) + 1; + return acc; + }, {}); + + // Create status summary + const summary = Object.entries(stats) + .map(([status, count]) => `${count} ${status}`) + .join(', '); + + core.exportVariable('CHECKS_SUMMARY', summary); + + // Determine overall status + const hasFailures = stats.failure > 0; + const hasSuccess = stats.success > 0; + const hasCancelled = stats.cancelled > 0; + + let overallStatus; + if (hasFailures) { + overallStatus = 'failure'; + } else if (hasCancelled && !hasSuccess) { + overallStatus = 'cancelled'; + } else if (hasSuccess) { + overallStatus = 'success'; + } else { + overallStatus = 'unknown'; + } + + // Only set status if this is the last workflow to complete + const incompleteRuns = await github.rest.actions.listWorkflowRunsForRepo({ + owner, + repo, + head_sha: run.head_sha, + status: 'in_progress' + }); + + if (incompleteRuns.data.total_count === 0) { + core.exportVariable('ALL_CHECKS_STATUS', overallStatus); + core.exportVariable('SHOULD_NOTIFY', 'true'); + + // If checks failed and PR exists, close it + if ((overallStatus === 'failure' || overallStatus === 'cancelled') && pr) { + await github.rest.pulls.update({ + owner, + repo, + pull_number: pr.number, + state: 'closed' + }); + + // Add comment explaining why PR was closed + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: `This PR was automatically closed because some checks failed.\nStatus Summary: ${summary}` + }); + } + } else { + core.exportVariable('SHOULD_NOTIFY', 'false'); + } + + - name: Send Slack Notification for Success + if: env.SHOULD_NOTIFY == 'true' && env.ALL_CHECKS_STATUS == 'success' && github.actor == 'dependabot[bot]' + id: slack + uses: slackapi/slack-github-action@v1.25.0 + with: + channel-id: 'C08TLGVQ6V8' + slack-message: | + Repository: ${{ github.repository }} + Title: ${{ env.PR_TITLE }} + Author: ${{ env.PR_AUTHOR }} + Link: ${{ env.PR_LINK }} + Status Summary: ${{ env.CHECKS_SUMMARY }} + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} From 00fc8a61083168443b6671eeaa93445a23b15cec Mon Sep 17 00:00:00 2001 From: Ayesha <88117894+Ayeshas09@users.noreply.github.com> Date: Thu, 31 Jul 2025 17:40:19 +0500 Subject: [PATCH 2/3] ci: migrate code coverage from CodeClimate to SonarCloud (#100) --- .github/workflows/test-runner.yml | 13 ++++++------- README.md | 13 +++++++++---- sonar-project.properties | 10 ++++++++++ 3 files changed, 25 insertions(+), 11 deletions(-) create mode 100644 sonar-project.properties diff --git a/.github/workflows/test-runner.yml b/.github/workflows/test-runner.yml index c614724..fdd17d4 100644 --- a/.github/workflows/test-runner.yml +++ b/.github/workflows/test-runner.yml @@ -32,11 +32,10 @@ jobs: run: coverage run -m pytest - name: Generate coverage report run: coverage xml - - name: Upload coverage report - if: ${{ matrix.os == 'ubuntu-latest' && matrix.python == '3.13' && github.actor != 'dependabot[bot]' }} - uses: paambaati/codeclimate-action@v3.0.0 + + - name: SonarQube Scan + if: ${{ matrix.python == '3.13' && github.actor != 'dependabot[bot]' }} + uses: SonarSource/sonarqube-scan-action@v5.2.0 env: - CC_TEST_REPORTER_ID: ${{ secrets.CODE_CLIMATE_TEST_REPORTER_ID }} - with: - coverageLocations: | - ${{github.workspace}}/coverage.xml:coverage.py + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 34ae534..08f71f7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # apimatic-core [![PyPI][pypi-version]][apimatic-core-pypi-url] [![Tests][test-badge]][test-url] -[![Test Coverage][test-coverage-url]][code-climate-url] +[![Test Coverage][coverage-badge]][coverage-url] +[![Maintainability Rating][maintainability-badge]][maintainability-url] +[![Vulnerabilities][vulnerabilities-badge]][vulnerabilities-url] [![Licence][license-badge]][license-url] ## Introduction @@ -125,8 +127,11 @@ pip install apimatic-core [apimatic-core-pypi-url]: https://pypi.org/project/apimatic-core/ [test-badge]: https://github.com/apimatic/core-lib-python/actions/workflows/test-runner.yml/badge.svg [test-url]: https://github.com/apimatic/core-lib-python/actions/workflows/test-runner.yml -[code-climate-url]: https://codeclimate.com/github/apimatic/core-lib-python -[maintainability-url]: https://api.codeclimate.com/v1/badges/32e7abfdd4d27613ae76/maintainability -[test-coverage-url]: https://api.codeclimate.com/v1/badges/32e7abfdd4d27613ae76/test_coverage +[coverage-badge]: https://sonarcloud.io/api/project_badges/measure?project=apimatic_core-lib-python&metric=coverage +[coverage-url]: https://sonarcloud.io/summary/new_code?id=apimatic_core-lib-python +[maintainability-badge]: https://sonarcloud.io/api/project_badges/measure?project=apimatic_core-lib-python&metric=sqale_rating +[maintainability-url]: https://sonarcloud.io/summary/new_code?id=apimatic_core-lib-python +[vulnerabilities-badge]: https://sonarcloud.io/api/project_badges/measure?project=apimatic_core-lib-python&metric=vulnerabilities +[vulnerabilities-url]: https://sonarcloud.io/summary/new_code?id=apimatic_core-lib-python [license-badge]: https://img.shields.io/badge/licence-MIT-blue [license-url]: LICENSE diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..f9def5a --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,10 @@ +sonar.projectKey=apimatic_core-lib-python +sonar.projectName=APIMatic Core Library for Python +sonar.organization=apimatic +sonar.host.url=https://sonarcloud.io +sonar.sourceEncoding=UTF-8 + +sonar.sources=apimatic_core +sonar.tests=tests + +sonar.python.coverage.reportPaths=coverage.xml From 9b4407e33ce3af8e1bfcd1de855aa76549959d9b Mon Sep 17 00:00:00 2001 From: Muhammad Sufyan Date: Mon, 1 Sep 2025 14:18:10 +0500 Subject: [PATCH 3/3] feat(proxy): add ProxySettings model and Requests-compatible proxies mapping (#103) This commit introduces a new `ProxySettings` model to support HTTP proxy configuration in the HTTP client. The implementation provides a typed data model with fields for address, port, username, and password, along with a method to generate Requests-compatible proxy dictionaries. - Adds `ProxySettings` class with typed fields and URL encoding support - Integrates proxy settings into `HttpClientConfiguration` - Provides comprehensive test coverage for various proxy configuration scenarios --- README.md | 13 ++- apimatic_core/http/configurations/__init__.py | 3 +- .../http_client_configuration.py | 11 ++- .../http/configurations/proxy_settings.py | 81 ++++++++++++++++ setup.py | 2 +- tests/apimatic_core/configuration/__init__.py | 0 .../configuration/test_proxy_settings.py | 94 +++++++++++++++++++ 7 files changed, 196 insertions(+), 8 deletions(-) create mode 100644 apimatic_core/http/configurations/proxy_settings.py create mode 100644 tests/apimatic_core/configuration/__init__.py create mode 100644 tests/apimatic_core/configuration/test_proxy_settings.py diff --git a/README.md b/README.md index 08f71f7..e5eff48 100644 --- a/README.md +++ b/README.md @@ -60,13 +60,18 @@ pip install apimatic-core |---------------------------------------------------------------------------|-----------------------------------------------------------------------------| | [`HttpResponseFactory`](apimatic_core/factories/http_response_factory.py) | A factory class to create an HTTP Response | +## HTTP Configurations +| Name | Description | +|---------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------| +| [`HttpClientConfiguration`](apimatic_core/http/configurations/http_client_configuration.py) | A class used for configuring the SDK by a user | +| [`ProxySettings`](apimatic_core/http/configurations/proxy_settings.py) | ProxySettings encapsulates HTTP proxy configuration for Requests, e.g. address, port and optional basic authentication for HTTP and HTTPS | + ## HTTP | Name | Description | |---------------------------------------------------------------------------------------------|-------------------------------------------------------------| -| [`HttpCallBack`](apimatic_core/factories/http_response_factory.py) | A factory class to create an HTTP Response | -| [`HttpClientConfiguration`](apimatic_core/http/configurations/http_client_configuration.py) | A class used for configuring the SDK by a user | -| [`HttpRequest`](apimatic_core/http/request/http_request.py) | A class which contains information about the HTTP Response | -| [`ApiResponse`](apimatic_core/http/response/api_response.py) | A wrapper class for Api Response | +| [`HttpCallBack`](apimatic_core/http/http_callback.py) | A class to register hooks for the API request and response | +| [`HttpRequest`](apimatic_core/http/request/http_request.py) | A class which contains information about the HTTP Request | +| [`ApiResponse`](apimatic_core/http/response/api_response.py) | A wrapper class for the complete Http Response including raw body and headers etc. | | [`HttpResponse`](apimatic_core/http/response/http_response.py) | A class which contains information about the HTTP Response | ## Logging Configuration diff --git a/apimatic_core/http/configurations/__init__.py b/apimatic_core/http/configurations/__init__.py index 9cc094b..79c209c 100644 --- a/apimatic_core/http/configurations/__init__.py +++ b/apimatic_core/http/configurations/__init__.py @@ -1,3 +1,4 @@ __all__ = [ - 'http_client_configuration' + 'http_client_configuration', + 'proxy_settings' ] \ No newline at end of file diff --git a/apimatic_core/http/configurations/http_client_configuration.py b/apimatic_core/http/configurations/http_client_configuration.py index d2b3935..80a001f 100644 --- a/apimatic_core/http/configurations/http_client_configuration.py +++ b/apimatic_core/http/configurations/http_client_configuration.py @@ -50,10 +50,15 @@ def retry_methods(self): def logging_configuration(self): return self._logging_configuration + @property + def proxy_settings(self): + return self._proxy_settings + def __init__(self, http_client_instance=None, override_http_client_configuration=False, http_call_back=None, timeout=60, max_retries=0, backoff_factor=2, - retry_statuses=None, retry_methods=None, logging_configuration=None): + retry_statuses=None, retry_methods=None, logging_configuration=None, + proxy_settings=None): if retry_statuses is None: retry_statuses = [408, 413, 429, 500, 502, 503, 504, 521, 522, 524] @@ -93,6 +98,8 @@ def __init__(self, http_client_instance=None, self._logging_configuration = logging_configuration + self._proxy_settings = proxy_settings + def set_http_client(self, http_client): self._http_client = http_client @@ -103,6 +110,6 @@ def clone(self, http_callback=None): http_call_back=http_callback or self.http_callback, timeout=self.timeout, max_retries=self.max_retries, backoff_factor=self.backoff_factor, retry_statuses=self.retry_statuses, retry_methods=self.retry_methods, - logging_configuration=self.logging_configuration) + logging_configuration=self.logging_configuration, proxy_settings=self.proxy_settings) http_client_instance.set_http_client(self.http_client) return http_client_instance \ No newline at end of file diff --git a/apimatic_core/http/configurations/proxy_settings.py b/apimatic_core/http/configurations/proxy_settings.py new file mode 100644 index 0000000..00d2e05 --- /dev/null +++ b/apimatic_core/http/configurations/proxy_settings.py @@ -0,0 +1,81 @@ +from typing import Dict, Optional +from urllib.parse import quote + + +class ProxySettings: + """ + A simple data model for configuring HTTP(S) proxy settings. + """ + + HTTP_SCHEME: str = "http://" + HTTPS_SCHEME: str = "https://" + + address: str + port: Optional[int] + username: Optional[str] + password: Optional[str] + + def __init__(self, address: str, port: Optional[int] = None, + username: Optional[str] = None, password: Optional[str] = None) -> None: + """ + Parameters + ---------- + address : str + Hostname or IP of the proxy. + port : int, optional + Port of the proxy server. + username : str, optional + Username for authentication. + password : str, optional + Password for authentication. + """ + self.address = address + self.port = port + self.username = username + self.password = password + + def __repr__(self) -> str: + """ + Developer-friendly representation. + """ + return ( + f"ProxySettings(address={self.address!r}, " + f"port={self.port!r}, " + f"username={self.username!r}, " + f"password={'***' if self.password else None})" + ) + + def __str__(self) -> str: + """ + Human-friendly string for display/logging. + """ + user_info = f"{self.username}:***@" if self.username else "" + port = f":{self.port}" if self.port else "" + return f"{user_info}{self.address}{port}" + + def _sanitize_address(self) -> str: + addr = (self.address or "").strip() + # Trim scheme if present + if addr.startswith(self.HTTP_SCHEME): + addr = addr[len(self.HTTP_SCHEME):] + elif addr.startswith(self.HTTPS_SCHEME): + addr = addr[len(self.HTTPS_SCHEME):] + # Drop trailing slash if user typed a URL-like form + return addr.rstrip("/") + + def to_proxies(self) -> Dict[str, str]: + """ + Build a `requests`-compatible proxies dictionary. + """ + host = self._sanitize_address() + auth = "" + if self.username is not None: + # URL-encode in case of special chars + u = quote(self.username, safe="") + p = quote(self.password or "", safe="") + auth = f"{u}:{p}@" + port = f":{self.port}" if self.port is not None else "" + return { + "http": f"{self.HTTP_SCHEME}{auth}{host}{port}", + "https": f"{self.HTTPS_SCHEME}{auth}{host}{port}", + } diff --git a/setup.py b/setup.py index ba49cd0..87c4e51 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ setup( name='apimatic-core', - version='0.2.21', + version='0.2.22', description='A library that contains core logic and utilities for ' 'consuming REST APIs using Python SDKs generated by APIMatic.', long_description=long_description, diff --git a/tests/apimatic_core/configuration/__init__.py b/tests/apimatic_core/configuration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/apimatic_core/configuration/test_proxy_settings.py b/tests/apimatic_core/configuration/test_proxy_settings.py new file mode 100644 index 0000000..b64e56b --- /dev/null +++ b/tests/apimatic_core/configuration/test_proxy_settings.py @@ -0,0 +1,94 @@ +import pytest + +from apimatic_core.http.configurations.proxy_settings import ProxySettings + + +class TestProxySettings: + def test_has_expected_keys(self): + ps = ProxySettings(address="proxy.local") + proxies = ps.to_proxies() + assert set(proxies.keys()) == {"http", "https"} + + @pytest.mark.parametrize( + "address, port, username, password, exp_http, exp_https", + [ + pytest.param( + "proxy.local", None, None, None, + "http://proxy.local", + "https://proxy.local", + id="no-auth-no-port", + ), + pytest.param( + "proxy.local", 8080, None, None, + "http://proxy.local:8080", + "https://proxy.local:8080", + id="no-auth-with-port", + ), + pytest.param( + "proxy.local", 8080, "user", "pass", + "http://user:pass@proxy.local:8080", + "https://user:pass@proxy.local:8080", + id="auth-with-port", + ), + pytest.param( + "proxy.local", None, "user", None, + # password None -> empty string: "user:@" + "http://user:@proxy.local", + "https://user:@proxy.local", + id="auth-username-only-password-none", + ), + pytest.param( + "proxy.local", None, "a b", "p@ss#", + # URL-encoding of space/@/# + "http://a%20b:p%40ss%23@proxy.local", + "https://a%20b:p%40ss%23@proxy.local", + id="auth-with-url-encoding", + ), + pytest.param( + "localhost", None, "", "", + # empty username triggers auth block (since not None) -> ':@' + "http://:@localhost", + "https://:@localhost", + id="empty-username-and-password", + ), + ], + ) + def test_formats(self, address, port, username, password, exp_http, exp_https): + ps = ProxySettings(address, port, username, password) + proxies = ps.to_proxies() + assert proxies["http"] == exp_http + assert proxies["https"] == exp_https + + @pytest.mark.parametrize("address", ["proxy.local", "localhost"]) + def test_no_trailing_colon_when_no_port(self, address): + ps = ProxySettings(address) + proxies = ps.to_proxies() + assert not proxies["http"].endswith(":") + assert not proxies["https"].endswith(":") + assert "::" not in proxies["http"] + assert "::" not in proxies["https"] + + def test_single_colon_before_port(self): + ps = ProxySettings(address="proxy.local", port=3128) + proxies = ps.to_proxies() + assert proxies["http"].endswith(":3128") + assert proxies["https"].endswith(":3128") + assert "proxy.local::3128" not in proxies["http"] + assert "proxy.local::3128" not in proxies["https"] + + # --- NEW: scheme trimming cases (reflecting the reverted, simpler behavior) --- + + def test_trims_http_scheme_no_port(self): + ps = ProxySettings(address="http://proxy.local") + proxies = ps.to_proxies() + assert proxies["http"] == "http://proxy.local" + assert proxies["https"] == "https://proxy.local" + + def test_trims_https_scheme_trailing_slash_with_port_and_auth(self): + ps = ProxySettings(address="https://proxy.local/", port=8080, username="user", password="secret") + proxies = ps.to_proxies() + assert proxies["http"] == "http://user:secret@proxy.local:8080" + assert proxies["https"] == "https://user:secret@proxy.local:8080" + assert not proxies["http"].endswith(":") + assert not proxies["https"].endswith(":") +