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.
@@ -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,6 @@
1
+ """Allow python -m phenoworks_sdk to run the CLI."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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)