Skip to content

Commit ec99db3

Browse files
authored
Merge pull request #2 from kbase/dev-client
Add base client call_method function and supporting code.
2 parents ada71be + dc8ebc4 commit ec99db3

File tree

8 files changed

+390
-2
lines changed

8 files changed

+390
-2
lines changed

.github/workflows/test.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,12 @@ jobs:
4343
4444
- name: Run tests
4545
shell: bash
46-
run: PYTHONPATH=src pytest --cov=src --cov-report=xml test
46+
env:
47+
KBASE_TEST_TOKEN: ${{ secrets.KBASE_CI_TOKEN }}
48+
run: |
49+
cp test.cfg.example test.cfg
50+
sed -ie "s/^test_token=.*$/&$KBASE_TEST_TOKEN/g" test.cfg
51+
PYTHONPATH=src pytest --cov=src --cov-report=xml test
4752
4853
- name: Upload coverage to Codecov
4954
uses: codecov/codecov-action@v5

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@
2626
/.pytest_cache/
2727
__pycache__
2828
/.venv/
29+
/test.cfg

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ code and tests to determine proper useage.
3535

3636
### Testing
3737

38+
Copy `test.cfg.example` to `test.cfg` and fill it in appropriately.
39+
3840
```
3941
uv sync --dev # only required on first run or when the uv.lock file changes
4042
PYTHONPATH=src uv run pytest test

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ dev = [
2727
"ipython==9.6.0",
2828
"pytest==8.4.2",
2929
"pytest-cov==7.0.0",
30+
"semver>=3.0.4",
3031
]
3132

3233
[project.urls]

src/kbase/sdk_baseclient.py

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,165 @@
22
The base client for all SDK clients.
33
"""
44

5+
import json as _json
6+
import random as _random
7+
import requests as _requests
8+
import os as _os
9+
from urllib.parse import urlparse as _urlparse
10+
from typing import Any
11+
12+
13+
# The first version is a pretty basic port from the old baseclient, removing some no longer
14+
# relevant cruft.
15+
516

617
__version__ = "0.1.0"
718

819

20+
_CT = "content-type"
21+
_AJ = "application/json"
22+
_URL_SCHEME = frozenset(["http", "https"])
23+
_CHECK_JOB_RETRIES = 3
24+
25+
26+
class ServerError(Exception):
27+
28+
def __init__(self, name, code, message, data=None, error=None):
29+
super(Exception, self).__init__(message)
30+
self.name = name
31+
self.code = code
32+
# Ew. Leave it for backwards compatibility
33+
self.message = "" if message is None else message
34+
# Not really worth setting up a mock for the error case
35+
# data = JSON RPC 2.0, error = 1.1
36+
self.data = data or error or ""
37+
38+
def __str__(self):
39+
return self.name + ": " + str(self.code) + ". " + self.message + \
40+
"\n" + self.data
41+
42+
43+
class _JSONObjectEncoder(_json.JSONEncoder):
44+
45+
def default(self, obj):
46+
if isinstance(obj, set):
47+
return list(obj)
48+
if isinstance(obj, frozenset):
49+
return list(obj)
50+
return _json.JSONEncoder.default(self, obj)
51+
52+
953
class SDKBaseClient:
10-
""" The base client. """
54+
"""
55+
The KBase base client.
56+
57+
url - the url of the the service to contact:
58+
For SDK methods: the url of the callback service.
59+
For SDK dynamic services: the url of the Service Wizard.
60+
For other services: the url of the service.
61+
timeout - methods will fail if they take longer than this value in seconds.
62+
Default 1800.
63+
token - a KBase authentication token.
64+
trust_all_ssl_certificates - set to True to trust self-signed certificates.
65+
If you don't understand the implications, leave as the default, False.
66+
lookup_url - set to true when contacting KBase dynamic services.
67+
async_job_check_time_ms - the wait time between checking job state for
68+
asynchronous jobs run with the run_job method.
69+
async_job_check_time_scale_percent - the percentage increase in wait time between async job
70+
check attempts.
71+
async_job_check_max_time_ms - the maximum time to wait for a job check attempt before
72+
failing.
73+
"""
74+
def __init__(
75+
self,
76+
url: str,
77+
*,
78+
timeout: int = 30 * 60,
79+
token: str = None,
80+
trust_all_ssl_certificates: bool = False, # Too much of a pain to test
81+
lookup_url: bool = False,
82+
async_job_check_time_ms: int = 100,
83+
async_job_check_time_scale_percent: int = 150,
84+
async_job_check_max_time_ms: int = 300000
85+
):
86+
if url is None:
87+
raise ValueError("A url is required")
88+
scheme, _, _, _, _, _ = _urlparse(url)
89+
if scheme not in _URL_SCHEME:
90+
raise ValueError(url + " isn't a valid http url")
91+
self.url = url
92+
self.timeout = int(timeout)
93+
self._headers = {}
94+
self.trust_all_ssl_certificates = trust_all_ssl_certificates
95+
self.lookup_url = lookup_url
96+
self.async_job_check_time = async_job_check_time_ms / 1000.0
97+
self.async_job_check_time_scale_percent = async_job_check_time_scale_percent
98+
self.async_job_check_max_time = async_job_check_max_time_ms / 1000.0
99+
self.token = None
100+
if token is not None:
101+
self.token = token
102+
# Not a fan of magic env vars but this is too baked in to remove
103+
elif "KB_AUTH_TOKEN" in _os.environ:
104+
self.token = _os.environ.get("KB_AUTH_TOKEN")
105+
if self.token:
106+
self._headers["AUTHORIZATION"] = self.token
107+
if self.timeout < 1:
108+
raise ValueError("Timeout value must be at least 1 second")
109+
110+
def _call(self, url: str, method: str, params: list[Any], context: dict[str, Any] | None):
111+
arg_hash = {"method": method,
112+
"params": params,
113+
"version": "1.1",
114+
"id": str(_random.random())[2:],
115+
}
116+
if context:
117+
arg_hash["context"] = context
118+
119+
body = _json.dumps(arg_hash, cls=_JSONObjectEncoder)
120+
ret = _requests.post(
121+
url,
122+
data=body,
123+
headers=self._headers,
124+
timeout=self.timeout,
125+
verify=not self.trust_all_ssl_certificates
126+
)
127+
ret.encoding = "utf-8"
128+
if ret.status_code == 500:
129+
if ret.headers.get(_CT) == _AJ:
130+
err = ret.json()
131+
if "error" in err:
132+
raise ServerError(**err["error"])
133+
else:
134+
raise ServerError(
135+
"Unknown", 0, f"The server returned unexpected error JSON: {ret.text}"
136+
)
137+
else:
138+
raise ServerError(
139+
"Unknown", 0, f"The server returned a non-JSON response: {ret.text}"
140+
)
141+
if not ret.ok:
142+
ret.raise_for_status()
143+
resp = ret.json()
144+
if "result" not in resp:
145+
raise ServerError("Unknown", 0, "An unknown server error occurred")
146+
if not resp["result"]:
147+
return None
148+
if len(resp["result"]) == 1:
149+
return resp["result"][0]
150+
return resp["result"]
151+
152+
def call_method(self, service_method: str, args: list[Any], *, service_ver: str | None = None):
153+
"""
154+
Call a standard or dynamic service synchronously.
155+
Required arguments:
156+
service_method - the service and method to run, e.g. myserv.mymeth.
157+
args - a list of arguments to the method.
158+
Optional arguments:
159+
service_ver - the version of the service to run, e.g. a git hash
160+
or dev/beta/release.
161+
"""
162+
# TDOO NEXT implement dynamic methods
163+
#url = self._get_service_url(service_method, service_ver)
164+
#context = self._set_up_context(service_ver)
165+
url = self.url
166+
return self._call(url, service_method, args, None)

test.cfg.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[kbase_sdk_baseclient_tests]
2+
# The url for the environment to test against.
3+
test_url=https://ci.kbase.us/
4+
# A valid token for the environment.
5+
test_token=

0 commit comments

Comments
 (0)