cuiman 0.2.0.dev1__tar.gz → 0.3.0__tar.gz
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.
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/.gitignore +6 -2
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/PKG-INFO +10 -6
- cuiman-0.3.0/cuiman/__init__.py +42 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/async_client.py +43 -38
- cuiman-0.3.0/cuiman/api/async_client_mixin.py +319 -0
- cuiman-0.3.0/cuiman/api/auth/__init__.py +35 -0
- cuiman-0.3.0/cuiman/api/auth/config.py +293 -0
- cuiman-0.3.0/cuiman/api/auth/interactive.py +101 -0
- cuiman-0.3.0/cuiman/api/auth/login.py +73 -0
- cuiman-0.3.0/cuiman/api/auth/oauth2_client.py +75 -0
- cuiman-0.3.0/cuiman/api/auth/oidc.py +167 -0
- cuiman-0.3.0/cuiman/api/auth/secret_store.py +185 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/client.py +43 -38
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/client_app_mixin.py +18 -2
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/client_mixin.py +132 -11
- cuiman-0.3.0/cuiman/api/client_mixin_base.py +290 -0
- cuiman-0.3.0/cuiman/api/config.py +618 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/defaults.py +1 -1
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/ishell.py +1 -2
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/opener/context.py +8 -8
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/opener/opener.py +4 -4
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/opener/registry.py +3 -0
- cuiman-0.2.0.dev1/cuiman/api/transport/httpx.py → cuiman-0.3.0/cuiman/api/transport/httpx2.py +44 -40
- cuiman-0.3.0/cuiman/app/display.py +111 -0
- cuiman-0.3.0/cuiman/app/dist/assets/index-Br7nJ6aR.js +85 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/app/dist/index.html +3 -3
- cuiman-0.3.0/cuiman/app/launch.py +347 -0
- cuiman-0.3.0/cuiman/app/notebook-display.js +217 -0
- cuiman-0.3.0/cuiman/app/serve.py +156 -0
- cuiman-0.3.0/cuiman/app/url.py +60 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/cli/cli.py +142 -49
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/cli/client.py +36 -1
- cuiman-0.3.0/cuiman/cli/config.py +223 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/pyproject.toml +10 -7
- cuiman-0.2.0.dev1/cuiman/__init__.py +0 -23
- cuiman-0.2.0.dev1/cuiman/api/async_client_mixin.py +0 -153
- cuiman-0.2.0.dev1/cuiman/api/auth/__init__.py +0 -16
- cuiman-0.2.0.dev1/cuiman/api/auth/config.py +0 -161
- cuiman-0.2.0.dev1/cuiman/api/auth/login.py +0 -180
- cuiman-0.2.0.dev1/cuiman/api/auth/login_async.py +0 -62
- cuiman-0.2.0.dev1/cuiman/api/config.py +0 -204
- cuiman-0.2.0.dev1/cuiman/app/display.py +0 -110
- cuiman-0.2.0.dev1/cuiman/app/dist/assets/index-CEQarI7F.js +0 -84
- cuiman-0.2.0.dev1/cuiman/app/serve.py +0 -68
- cuiman-0.2.0.dev1/cuiman/app/service.py +0 -96
- cuiman-0.2.0.dev1/cuiman/app/url.py +0 -77
- cuiman-0.2.0.dev1/cuiman/cli/config.py +0 -189
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/LICENSE +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/README.md +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/__init__.py +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/exceptions.py +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/jobs.py +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/opener/__init__.py +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/opener/errors.py +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/opener/impl/__init__.py +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/opener/impl/_gpd.py +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/opener/impl/_image.py +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/opener/impl/_pd.py +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/opener/impl/_xr.py +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/opener/impl/base.py +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/opener/impl/openers.py +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/service_client.py +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/transport/__init__.py +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/transport/args.py +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/api/transport/transport.py +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/app/__init__.py +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/app/app.py +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/app/dist/assets/index-DKE7nU5k.css +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/app/dist/eozilla-small.svg +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/app/dist/eozilla.svg +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/app/dist/privacy.md +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/cli/__init__.py +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/cli/output.py +0 -0
- {cuiman-0.2.0.dev1 → cuiman-0.3.0}/cuiman/py.typed +0 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# Testing
|
|
2
2
|
*.zarr/
|
|
3
3
|
.cov-report
|
|
4
|
+
notebooks/auth-test.ipynb
|
|
4
5
|
|
|
5
|
-
#
|
|
6
|
-
docs/notebooks
|
|
6
|
+
# Legacy generated documentation (excluded from the site)
|
|
7
|
+
docs/notebooks/
|
|
7
8
|
|
|
8
9
|
# Byte-compiled / optimized / DLL files
|
|
9
10
|
__pycache__/
|
|
@@ -202,3 +203,6 @@ cython_debug/
|
|
|
202
203
|
# refer to https://docs.cursor.com/context/ignore-files
|
|
203
204
|
.cursorignore
|
|
204
205
|
.cursorindexingignore
|
|
206
|
+
|
|
207
|
+
# Downloaded font assets
|
|
208
|
+
docs/assets/fonts/*.ttf
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cuiman
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Provides a client Python API, GUI, and CLI for servers compliant with OGC API - Processes
|
|
5
5
|
Project-URL: Documentation, https://eo-tools.github.io/eozilla
|
|
6
6
|
Project-URL: Repository, https://github.com/eo-tools/eozilla
|
|
@@ -27,12 +27,16 @@ Classifier: Topic :: Scientific/Engineering
|
|
|
27
27
|
Classifier: Topic :: Software Development
|
|
28
28
|
Classifier: Typing :: Typed
|
|
29
29
|
Requires-Python: >=3.10
|
|
30
|
-
Requires-Dist:
|
|
31
|
-
Requires-Dist:
|
|
32
|
-
Requires-Dist:
|
|
33
|
-
Requires-Dist:
|
|
30
|
+
Requires-Dist: authlib<1.9,>=1.8
|
|
31
|
+
Requires-Dist: gavicore>=0.3.0
|
|
32
|
+
Requires-Dist: httpx2
|
|
33
|
+
Requires-Dist: ipython
|
|
34
|
+
Requires-Dist: joserfc<2,>=1.6
|
|
35
|
+
Requires-Dist: keyring
|
|
36
|
+
Requires-Dist: pydantic-settings>=2.14.2
|
|
37
|
+
Requires-Dist: pydantic>=2.12
|
|
34
38
|
Requires-Dist: pyyaml
|
|
35
|
-
Requires-Dist: remotestate<1,>=0.3.
|
|
39
|
+
Requires-Dist: remotestate<1,>=0.3.4
|
|
36
40
|
Requires-Dist: typer<1,>=0.26.0
|
|
37
41
|
Requires-Dist: uri-template
|
|
38
42
|
Description-Content-Type: text/markdown
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Copyright (c) 2025-2026 by the Eozilla team and contributors
|
|
2
|
+
# Permissions are hereby granted under the terms of the Apache 2.0 License:
|
|
3
|
+
# https://opensource.org/license/apache-2-0.
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import version
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .api.async_client import AsyncClient as AsyncClient
|
|
10
|
+
from .api.client import Client as Client
|
|
11
|
+
from .api.config import ClientConfig as ClientConfig
|
|
12
|
+
from .api.exceptions import ClientError as ClientError
|
|
13
|
+
from .api.jobs import JobMonitor as JobMonitor
|
|
14
|
+
from .api.jobs import JobOptions as JobOptions
|
|
15
|
+
|
|
16
|
+
__version__ = version("cuiman")
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AsyncClient",
|
|
20
|
+
"Client",
|
|
21
|
+
"ClientConfig",
|
|
22
|
+
"ClientError",
|
|
23
|
+
"JobMonitor",
|
|
24
|
+
"JobOptions",
|
|
25
|
+
"__version__",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def __getattr__(name: str) -> Any:
|
|
30
|
+
"""Load public API exports only when requested, keeping CLI startup light."""
|
|
31
|
+
if name not in __all__:
|
|
32
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
33
|
+
from importlib import import_module
|
|
34
|
+
|
|
35
|
+
value = getattr(import_module(".api", __name__), name)
|
|
36
|
+
globals()[name] = value
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def __dir__() -> list[str]:
|
|
41
|
+
"""Include lazy public API exports in interactive discovery."""
|
|
42
|
+
return sorted(set(globals()) | set(__all__))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# generated by gen_client.py:
|
|
2
2
|
# filename: async_client.py:
|
|
3
|
-
# timestamp: 2026-
|
|
3
|
+
# timestamp: 2026-09-14T06:56:16.079586
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
from typing import Any, Optional
|
|
@@ -22,7 +22,6 @@ from .client_app_mixin import ClientAppMixin
|
|
|
22
22
|
from .config import ClientConfig
|
|
23
23
|
from .ishell import has_ishell as _ # noqa F401
|
|
24
24
|
from .transport import AsyncTransport, TransportArgs
|
|
25
|
-
from .transport.httpx import HttpxTransport
|
|
26
25
|
|
|
27
26
|
|
|
28
27
|
class AsyncClient(ClientAppMixin, AsyncClientMixin):
|
|
@@ -30,10 +29,18 @@ class AsyncClient(ClientAppMixin, AsyncClientMixin):
|
|
|
30
29
|
The client API for the web service (asynchronous mode).
|
|
31
30
|
|
|
32
31
|
Args:
|
|
33
|
-
config: Optional
|
|
34
|
-
|
|
32
|
+
config: Optional configuration object. Explicit keyword settings override
|
|
33
|
+
its values.
|
|
34
|
+
config_type: Optional application configuration class. It owns the
|
|
35
|
+
settings schema, defaults, environment namespace, and profile path.
|
|
36
|
+
When omitted, a supplied ``config`` object's concrete type is used.
|
|
35
37
|
config_path: Optional path of the configuration file to be loaded
|
|
36
|
-
config_kwargs: Configuration
|
|
38
|
+
config_kwargs (Any): Configuration overrides, including ``auth``. An auth model
|
|
39
|
+
or a dictionary containing ``auth_type`` replaces previous auth settings,
|
|
40
|
+
even when the type is unchanged. A dictionary without ``auth_type``
|
|
41
|
+
merges into the selected auth configuration, including nested mappings.
|
|
42
|
+
If credentials are still missing, a matching profile's keyring entry may
|
|
43
|
+
supply them; explicitly supplied credentials take precedence.
|
|
37
44
|
api_url: The service URL of the OGC API - Processes.
|
|
38
45
|
"""
|
|
39
46
|
|
|
@@ -41,6 +48,7 @@ class AsyncClient(ClientAppMixin, AsyncClientMixin):
|
|
|
41
48
|
self,
|
|
42
49
|
*,
|
|
43
50
|
config: Optional[ClientConfig] = None,
|
|
51
|
+
config_type: type[ClientConfig] | None = None,
|
|
44
52
|
config_path: Optional[str] = None,
|
|
45
53
|
api_url: Optional[str] = None,
|
|
46
54
|
_debug: bool = False,
|
|
@@ -49,23 +57,16 @@ class AsyncClient(ClientAppMixin, AsyncClientMixin):
|
|
|
49
57
|
):
|
|
50
58
|
self._config = ClientConfig.create(
|
|
51
59
|
config=config,
|
|
60
|
+
config_type=config_type,
|
|
52
61
|
config_path=config_path,
|
|
53
62
|
api_url=api_url,
|
|
54
63
|
**config_kwargs,
|
|
55
64
|
)
|
|
56
65
|
if not self._config.api_url:
|
|
57
66
|
raise ValueError("Required setting 'api_url' not configured")
|
|
58
|
-
self._transport =
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
headers=self._config.auth_headers,
|
|
62
|
-
return_type_map=self._config.return_type_map,
|
|
63
|
-
async_token_refresher=self._config._make_async_token_refresher(),
|
|
64
|
-
debug=_debug,
|
|
65
|
-
)
|
|
66
|
-
if _transport is None
|
|
67
|
-
else _transport
|
|
68
|
-
)
|
|
67
|
+
self._transport = _transport
|
|
68
|
+
self._debug = _debug
|
|
69
|
+
self._init_client_runtime()
|
|
69
70
|
|
|
70
71
|
@property
|
|
71
72
|
def config(self) -> ClientConfig:
|
|
@@ -100,7 +101,8 @@ class AsyncClient(ClientAppMixin, AsyncClientMixin):
|
|
|
100
101
|
|
|
101
102
|
- `500`: A server error occurred.
|
|
102
103
|
"""
|
|
103
|
-
|
|
104
|
+
transport = self._get_transport()
|
|
105
|
+
return await transport.async_call(
|
|
104
106
|
TransportArgs(
|
|
105
107
|
path="/",
|
|
106
108
|
method="get",
|
|
@@ -127,8 +129,8 @@ class AsyncClient(ClientAppMixin, AsyncClientMixin):
|
|
|
127
129
|
|Dismiss|http://www.opengis.net/spec/ogcapi-processes-1/1.0/conf/dismiss|
|
|
128
130
|
|
|
129
131
|
For more information, see [OGC API — Processes — Part 1
|
|
130
|
-
Section
|
|
131
|
-
062r2.html#sc_conformance_classes).
|
|
132
|
+
Section
|
|
133
|
+
7.4](https://docs.ogc.org/is/18-062r2/18-062r2.html#sc_conformance_classes).
|
|
132
134
|
|
|
133
135
|
|
|
134
136
|
Returns:
|
|
@@ -144,7 +146,8 @@ class AsyncClient(ClientAppMixin, AsyncClientMixin):
|
|
|
144
146
|
|
|
145
147
|
- `500`: A server error occurred.
|
|
146
148
|
"""
|
|
147
|
-
|
|
149
|
+
transport = self._get_transport()
|
|
150
|
+
return await transport.async_call(
|
|
148
151
|
TransportArgs(
|
|
149
152
|
path="/conformance",
|
|
150
153
|
method="get",
|
|
@@ -172,7 +175,8 @@ class AsyncClient(ClientAppMixin, AsyncClientMixin):
|
|
|
172
175
|
ClientError: If the call to the web service fails
|
|
173
176
|
with a status code != `2xx`.
|
|
174
177
|
"""
|
|
175
|
-
|
|
178
|
+
transport = self._get_transport()
|
|
179
|
+
return await transport.async_call(
|
|
176
180
|
TransportArgs(
|
|
177
181
|
path="/processes",
|
|
178
182
|
method="get",
|
|
@@ -193,8 +197,8 @@ class AsyncClient(ClientAppMixin, AsyncClientMixin):
|
|
|
193
197
|
description.
|
|
194
198
|
|
|
195
199
|
For more information, see [OGC API — Processes — Part 1
|
|
196
|
-
Section
|
|
197
|
-
062r2.html#sc_process_description).
|
|
200
|
+
Section
|
|
201
|
+
7.10](https://docs.ogc.org/is/18-062r2/18-062r2.html#sc_process_description).
|
|
198
202
|
|
|
199
203
|
Args:
|
|
200
204
|
process_id:
|
|
@@ -210,7 +214,8 @@ class AsyncClient(ClientAppMixin, AsyncClientMixin):
|
|
|
210
214
|
|
|
211
215
|
- `404`: The requested URI was not found.
|
|
212
216
|
"""
|
|
213
|
-
|
|
217
|
+
transport = self._get_transport()
|
|
218
|
+
return await transport.async_call(
|
|
214
219
|
TransportArgs(
|
|
215
220
|
path="/processes/{processID}",
|
|
216
221
|
method="get",
|
|
@@ -247,7 +252,8 @@ class AsyncClient(ClientAppMixin, AsyncClientMixin):
|
|
|
247
252
|
- `404`: The requested URI was not found.
|
|
248
253
|
- `500`: A server error occurred.
|
|
249
254
|
"""
|
|
250
|
-
|
|
255
|
+
transport = self._get_transport()
|
|
256
|
+
return await transport.async_call(
|
|
251
257
|
TransportArgs(
|
|
252
258
|
path="/processes/{processID}/execution",
|
|
253
259
|
method="post",
|
|
@@ -276,7 +282,8 @@ class AsyncClient(ClientAppMixin, AsyncClientMixin):
|
|
|
276
282
|
|
|
277
283
|
- `404`: The requested URI was not found.
|
|
278
284
|
"""
|
|
279
|
-
|
|
285
|
+
transport = self._get_transport()
|
|
286
|
+
return await transport.async_call(
|
|
280
287
|
TransportArgs(
|
|
281
288
|
path="/jobs",
|
|
282
289
|
method="get",
|
|
@@ -291,8 +298,8 @@ class AsyncClient(ClientAppMixin, AsyncClientMixin):
|
|
|
291
298
|
Show the status of a job.
|
|
292
299
|
|
|
293
300
|
For more information, see [OGC API — Processes — Part 1
|
|
294
|
-
Section
|
|
295
|
-
062r2.html#sc_retrieve_status_info).
|
|
301
|
+
Section
|
|
302
|
+
7.12](https://docs.ogc.org/is/18-062r2/18-062r2.html#sc_retrieve_status_info).
|
|
296
303
|
|
|
297
304
|
Args:
|
|
298
305
|
job_id: Local identifier of a job
|
|
@@ -309,7 +316,8 @@ class AsyncClient(ClientAppMixin, AsyncClientMixin):
|
|
|
309
316
|
- `404`: The requested URI was not found.
|
|
310
317
|
- `500`: A server error occurred.
|
|
311
318
|
"""
|
|
312
|
-
|
|
319
|
+
transport = self._get_transport()
|
|
320
|
+
return await transport.async_call(
|
|
313
321
|
TransportArgs(
|
|
314
322
|
path="/jobs/{jobId}",
|
|
315
323
|
method="get",
|
|
@@ -342,7 +350,8 @@ class AsyncClient(ClientAppMixin, AsyncClientMixin):
|
|
|
342
350
|
- `404`: The requested URI was not found.
|
|
343
351
|
- `500`: A server error occurred.
|
|
344
352
|
"""
|
|
345
|
-
|
|
353
|
+
transport = self._get_transport()
|
|
354
|
+
return await transport.async_call(
|
|
346
355
|
TransportArgs(
|
|
347
356
|
path="/jobs/{jobId}",
|
|
348
357
|
method="delete",
|
|
@@ -359,8 +368,8 @@ class AsyncClient(ClientAppMixin, AsyncClientMixin):
|
|
|
359
368
|
instead.
|
|
360
369
|
|
|
361
370
|
For more information, see [OGC API — Processes — Part 1
|
|
362
|
-
Section
|
|
363
|
-
062r2.html#sc_retrieve_job_results).
|
|
371
|
+
Section
|
|
372
|
+
7.13](https://docs.ogc.org/is/18-062r2/18-062r2.html#sc_retrieve_job_results).
|
|
364
373
|
|
|
365
374
|
Args:
|
|
366
375
|
job_id: Local identifier of a job
|
|
@@ -377,7 +386,8 @@ class AsyncClient(ClientAppMixin, AsyncClientMixin):
|
|
|
377
386
|
- `404`: The requested URI was not found.
|
|
378
387
|
- `500`: A server error occurred.
|
|
379
388
|
"""
|
|
380
|
-
|
|
389
|
+
transport = self._get_transport()
|
|
390
|
+
return await transport.async_call(
|
|
381
391
|
TransportArgs(
|
|
382
392
|
path="/jobs/{jobId}/results",
|
|
383
393
|
method="get",
|
|
@@ -387,8 +397,3 @@ class AsyncClient(ClientAppMixin, AsyncClientMixin):
|
|
|
387
397
|
extra_kwargs=kwargs,
|
|
388
398
|
)
|
|
389
399
|
)
|
|
390
|
-
|
|
391
|
-
async def close(self):
|
|
392
|
-
"""Close this client."""
|
|
393
|
-
if self._transport is not None:
|
|
394
|
-
await self._transport.async_close()
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# Copyright (c) 2026 by the Eozilla team and contributors
|
|
2
|
+
# Permissions are hereby granted under the terms of the Apache 2.0 License:
|
|
3
|
+
# https://opensource.org/license/apache-2-0.
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import threading
|
|
7
|
+
import warnings
|
|
8
|
+
from abc import abstractmethod
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx2
|
|
12
|
+
from authlib.integrations.httpx_client import AsyncOAuth2Client
|
|
13
|
+
|
|
14
|
+
from gavicore.models import JobInfo, JobResults, JobStatus, ProcessDescription
|
|
15
|
+
from gavicore.util.request import ExecutionRequest
|
|
16
|
+
|
|
17
|
+
from .auth.config import OidcAuthConfig
|
|
18
|
+
from .auth.interactive import authorize
|
|
19
|
+
from .auth.oidc import LoopbackCallbackServer
|
|
20
|
+
from .client_mixin_base import ClientMixinBase
|
|
21
|
+
from .defaults import (
|
|
22
|
+
DEFAULT_OPEN_JOB_JOB_POLL_INTERVAL,
|
|
23
|
+
DEFAULT_OPEN_JOB_RESULT_TIMEOUT,
|
|
24
|
+
)
|
|
25
|
+
from .exceptions import ClientError, ClientWarning
|
|
26
|
+
from .opener import JobResultOpenContext, JobResultStatusError
|
|
27
|
+
from .opener.opener import open_job_result
|
|
28
|
+
from .transport import AsyncTransport
|
|
29
|
+
|
|
30
|
+
# -----------------------------------------------------
|
|
31
|
+
# IMPORTANT: Sync changes here with ClientMixin!
|
|
32
|
+
# -----------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# noinspection PyShadowingBuiltins
|
|
36
|
+
class AsyncClientMixin(ClientMixinBase[httpx2.AsyncClient]):
|
|
37
|
+
"""
|
|
38
|
+
Extra methods for the API client (asynchronous mode).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
_async_mode = True
|
|
42
|
+
_transport: AsyncTransport | None
|
|
43
|
+
|
|
44
|
+
def _init_client_runtime(self) -> None:
|
|
45
|
+
super()._init_client_runtime()
|
|
46
|
+
self._runtime_lock = asyncio.Lock()
|
|
47
|
+
self._owner_loop: asyncio.AbstractEventLoop | None = None
|
|
48
|
+
|
|
49
|
+
def _bind_loop(self) -> asyncio.AbstractEventLoop:
|
|
50
|
+
loop = asyncio.get_running_loop()
|
|
51
|
+
if self._owner_loop is not None and self._owner_loop is not loop:
|
|
52
|
+
raise RuntimeError("AsyncClient must be used on its owning event loop.")
|
|
53
|
+
self._owner_loop = loop
|
|
54
|
+
return loop
|
|
55
|
+
|
|
56
|
+
async def close(self) -> None:
|
|
57
|
+
"""Close owned connections. A closed client cannot be used again."""
|
|
58
|
+
self._bind_loop()
|
|
59
|
+
async with self._runtime_lock:
|
|
60
|
+
await self._close()
|
|
61
|
+
|
|
62
|
+
async def _close(self) -> None:
|
|
63
|
+
"""Close connections while the caller holds the owner lock."""
|
|
64
|
+
self._closed = True
|
|
65
|
+
transport, self._transport = self._transport, None
|
|
66
|
+
try:
|
|
67
|
+
if transport is not None:
|
|
68
|
+
await transport.async_close()
|
|
69
|
+
finally:
|
|
70
|
+
if self._http_client is not None:
|
|
71
|
+
await self._http_client.aclose()
|
|
72
|
+
self._http_client = None
|
|
73
|
+
|
|
74
|
+
async def login(
|
|
75
|
+
self,
|
|
76
|
+
*,
|
|
77
|
+
interactive: bool = True,
|
|
78
|
+
no_browser: bool = False,
|
|
79
|
+
force: bool = False,
|
|
80
|
+
save: bool = False,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Prepare authentication using the client's persistent HTTP session.
|
|
83
|
+
|
|
84
|
+
Use force to acquire a fresh token. Requests never prompt or open a
|
|
85
|
+
browser. With save=True, failure to save credentials raises an error.
|
|
86
|
+
"""
|
|
87
|
+
self._bind_loop()
|
|
88
|
+
async with self._runtime_lock:
|
|
89
|
+
await self._login(
|
|
90
|
+
interactive=interactive, no_browser=no_browser, force=force, save=save
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
async def _login(
|
|
94
|
+
self,
|
|
95
|
+
*,
|
|
96
|
+
interactive: bool = False,
|
|
97
|
+
no_browser: bool = False,
|
|
98
|
+
force: bool = False,
|
|
99
|
+
save: bool = False,
|
|
100
|
+
) -> None:
|
|
101
|
+
self._require_open()
|
|
102
|
+
auth = (
|
|
103
|
+
await asyncio.to_thread(self._credentials, interactive=True, force=force)
|
|
104
|
+
if interactive
|
|
105
|
+
else self._credentials(interactive=False, force=force)
|
|
106
|
+
)
|
|
107
|
+
self._configure_http_client(auth, self._updated_token)
|
|
108
|
+
await self._discover()
|
|
109
|
+
client = self._http_client
|
|
110
|
+
response = None
|
|
111
|
+
if isinstance(client, AsyncOAuth2Client) and client.token and not force:
|
|
112
|
+
await client.ensure_active_token(client.token)
|
|
113
|
+
elif isinstance(auth, OidcAuthConfig):
|
|
114
|
+
assert isinstance(client, AsyncOAuth2Client)
|
|
115
|
+
with LoopbackCallbackServer() as server:
|
|
116
|
+
url, state, verifier = self._authorization(client, server)
|
|
117
|
+
cancelled = threading.Event()
|
|
118
|
+
try:
|
|
119
|
+
code = await asyncio.to_thread(
|
|
120
|
+
authorize,
|
|
121
|
+
server,
|
|
122
|
+
url,
|
|
123
|
+
state,
|
|
124
|
+
no_browser=no_browser,
|
|
125
|
+
cancelled=cancelled,
|
|
126
|
+
)
|
|
127
|
+
except asyncio.CancelledError:
|
|
128
|
+
cancelled.set()
|
|
129
|
+
raise
|
|
130
|
+
response = await client.fetch_token(code=code, code_verifier=verifier)
|
|
131
|
+
await self._validate_oidc_token(required=True)
|
|
132
|
+
elif request := self._login_request(force=force):
|
|
133
|
+
response = await request()
|
|
134
|
+
self._accept_login(response, force=force, save=save)
|
|
135
|
+
|
|
136
|
+
async def _discover(self) -> None:
|
|
137
|
+
if url := self._discovery_url:
|
|
138
|
+
assert self._http_client is not None
|
|
139
|
+
self._accept_discovery(
|
|
140
|
+
await self._http_client.request("GET", url, auth=None)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
async def _validate_oidc_token(self, *, required: bool = False) -> None:
|
|
144
|
+
with self._oidc_validation(required=required) as url:
|
|
145
|
+
if url:
|
|
146
|
+
assert self._http_client is not None
|
|
147
|
+
response = await self._http_client.request("GET", url, auth=None)
|
|
148
|
+
self._validate_id_token(response, required=required)
|
|
149
|
+
|
|
150
|
+
async def _updated_token(self, _token: dict[str, Any], **_previous: Any) -> None:
|
|
151
|
+
await self._validate_oidc_token()
|
|
152
|
+
self._save_credentials()
|
|
153
|
+
|
|
154
|
+
async def logout(self) -> None:
|
|
155
|
+
"""Revoke an OIDC token when supported, remove local secrets, and close."""
|
|
156
|
+
self._bind_loop()
|
|
157
|
+
async with self._runtime_lock:
|
|
158
|
+
self._require_open()
|
|
159
|
+
try:
|
|
160
|
+
if self._can_revoke:
|
|
161
|
+
self._configure_http_client(self.config.auth, self._updated_token)
|
|
162
|
+
await self._discover()
|
|
163
|
+
if options := self._revocation():
|
|
164
|
+
assert isinstance(self._http_client, AsyncOAuth2Client)
|
|
165
|
+
response = await self._http_client.revoke_token(**options)
|
|
166
|
+
response.raise_for_status()
|
|
167
|
+
finally:
|
|
168
|
+
self._closed = True
|
|
169
|
+
try:
|
|
170
|
+
self._forget_credentials()
|
|
171
|
+
finally:
|
|
172
|
+
await self._close()
|
|
173
|
+
|
|
174
|
+
async def _request(self, method: str, url: str, **kwargs: Any) -> httpx2.Response:
|
|
175
|
+
self._bind_loop()
|
|
176
|
+
async with self._runtime_lock:
|
|
177
|
+
await self._login()
|
|
178
|
+
assert self._http_client is not None
|
|
179
|
+
return await self._http_client.request(
|
|
180
|
+
method, url, **self._request_options(kwargs)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def _app_callbacks(self) -> dict[str, Any]:
|
|
184
|
+
self._require_open()
|
|
185
|
+
loop = self._bind_loop()
|
|
186
|
+
|
|
187
|
+
def require_running_owner() -> None:
|
|
188
|
+
self._require_open()
|
|
189
|
+
if not loop.is_running():
|
|
190
|
+
raise RuntimeError("The AsyncClient owning event loop is not running.")
|
|
191
|
+
|
|
192
|
+
async def prepare() -> None:
|
|
193
|
+
require_running_owner()
|
|
194
|
+
await asyncio.wrap_future(
|
|
195
|
+
asyncio.run_coroutine_threadsafe(self.login(interactive=False), loop)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
async def request(method: str, url: str, **kwargs: Any) -> httpx2.Response:
|
|
199
|
+
require_running_owner()
|
|
200
|
+
return await asyncio.wrap_future(
|
|
201
|
+
asyncio.run_coroutine_threadsafe(
|
|
202
|
+
self._request(method, url, **kwargs), loop
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return dict(prepare=prepare, request=request)
|
|
207
|
+
|
|
208
|
+
@abstractmethod
|
|
209
|
+
async def get_process(self, process_id: str, **kwargs: Any) -> ProcessDescription:
|
|
210
|
+
"""Will be overridden by the actual client class."""
|
|
211
|
+
|
|
212
|
+
@abstractmethod
|
|
213
|
+
async def get_job(self, job_id: str, **kwargs: Any) -> JobInfo:
|
|
214
|
+
"""Will be overridden by the actual client class."""
|
|
215
|
+
|
|
216
|
+
@abstractmethod
|
|
217
|
+
async def get_job_results(self, job_id: str, **kwargs: Any) -> JobResults:
|
|
218
|
+
"""Will be overridden by the actual client class."""
|
|
219
|
+
|
|
220
|
+
async def create_execution_request(
|
|
221
|
+
self,
|
|
222
|
+
process_id: str,
|
|
223
|
+
dotpath: bool = False,
|
|
224
|
+
) -> ExecutionRequest:
|
|
225
|
+
"""
|
|
226
|
+
Create a template for an execution request
|
|
227
|
+
generated from the process description of the
|
|
228
|
+
given process identifier.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
process_id: The process identifier
|
|
232
|
+
dotpath: Whether to create dot-separated input
|
|
233
|
+
names for nested object values
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
The execution request template.
|
|
237
|
+
|
|
238
|
+
Raises:
|
|
239
|
+
ClientError: if an error occurs
|
|
240
|
+
"""
|
|
241
|
+
process_description = await self.get_process(process_id)
|
|
242
|
+
return ExecutionRequest.from_process_description(
|
|
243
|
+
process_description, dotpath=dotpath
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
async def open_job_result(
|
|
247
|
+
self,
|
|
248
|
+
job_id: str,
|
|
249
|
+
output_name: str | None = None,
|
|
250
|
+
data_type: type | None = None,
|
|
251
|
+
media_type: str | None = None,
|
|
252
|
+
poll_interval: float = DEFAULT_OPEN_JOB_JOB_POLL_INTERVAL,
|
|
253
|
+
timeout: float = DEFAULT_OPEN_JOB_RESULT_TIMEOUT,
|
|
254
|
+
**options: Any,
|
|
255
|
+
) -> Any:
|
|
256
|
+
"""Open the results of the job given by its ID.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
job_id: the job ID
|
|
260
|
+
output_name: the name of the output to be opened.
|
|
261
|
+
data_type: the expected/desired data type to be returned.
|
|
262
|
+
If provided, the return value will be of that type.
|
|
263
|
+
If not provided, the return value will be the type
|
|
264
|
+
decided by the opener.
|
|
265
|
+
media_type: the media type of the output produced.
|
|
266
|
+
Only needed, if the output does not provide its
|
|
267
|
+
media type or if its media type should be overridden.
|
|
268
|
+
poll_interval: interval in seconds between job status polls.
|
|
269
|
+
Applies while job status is still "accepted" or "running".
|
|
270
|
+
timeout: maximum time in seconds to wait for job completion.
|
|
271
|
+
options: additional opener-specific options.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
The job result value.
|
|
275
|
+
|
|
276
|
+
Raises:
|
|
277
|
+
ClientError: if an API error occurs
|
|
278
|
+
JobResultOpenError: if an opener error occurs
|
|
279
|
+
JobResultStatusError: if the job failed or was canceled
|
|
280
|
+
TimeoutError: if the job does not finish within the timeout
|
|
281
|
+
"""
|
|
282
|
+
deadline = asyncio.get_running_loop().time() + timeout
|
|
283
|
+
while True:
|
|
284
|
+
job_info = await self.get_job(job_id)
|
|
285
|
+
if job_info.status not in (JobStatus.accepted, JobStatus.running):
|
|
286
|
+
break
|
|
287
|
+
if asyncio.get_running_loop().time() >= deadline:
|
|
288
|
+
raise TimeoutError(
|
|
289
|
+
f"Cannot open result of job #{job_id}; "
|
|
290
|
+
f"it did not finish within {timeout} seconds"
|
|
291
|
+
)
|
|
292
|
+
await asyncio.sleep(poll_interval)
|
|
293
|
+
if job_info.status != JobStatus.successful:
|
|
294
|
+
raise JobResultStatusError(job_info)
|
|
295
|
+
job_results = await self.get_job_results(job_id)
|
|
296
|
+
process_id = job_info.processID
|
|
297
|
+
process_description: ProcessDescription | None = None
|
|
298
|
+
if process_id:
|
|
299
|
+
try:
|
|
300
|
+
process_description = await self.get_process(process_id)
|
|
301
|
+
except ClientError:
|
|
302
|
+
warnings.warn(
|
|
303
|
+
"An error occurred while getting process "
|
|
304
|
+
f"description for process ID {process_id!r}",
|
|
305
|
+
category=ClientWarning,
|
|
306
|
+
stacklevel=2,
|
|
307
|
+
)
|
|
308
|
+
ctx = JobResultOpenContext(
|
|
309
|
+
config=self.config,
|
|
310
|
+
job_id=job_id,
|
|
311
|
+
job_results=job_results,
|
|
312
|
+
process_description=process_description,
|
|
313
|
+
output_name=output_name,
|
|
314
|
+
data_type=data_type,
|
|
315
|
+
_media_type=media_type,
|
|
316
|
+
options=options,
|
|
317
|
+
)
|
|
318
|
+
opener_registry = self.config.get_job_result_opener_registry()
|
|
319
|
+
return await open_job_result(ctx, *opener_registry.opener_types)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Copyright (c) 2025-2026 by the Eozilla team and contributors
|
|
2
|
+
# Permissions are hereby granted under the terms of the Apache 2.0 License:
|
|
3
|
+
# https://opensource.org/license/apache-2.0.
|
|
4
|
+
|
|
5
|
+
from .config import (
|
|
6
|
+
ApiKeyAuthConfig,
|
|
7
|
+
AuthConfig,
|
|
8
|
+
AuthConfigBase,
|
|
9
|
+
AuthType,
|
|
10
|
+
BasicAuthConfig,
|
|
11
|
+
LoginAuthConfig,
|
|
12
|
+
NoAuthConfig,
|
|
13
|
+
OAuth2AuthConfig,
|
|
14
|
+
OAuth2GrantType,
|
|
15
|
+
OidcAuthConfig,
|
|
16
|
+
SecretFields,
|
|
17
|
+
TokenAuthConfig,
|
|
18
|
+
)
|
|
19
|
+
from .oauth2_client import LoginRequiredError
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"ApiKeyAuthConfig",
|
|
23
|
+
"AuthConfig",
|
|
24
|
+
"AuthConfigBase",
|
|
25
|
+
"AuthType",
|
|
26
|
+
"BasicAuthConfig",
|
|
27
|
+
"LoginAuthConfig",
|
|
28
|
+
"NoAuthConfig",
|
|
29
|
+
"OAuth2AuthConfig",
|
|
30
|
+
"OAuth2GrantType",
|
|
31
|
+
"OidcAuthConfig",
|
|
32
|
+
"SecretFields",
|
|
33
|
+
"TokenAuthConfig",
|
|
34
|
+
"LoginRequiredError",
|
|
35
|
+
]
|