pendingai 0.0.1__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.
- pendingai-0.0.1/PKG-INFO +90 -0
- pendingai-0.0.1/README.md +70 -0
- pendingai-0.0.1/pyproject.toml +52 -0
- pendingai-0.0.1/src/pendingai/__init__.py +52 -0
- pendingai-0.0.1/src/pendingai/auth/__init__.py +38 -0
- pendingai-0.0.1/src/pendingai/auth/device_code.py +194 -0
- pendingai-0.0.1/src/pendingai/client/__init__.py +30 -0
- pendingai-0.0.1/src/pendingai/client/client.py +84 -0
- pendingai-0.0.1/src/pendingai/commands/__init__.py +43 -0
- pendingai-0.0.1/src/pendingai/commands/retro.py +709 -0
- pendingai-0.0.1/src/pendingai/context.py +100 -0
- pendingai-0.0.1/src/pendingai/main.py +254 -0
pendingai-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: pendingai
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Pending AI CLI Cheminformatics Platform tool
|
|
5
|
+
Author: Pending AI
|
|
6
|
+
Author-email: support@pending.ai
|
|
7
|
+
Requires-Python: >=3.8.0,<4.0.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Dist: httpx (>=0.27.2,<0.28.0)
|
|
16
|
+
Requires-Dist: pydantic (>=2.7.1,<3.0.0)
|
|
17
|
+
Requires-Dist: typer[all] (>=0.12.3,<0.13.0)
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# Pending.ai CLI
|
|
21
|
+
|
|
22
|
+
Owner: @JBercich
|
|
23
|
+
|
|
24
|
+
Command-line tool for using the Pending.ai Cheminformatics Platform.
|
|
25
|
+
|
|
26
|
+
- [`retro`](#retrosynthesis-platform) - Molecule retrosynthesis using machine-learning MCTS inference for generating synthesis routes.
|
|
27
|
+
|
|
28
|
+
## Getting Started
|
|
29
|
+
|
|
30
|
+
> [!NOTE]
|
|
31
|
+
> The pending.ai CLI will soon be available on PyPi.
|
|
32
|
+
|
|
33
|
+
### Build from Source
|
|
34
|
+
|
|
35
|
+
The CLI tool can be built using the `poetry` build tool. Clone the repository locally
|
|
36
|
+
and install the package for immediate use.
|
|
37
|
+
|
|
38
|
+
```shell
|
|
39
|
+
git clone https://github.com/pendingai/pendingai-cli.git
|
|
40
|
+
cd pendingai-cli
|
|
41
|
+
pip install poetry
|
|
42
|
+
poetry build
|
|
43
|
+
poetry install
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The CLI can be used by now running `pendingai --help` to display the available commands.
|
|
47
|
+
|
|
48
|
+
```shell
|
|
49
|
+
pendingai --version
|
|
50
|
+
> 0.0.1
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Authenticating the Client
|
|
54
|
+
|
|
55
|
+
> [!WARNING]
|
|
56
|
+
> To authenticate against different environments, use `pendingai --env <envname> login`
|
|
57
|
+
> to generate access tokens for different authenticate tenants.
|
|
58
|
+
|
|
59
|
+
Authorised platform access requires a valid set of user credentials. You can register a
|
|
60
|
+
device code using `pendingai login` to retrieve and cache an access token to use the
|
|
61
|
+
different services.
|
|
62
|
+
|
|
63
|
+
### Retrosynthesis Platform
|
|
64
|
+
|
|
65
|
+
```shell
|
|
66
|
+
pendingai retro --help
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The Pending.ai retrosynthesis service requires authentication credentials with attached
|
|
70
|
+
billing information to submit query molecules for synthesis. You will be notified if the
|
|
71
|
+
billed `query` request fails with no charge.
|
|
72
|
+
|
|
73
|
+
Below are some typical use cases:
|
|
74
|
+
|
|
75
|
+
```shell
|
|
76
|
+
# Inspect available engines and libraries for synthesis requests
|
|
77
|
+
pendingai retro engines
|
|
78
|
+
pendingai retro libraies
|
|
79
|
+
|
|
80
|
+
# Submit a query molecule and inspect the results
|
|
81
|
+
pendingai retro query --smi <molecule>
|
|
82
|
+
pendingai retro status --id <query-id>
|
|
83
|
+
pendingai retro view --id <query-id> --json > result.json
|
|
84
|
+
|
|
85
|
+
# Submit a file batch of results
|
|
86
|
+
pendingai retro query --batch-file <smi-filepath>
|
|
87
|
+
pendingai retro status --batch-file <ids-filepath>
|
|
88
|
+
pendingai retro view --batch-file <ids-filepath> --json > results.json
|
|
89
|
+
```
|
|
90
|
+
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Pending.ai CLI
|
|
2
|
+
|
|
3
|
+
Owner: @JBercich
|
|
4
|
+
|
|
5
|
+
Command-line tool for using the Pending.ai Cheminformatics Platform.
|
|
6
|
+
|
|
7
|
+
- [`retro`](#retrosynthesis-platform) - Molecule retrosynthesis using machine-learning MCTS inference for generating synthesis routes.
|
|
8
|
+
|
|
9
|
+
## Getting Started
|
|
10
|
+
|
|
11
|
+
> [!NOTE]
|
|
12
|
+
> The pending.ai CLI will soon be available on PyPi.
|
|
13
|
+
|
|
14
|
+
### Build from Source
|
|
15
|
+
|
|
16
|
+
The CLI tool can be built using the `poetry` build tool. Clone the repository locally
|
|
17
|
+
and install the package for immediate use.
|
|
18
|
+
|
|
19
|
+
```shell
|
|
20
|
+
git clone https://github.com/pendingai/pendingai-cli.git
|
|
21
|
+
cd pendingai-cli
|
|
22
|
+
pip install poetry
|
|
23
|
+
poetry build
|
|
24
|
+
poetry install
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The CLI can be used by now running `pendingai --help` to display the available commands.
|
|
28
|
+
|
|
29
|
+
```shell
|
|
30
|
+
pendingai --version
|
|
31
|
+
> 0.0.1
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Authenticating the Client
|
|
35
|
+
|
|
36
|
+
> [!WARNING]
|
|
37
|
+
> To authenticate against different environments, use `pendingai --env <envname> login`
|
|
38
|
+
> to generate access tokens for different authenticate tenants.
|
|
39
|
+
|
|
40
|
+
Authorised platform access requires a valid set of user credentials. You can register a
|
|
41
|
+
device code using `pendingai login` to retrieve and cache an access token to use the
|
|
42
|
+
different services.
|
|
43
|
+
|
|
44
|
+
### Retrosynthesis Platform
|
|
45
|
+
|
|
46
|
+
```shell
|
|
47
|
+
pendingai retro --help
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The Pending.ai retrosynthesis service requires authentication credentials with attached
|
|
51
|
+
billing information to submit query molecules for synthesis. You will be notified if the
|
|
52
|
+
billed `query` request fails with no charge.
|
|
53
|
+
|
|
54
|
+
Below are some typical use cases:
|
|
55
|
+
|
|
56
|
+
```shell
|
|
57
|
+
# Inspect available engines and libraries for synthesis requests
|
|
58
|
+
pendingai retro engines
|
|
59
|
+
pendingai retro libraies
|
|
60
|
+
|
|
61
|
+
# Submit a query molecule and inspect the results
|
|
62
|
+
pendingai retro query --smi <molecule>
|
|
63
|
+
pendingai retro status --id <query-id>
|
|
64
|
+
pendingai retro view --id <query-id> --json > result.json
|
|
65
|
+
|
|
66
|
+
# Submit a file batch of results
|
|
67
|
+
pendingai retro query --batch-file <smi-filepath>
|
|
68
|
+
pendingai retro status --batch-file <ids-filepath>
|
|
69
|
+
pendingai retro view --batch-file <ids-filepath> --json > results.json
|
|
70
|
+
```
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# pendingai-cli project configuration
|
|
2
|
+
|
|
3
|
+
[tool.poetry]
|
|
4
|
+
name = "pendingai"
|
|
5
|
+
description = "Pending AI CLI Cheminformatics Platform tool"
|
|
6
|
+
authors = ["Pending AI <support@pending.ai>"]
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
version = "0.0.1" # See `pending_cli/__init__.py`
|
|
9
|
+
|
|
10
|
+
[tool.poetry.dependencies]
|
|
11
|
+
python = "^3.8.0"
|
|
12
|
+
typer = { extras = ["all"], version = "^0.12.3" }
|
|
13
|
+
pydantic = "^2.7.1"
|
|
14
|
+
httpx = "^0.27.2"
|
|
15
|
+
|
|
16
|
+
[tool.poetry.group.test.dependencies]
|
|
17
|
+
pytest = "^8.1.1"
|
|
18
|
+
|
|
19
|
+
[tool.poetry.scripts]
|
|
20
|
+
# Adding an entry CLI command from a shell; naming can be changed by modifying the name
|
|
21
|
+
# of the script (and requires changes in the top-level repository README.md) to align;
|
|
22
|
+
# see typer docs for more info https://typer.tiangolo.com/tutorial/package/#add-a-script
|
|
23
|
+
pendingai = "pendingai.main:cli"
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["poetry-core"]
|
|
27
|
+
build-backend = "poetry.core.masonry.api"
|
|
28
|
+
|
|
29
|
+
[tool.mypy]
|
|
30
|
+
strict = false
|
|
31
|
+
ignore_missing_imports = true
|
|
32
|
+
no_implicit_optional = false
|
|
33
|
+
|
|
34
|
+
[tool.interrogate]
|
|
35
|
+
fail-under = 95
|
|
36
|
+
ignore-module = true
|
|
37
|
+
ignore-init-method = true
|
|
38
|
+
ignore-init-module = true
|
|
39
|
+
ignore-property-decorators = true
|
|
40
|
+
quiet = false
|
|
41
|
+
verbose = true
|
|
42
|
+
|
|
43
|
+
[tool.ruff]
|
|
44
|
+
line-length = 89
|
|
45
|
+
indent-width = 4
|
|
46
|
+
|
|
47
|
+
[tool.ruff.format]
|
|
48
|
+
docstring-code-format = true
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint.isort]
|
|
51
|
+
combine-as-imports = true
|
|
52
|
+
force-sort-within-sections = false
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding:utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import enum
|
|
5
|
+
import logging
|
|
6
|
+
import logging.config
|
|
7
|
+
import typing
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from rich.logging import RichHandler
|
|
11
|
+
|
|
12
|
+
__appname__: str = "pendingai"
|
|
13
|
+
__version__: str = "0.0.1"
|
|
14
|
+
|
|
15
|
+
logging.basicConfig(
|
|
16
|
+
level=logging.WARNING,
|
|
17
|
+
format="%(name)s %(message)s",
|
|
18
|
+
datefmt="[%X]",
|
|
19
|
+
handlers=[RichHandler(tracebacks_suppress=[httpx])],
|
|
20
|
+
)
|
|
21
|
+
logging.getLogger("httpcore.connection").setLevel(logging.WARNING)
|
|
22
|
+
logging.getLogger("httpcore.http11").setLevel(logging.WARNING)
|
|
23
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@enum.unique
|
|
27
|
+
class Environment(str, enum.Enum):
|
|
28
|
+
"""
|
|
29
|
+
Deployment environment used for building client connection strings,
|
|
30
|
+
authentication flows for the device or refresh tokens, and controls
|
|
31
|
+
cached state data between different environments
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
DEVELOPMENT = "dev"
|
|
35
|
+
STAGING = "stage"
|
|
36
|
+
PRODUCTION = "prod"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@enum.unique
|
|
40
|
+
class Command(str, enum.Enum):
|
|
41
|
+
"""
|
|
42
|
+
Runtime command used for naming commands and linking the client
|
|
43
|
+
service domain prefix.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
DOCKING = "docking"
|
|
47
|
+
GENERATOR = "generator"
|
|
48
|
+
RETRO = "retro"
|
|
49
|
+
RETROGRAPH = "retrograph"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
__all__: typing.List[str] = ["__appname__", "__version_", "Environment", "Command"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding:utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import typing
|
|
5
|
+
|
|
6
|
+
from pendingai import Environment
|
|
7
|
+
from pendingai.auth.device_code import DeviceCode, DeviceCodeAuthorization
|
|
8
|
+
|
|
9
|
+
# Environment-dependent constants are stored in dictionaries as runtime
|
|
10
|
+
# app logic uses an environment input optional to control access to the
|
|
11
|
+
# different resorce authentication tenants.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuthenticationEnvironment:
|
|
15
|
+
"""Container class for environment-dependent module constants."""
|
|
16
|
+
|
|
17
|
+
DOMAIN: typing.Dict[Environment, str] = {
|
|
18
|
+
Environment.DEVELOPMENT: "pendingai-dev.au.auth0.com",
|
|
19
|
+
Environment.STAGING: "pendingai-stage.au.auth0.com",
|
|
20
|
+
Environment.PRODUCTION: "pendingai.us.auth0.com",
|
|
21
|
+
}
|
|
22
|
+
CLIENT_ID: typing.Dict[Environment, str] = {
|
|
23
|
+
Environment.DEVELOPMENT: "GM1gfvGCnokIySbVO7vjmkRy4tVx5WYm",
|
|
24
|
+
Environment.STAGING: "PDWKoudtiP4WZV7aQt5YZbb5xlcmN6ju",
|
|
25
|
+
Environment.PRODUCTION: "dH69BCxGo4MyCcMWi64ZBq2YZx3UIoh1",
|
|
26
|
+
}
|
|
27
|
+
AUDIENCE: typing.Dict[Environment, str] = {
|
|
28
|
+
Environment.DEVELOPMENT: "api.dev.pending.ai/external-api",
|
|
29
|
+
Environment.STAGING: "api.stage.pending.ai/external-api",
|
|
30
|
+
Environment.PRODUCTION: "api.pending.ai/external-api",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__all__: typing.List[str] = [
|
|
35
|
+
"AuthenticationEnvironment",
|
|
36
|
+
"DeviceCodeAuthorization",
|
|
37
|
+
"DeviceCode",
|
|
38
|
+
]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding:utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import datetime
|
|
5
|
+
import time
|
|
6
|
+
import typing
|
|
7
|
+
import webbrowser
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import pydantic
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
_MAX_RETRIES: int = 10
|
|
14
|
+
|
|
15
|
+
std: Console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DeviceCode(pydantic.BaseModel):
|
|
19
|
+
"""Device code access token data model."""
|
|
20
|
+
|
|
21
|
+
access_token: str
|
|
22
|
+
refresh_token: str
|
|
23
|
+
id_token: str
|
|
24
|
+
token_type: str
|
|
25
|
+
expires_in: int
|
|
26
|
+
scope: str
|
|
27
|
+
|
|
28
|
+
@pydantic.computed_field # type: ignore
|
|
29
|
+
@property
|
|
30
|
+
def expires_at(self) -> int:
|
|
31
|
+
"""Computed field `expires_at`. Requires `expires_in`."""
|
|
32
|
+
return int(
|
|
33
|
+
(
|
|
34
|
+
datetime.datetime.utcnow() + datetime.timedelta(seconds=self.expires_in)
|
|
35
|
+
).timestamp()
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def is_expired(self) -> bool:
|
|
39
|
+
"""Check device code has reached `expires_at` timestamp."""
|
|
40
|
+
return self.expires_at < datetime.datetime.utcnow().timestamp()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DeviceCodeAuthorization:
|
|
44
|
+
"""
|
|
45
|
+
Device code authentication is used wrapping the Auth0 sdk flow. The
|
|
46
|
+
class instance will immediately execute the authorization process if
|
|
47
|
+
given a valid domain, audience and client, retriving a device code
|
|
48
|
+
token and id/refresh tokens.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
domain (str): Auth0 domain name for the authorization flow.
|
|
52
|
+
client_id (str): Application client id for a device code token.
|
|
53
|
+
audience (str): Application audience being authenticated.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
_scopes: str = "openid profile email offline_access"
|
|
57
|
+
_grant_type: str = "urn:ietf:params:oauth:grant-type:device_code"
|
|
58
|
+
|
|
59
|
+
def __init__(self, *, domain: str, client_id: str, audience: str):
|
|
60
|
+
self._domain: str = domain
|
|
61
|
+
self._client_id: str = client_id
|
|
62
|
+
self._audience: str = audience
|
|
63
|
+
|
|
64
|
+
def _request_device_code(self) -> httpx.Response:
|
|
65
|
+
"""
|
|
66
|
+
Request for a device code from Auth0. Scopes are defined at the
|
|
67
|
+
class level to include oidc response claims and an `id_token`,
|
|
68
|
+
and an `offline_access` scope to get a `refresh_token`.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
httpx.Response: Auth0 device code response.
|
|
72
|
+
"""
|
|
73
|
+
return httpx.post(
|
|
74
|
+
url=f"https://{self._domain}/oauth/device/code",
|
|
75
|
+
data={
|
|
76
|
+
"client_id": self._client_id,
|
|
77
|
+
"audience": self._audience,
|
|
78
|
+
"scope": self._scopes,
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def _request_access_code(self, device_code: str) -> httpx.Response:
|
|
83
|
+
"""
|
|
84
|
+
Request for an access token from Auth0 using the device code
|
|
85
|
+
authorization flow. A `refresh_token` and `id_token` should also
|
|
86
|
+
be returned by Auth0.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
device_code (str): Device code received from requesting the
|
|
90
|
+
authorization flow from Auth0.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
httpx.Response: Auth0 access token response.
|
|
94
|
+
"""
|
|
95
|
+
return httpx.post(
|
|
96
|
+
url=f"https://{self._domain}/oauth/token",
|
|
97
|
+
data={
|
|
98
|
+
"client_id": self._client_id,
|
|
99
|
+
"audience": self._audience,
|
|
100
|
+
"grant_type": self._grant_type,
|
|
101
|
+
"device_code": device_code,
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _request_refresh_token(self, refresh_token: str) -> httpx.Response:
|
|
106
|
+
"""
|
|
107
|
+
Request for a refresh token from Auth0 using the device code
|
|
108
|
+
authorization flow. A `refresh_token` and `id_token` should also
|
|
109
|
+
be returned by Auth0 from the token rotation setting.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
refresh_token (str): Refresh token.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
httpx.Response: Auth0 refresh token response.
|
|
116
|
+
"""
|
|
117
|
+
return httpx.post(
|
|
118
|
+
url=f"https://{self._domain}/oauth/token",
|
|
119
|
+
data={
|
|
120
|
+
"client_id": self._client_id,
|
|
121
|
+
"audience": self._audience,
|
|
122
|
+
"grant_type": "refresh_token",
|
|
123
|
+
"refresh_token": refresh_token,
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def execute_flow(self) -> DeviceCode:
|
|
128
|
+
"""
|
|
129
|
+
Execute the device code authentication flow to obtain a token.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
RuntimeError: Authentication failed due to tenant details.
|
|
133
|
+
RuntimeError: Authentication failed due to unknown errors.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
DeviceCode: New device code token.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
# Generate the device code, request and process response
|
|
140
|
+
device_code_response: httpx.Response = self._request_device_code()
|
|
141
|
+
if device_code_response.status_code != 200:
|
|
142
|
+
raise RuntimeError("Failed authenticating device, unauthorized access")
|
|
143
|
+
device_code_data: typing.Dict[str, typing.Any] = device_code_response.json()
|
|
144
|
+
|
|
145
|
+
# Redirect to the authentication portal for the device
|
|
146
|
+
uri: str = device_code_data["verification_uri_complete"]
|
|
147
|
+
std.print("1. On your computer or mobile device navigate to: ", uri)
|
|
148
|
+
std.print("2. Enter the following code: ", device_code_data["user_code"])
|
|
149
|
+
time.sleep(2)
|
|
150
|
+
webbrowser.open(uri)
|
|
151
|
+
|
|
152
|
+
# Ping Auth0 for the access token requiring user authentication
|
|
153
|
+
retries: int = 0
|
|
154
|
+
authenticated: bool = False
|
|
155
|
+
device_code: str = device_code_data["device_code"]
|
|
156
|
+
while not authenticated:
|
|
157
|
+
if retries == _MAX_RETRIES:
|
|
158
|
+
std.print("Authentication timed out with maximum retries, try again.")
|
|
159
|
+
raise RuntimeError("Maximum retries timed out device authorization")
|
|
160
|
+
std.print("Checking for completed device authentication...")
|
|
161
|
+
access_code_response: httpx.Response = self._request_access_code(device_code)
|
|
162
|
+
if access_code_response.status_code == 200:
|
|
163
|
+
authenticated = True
|
|
164
|
+
std.print("Successfully logged into the Pending.ai Platform")
|
|
165
|
+
return DeviceCode.model_validate(access_code_response.json())
|
|
166
|
+
else:
|
|
167
|
+
error_data: typing.Dict[str, typing.Any] = access_code_response.json()
|
|
168
|
+
if error_data["error"] == "authorization_pending":
|
|
169
|
+
time.sleep(device_code_data["interval"])
|
|
170
|
+
retries += 1
|
|
171
|
+
continue
|
|
172
|
+
raise RuntimeError(error_data["error_description"])
|
|
173
|
+
raise RuntimeError("Unable to authenticate user")
|
|
174
|
+
|
|
175
|
+
def refresh_token(self, refresh_token: str) -> DeviceCode:
|
|
176
|
+
"""
|
|
177
|
+
Execute the refresh token authentication flow to obtain a token.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
refresh_token (str): Refresh token being refreshed.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
DeviceCode: New device code token.
|
|
184
|
+
"""
|
|
185
|
+
refresh_response: httpx.Response = self._request_refresh_token(refresh_token)
|
|
186
|
+
try:
|
|
187
|
+
if refresh_response.status_code == 200:
|
|
188
|
+
return DeviceCode.model_validate(refresh_response.json())
|
|
189
|
+
except pydantic.ValidationError:
|
|
190
|
+
pass
|
|
191
|
+
raise RuntimeError("Failed to refresh access token")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
__all__: typing.List[str] = ["DeviceCode", "DeviceCodeAuthorization"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding:utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import typing
|
|
5
|
+
|
|
6
|
+
from pendingai import Command, Environment
|
|
7
|
+
from pendingai.client.client import Client
|
|
8
|
+
|
|
9
|
+
# Environment-dependent constants are stored in dictionaries as runtime
|
|
10
|
+
# app logic uses an environment input optional to control access to the
|
|
11
|
+
# different resorce client domains.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ClientEnvironment:
|
|
15
|
+
"""Container class for environment-dependent module constants."""
|
|
16
|
+
|
|
17
|
+
DOMAIN: typing.Dict[Environment, str] = {
|
|
18
|
+
Environment.DEVELOPMENT: "https://api.dev.pending.ai/",
|
|
19
|
+
Environment.STAGING: "https://api.stage.pending.ai/",
|
|
20
|
+
Environment.PRODUCTION: "https://api.pending.ai/",
|
|
21
|
+
}
|
|
22
|
+
SERVICE_DOMAIN: typing.Dict[Command, str] = {
|
|
23
|
+
Command.DOCKING: "/docking/v1/",
|
|
24
|
+
Command.GENERATOR: "/generator/v1/",
|
|
25
|
+
Command.RETRO: "/retro/v2/",
|
|
26
|
+
Command.RETROGRAPH: "/retrograph/v1/",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__: typing.List[str] = ["ClientEnvironment", "Client"]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding:utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import typing
|
|
5
|
+
from urllib.parse import urljoin
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Client:
|
|
11
|
+
"""
|
|
12
|
+
HTTPX client wrapper class for controlling requests to the external
|
|
13
|
+
service APIs. Basic method control is managed through http methods
|
|
14
|
+
for a specified domain determined by the app runtime environment.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
domain (str): Environment-dependent app domain url.
|
|
18
|
+
service_domain (str): Service prefix domain and version.
|
|
19
|
+
access_token (str, optional): Token for authenticated requests.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
_timeout: int = 10
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
domain: str,
|
|
28
|
+
service_domain: str,
|
|
29
|
+
access_token: typing.Optional[str] = None,
|
|
30
|
+
):
|
|
31
|
+
self._domain: str = domain
|
|
32
|
+
self._service_domain: str = service_domain
|
|
33
|
+
self._access_token: typing.Optional[str] = access_token
|
|
34
|
+
self._client: httpx.Client = self._setup_client()
|
|
35
|
+
|
|
36
|
+
def _setup_client(self) -> httpx.Client:
|
|
37
|
+
"""
|
|
38
|
+
Initialisation of the HTTPX client instance for the specific
|
|
39
|
+
service domain. The optional access token header can be given
|
|
40
|
+
as the header fields used in all requests to the domain.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
httpx.Client: Initialised HTTPX client instance.
|
|
44
|
+
"""
|
|
45
|
+
headers: typing.Dict[str, str] = {}
|
|
46
|
+
if self._access_token is not None:
|
|
47
|
+
headers["Authorization"] = f"Bearer {self._access_token}"
|
|
48
|
+
url: str = urljoin(self._domain, self._service_domain)
|
|
49
|
+
return httpx.Client(base_url=url, headers=headers, timeout=self._timeout)
|
|
50
|
+
|
|
51
|
+
def __del__(self) -> None:
|
|
52
|
+
"""Client connection is closed once the instance is OOM."""
|
|
53
|
+
self._client.close()
|
|
54
|
+
|
|
55
|
+
def get(self, *args, **kwargs) -> httpx.Response:
|
|
56
|
+
"""Client method wrapper for making a `GET` request."""
|
|
57
|
+
return self._client.get(*args, **kwargs)
|
|
58
|
+
|
|
59
|
+
def post(self, *args, **kwargs) -> httpx.Response:
|
|
60
|
+
"""Client method wrapper for making a `POST` request."""
|
|
61
|
+
return self._client.post(*args, **kwargs)
|
|
62
|
+
|
|
63
|
+
def patch(self, *args, **kwargs) -> httpx.Response:
|
|
64
|
+
"""Client method wrapper for making a `PATCH` request."""
|
|
65
|
+
return self._client.patch(*args, **kwargs)
|
|
66
|
+
|
|
67
|
+
def put(self, *args, **kwargs) -> httpx.Response:
|
|
68
|
+
"""Client method wrapper for making a `PUT` request."""
|
|
69
|
+
return self._client.put(*args, **kwargs)
|
|
70
|
+
|
|
71
|
+
def delete(self, *args, **kwargs) -> httpx.Response:
|
|
72
|
+
"""Client method wrapper for making a `DELETE` request."""
|
|
73
|
+
return self._client.delete(*args, **kwargs)
|
|
74
|
+
|
|
75
|
+
def options(self, *args, **kwargs) -> httpx.Response:
|
|
76
|
+
"""Client method wrapper for making a `OPTIONS` request."""
|
|
77
|
+
return self._client.options(*args, **kwargs)
|
|
78
|
+
|
|
79
|
+
def head(self, *args, **kwargs) -> httpx.Response:
|
|
80
|
+
"""Client method wrapper for making a `HEAD` request."""
|
|
81
|
+
return self._client.head(*args, **kwargs)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
__all__: typing.List[str] = ["Client"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding:utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import typing
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from pendingai import Command
|
|
11
|
+
from pendingai.client import Client, ClientEnvironment
|
|
12
|
+
|
|
13
|
+
std: Console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def add_client_callback(ctx: typer.Context):
|
|
17
|
+
"""
|
|
18
|
+
Auxiliary callback method for calling command callback methods with
|
|
19
|
+
a static service domain determined by the `Command` set in `main.py`
|
|
20
|
+
and verified access permission by hitting the service `/alive`.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
ctx (typer.Context): App runtime context.
|
|
24
|
+
"""
|
|
25
|
+
command: Command = Command._value2member_map_[ctx.command.name] # type: ignore
|
|
26
|
+
ctx.obj.client = Client(
|
|
27
|
+
domain=ClientEnvironment.DOMAIN[ctx.obj.environment],
|
|
28
|
+
service_domain=ClientEnvironment.SERVICE_DOMAIN[command],
|
|
29
|
+
access_token=ctx.obj.access_token,
|
|
30
|
+
)
|
|
31
|
+
response: httpx.Reponse = ctx.obj.client.get("/alive")
|
|
32
|
+
if response.status_code != 200:
|
|
33
|
+
std.print(
|
|
34
|
+
f"User is not authorized for the '{ctx.command.name}' Platform. "
|
|
35
|
+
+ "Try logging in again. If the problem is unexpected, contact support "
|
|
36
|
+
+ "services (see <pendingai --help>).",
|
|
37
|
+
width=90,
|
|
38
|
+
highlight=False,
|
|
39
|
+
)
|
|
40
|
+
raise typer.Exit(2)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
__all__: typing.List[str] = ["add_client_callback"]
|