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.
Files changed (66) hide show
  1. klio_compute/__init__.py +57 -0
  2. klio_compute/__main__.py +3 -0
  3. klio_compute/_branding.py +63 -0
  4. klio_compute/_compat_cli/__init__.py +31 -0
  5. klio_compute/_compat_cli/_app_cmd.py +111 -0
  6. klio_compute/_compat_cli/_curl.py +85 -0
  7. klio_compute/_compat_cli/_dashboard.py +17 -0
  8. klio_compute/_compat_cli/_deploy.py +125 -0
  9. klio_compute/_compat_cli/_loader.py +91 -0
  10. klio_compute/_compat_cli/_migrate.py +364 -0
  11. klio_compute/_compat_cli/_run.py +190 -0
  12. klio_compute/_compat_cli/_secret_cmd.py +153 -0
  13. klio_compute/_compat_cli/_shell.py +37 -0
  14. klio_compute/_compat_cli/_volume_cmd.py +85 -0
  15. klio_compute/_connect.py +63 -0
  16. klio_compute/_distribution.py +33 -0
  17. klio_compute/_errors.py +93 -0
  18. klio_compute/_gpu_access.py +84 -0
  19. klio_compute/_images.py +74 -0
  20. klio_compute/_model_cli.py +234 -0
  21. klio_compute/_model_wait.py +243 -0
  22. klio_compute/_models.py +118 -0
  23. klio_compute/_settings.py +59 -0
  24. klio_compute/_unavailable.py +45 -0
  25. klio_compute/cli.py +67 -0
  26. klio_compute/compat/__init__.py +107 -0
  27. klio_compute/compat/_aio.py +45 -0
  28. klio_compute/compat/_app.py +142 -0
  29. klio_compute/compat/_build_function.py +69 -0
  30. klio_compute/compat/_bundle.py +70 -0
  31. klio_compute/compat/_calls.py +122 -0
  32. klio_compute/compat/_cls.py +183 -0
  33. klio_compute/compat/_concurrent.py +104 -0
  34. klio_compute/compat/_container.py +313 -0
  35. klio_compute/compat/_dict.py +175 -0
  36. klio_compute/compat/_envelope.py +116 -0
  37. klio_compute/compat/_fanout.py +105 -0
  38. klio_compute/compat/_function.py +134 -0
  39. klio_compute/compat/_gpu_names.py +15 -0
  40. klio_compute/compat/_image.py +297 -0
  41. klio_compute/compat/_live.py +300 -0
  42. klio_compute/compat/_logs.py +101 -0
  43. klio_compute/compat/_marks.py +159 -0
  44. klio_compute/compat/_names.py +45 -0
  45. klio_compute/compat/_nested.py +77 -0
  46. klio_compute/compat/_options.py +257 -0
  47. klio_compute/compat/_platform.py +70 -0
  48. klio_compute/compat/_queue.py +132 -0
  49. klio_compute/compat/_refuse.py +90 -0
  50. klio_compute/compat/_retry.py +29 -0
  51. klio_compute/compat/_sandbox.py +149 -0
  52. klio_compute/compat/_schedule.py +179 -0
  53. klio_compute/compat/_secret.py +188 -0
  54. klio_compute/compat/_stores.py +79 -0
  55. klio_compute/compat/_stubs.py +162 -0
  56. klio_compute/compat/_volume.py +135 -0
  57. klio_compute/compat/_volume_io.py +30 -0
  58. klio_compute/compat/_web.py +37 -0
  59. klio_compute/compat/exception.py +82 -0
  60. klio_compute/py.typed +0 -0
  61. klio_compute-0.5.4.dist-info/METADATA +142 -0
  62. klio_compute-0.5.4.dist-info/RECORD +66 -0
  63. klio_compute-0.5.4.dist-info/WHEEL +5 -0
  64. klio_compute-0.5.4.dist-info/entry_points.txt +2 -0
  65. klio_compute-0.5.4.dist-info/licenses/LICENSE +661 -0
  66. klio_compute-0.5.4.dist-info/top_level.txt +1 -0
@@ -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__))
@@ -0,0 +1,3 @@
1
+ from .cli import start
2
+
3
+ start()
@@ -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())