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.
@@ -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"]