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

1# SPDX-License-Identifier: Apache-2.0 

2import asyncclick as click 

3from rich.console import Console 

4 

5from sensorkit.cli.utils import with_kit 

6 

7 

8@click.group("service") 

9async def service_group(): 

10 """Service management commands.""" 

11 

12 

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. 

26 

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 

34 

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 ) 

45 

46 # Configure logging 

47 configure_logging() 

48 

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 

67 

68 await sk.import_modules() 

69 

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 ) 

75 

76 if configured_spec is None: 

77 raise click.UsageError( 

78 f"Could not resolve a python module for service '{name}' from configuration" 

79 ) 

80 

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 ) 

87 

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 ) 

94 

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 

101 

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) 

126 

127 

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() 

134 

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 )