juniper-service-core 0.1.0__tar.gz

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 (28) hide show
  1. juniper_service_core-0.1.0/LICENSE +21 -0
  2. juniper_service_core-0.1.0/PKG-INFO +104 -0
  3. juniper_service_core-0.1.0/README.md +72 -0
  4. juniper_service_core-0.1.0/juniper_service_core/__init__.py +103 -0
  5. juniper_service_core-0.1.0/juniper_service_core/_version.py +5 -0
  6. juniper_service_core-0.1.0/juniper_service_core/app.py +39 -0
  7. juniper_service_core-0.1.0/juniper_service_core/health.py +41 -0
  8. juniper_service_core-0.1.0/juniper_service_core/launcher.py +198 -0
  9. juniper_service_core-0.1.0/juniper_service_core/lifecycle.py +80 -0
  10. juniper_service_core-0.1.0/juniper_service_core/middleware.py +203 -0
  11. juniper_service_core-0.1.0/juniper_service_core/secrets.py +36 -0
  12. juniper_service_core-0.1.0/juniper_service_core/security.py +282 -0
  13. juniper_service_core-0.1.0/juniper_service_core/settings.py +36 -0
  14. juniper_service_core-0.1.0/juniper_service_core.egg-info/PKG-INFO +104 -0
  15. juniper_service_core-0.1.0/juniper_service_core.egg-info/SOURCES.txt +26 -0
  16. juniper_service_core-0.1.0/juniper_service_core.egg-info/dependency_links.txt +1 -0
  17. juniper_service_core-0.1.0/juniper_service_core.egg-info/requires.txt +10 -0
  18. juniper_service_core-0.1.0/juniper_service_core.egg-info/top_level.txt +1 -0
  19. juniper_service_core-0.1.0/pyproject.toml +82 -0
  20. juniper_service_core-0.1.0/setup.cfg +4 -0
  21. juniper_service_core-0.1.0/tests/test_app.py +42 -0
  22. juniper_service_core-0.1.0/tests/test_launcher.py +122 -0
  23. juniper_service_core-0.1.0/tests/test_lifecycle.py +79 -0
  24. juniper_service_core-0.1.0/tests/test_middleware.py +166 -0
  25. juniper_service_core-0.1.0/tests/test_secrets.py +47 -0
  26. juniper_service_core-0.1.0/tests/test_security.py +164 -0
  27. juniper_service_core-0.1.0/tests/test_settings.py +42 -0
  28. juniper_service_core-0.1.0/tests/test_smoke.py +74 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Overtoad
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: juniper-service-core
3
+ Version: 0.1.0
4
+ Summary: Shared service-tier scaffolding (FastAPI app factory, settings base, generic routes) for Juniper ML model services
5
+ Author: Paul Calnon
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/pcalnon/juniper-ml
8
+ Project-URL: Repository, https://github.com/pcalnon/juniper-ml
9
+ Project-URL: Issues, https://github.com/pcalnon/juniper-ml/issues
10
+ Keywords: juniper,service,fastapi,scaffolding,middleware
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
19
+ Requires-Python: >=3.12
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: fastapi>=0.110
23
+ Requires-Dist: pydantic>=2.0
24
+ Requires-Dist: pydantic-settings>=2.0
25
+ Requires-Dist: juniper-model-core>=0.1.0
26
+ Provides-Extra: test
27
+ Requires-Dist: pytest>=8.0; extra == "test"
28
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
29
+ Requires-Dist: pytest-cov>=5.0; extra == "test"
30
+ Requires-Dist: httpx>=0.27; extra == "test"
31
+ Dynamic: license-file
32
+
33
+ # juniper-service-core
34
+
35
+ **Project**: Juniper — Cascade Correlation Neural Network Research Platform
36
+ **Application**: juniper-service-core (subdirectory of juniper-ml)
37
+ **Author**: Paul Calnon
38
+ **License**: MIT License
39
+ **Version**: 0.1.0
40
+
41
+ Shared **service-tier scaffolding** for Juniper ML model services: a model-agnostic
42
+ FastAPI application factory, a `pydantic-settings` base, and a generic
43
+ liveness/readiness health router. This is WS-2 of the model/middleware refactor
44
+ (`notes/JUNIPER_MODEL_MIDDLEWARE_REFACTOR_DESIGN_AND_PLAN_2026-05-31.md` in the
45
+ juniper-ml repo).
46
+
47
+ The `-core` suffix marks it as genuinely shared: it carries **no** model,
48
+ classification, or training logic — those stay in the owning service (e.g.
49
+ `juniper-cascor`) and are passed in.
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ pip install juniper-service-core
55
+ ```
56
+
57
+ ## What's in this scaffold
58
+
59
+ | Surface | Module | Purpose |
60
+ |---------|--------|---------|
61
+ | `create_app(...)` | `juniper_service_core.app` | FastAPI app factory: mounts the health router, then any service-supplied routers. Model-agnostic. |
62
+ | `SettingsBase` | `juniper_service_core.settings` | `pydantic-settings` base with generic fields (`service_name`, `host`, `port`, `log_level`). Subclasses set their own `env_prefix`. |
63
+ | `health_router()` | `juniper_service_core.health` | Generic `APIRouter` exposing `GET /v1/health` (liveness) and `GET /v1/health/ready` (readiness). |
64
+
65
+ ### Dependency-free top-level import
66
+
67
+ `import juniper_service_core` pulls **no** third-party runtime dependency. Only
68
+ `__version__` is exposed eagerly; `create_app` and `SettingsBase` are imported lazily
69
+ on attribute access (PEP 562 `__getattr__`) from submodules that require `fastapi` /
70
+ `pydantic-settings`. This lets the TestPyPI publish-verify run a clean `--no-deps`
71
+ import check.
72
+
73
+ ## Usage
74
+
75
+ ```python
76
+ from juniper_service_core import create_app, SettingsBase
77
+ from pydantic_settings import SettingsConfigDict
78
+
79
+
80
+ class MyServiceSettings(SettingsBase):
81
+ model_config = SettingsConfigDict(env_prefix="JUNIPER_MYSVC_")
82
+
83
+
84
+ app = create_app(title="My Service", version="1.0.0", routers=[...])
85
+ # GET /v1/health -> {"status": "ok"}
86
+ # GET /v1/health/ready -> {"status": "ready"}
87
+ ```
88
+
89
+ ## What's deferred
90
+
91
+ This first PR is an additive package skeleton. The following are intentionally **not**
92
+ in this scaffold and arrive in later WS-2 follow-ups:
93
+
94
+ - Extraction of the **security / middleware / websocket / worker / generic-route**
95
+ helpers from `juniper-cascor`.
96
+ - The `TrainingLifecycleBase` body, which depends on `juniper-model-core`
97
+ (this scaffold does not yet depend on `juniper-model-core`).
98
+
99
+ ## Development
100
+
101
+ ```bash
102
+ pip install -e ".[test]"
103
+ pytest tests/ -v
104
+ ```
@@ -0,0 +1,72 @@
1
+ # juniper-service-core
2
+
3
+ **Project**: Juniper — Cascade Correlation Neural Network Research Platform
4
+ **Application**: juniper-service-core (subdirectory of juniper-ml)
5
+ **Author**: Paul Calnon
6
+ **License**: MIT License
7
+ **Version**: 0.1.0
8
+
9
+ Shared **service-tier scaffolding** for Juniper ML model services: a model-agnostic
10
+ FastAPI application factory, a `pydantic-settings` base, and a generic
11
+ liveness/readiness health router. This is WS-2 of the model/middleware refactor
12
+ (`notes/JUNIPER_MODEL_MIDDLEWARE_REFACTOR_DESIGN_AND_PLAN_2026-05-31.md` in the
13
+ juniper-ml repo).
14
+
15
+ The `-core` suffix marks it as genuinely shared: it carries **no** model,
16
+ classification, or training logic — those stay in the owning service (e.g.
17
+ `juniper-cascor`) and are passed in.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install juniper-service-core
23
+ ```
24
+
25
+ ## What's in this scaffold
26
+
27
+ | Surface | Module | Purpose |
28
+ |---------|--------|---------|
29
+ | `create_app(...)` | `juniper_service_core.app` | FastAPI app factory: mounts the health router, then any service-supplied routers. Model-agnostic. |
30
+ | `SettingsBase` | `juniper_service_core.settings` | `pydantic-settings` base with generic fields (`service_name`, `host`, `port`, `log_level`). Subclasses set their own `env_prefix`. |
31
+ | `health_router()` | `juniper_service_core.health` | Generic `APIRouter` exposing `GET /v1/health` (liveness) and `GET /v1/health/ready` (readiness). |
32
+
33
+ ### Dependency-free top-level import
34
+
35
+ `import juniper_service_core` pulls **no** third-party runtime dependency. Only
36
+ `__version__` is exposed eagerly; `create_app` and `SettingsBase` are imported lazily
37
+ on attribute access (PEP 562 `__getattr__`) from submodules that require `fastapi` /
38
+ `pydantic-settings`. This lets the TestPyPI publish-verify run a clean `--no-deps`
39
+ import check.
40
+
41
+ ## Usage
42
+
43
+ ```python
44
+ from juniper_service_core import create_app, SettingsBase
45
+ from pydantic_settings import SettingsConfigDict
46
+
47
+
48
+ class MyServiceSettings(SettingsBase):
49
+ model_config = SettingsConfigDict(env_prefix="JUNIPER_MYSVC_")
50
+
51
+
52
+ app = create_app(title="My Service", version="1.0.0", routers=[...])
53
+ # GET /v1/health -> {"status": "ok"}
54
+ # GET /v1/health/ready -> {"status": "ready"}
55
+ ```
56
+
57
+ ## What's deferred
58
+
59
+ This first PR is an additive package skeleton. The following are intentionally **not**
60
+ in this scaffold and arrive in later WS-2 follow-ups:
61
+
62
+ - Extraction of the **security / middleware / websocket / worker / generic-route**
63
+ helpers from `juniper-cascor`.
64
+ - The `TrainingLifecycleBase` body, which depends on `juniper-model-core`
65
+ (this scaffold does not yet depend on `juniper-model-core`).
66
+
67
+ ## Development
68
+
69
+ ```bash
70
+ pip install -e ".[test]"
71
+ pytest tests/ -v
72
+ ```
@@ -0,0 +1,103 @@
1
+ """``juniper-service-core`` -- shared service-tier scaffolding for Juniper ML model services.
2
+
3
+ A genuinely-shared abstraction (the ``-core`` suffix): the minimal, model-agnostic
4
+ FastAPI plumbing every Juniper model service needs -- an app factory
5
+ (:func:`create_app`), a pydantic-settings base (:class:`SettingsBase`), and a generic
6
+ liveness/readiness health router. WS-2 of the model/middleware refactor
7
+ (``notes/JUNIPER_MODEL_MIDDLEWARE_REFACTOR_DESIGN_AND_PLAN_2026-05-31.md`` in the
8
+ juniper-ml repo).
9
+
10
+ **Dependency-free top-level import.** Importing this top-level package pulls **no**
11
+ third-party runtime dependency. Only :data:`__version__` is exposed eagerly; the rest
12
+ of the public surface (``create_app``, ``SettingsBase``, the security helpers, the
13
+ secrets helper, and the middleware classes) is imported lazily on attribute access
14
+ (PEP 562 ``__getattr__``) from submodules that *do* require ``fastapi`` /
15
+ ``pydantic-settings`` / ``starlette``. This is what lets the TestPyPI publish-verify
16
+ run a clean ``--no-deps`` ``import juniper_service_core`` check.
17
+
18
+ cascor's generic service infra extracted so far (de-cascored): API-key auth + rate
19
+ limiting (:mod:`~juniper_service_core.security`), Docker-secrets reading
20
+ (:mod:`~juniper_service_core.secrets`), the security / body-limit middleware
21
+ (:mod:`~juniper_service_core.middleware`), the subprocess service launcher
22
+ (:mod:`~juniper_service_core.launcher`), and the **synchronous**
23
+ ``TrainingLifecycleBase`` body (:mod:`~juniper_service_core.lifecycle`, which drives a
24
+ ``juniper-model-core`` ``TrainableModel``). The websocket / worker / generic-route
25
+ helpers -- and the threaded / worker-coordinated lifecycle bodies (OQ-11) -- remain
26
+ deferred (later WS-2 follow-ups).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from juniper_service_core._version import __version__
32
+
33
+ __all__ = [
34
+ "__version__",
35
+ "create_app",
36
+ "SettingsBase",
37
+ # Security (lazy, from .security)
38
+ "APIKeyAuth",
39
+ "RateLimiter",
40
+ "api_key_header",
41
+ "build_api_key_auth",
42
+ "build_rate_limiter",
43
+ # Secrets (lazy, from .secrets)
44
+ "get_secret",
45
+ # Middleware (lazy, from .middleware)
46
+ "SecurityHeadersMiddleware",
47
+ "RequestBodyLimitMiddleware",
48
+ "SecurityMiddleware",
49
+ # Launcher (lazy, from .launcher -- stdlib-only)
50
+ "ManagedService",
51
+ "start_service",
52
+ "wait_for_health",
53
+ # Lifecycle (lazy, from .lifecycle -- requires juniper-model-core)
54
+ "TrainingLifecycle",
55
+ "EventCollector",
56
+ ]
57
+
58
+ # Maps each lazily-resolved public name to the submodule that defines it. Keeping
59
+ # these imports out of module top level preserves the dependency-free
60
+ # ``import juniper_service_core`` guarantee: ``fastapi`` / ``pydantic-settings`` /
61
+ # ``starlette`` are only imported when one of these names is actually accessed.
62
+ # (``.secrets`` is stdlib-only, but is routed here too for uniformity.)
63
+ _LAZY_EXPORTS = {
64
+ "create_app": "juniper_service_core.app",
65
+ "SettingsBase": "juniper_service_core.settings",
66
+ "APIKeyAuth": "juniper_service_core.security",
67
+ "RateLimiter": "juniper_service_core.security",
68
+ "api_key_header": "juniper_service_core.security",
69
+ "build_api_key_auth": "juniper_service_core.security",
70
+ "build_rate_limiter": "juniper_service_core.security",
71
+ "get_secret": "juniper_service_core.secrets",
72
+ "SecurityHeadersMiddleware": "juniper_service_core.middleware",
73
+ "RequestBodyLimitMiddleware": "juniper_service_core.middleware",
74
+ "SecurityMiddleware": "juniper_service_core.middleware",
75
+ # .launcher is stdlib-only (asyncio / subprocess / urllib), but is routed
76
+ # through the lazy path too so the PEP 562 pattern stays uniform.
77
+ "ManagedService": "juniper_service_core.launcher",
78
+ "start_service": "juniper_service_core.launcher",
79
+ "wait_for_health": "juniper_service_core.launcher",
80
+ # .lifecycle requires juniper-model-core; kept lazy so the top-level import stays
81
+ # dependency-free and the --no-deps publish-verify still works.
82
+ "TrainingLifecycle": "juniper_service_core.lifecycle",
83
+ "EventCollector": "juniper_service_core.lifecycle",
84
+ }
85
+
86
+
87
+ def __getattr__(name: str):
88
+ """Lazily resolve the third-party-dependent public surface (PEP 562).
89
+
90
+ Keeping these imports out of module top level preserves the dependency-free
91
+ ``import juniper_service_core`` guarantee: ``fastapi`` / ``pydantic-settings`` /
92
+ ``starlette`` are only imported when one of the lazy exports is accessed.
93
+ """
94
+ module_name = _LAZY_EXPORTS.get(name)
95
+ if module_name is not None:
96
+ from importlib import import_module
97
+
98
+ return getattr(import_module(module_name), name)
99
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
100
+
101
+
102
+ def __dir__() -> list[str]:
103
+ return sorted(__all__)
@@ -0,0 +1,5 @@
1
+ """Single source of truth for the ``juniper-service-core`` version string."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,39 @@
1
+ """FastAPI application factory for Juniper model services.
2
+
3
+ :func:`create_app` builds a model-agnostic FastAPI app, mounts the generic health
4
+ router, then includes any service-supplied routers. It carries **no** model,
5
+ classification, or training logic -- those live in the owning service and are passed in
6
+ as ``routers``. This keeps the service-tier scaffolding reusable across every Juniper
7
+ model service (WS-2).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Iterable
13
+
14
+ from fastapi import APIRouter, FastAPI
15
+
16
+ from juniper_service_core.health import health_router
17
+
18
+
19
+ def create_app(
20
+ *,
21
+ title: str = "Juniper Service",
22
+ version: str = "0.1.0",
23
+ routers: Iterable[APIRouter] = (),
24
+ ) -> FastAPI:
25
+ """Create a FastAPI app with the generic health router plus any extra routers.
26
+
27
+ Args:
28
+ title: OpenAPI title for the app.
29
+ version: OpenAPI version string for the app.
30
+ routers: Additional service routers to mount after the health router.
31
+
32
+ Returns:
33
+ A configured :class:`~fastapi.FastAPI` instance. Model-agnostic by design.
34
+ """
35
+ app = FastAPI(title=title, version=version)
36
+ app.include_router(health_router())
37
+ for router in routers:
38
+ app.include_router(router)
39
+ return app
@@ -0,0 +1,41 @@
1
+ """Generic liveness/readiness health router shared by Juniper model services.
2
+
3
+ Provides a minimal, model-agnostic ``/v1/health`` (liveness) and ``/v1/health/ready``
4
+ (readiness) pair. Services that need richer dependency-probe readiness (the
5
+ ``ReadinessResponse`` contract) layer that on via ``juniper-observability``; this router
6
+ is the baseline every service gets for free from the app factory.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from fastapi import APIRouter
12
+ from pydantic import BaseModel
13
+
14
+
15
+ class HealthStatus(BaseModel):
16
+ """Response body for the generic health endpoints."""
17
+
18
+ status: str
19
+
20
+
21
+ def health_router() -> APIRouter:
22
+ """Build the generic health :class:`~fastapi.APIRouter`.
23
+
24
+ Returns a router exposing:
25
+
26
+ * ``GET /v1/health`` -- liveness, returns ``{"status": "ok"}``.
27
+ * ``GET /v1/health/ready`` -- readiness, returns ``{"status": "ready"}``.
28
+ """
29
+ router = APIRouter(tags=["health"])
30
+
31
+ @router.get("/v1/health", response_model=HealthStatus)
32
+ async def health() -> HealthStatus:
33
+ """Liveness probe: the process is up and serving requests."""
34
+ return HealthStatus(status="ok")
35
+
36
+ @router.get("/v1/health/ready", response_model=HealthStatus)
37
+ async def health_ready() -> HealthStatus:
38
+ """Readiness probe: the service is ready to accept traffic."""
39
+ return HealthStatus(status="ready")
40
+
41
+ return router
@@ -0,0 +1,198 @@
1
+ """Generic subprocess launcher for companion services.
2
+
3
+ Provides a small, model-agnostic mechanism for starting auxiliary services as
4
+ managed subprocesses and waiting for them to report healthy over HTTP:
5
+
6
+ * :class:`ManagedService` — a subprocess wrapper with lifecycle support
7
+ (``is_running`` / ``terminate``).
8
+ * :func:`wait_for_health` — poll an HTTP health endpoint until it responds 200
9
+ or a timeout expires.
10
+ * :func:`start_service` — ``Popen`` a service from a shell-command string and
11
+ wait for it to become healthy.
12
+
13
+ Started services are tracked in a module-level registry and terminated on
14
+ interpreter exit via an :mod:`atexit` hook.
15
+
16
+ Primarily intended for non-containerized (local development) environments where
17
+ Docker Compose is not managing service orchestration. In Docker deployments,
18
+ use ``depends_on`` with ``condition: service_healthy`` instead.
19
+ """
20
+
21
+ import asyncio
22
+ import atexit
23
+ import logging
24
+ import os
25
+ import shlex
26
+ import subprocess # nosec B404 — subprocess is the core purpose of this module
27
+ import urllib.request
28
+ from pathlib import Path
29
+
30
+ # Local timeout / interval defaults (seconds). Defined here (rather than imported
31
+ # from a project-specific constants module) so this launcher carries no coupling to
32
+ # any particular Juniper service.
33
+ _HEALTH_CHECK_HTTP_TIMEOUT = 5.0
34
+ _PROCESS_TERMINATION_TIMEOUT = 5.0
35
+ _SERVICE_DEFAULT_TERMINATE_TIMEOUT = 10.0
36
+ _SERVICE_HEALTH_POLL_INTERVAL = 0.5
37
+ _SERVICE_HEALTH_POLL_TIMEOUT = 30.0
38
+ _SERVICE_TERMINATION_TIMEOUT = 10.0
39
+
40
+ logger = logging.getLogger("juniper_service_core.launcher")
41
+
42
+ _active_services: list["ManagedService"] = []
43
+
44
+
45
+ class ManagedService:
46
+ """A subprocess-managed companion service with lifecycle support."""
47
+
48
+ def __init__(
49
+ self,
50
+ name: str,
51
+ process: subprocess.Popen,
52
+ log_handle: object | None = None,
53
+ ):
54
+ self.name = name
55
+ self.process = process
56
+ self._log_handle = log_handle
57
+
58
+ def is_running(self) -> bool:
59
+ return self.process.poll() is None
60
+
61
+ def terminate(self, timeout: float = _SERVICE_DEFAULT_TERMINATE_TIMEOUT) -> None:
62
+ if not self.is_running():
63
+ logger.debug(f"{self.name} already stopped (rc={self.process.returncode})")
64
+ self._close_log()
65
+ return
66
+ logger.info(f"Terminating {self.name} (pid={self.process.pid})")
67
+ self.process.terminate()
68
+ try:
69
+ self.process.wait(timeout=timeout)
70
+ logger.info(f"{self.name} stopped gracefully")
71
+ except subprocess.TimeoutExpired:
72
+ logger.warning(f"{self.name} did not stop in {timeout}s, sending SIGKILL")
73
+ self.process.kill()
74
+ self.process.wait(timeout=_PROCESS_TERMINATION_TIMEOUT)
75
+ logger.info(f"{self.name} killed")
76
+ self._close_log()
77
+
78
+ def _close_log(self) -> None:
79
+ if self._log_handle is not None:
80
+ try:
81
+ self._log_handle.close()
82
+ except Exception: # nosec B110 — cleanup must not propagate exceptions
83
+ pass
84
+ self._log_handle = None
85
+
86
+
87
+ def _cleanup_at_exit() -> None:
88
+ """Terminate all managed services on interpreter exit."""
89
+ for svc in _active_services:
90
+ try:
91
+ svc.terminate(timeout=_SERVICE_TERMINATION_TIMEOUT)
92
+ except Exception: # nosec B110 — cleanup must not propagate exceptions
93
+ pass
94
+
95
+
96
+ atexit.register(_cleanup_at_exit)
97
+
98
+
99
+ def _resolve_log_dir() -> Path:
100
+ """Resolve the canonical log directory for subprocess output."""
101
+ return Path(os.environ.get("JUNIPER_SERVICE_LOG_DIR") or (Path.cwd() / "logs"))
102
+
103
+
104
+ async def wait_for_health(
105
+ url: str,
106
+ timeout: float = _SERVICE_HEALTH_POLL_TIMEOUT,
107
+ interval: float = _SERVICE_HEALTH_POLL_INTERVAL,
108
+ ) -> bool:
109
+ """Poll a health endpoint until it responds HTTP 200 or timeout expires."""
110
+ import time
111
+
112
+ deadline = time.monotonic() + timeout
113
+ while time.monotonic() < deadline:
114
+ try:
115
+ req = urllib.request.Request(url, method="GET")
116
+ with urllib.request.urlopen(req, timeout=_HEALTH_CHECK_HTTP_TIMEOUT) as resp: # nosec B310 — internal health check URL from configuration
117
+ if resp.status == 200:
118
+ return True
119
+ except Exception: # nosec B110 — health poll retries on any exception
120
+ pass
121
+ await asyncio.sleep(interval)
122
+ return False
123
+
124
+
125
+ async def start_service(
126
+ name: str,
127
+ command: str,
128
+ health_url: str,
129
+ env_overrides: dict[str, str] | None = None,
130
+ health_timeout: float = _SERVICE_HEALTH_POLL_TIMEOUT,
131
+ ) -> ManagedService | None:
132
+ """Start a service as a subprocess and wait for it to become healthy.
133
+
134
+ Args:
135
+ name: Human-readable service name for logging.
136
+ command: Shell command string to start the service (parsed with shlex).
137
+ health_url: URL to poll for health status (expects HTTP 200).
138
+ env_overrides: Additional environment variables for the subprocess.
139
+ health_timeout: Seconds to wait for the health check to pass.
140
+
141
+ Returns:
142
+ ManagedService instance if started successfully, None otherwise.
143
+ """
144
+ cmd_parts = shlex.split(command)
145
+ logger.info(f"Starting {name}: {command}")
146
+
147
+ env = os.environ.copy()
148
+ if env_overrides:
149
+ env.update(env_overrides)
150
+
151
+ # Redirect subprocess output to a log file for diagnostics
152
+ log_dir = _resolve_log_dir()
153
+ log_dir.mkdir(parents=True, exist_ok=True)
154
+ safe_name = name.lower().replace(" ", "_").replace("-", "_")
155
+ log_file = log_dir / f"subprocess_{safe_name}.log"
156
+
157
+ log_handle = None
158
+ stdout_target = subprocess.DEVNULL
159
+ stderr_target = subprocess.DEVNULL
160
+ try:
161
+ log_handle = open(log_file, "a", encoding="utf-8")
162
+ stdout_target = log_handle
163
+ stderr_target = subprocess.STDOUT
164
+ logger.info(f"{name} output -> {log_file}")
165
+ except OSError:
166
+ logger.warning(f"Could not open log file {log_file}, using /dev/null")
167
+
168
+ try:
169
+ process = subprocess.Popen( # nosec B603 — command is from settings, not user input
170
+ cmd_parts,
171
+ env=env,
172
+ stdout=stdout_target,
173
+ stderr=stderr_target,
174
+ start_new_session=True,
175
+ )
176
+ except Exception:
177
+ logger.exception(f"Failed to start {name}")
178
+ if log_handle:
179
+ log_handle.close()
180
+ return None
181
+
182
+ service = ManagedService(name, process, log_handle)
183
+ _active_services.append(service)
184
+
185
+ logger.info(f"Waiting for {name} health at {health_url} (timeout={health_timeout}s)")
186
+ healthy = await wait_for_health(health_url, timeout=health_timeout)
187
+
188
+ if not healthy:
189
+ if service.is_running():
190
+ logger.error(f"{name} started but health check failed after {health_timeout}s")
191
+ else:
192
+ logger.error(f"{name} exited prematurely (rc={process.returncode})")
193
+ service.terminate()
194
+ _active_services.remove(service)
195
+ return None
196
+
197
+ logger.info(f"{name} is healthy (pid={process.pid})")
198
+ return service
@@ -0,0 +1,80 @@
1
+ """Generic training-lifecycle bodies (WS-2).
2
+
3
+ Concrete :class:`juniper_model_core.lifecycle.TrainingLifecycleBase` implementations that
4
+ drive a model-core :class:`~juniper_model_core.interfaces.TrainableModel` through training
5
+ and forward the model's :class:`~juniper_model_core.events.TrainingEvent`s to the injected
6
+ sink. This is the **synchronous foundation**; the threaded / finite-state-machine /
7
+ dataset-hot-swap / worker-coordinated bodies -- and the worker-parallelism question
8
+ (OQ-11) -- are deferred follow-ups (model-core ``lifecycle.py`` decision D8).
9
+
10
+ Importing this module requires ``juniper-model-core`` (the model contract); it is therefore
11
+ NOT imported at the top level of :mod:`juniper_service_core` (it is exposed lazily) so the
12
+ dependency-free top-level import is preserved.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Callable
18
+ from dataclasses import replace
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ from juniper_model_core.lifecycle import TrainingLifecycleBase
22
+
23
+ if TYPE_CHECKING:
24
+ import numpy as np
25
+ from juniper_model_core.events import TrainingEvent
26
+ from juniper_model_core.interfaces import TrainableModel, TrainResult
27
+
28
+ __all__ = ["TrainingLifecycle", "EventCollector"]
29
+
30
+
31
+ class EventCollector:
32
+ """A simple, ordered event sink -- for tests, inspection, and replay.
33
+
34
+ Use it as the ``on_event`` sink of a lifecycle; it records every emitted event in order.
35
+ """
36
+
37
+ def __init__(self) -> None:
38
+ self.events: list[TrainingEvent] = []
39
+
40
+ def __call__(self, event: TrainingEvent) -> None:
41
+ self.events.append(event)
42
+
43
+ @property
44
+ def types(self) -> list[str]:
45
+ """The event ``type`` strings, in emission order."""
46
+ return [event.type for event in self.events]
47
+
48
+
49
+ class TrainingLifecycle(TrainingLifecycleBase):
50
+ """Synchronous lifecycle: drives ``model.fit`` to completion on the calling thread.
51
+
52
+ :meth:`run` wires the model's ``on_event`` to this lifecycle's :meth:`emit`, so the
53
+ model's progress events flow to the injected sink. The lifecycle owns **run-level
54
+ ordering**: it stamps a monotonic ``seq`` on each event as it passes through, so the
55
+ sink sees a legally-ordered stream regardless of what ``seq`` the model emits.
56
+
57
+ Growth (``unit_added``) for a :class:`~juniper_model_core.interfaces.GrowableModel`
58
+ happens *inside* its ``fit`` (the model-core contract), so this single synchronous body
59
+ drives both fixed-topology and growable models. The threaded / FSM / dataset-hot-swap /
60
+ worker-coordinated bodies are deferred (D8; worker parallelism is OQ-11).
61
+ """
62
+
63
+ def __init__(self, model: TrainableModel, on_event: Callable[[TrainingEvent], None] | None = None) -> None:
64
+ super().__init__(model, on_event)
65
+ self._seq = 0
66
+
67
+ def emit(self, event: TrainingEvent) -> None:
68
+ """Forward ``event`` to the sink with a monotonic, run-scoped ``seq`` stamped."""
69
+ super().emit(replace(event, seq=self._seq))
70
+ self._seq += 1
71
+
72
+ def run(self, X: np.ndarray, y: np.ndarray, **kw: Any) -> TrainResult:
73
+ """Drive the model's full ``fit`` synchronously, routing its events through the
74
+ lifecycle.
75
+
76
+ ``**kw`` (e.g. ``X_val`` / ``y_val``, or sequence auxiliaries like ``dt``) is
77
+ forwarded to ``fit``. Do **not** pass ``on_event`` -- the lifecycle owns the sink.
78
+ """
79
+ self._seq = 0
80
+ return self.model.fit(X, y, on_event=self.emit, **kw)