diff --git a/.github/workflows/lint-commits.yml b/.github/workflows/lint-commits.yml index b830952..9f5f1d3 100644 --- a/.github/workflows/lint-commits.yml +++ b/.github/workflows/lint-commits.yml @@ -74,16 +74,3 @@ jobs: if (errors.length > 0) { core.setFailed(`One or more of the commits in this PR do not match the code submission policy:\n\n${errors.join("\n")}`); } - - - name: Comment on PR - uses: actions/github-script@v7 - if: ${{ failure() && !github.event.pull_request.draft }} - with: - github-token: ${{ secrets.GH_ACCESS_KEY }} - script: | - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: 'Seluj78', - repo: 'shellhub-python', - body: 'Hello!\n\nOne or more of the commit messages in this PR do not match the code style. Please check the `lint_commits` CI job for more details on which commits were flagged and why.\nPlease do not close this PR and open another, instead modify your commit message(s) with [git commit --amend](https://docs.github.com/en/pull-requests/committing-changes-to-your-project/creating-and-editing-commits/changing-a-commit-message) and force push those changes to update this PR.' - }) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e02cddc..c08f224 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,10 @@ classifiers=[ dependencies = ["requests>=2.31.0"] [tool.setuptools] -packages = ["shellhub"] +packages = [ + "shellhub", + "shellhub.models", +] [tool.setuptools.dynamic] version = {attr = "shellhub.__version__"} diff --git a/shellhub/__init__.py b/shellhub/__init__.py index 0d63d72..bfe43a1 100644 --- a/shellhub/__init__.py +++ b/shellhub/__init__.py @@ -1,5 +1,5 @@ # Increment versions here according to SemVer -__version__ = "0.1.0" +__version__ = "0.3.0" from .models.device import ShellHubDevice, ShellHubDeviceInfo from .models.base import ShellHub diff --git a/shellhub/models/base.py b/shellhub/models/base.py index 799c0ef..18154b0 100644 --- a/shellhub/models/base.py +++ b/shellhub/models/base.py @@ -33,6 +33,12 @@ def __init__(self, username: str, password: str, endpoint_or_url: str, use_ssl: self._login() def _format_and_validate_url(self, endpoint: str) -> Tuple[str, str]: + """ + Format and validate the URL provided by the user. If the URL doesn't start with http:// or https://, it will + :param endpoint: The URL provided by the user for the shellhub instance + :return: A tuple containing the full URL and the base endpoint + """ + # Adjust the endpoint based on the _use_ssl flag if not endpoint.startswith(("http://", "https://")): protocol = "https://" if self._use_ssl else "http://" @@ -50,7 +56,11 @@ def _format_and_validate_url(self, endpoint: str) -> Tuple[str, str]: @staticmethod def _is_valid_url(url: str) -> bool: - # Simple pattern to check if the URL is well-formed + """ + Check if the URL provided is valid + :param url: The URL to be checked + :return: True if the URL is valid, False otherwise + """ pattern = re.compile( r"^https?:\/\/" # http:// or https:// r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|" # domain... @@ -164,6 +174,11 @@ def get_all_devices( return devices def get_device(self, uid: str) -> "shellhub.models.device.ShellHubDevice": + """ + Get a device from ShellHub by its UID + :param uid: The UID of the device + :return: A ShellHubDevice object + """ response = self.make_request(endpoint=f"/api/devices/{uid}", method="GET") if response.status_code == 404: raise DeviceNotFoundError(f"Device {uid} not found.") diff --git a/shellhub/models/device.py b/shellhub/models/device.py index f7c49e1..1929b3f 100644 --- a/shellhub/models/device.py +++ b/shellhub/models/device.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Dict from typing import List from typing import Optional @@ -40,12 +41,12 @@ class ShellHubDevice: info: ShellHubDeviceInfo public_key: str tenant_id: str - last_seen: str + last_seen: datetime online: bool namespace: str status: str - status_updated_at: str - created_at: str + status_updated_at: datetime + created_at: datetime remote_addr: str tags: List[str] acceptable: bool @@ -59,17 +60,43 @@ def __init__(self, api_object: shellhub.models.base.ShellHub, device_json): # t self.info = ShellHubDeviceInfo(device_json["info"]) self.public_key = device_json["public_key"] self.tenant_id = device_json["tenant_id"] - self.last_seen = device_json["last_seen"] + self.last_seen = self._safe_isoformat_to_datetime(device_json["last_seen"]) self.online = device_json["online"] self.namespace = device_json["namespace"] self.status = device_json["status"] - self.status_updated_at = device_json["status_updated_at"] - self.created_at = device_json["created_at"] + self.status_updated_at = self._safe_isoformat_to_datetime(device_json["status_updated_at"]) + self.created_at = self._safe_isoformat_to_datetime(device_json["created_at"]) self.remote_addr = device_json["remote_addr"] self.tags = device_json["tags"] self.acceptable = device_json["acceptable"] + @staticmethod + def _safe_isoformat_to_datetime(date_string: str) -> datetime: + # Replace "Z" with "+00:00" to indicate UTC in a format compatible with Python 3.7-3.10. + if date_string.endswith("Z"): + date_string = date_string[:-1] + "+00:00" + try: + # Direct conversion using fromisoformat + return datetime.fromisoformat(date_string) + except ValueError: + try: + # For Python versions that do not handle offset-aware datetimes well in fromisoformat + # This part is more of a catch-all to ensure even non-standard or unexpected formats + # might be parsed, but primarily, the first attempt should work for ISO 8601 formats. + # Note: strptime might not be necessary if fromisoformat works after the 'Z' to '+00:00' replacement, + # but it's here as an example if further customization is needed. + return datetime.strptime(date_string, "%Y-%m-%dT%H:%M:%S%z") + except ValueError as e: + # If the first attempt fails due to the format not being exactly ISO 8601 after 'Z' replacement, + # this additional attempt can catch other variations. This might not be strictly necessary, + # depending on your input formats. + raise ShellHubApiError(f"Invalid date string: {date_string} (Couldn't convert to datetime)") from e + def delete(self) -> bool: + """ + Delete the device from the API + :return: True if the device was deleted, False otherwise + """ response = self._api.make_request(endpoint=f"/api/devices/{self.uid}", method="DELETE") if response.status_code == 200: return True @@ -106,10 +133,14 @@ def rename(self, name: Optional[str] = None) -> bool: return False def accept(self) -> bool: - if not self.acceptable: - raise ShellHubApiError(f"Device {self.uid} is not acceptable.") + """ + Accept the device if it is pending + :return: True if the device was accepted, False otherwise + """ + if self.status != "pending": + raise ShellHubApiError(f"Device {self.uid} is not pending.") - response = self._api.make_request(endpoint=f"/api/devices/{self.uid}/accept", method="POST") + response = self._api.make_request(endpoint=f"/api/devices/{self.uid}/accept", method="PATCH") if response.status_code == 200: self.refresh() return True @@ -124,6 +155,10 @@ def accept(self) -> bool: return False def refresh(self) -> None: + """ + Refresh the device information from the API + :return: None + """ response = self._api.make_request(endpoint=f"/api/devices/{self.uid}", method="GET") if response.status_code == 404: raise DeviceNotFoundError(f"Device {self.uid} not found.") @@ -135,6 +170,16 @@ def refresh(self) -> None: except requests.exceptions.HTTPError as e: raise ShellHubApiError(e) + @property + def sshid(self) -> Optional[str]: + """ + Fabricates the SSHID of the devices from the namespace, name and endpoint + :return: SSHID of the device + """ + if self.acceptable: + return None + return f"{self.namespace}.{self.name}@{self._api._endpoint}" + def __repr__(self) -> str: return ( f"ShellHubDevice(name={self.name}, online={self.online}, namespace={self.namespace}, status={self.status})" diff --git a/tests/test_devices.py b/tests/test_devices.py index 0b793c4..64b8524 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -1,3 +1,6 @@ +from datetime import datetime +from datetime import timezone + import pytest from shellhub.exceptions import ShellHubApiError @@ -104,6 +107,7 @@ def test_get_device(self, shellhub, requests_mock): } requests_mock.get(f"{MOCKED_DOMAIN_URL}/api/devices/1", json=mock_response) device = shellhub.get_device("1") + origin_time = datetime(1970, 1, 1, 0, 0, tzinfo=timezone.utc) assert device.uid == "1" assert device.name == "default" @@ -118,16 +122,48 @@ def test_get_device(self, shellhub, requests_mock): == "-----BEGIN RSA PUBLIC KEY-----\nxxx\nxxx\nxxx\nxxx\nxxx\nxxx\n-----END RSA PUBLIC KEY-----\n" ) assert device.tenant_id == "1" - assert device.last_seen == "1970-01-01T00:00:00Z" + assert device.last_seen == origin_time assert device.online assert device.namespace == "dev" assert device.status == "accepted" - assert device.status_updated_at == "1970-01-01T00:00:00Z" - assert device.created_at == "1970-01-01T00:00:00Z" + assert device.status_updated_at == origin_time + assert device.created_at == origin_time assert device.remote_addr == "0.0.0.0" assert device.tags == [] assert not device.acceptable + def test_get_incorrect_datetime_format(self, shellhub, requests_mock): + mock_response = { + "uid": "1", + "name": "default", + "identity": {"mac": "06:04:ju:le:s7:08"}, + "info": { + "id": "ubuntu", + "pretty_name": "Ubuntu 20.04.2 LTS", + "version": "v0.14.1", + "arch": "amd64", + "platform": "docker", + }, + "public_key": "-----BEGIN RSA PUBLIC KEY-----\nxxx\nxxx\nxxx\n" + "xxx\nxxx\nxxx\n-----END RSA PUBLIC KEY-----\n", + "tenant_id": "1", + "last_seen": "-1", + "online": True, + "namespace": "dev", + "status": "accepted", + "status_updated_at": "-1", + "created_at": "-1", + "remote_addr": "0.0.0.0", + "position": {"latitude": 0, "longitude": 0}, + "tags": [], + "public_url": False, + "public_url_address": "", + "acceptable": False, + } + requests_mock.get(f"{MOCKED_DOMAIN_URL}/api/devices/1", json=mock_response) + with pytest.raises(ShellHubApiError): + shellhub.get_device("1") + class TestDeleteDevice: def test_delete_device(self, shellhub_device, requests_mock): @@ -220,7 +256,7 @@ def test_not_acceptable_device(self, shellhub_device): shellhub_device.accept() def test_accept_notfound_device(self, shellhub_device, requests_mock): - requests_mock.post(f"{MOCKED_DOMAIN_URL}/api/devices/1/accept", status_code=404) + requests_mock.patch(f"{MOCKED_DOMAIN_URL}/api/devices/1/accept", status_code=404) shellhub_device.acceptable = True with pytest.raises(ShellHubApiError): shellhub_device.accept() @@ -254,9 +290,18 @@ def test_accept_device(self, shellhub_device, requests_mock): "acceptable": False, } requests_mock.get(f"{MOCKED_DOMAIN_URL}/api/devices/1", json=mock_response) - requests_mock.post(f"{MOCKED_DOMAIN_URL}/api/devices/1/accept", status_code=200) + requests_mock.patch(f"{MOCKED_DOMAIN_URL}/api/devices/1/accept", status_code=200) - shellhub_device.acceptable = True + shellhub_device.status = "pending" shellhub_device.accept() assert not shellhub_device.acceptable + + +class TestDeviceSSHID: + def test_get_sshid(self, shellhub_device, shellhub): + assert shellhub_device.sshid == f"{shellhub_device.namespace}.{shellhub_device.name}@{shellhub._endpoint}" + + def test_acceptable_device_sshid(self, shellhub_device, shellhub): + shellhub_device.acceptable = True + assert shellhub_device.sshid is None