Coverage for core / src / sensorkit / cli / service.py: 0%
72 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-02 00:03 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-02 00:03 +0000
1# SPDX-License-Identifier: Apache-2.0
2import asyncclick as click
3from rich.console import Console
5from sensorkit.cli.utils import with_kit
8@click.group("service")
9async def service_group():
10 """Service management commands."""
13@service_group.command("run")
14@click.argument("name", metavar="name", nargs=1)
15@click.argument("spec", metavar="[python_module[:entrypoint]]", nargs=1, required=False)
16@click.option(
17 "-c", "--config", "config_file",
18 metavar="[config_file]",
19 default=None,
20 help="Config file to resolve the service implementation from (defaults to sensorkit.yaml)",
21)
22@click.option("-r", "--restart", is_flag=True, help="Automatically restart service on failure")
23async def service_run(name, spec, config_file, restart):
24 """
25 Run a SensorKit service.
27 Resolves the implementation's python module path from configuration based on
28 NAME. If the config cannot be loaded, the path must be supplied explicitly as
29 the second argument.
30 """
31 import yaml
32 from loguru import logger
33 from pydantic import ValidationError
35 import sensorkit.api as sk
36 from sensorkit.api.entrypoint import ServiceEntrypoint, ShutdownSignal, run_services
37 from sensorkit.backend.base import KVError
38 from sensorkit.backend.lease import LeaseUnavailableError
39 from sensorkit.common.logging import configure_logging
40 from sensorkit.config.parser import (
41 ConfigError,
42 ConfigSectionUnknown,
43 ConfigVersionUnsupported,
44 )
46 # Configure logging
47 configure_logging()
49 # Try to load config. A missing file is nonfatal (the spec must then be
50 # supplied explicitly); any other load error is fatal.
51 try:
52 sk.set_config_location(config_file)
53 config = await sk.load_config()
54 except FileNotFoundError as e:
55 logger.opt(exception=e).debug("config file not found")
56 config = None
57 except yaml.YAMLError as e:
58 raise click.ClickException(f"invalid YAML in config: {e}") from e
59 except ConfigVersionUnsupported as e:
60 raise click.ClickException(f"unsupported config version: {e}") from e
61 except ConfigSectionUnknown as e:
62 raise click.ClickException(f"unknown config section: {e}") from e
63 except ConfigError as e:
64 raise click.ClickException(f"config processing failed: {e}") from e
65 except ValidationError as e:
66 raise click.ClickException(f"config validation failed: {e}") from e
68 await sk.import_modules()
70 if config is not None:
71 configured_spec = next(
72 (svc.python_module for svc in config.services if svc.id == name and svc.python_module),
73 None,
74 )
76 if configured_spec is None:
77 raise click.UsageError(
78 f"Could not resolve a python module for service '{name}' from configuration"
79 )
81 if spec is not None and spec != configured_spec:
82 raise click.UsageError(
83 f"An explicit python module is not allowed when configuration is loaded; "
84 f"the implementation for service '{name}' is resolved from config "
85 f"('{configured_spec}')"
86 )
88 spec = configured_spec
89 elif spec is None:
90 raise click.UsageError(
91 f"No configuration found; supply the implementation for service '{name}' "
92 "explicitly as 'python_module[:entrypoint]'"
93 )
95 try:
96 entrypoints = {
97 name: ServiceEntrypoint.from_spec(spec, load_file=True)
98 }
99 except (ValueError, ModuleNotFoundError) as e:
100 raise click.UsageError(f"Could not find an entrypoint at '{spec}'") from e
102 try:
103 # Find and run the service entrypoint.
104 await run_services(
105 entrypoints,
106 max_restarts=None if restart else 0
107 )
108 except* KVError as eg:
109 logger.opt(exception=eg.exceptions[0]).debug("service config error")
110 click.secho(
111 "Could not find required data (is there missing configuration?)",
112 fg="red",
113 err=True,
114 )
115 except* LeaseUnavailableError:
116 click.secho(
117 "Service cannot start due to a conflict (is this service already running?)",
118 fg="red",
119 err=True,
120 )
121 except* ShutdownSignal:
122 logger.debug("service exiting due to shutdown signal")
123 except* Exception as eg:
124 logger.opt(exception=eg.exceptions[0]).debug("service exiting with error")
125 click.secho("Service exiting with error", fg="red", err=True)
128@service_group.command("ls")
129@with_kit
130async def service_list(kit):
131 """List services registered in the backend."""
132 console = Console()
133 services = await kit.list_services()
135 for _, status in services.items():
136 sr = status.service.info
137 deco = "bold white" if status.online else "bold yellow"
138 console.print(
139 f"[{deco}]{sr.name} (version {sr.version}): {'online' if status.online else 'offline'}[/{deco}]"
140 )