klio-compute 0.5.4__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.
- klio_compute/__init__.py +57 -0
- klio_compute/__main__.py +3 -0
- klio_compute/_branding.py +63 -0
- klio_compute/_compat_cli/__init__.py +31 -0
- klio_compute/_compat_cli/_app_cmd.py +111 -0
- klio_compute/_compat_cli/_curl.py +85 -0
- klio_compute/_compat_cli/_dashboard.py +17 -0
- klio_compute/_compat_cli/_deploy.py +125 -0
- klio_compute/_compat_cli/_loader.py +91 -0
- klio_compute/_compat_cli/_migrate.py +364 -0
- klio_compute/_compat_cli/_run.py +190 -0
- klio_compute/_compat_cli/_secret_cmd.py +153 -0
- klio_compute/_compat_cli/_shell.py +37 -0
- klio_compute/_compat_cli/_volume_cmd.py +85 -0
- klio_compute/_connect.py +63 -0
- klio_compute/_distribution.py +33 -0
- klio_compute/_errors.py +93 -0
- klio_compute/_gpu_access.py +84 -0
- klio_compute/_images.py +74 -0
- klio_compute/_model_cli.py +234 -0
- klio_compute/_model_wait.py +243 -0
- klio_compute/_models.py +118 -0
- klio_compute/_settings.py +59 -0
- klio_compute/_unavailable.py +45 -0
- klio_compute/cli.py +67 -0
- klio_compute/compat/__init__.py +107 -0
- klio_compute/compat/_aio.py +45 -0
- klio_compute/compat/_app.py +142 -0
- klio_compute/compat/_build_function.py +69 -0
- klio_compute/compat/_bundle.py +70 -0
- klio_compute/compat/_calls.py +122 -0
- klio_compute/compat/_cls.py +183 -0
- klio_compute/compat/_concurrent.py +104 -0
- klio_compute/compat/_container.py +313 -0
- klio_compute/compat/_dict.py +175 -0
- klio_compute/compat/_envelope.py +116 -0
- klio_compute/compat/_fanout.py +105 -0
- klio_compute/compat/_function.py +134 -0
- klio_compute/compat/_gpu_names.py +15 -0
- klio_compute/compat/_image.py +297 -0
- klio_compute/compat/_live.py +300 -0
- klio_compute/compat/_logs.py +101 -0
- klio_compute/compat/_marks.py +159 -0
- klio_compute/compat/_names.py +45 -0
- klio_compute/compat/_nested.py +77 -0
- klio_compute/compat/_options.py +257 -0
- klio_compute/compat/_platform.py +70 -0
- klio_compute/compat/_queue.py +132 -0
- klio_compute/compat/_refuse.py +90 -0
- klio_compute/compat/_retry.py +29 -0
- klio_compute/compat/_sandbox.py +149 -0
- klio_compute/compat/_schedule.py +179 -0
- klio_compute/compat/_secret.py +188 -0
- klio_compute/compat/_stores.py +79 -0
- klio_compute/compat/_stubs.py +162 -0
- klio_compute/compat/_volume.py +135 -0
- klio_compute/compat/_volume_io.py +30 -0
- klio_compute/compat/_web.py +37 -0
- klio_compute/compat/exception.py +82 -0
- klio_compute/py.typed +0 -0
- klio_compute-0.5.4.dist-info/METADATA +142 -0
- klio_compute-0.5.4.dist-info/RECORD +66 -0
- klio_compute-0.5.4.dist-info/WHEEL +5 -0
- klio_compute-0.5.4.dist-info/entry_points.txt +2 -0
- klio_compute-0.5.4.dist-info/licenses/LICENSE +661 -0
- klio_compute-0.5.4.dist-info/top_level.txt +1 -0
klio_compute/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Klio Compute SDK.
|
|
2
|
+
|
|
3
|
+
Everything the underlying SDK exports publicly is available here::
|
|
4
|
+
|
|
5
|
+
from klio_compute import function, Image
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib import import_module
|
|
9
|
+
|
|
10
|
+
import beta9 as _sdk
|
|
11
|
+
from beta9.config import set_settings as _set_settings
|
|
12
|
+
|
|
13
|
+
from ._branding import install_terminal_wording as _install_terminal_wording
|
|
14
|
+
from ._connect import install_connect_timeout as _install_connect_timeout
|
|
15
|
+
from ._distribution import installed_version as _installed_version
|
|
16
|
+
from ._errors import install_error_reports as _install_error_reports
|
|
17
|
+
from ._gpu_access import install_gpu_access_check as _install_gpu_access_check
|
|
18
|
+
from ._images import Image as _Image
|
|
19
|
+
from ._settings import in_platform_container as _in_platform_container
|
|
20
|
+
from ._settings import klio_settings as _klio_settings
|
|
21
|
+
|
|
22
|
+
# Customer code imports this package inside containers too, where the platform's runner already
|
|
23
|
+
# has what it needs from the environment and the SDK is mounted with no package metadata.
|
|
24
|
+
if not _in_platform_container():
|
|
25
|
+
_install_connect_timeout()
|
|
26
|
+
_install_terminal_wording()
|
|
27
|
+
_install_error_reports()
|
|
28
|
+
_install_gpu_access_check()
|
|
29
|
+
# Point the SDK at Klio Compute before any of its code reads its settings.
|
|
30
|
+
_set_settings(_klio_settings())
|
|
31
|
+
|
|
32
|
+
__version__ = _installed_version()
|
|
33
|
+
|
|
34
|
+
__all__ = list(_sdk.__all__)
|
|
35
|
+
|
|
36
|
+
# Names that Klio replaces with its own version of the SDK's.
|
|
37
|
+
_OVERRIDES = {"Image": _Image}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def __getattr__(name):
|
|
41
|
+
if name in _OVERRIDES:
|
|
42
|
+
globals()[name] = _OVERRIDES[name]
|
|
43
|
+
return _OVERRIDES[name]
|
|
44
|
+
if name in _sdk.__all__:
|
|
45
|
+
value = getattr(_sdk, name)
|
|
46
|
+
globals()[name] = value
|
|
47
|
+
return value
|
|
48
|
+
try:
|
|
49
|
+
return import_module(f"{__name__}.{name}")
|
|
50
|
+
except ModuleNotFoundError as error:
|
|
51
|
+
if error.name != f"{__name__}.{name}":
|
|
52
|
+
raise
|
|
53
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def __dir__():
|
|
57
|
+
return sorted(set(globals()) | set(__all__))
|
klio_compute/__main__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Customer-facing wording: the SDK prints a few lines that name itself.
|
|
2
|
+
|
|
3
|
+
The SDK writes everything the customer reads through ``beta9.terminal``. Its functions are
|
|
4
|
+
looked up on that module at call time, so wrapping them here rewrites the handful of upstream
|
|
5
|
+
names (the token variable in the endpoint's curl example, a ``beta9 <command>`` hint) without
|
|
6
|
+
touching any other behaviour. Paths, URLs and module names are left alone.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import functools
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any, Callable, Tuple
|
|
12
|
+
|
|
13
|
+
import beta9.terminal as sdk_terminal
|
|
14
|
+
|
|
15
|
+
TOKEN_ENV_VAR = "KLIO_TOKEN"
|
|
16
|
+
|
|
17
|
+
_NOT_A_NAME_PART = r"[\w./-]"
|
|
18
|
+
|
|
19
|
+
# Most specific first: a later rule must not see text an earlier one already replaced.
|
|
20
|
+
_RULES: Tuple[Tuple["re.Pattern[str]", str], ...] = (
|
|
21
|
+
(re.compile(r"\$\{BETA9_TOKEN:\?[^}]*\}"), "${KLIO_TOKEN:?Set KLIO_TOKEN to your Klio API key}"),
|
|
22
|
+
(
|
|
23
|
+
re.compile(r"Set the token from your selected profile:"),
|
|
24
|
+
"Set your Klio API key (create one in the console under Keys):",
|
|
25
|
+
),
|
|
26
|
+
(re.compile(r"<your-profile-token>"), "<your-klio-key>"),
|
|
27
|
+
(re.compile(r"\bBETA9_TOKEN\b"), TOKEN_ENV_VAR),
|
|
28
|
+
(re.compile(rf"(?<!{_NOT_A_NAME_PART})beta9(?!{_NOT_A_NAME_PART})"), "klio"),
|
|
29
|
+
(re.compile(rf"(?<!{_NOT_A_NAME_PART})Beta9(?!{_NOT_A_NAME_PART})"), "Klio"),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Lines about the platform's own capacity: nothing a customer can act on, so they are not shown.
|
|
33
|
+
_NOT_SHOWN = re.compile(r"^GPU capacity for .* is currently")
|
|
34
|
+
|
|
35
|
+
# Every output function of the SDK's terminal that takes customer-visible text.
|
|
36
|
+
_WRAPPED = ("print", "detail", "debug", "warn", "success", "error", "header")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def rewrite(text: Any) -> Any:
|
|
40
|
+
"""The text as Klio's customer should read it; anything that is not a string passes through."""
|
|
41
|
+
if not isinstance(text, str):
|
|
42
|
+
return text
|
|
43
|
+
for pattern, replacement in _RULES:
|
|
44
|
+
text = pattern.sub(replacement, text)
|
|
45
|
+
return text
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _wrap(function: Callable) -> Callable:
|
|
49
|
+
@functools.wraps(function)
|
|
50
|
+
def wrapper(*args, **kwargs):
|
|
51
|
+
if args and isinstance(args[0], str) and _NOT_SHOWN.match(args[0]):
|
|
52
|
+
return None
|
|
53
|
+
return function(*(rewrite(arg) for arg in args), **kwargs)
|
|
54
|
+
|
|
55
|
+
wrapper._klio_wrapped = True # type: ignore[attr-defined]
|
|
56
|
+
return wrapper
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def install_terminal_wording() -> None:
|
|
60
|
+
for name in _WRAPPED:
|
|
61
|
+
function = getattr(sdk_terminal, name)
|
|
62
|
+
if not getattr(function, "_klio_wrapped", False):
|
|
63
|
+
setattr(sdk_terminal, name, _wrap(function))
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""CLI verbs for Modal-style apps. Existing `klio` commands and their forms keep working as they did."""
|
|
2
|
+
|
|
3
|
+
from typing import Iterator, Tuple
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from . import _app_cmd, _curl, _dashboard, _deploy, _migrate, _run, _secret_cmd, _shell, _volume_cmd
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _command_holders(cli) -> Iterator[Tuple[click.Group, str]]:
|
|
11
|
+
for source in cli.common_group.sources:
|
|
12
|
+
for name in list(getattr(source, "commands", {})):
|
|
13
|
+
yield source, name
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _replace(cli, name: str, wrap) -> None:
|
|
17
|
+
for group, command_name in _command_holders(cli):
|
|
18
|
+
if command_name == name:
|
|
19
|
+
group.commands[name] = wrap(group.commands[name])
|
|
20
|
+
return
|
|
21
|
+
raise RuntimeError(f"expected the SDK to have a command named {name}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def install(cli) -> None:
|
|
25
|
+
_replace(cli, "run", _run.wrap)
|
|
26
|
+
_replace(cli, "deploy", _deploy.wrap)
|
|
27
|
+
_replace(cli, "shell", _shell.wrap)
|
|
28
|
+
_replace(cli, "secret", _secret_cmd.extend)
|
|
29
|
+
_replace(cli, "volume", _volume_cmd.extend)
|
|
30
|
+
for command in (_app_cmd.app, _curl.curl, _dashboard.dashboard, _migrate.migrate):
|
|
31
|
+
cli.management_group.add_command(command)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""`klio app`: deployments grouped into apps. The platform's own list is one line per function."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from beta9 import terminal
|
|
8
|
+
from rich.table import Column, Table, box
|
|
9
|
+
|
|
10
|
+
from ..compat import _platform
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _app_name_of(deployment: Any) -> str:
|
|
14
|
+
"""Stubs from `klio deploy app.py` are named <type>/<app>.<function>. Others are their own app."""
|
|
15
|
+
last = deployment.stub_name.rsplit("/", 1)[-1]
|
|
16
|
+
if "." in last and ":" not in last:
|
|
17
|
+
return last.split(".", 1)[0]
|
|
18
|
+
return deployment.name
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _apps() -> Dict[str, List[Any]]:
|
|
22
|
+
grouped: Dict[str, List[Any]] = {}
|
|
23
|
+
for deployment in _platform.current().deployment_rows():
|
|
24
|
+
if deployment.active:
|
|
25
|
+
grouped.setdefault(_app_name_of(deployment), []).append(deployment)
|
|
26
|
+
return grouped
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _latest_versions(deployments: List[Any]) -> List[Any]:
|
|
30
|
+
"""Earlier versions stay active while they drain. What an app is, is the newest of each."""
|
|
31
|
+
latest: Dict[str, Any] = {}
|
|
32
|
+
for deployment in deployments:
|
|
33
|
+
if deployment.name not in latest or deployment.version > latest[deployment.name].version:
|
|
34
|
+
latest[deployment.name] = deployment
|
|
35
|
+
return list(latest.values())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _app_or_exit(name: str) -> List[Any]:
|
|
39
|
+
deployments = _apps().get(name)
|
|
40
|
+
if not deployments:
|
|
41
|
+
raise click.ClickException(f"No app named {name} is running. See `klio app list`.")
|
|
42
|
+
return deployments
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _run_logs_command(arguments: List[str]) -> None:
|
|
46
|
+
from beta9.cli import logs as sdk_logs
|
|
47
|
+
|
|
48
|
+
command = sdk_logs.common.commands["logs"]
|
|
49
|
+
parent = click.get_current_context()
|
|
50
|
+
delegated = command.make_context("logs", arguments, parent=parent)
|
|
51
|
+
with delegated:
|
|
52
|
+
command.invoke(delegated)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _web_url(deployment: Any) -> Optional[str]:
|
|
56
|
+
if "asgi" not in deployment.stub_type and "endpoint" not in deployment.stub_type:
|
|
57
|
+
return None
|
|
58
|
+
return _platform.current().web_url(deployment)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_group(run_logs: Callable[[List[str]], None] = _run_logs_command) -> click.Group:
|
|
62
|
+
@click.group("app", help="List, inspect, stop and read the logs of apps.")
|
|
63
|
+
def app() -> None:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
@app.command("list", help="List running apps.")
|
|
67
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
68
|
+
def list_apps(as_json: bool) -> None:
|
|
69
|
+
apps = _apps()
|
|
70
|
+
if as_json:
|
|
71
|
+
rows = [{"name": name, "app_id": items[0].app_id, "deployments": len(_latest_versions(items))} for name, items in sorted(apps.items())]
|
|
72
|
+
click.echo(json.dumps(rows))
|
|
73
|
+
return
|
|
74
|
+
table = Table(Column("App"), Column("App id"), Column("Deployments"), box=box.SIMPLE)
|
|
75
|
+
for name, items in sorted(apps.items()):
|
|
76
|
+
table.add_row(name, items[0].app_id, str(len(_latest_versions(items))))
|
|
77
|
+
terminal.print(table)
|
|
78
|
+
|
|
79
|
+
@app.command("info", help="Show an app's deployments and addresses.")
|
|
80
|
+
@click.argument("name")
|
|
81
|
+
def info(name: str) -> None:
|
|
82
|
+
for deployment in _latest_versions(_app_or_exit(name)):
|
|
83
|
+
url = _web_url(deployment)
|
|
84
|
+
click.echo(f"{deployment.name} v{deployment.version} {deployment.id}" + (f" {url}" if url else ""))
|
|
85
|
+
|
|
86
|
+
@app.command("stop", help="Stop every deployment of an app.")
|
|
87
|
+
@click.argument("name")
|
|
88
|
+
@click.option("--yes", "-y", is_flag=True, help="Don't ask first.")
|
|
89
|
+
def stop(name: str, yes: bool) -> None:
|
|
90
|
+
deployments = _app_or_exit(name)
|
|
91
|
+
if not yes:
|
|
92
|
+
click.confirm(f"Stop {name} ({len(deployments)} deployments)?", abort=True)
|
|
93
|
+
platform = _platform.current()
|
|
94
|
+
for deployment in deployments:
|
|
95
|
+
platform.stop_deployment(deployment.id)
|
|
96
|
+
click.echo(f"Stopped {name}.")
|
|
97
|
+
|
|
98
|
+
@app.command(
|
|
99
|
+
"logs",
|
|
100
|
+
help="Read an app's logs. Takes the options of `klio logs`.",
|
|
101
|
+
context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
|
|
102
|
+
)
|
|
103
|
+
@click.argument("name")
|
|
104
|
+
@click.argument("options", nargs=-1, type=click.UNPROCESSED)
|
|
105
|
+
def logs(name: str, options: List[str]) -> None:
|
|
106
|
+
run_logs(["--app-id", _app_or_exit(name)[0].app_id, *options])
|
|
107
|
+
|
|
108
|
+
return app
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
app = build_group()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""`klio curl`: calls a private endpoint with your key, and only ever sends it to an endpoint's own address."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
ENDPOINT_DOMAIN = ".run.klio.tech"
|
|
12
|
+
# Only these are passed on. Anything else could send the request, and with it the key, somewhere else.
|
|
13
|
+
FLAGS = set("sSfiIvgG")
|
|
14
|
+
LONG_FLAGS = {
|
|
15
|
+
"--silent", "--show-error", "--fail", "--include", "--head", "--verbose", "--globoff", "--get", "--compressed",
|
|
16
|
+
"--no-buffer",
|
|
17
|
+
} # fmt: skip
|
|
18
|
+
OPTIONS_WITH_VALUE = {
|
|
19
|
+
"-X", "--request", "-H", "--header", "-d", "--data", "--data-raw", "--data-binary", "--data-urlencode", "--json",
|
|
20
|
+
"-F", "--form", "-o", "--output", "-w", "--write-out", "-m", "--max-time", "--connect-timeout", "-A", "--user-agent",
|
|
21
|
+
} # fmt: skip
|
|
22
|
+
# Before the user's arguments and after our checks: https only, no redirects, no proxy from the environment.
|
|
23
|
+
PINNED = ["--proto", "=https", "--max-redirs", "0", "--noproxy", "*"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _token() -> str:
|
|
27
|
+
from beta9.abstractions.base import get_channel
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
return get_channel().config.token or ""
|
|
31
|
+
except Exception: # no saved login: the message below says what to do
|
|
32
|
+
return ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_endpoint_url(url: str) -> bool:
|
|
36
|
+
parts = urlsplit(url)
|
|
37
|
+
host = parts.hostname or ""
|
|
38
|
+
return parts.scheme == "https" and host.endswith(ENDPOINT_DOMAIN) and not parts.username and not parts.password
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _refuse_option(option: str) -> None:
|
|
42
|
+
raise click.ClickException(f"klio curl doesn't take {option}, so that your key only goes to the endpoint you name.")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _check(arguments: List[str]) -> str:
|
|
46
|
+
"""The URL these arguments call. Everything else must be an option known to stay on that URL."""
|
|
47
|
+
url: Optional[str] = None
|
|
48
|
+
remaining = iter(arguments)
|
|
49
|
+
for argument in remaining:
|
|
50
|
+
option = argument.split("=", 1)[0]
|
|
51
|
+
if option in OPTIONS_WITH_VALUE:
|
|
52
|
+
if "=" not in argument or not option.startswith("--"):
|
|
53
|
+
next(remaining, None)
|
|
54
|
+
elif option in LONG_FLAGS or (option.startswith("-") and not option.startswith("--") and set(option[1:]) <= FLAGS and len(option) > 1):
|
|
55
|
+
continue
|
|
56
|
+
elif option.startswith("-"):
|
|
57
|
+
_refuse_option(option)
|
|
58
|
+
elif url is None and _is_endpoint_url(argument):
|
|
59
|
+
url = argument
|
|
60
|
+
else:
|
|
61
|
+
raise click.ClickException(f"klio curl takes one address, and only sends your key to https://*{ENDPOINT_DOMAIN}.")
|
|
62
|
+
if url is None:
|
|
63
|
+
raise click.ClickException(f"Give the endpoint's address: klio curl https://NAME{ENDPOINT_DOMAIN}/path")
|
|
64
|
+
return url
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@click.command(
|
|
68
|
+
"curl",
|
|
69
|
+
help="Call a private endpoint with your key. Takes curl's options.",
|
|
70
|
+
context_settings={"ignore_unknown_options": True, "allow_extra_args": True, "help_option_names": []},
|
|
71
|
+
)
|
|
72
|
+
@click.argument("arguments", nargs=-1, type=click.UNPROCESSED)
|
|
73
|
+
def curl(arguments: List[str]) -> None:
|
|
74
|
+
arguments = list(arguments)
|
|
75
|
+
_check(arguments)
|
|
76
|
+
token = os.environ.get("KLIO_TOKEN") or _token()
|
|
77
|
+
if not token:
|
|
78
|
+
raise click.ClickException("You aren't signed in. Run `klio login`, or set KLIO_TOKEN.")
|
|
79
|
+
binary = shutil.which("curl")
|
|
80
|
+
if not binary:
|
|
81
|
+
raise click.ClickException("curl isn't installed on this machine.")
|
|
82
|
+
# Through a config on stdin, so the key is never in the process list.
|
|
83
|
+
config = f'header = "Authorization: Bearer {token}"\n'
|
|
84
|
+
done = subprocess.run([binary, "-K", "-", *PINNED, *arguments], input=config, text=True)
|
|
85
|
+
click.get_current_context().exit(done.returncode)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""`klio dashboard`: the console."""
|
|
2
|
+
|
|
3
|
+
import webbrowser
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
CONSOLE_URL = "https://compute.klio.tech/dashboard"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _open(url: str) -> None:
|
|
11
|
+
webbrowser.open(url)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.command("dashboard", help="Open the Klio Compute console.")
|
|
15
|
+
def dashboard() -> None:
|
|
16
|
+
click.echo(CONSOLE_URL)
|
|
17
|
+
_open(CONSOLE_URL)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""`klio deploy app.py`: every function and class of an app. `klio deploy app.py:handler` stays as it was."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
from typing import Any, Dict, Iterator, List, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from ..compat import _names, _platform
|
|
9
|
+
from ..compat._app import App
|
|
10
|
+
from ..compat._options import StubSpec
|
|
11
|
+
from ..compat.exception import Error
|
|
12
|
+
from . import _loader
|
|
13
|
+
|
|
14
|
+
REFUSED_FLAGS = {"--tag", "-e", "--env", "--stream-logs", "--strategy", "--timeout"}
|
|
15
|
+
HELP = """Deploy an app from a file, or one handler.
|
|
16
|
+
|
|
17
|
+
\b
|
|
18
|
+
klio deploy app.py every function and class in the file's App
|
|
19
|
+
klio deploy app.py --name NAME the same, under another app name
|
|
20
|
+
klio deploy app.py:handler one handler, as before
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _deployments_of(app: App, app_name: str) -> Iterator[Tuple[str, StubSpec]]:
|
|
25
|
+
for name, function in app.registered_functions.items():
|
|
26
|
+
spec = dataclasses.replace(function.spec, app_name=app_name)
|
|
27
|
+
label = spec.web.label if spec.web else None
|
|
28
|
+
yield (label or _names.deployment_name(app_name, name)), spec
|
|
29
|
+
for name, wrapped in app.registered_classes.items():
|
|
30
|
+
if wrapped.methods:
|
|
31
|
+
yield _names.deployment_name(app_name, name), dataclasses.replace(wrapped.spec, app_name=app_name)
|
|
32
|
+
for method_name, marked in wrapped.web_methods.items():
|
|
33
|
+
spec = dataclasses.replace(wrapped.web_spec(method_name), app_name=app_name)
|
|
34
|
+
yield (marked.web.label or _names.deployment_name(app_name, f"{name}.{method_name}")), spec
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def deploy_app(app: App, app_name: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
38
|
+
platform = _platform.current()
|
|
39
|
+
name = app_name or app.name
|
|
40
|
+
deployed = []
|
|
41
|
+
for deployment_name, spec in _deployments_of(app, name):
|
|
42
|
+
reference = platform.deploy(spec, deployment_name)
|
|
43
|
+
deployed.append(
|
|
44
|
+
{
|
|
45
|
+
"name": deployment_name,
|
|
46
|
+
"deployment_id": reference.id,
|
|
47
|
+
"url": platform.web_url(reference) if spec.web else None,
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
return deployed
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _print_summary(app_name: str, deployed: List[Dict[str, Any]]) -> None:
|
|
54
|
+
click.echo(f"Deployed {app_name}: {len(deployed)} deployment{'' if len(deployed) == 1 else 's'}")
|
|
55
|
+
for item in deployed:
|
|
56
|
+
click.echo(f" {item['name']}" + (f" {item['url']}" if item["url"] else ""))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _flag_value(arguments: List[str], flag: str) -> Optional[str]:
|
|
60
|
+
for index, argument in enumerate(arguments):
|
|
61
|
+
if argument == flag and index + 1 < len(arguments):
|
|
62
|
+
return arguments[index + 1]
|
|
63
|
+
if argument.startswith(f"{flag}="):
|
|
64
|
+
return argument.split("=", 1)[1]
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _file_target(arguments: List[str]) -> Optional[_loader.Target]:
|
|
69
|
+
for argument in arguments:
|
|
70
|
+
if not argument.startswith("-"):
|
|
71
|
+
target = _loader.parse_target(argument)
|
|
72
|
+
if target.is_python_file:
|
|
73
|
+
return target
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _wants_compat(target: Optional[_loader.Target]) -> bool:
|
|
78
|
+
if target is None or target.name:
|
|
79
|
+
return False
|
|
80
|
+
return bool(_loader.load(target.path).apps)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _refuse_flags(arguments: List[str]) -> None:
|
|
84
|
+
for argument in arguments:
|
|
85
|
+
flag = argument.split("=", 1)[0]
|
|
86
|
+
if flag in REFUSED_FLAGS:
|
|
87
|
+
_loader.refuse_flag(flag)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def wrap(original: click.Command) -> click.Command:
|
|
91
|
+
def callback(arguments: Tuple[str, ...]) -> None:
|
|
92
|
+
context = click.get_current_context()
|
|
93
|
+
items = list(arguments)
|
|
94
|
+
target = _file_target(items)
|
|
95
|
+
if not _wants_compat(target):
|
|
96
|
+
delegated = original.make_context(original.name, items, parent=context)
|
|
97
|
+
with delegated:
|
|
98
|
+
original.invoke(delegated)
|
|
99
|
+
return
|
|
100
|
+
_refuse_flags(items)
|
|
101
|
+
name = _flag_value(items, "--name") or _flag_value(items, "-n")
|
|
102
|
+
app = _loader.only_app(_loader.load(target.path))
|
|
103
|
+
try:
|
|
104
|
+
deployed = deploy_app(app, name)
|
|
105
|
+
except Error as error:
|
|
106
|
+
raise click.ClickException(str(error)) from None
|
|
107
|
+
_print_summary(name or app.name, deployed)
|
|
108
|
+
|
|
109
|
+
def show_help(ctx: click.Context, _param: Any, value: bool) -> None:
|
|
110
|
+
if value and not ctx.resilient_parsing:
|
|
111
|
+
click.echo(HELP)
|
|
112
|
+
click.echo(original.get_help(click.Context(original, info_name="klio deploy", parent=ctx.parent)))
|
|
113
|
+
ctx.exit()
|
|
114
|
+
|
|
115
|
+
return click.Command(
|
|
116
|
+
original.name,
|
|
117
|
+
help=HELP,
|
|
118
|
+
callback=callback,
|
|
119
|
+
params=[
|
|
120
|
+
click.Option(["--help", "-h"], is_flag=True, is_eager=True, expose_value=False, callback=show_help),
|
|
121
|
+
click.Argument(["arguments"], nargs=-1, type=click.UNPROCESSED),
|
|
122
|
+
],
|
|
123
|
+
add_help_option=False,
|
|
124
|
+
context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
|
|
125
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Finds what a file defines: the object a CLI target names, and which apps live there."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, List, Optional
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from ..compat import _cls, _function, _names
|
|
12
|
+
from ..compat._app import App, registered_apps
|
|
13
|
+
from ..compat._refuse import SUFFIX
|
|
14
|
+
from ..compat.exception import Error
|
|
15
|
+
|
|
16
|
+
PYTHON_SUFFIX = ".py"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Loaded:
|
|
21
|
+
path: str
|
|
22
|
+
module: Any
|
|
23
|
+
apps: List[App]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Target:
|
|
28
|
+
path: str
|
|
29
|
+
name: str
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def is_python_file(self) -> bool:
|
|
33
|
+
return self.path.endswith(PYTHON_SUFFIX)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_target(text: str) -> Target:
|
|
37
|
+
"""`file.py`, `file.py::name` or `file.py:name`."""
|
|
38
|
+
if "::" in text:
|
|
39
|
+
path, _, name = text.partition("::")
|
|
40
|
+
else:
|
|
41
|
+
path, _, name = text.partition(":")
|
|
42
|
+
return Target(path, name)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load(path: str) -> Loaded:
|
|
46
|
+
if not os.path.exists(path):
|
|
47
|
+
raise click.ClickException(f"Can't find {path}.")
|
|
48
|
+
root = os.getcwd()
|
|
49
|
+
if root not in sys.path:
|
|
50
|
+
sys.path.insert(0, root)
|
|
51
|
+
before = set(id(app) for app in registered_apps())
|
|
52
|
+
name = _names.module_for_file(path)
|
|
53
|
+
try:
|
|
54
|
+
module = importlib.import_module(name)
|
|
55
|
+
except (Error, click.ClickException):
|
|
56
|
+
raise
|
|
57
|
+
except Exception as error:
|
|
58
|
+
raise click.ClickException(
|
|
59
|
+
f"Couldn't import {path}: {type(error).__name__}: {error}\n"
|
|
60
|
+
"Its imports must be installed on this machine. Wrap packages that exist only in the image in "
|
|
61
|
+
"`with image.imports():`."
|
|
62
|
+
) from error
|
|
63
|
+
apps = [app for app in registered_apps() if id(app) not in before]
|
|
64
|
+
if not apps:
|
|
65
|
+
apps = [value for value in vars(module).values() if isinstance(value, App)]
|
|
66
|
+
return Loaded(path, module, apps)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_compat_object(value: Any) -> bool:
|
|
70
|
+
return isinstance(value, (App, _function.Function, _cls.Cls)) or (callable(value) and _entrypoint_of(value))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _entrypoint_of(value: Any) -> Optional[App]:
|
|
74
|
+
for app in registered_apps():
|
|
75
|
+
if value in app.registered_entrypoints.values():
|
|
76
|
+
return app
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def only_app(loaded: Loaded) -> App:
|
|
81
|
+
if not loaded.apps:
|
|
82
|
+
raise click.ClickException(f"{loaded.path} doesn't define an App.")
|
|
83
|
+
if len(loaded.apps) > 1:
|
|
84
|
+
names = ", ".join(app.name for app in loaded.apps)
|
|
85
|
+
raise click.ClickException(f"{loaded.path} defines several apps ({names}). Keep one per file.")
|
|
86
|
+
return loaded.apps[0]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def refuse_flag(flag: str, hint: str = "") -> None:
|
|
90
|
+
message = f"{flag} {SUFFIX}"
|
|
91
|
+
raise click.ClickException(f"{message} {hint}".strip())
|