From 523c8819d91a18e88a26ecefecf553627540ff01 Mon Sep 17 00:00:00 2001 From: "Ch.-David Blot" Date: Tue, 6 Jan 2026 13:55:47 +0100 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20feat:=20implement=20RFC=209457?= =?UTF-8?q?=20error=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- osc_sdk/problem.py | 140 +++++++++++++++++++++++++++++++++++++++++ osc_sdk/sdk.py | 79 ++--------------------- osc_sdk/test_auth.py | 14 ++--- osc_sdk/test_errors.py | 5 +- osc_sdk/test_noauth.py | 31 --------- tests/test_pytest.sh | 2 +- 6 files changed, 156 insertions(+), 115 deletions(-) create mode 100644 osc_sdk/problem.py diff --git a/osc_sdk/problem.py b/osc_sdk/problem.py new file mode 100644 index 0000000..51ca733 --- /dev/null +++ b/osc_sdk/problem.py @@ -0,0 +1,140 @@ +import json + +import defusedxml.ElementTree as ET + + +class ProblemDecoder(json.JSONDecoder): + def decode(self, s): + data = super().decode(s) + if isinstance(data, dict): + return self._make_problem(data) + return data + + def _make_problem(self, data): + type_ = data.pop("type", None) + status = data.pop("status", None) + title = data.pop("title", None) + detail = data.pop("detail", None) + instance = data.pop("instance", None) + return Problem(type_, status, title, detail, instance, **data) + + +class Problem(Exception): + def __init__(self, type_, status, title, detail, instance, **kwargs): + self._type = type_ or "about:blank" + self.status = status + self.title = title + self.detail = detail + self.instance = instance + self.extras = kwargs + + for k in self.extras: + if k in ["type", "status", "title", "detail", "instance"]: + raise ValueError(f"Reserved key '{k}' used in Problem extra arguments.") + + def __str__(self): + return self.title + + def __repr__(self): + return f"{self.__class__.__name__}" + + def msg(self): + msg = ( + f"type = {self._type}, " + f"status = {self.status}, " + f"title = {self.title}, " + f"detail = {self.detail}, " + f"instance = {self.instance}, " + f"extras = {self.extras}" + ) + return msg + + @property + def type(self): + return self._type + + +class LegacyProblemDecoder(json.JSONDecoder): + def decode(self, s): + data = super().decode(s) + if isinstance(data, dict): + return self._make_legacy_problem(data) + return data + + def _make_legacy_problem(self, data): + request_id = None + error_code = None + code_type = None + + if "__type" in data: + error_code = data.get("__type") + else: + request_id = (data.get("ResponseContext") or {}).get("RequestId") + errors = data.get("Errors") + if errors: + error = errors[0] + error_code = error.get("Code") + reason = error.get("Type") + if error.get("Details"): + code_type = reason + else: + code_type = None + return LegacyProblem(None, error_code, code_type, request_id, None) + + +class LegacyProblem(Exception): + def __init__(self, status, error_code, code_type, request_id, url): + self.status = status + self.error_code = error_code + self.code_type = code_type + self.request_id = request_id + self.url = url + + def msg(self): + msg = ( + f"status = {self.status}, " + f"code = {self.error_code}, " + f"{'code_type = ' if self.code_type is not None else ''}" + f"{self.code_type + ', ' if self.code_type is not None else ''}" + f"request_id = {self.request_id}, " + f"url = {self.url}" + ) + return msg + + +def api_error(response): + try: + problem = None + ct = response.headers.get("content-type") or "" + if "application/json" in ct: + problem = response.json(cls=LegacyProblemDecoder) + problem.status = problem.status or str(response.status_code) + problem.url = response.url + elif "application/problem+json" in ct: + problem = response.json(cls=ProblemDecoder) + problem.status = problem.status or str(response.status_code) + + if problem: + return problem + except json.JSONDecodeError: + pass + + try: + error = ET.fromstring(response.text) + + err = dict() + for key, attr in [ + ("Code", "error_code"), + ("Message", "status"), + ("RequestId", "request_id"), + ("RequestID", "request_id"), + ]: + value = next((x.text for x in error.iter() if x.tag.endswith(key)), None) + if value: + err[attr] = value + + return LegacyProblem(**err) + except: + raise Exception( + f"Could not decode error response from {response.url} with status code {response.status_code}" + ) diff --git a/osc_sdk/sdk.py b/osc_sdk/sdk.py index 330463f..79b1425 100755 --- a/osc_sdk/sdk.py +++ b/osc_sdk/sdk.py @@ -19,6 +19,8 @@ from requests.models import Response from typing_extensions import TypedDict +from .problem import api_error + CANONICAL_URI = "/" CONFIGURATION_FILE = "config.json" CONFIGURATION_FOLDER = ".osc" @@ -121,77 +123,6 @@ class Tag(TypedDict): Values: List[str] -@dataclass -class OscApiException(Exception): - http_response: InitVar[Response] - - status_code: int = field(init=False) - error_code: Optional[str] = field(default=None, init=False) - message: Optional[str] = field(default=None, init=False) - code_type: Optional[str] = field(default=None, init=False) - request_id: Optional[str] = field(default=None, init=False) - - def __post_init__(self, http_response: Response): - super().__init__() - self.status_code = http_response.status_code - # Set error details - self._set(http_response) - - def __str__(self) -> str: - return ( - f"Error --> status = {self.status_code}, " - f"code = {self.error_code}, " - f"{'code_type = ' if self.code_type is not None else ''}" - f"{self.code_type + ', ' if self.code_type is not None else ''}" - f"Reason = {self.message}, " - f"request_id = {self.request_id}" - ) - - def _set(self, http_response: Response): - content = http_response.content.decode() - # In case it is JSON error format - try: - error = json.loads(content) - except json.JSONDecodeError: - pass - else: - if "__type" in error: - self.error_code = error.get("__type") - self.message = error.get("message") - self.request_id = http_response.headers.get("x-amz-requestid") - else: - self.request_id = (error.get("ResponseContext") or {}).get("RequestId") - errors = error.get("Errors") - if errors: - error = errors[0] - self.error_code = error.get("Code") - self.message = error.get("Type") - if error.get("Details"): - self.code_type = self.message - self.message = error.get("Details") - else: - self.code_type = None - return - - # In case it is XML format - try: - error = ET.fromstring(content) - except ET.ParseError: - return - else: - for key, attr in [ - ("Code", "error_code"), - ("Message", "message"), - ("RequestId", "request_id"), - ("RequestID", "request_id"), - ]: - value = next( - (x.text for x in error.iter() if x.tag.endswith(key)), None - ) - if value: - setattr(self, attr, value) - - @dataclass class ApiCall: profile: str = DEFAULT_PROFILE @@ -437,7 +368,7 @@ def make_request(self, call: str, **kwargs: CallParameters): class XmlApiCall(ApiCall): def get_response(self, http_response: Response) -> Union[str, ResponseContent]: if http_response.status_code not in SUCCESS_CODES: - raise OscApiException(http_response) + raise api_error(http_response) try: return cast(ResponseContent, xmltodict.parse(http_response.content)) except Exception: @@ -502,7 +433,7 @@ def get_parameters( def get_response(self, http_response: Response) -> ResponseContent: if http_response.status_code not in SUCCESS_CODES: - raise OscApiException(http_response) + raise api_error(http_response) return json.loads(http_response.text) @@ -641,7 +572,7 @@ class DirectLinkCall(JsonApiCall): def get_response(self, http_response: Response) -> ResponseContent: if http_response.status_code not in SUCCESS_CODES: - raise OscApiException(http_response) + raise api_error(http_response) res = json.loads(http_response.text) res["requestid"] = http_response.headers["x-amz-requestid"] diff --git a/osc_sdk/test_auth.py b/osc_sdk/test_auth.py index 06b2db0..d69eb89 100644 --- a/osc_sdk/test_auth.py +++ b/osc_sdk/test_auth.py @@ -10,7 +10,7 @@ class Env(object): access_key: str secret_key: str - endpoint_icu: str + endpoint_api: str region: str @@ -19,17 +19,17 @@ def env() -> Env: return Env( access_key=os.getenv("OSC_TEST_ACCESS_KEY", ""), secret_key=os.getenv("OSC_TEST_SECRET_KEY", ""), - endpoint_icu=os.getenv("OSC_TEST_ENDPOINT_ICU", ""), + endpoint_api=os.getenv("OSC_TEST_ENDPOINT_API", ""), region=os.getenv("OSC_TEST_REGION", ""), ) -def test_icu_auth_ak_sk(env): - icu = sdk.IcuCall( +def test_api_auth_ak_sk(env): + api = sdk.OSCCall( access_key=env.access_key, secret_key=env.secret_key, - endpoint=env.endpoint_icu, + endpoint=env.endpoint_api, region_name=env.region, ) - icu.make_request("GetAccount") - assert len(icu.response) > 0 + api.make_request("ReadAccounts") + assert len(api.response) > 0 diff --git a/osc_sdk/test_errors.py b/osc_sdk/test_errors.py index d802d8d..dca3c88 100644 --- a/osc_sdk/test_errors.py +++ b/osc_sdk/test_errors.py @@ -4,6 +4,7 @@ import pytest from . import sdk +from .problem import LegacyProblem @dataclass @@ -32,6 +33,6 @@ def test_bad_filter(env): endpoint=env.endpoint_api, region_name=env.region, ) - with pytest.raises(sdk.OscApiException) as e: + with pytest.raises(LegacyProblem) as e: oapi.make_request("ReadImages", Filters='"bad_filter"') - assert e.value.status_code == 400 + assert e.value.status == "400" diff --git a/osc_sdk/test_noauth.py b/osc_sdk/test_noauth.py index cb06c6d..9fcf9b0 100644 --- a/osc_sdk/test_noauth.py +++ b/osc_sdk/test_noauth.py @@ -28,37 +28,6 @@ def env() -> Env: ) -def test_icu_noauth_call_with_auth_env(env): - icu = sdk.IcuCall( - access_key=env.access_key, - secret_key=env.secret_key, - endpoint=env.endpoint_icu, - region_name=env.region, - ) - icu.make_request("ReadPublicCatalog") - assert len(icu.response) - - -def test_icu_noauth_call_with_empty_auth_env(env): - icu = sdk.IcuCall( # nosec - access_key="", - secret_key="", - endpoint=env.endpoint_icu, - region_name=env.region, - ) - icu.make_request("ReadPublicCatalog") - assert len(icu.response) - - -def test_icu_noauth_basic(env): - icu = sdk.IcuCall( - endpoint=env.endpoint_icu, - region_name=env.region, - ) - icu.make_request("ReadPublicCatalog") - assert len(icu.response) - - def test_api_noauth_call_with_auth_env(env): api = sdk.OSCCall( access_key=env.access_key, diff --git a/tests/test_pytest.sh b/tests/test_pytest.sh index 48286b0..3fc8ac3 100755 --- a/tests/test_pytest.sh +++ b/tests/test_pytest.sh @@ -30,5 +30,5 @@ fi PROJECT_ROOT=$(cd "$(dirname $0)/.." && pwd) cd $PROJECT_ROOT . .venv/bin/activate -pytest osc_sdk &> /dev/null +pytest osc_sdk echo "OK" From 04f9d695eedb32f70f96dc80b1c0fb4ce88ca091 Mon Sep 17 00:00:00 2001 From: "Ch.-David Blot" Date: Mon, 19 Jan 2026 10:04:08 +0100 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=9A=A8=20fix:=20fix=20integration=20t?= =?UTF-8?q?ests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/Pyinstaller-windows.yml | 39 ++--- osc_sdk/__init__.py | 12 +- osc_sdk/problem.py | 140 ------------------ osc_sdk/sdk.py | 139 ++++++++++++++++- osc_sdk/test_errors.py | 3 +- .../generic_tests/00_one_call_per_service.sh | 2 +- tests/generic_tests/01_auth_access_key.sh | 2 +- tests/generic_tests/02_auth_bad.sh | 2 +- tests/generic_tests/03_auth_password.sh | 2 +- tests/generic_tests/04_auth_none.sh | 2 +- 10 files changed, 164 insertions(+), 179 deletions(-) delete mode 100644 osc_sdk/problem.py diff --git a/.github/workflows/Pyinstaller-windows.yml b/.github/workflows/Pyinstaller-windows.yml index 599ac1d..5a90515 100644 --- a/.github/workflows/Pyinstaller-windows.yml +++ b/.github/workflows/Pyinstaller-windows.yml @@ -11,41 +11,20 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v5 + with: + submodules: true - - uses: msys2/setup-msys2@v2 + - uses: actions/setup-python@v6 with: - msystem: MINGW64 - install: >- - git - base-devel - zip - pacboy: - openssl - python-pip + python-version: '3.13' - - shell: msys2 {0} + - name: build and package run: | - git submodule update --init python --version python -m pip install pyinstaller python -m pip install . - pyinstaller --distpath ./pkg --clean --name osc-cli osc_sdk/sdk.py --add-data /mingw64/bin/libcrypto-3-x64.dll:. --add-data /mingw64/bin/libssl-3-x64.dll:. - zip -r osc-cli-x86_64.zip pkg/osc-cli + pyinstaller --distpath ./pkg --clean --name osc-cli osc_sdk/sdk.py + + - name: smoke test + run: | ./pkg/osc-cli/osc-cli.exe api ReadRegions | grep api.eu-west-2.outscale.com - - name: Upload artifacts - uses: actions/upload-artifact@v5 - if: ${{ github.event_name != 'push' }} - with: - name: osc-cli-win - path: | - osc-cli-x86_64.zip - - name: upload nightly - uses: "marvinpinto/action-automatic-releases@latest" - if: ${{ github.event_name == 'push' }} - with: - repo_token: "${{ secrets.GITHUB_TOKEN }}" - prerelease: true - automatic_release_tag: "nightly-windows" - title: "Windows Development Build" - files: | - osc-cli-x86_64.zip diff --git a/osc_sdk/__init__.py b/osc_sdk/__init__.py index 3d297c3..ab979f3 100644 --- a/osc_sdk/__init__.py +++ b/osc_sdk/__init__.py @@ -1 +1,11 @@ -from .sdk import DirectLinkCall, EimCall, FcuCall, IcuCall, LbuCall, OKMSCall, OSCCall +from .sdk import ( + DirectLinkCall, + EimCall, + FcuCall, + IcuCall, + LbuCall, + LegacyProblem, + OKMSCall, + OSCCall, + Problem, +) diff --git a/osc_sdk/problem.py b/osc_sdk/problem.py deleted file mode 100644 index 51ca733..0000000 --- a/osc_sdk/problem.py +++ /dev/null @@ -1,140 +0,0 @@ -import json - -import defusedxml.ElementTree as ET - - -class ProblemDecoder(json.JSONDecoder): - def decode(self, s): - data = super().decode(s) - if isinstance(data, dict): - return self._make_problem(data) - return data - - def _make_problem(self, data): - type_ = data.pop("type", None) - status = data.pop("status", None) - title = data.pop("title", None) - detail = data.pop("detail", None) - instance = data.pop("instance", None) - return Problem(type_, status, title, detail, instance, **data) - - -class Problem(Exception): - def __init__(self, type_, status, title, detail, instance, **kwargs): - self._type = type_ or "about:blank" - self.status = status - self.title = title - self.detail = detail - self.instance = instance - self.extras = kwargs - - for k in self.extras: - if k in ["type", "status", "title", "detail", "instance"]: - raise ValueError(f"Reserved key '{k}' used in Problem extra arguments.") - - def __str__(self): - return self.title - - def __repr__(self): - return f"{self.__class__.__name__}" - - def msg(self): - msg = ( - f"type = {self._type}, " - f"status = {self.status}, " - f"title = {self.title}, " - f"detail = {self.detail}, " - f"instance = {self.instance}, " - f"extras = {self.extras}" - ) - return msg - - @property - def type(self): - return self._type - - -class LegacyProblemDecoder(json.JSONDecoder): - def decode(self, s): - data = super().decode(s) - if isinstance(data, dict): - return self._make_legacy_problem(data) - return data - - def _make_legacy_problem(self, data): - request_id = None - error_code = None - code_type = None - - if "__type" in data: - error_code = data.get("__type") - else: - request_id = (data.get("ResponseContext") or {}).get("RequestId") - errors = data.get("Errors") - if errors: - error = errors[0] - error_code = error.get("Code") - reason = error.get("Type") - if error.get("Details"): - code_type = reason - else: - code_type = None - return LegacyProblem(None, error_code, code_type, request_id, None) - - -class LegacyProblem(Exception): - def __init__(self, status, error_code, code_type, request_id, url): - self.status = status - self.error_code = error_code - self.code_type = code_type - self.request_id = request_id - self.url = url - - def msg(self): - msg = ( - f"status = {self.status}, " - f"code = {self.error_code}, " - f"{'code_type = ' if self.code_type is not None else ''}" - f"{self.code_type + ', ' if self.code_type is not None else ''}" - f"request_id = {self.request_id}, " - f"url = {self.url}" - ) - return msg - - -def api_error(response): - try: - problem = None - ct = response.headers.get("content-type") or "" - if "application/json" in ct: - problem = response.json(cls=LegacyProblemDecoder) - problem.status = problem.status or str(response.status_code) - problem.url = response.url - elif "application/problem+json" in ct: - problem = response.json(cls=ProblemDecoder) - problem.status = problem.status or str(response.status_code) - - if problem: - return problem - except json.JSONDecodeError: - pass - - try: - error = ET.fromstring(response.text) - - err = dict() - for key, attr in [ - ("Code", "error_code"), - ("Message", "status"), - ("RequestId", "request_id"), - ("RequestID", "request_id"), - ]: - value = next((x.text for x in error.iter() if x.tag.endswith(key)), None) - if value: - err[attr] = value - - return LegacyProblem(**err) - except: - raise Exception( - f"Could not decode error response from {response.url} with status code {response.status_code}" - ) diff --git a/osc_sdk/sdk.py b/osc_sdk/sdk.py index 79b1425..e33bfdd 100755 --- a/osc_sdk/sdk.py +++ b/osc_sdk/sdk.py @@ -19,7 +19,144 @@ from requests.models import Response from typing_extensions import TypedDict -from .problem import api_error + +class ProblemDecoder(json.JSONDecoder): + def decode(self, s): + data = super().decode(s) + if isinstance(data, dict): + return self._make_problem(data) + return data + + def _make_problem(self, data): + type_ = data.pop("type", None) + status = data.pop("status", None) + title = data.pop("title", None) + detail = data.pop("detail", None) + instance = data.pop("instance", None) + return Problem(type_, status, title, detail, instance, **data) + + +class Problem(Exception): + def __init__(self, type_, status, title, detail, instance, **kwargs): + self._type = type_ or "about:blank" + self.status = status + self.title = title + self.detail = detail + self.instance = instance + self.extras = kwargs + + for k in self.extras: + if k in ["type", "status", "title", "detail", "instance"]: + raise ValueError(f"Reserved key '{k}' used in Problem extra arguments.") + + def __str__(self): + return self.title + + def __repr__(self): + return f"{self.__class__.__name__}" + + def msg(self): + msg = ( + f"type = {self._type}, " + f"status = {self.status}, " + f"title = {self.title}, " + f"detail = {self.detail}, " + f"instance = {self.instance}, " + f"extras = {self.extras}" + ) + return msg + + @property + def type(self): + return self._type + + +class LegacyProblemDecoder(json.JSONDecoder): + def decode(self, s): + data = super().decode(s) + if isinstance(data, dict): + return self._make_legacy_problem(data) + return data + + def _make_legacy_problem(self, data): + request_id = None + error_code = None + code_type = None + + if "__type" in data: + error_code = data.get("__type") + else: + request_id = (data.get("ResponseContext") or {}).get("RequestId") + errors = data.get("Errors") + if errors: + error = errors[0] + error_code = error.get("Code") + reason = error.get("Type") + if error.get("Details"): + code_type = reason + else: + code_type = None + return LegacyProblem(None, error_code, code_type, request_id, None) + + +class LegacyProblem(Exception): + def __init__(self, status, error_code, code_type, request_id, url): + self.status = status + self.error_code = error_code + self.code_type = code_type + self.request_id = request_id + self.url = url + + def msg(self): + msg = ( + f"status = {self.status}, " + f"code = {self.error_code}, " + f"{'code_type = ' if self.code_type is not None else ''}" + f"{self.code_type + ', ' if self.code_type is not None else ''}" + f"request_id = {self.request_id}, " + f"url = {self.url}" + ) + return msg + + +def api_error(response): + try: + problem = None + ct = response.headers.get("content-type") or "" + if "application/json" in ct: + problem: LegacyProblem = response.json(cls=LegacyProblemDecoder) + problem.status = problem.status or str(response.status_code) + problem.url = response.url + elif "application/problem+json" in ct: + problem: Problem = response.json(cls=ProblemDecoder) + problem.status = problem.status or str(response.status_code) + + if problem: + return problem + except json.JSONDecodeError: + # If it is not JSON, pass + pass + + try: + error = ET.fromstring(response.text) + + err = dict() + for key, attr in [ + ("Code", "error_code"), + ("Message", "status"), + ("RequestId", "request_id"), + ("RequestID", "request_id"), + ]: + value = next((x.text for x in error.iter() if x.tag.endswith(key)), None) + if value: + err[attr] = value + + return LegacyProblem(**err) + except: + raise Exception( + f"Could not decode error response from {response.url} with status code {response.status_code}" + ) + CANONICAL_URI = "/" CONFIGURATION_FILE = "config.json" diff --git a/osc_sdk/test_errors.py b/osc_sdk/test_errors.py index dca3c88..0dd342b 100644 --- a/osc_sdk/test_errors.py +++ b/osc_sdk/test_errors.py @@ -3,8 +3,7 @@ import pytest -from . import sdk -from .problem import LegacyProblem +from . import LegacyProblem, sdk @dataclass diff --git a/tests/generic_tests/00_one_call_per_service.sh b/tests/generic_tests/00_one_call_per_service.sh index e10af41..df85eec 100755 --- a/tests/generic_tests/00_one_call_per_service.sh +++ b/tests/generic_tests/00_one_call_per_service.sh @@ -5,7 +5,7 @@ source common_functions.sh # Assuming you are running this from a prepared virtual environment PROJECT_ROOT=$(cd "$(dirname $0)/../.." && pwd) cd $PROJECT_ROOT -c="python osc_sdk/sdk.py" +c="python -m osc_sdk.sdk" echo -n "$(basename $0): " diff --git a/tests/generic_tests/01_auth_access_key.sh b/tests/generic_tests/01_auth_access_key.sh index 1a032a2..834a928 100755 --- a/tests/generic_tests/01_auth_access_key.sh +++ b/tests/generic_tests/01_auth_access_key.sh @@ -5,7 +5,7 @@ source common_functions.sh # Assuming you are running this from a prepared virtual environment PROJECT_ROOT=$(cd "$(dirname $0)/../.." && pwd) cd $PROJECT_ROOT -c="python osc_sdk/sdk.py" +c="python -m osc_sdk.sdk" echo -n "$(basename $0): " diff --git a/tests/generic_tests/02_auth_bad.sh b/tests/generic_tests/02_auth_bad.sh index a191c64..64c9754 100755 --- a/tests/generic_tests/02_auth_bad.sh +++ b/tests/generic_tests/02_auth_bad.sh @@ -5,7 +5,7 @@ source common_functions.sh # Assuming you are running this from a prepared virtual environment PROJECT_ROOT=$(cd "$(dirname $0)/../.." && pwd) cd $PROJECT_ROOT -c="python osc_sdk/sdk.py" +c="python -m osc_sdk.sdk" echo -n "$(basename $0): " diff --git a/tests/generic_tests/03_auth_password.sh b/tests/generic_tests/03_auth_password.sh index b5a5736..499f49d 100755 --- a/tests/generic_tests/03_auth_password.sh +++ b/tests/generic_tests/03_auth_password.sh @@ -5,7 +5,7 @@ source common_functions.sh # Assuming you are running this from a prepared virtual environment PROJECT_ROOT=$(cd "$(dirname $0)/../.." && pwd) cd $PROJECT_ROOT -c="python osc_sdk/sdk.py" +c="python -m osc_sdk.sdk" echo -n "$(basename $0): " diff --git a/tests/generic_tests/04_auth_none.sh b/tests/generic_tests/04_auth_none.sh index 661f31c..af1d310 100755 --- a/tests/generic_tests/04_auth_none.sh +++ b/tests/generic_tests/04_auth_none.sh @@ -5,7 +5,7 @@ source common_functions.sh # Assuming you are running this from a prepared virtual environment PROJECT_ROOT=$(cd "$(dirname $0)/../.." && pwd) cd $PROJECT_ROOT -c="python osc_sdk/sdk.py" +c="python -m osc_sdk.sdk" echo -n "$(basename $0): " From 119a9800d86f3e1d93ffbc1bfa9fbb156ab2c0f6 Mon Sep 17 00:00:00 2001 From: "Ch.-David Blot" Date: Mon, 19 Jan 2026 15:39:58 +0100 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=85=20test:=20add=20test=20cases=20fo?= =?UTF-8?q?r=20RFC=209457=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/Pyinstaller-windows.yml | 8 ++++++++ osc_sdk/test_problems.py | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 osc_sdk/test_problems.py diff --git a/.github/workflows/Pyinstaller-windows.yml b/.github/workflows/Pyinstaller-windows.yml index 5a90515..2fcecbc 100644 --- a/.github/workflows/Pyinstaller-windows.yml +++ b/.github/workflows/Pyinstaller-windows.yml @@ -24,7 +24,15 @@ jobs: python -m pip install pyinstaller python -m pip install . pyinstaller --distpath ./pkg --clean --name osc-cli osc_sdk/sdk.py + Compress-Archive -Path pkg/osc-cli -DestinationPath osc-cli-x86_64.zip - name: smoke test run: | ./pkg/osc-cli/osc-cli.exe api ReadRegions | grep api.eu-west-2.outscale.com + + - name: Upload artifact + if: github.event_name == 'push' + uses: actions/upload-artifact@v4 + with: + name: osc-cli-win64 + path: osc-cli-x86_64.zip diff --git a/osc_sdk/test_problems.py b/osc_sdk/test_problems.py new file mode 100644 index 0000000..e9ba27b --- /dev/null +++ b/osc_sdk/test_problems.py @@ -0,0 +1,21 @@ +import json +from typing import List + +import pytest + +from .sdk import Problem, ProblemDecoder + +RAW_JSONS: List[str] = [ + r'{"type":"/errors/unsupported_media_type","status":415,"title":"Unsupported Media Type","detail":"Expected request with `Content-Type: application/json`"}', + r'{"type":"/errors/invalid_parameter","status":400,"title":"Bad Request","detail":"Origin: unknown variant `BAD_VALUE`, expected `OSC_KMS` or `EXTERNAL`"}', + r'{"type":"/errors/invalid_parameter","status":400,"title":"Bad Request","detail":"missing field `KeyId`"}', + """{"type":"/errors/invalid_parameter","status":400,"title":"Bad Request","detail":"KeyId: 'mck-toto' is not a key ID, an alias name, a key ORN or an alias ORN"}""", + r'{"type":"/errors/invalid_parameter","status":404,"title":"Not Found","detail":"Key not found: mck-6480ba0cd94845deb4967f18c6b32cb1"}', +] + + +@pytest.mark.parametrize("raw_json", RAW_JSONS) +def test_decode_raw_json_errors(raw_json: str): + """Test that ProblemDecoder can correctly decode raw JSON error strings.""" + decoded = json.loads(raw_json, cls=ProblemDecoder) + assert isinstance(decoded, Problem)