Skip to content

Commit

Permalink
feat: spec-compliant routes, with legacy support
Browse files Browse the repository at this point in the history
  • Loading branch information
uniqueg committed Feb 11, 2023
1 parent a90cd3e commit 3ac3052
Show file tree
Hide file tree
Showing 3 changed files with 273 additions and 47 deletions.
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,30 @@ cli.cancel_task(task_id)
tasks_list = cli.list_tasks(view="MINIMAL") # default view
```

> For backward compatibility and flexibility, `py-tes` is deliberately
> forgiving with respect to the path at which the TES API is hosted. It will
> try to locate the API by appending `/ga4gh/tes/v1` (standard location
> since TES v0.4.0) and `/v1` (standard location up to TES v0.3.0) to the host
> URL provided during client instantiation, in that order. To support TES APIs
> hosted at non-standard locations, `py-tes` will then try to locate the API at
> the provided host URL, without any suffix.
>
> Similarly, `py-tes` currently supports legacy TES implementations where the
> service info endpoint is hosted at `/tasks/service-info` (standard route up
> until TES 0.4.0) - if it does not find the endpoint at route `/service-info`
> (standard location since TES 0.5.0).
>
> Please note that this flexibility comes at cost: Up to six HTTP requests
> (accessing the service info via `/tasks/service-info` from a TES API at a
> non-standard location) may be necessary to locate the API. Therefore, if you
> are dealing with such TES services, you may need to increase the `timeout`
> duration (passed during client instantiation) beyond the default value of 10
> seconds.
>
> Please also consider asking your TES provider(s) to adopt the standard suffix
> and endpoint routes, as we may drop support for flexible API hosting in the
> future.
### How to...

> Makes use of the objects above...
Expand Down
127 changes: 101 additions & 26 deletions tes/client.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,100 @@
import re
import requests
import time

from attr import attrs, attrib
from attr.validators import instance_of, optional
from urllib.parse import urlparse
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional

from tes.models import (Task, ListTasksRequest, ListTasksResponse, ServiceInfo,
GetTaskRequest, CancelTaskRequest, CreateTaskResponse,
strconv)
from tes.utils import unmarshal, TimeoutError


def process_url(value):
return re.sub("[/]+$", "", value)
def append_suffixes_to_url(
urls: List[str], suffixes: List[str]
) -> List[str]:
"""Compile all combinations of full paths from paths and suffixes.
Args:
urls: List of URL paths.
prefixes: List of suffixes to be appended to `urls`.
Returns:
List of full path combinations, in the provided order of `paths` and
`suffixes`, starting with all suffix combinations for the first path,
then those for the second path, and so on. Paths are stripped of any
trailing slashes.
Examples:
>>> client = tes.HTTPClient.append_suffixes_to_url(['https://funnel.exa
mple.com'], ['ga4gh/tes/v1', 'v1', ''])
['https://funnel.example.com/ga4gh/tes/v1', 'https://funnel.example.com
/v1', 'https://funnel.example.com']
"""
compiled_paths: List[str] = []
for url in urls:
for suffix in suffixes:
compiled_paths.append(
f"{url.rstrip('/')}/{suffix.strip('/')}".rstrip('/'))
return compiled_paths


def send_request(
paths: List[str], method: str = 'get',
kwargs_requests: Optional[Dict[str, Any]] = None, **kwargs: Any
) -> requests.Response:
"""Send request to a list of URLs, returning the first valid response.
Args:
paths: List of fully qualified URLs.
method: HTTP method to use for the request; one of 'get' (default),
'post', 'put', and 'delete'.
kwargs_requests: Keyword arguments to pass to the :mod:`requests` call.
**kwargs: Keyword arguments for path parameter substition.
Returns:
The first successful response from the list of endpoints.
Raises:
requests.exceptions.HTTPError: If no response is received from any
path.
requests.exceptions.HTTPError: As soon as the first 4xx or 5xx status
code is received.
requests.exceptions.HTTPError: If, after trying all paths, at least one
404 status code and no other 4xx or 5xx status codes are received.
ValueError: If an unsupported HTTP method is provided.
"""
if kwargs_requests is None:
kwargs_requests = {}
if method not in ('get', 'post', 'put', 'delete'):
raise ValueError(f"Unsupported HTTP method: {method}")

response: requests.Response = requests.Response()
http_exceptions: Dict[str, Exception] = {}
for path in paths:
try:
response = getattr(requests, method)(
path.format(**kwargs), **kwargs_requests)
except requests.exceptions.RequestException as exc:
print("EXCEPTION")
http_exceptions[path] = exc
continue
if response.status_code != 404:
print("SUCCESS")
break

if response.status_code is None:
raise requests.exceptions.HTTPError(
f"No response received; HTTP Exceptions: {http_exceptions}")
response.raise_for_status()
return response


@attrs
class HTTPClient(object):
url: str = attrib(converter=process_url)
url: str = attrib(validator=instance_of(str))
timeout: int = attrib(default=10, validator=instance_of(int))
user: Optional[str] = attrib(
default=None, converter=strconv, validator=optional(instance_of(str)))
Expand All @@ -28,6 +103,12 @@ class HTTPClient(object):
token: Optional[str] = attrib(
default=None, converter=strconv, validator=optional(instance_of(str)))

def __attrs_post_init__(self):
# for backward compatibility
self.urls: List[str] = append_suffixes_to_url(
[self.url], ["/ga4gh/tes/v1", "/v1", "/"]
)

@url.validator # type: ignore
def __check_url(self, attribute, value):
u = urlparse(value)
Expand All @@ -39,10 +120,10 @@ def __check_url(self, attribute, value):

def get_service_info(self) -> ServiceInfo:
kwargs: Dict[str, Any] = self._request_params()
response: requests.Response = requests.get(
f"{self.url}/v1/tasks/service-info",
**kwargs)
response.raise_for_status()
paths = append_suffixes_to_url(
self.urls, ["service-info", "tasks/service-info"]
)
response = send_request(paths=paths, kwargs_requests=kwargs)
return unmarshal(response.json(), ServiceInfo)

def create_task(self, task: Task) -> CreateTaskResponse:
Expand All @@ -52,30 +133,26 @@ def create_task(self, task: Task) -> CreateTaskResponse:
raise TypeError("Expected Task instance")

kwargs: Dict[str, Any] = self._request_params(data=msg)
response: requests.Response = requests.post(
f"{self.url}/v1/tasks",
**kwargs
)
response.raise_for_status()
paths = append_suffixes_to_url(self.urls, ["/tasks"])
response = send_request(paths=paths, method='post',
kwargs_requests=kwargs)
return unmarshal(response.json(), CreateTaskResponse).id

def get_task(self, task_id: str, view: str = "BASIC") -> Task:
req: GetTaskRequest = GetTaskRequest(task_id, view)
payload: Dict[str, Optional[str]] = {"view": req.view}
kwargs: Dict[str, Any] = self._request_params(params=payload)
response: requests.Response = requests.get(
f"{self.url}/v1/tasks/{req.id}",
**kwargs)
response.raise_for_status()
paths = append_suffixes_to_url(self.urls, ["/tasks/{task_id}"])
response = send_request(paths=paths, kwargs_requests=kwargs,
task_id=req.id)
return unmarshal(response.json(), Task)

def cancel_task(self, task_id: str) -> None:
req: CancelTaskRequest = CancelTaskRequest(task_id)
kwargs: Dict[str, Any] = self._request_params()
response: requests.Response = requests.post(
f"{self.url}/v1/tasks/{req.id}:cancel",
**kwargs)
response.raise_for_status()
paths = append_suffixes_to_url(self.urls, ["/tasks/{task_id}:cancel"])
send_request(paths=paths, method='post', kwargs_requests=kwargs,
task_id=req.id)
return None

def list_tasks(
Expand All @@ -92,10 +169,8 @@ def list_tasks(
msg: Dict = req.as_dict()

kwargs: Dict[str, Any] = self._request_params(params=msg)
response: requests.Response = requests.get(
f"{self.url}/v1/tasks",
**kwargs)
response.raise_for_status()
paths = append_suffixes_to_url(self.urls, ["/tasks"])
response = send_request(paths=paths, kwargs_requests=kwargs)
return unmarshal(response.json(), ListTasksResponse)

def wait(self, task_id: str, timeout=None) -> Task:
Expand Down
Loading

0 comments on commit 3ac3052

Please sign in to comment.