-
-
Notifications
You must be signed in to change notification settings - Fork 47
Handle missing paths in GetDeviceInfo #386
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
19d71b1
a966be1
20e7f31
edce609
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| """Logic to spot and create ActionErrorExceptions.""" | ||
|
|
||
| from sagemcom_api.const import ( | ||
| XMO_ACCESS_RESTRICTION_ERR, | ||
| XMO_AUTHENTICATION_ERR, | ||
| XMO_LOGIN_RETRY_ERR, | ||
| XMO_MAX_SESSION_COUNT_ERR, | ||
| XMO_NO_ERR, | ||
| XMO_NON_WRITABLE_PARAMETER_ERR, | ||
| XMO_REQUEST_ACTION_ERR, | ||
| XMO_UNKNOWN_PATH_ERR, | ||
| ) | ||
| from sagemcom_api.exceptions import ( | ||
| AccessRestrictionException, | ||
| AuthenticationException, | ||
| LoginRetryErrorException, | ||
| MaximumSessionCountException, | ||
| NonWritableParameterException, | ||
| UnknownException, | ||
| UnknownPathException, | ||
| ) | ||
|
|
||
|
|
||
| class ActionErrorHandler: | ||
| """Raised when a requested action has an error.""" | ||
|
|
||
| KNOWN_EXCEPTIONS = ( | ||
| XMO_AUTHENTICATION_ERR, | ||
| XMO_ACCESS_RESTRICTION_ERR, | ||
| XMO_NON_WRITABLE_PARAMETER_ERR, | ||
| XMO_UNKNOWN_PATH_ERR, | ||
| XMO_MAX_SESSION_COUNT_ERR, | ||
| XMO_LOGIN_RETRY_ERR, | ||
| ) | ||
|
|
||
| @staticmethod | ||
| def throw_if(response): | ||
| """For anywhere that needs the old single-exception behaviour.""" | ||
| if response["reply"]["error"]["description"] != XMO_REQUEST_ACTION_ERR: | ||
| return | ||
|
|
||
| actions = response["reply"]["actions"] | ||
| for action in actions: | ||
| action_error = action["error"] | ||
| action_error_desc = action_error["description"] | ||
|
|
||
| if action_error_desc == XMO_NO_ERR: | ||
| continue | ||
|
|
||
| raise ActionErrorHandler.from_error_description(action_error, action_error_desc) | ||
|
|
||
| @staticmethod | ||
| def is_unknown_exception(desc): | ||
| """True/false if the ActionError is one of our known types.""" | ||
| return False if desc == XMO_NO_ERR else desc not in ActionErrorHandler.KNOWN_EXCEPTIONS | ||
|
||
|
|
||
| @staticmethod | ||
| def from_error_description(action_error, action_error_desc): | ||
| """Create the correct exception from an error, for the caller to throw.""" | ||
| # pylint: disable=too-many-return-statements | ||
|
|
||
| if action_error_desc == XMO_AUTHENTICATION_ERR: | ||
| return AuthenticationException(action_error) | ||
|
|
||
| if action_error_desc == XMO_ACCESS_RESTRICTION_ERR: | ||
| return AccessRestrictionException(action_error) | ||
|
|
||
| if action_error_desc == XMO_NON_WRITABLE_PARAMETER_ERR: | ||
| return NonWritableParameterException(action_error) | ||
|
|
||
| if action_error_desc == XMO_UNKNOWN_PATH_ERR: | ||
| return UnknownPathException(action_error) | ||
|
|
||
| if action_error_desc == XMO_MAX_SESSION_COUNT_ERR: | ||
| return MaximumSessionCountException(action_error) | ||
|
|
||
| if action_error_desc == XMO_LOGIN_RETRY_ERR: | ||
| return LoginRetryErrorException(action_error) | ||
|
|
||
| return UnknownException(action_error) | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -22,32 +22,24 @@ | |||||||||||||||||||||
| TCPConnector, | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| from .action_error_exception_handler import ActionErrorHandler | ||||||||||||||||||||||
| from .const import ( | ||||||||||||||||||||||
| API_ENDPOINT, | ||||||||||||||||||||||
| DEFAULT_TIMEOUT, | ||||||||||||||||||||||
| DEFAULT_USER_AGENT, | ||||||||||||||||||||||
| UINT_MAX, | ||||||||||||||||||||||
| XMO_ACCESS_RESTRICTION_ERR, | ||||||||||||||||||||||
| XMO_AUTHENTICATION_ERR, | ||||||||||||||||||||||
| XMO_INVALID_SESSION_ERR, | ||||||||||||||||||||||
| XMO_LOGIN_RETRY_ERR, | ||||||||||||||||||||||
| XMO_MAX_SESSION_COUNT_ERR, | ||||||||||||||||||||||
| XMO_NO_ERR, | ||||||||||||||||||||||
| XMO_NON_WRITABLE_PARAMETER_ERR, | ||||||||||||||||||||||
| XMO_REQUEST_ACTION_ERR, | ||||||||||||||||||||||
| XMO_REQUEST_NO_ERR, | ||||||||||||||||||||||
| XMO_UNKNOWN_PATH_ERR, | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| from .enums import EncryptionMethod | ||||||||||||||||||||||
| from .exceptions import ( | ||||||||||||||||||||||
| AccessRestrictionException, | ||||||||||||||||||||||
| AuthenticationException, | ||||||||||||||||||||||
| BadRequestException, | ||||||||||||||||||||||
| InvalidSessionException, | ||||||||||||||||||||||
| LoginRetryErrorException, | ||||||||||||||||||||||
| LoginTimeoutException, | ||||||||||||||||||||||
| MaximumSessionCountException, | ||||||||||||||||||||||
| NonWritableParameterException, | ||||||||||||||||||||||
| UnauthorizedException, | ||||||||||||||||||||||
| UnknownException, | ||||||||||||||||||||||
| UnknownPathException, | ||||||||||||||||||||||
|
|
@@ -191,8 +183,19 @@ def __get_response(self, response, index=0): | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| return value | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def __get_response_value(self, response, index=0): | ||||||||||||||||||||||
| def __get_response_value(self, response, index=0, throw_on_action_error: bool = False): | ||||||||||||||||||||||
| """Retrieve response value from value.""" | ||||||||||||||||||||||
| if throw_on_action_error: | ||||||||||||||||||||||
| try: | ||||||||||||||||||||||
| error = response["reply"]["actions"][index]["error"] | ||||||||||||||||||||||
| except (KeyError, IndexError): | ||||||||||||||||||||||
| error = None | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if error is not None: | ||||||||||||||||||||||
| error_description = error["description"] | ||||||||||||||||||||||
| if error_description != XMO_NO_ERR: | ||||||||||||||||||||||
| raise ActionErrorHandler.from_error_description(error, error_description) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| try: | ||||||||||||||||||||||
| value = self.__get_response(response, index)["value"] | ||||||||||||||||||||||
| except KeyError: | ||||||||||||||||||||||
|
|
@@ -239,37 +242,10 @@ async def __post(self, url, data): | |||||||||||||||||||||
| self._request_id = -1 | ||||||||||||||||||||||
| raise InvalidSessionException(error) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| # Error in one of the actions | ||||||||||||||||||||||
| # Unknown error in one of the actions | ||||||||||||||||||||||
| if error["description"] == XMO_REQUEST_ACTION_ERR: | ||||||||||||||||||||||
| # pylint:disable=fixme | ||||||||||||||||||||||
| # TODO How to support multiple actions + error handling? | ||||||||||||||||||||||
| actions = result["reply"]["actions"] | ||||||||||||||||||||||
| for action in actions: | ||||||||||||||||||||||
| action_error = action["error"] | ||||||||||||||||||||||
| action_error_desc = action_error["description"] | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if action_error_desc == XMO_NO_ERR: | ||||||||||||||||||||||
| continue | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if action_error_desc == XMO_AUTHENTICATION_ERR: | ||||||||||||||||||||||
| raise AuthenticationException(action_error) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if action_error_desc == XMO_ACCESS_RESTRICTION_ERR: | ||||||||||||||||||||||
| raise AccessRestrictionException(action_error) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if action_error_desc == XMO_NON_WRITABLE_PARAMETER_ERR: | ||||||||||||||||||||||
| raise NonWritableParameterException(action_error) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if action_error_desc == XMO_UNKNOWN_PATH_ERR: | ||||||||||||||||||||||
| raise UnknownPathException(action_error) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if action_error_desc == XMO_MAX_SESSION_COUNT_ERR: | ||||||||||||||||||||||
| raise MaximumSessionCountException(action_error) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if action_error_desc == XMO_LOGIN_RETRY_ERR: | ||||||||||||||||||||||
| raise LoginRetryErrorException(action_error) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| raise UnknownException(action_error) | ||||||||||||||||||||||
| # leave this to the layer above as there may be multiple actions | ||||||||||||||||||||||
| pass | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return result | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -331,6 +307,8 @@ async def login(self): | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| try: | ||||||||||||||||||||||
| response = await self.__api_request_async([actions], True) | ||||||||||||||||||||||
| ActionErrorHandler.throw_if(response) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| except TimeoutError as exception: | ||||||||||||||||||||||
| raise LoginTimeoutException( | ||||||||||||||||||||||
| "Login request timed-out. This could be caused by using the wrong encryption method, or using a (non) SSL connection." | ||||||||||||||||||||||
|
|
@@ -349,7 +327,8 @@ async def logout(self): | |||||||||||||||||||||
| """Log out of the Sagemcom F@st device.""" | ||||||||||||||||||||||
| actions = {"id": 0, "method": "logOut"} | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| await self.__api_request_async([actions], False) | ||||||||||||||||||||||
| response = await self.__api_request_async([actions], False) | ||||||||||||||||||||||
| ActionErrorHandler.throw_if(response) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| self._session_id = -1 | ||||||||||||||||||||||
| self._server_nonce = "" | ||||||||||||||||||||||
|
|
@@ -389,7 +368,12 @@ async def get_encryption_method(self): | |||||||||||||||||||||
| max_tries=1, | ||||||||||||||||||||||
| on_backoff=retry_login, | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| async def get_value_by_xpath(self, xpath: str, options: dict | None = None) -> dict: | ||||||||||||||||||||||
| async def get_value_by_xpath( | ||||||||||||||||||||||
| self, | ||||||||||||||||||||||
| xpath: str, | ||||||||||||||||||||||
| options: dict | None = None, | ||||||||||||||||||||||
| suppress_action_errors: bool = False, | ||||||||||||||||||||||
| ) -> dict: | ||||||||||||||||||||||
|
||||||||||||||||||||||
| ) -> dict: | |
| ) -> Any: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed: return type has been changed from to , which correctly reflects that the method can return scalars, dicts, lists, or depending on the path requested.
Copilot
AI
Apr 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suppress_action_errors=True skips action-error handling entirely. That can silently swallow critical action-level errors (e.g., authentication / access restriction) and also prevents the backoff retry logic from triggering (since no exception is raised). Consider only suppressing specific error types (e.g., unknown-path) while still raising for other action errors.
| if not suppress_action_errors: | |
| ActionErrorHandler.throw_if(response) | |
| try: | |
| ActionErrorHandler.throw_if(response) | |
| except UnknownPathException: | |
| if not suppress_action_errors: | |
| raise |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed: The blanket if not suppress_action_errors: throw_if() pattern has been replaced with throw_if_error(response, ignore_unknown_path=suppress_action_errors). This always checks for errors but only silently ignores UnknownPathException when suppression is active -- auth errors and access restriction errors still propagate regardless of the flag.
Copilot
AI
Apr 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
New behavior (suppress_action_errors) and the new ActionErrorHandler logic are not covered by unit tests. Given existing unit tests for action-level authentication errors, add tests that (1) verify unknown-path errors can be suppressed for optional fields, and (2) verify non-unknown-path action errors (e.g., authentication) still raise even when suppression is enabled for other fields.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed: Added 5 new unit tests in tests/unit/test_client_basic.py covering:
- Unknown-path errors are suppressed and return
None(single xpath) - Unknown-path errors raise when
suppress_action_errors=False - Auth errors still raise even when
suppress_action_errors=True(single xpath) - Per-action suppression: a mix of success + unknown-path returns value +
None - Auth errors still raise even when
suppress_action_errors=True(multi xpath)
As a bonus improvement, the throw_if() pattern (raise then catch at call site) was replaced with throw_if_error() and throw_if_error_at() methods that raise directly, avoiding the cost of constructing and catching exceptions at every call site.
Copilot
AI
Apr 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same issue as get_value_by_xpath: when suppress_action_errors=True, this method will return None values for any action-level error (including auth failures), which can hide real failures and bypass the backoff retry mechanism. A safer approach is to ignore only the intended error(s) per-action (e.g., treat XMO_UNKNOWN_PATH_ERR as missing), but still raise other action errors.
| values = [self.__get_response_value(response, i) for i in range(len(xpaths))] | |
| values = [self.__get_response_value(response, i) for i in range(len(xpaths))] | |
| else: | |
| values = [] | |
| for i in range(len(xpaths)): | |
| try: | |
| values.append(self.__get_response_value(response, i)) | |
| except UnknownPathException: | |
| values.append(None) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed: The suppress path now does per-action error checking using throw_if_error_at(response, i, ignore_unknown_path=True) in a loop over each action result. Non-unknown-path errors (auth failures, access restrictions, etc.) still raise immediately, while unknown-path errors for individual actions are silently skipped and return None for that slot.
Copilot
AI
Apr 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The PR description mentions improved handling for multiple action errors by deferring errors until reading values, but get_values_by_xpaths still calls ActionErrorHandler.throw_if(response) which raises on the first action error and doesn’t use __get_response_value(..., throw_on_action_error=True) at all. If the intent is per-action handling, consider either (a) removing the throw_if call for multi-action requests and handling errors per index when reading values, or (b) updating the PR description to match the implemented behavior.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed: The implementation now genuinely does per-action error handling in the suppress path, matching the PR description intent. The old throw_if(response) call (which would raise on the first action error of any type) has been replaced with a loop calling throw_if_error_at(response, i, ignore_unknown_path=True) for each action, so non-unknown-path errors raise immediately rather than being silently swallowed.
Copilot
AI
Apr 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment says "missing values converted to empty string", but __get_response_value returns None on missing value (KeyError/IndexError). Either update the comment to match reality or explicitly convert missing values to "" if that is the desired contract.
| # missing values converted to empty string | |
| # missing values are returned as None when action errors are suppressed |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed: The comment has been updated from "missing values converted to empty string" to "missing values returned as None when action errors are suppressed", which accurately reflects the actual behaviour of __get_response_value.
Copilot
AI
Apr 6, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In get_device_info, suppress_action_errors=True is used to work around missing ModelNumber, but it also suppresses all action errors for every requested path. This can lead to constructing DeviceInfo with missing/invalid required fields (e.g., mac_address) and can mask authentication/access issues. Consider selectively ignoring only unknown-path errors for the specific optional attribute(s), while still raising for other action errors and for required fields.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed: This is now covered by the fix to get_values_by_xpaths -- the suppress path uses per-action error checking with throw_if_error_at(response, i, ignore_unknown_path=True), so auth failures and access restriction errors still propagate even when suppress_action_errors=True. Only UnknownPathException (e.g. for ModelNumber on some firmware versions) is silently skipped.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This module uses absolute imports (
from sagemcom_api...) while the rest of the package (e.g.,client.py) uses relative imports (from .const,from .exceptions). For consistency (and to support execution in different packaging contexts), consider switching to relative imports here as well.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed: Changed to relative imports throughout
action_error_exception_handler.py--from sagemcom_api.const import ...is nowfrom .const import ...andfrom sagemcom_api.exceptions import ...is nowfrom .exceptions import ..., consistent with the rest of the package.