phenoworks-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- phenoworks_sdk/__init__.py +27 -0
- phenoworks_sdk/__main__.py +6 -0
- phenoworks_sdk/_common.py +110 -0
- phenoworks_sdk/async_client.py +663 -0
- phenoworks_sdk/async_resources.py +2689 -0
- phenoworks_sdk/auth.py +38 -0
- phenoworks_sdk/cli/__init__.py +16 -0
- phenoworks_sdk/cli/app.py +363 -0
- phenoworks_sdk/cli/authoring.py +170 -0
- phenoworks_sdk/cli/formatting.py +63 -0
- phenoworks_sdk/cli/metadata.py +627 -0
- phenoworks_sdk/client.py +649 -0
- phenoworks_sdk/errors.py +68 -0
- phenoworks_sdk/py.typed +0 -0
- phenoworks_sdk/resources.py +2659 -0
- phenoworks_sdk-0.1.0.dist-info/METADATA +186 -0
- phenoworks_sdk-0.1.0.dist-info/RECORD +20 -0
- phenoworks_sdk-0.1.0.dist-info/WHEEL +4 -0
- phenoworks_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- phenoworks_sdk-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Standalone Python and async clients for the PhenoWorks HTTP API."""
|
|
2
|
+
|
|
3
|
+
from .async_client import AsyncPhenoWorksClient, AsyncPipelineRun
|
|
4
|
+
from .client import PhenoWorksClient, PipelineRun
|
|
5
|
+
from .errors import (
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
NotFoundError,
|
|
8
|
+
PermissionDeniedError,
|
|
9
|
+
PhenoWorksAPIError,
|
|
10
|
+
PhenoWorksError,
|
|
11
|
+
PipelineFailedError,
|
|
12
|
+
PipelineTimeoutError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"PhenoWorksClient",
|
|
17
|
+
"AsyncPhenoWorksClient",
|
|
18
|
+
"PipelineRun",
|
|
19
|
+
"AsyncPipelineRun",
|
|
20
|
+
"PhenoWorksError",
|
|
21
|
+
"PhenoWorksAPIError",
|
|
22
|
+
"AuthenticationError",
|
|
23
|
+
"NotFoundError",
|
|
24
|
+
"PermissionDeniedError",
|
|
25
|
+
"PipelineFailedError",
|
|
26
|
+
"PipelineTimeoutError",
|
|
27
|
+
]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Transport validation and atomic local download helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import BinaryIO
|
|
11
|
+
from urllib.parse import unquote, urlsplit, urlunsplit
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from .errors import AuthenticationError, NotFoundError, PermissionDeniedError, PhenoWorksAPIError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def normalize_base_url(value: str) -> str:
|
|
19
|
+
"""Normalize value, an HTTP(S) server or API URL, to an API root ending in /.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Validated API base URL, preserving reverse-proxy path prefixes.
|
|
23
|
+
Raises:
|
|
24
|
+
ValueError: Credentials, query strings, fragments, or non-HTTP URLs were supplied.
|
|
25
|
+
"""
|
|
26
|
+
parts = urlsplit(value.strip())
|
|
27
|
+
if (
|
|
28
|
+
parts.scheme not in {"https", "http"}
|
|
29
|
+
or not parts.netloc
|
|
30
|
+
or parts.username
|
|
31
|
+
or parts.password
|
|
32
|
+
or parts.query
|
|
33
|
+
or parts.fragment
|
|
34
|
+
):
|
|
35
|
+
raise ValueError(
|
|
36
|
+
"base_url must be an HTTP(S) server URL without credentials, query, or fragment."
|
|
37
|
+
)
|
|
38
|
+
path = parts.path.rstrip("/")
|
|
39
|
+
if not path.endswith("/api"):
|
|
40
|
+
path += "/api"
|
|
41
|
+
return urlunsplit((parts.scheme, parts.netloc, path + "/", "", ""))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def api_path(value: str) -> str:
|
|
45
|
+
"""Validate value as an API-relative path and return it without the /api prefix.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
ValueError: The path could escape the configured API root.
|
|
49
|
+
"""
|
|
50
|
+
parts = urlsplit(value)
|
|
51
|
+
decoded = unquote(value)
|
|
52
|
+
if (
|
|
53
|
+
parts.scheme
|
|
54
|
+
or parts.netloc
|
|
55
|
+
or parts.query
|
|
56
|
+
or parts.fragment
|
|
57
|
+
or "\\" in decoded
|
|
58
|
+
or any(p in {".", ".."} for p in decoded.split("/"))
|
|
59
|
+
):
|
|
60
|
+
raise ValueError("Use an API-relative path and pass query parameters through params.")
|
|
61
|
+
return value.removeprefix("/api/").lstrip("/")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def check_response(response: httpx.Response, api_key: str = "") -> None:
|
|
65
|
+
"""Raise a typed error for an unsuccessful response, redacting api_key.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
response: Response whose error body has been read.
|
|
69
|
+
api_key: Credential to remove from server error text.
|
|
70
|
+
Raises:
|
|
71
|
+
PhenoWorksAPIError: Any non-2xx response, including redirects.
|
|
72
|
+
"""
|
|
73
|
+
if response.is_success:
|
|
74
|
+
return
|
|
75
|
+
detail = response.text[:4000]
|
|
76
|
+
if api_key:
|
|
77
|
+
detail = detail.replace(api_key, "[REDACTED]")
|
|
78
|
+
error = {401: AuthenticationError, 403: PermissionDeniedError, 404: NotFoundError}.get(
|
|
79
|
+
response.status_code, PhenoWorksAPIError
|
|
80
|
+
)
|
|
81
|
+
raise error(response.status_code, detail)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@contextmanager
|
|
85
|
+
def atomic_download(destination: str | Path, overwrite: bool) -> Iterator[tuple[Path, BinaryIO]]:
|
|
86
|
+
"""Write a unique temporary file and publish it only after success.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
destination: Exact destination filename; filenames from HTTP are never trusted.
|
|
90
|
+
overwrite: Allow replacing an existing destination.
|
|
91
|
+
Yields:
|
|
92
|
+
Destination path and writable temporary stream.
|
|
93
|
+
Raises:
|
|
94
|
+
FileExistsError: Destination exists and overwrite is false.
|
|
95
|
+
"""
|
|
96
|
+
target = Path(destination).expanduser()
|
|
97
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
if target.exists() and not overwrite:
|
|
99
|
+
raise FileExistsError(target)
|
|
100
|
+
fd, name = tempfile.mkstemp(prefix=f".{target.name}.", suffix=".part", dir=target.parent)
|
|
101
|
+
temporary = Path(name)
|
|
102
|
+
try:
|
|
103
|
+
with os.fdopen(fd, "wb") as output:
|
|
104
|
+
yield target, output
|
|
105
|
+
if overwrite:
|
|
106
|
+
os.replace(temporary, target)
|
|
107
|
+
else:
|
|
108
|
+
os.link(temporary, target)
|
|
109
|
+
finally:
|
|
110
|
+
temporary.unlink(missing_ok=True)
|