Coverage for core / src / sensorkit / api / bootstrap.py: 39%
76 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 asyncio
3import os
4import pathlib
5import warnings
6from collections.abc import Iterable
7from typing import Literal, cast, overload
9from sensorkit.backend.base import BackendImpl
10from sensorkit.common.importutil import import_module_or_file, obj_from_spec
11from sensorkit.config.parser import SensorKitBaseConfig, SensorKitConfig, parse_config
13DEFAULT_BACKEND = "nats"
14DEFAULT_CONFIG_FILE = "sensorkit.yaml"
15DEFAULT_BASE_IMPORTS = (
16 "sensorkit.std",
17 "sensorkit.webapi",
18 "sensorkit.data.filesys",
19 "sensorkit.data.fits",
20 "sensorkit.data.local",
21)
22BACKEND_MODULES = {
23 "nats": "sensorkit.backend.nats",
24 "fake": "sensorkit.backend.fake",
25}
27_read_lock = asyncio.Lock()
28_base: SensorKitBaseConfig | None = None
29_resolved: SensorKitConfig | None = None
32@overload
33async def _read_config(*, required: Literal[False] = False) -> SensorKitBaseConfig | None: ...
36@overload
37async def _read_config(*, required: Literal[True]) -> SensorKitBaseConfig: ...
40async def _read_config(*, required: bool = False):
41 """Read and memoize the base config.
43 Callers must hold `_read_lock`.
44 """
45 global _base
47 def _read_config_sync(location: str | None):
48 import yaml
50 path = pathlib.Path(location or DEFAULT_CONFIG_FILE)
52 try:
53 base = parse_config(yaml.safe_load(path.read_text()))
54 except FileNotFoundError:
55 if required or location is not None:
56 raise
58 base = None
60 return base
62 if _base is None:
63 _base = await asyncio.to_thread(
64 _read_config_sync,
65 os.environ.get("SENSORKIT_CONFIG"),
66 )
68 return _base
71def _imports_from_env(var: str, default: Iterable[str] = ()) -> list[str]:
72 return [mod.strip() for mod in os.environ.get(var, "").split(",") if mod] or list(default)
75def _import_modules_sync(*, fail_policy: Literal["error", "warn", "ignore"]):
76 imports = _imports_from_env("SENSORKIT_BASE_IMPORTS", DEFAULT_BASE_IMPORTS)
77 imports.extend(_imports_from_env("SENSORKIT_IMPORTS"))
79 if _base is not None:
80 imports.extend(_base.configured_imports())
82 for module in imports:
83 try:
84 import_module_or_file(module)
85 except Exception:
86 match fail_policy:
87 case "error":
88 raise
89 case "warn":
90 warnings.warn(f"Failed to import: {module}", stacklevel=1)
91 case "ignore":
92 pass
95def set_config_location(location: str | None) -> None:
96 """Set the location of the SensorKit configuration file.
98 This must be called before `load_config`, `import_modules`, and `connect`
99 to have effect.
100 """
101 if location is not None:
102 current = os.environ.get("SENSORKIT_CONFIG", DEFAULT_CONFIG_FILE)
104 if _base is not None and location != current:
105 raise RuntimeError(f"config already loaded from {current}, cannot set to {location}")
107 os.environ["SENSORKIT_CONFIG"] = location
110async def load_config(
111 *,
112 fail_policy: Literal["error", "warn", "ignore"] = "warn",
113) -> SensorKitConfig:
114 """Loads and fully resolves configuration, importing modules as needed.
116 The fully resolved configuration is memoized; subsequent calls return the
117 cached result without re-resolving.
118 """
119 global _resolved
121 async with _read_lock:
122 if _resolved is None:
123 base = await _read_config(required=True)
124 await asyncio.to_thread(_import_modules_sync, fail_policy=fail_policy)
125 _resolved = base.resolve_dynamic_sections()
127 return cast(SensorKitConfig, _resolved)
130async def import_modules(*, fail_policy: Literal["error", "warn", "ignore"] = "error"):
131 """Imports modules based on environment and configuration."""
132 async with _read_lock:
133 await _read_config()
135 await asyncio.to_thread(_import_modules_sync, fail_policy=fail_policy)
138async def connect(*, backend: str | None = None):
139 """Creates a SensorKit client and connects to the backend.
141 The backend is either provided as a string, read from the environment, or
142 (if neither is present) read from the SensorKit configuration file.
144 User modules are not imported automatically. Callers that need to access
145 types provided by modules must opt in to these imports by calling
146 `import_modules()` or `load_config()` (to retrieve the fully resolved
147 configuration structure).
148 """
149 from sensorkit.core.client import SensorKit
151 backend = backend or os.environ.get("SENSORKIT_BACKEND")
153 if not backend:
154 async with _read_lock:
155 config = await _read_config()
157 backend = config.sensorkit.backend if config else None
159 backend_module = BACKEND_MODULES.get(backend) or BACKEND_MODULES[DEFAULT_BACKEND]
160 backend_cls = await asyncio.to_thread(
161 obj_from_spec,
162 spec=backend_module,
163 base=BackendImpl,
164 subclass=True,
165 )
166 backend_impl = await backend_cls.create()
167 return SensorKit(backend=backend_impl)