From 5a2c8dafc5e5f3d383df05321dd0f5ead5f82f32 Mon Sep 17 00:00:00 2001 From: Jules Lasne Date: Wed, 31 Jan 2024 13:01:23 +0100 Subject: [PATCH 1/8] Core: Added base code and tests --- README.md | 8 +- pyproject.toml | 19 +++ requirements-dev.txt | 4 + shellhub/__init__.py | 2 + shellhub/exceptions.py | 2 + shellhub/models/base.py | 105 ++++++++++++++ shellhub/models/device.py | 124 +++++++++++++++++ tests/__init__.py | 0 tests/conftest.py | 55 ++++++++ tests/test_base.py | 37 +++++ tests/test_devices.py | 279 ++++++++++++++++++++++++++++++++++++++ 11 files changed, 634 insertions(+), 1 deletion(-) create mode 100644 shellhub/exceptions.py create mode 100644 shellhub/models/base.py create mode 100644 shellhub/models/device.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_base.py create mode 100644 tests/test_devices.py diff --git a/README.md b/README.md index c899626..e9bcb08 100644 --- a/README.md +++ b/README.md @@ -91,4 +91,10 @@ When contributing: ## Code owner -jules.lasne@gmail.com \ No newline at end of file +jules.lasne@gmail.com + +## TODO: + +- [ ] Migrate tests to pytest-recording +- [ ] Add a readthedocs documentation +- [ ] Switch to an OpenAPI generated client ? see https://github.com/shellhub-io/shellhub/issues/3497#issuecomment-1917478654 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index d519223..dbc6411 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,3 +50,22 @@ packages = ["shellhub"] [tool.setuptools.dynamic] version = {attr = "shellhub.__version__"} + +[tool.coverage.run] +branch = true +source = ['shellhub/', 'tests/'] + +[tool.pytest.ini_options] +pythonpath = [ + "." +] +testpaths = [ + "tests", +] +addopts = [ + "--strict-markers", + "-W ignore::DeprecationWarning", + "-W ignore::PendingDeprecationWarning", +] +markers = [ +] diff --git a/requirements-dev.txt b/requirements-dev.txt index e96a5a6..fa45922 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,3 +3,7 @@ reorder-python-imports flake8 pre-commit -r requirements.txt + +pytest +requests-mock +pytest-cov \ No newline at end of file diff --git a/shellhub/__init__.py b/shellhub/__init__.py index d4e2e08..620d0a5 100644 --- a/shellhub/__init__.py +++ b/shellhub/__init__.py @@ -4,9 +4,11 @@ # Import here what you want to be able to import from this package and place it in __all__ from shellhub.models.device import ShellHubDevice, ShellHubDeviceInfo from shellhub.models.base import ShellHub +from shellhub.exceptions import ShellHubApiError __all__ = [ "ShellHub", "ShellHubDevice", "ShellHubDeviceInfo", + "ShellHubApiError", ] diff --git a/shellhub/exceptions.py b/shellhub/exceptions.py new file mode 100644 index 0000000..e450873 --- /dev/null +++ b/shellhub/exceptions.py @@ -0,0 +1,2 @@ +class ShellHubApiError(Exception): + pass diff --git a/shellhub/models/base.py b/shellhub/models/base.py new file mode 100644 index 0000000..af0c40e --- /dev/null +++ b/shellhub/models/base.py @@ -0,0 +1,105 @@ +from typing import List +from typing import Optional + +import requests + +import shellhub.models.device +from shellhub.exceptions import ShellHubApiError + + +class ShellHub: + _username: str + _password: str + _endpoint: str + _access_token: Optional[str] + + def __init__(self, username: str, password: str, endpoint: str): + self._username: str = username + self._password: str = password + self._endpoint: str = endpoint + self._access_token: Optional[str] = None + + self._login() + + def __repr__(self): + return f"" + + def __str__(self): + return self._endpoint + + def _login(self): + try: + response = requests.post( + f"{self._endpoint}/api/login", + json={ + "username": self._username, + "password": self._password, + }, + ) + except requests.exceptions.ConnectionError: + raise ValueError("Incorrect endpoint. Is the server up and running ?") + + if response.status_code == 401: + raise ShellHubApiError("Incorrect username or password") + elif response.status_code != 200: + response.raise_for_status() + self._access_token = response.json()["token"] + + def make_request( + self, endpoint: str, method: str, query_params: Optional[dict] = None, json: Optional[dict] = None + ): + params = "" + if query_params: + params = "?" + for key, value in query_params.items(): + params += f"{key}={value}&" + params = params[:-1] + + response = getattr(requests, method.lower())( + f"{self._endpoint}{endpoint}{params if params else ''}", + headers={ + "Authorization": f"Bearer {self._access_token}", + }, + json=json, + ) + return response + + def _get_devices(self, query_params: Optional[dict] = None) -> "List[shellhub.models.device.ShellHubDevice]": + response = self.make_request(endpoint="/api/devices", method="GET", query_params=query_params) + + response.raise_for_status() + + devices = [] + for device in response.json(): + devices.append(shellhub.models.device.ShellHubDevice(self, device)) + return devices + + def get_all_devices( + self, status: Optional[str] = None, query_params: Optional[dict] = None + ) -> "List[shellhub.models.device.ShellHubDevice]": + """ + Get all devices from ShellHub. Default gets all devices + """ + if not query_params: + query_params = {} + if status: + if status not in ["accepted", "rejected", "pending", "removed", "unused"]: + raise ValueError("status must be one of accepted, rejected or pending") + query_params["status"] = status + devices = [] + page = 1 + while True: + devices_response = self._get_devices(query_params={"page": page, "per_page": 100, **query_params}) + devices += devices_response + if len(devices_response) < 100: + break + page += 1 + return devices + + def get_device(self, uid: str) -> "shellhub.models.device.ShellHubDevice": + response = self.make_request(endpoint=f"/api/devices/{uid}", method="GET") + if response.status_code == 404: + raise ShellHubApiError(f"Device {uid} not found.") + elif response.status_code != 200: + response.raise_for_status() + return shellhub.models.device.ShellHubDevice(self, response.json()) diff --git a/shellhub/models/device.py b/shellhub/models/device.py new file mode 100644 index 0000000..3410d83 --- /dev/null +++ b/shellhub/models/device.py @@ -0,0 +1,124 @@ +from typing import List +from typing import Optional + +import shellhub.models.base +from shellhub.exceptions import ShellHubApiError + + +class ShellHubDeviceInfo: + id: str + pretty_name: str + version: str + arch: str + platform: str + + def __init__(self, device_info_json: dict): + self.id = device_info_json["id"] + self.pretty_name = device_info_json["pretty_name"] + self.version = device_info_json["version"] + self.arch = device_info_json["arch"] + self.platform = device_info_json["platform"] + + def __repr__(self): + return ( + f"ShellHubDeviceInfo(id={self.id}, pretty_name={self.pretty_name}, " + f"version={self.version}, arch={self.arch}, platform={self.platform})" + ) + + def __str__(self): + return self.__repr__() + + +class ShellHubDevice: + uid: str + name: str + mac_address: str + info: ShellHubDeviceInfo + public_key: str + tenant_id: str + last_seen: str + online: bool + namespace: str + status: str + status_updated_at: str + created_at: str + remote_addr: str + tags: List[str] + acceptable: bool + + def __init__(self, api_object: shellhub.models.base.ShellHub, device_json: dict): + self._api = api_object + + self.uid = device_json["uid"] + self.name = device_json["name"] + self.mac_address = device_json["identity"]["mac"] + self.info = ShellHubDeviceInfo(device_json["info"]) + self.public_key = device_json["public_key"] + self.tenant_id = device_json["tenant_id"] + # TODO: Convert to datetime object + self.last_seen = device_json["last_seen"] + self.online = device_json["online"] + self.namespace = device_json["namespace"] + self.status = device_json["status"] + # TODO: Convert to datetime object + self.status_updated_at = device_json["status_updated_at"] + # TODO: Convert to datetime object + self.created_at = device_json["created_at"] + self.remote_addr = device_json["remote_addr"] + self.tags = device_json["tags"] + self.acceptable = device_json["acceptable"] + + def delete(self): + response = self._api.make_request(endpoint=f"/api/devices/{self.uid}", method="DELETE") + if response.status_code == 200: + return True + elif response.status_code == 404: + raise ShellHubApiError(f"Device {self.uid} not found.") + else: + response.raise_for_status() + + def rename(self, name: Optional[str] = None): + """ + Set a new name for the device. If no name is provided, the name will be the mac address of the device + """ + if not name: + name = self.mac_address.replace(":", "-") + response = self._api.make_request(endpoint=f"/api/devices/{self.uid}", method="PUT", json={"name": name}) + if response.status_code == 200: + self.name = name + return True + elif response.status_code == 404: + raise ShellHubApiError(f"Device {self.uid} not found.") + elif response.status_code == 409: + raise ShellHubApiError(f"Device with name {name} already exists.") + else: + response.raise_for_status() + + def accept(self): + if not self.acceptable: + raise ShellHubApiError(f"Device {self.uid} is not acceptable.") + + response = self._api.make_request(endpoint=f"/api/devices/{self.uid}/accept", method="POST") + if response.status_code == 200: + self.refresh() + return True + elif response.status_code == 404: + raise ShellHubApiError(f"Device {self.uid} not found.") + else: + response.raise_for_status() + + def refresh(self): + response = self._api.make_request(endpoint=f"/api/devices/{self.uid}", method="GET") + if response.status_code == 404: + raise ShellHubApiError(f"Device {self.uid} not found.") + elif response.status_code != 200: + response.raise_for_status() + self.__init__(self._api, response.json()) + + def __repr__(self): + return ( + f"ShellHubDevice(name={self.name}, online={self.online}, namespace={self.namespace}, status={self.status})" + ) + + def __str__(self): + return self.uid diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b6091cf --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,55 @@ +import pytest +import requests_mock as r_mock + +from shellhub import ShellHub + + +@pytest.fixture(scope="function") +def shellhub(): + with r_mock.Mocker() as m: + # Mock the login URL + login_url = "http://localhost.shellhub/api/login" + mock_response = { + "token": "jwt_token", + } + m.post(login_url, json=mock_response) + + # Create an instance of ShellHub with mocked login + shellhub_instance = ShellHub(username="john.doe", password="dolphin", endpoint="http://localhost.shellhub") + + yield shellhub_instance + + +@pytest.fixture(scope="function") +def shellhub_device(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": "1970-01-01T00:00:00Z", + "online": True, + "namespace": "dev", + "status": "accepted", + "status_updated_at": "1970-01-01T00:00:00Z", + "created_at": "1970-01-01T00:00:00Z", + "remote_addr": "0.0.0.0", + "position": {"latitude": 0, "longitude": 0}, + "tags": [], + "public_url": False, + "public_url_address": "", + "acceptable": False, + } + ] + requests_mock.get("http://localhost.shellhub/api/devices", json=mock_response) + return shellhub.get_all_devices()[0] diff --git a/tests/test_base.py b/tests/test_base.py new file mode 100644 index 0000000..73ef93f --- /dev/null +++ b/tests/test_base.py @@ -0,0 +1,37 @@ +import pytest + +from shellhub import ShellHub +from shellhub import ShellHubApiError + + +def test_login(requests_mock): + login_url = "http://localhost.shellhub/api/login" + mock_response = { + "token": "jwt_token", + } + requests_mock.post(login_url, json=mock_response) + shellhub = ShellHub(username="john.doe", password="dolphin", endpoint="http://localhost.shellhub") + assert shellhub._access_token == mock_response["token"] + + +def test_incorrect_endpoint(): + with pytest.raises(ValueError): + ShellHub(username="john.doe", password="dolphin", endpoint="http://localhost.shellhub") + + +def test_incorrect_username_password(requests_mock): + login_url = "http://localhost.shellhub/api/login" + mock_response = { + "detail": "Incorrect username or password", + } + requests_mock.post(login_url, json=mock_response, status_code=401) + with pytest.raises(ShellHubApiError): + ShellHub(username="john.doe", password="dolphin", endpoint="http://localhost.shellhub") + + +def test_repr(shellhub): + assert repr(shellhub) == "" + + +def test_str(shellhub): + assert str(shellhub) == shellhub._endpoint diff --git a/tests/test_devices.py b/tests/test_devices.py new file mode 100644 index 0000000..675b29a --- /dev/null +++ b/tests/test_devices.py @@ -0,0 +1,279 @@ +import pytest + +from shellhub.exceptions import ShellHubApiError + + +def test_repr(shellhub_device): + assert repr(shellhub_device) == "ShellHubDevice(name=default, online=True, namespace=dev, status=accepted)" + + +def test_str(shellhub_device): + assert str(shellhub_device) == shellhub_device.uid + + +class TestGetDevices: + def test_get_no_devices(self, shellhub, requests_mock): + mock_response = [] + requests_mock.get("http://localhost.shellhub/api/devices", json=mock_response) + devices = shellhub.get_all_devices() + assert len(devices) == 0 + + def test_get_incorrect_status(self, shellhub): + with pytest.raises(ValueError): + shellhub.get_all_devices(status="incorrect_status") + + @pytest.mark.parametrize( + "status", + [ + "accepted", + "pending", + "rejected", + "removed", + "unused", + ], + ) + def test_correct_status(self, shellhub, requests_mock, status): + 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" + "\nxxx\nxxx\nxxx\n-----END RSA PUBLIC KEY-----\n", + "tenant_id": "1", + "last_seen": "1970-01-01T00:00:00Z", + "online": True, + "namespace": "dev", + "status": "accepted", + "status_updated_at": "1970-01-01T00:00:00Z", + "created_at": "1970-01-01T00:00:00Z", + "remote_addr": "0.0.0.0", + "position": {"latitude": 0, "longitude": 0}, + "tags": [], + "public_url": False, + "public_url_address": "", + "acceptable": False, + } + ] + requests_mock.get("http://localhost.shellhub/api/devices", json=mock_response) + devices = shellhub.get_all_devices(status=status) + assert len(devices) == 1 + + +class TestGetDevice: + def test_device_not_found(self, shellhub, requests_mock): + requests_mock.get("http://localhost.shellhub/api/devices/1", status_code=404) + with pytest.raises(ShellHubApiError): + shellhub.get_device("1") + + def test_get_device(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": "1970-01-01T00:00:00Z", + "online": True, + "namespace": "dev", + "status": "accepted", + "status_updated_at": "1970-01-01T00:00:00Z", + "created_at": "1970-01-01T00:00:00Z", + "remote_addr": "0.0.0.0", + "position": {"latitude": 0, "longitude": 0}, + "tags": [], + "public_url": False, + "public_url_address": "", + "acceptable": False, + } + requests_mock.get("http://localhost.shellhub/api/devices/1", json=mock_response) + device = shellhub.get_device("1") + + """ + self.uid = device_json["uid"] + self.name = device_json["name"] + self.mac_address = device_json["identity"]["mac"] + 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.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.remote_addr = device_json["remote_addr"] + self.tags = device_json["tags"] + self.acceptable = device_json["acceptable"] + """ + + assert device.uid == "1" + assert device.name == "default" + assert device.mac_address == "06:04:ju:le:s7:08" + assert device.info.id == "ubuntu" + assert device.info.pretty_name == "Ubuntu 20.04.2 LTS" + assert device.info.version == "v0.14.1" + assert device.info.arch == "amd64" + assert device.info.platform == "docker" + assert ( + device.public_key + == "-----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.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.remote_addr == "0.0.0.0" + assert device.tags == [] + assert not device.acceptable + + +class TestDeleteDevice: + def test_delete_device(self, shellhub_device, requests_mock): + requests_mock.delete("http://localhost.shellhub/api/devices/1", status_code=200) + assert shellhub_device.delete() + + def test_delete_device_already_deleted(self, shellhub_device, requests_mock): + requests_mock.delete("http://localhost.shellhub/api/devices/1", status_code=404) + with pytest.raises(ShellHubApiError): + shellhub_device.delete() + + +class TestRenameDevice: + def test_rename_device_new_name(self, shellhub_device, requests_mock): + requests_mock.put("http://localhost.shellhub/api/devices/1", status_code=200) + shellhub_device.rename("new_name") + + assert shellhub_device.name == "new_name" + + def test_rename_non_existent_device(self, shellhub_device, requests_mock): + requests_mock.put("http://localhost.shellhub/api/devices/1", status_code=404) + with pytest.raises(ShellHubApiError): + shellhub_device.rename("new_name") + + def test_rename_conflict(self, shellhub_device, requests_mock): + requests_mock.put("http://localhost.shellhub/api/devices/1", status_code=409) + with pytest.raises(ShellHubApiError): + shellhub_device.rename("new_name") + + def test_rename_original_name(self, shellhub_device, requests_mock): + requests_mock.put("http://localhost.shellhub/api/devices/1", status_code=200) + shellhub_device.rename("default") + + assert shellhub_device.name == "default" + + requests_mock.put("http://localhost.shellhub/api/devices/1", status_code=200) + shellhub_device.rename() + + assert shellhub_device.name == "06-04-ju-le-s7-08" + + +class TestRefreshDevice: + def test_refresh_unknown_device(self, shellhub_device, requests_mock): + requests_mock.get("http://localhost.shellhub/api/devices/2", status_code=404) + shellhub_device.uid = "2" + with pytest.raises(ShellHubApiError): + shellhub_device.refresh() + + def test_refresh_device(self, shellhub_device, requests_mock): + mock_response = { + "uid": "2", + "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": "1970-01-01T00:00:00Z", + "online": True, + "namespace": "dev", + "status": "accepted", + "status_updated_at": "1970-01-01T00:00:00Z", + "created_at": "1970-01-01T00:00:00Z", + "remote_addr": "0.0.0.0", + "position": {"latitude": 0, "longitude": 0}, + "tags": [], + "public_url": False, + "public_url_address": "", + "acceptable": False, + } + requests_mock.get("http://localhost.shellhub/api/devices/1", json=mock_response) + + assert shellhub_device.uid == "1" + + shellhub_device.refresh() + + assert shellhub_device.uid == "2" + + +class TestAcceptDevice: + def test_not_acceptable_device(self, shellhub_device): + shellhub_device.acceptable = False + with pytest.raises(ShellHubApiError): + shellhub_device.accept() + + def test_accept_notfound_device(self, shellhub_device, requests_mock): + requests_mock.post("http://localhost.shellhub/api/devices/1/accept", status_code=404) + shellhub_device.acceptable = True + with pytest.raises(ShellHubApiError): + shellhub_device.accept() + + def test_accept_device(self, shellhub_device, requests_mock): + mock_response = { + "uid": "2", + "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": "1970-01-01T00:00:00Z", + "online": True, + "namespace": "dev", + "status": "accepted", + "status_updated_at": "1970-01-01T00:00:00Z", + "created_at": "1970-01-01T00:00:00Z", + "remote_addr": "0.0.0.0", + "position": {"latitude": 0, "longitude": 0}, + "tags": [], + "public_url": False, + "public_url_address": "", + "acceptable": False, + } + requests_mock.get("http://localhost.shellhub/api/devices/1", json=mock_response) + requests_mock.post("http://localhost.shellhub/api/devices/1/accept", status_code=200) + + shellhub_device.acceptable = True + shellhub_device.accept() + + assert not shellhub_device.acceptable From c5a5c080260f4e3a47a02366b28c4e60a5daca76 Mon Sep 17 00:00:00 2001 From: Jules Lasne Date: Wed, 31 Jan 2024 13:05:16 +0100 Subject: [PATCH 2/8] CI/CD: Added config for pep8speaks --- .pep8speaks.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .pep8speaks.yml diff --git a/.pep8speaks.yml b/.pep8speaks.yml new file mode 100644 index 0000000..0415384 --- /dev/null +++ b/.pep8speaks.yml @@ -0,0 +1,4 @@ +pycodestyle: # Same as scanner.linter value. Other option is flake8 + max-line-length: 120 # Default is 79 in PEP 8 + +no_blank_comment: True # If True, no comment is made on PR without any errors. \ No newline at end of file From 0c59ac1f60ce4a2540d9876fcc90e73607f62ea9 Mon Sep 17 00:00:00 2001 From: Jules Lasne Date: Wed, 31 Jan 2024 13:19:11 +0100 Subject: [PATCH 3/8] Chore: Updated code for typing with mypy strict --- .pre-commit-config.yaml | 11 +++++++++++ requirements-dev.txt | 2 ++ shellhub/models/base.py | 26 +++++++++++++++++--------- shellhub/models/device.py | 28 ++++++++++++++++------------ 4 files changed, 46 insertions(+), 21 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7d05ec1..191f044 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,3 +21,14 @@ repos: hooks: - id: reorder-python-imports args: [--py3-plus] + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.8.0 + hooks: + - id: mypy + additional_dependencies: ['types-requests'] + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-merge-conflict + - id: debug-statements + - id: check-case-conflict diff --git a/requirements-dev.txt b/requirements-dev.txt index fa45922..724b4f5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,8 @@ black reorder-python-imports flake8 +mypy +types-requests pre-commit -r requirements.txt diff --git a/shellhub/models/base.py b/shellhub/models/base.py index af0c40e..6183043 100644 --- a/shellhub/models/base.py +++ b/shellhub/models/base.py @@ -1,3 +1,5 @@ +from typing import Any +from typing import Dict from typing import List from typing import Optional @@ -13,7 +15,7 @@ class ShellHub: _endpoint: str _access_token: Optional[str] - def __init__(self, username: str, password: str, endpoint: str): + def __init__(self, username: str, password: str, endpoint: str) -> None: self._username: str = username self._password: str = password self._endpoint: str = endpoint @@ -21,13 +23,13 @@ def __init__(self, username: str, password: str, endpoint: str): self._login() - def __repr__(self): + def __repr__(self) -> str: return f"" - def __str__(self): + def __str__(self) -> str: return self._endpoint - def _login(self): + def _login(self) -> None: try: response = requests.post( f"{self._endpoint}/api/login", @@ -46,8 +48,12 @@ def _login(self): self._access_token = response.json()["token"] def make_request( - self, endpoint: str, method: str, query_params: Optional[dict] = None, json: Optional[dict] = None - ): + self, + endpoint: str, + method: str, + query_params: Optional[Dict[Any, Any]] = None, + json: Optional[Dict[Any, Any]] = None, + ) -> requests.Response: params = "" if query_params: params = "?" @@ -55,7 +61,7 @@ def make_request( params += f"{key}={value}&" params = params[:-1] - response = getattr(requests, method.lower())( + response: requests.Response = getattr(requests, method.lower())( f"{self._endpoint}{endpoint}{params if params else ''}", headers={ "Authorization": f"Bearer {self._access_token}", @@ -64,7 +70,9 @@ def make_request( ) return response - def _get_devices(self, query_params: Optional[dict] = None) -> "List[shellhub.models.device.ShellHubDevice]": + def _get_devices( + self, query_params: Optional[Dict[Any, Any]] = None + ) -> "List[shellhub.models.device.ShellHubDevice]": response = self.make_request(endpoint="/api/devices", method="GET", query_params=query_params) response.raise_for_status() @@ -75,7 +83,7 @@ def _get_devices(self, query_params: Optional[dict] = None) -> "List[shellhub.mo return devices def get_all_devices( - self, status: Optional[str] = None, query_params: Optional[dict] = None + self, status: Optional[str] = None, query_params: Optional[Dict[Any, Any]] = None ) -> "List[shellhub.models.device.ShellHubDevice]": """ Get all devices from ShellHub. Default gets all devices diff --git a/shellhub/models/device.py b/shellhub/models/device.py index 3410d83..21d39a7 100644 --- a/shellhub/models/device.py +++ b/shellhub/models/device.py @@ -1,3 +1,4 @@ +from typing import Dict from typing import List from typing import Optional @@ -12,21 +13,21 @@ class ShellHubDeviceInfo: arch: str platform: str - def __init__(self, device_info_json: dict): + def __init__(self, device_info_json: Dict[str, str]): self.id = device_info_json["id"] self.pretty_name = device_info_json["pretty_name"] self.version = device_info_json["version"] self.arch = device_info_json["arch"] self.platform = device_info_json["platform"] - def __repr__(self): + def __repr__(self) -> str: return ( f"ShellHubDeviceInfo(id={self.id}, pretty_name={self.pretty_name}, " f"version={self.version}, arch={self.arch}, platform={self.platform})" ) - def __str__(self): - return self.__repr__() + def __str__(self) -> str: + return self.pretty_name class ShellHubDevice: @@ -46,7 +47,7 @@ class ShellHubDevice: tags: List[str] acceptable: bool - def __init__(self, api_object: shellhub.models.base.ShellHub, device_json: dict): + def __init__(self, api_object: shellhub.models.base.ShellHub, device_json): # type: ignore self._api = api_object self.uid = device_json["uid"] @@ -68,7 +69,7 @@ def __init__(self, api_object: shellhub.models.base.ShellHub, device_json: dict) self.tags = device_json["tags"] self.acceptable = device_json["acceptable"] - def delete(self): + def delete(self) -> bool: response = self._api.make_request(endpoint=f"/api/devices/{self.uid}", method="DELETE") if response.status_code == 200: return True @@ -76,8 +77,9 @@ def delete(self): raise ShellHubApiError(f"Device {self.uid} not found.") else: response.raise_for_status() + return False - def rename(self, name: Optional[str] = None): + def rename(self, name: Optional[str] = None) -> bool: """ Set a new name for the device. If no name is provided, the name will be the mac address of the device """ @@ -93,8 +95,9 @@ def rename(self, name: Optional[str] = None): raise ShellHubApiError(f"Device with name {name} already exists.") else: response.raise_for_status() + return False - def accept(self): + def accept(self) -> bool: if not self.acceptable: raise ShellHubApiError(f"Device {self.uid} is not acceptable.") @@ -106,19 +109,20 @@ def accept(self): raise ShellHubApiError(f"Device {self.uid} not found.") else: response.raise_for_status() + return False - def refresh(self): + def refresh(self) -> None: response = self._api.make_request(endpoint=f"/api/devices/{self.uid}", method="GET") if response.status_code == 404: raise ShellHubApiError(f"Device {self.uid} not found.") elif response.status_code != 200: response.raise_for_status() - self.__init__(self._api, response.json()) + self.__init__(self._api, response.json()) # type: ignore - def __repr__(self): + def __repr__(self) -> str: return ( f"ShellHubDevice(name={self.name}, online={self.online}, namespace={self.namespace}, status={self.status})" ) - def __str__(self): + def __str__(self) -> str: return self.uid From 44efbbdab09dbe977ed3f4a11dc288705ab186d7 Mon Sep 17 00:00:00 2001 From: Jules Lasne Date: Wed, 31 Jan 2024 13:21:31 +0100 Subject: [PATCH 4/8] CI/CD: Added workflow to create GH release on merge --- .github/workflows/deploy.yml | 36 ++++++++++++++++++++++++++++++++++++ README.md | 3 ++- 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..8d30125 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,36 @@ +name: Deploy +on: + push: + branches: + - main + +jobs: + set_version: + name: Set the version of the release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set version + id: set_version + run: | + echo version=$( sed -e 's/__version__ = "\(.*\)"/\1/g' <<< $(grep -E '__version__ = ' shellhub/__init__.py)) >> "$GITHUB_OUTPUT" + outputs: + version: ${{ steps.set_version.outputs.version }} + + gh-release: + name: Create a release in GitHub + needs: + - set_version + runs-on: ubuntu-latest + if: test -z "${{ needs.set_version.outputs.version }}" + steps: + - name: Create Release + id: create_release + uses: softprops/action-gh-release@v1 + with: + draft: false + prerelease: false + tag_name: v${{ needs.set_version.outputs.version }} + generate_release_notes: true diff --git a/README.md b/README.md index e9bcb08..e5a530c 100644 --- a/README.md +++ b/README.md @@ -97,4 +97,5 @@ jules.lasne@gmail.com - [ ] Migrate tests to pytest-recording - [ ] Add a readthedocs documentation -- [ ] Switch to an OpenAPI generated client ? see https://github.com/shellhub-io/shellhub/issues/3497#issuecomment-1917478654 \ No newline at end of file +- [ ] Switch to an OpenAPI generated client ? see https://github.com/shellhub-io/shellhub/issues/3497#issuecomment-1917478654 +- [ ] Add deployment to pypi on merge to main \ No newline at end of file From ae055f11f9055e687d29bbf2eba34c532523163e Mon Sep 17 00:00:00 2001 From: Jules Lasne Date: Thu, 1 Feb 2024 12:39:07 +0100 Subject: [PATCH 5/8] Core: Added token refreshing --- README.md | 5 ++++- shellhub/models/base.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e5a530c..81a7daf 100644 --- a/README.md +++ b/README.md @@ -98,4 +98,7 @@ jules.lasne@gmail.com - [ ] Migrate tests to pytest-recording - [ ] Add a readthedocs documentation - [ ] Switch to an OpenAPI generated client ? see https://github.com/shellhub-io/shellhub/issues/3497#issuecomment-1917478654 -- [ ] Add deployment to pypi on merge to main \ No newline at end of file +- [ ] Add deployment to pypi on merge to main +- [ ] Add a changelog +- [ ] Setup coverage reporting +- [ ] Update tests to tests on multiple python versions \ No newline at end of file diff --git a/shellhub/models/base.py b/shellhub/models/base.py index 6183043..81ca76c 100644 --- a/shellhub/models/base.py +++ b/shellhub/models/base.py @@ -68,6 +68,19 @@ def make_request( }, json=json, ) + + if response.status_code == 401: + self._login() + response = getattr(requests, method.lower())( + f"{self._endpoint}{endpoint}{params if params else ''}", + headers={ + "Authorization": f"Bearer {self._access_token}", + }, + json=json, + ) + if response.status_code == 401: + raise ShellHubApiError("Couldn't fix request with a token refresh") + return response def _get_devices( From 5ba3906f5591a052d59b63fa19c9ed332d710337 Mon Sep 17 00:00:00 2001 From: Jules Lasne Date: Thu, 1 Feb 2024 12:52:52 +0100 Subject: [PATCH 6/8] CI/CD: Added tox for multi-version checks --- .github/workflows/build_pkg.yml | 32 +++++++++++++++++++ .github/workflows/tests.yml | 55 +++++++++++++++++++++++++++++++++ README.md | 2 ++ codecov.yml | 8 +++++ pyproject.toml | 16 +++++++--- requirements-dev.txt | 7 ++++- tox.ini | 18 +++++++++++ 7 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/build_pkg.yml create mode 100644 .github/workflows/tests.yml create mode 100644 codecov.yml create mode 100644 tox.ini diff --git a/.github/workflows/build_pkg.yml b/.github/workflows/build_pkg.yml new file mode 100644 index 0000000..ae8b397 --- /dev/null +++ b/.github/workflows/build_pkg.yml @@ -0,0 +1,32 @@ +name: Build package + +on: + workflow_call: + inputs: + artifact-name: + description: "Name of an artifact" + type: "string" + required: false + default: "package" + +jobs: + build-pkg: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + + - name: Install dependencies + run: pip install -U build twine + - name: Build 📦 package + run: python -m build + - name: Check 📦 package + run: twine check dist/* + + - uses: actions/upload-artifact@v3 + with: + name: ${{ inputs.artifact-name }} + path: dist \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..c054e20 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,55 @@ +name: Tests +on: + push: + branches: + - main + pull_request: + merge_group: + +jobs: + + build: + uses: "./.github/workflows/build_pkg.yml" + with: + artifact-name: package + + test: + runs-on: ${{ matrix.os }} + name: test (Python ${{ matrix.python-version }} on ${{ matrix.os-label }}) + strategy: + fail-fast: false + matrix: + # keep it sync with tox.ini [gh-actions] section + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12-dev"] + os: ["ubuntu-latest"] + os-label: ["Ubuntu"] + include: + - {python-version: "3.8", os: "windows-latest", os-label: "Windows"} + - {python-version: "3.8", os: "macos-latest", os-label: "macOS"} + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "${{ matrix.python-version }}" + - name: Install tox + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + - name: Run tests + run: tox + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + test_success: + # this aggregates success state of all jobs listed in `needs` + # this is the only required check to pass CI + name: "Test success" + runs-on: ubuntu-latest + needs: [test] + steps: + - name: "Noop" + run: true + shell: bash diff --git a/README.md b/README.md index 81a7daf..9287273 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # [Shellhub Python SDK] +[![codecov](https://codecov.io/gh/Seluj78/shellhub-python/graph/badge.svg?token=FPWuNDtwdz)](https://codecov.io/gh/Seluj78/shellhub-python) + * [What is it](#what-is-it) * [Installation](#installation) * [Locally](#locally) diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..ea19c23 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,8 @@ +comment: + after_n_builds: 4 + +codecov: + disable_default_path_fixes: true # Automatic detection does not discover all files + +fixes: + - "::shellhub/" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index dbc6411..e02cddc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,19 +31,25 @@ authors = [ ] readme = "README.md" dynamic = ["version"] -requires-python = ">=3.11" +requires-python = ">=3.7" classifiers=[ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Natural Language:: English", "Topic :: Software Development :: Libraries :: Python Modules", "License :: Other/Proprietary License", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.9", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development", ] -dependencies = [""] +dependencies = ["requests>=2.31.0"] [tool.setuptools] packages = ["shellhub"] diff --git a/requirements-dev.txt b/requirements-dev.txt index 724b4f5..2d9c224 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,4 +8,9 @@ pre-commit pytest requests-mock -pytest-cov \ No newline at end of file +pytest-cov +tox +build +setuptools +wheel +twine \ No newline at end of file diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..d8dc605 --- /dev/null +++ b/tox.ini @@ -0,0 +1,18 @@ +[tox] +envlist = + py{37,38,39,310,311,312}, + +[gh-actions] +# this make sure each ci job only run tests once. +# keey it sync with workflows/tests.yaml matrix +python = + 3.7: py37 + 3.8: py38 + 3.9: py39 + 3.10: py310 + 3.11: py311 + 3.12: py312 + +[testenv] +deps = -rrequirements-dev.txt +commands = pytest --cov=shellhub --cov-report=xml {posargs} From 158e91001dfc2bec23e42345252ae7bbb77d0b6e Mon Sep 17 00:00:00 2001 From: Jules Lasne Date: Tue, 6 Feb 2024 16:18:44 +0100 Subject: [PATCH 7/8] Tests: Removed useless comment --- tests/test_devices.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/tests/test_devices.py b/tests/test_devices.py index 675b29a..30d5719 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -104,24 +104,6 @@ def test_get_device(self, shellhub, requests_mock): requests_mock.get("http://localhost.shellhub/api/devices/1", json=mock_response) device = shellhub.get_device("1") - """ - self.uid = device_json["uid"] - self.name = device_json["name"] - self.mac_address = device_json["identity"]["mac"] - 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.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.remote_addr = device_json["remote_addr"] - self.tags = device_json["tags"] - self.acceptable = device_json["acceptable"] - """ - assert device.uid == "1" assert device.name == "default" assert device.mac_address == "06:04:ju:le:s7:08" From ad99a1a6eec7536e5b6c4f333b2b9c4ffcf418fe Mon Sep 17 00:00:00 2001 From: Jules Lasne Date: Wed, 7 Feb 2024 11:10:40 +0100 Subject: [PATCH 8/8] Core: Updated exceptions --- shellhub/__init__.py | 16 +++++++++---- shellhub/exceptions.py | 14 +++++++++++- shellhub/models/base.py | 34 +++++++++++++++++++-------- shellhub/models/device.py | 48 ++++++++++++++++++++++++++------------- tests/conftest.py | 7 +++--- tests/test_base.py | 20 ++++++++-------- tests/test_devices.py | 33 ++++++++++++++------------- tests/utils.py | 1 + 8 files changed, 114 insertions(+), 59 deletions(-) create mode 100644 tests/utils.py diff --git a/shellhub/__init__.py b/shellhub/__init__.py index 620d0a5..237565d 100644 --- a/shellhub/__init__.py +++ b/shellhub/__init__.py @@ -1,14 +1,22 @@ # Increment versions here according to SemVer __version__ = "0.0.1" -# Import here what you want to be able to import from this package and place it in __all__ -from shellhub.models.device import ShellHubDevice, ShellHubDeviceInfo -from shellhub.models.base import ShellHub -from shellhub.exceptions import ShellHubApiError +from .models.device import ShellHubDevice, ShellHubDeviceInfo +from .models.base import ShellHub +from .exceptions import ( + ShellHubApiError, + ShellHubAuthenticationError, + DeviceNotFoundError, + ShellHubBaseException, +) + __all__ = [ "ShellHub", "ShellHubDevice", "ShellHubDeviceInfo", "ShellHubApiError", + "ShellHubAuthenticationError", + "DeviceNotFoundError", + "ShellHubBaseException", ] diff --git a/shellhub/exceptions.py b/shellhub/exceptions.py index e450873..b27e23f 100644 --- a/shellhub/exceptions.py +++ b/shellhub/exceptions.py @@ -1,2 +1,14 @@ -class ShellHubApiError(Exception): +class ShellHubBaseException(Exception): + pass + + +class ShellHubApiError(ShellHubBaseException): + pass + + +class ShellHubAuthenticationError(ShellHubBaseException): + pass + + +class DeviceNotFoundError(ShellHubApiError): pass diff --git a/shellhub/models/base.py b/shellhub/models/base.py index 81ca76c..722644b 100644 --- a/shellhub/models/base.py +++ b/shellhub/models/base.py @@ -6,7 +6,10 @@ import requests import shellhub.models.device +from shellhub.exceptions import DeviceNotFoundError from shellhub.exceptions import ShellHubApiError +from shellhub.exceptions import ShellHubAuthenticationError +from shellhub.exceptions import ShellHubBaseException class ShellHub: @@ -39,13 +42,17 @@ def _login(self) -> None: }, ) except requests.exceptions.ConnectionError: - raise ValueError("Incorrect endpoint. Is the server up and running ?") + raise ShellHubBaseException("Incorrect endpoint. Is the server up and running ?") if response.status_code == 401: - raise ShellHubApiError("Incorrect username or password") + raise ShellHubAuthenticationError("Incorrect username or password") elif response.status_code != 200: - response.raise_for_status() - self._access_token = response.json()["token"] + try: + response.raise_for_status() + except requests.exceptions.HTTPError as e: + raise ShellHubApiError(e) + else: + self._access_token = response.json()["token"] def make_request( self, @@ -79,7 +86,7 @@ def make_request( json=json, ) if response.status_code == 401: - raise ShellHubApiError("Couldn't fix request with a token refresh") + raise ShellHubApiError(f"Couldn't fix request with a token refresh: {response.text}") return response @@ -88,7 +95,10 @@ def _get_devices( ) -> "List[shellhub.models.device.ShellHubDevice]": response = self.make_request(endpoint="/api/devices", method="GET", query_params=query_params) - response.raise_for_status() + try: + response.raise_for_status() + except requests.exceptions.HTTPError as e: + raise ShellHubApiError(e) devices = [] for device in response.json(): @@ -120,7 +130,11 @@ def get_all_devices( def get_device(self, uid: str) -> "shellhub.models.device.ShellHubDevice": response = self.make_request(endpoint=f"/api/devices/{uid}", method="GET") if response.status_code == 404: - raise ShellHubApiError(f"Device {uid} not found.") - elif response.status_code != 200: - response.raise_for_status() - return shellhub.models.device.ShellHubDevice(self, response.json()) + raise DeviceNotFoundError(f"Device {uid} not found.") + else: + try: + response.raise_for_status() + except requests.exceptions.HTTPError as e: + raise ShellHubApiError(e) + else: + return shellhub.models.device.ShellHubDevice(self, response.json()) diff --git a/shellhub/models/device.py b/shellhub/models/device.py index 21d39a7..f7c49e1 100644 --- a/shellhub/models/device.py +++ b/shellhub/models/device.py @@ -2,7 +2,10 @@ from typing import List from typing import Optional +import requests + import shellhub.models.base +from shellhub.exceptions import DeviceNotFoundError from shellhub.exceptions import ShellHubApiError @@ -56,14 +59,11 @@ 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"] - # TODO: Convert to datetime object self.last_seen = device_json["last_seen"] self.online = device_json["online"] self.namespace = device_json["namespace"] self.status = device_json["status"] - # TODO: Convert to datetime object self.status_updated_at = device_json["status_updated_at"] - # TODO: Convert to datetime object self.created_at = device_json["created_at"] self.remote_addr = device_json["remote_addr"] self.tags = device_json["tags"] @@ -74,10 +74,14 @@ def delete(self) -> bool: if response.status_code == 200: return True elif response.status_code == 404: - raise ShellHubApiError(f"Device {self.uid} not found.") + raise DeviceNotFoundError(f"Device {self.uid} not found.") else: - response.raise_for_status() - return False + try: + response.raise_for_status() + except requests.exceptions.HTTPError as e: + raise ShellHubApiError(e) + else: + return False def rename(self, name: Optional[str] = None) -> bool: """ @@ -90,12 +94,16 @@ def rename(self, name: Optional[str] = None) -> bool: self.name = name return True elif response.status_code == 404: - raise ShellHubApiError(f"Device {self.uid} not found.") + raise DeviceNotFoundError(f"Device {self.uid} not found.") elif response.status_code == 409: raise ShellHubApiError(f"Device with name {name} already exists.") else: - response.raise_for_status() - return False + try: + response.raise_for_status() + except requests.exceptions.HTTPError as e: + raise ShellHubApiError(e) + else: + return False def accept(self) -> bool: if not self.acceptable: @@ -106,18 +114,26 @@ def accept(self) -> bool: self.refresh() return True elif response.status_code == 404: - raise ShellHubApiError(f"Device {self.uid} not found.") + raise DeviceNotFoundError(f"Device {self.uid} not found.") else: - response.raise_for_status() - return False + try: + response.raise_for_status() + except requests.exceptions.HTTPError as e: + raise ShellHubApiError(e) + else: + return False def refresh(self) -> None: response = self._api.make_request(endpoint=f"/api/devices/{self.uid}", method="GET") if response.status_code == 404: - raise ShellHubApiError(f"Device {self.uid} not found.") - elif response.status_code != 200: - response.raise_for_status() - self.__init__(self._api, response.json()) # type: ignore + raise DeviceNotFoundError(f"Device {self.uid} not found.") + elif response.status_code == 200: + self.__init__(self._api, response.json()) # type: ignore + else: + try: + response.raise_for_status() + except requests.exceptions.HTTPError as e: + raise ShellHubApiError(e) def __repr__(self) -> str: return ( diff --git a/tests/conftest.py b/tests/conftest.py index b6091cf..9e91aca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,20 +2,21 @@ import requests_mock as r_mock from shellhub import ShellHub +from tests.utils import MOCKED_DOMAIN_URL @pytest.fixture(scope="function") def shellhub(): with r_mock.Mocker() as m: # Mock the login URL - login_url = "http://localhost.shellhub/api/login" + login_url = f"{MOCKED_DOMAIN_URL}/api/login" mock_response = { "token": "jwt_token", } m.post(login_url, json=mock_response) # Create an instance of ShellHub with mocked login - shellhub_instance = ShellHub(username="john.doe", password="dolphin", endpoint="http://localhost.shellhub") + shellhub_instance = ShellHub(username="john.doe", password="dolphin", endpoint=MOCKED_DOMAIN_URL) yield shellhub_instance @@ -51,5 +52,5 @@ def shellhub_device(shellhub, requests_mock): "acceptable": False, } ] - requests_mock.get("http://localhost.shellhub/api/devices", json=mock_response) + requests_mock.get(f"{MOCKED_DOMAIN_URL}/api/devices", json=mock_response) return shellhub.get_all_devices()[0] diff --git a/tests/test_base.py b/tests/test_base.py index 73ef93f..ab37405 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,36 +1,38 @@ import pytest from shellhub import ShellHub -from shellhub import ShellHubApiError +from shellhub import ShellHubAuthenticationError +from shellhub import ShellHubBaseException +from tests.utils import MOCKED_DOMAIN_URL def test_login(requests_mock): - login_url = "http://localhost.shellhub/api/login" + login_url = f"{MOCKED_DOMAIN_URL}/api/login" mock_response = { "token": "jwt_token", } requests_mock.post(login_url, json=mock_response) - shellhub = ShellHub(username="john.doe", password="dolphin", endpoint="http://localhost.shellhub") + shellhub = ShellHub(username="john.doe", password="dolphin", endpoint=MOCKED_DOMAIN_URL) assert shellhub._access_token == mock_response["token"] def test_incorrect_endpoint(): - with pytest.raises(ValueError): - ShellHub(username="john.doe", password="dolphin", endpoint="http://localhost.shellhub") + with pytest.raises(ShellHubBaseException): + ShellHub(username="john.doe", password="dolphin", endpoint=MOCKED_DOMAIN_URL) def test_incorrect_username_password(requests_mock): - login_url = "http://localhost.shellhub/api/login" + login_url = f"{MOCKED_DOMAIN_URL}/api/login" mock_response = { "detail": "Incorrect username or password", } requests_mock.post(login_url, json=mock_response, status_code=401) - with pytest.raises(ShellHubApiError): - ShellHub(username="john.doe", password="dolphin", endpoint="http://localhost.shellhub") + with pytest.raises(ShellHubAuthenticationError): + ShellHub(username="john.doe", password="dolphin", endpoint=MOCKED_DOMAIN_URL) def test_repr(shellhub): - assert repr(shellhub) == "" + assert repr(shellhub) == f"" def test_str(shellhub): diff --git a/tests/test_devices.py b/tests/test_devices.py index 30d5719..0b793c4 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -1,6 +1,7 @@ import pytest from shellhub.exceptions import ShellHubApiError +from tests.utils import MOCKED_DOMAIN_URL def test_repr(shellhub_device): @@ -14,7 +15,7 @@ def test_str(shellhub_device): class TestGetDevices: def test_get_no_devices(self, shellhub, requests_mock): mock_response = [] - requests_mock.get("http://localhost.shellhub/api/devices", json=mock_response) + requests_mock.get(f"{MOCKED_DOMAIN_URL}/api/devices", json=mock_response) devices = shellhub.get_all_devices() assert len(devices) == 0 @@ -62,14 +63,14 @@ def test_correct_status(self, shellhub, requests_mock, status): "acceptable": False, } ] - requests_mock.get("http://localhost.shellhub/api/devices", json=mock_response) + requests_mock.get(f"{MOCKED_DOMAIN_URL}/api/devices", json=mock_response) devices = shellhub.get_all_devices(status=status) assert len(devices) == 1 class TestGetDevice: def test_device_not_found(self, shellhub, requests_mock): - requests_mock.get("http://localhost.shellhub/api/devices/1", status_code=404) + requests_mock.get(f"{MOCKED_DOMAIN_URL}/api/devices/1", status_code=404) with pytest.raises(ShellHubApiError): shellhub.get_device("1") @@ -101,7 +102,7 @@ def test_get_device(self, shellhub, requests_mock): "public_url_address": "", "acceptable": False, } - requests_mock.get("http://localhost.shellhub/api/devices/1", json=mock_response) + requests_mock.get(f"{MOCKED_DOMAIN_URL}/api/devices/1", json=mock_response) device = shellhub.get_device("1") assert device.uid == "1" @@ -130,39 +131,39 @@ def test_get_device(self, shellhub, requests_mock): class TestDeleteDevice: def test_delete_device(self, shellhub_device, requests_mock): - requests_mock.delete("http://localhost.shellhub/api/devices/1", status_code=200) + requests_mock.delete(f"{MOCKED_DOMAIN_URL}/api/devices/1", status_code=200) assert shellhub_device.delete() def test_delete_device_already_deleted(self, shellhub_device, requests_mock): - requests_mock.delete("http://localhost.shellhub/api/devices/1", status_code=404) + requests_mock.delete(f"{MOCKED_DOMAIN_URL}/api/devices/1", status_code=404) with pytest.raises(ShellHubApiError): shellhub_device.delete() class TestRenameDevice: def test_rename_device_new_name(self, shellhub_device, requests_mock): - requests_mock.put("http://localhost.shellhub/api/devices/1", status_code=200) + requests_mock.put(f"{MOCKED_DOMAIN_URL}/api/devices/1", status_code=200) shellhub_device.rename("new_name") assert shellhub_device.name == "new_name" def test_rename_non_existent_device(self, shellhub_device, requests_mock): - requests_mock.put("http://localhost.shellhub/api/devices/1", status_code=404) + requests_mock.put(f"{MOCKED_DOMAIN_URL}/api/devices/1", status_code=404) with pytest.raises(ShellHubApiError): shellhub_device.rename("new_name") def test_rename_conflict(self, shellhub_device, requests_mock): - requests_mock.put("http://localhost.shellhub/api/devices/1", status_code=409) + requests_mock.put(f"{MOCKED_DOMAIN_URL}/api/devices/1", status_code=409) with pytest.raises(ShellHubApiError): shellhub_device.rename("new_name") def test_rename_original_name(self, shellhub_device, requests_mock): - requests_mock.put("http://localhost.shellhub/api/devices/1", status_code=200) + requests_mock.put(f"{MOCKED_DOMAIN_URL}/api/devices/1", status_code=200) shellhub_device.rename("default") assert shellhub_device.name == "default" - requests_mock.put("http://localhost.shellhub/api/devices/1", status_code=200) + requests_mock.put(f"{MOCKED_DOMAIN_URL}/api/devices/1", status_code=200) shellhub_device.rename() assert shellhub_device.name == "06-04-ju-le-s7-08" @@ -170,7 +171,7 @@ def test_rename_original_name(self, shellhub_device, requests_mock): class TestRefreshDevice: def test_refresh_unknown_device(self, shellhub_device, requests_mock): - requests_mock.get("http://localhost.shellhub/api/devices/2", status_code=404) + requests_mock.get(f"{MOCKED_DOMAIN_URL}/api/devices/2", status_code=404) shellhub_device.uid = "2" with pytest.raises(ShellHubApiError): shellhub_device.refresh() @@ -203,7 +204,7 @@ def test_refresh_device(self, shellhub_device, requests_mock): "public_url_address": "", "acceptable": False, } - requests_mock.get("http://localhost.shellhub/api/devices/1", json=mock_response) + requests_mock.get(f"{MOCKED_DOMAIN_URL}/api/devices/1", json=mock_response) assert shellhub_device.uid == "1" @@ -219,7 +220,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("http://localhost.shellhub/api/devices/1/accept", status_code=404) + requests_mock.post(f"{MOCKED_DOMAIN_URL}/api/devices/1/accept", status_code=404) shellhub_device.acceptable = True with pytest.raises(ShellHubApiError): shellhub_device.accept() @@ -252,8 +253,8 @@ def test_accept_device(self, shellhub_device, requests_mock): "public_url_address": "", "acceptable": False, } - requests_mock.get("http://localhost.shellhub/api/devices/1", json=mock_response) - requests_mock.post("http://localhost.shellhub/api/devices/1/accept", status_code=200) + 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) shellhub_device.acceptable = True shellhub_device.accept() diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..3c283ea --- /dev/null +++ b/tests/utils.py @@ -0,0 +1 @@ +MOCKED_DOMAIN_URL = "http://shellhub.localhost"