spot-sdk-python 1.0.0__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.
- spot_sdk/__init__.py +86 -0
- spot_sdk/analysis_context.py +99 -0
- spot_sdk/analyzer.py +106 -0
- spot_sdk/analyzer_base.py +316 -0
- spot_sdk/api_gateway.py +271 -0
- spot_sdk/config.py +46 -0
- spot_sdk/config_client.py +203 -0
- spot_sdk/config_helpers.py +25 -0
- spot_sdk/email.py +136 -0
- spot_sdk/errors.py +24 -0
- spot_sdk/knowledge.py +341 -0
- spot_sdk/knowledge_tags.py +31 -0
- spot_sdk/logging.py +133 -0
- spot_sdk/ollama.py +58 -0
- spot_sdk/orchestrator.py +70 -0
- spot_sdk/plugin.py +30 -0
- spot_sdk/results.py +129 -0
- spot_sdk/settings_schema.py +56 -0
- spot_sdk/testing/README.md +83 -0
- spot_sdk/testing/__init__.py +22 -0
- spot_sdk/testing/factories.py +177 -0
- spot_sdk/testing/fake_knowledge_client.py +105 -0
- spot_sdk/threat_levels.py +33 -0
- spot_sdk/workflow.py +139 -0
- spot_sdk_python-1.0.0.dist-info/METADATA +353 -0
- spot_sdk_python-1.0.0.dist-info/RECORD +27 -0
- spot_sdk_python-1.0.0.dist-info/WHEEL +4 -0
spot_sdk/__init__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""SPOT API interfaces and data models."""
|
|
2
|
+
|
|
3
|
+
from .analysis_context import AnalysisContextReader
|
|
4
|
+
from .analyzer import AnalyzerCapability, AnalyzerInterface, ThreatLevel
|
|
5
|
+
from .config import ConfigOption, ConfigReloadResult, ConfigStatus, ConfigUpdateRequest
|
|
6
|
+
from .config_helpers import merge_settings
|
|
7
|
+
from .email import Attachment, Email, EmailHeader
|
|
8
|
+
from .errors import ErrorResponse
|
|
9
|
+
from .knowledge import KnowledgeClient, KnowledgeDocument, chunk_text, content_hash
|
|
10
|
+
from .knowledge_tags import KnowledgeTag
|
|
11
|
+
from .logging import configure_logging, get_logger, get_logging_config
|
|
12
|
+
from .ollama import LLMResponse, OllamaClientProtocol
|
|
13
|
+
from .orchestrator import AnalyzerResult, OrchestrationResult, WorkflowStageResult
|
|
14
|
+
from .plugin import PluginKind
|
|
15
|
+
from .results import (
|
|
16
|
+
AnalysisIndicator,
|
|
17
|
+
AnalysisMetadata,
|
|
18
|
+
AnalysisResult,
|
|
19
|
+
IndicatorType,
|
|
20
|
+
)
|
|
21
|
+
from .settings_schema import register_settings_schema
|
|
22
|
+
from .threat_levels import confidence_to_threat_level
|
|
23
|
+
from .workflow import (
|
|
24
|
+
AnalyzerConfig,
|
|
25
|
+
FailureStrategy,
|
|
26
|
+
RetrievalLimits,
|
|
27
|
+
RetryConfig,
|
|
28
|
+
Workflow,
|
|
29
|
+
WorkflowStage,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
# Analyzer interface
|
|
34
|
+
"AnalyzerInterface",
|
|
35
|
+
"AnalyzerCapability",
|
|
36
|
+
"ThreatLevel",
|
|
37
|
+
# Config models
|
|
38
|
+
"ConfigOption",
|
|
39
|
+
"ConfigStatus",
|
|
40
|
+
"ConfigUpdateRequest",
|
|
41
|
+
"ConfigReloadResult",
|
|
42
|
+
# Error response
|
|
43
|
+
"ErrorResponse",
|
|
44
|
+
# Email models
|
|
45
|
+
"Email",
|
|
46
|
+
"EmailHeader",
|
|
47
|
+
"Attachment",
|
|
48
|
+
"AnalysisContextReader",
|
|
49
|
+
# Analysis results
|
|
50
|
+
"AnalysisResult",
|
|
51
|
+
"AnalysisIndicator",
|
|
52
|
+
"AnalysisMetadata",
|
|
53
|
+
"IndicatorType",
|
|
54
|
+
# Logging
|
|
55
|
+
"configure_logging",
|
|
56
|
+
"get_logger",
|
|
57
|
+
"get_logging_config",
|
|
58
|
+
# Workflow
|
|
59
|
+
"Workflow",
|
|
60
|
+
"WorkflowStage",
|
|
61
|
+
"AnalyzerConfig",
|
|
62
|
+
"RetryConfig",
|
|
63
|
+
"RetrievalLimits",
|
|
64
|
+
"FailureStrategy",
|
|
65
|
+
# Knowledge Store
|
|
66
|
+
"KnowledgeDocument",
|
|
67
|
+
"KnowledgeClient",
|
|
68
|
+
"KnowledgeTag",
|
|
69
|
+
"chunk_text",
|
|
70
|
+
"content_hash",
|
|
71
|
+
# Plugin vocabulary
|
|
72
|
+
"PluginKind",
|
|
73
|
+
# Orchestration
|
|
74
|
+
"OrchestrationResult",
|
|
75
|
+
"AnalyzerResult",
|
|
76
|
+
"WorkflowStageResult",
|
|
77
|
+
# Ollama protocol
|
|
78
|
+
"OllamaClientProtocol",
|
|
79
|
+
"LLMResponse",
|
|
80
|
+
# Settings schema
|
|
81
|
+
"register_settings_schema",
|
|
82
|
+
# Config helpers
|
|
83
|
+
"merge_settings",
|
|
84
|
+
# Threat levels
|
|
85
|
+
"confidence_to_threat_level",
|
|
86
|
+
]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Ergonomic accessor for Email.analysis_context.
|
|
2
|
+
|
|
3
|
+
Wraps the stage-scoped dict produced by the orchestrator so analyzers can
|
|
4
|
+
look up previous stage results without writing manual dict traversal.
|
|
5
|
+
|
|
6
|
+
The underlying structure is:
|
|
7
|
+
|
|
8
|
+
{
|
|
9
|
+
"<stage-name>": {
|
|
10
|
+
"analyzers": {"<analyzer-id>": {...AnalyzerResult fields...}}
|
|
11
|
+
},
|
|
12
|
+
...
|
|
13
|
+
}
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, cast
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AnalysisContextReader:
|
|
22
|
+
"""Read-only accessor for Email.analysis_context data.
|
|
23
|
+
|
|
24
|
+
Use via the Email.ctx property:
|
|
25
|
+
|
|
26
|
+
nlp = email.ctx.analyzer("analyzer-nlp")
|
|
27
|
+
if nlp:
|
|
28
|
+
confidence = nlp["confidence"]
|
|
29
|
+
|
|
30
|
+
# All analyzers in a specific stage
|
|
31
|
+
for aid, data in email.ctx.analyzers_in("parallel-analysis").items():
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
Missing keys return None (single accessors) or empty dict (bulk accessors).
|
|
35
|
+
Nothing in this class raises.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, context: dict[str, Any] | None) -> None:
|
|
39
|
+
self._ctx: dict[str, Any] = context or {}
|
|
40
|
+
|
|
41
|
+
# ------------------------------------------------------------------
|
|
42
|
+
# Stage listing
|
|
43
|
+
# ------------------------------------------------------------------
|
|
44
|
+
def stages(self) -> list[str]:
|
|
45
|
+
"""Return the names of all stages that have completed, in insertion order."""
|
|
46
|
+
return list(self._ctx.keys())
|
|
47
|
+
|
|
48
|
+
def has_stage(self, stage: str) -> bool:
|
|
49
|
+
"""Return True if the given stage has completed."""
|
|
50
|
+
return stage in self._ctx
|
|
51
|
+
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
# Analyzer accessors
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
def analyzer_ids(self) -> list[str]:
|
|
56
|
+
"""Return all analyzer IDs across all stages (deduplicated, stable order)."""
|
|
57
|
+
seen: dict[str, None] = {}
|
|
58
|
+
for stage in self._ctx.values():
|
|
59
|
+
for aid in (stage.get("analyzers") or {}).keys():
|
|
60
|
+
seen[aid] = None
|
|
61
|
+
return list(seen.keys())
|
|
62
|
+
|
|
63
|
+
def analyzer_ids_in(self, stage: str) -> list[str]:
|
|
64
|
+
"""Return analyzer IDs in a specific stage. Empty list if stage is absent."""
|
|
65
|
+
return list((self._ctx.get(stage, {}).get("analyzers") or {}).keys())
|
|
66
|
+
|
|
67
|
+
def analyzer(
|
|
68
|
+
self, analyzer_id: str, stage: str | None = None
|
|
69
|
+
) -> dict[str, Any] | None:
|
|
70
|
+
"""Return a single analyzer result, or None if not found.
|
|
71
|
+
|
|
72
|
+
If ``stage`` is given, only look in that stage.
|
|
73
|
+
If ``stage`` is None, return the first match across all stages (in stage order).
|
|
74
|
+
"""
|
|
75
|
+
if stage is not None:
|
|
76
|
+
hit = (self._ctx.get(stage, {}).get("analyzers") or {}).get(analyzer_id)
|
|
77
|
+
return cast("dict[str, Any] | None", hit)
|
|
78
|
+
for stage_data in self._ctx.values():
|
|
79
|
+
analyzers = stage_data.get("analyzers") or {}
|
|
80
|
+
if analyzer_id in analyzers:
|
|
81
|
+
return cast("dict[str, Any]", analyzers[analyzer_id])
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
def analyzers_in(self, stage: str) -> dict[str, dict[str, Any]]:
|
|
85
|
+
"""Return all analyzer results in a stage as {analyzer_id: data}.
|
|
86
|
+
|
|
87
|
+
Empty dict if the stage is absent or has no analyzers.
|
|
88
|
+
"""
|
|
89
|
+
return dict(self._ctx.get(stage, {}).get("analyzers") or {})
|
|
90
|
+
|
|
91
|
+
def all_analyzers(self) -> dict[str, dict[str, dict[str, Any]]]:
|
|
92
|
+
"""Return all analyzer results across every stage.
|
|
93
|
+
|
|
94
|
+
Shape: ``{stage_name: {analyzer_id: data, ...}, ...}``
|
|
95
|
+
"""
|
|
96
|
+
return {
|
|
97
|
+
stage_name: dict(stage_data.get("analyzers") or {})
|
|
98
|
+
for stage_name, stage_data in self._ctx.items()
|
|
99
|
+
}
|
spot_sdk/analyzer.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Analyzer interface definition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from .email import Email
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .results import AnalysisResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ThreatLevel(str, Enum):
|
|
16
|
+
"""Email threat level classification."""
|
|
17
|
+
|
|
18
|
+
SAFE = "safe"
|
|
19
|
+
LOW = "low"
|
|
20
|
+
MEDIUM = "medium"
|
|
21
|
+
HIGH = "high"
|
|
22
|
+
CRITICAL = "critical"
|
|
23
|
+
|
|
24
|
+
def __str__(self) -> str:
|
|
25
|
+
return self.value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AnalyzerCapability(str, Enum):
|
|
29
|
+
"""Analyzer capability types."""
|
|
30
|
+
|
|
31
|
+
SENTIMENT_ANALYSIS = "sentiment_analysis"
|
|
32
|
+
ENTITY_RECOGNITION = "entity_recognition"
|
|
33
|
+
URL_ANALYSIS = "url_analysis"
|
|
34
|
+
ATTACHMENT_SCANNING = "attachment_scanning"
|
|
35
|
+
LANGUAGE_DETECTION = "language_detection"
|
|
36
|
+
PHISHING_PATTERNS = "phishing_patterns"
|
|
37
|
+
SOCIAL_ENGINEERING = "social_engineering"
|
|
38
|
+
DOMAIN_REPUTATION = "domain_reputation"
|
|
39
|
+
|
|
40
|
+
def __str__(self) -> str:
|
|
41
|
+
return self.value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AnalyzerInterface(ABC):
|
|
45
|
+
"""
|
|
46
|
+
Standard interface that all SPOT analyzers must implement.
|
|
47
|
+
|
|
48
|
+
This interface ensures compatibility across all analyzer plugins,
|
|
49
|
+
enabling the orchestrator to coordinate analysis workflows.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
async def analyze(self, email: Email) -> AnalysisResult:
|
|
54
|
+
"""
|
|
55
|
+
Analyze an email and return threat assessment.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
email: Email object containing headers, body, and attachments
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
AnalysisResult: Structured analysis result with threat assessment
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
AnalysisError: If analysis cannot be completed
|
|
65
|
+
"""
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
async def get_capabilities(self) -> list[AnalyzerCapability]:
|
|
70
|
+
"""
|
|
71
|
+
Return list of analysis capabilities this analyzer provides.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
List of AnalyzerCapability enums
|
|
75
|
+
"""
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
@abstractmethod
|
|
79
|
+
async def health_check(self) -> bool:
|
|
80
|
+
"""
|
|
81
|
+
Check if analyzer is healthy and ready to process emails.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
True if healthy, False otherwise
|
|
85
|
+
"""
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
@abstractmethod
|
|
89
|
+
def get_version(self) -> str:
|
|
90
|
+
"""
|
|
91
|
+
Return analyzer version string.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Semantic version string (e.g., "1.2.3")
|
|
95
|
+
"""
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
@abstractmethod
|
|
99
|
+
async def get_configuration(self) -> dict[str, Any]:
|
|
100
|
+
"""
|
|
101
|
+
Return current analyzer configuration.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Dictionary containing analyzer configuration parameters
|
|
105
|
+
"""
|
|
106
|
+
pass
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Base classes for SPOT analyzer implementations.
|
|
2
|
+
|
|
3
|
+
Provides:
|
|
4
|
+
- BaseAnalyzerSettings: Common settings fields all analyzers share
|
|
5
|
+
- settings_singleton: Decorator to add caching to a get_settings() function
|
|
6
|
+
- merge_settings: Merge base settings with central config overrides
|
|
7
|
+
- create_analyzer_app: Factory for FastAPI app with standard analyzer endpoints
|
|
8
|
+
|
|
9
|
+
Usage in an analyzer's config.py::
|
|
10
|
+
|
|
11
|
+
from spot_sdk.analyzer_base import BaseAnalyzerSettings, settings_singleton
|
|
12
|
+
|
|
13
|
+
class Settings(BaseAnalyzerSettings):
|
|
14
|
+
app_name: str = "analyzer-nlp"
|
|
15
|
+
model_path: str = "/models/distilbert"
|
|
16
|
+
use_gpu: bool = False
|
|
17
|
+
|
|
18
|
+
@settings_singleton
|
|
19
|
+
def get_settings() -> Settings:
|
|
20
|
+
return Settings()
|
|
21
|
+
|
|
22
|
+
Usage in an analyzer's main.py::
|
|
23
|
+
|
|
24
|
+
from spot_sdk.analyzer_base import create_analyzer_app
|
|
25
|
+
|
|
26
|
+
app, state = create_analyzer_app(
|
|
27
|
+
title="SPOT NLP Analyzer",
|
|
28
|
+
description="NLP-based phishing email analyzer",
|
|
29
|
+
analyzer_id="analyzer-nlp",
|
|
30
|
+
settings_factory=get_settings,
|
|
31
|
+
)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import os
|
|
37
|
+
from collections.abc import Awaitable, Callable
|
|
38
|
+
from functools import wraps
|
|
39
|
+
from typing import Any, TypeVar
|
|
40
|
+
|
|
41
|
+
import uvicorn
|
|
42
|
+
from fastapi import FastAPI
|
|
43
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
44
|
+
from starlette.responses import Response
|
|
45
|
+
|
|
46
|
+
from .config_client import ConfigClient
|
|
47
|
+
from .logging import configure_logging, get_logger
|
|
48
|
+
|
|
49
|
+
logger = get_logger(__name__)
|
|
50
|
+
|
|
51
|
+
T = TypeVar("T", bound=BaseSettings)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BaseAnalyzerSettings(BaseSettings):
|
|
55
|
+
"""Common settings shared by all SPOT analyzers.
|
|
56
|
+
|
|
57
|
+
Analyzers extend this class with their own specific fields::
|
|
58
|
+
|
|
59
|
+
class Settings(BaseAnalyzerSettings):
|
|
60
|
+
app_name: str = "analyzer-nlp"
|
|
61
|
+
model_path: str = "/models/distilbert"
|
|
62
|
+
use_gpu: bool = False
|
|
63
|
+
|
|
64
|
+
Fields:
|
|
65
|
+
app_name: Human-readable analyzer name
|
|
66
|
+
app_version: Semantic version string
|
|
67
|
+
debug: Enable debug mode (hot reload, verbose logging)
|
|
68
|
+
log_level: Python log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
|
69
|
+
host: Server bind address
|
|
70
|
+
port: Server bind port
|
|
71
|
+
platform_url: URL of the SPOT API gateway for config fetching
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
75
|
+
|
|
76
|
+
app_name: str = "spot-analyzer"
|
|
77
|
+
app_version: str = "1.0.0"
|
|
78
|
+
debug: bool = False
|
|
79
|
+
log_level: str = "INFO"
|
|
80
|
+
|
|
81
|
+
# Server
|
|
82
|
+
host: str = "0.0.0.0"
|
|
83
|
+
port: int = 8000
|
|
84
|
+
|
|
85
|
+
# Platform URL for ConfigClient
|
|
86
|
+
platform_url: str = "http://api-gateway:8000"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def settings_singleton(func: Callable[[], T]) -> Callable[[], T]:
|
|
90
|
+
"""Decorator that turns a settings factory into a cached singleton.
|
|
91
|
+
|
|
92
|
+
Usage::
|
|
93
|
+
|
|
94
|
+
@settings_singleton
|
|
95
|
+
def get_settings() -> Settings:
|
|
96
|
+
return Settings()
|
|
97
|
+
|
|
98
|
+
# First call creates the instance, subsequent calls return cached value
|
|
99
|
+
settings = get_settings()
|
|
100
|
+
|
|
101
|
+
The cache can be cleared by setting the internal list element to None::
|
|
102
|
+
|
|
103
|
+
get_settings._instance[0] = None
|
|
104
|
+
"""
|
|
105
|
+
instance: list[T | None] = [None]
|
|
106
|
+
|
|
107
|
+
@wraps(func)
|
|
108
|
+
def wrapper() -> T:
|
|
109
|
+
if instance[0] is None:
|
|
110
|
+
instance[0] = func()
|
|
111
|
+
result = instance[0]
|
|
112
|
+
assert result is not None
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
# Allow cache clearing for testing
|
|
116
|
+
wrapper._instance = instance # type: ignore[attr-defined]
|
|
117
|
+
return wrapper
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def merge_settings(base: T, overrides: dict[str, Any]) -> T:
|
|
121
|
+
"""Merge base settings with central config overrides.
|
|
122
|
+
|
|
123
|
+
Only applies overrides for fields that actually exist in the Settings model.
|
|
124
|
+
Returns a new Settings instance (the original is not mutated).
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
base: Base Settings instance from local env/defaults
|
|
128
|
+
overrides: Dict of override values from central config
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
New Settings instance with overrides applied.
|
|
132
|
+
"""
|
|
133
|
+
valid_keys = set(type(base).model_fields.keys())
|
|
134
|
+
filtered = {k: v for k, v in overrides.items() if k in valid_keys}
|
|
135
|
+
return base.model_copy(update=filtered)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class AnalyzerState:
|
|
139
|
+
"""Shared mutable state for an analyzer application.
|
|
140
|
+
|
|
141
|
+
Holds the settings, config client, and analysis service references
|
|
142
|
+
that are initialized during the lifespan and used by endpoints.
|
|
143
|
+
|
|
144
|
+
Attributes:
|
|
145
|
+
settings: Current merged settings (None before startup)
|
|
146
|
+
config_client: ConfigClient for central config fetching
|
|
147
|
+
analysis_service: The analyzer's AnalysisService instance (None before startup)
|
|
148
|
+
settings_factory: Callable that returns a fresh Settings instance
|
|
149
|
+
on_reload: Optional async callback invoked after config reload
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def __init__(
|
|
153
|
+
self,
|
|
154
|
+
config_client: ConfigClient,
|
|
155
|
+
settings_factory: Callable[[], BaseAnalyzerSettings],
|
|
156
|
+
) -> None:
|
|
157
|
+
self.settings: BaseAnalyzerSettings | None = None
|
|
158
|
+
self.config_client = config_client
|
|
159
|
+
self.analysis_service: Any = None
|
|
160
|
+
self.settings_factory = settings_factory
|
|
161
|
+
self.on_reload: Callable[[], Awaitable[None]] | None = None
|
|
162
|
+
|
|
163
|
+
def apply_settings(self) -> BaseAnalyzerSettings:
|
|
164
|
+
"""Load base settings, merge with central overrides, configure logging.
|
|
165
|
+
|
|
166
|
+
Updates ``self.settings`` with the merged result and configures
|
|
167
|
+
the logging level. Does NOT touch ``self.analysis_service`` --
|
|
168
|
+
that is the caller's responsibility.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Merged settings instance.
|
|
172
|
+
"""
|
|
173
|
+
base = self.settings_factory()
|
|
174
|
+
overrides = self.config_client.config
|
|
175
|
+
self.settings = merge_settings(base, overrides)
|
|
176
|
+
configure_logging(log_level=self.settings.log_level)
|
|
177
|
+
return self.settings
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def create_analyzer_app(
|
|
181
|
+
*,
|
|
182
|
+
title: str,
|
|
183
|
+
description: str,
|
|
184
|
+
analyzer_id: str,
|
|
185
|
+
settings_factory: Callable[[], BaseAnalyzerSettings],
|
|
186
|
+
platform_url: str | None = None,
|
|
187
|
+
lifespan: Any | None = None,
|
|
188
|
+
) -> tuple[FastAPI, AnalyzerState]:
|
|
189
|
+
"""Create a FastAPI app with standard analyzer endpoints.
|
|
190
|
+
|
|
191
|
+
Returns the app and a state object. The caller is responsible for:
|
|
192
|
+
|
|
193
|
+
1. Adding a lifespan or startup handler that calls
|
|
194
|
+
``state.config_client.fetch_config()``, ``state.apply_settings()``,
|
|
195
|
+
and sets ``state.analysis_service``.
|
|
196
|
+
2. Adding a ``/health`` endpoint (health checks are analyzer-specific).
|
|
197
|
+
3. Adding a ``/internal/analyze`` endpoint (pre-checks are analyzer-specific).
|
|
198
|
+
4. Optionally setting ``state.on_reload`` for post-reload logic.
|
|
199
|
+
|
|
200
|
+
Standard endpoints provided:
|
|
201
|
+
|
|
202
|
+
- GET /capabilities -- delegates to analysis_service.get_capabilities()
|
|
203
|
+
- GET /config -- delegates to analysis_service.get_configuration()
|
|
204
|
+
- POST /admin/reload-config -- reloads central config, calls state.on_reload if set
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
title: FastAPI app title
|
|
208
|
+
description: FastAPI app description
|
|
209
|
+
analyzer_id: Unique analyzer identifier for config client
|
|
210
|
+
settings_factory: Callable returning a Settings instance
|
|
211
|
+
platform_url: Override platform URL (default: from settings or env)
|
|
212
|
+
lifespan: Optional lifespan async context manager for the app
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Tuple of (FastAPI app, AnalyzerState).
|
|
216
|
+
"""
|
|
217
|
+
initial_settings = settings_factory()
|
|
218
|
+
|
|
219
|
+
# Determine platform URL
|
|
220
|
+
if platform_url is None:
|
|
221
|
+
_platform_url = getattr(
|
|
222
|
+
initial_settings,
|
|
223
|
+
"platform_url",
|
|
224
|
+
os.getenv("SPOT_PLATFORM_URL", "http://api-gateway:8000"),
|
|
225
|
+
)
|
|
226
|
+
else:
|
|
227
|
+
_platform_url = platform_url
|
|
228
|
+
|
|
229
|
+
default_version = initial_settings.app_version
|
|
230
|
+
|
|
231
|
+
config_client = ConfigClient(
|
|
232
|
+
analyzer_id=analyzer_id,
|
|
233
|
+
platform_url=_platform_url,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
state = AnalyzerState(
|
|
237
|
+
config_client=config_client,
|
|
238
|
+
settings_factory=settings_factory,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
app_kwargs: dict[str, Any] = {
|
|
242
|
+
"title": title,
|
|
243
|
+
"description": description,
|
|
244
|
+
"version": default_version,
|
|
245
|
+
}
|
|
246
|
+
if lifespan is not None:
|
|
247
|
+
app_kwargs["lifespan"] = lifespan
|
|
248
|
+
|
|
249
|
+
app = FastAPI(**app_kwargs)
|
|
250
|
+
|
|
251
|
+
# --- Standard endpoints ---
|
|
252
|
+
|
|
253
|
+
@app.get("/capabilities")
|
|
254
|
+
async def get_capabilities() -> list[str]:
|
|
255
|
+
"""Get analyzer capabilities."""
|
|
256
|
+
if state.analysis_service is None:
|
|
257
|
+
return []
|
|
258
|
+
capabilities = await state.analysis_service.get_capabilities()
|
|
259
|
+
return [cap.value for cap in capabilities]
|
|
260
|
+
|
|
261
|
+
@app.get("/config")
|
|
262
|
+
async def get_configuration() -> dict[str, Any]:
|
|
263
|
+
"""Get analyzer configuration."""
|
|
264
|
+
if state.analysis_service is None:
|
|
265
|
+
return {}
|
|
266
|
+
config: dict[str, Any] = await state.analysis_service.get_configuration()
|
|
267
|
+
return config
|
|
268
|
+
|
|
269
|
+
@app.post("/admin/reload-config", status_code=204, response_class=Response)
|
|
270
|
+
async def reload_config() -> Response:
|
|
271
|
+
"""Reload configuration from central platform.
|
|
272
|
+
|
|
273
|
+
Called by spot-cli config reload when analyzer config changes.
|
|
274
|
+
Fetches fresh central overrides, re-applies settings, and
|
|
275
|
+
invokes the on_reload callback if one is registered.
|
|
276
|
+
"""
|
|
277
|
+
logger.info("Reloading configuration...")
|
|
278
|
+
|
|
279
|
+
# Fetch fresh central overrides
|
|
280
|
+
await config_client.reload()
|
|
281
|
+
|
|
282
|
+
# Apply settings
|
|
283
|
+
state.apply_settings()
|
|
284
|
+
|
|
285
|
+
# Invoke analyzer-specific post-reload logic
|
|
286
|
+
if state.on_reload is not None:
|
|
287
|
+
await state.on_reload()
|
|
288
|
+
|
|
289
|
+
logger.info(
|
|
290
|
+
"Configuration reloaded (version: %s)",
|
|
291
|
+
config_client.version or "local-only",
|
|
292
|
+
)
|
|
293
|
+
return Response(status_code=204)
|
|
294
|
+
|
|
295
|
+
return app, state
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def run_analyzer(app_import_path: str, settings: BaseAnalyzerSettings) -> None:
|
|
299
|
+
"""Run an analyzer with uvicorn using standard settings.
|
|
300
|
+
|
|
301
|
+
Intended for use in ``if __name__ == "__main__":`` blocks::
|
|
302
|
+
|
|
303
|
+
if __name__ == "__main__":
|
|
304
|
+
run_analyzer("src.main:app", get_settings())
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
app_import_path: Dotted import path to the app (e.g. "src.main:app")
|
|
308
|
+
settings: Settings instance for host/port/log_level/debug
|
|
309
|
+
"""
|
|
310
|
+
uvicorn.run(
|
|
311
|
+
app_import_path,
|
|
312
|
+
host=settings.host,
|
|
313
|
+
port=settings.port,
|
|
314
|
+
log_level=settings.log_level.lower(),
|
|
315
|
+
reload=settings.debug,
|
|
316
|
+
)
|