diff --git a/CHANGELOG.md b/CHANGELOG.md index 59c7129..0b63e3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Nothing yet. +## Fixed + +- HTTP 4XX errors now raise a `SporeStackUserError` instead of `SporeStackServerError`. ## [10.2.0 - 2023-04-14] diff --git a/Pipfile b/Pipfile index 77d1446..d2a8566 100644 --- a/Pipfile +++ b/Pipfile @@ -15,7 +15,7 @@ pytest-mock = "~=3.6" pytest-socket = "~=0.6.0" ruff = "==0.0.261" -types-requests = "~=2.25" +respx = "~=0.20.1" # Building flit = "~=3.8" diff --git a/Pipfile.lock b/Pipfile.lock index c102bda..4b87a31 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "ad9d790601f514743ccca149f2c41fce3db8493e9386372bb8416af64a06eebf" + "sha256": "9d77640a42b679eb979a9e2ed727e532aa1e4239ad482c5583dc2069c6731935" }, "pipfile-spec": 6, "requires": {}, @@ -168,6 +168,14 @@ "index": "pypi", "version": "==0.5.2" }, + "anyio": { + "hashes": [ + "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421", + "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3" + ], + "markers": "python_full_version >= '3.6.2'", + "version": "==3.6.2" + }, "black": { "hashes": [ "sha256:064101748afa12ad2291c2b91c960be28b817c0c7eaa35bec09cc63aa56493c5", @@ -396,6 +404,33 @@ "markers": "python_version >= '3.6'", "version": "==3.8.0" }, + "h11": { + "hashes": [ + "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", + "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761" + ], + "markers": "python_version >= '3.7'", + "version": "==0.14.0" + }, + "httpcore": { + "hashes": [ + "sha256:0fdfea45e94f0c9fd96eab9286077f9ff788dd186635ae61b312693e4d943599", + "sha256:cc045a3241afbf60ce056202301b4d8b6af08845e3294055eb26b09913ef903c" + ], + "markers": "python_version >= '3.7'", + "version": "==0.17.0" + }, + "httpx": { + "extras": [ + "socks" + ], + "hashes": [ + "sha256:447556b50c1921c351ea54b4fe79d91b724ed2b027462ab9a329465d147d5a4e", + "sha256:507d676fc3e26110d41df7d35ebd8b3b8585052450f4097401c9be59d928c63e" + ], + "markers": "python_version >= '3.7'", + "version": "==0.24.0" + }, "idna": { "hashes": [ "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", @@ -566,11 +601,11 @@ }, "packaging": { "hashes": [ - "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2", - "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97" + "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", + "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" ], "markers": "python_version >= '3.7'", - "version": "==23.0" + "version": "==23.1" }, "pathspec": { "hashes": [ @@ -630,11 +665,11 @@ }, "pytest": { "hashes": [ - "sha256:58ecc27ebf0ea643ebfdf7fb1249335da761a00c9f955bcd922349bcb68ee57d", - "sha256:933051fa1bfbd38a21e73c3960cebdad4cf59483ddba7696c48509727e17f201" + "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362", + "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3" ], "index": "pypi", - "version": "==7.3.0" + "version": "==7.3.1" }, "pytest-cov": { "hashes": [ @@ -684,8 +719,15 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.10.1" }, + "respx": { + "hashes": [ + "sha256:372f06991c03d1f7f480a420a2199d01f1815b6ed5a802f4e4628043a93bd03e", + "sha256:cc47a86d7010806ab65abdcf3b634c56337a737bb5c4d74c19a0dfca83b3bc73" + ], + "index": "pypi", + "version": "==0.20.1" + }, "rfc3986": { - "extras": [], "hashes": [ "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" @@ -695,11 +737,11 @@ }, "rich": { "hashes": [ - "sha256:540c7d6d26a1178e8e8b37e9ba44573a3cd1464ff6348b99ee7061b95d1c6333", - "sha256:dc84400a9d842b3a9c5ff74addd8eb798d155f36c1c91303888e0a66850d2a15" + "sha256:22b74cae0278fd5086ff44144d3813be1cedc9115bdfabbfefd86400cb88b20a", + "sha256:b5d573e13605423ec80bdd0cd5f8541f7844a0e71a13f74cf454ccb2f490708b" ], "markers": "python_full_version >= '3.7.0'", - "version": "==13.3.3" + "version": "==13.3.4" }, "ruff": { "hashes": [ @@ -732,6 +774,14 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.16.0" }, + "sniffio": { + "hashes": [ + "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101", + "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384" + ], + "markers": "python_version >= '3.7'", + "version": "==1.3.0" + }, "tomli-w": { "hashes": [ "sha256:9f2a07e8be30a0729e533ec968016807069991ae2fd921a78d42f429ae5f4463", @@ -748,21 +798,6 @@ "index": "pypi", "version": "==4.0.2" }, - "types-requests": { - "hashes": [ - "sha256:0d580652ce903f643f8c3b494dd01d29367ea57cea0c7ad7f65cf3169092edb0", - "sha256:cc1aba862575019306b2ed134eb1ea994cab1c887a22e18d3383e6dd42e9789b" - ], - "index": "pypi", - "version": "==2.28.11.17" - }, - "types-urllib3": { - "hashes": [ - "sha256:12c744609d588340a07e45d333bf870069fc8793bcf96bae7a96d4712a42591d", - "sha256:c44881cde9fc8256d05ad6b21f50c4681eb20092552351570ab0a8a0653286d6" - ], - "version": "==1.26.25.10" - }, "typing-extensions": { "hashes": [ "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb", diff --git a/README.md b/README.md index 57b4cc4..dd7d3d2 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ * `pip install sporestack` * Recommended: Create a virtual environment, first, and use it inside there. +* Something else to consider: Installing [rich](https://github.com/Textualize/rich) (`pip install rich`) in the same virtual environment will make `--help`-style output prettier. ## Running without installing diff --git a/src/sporestack/api_client.py b/src/sporestack/api_client.py index d408468..1ba1c96 100644 --- a/src/sporestack/api_client.py +++ b/src/sporestack/api_client.py @@ -84,12 +84,12 @@ def _handle_response(response: httpx.Response) -> None: if response.status_code == 429: raise exceptions.SporeStackTooManyRequestsError(error_response_text) elif status_code_first_digit == 4: - raise exceptions.SporeStackServerError(error_response_text) + raise exceptions.SporeStackUserError(error_response_text) elif status_code_first_digit == 5: # User should probably retry. raise exceptions.SporeStackServerError(error_response_text) else: - # How did we get here? + # This would be weird. raise exceptions.SporeStackServerError(error_response_text) diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 5c45dd2..694fc84 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -1,4 +1,7 @@ -from sporestack import api_client +import httpx +import pytest +import respx +from sporestack import api_client, exceptions def test__is_onion_url() -> None: @@ -13,3 +16,87 @@ def test__is_onion_url() -> None: assert api_client._is_onion_url("http://onion.domain.com/.onion/") is False assert api_client._is_onion_url("http://me.me/file.onion/") is False assert api_client._is_onion_url("http://me.me/file.onion") is False + + +def test_get_response_error_text() -> None: + assert ( + api_client._get_response_error_text( + httpx.Response(status_code=422, text="just text") + ) + == "just text" + ) + + assert ( + api_client._get_response_error_text( + httpx.Response(status_code=422, json={"detail": "detail text"}) + ) + == "detail text" + ) + + # This may not be the best behavior overall. + assert ( + api_client._get_response_error_text( + httpx.Response(status_code=422, json={"detail": {"msg": "nested message"}}) + ) + == "{'msg': 'nested message'}" + ) + + +def test_handle_response() -> None: + with pytest.raises(exceptions.SporeStackServerError, match="What is this?"): + api_client._handle_response( + httpx.Response(status_code=100, text="What is this?") + ) + + api_client._handle_response(httpx.Response(status_code=200)) + api_client._handle_response(httpx.Response(status_code=201)) + api_client._handle_response(httpx.Response(status_code=204)) + + with pytest.raises(exceptions.SporeStackUserError, match="Invalid arguments"): + api_client._handle_response( + httpx.Response(status_code=400, text="Invalid arguments") + ) + + with pytest.raises(exceptions.SporeStackUserError, match="Invalid arguments"): + api_client._handle_response( + httpx.Response(status_code=422, text="Invalid arguments") + ) + + with pytest.raises( + exceptions.SporeStackTooManyRequestsError, match="Too many requests" + ): + api_client._handle_response( + httpx.Response(status_code=429, text="Too many requests") + ) + + with pytest.raises(exceptions.SporeStackServerError, match="Try again"): + api_client._handle_response(httpx.Response(status_code=500, text="Try again")) + + +def test_token_info(respx_mock: respx.MockRouter) -> None: + dummy_token = "dummyinvalidtoken" + response_json = { + "balance_cents": 0, + "balance_usd": "$0.00", + "servers": 0, + "burn_rate": 0, + "burn_rate_usd": "$0.00", + "burn_rate_cents": 0, + "days_remaining": 0, + } + route_response = httpx.Response(200, json=response_json) + route = respx_mock.get(f"https://api.sporestack.com/token/{dummy_token}/info").mock( + return_value=route_response + ) + + client = api_client.APIClient() + info_response = client.token_info(dummy_token) + assert info_response.balance_cents == 0 + assert info_response.balance_usd == "$0.00" + assert info_response.burn_rate == 0 + assert info_response.burn_rate_cents == 0 + assert info_response.burn_rate_usd == "$0.00" + assert info_response.servers == 0 + assert info_response.days_remaining == 0 + + assert route.called