homesec 0.1.1__py3-none-any.whl → 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.
Files changed (44) hide show
  1. homesec/app.py +34 -36
  2. homesec/cli.py +14 -11
  3. homesec/config/loader.py +11 -11
  4. homesec/config/validation.py +2 -5
  5. homesec/errors.py +2 -4
  6. homesec/health/server.py +29 -27
  7. homesec/interfaces.py +11 -6
  8. homesec/logging_setup.py +9 -5
  9. homesec/maintenance/cleanup_clips.py +2 -3
  10. homesec/models/__init__.py +1 -1
  11. homesec/models/alert.py +2 -0
  12. homesec/models/clip.py +8 -1
  13. homesec/models/config.py +9 -13
  14. homesec/models/events.py +14 -0
  15. homesec/models/filter.py +1 -3
  16. homesec/models/vlm.py +1 -2
  17. homesec/pipeline/core.py +15 -32
  18. homesec/plugins/alert_policies/__init__.py +3 -4
  19. homesec/plugins/alert_policies/default.py +3 -2
  20. homesec/plugins/alert_policies/noop.py +1 -2
  21. homesec/plugins/analyzers/__init__.py +3 -4
  22. homesec/plugins/analyzers/openai.py +34 -43
  23. homesec/plugins/filters/__init__.py +3 -4
  24. homesec/plugins/filters/yolo.py +27 -29
  25. homesec/plugins/notifiers/__init__.py +2 -1
  26. homesec/plugins/notifiers/mqtt.py +16 -17
  27. homesec/plugins/notifiers/multiplex.py +3 -2
  28. homesec/plugins/notifiers/sendgrid_email.py +6 -8
  29. homesec/plugins/storage/__init__.py +3 -4
  30. homesec/plugins/storage/dropbox.py +20 -17
  31. homesec/plugins/storage/local.py +3 -1
  32. homesec/plugins/utils.py +2 -1
  33. homesec/repository/clip_repository.py +5 -4
  34. homesec/sources/base.py +2 -2
  35. homesec/sources/local_folder.py +9 -7
  36. homesec/sources/rtsp.py +22 -10
  37. homesec/state/postgres.py +34 -35
  38. homesec/telemetry/db_log_handler.py +3 -2
  39. {homesec-0.1.1.dist-info → homesec-1.0.0.dist-info}/METADATA +39 -31
  40. homesec-1.0.0.dist-info/RECORD +62 -0
  41. homesec-0.1.1.dist-info/RECORD +0 -62
  42. {homesec-0.1.1.dist-info → homesec-1.0.0.dist-info}/WHEEL +0 -0
  43. {homesec-0.1.1.dist-info → homesec-1.0.0.dist-info}/entry_points.txt +0 -0
  44. {homesec-0.1.1.dist-info → homesec-1.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -62,7 +62,7 @@ class _Counts:
62
62
  delete_errors: int = 0
63
63
  state_errors: int = 0
64
64
 
65
- def __add__(self, other: "_Counts") -> "_Counts":
65
+ def __add__(self, other: _Counts) -> _Counts:
66
66
  return _Counts(
67
67
  scanned_rows=self.scanned_rows + other.scanned_rows,
68
68
  candidates=self.candidates + other.candidates,
@@ -206,8 +206,7 @@ async def run_cleanup(opts: CleanupOptions) -> None:
206
206
  candidates: list[tuple[str, ClipStateData, datetime]] = [
207
207
  (clip_id, state, created_at)
208
208
  for clip_id, state, created_at in rows
209
- if state.filter_result is not None
210
- and not state.filter_result.detected_classes
209
+ if state.filter_result is not None and not state.filter_result.detected_classes
211
210
  ]
212
211
  totals = totals + _Counts(candidates=len(candidates))
213
212
 
@@ -5,11 +5,11 @@ from homesec.models.clip import Clip, ClipStateData, _resolve_forward_refs
5
5
  from homesec.models.config import (
6
6
  AlertPolicyConfig,
7
7
  AlertPolicyOverrides,
8
- DefaultAlertPolicySettings,
9
8
  CameraConfig,
10
9
  CameraSourceConfig,
11
10
  ConcurrencyConfig,
12
11
  Config,
12
+ DefaultAlertPolicySettings,
13
13
  DropboxStorageConfig,
14
14
  HealthConfig,
15
15
  LocalStorageConfig,
homesec/models/alert.py CHANGED
@@ -3,10 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from datetime import datetime
6
+
6
7
  from pydantic import BaseModel
7
8
 
8
9
  from homesec.models.vlm import RiskLevel, SequenceAnalysis
9
10
 
11
+
10
12
  class AlertDecision(BaseModel):
11
13
  """Decision whether to send an alert for a clip."""
12
14
 
homesec/models/clip.py CHANGED
@@ -64,8 +64,15 @@ class ClipStateData(BaseModel):
64
64
  # Resolve forward references after imports are available
65
65
  def _resolve_forward_refs() -> None:
66
66
  """Resolve forward references in ClipStateData."""
67
+ # Explicitly import types to make them available for model_rebuild
67
68
  from homesec.models.alert import AlertDecision
68
69
  from homesec.models.filter import FilterResult
69
70
  from homesec.models.vlm import AnalysisResult
70
71
 
71
- ClipStateData.model_rebuild()
72
+ ClipStateData.model_rebuild(
73
+ _types_namespace={
74
+ "FilterResult": FilterResult,
75
+ "AnalysisResult": AnalysisResult,
76
+ "AlertDecision": AlertDecision,
77
+ }
78
+ )
homesec/models/config.py CHANGED
@@ -9,10 +9,8 @@ from pydantic import BaseModel, Field, model_validator
9
9
  from homesec.models.filter import FilterConfig
10
10
  from homesec.models.source import FtpSourceConfig, LocalFolderSourceConfig, RTSPSourceConfig
11
11
  from homesec.models.vlm import (
12
- OpenAILLMConfig,
13
12
  RiskLevel,
14
13
  VLMConfig,
15
- VLMPreprocessConfig,
16
14
  )
17
15
 
18
16
 
@@ -40,7 +38,7 @@ class AlertPolicyConfig(BaseModel):
40
38
  config: dict[str, object] = Field(default_factory=dict)
41
39
 
42
40
  @model_validator(mode="after")
43
- def _validate_alert_policy(self) -> "AlertPolicyConfig":
41
+ def _validate_alert_policy(self) -> AlertPolicyConfig:
44
42
  if self.backend == "default":
45
43
  DefaultAlertPolicySettings.model_validate(self.config)
46
44
  return self
@@ -86,7 +84,7 @@ class StorageConfig(BaseModel):
86
84
  paths: StoragePathsConfig = Field(default_factory=StoragePathsConfig)
87
85
 
88
86
  @model_validator(mode="after")
89
- def _validate_builtin_backends(self) -> "StorageConfig":
87
+ def _validate_builtin_backends(self) -> StorageConfig:
90
88
  """Validate that built-in backends have their required config.
91
89
 
92
90
  Third-party backends are validated later in create_storage().
@@ -117,7 +115,7 @@ class StateStoreConfig(BaseModel):
117
115
  dsn: str | None = None
118
116
 
119
117
  @model_validator(mode="after")
120
- def _validate_backend(self) -> "StateStoreConfig":
118
+ def _validate_backend(self) -> StateStoreConfig:
121
119
  if not (self.dsn_env or self.dsn):
122
120
  raise ValueError("state_store.dsn_env or state_store.dsn required for postgres")
123
121
  return self
@@ -174,7 +172,7 @@ class SendGridEmailConfig(BaseModel):
174
172
  "<p><strong>Activity:</strong> {activity_type}</p>"
175
173
  "<p><strong>Reason:</strong> {notify_reason}</p>"
176
174
  "<p><strong>Summary:</strong> {summary}</p>"
177
- "<p><strong>View:</strong> <a href=\"{view_url}\">{view_url}</a></p>"
175
+ '<p><strong>View:</strong> <a href="{view_url}">{view_url}</a></p>'
178
176
  "<p><strong>Storage:</strong> {storage_uri}</p>"
179
177
  "<p><strong>Time:</strong> {ts}</p>"
180
178
  "<p><strong>Upload failed:</strong> {upload_failed}</p>"
@@ -186,7 +184,7 @@ class SendGridEmailConfig(BaseModel):
186
184
  api_base: str = "https://api.sendgrid.com/v3"
187
185
 
188
186
  @model_validator(mode="after")
189
- def _validate_templates(self) -> "SendGridEmailConfig":
187
+ def _validate_templates(self) -> SendGridEmailConfig:
190
188
  if not self.text_template and not self.html_template:
191
189
  raise ValueError("sendgrid_email requires at least one of text_template/html_template")
192
190
  return self
@@ -245,9 +243,7 @@ class CameraSourceConfig(BaseModel):
245
243
  return data
246
244
  source_type = data.get("type")
247
245
  raw_config = data.get("config")
248
- if isinstance(
249
- raw_config, (RTSPSourceConfig, LocalFolderSourceConfig, FtpSourceConfig)
250
- ):
246
+ if isinstance(raw_config, (RTSPSourceConfig, LocalFolderSourceConfig, FtpSourceConfig)):
251
247
  return data
252
248
 
253
249
  config_data = raw_config or {}
@@ -262,7 +258,7 @@ class CameraSourceConfig(BaseModel):
262
258
  return updated
263
259
 
264
260
  @model_validator(mode="after")
265
- def _validate_source_config(self) -> "CameraSourceConfig":
261
+ def _validate_source_config(self) -> CameraSourceConfig:
266
262
  match self.type:
267
263
  case "rtsp":
268
264
  if not isinstance(self.config, RTSPSourceConfig):
@@ -308,13 +304,13 @@ class Config(BaseModel):
308
304
  per_camera_alert: dict[str, AlertPolicyOverrides] = Field(default_factory=dict)
309
305
 
310
306
  @model_validator(mode="after")
311
- def _validate_notifiers(self) -> "Config":
307
+ def _validate_notifiers(self) -> Config:
312
308
  if not self.notifiers:
313
309
  raise ValueError("notifiers must include at least one notifier")
314
310
  return self
315
311
 
316
312
  @model_validator(mode="after")
317
- def _validate_builtin_plugin_configs(self) -> "Config":
313
+ def _validate_builtin_plugin_configs(self) -> Config:
318
314
  """Validate built-in plugin configs for early error detection.
319
315
 
320
316
  Third-party plugin configs are validated later during plugin loading.
homesec/models/events.py CHANGED
@@ -12,6 +12,7 @@ from homesec.models.filter import FilterResult
12
12
 
13
13
  class ClipEvent(BaseModel):
14
14
  """Base class for all clip lifecycle events."""
15
+
15
16
  id: int | None = None
16
17
  clip_id: str
17
18
  timestamp: datetime
@@ -53,6 +54,7 @@ class ClipRecheckedEvent(ClipEvent):
53
54
 
54
55
  class UploadStartedEvent(ClipEvent):
55
56
  """Upload to storage backend started."""
57
+
56
58
  event_type: Literal["upload_started"] = "upload_started"
57
59
  dest_key: str
58
60
  attempt: int
@@ -60,6 +62,7 @@ class UploadStartedEvent(ClipEvent):
60
62
 
61
63
  class UploadCompletedEvent(ClipEvent):
62
64
  """Upload to storage backend completed successfully."""
65
+
63
66
  event_type: Literal["upload_completed"] = "upload_completed"
64
67
  storage_uri: str
65
68
  view_url: str | None
@@ -69,6 +72,7 @@ class UploadCompletedEvent(ClipEvent):
69
72
 
70
73
  class UploadFailedEvent(ClipEvent):
71
74
  """Upload to storage backend failed."""
75
+
72
76
  event_type: Literal["upload_failed"] = "upload_failed"
73
77
  attempt: int
74
78
  error_message: str
@@ -78,12 +82,14 @@ class UploadFailedEvent(ClipEvent):
78
82
 
79
83
  class FilterStartedEvent(ClipEvent):
80
84
  """Object detection filter started."""
85
+
81
86
  event_type: Literal["filter_started"] = "filter_started"
82
87
  attempt: int
83
88
 
84
89
 
85
90
  class FilterCompletedEvent(ClipEvent):
86
91
  """Object detection filter completed."""
92
+
87
93
  event_type: Literal["filter_completed"] = "filter_completed"
88
94
  detected_classes: list[str]
89
95
  confidence: float
@@ -95,6 +101,7 @@ class FilterCompletedEvent(ClipEvent):
95
101
 
96
102
  class FilterFailedEvent(ClipEvent):
97
103
  """Object detection filter failed."""
104
+
98
105
  event_type: Literal["filter_failed"] = "filter_failed"
99
106
  attempt: int
100
107
  error_message: str
@@ -104,12 +111,14 @@ class FilterFailedEvent(ClipEvent):
104
111
 
105
112
  class VLMStartedEvent(ClipEvent):
106
113
  """VLM analysis started."""
114
+
107
115
  event_type: Literal["vlm_started"] = "vlm_started"
108
116
  attempt: int
109
117
 
110
118
 
111
119
  class VLMCompletedEvent(ClipEvent):
112
120
  """VLM analysis completed."""
121
+
113
122
  event_type: Literal["vlm_completed"] = "vlm_completed"
114
123
  risk_level: str
115
124
  activity_type: str
@@ -123,6 +132,7 @@ class VLMCompletedEvent(ClipEvent):
123
132
 
124
133
  class VLMFailedEvent(ClipEvent):
125
134
  """VLM analysis failed."""
135
+
126
136
  event_type: Literal["vlm_failed"] = "vlm_failed"
127
137
  attempt: int
128
138
  error_message: str
@@ -132,12 +142,14 @@ class VLMFailedEvent(ClipEvent):
132
142
 
133
143
  class VLMSkippedEvent(ClipEvent):
134
144
  """VLM analysis skipped (no trigger classes detected)."""
145
+
135
146
  event_type: Literal["vlm_skipped"] = "vlm_skipped"
136
147
  reason: str
137
148
 
138
149
 
139
150
  class AlertDecisionMadeEvent(ClipEvent):
140
151
  """Alert policy decision made."""
152
+
141
153
  event_type: Literal["alert_decision_made"] = "alert_decision_made"
142
154
  should_notify: bool
143
155
  reason: str
@@ -147,6 +159,7 @@ class AlertDecisionMadeEvent(ClipEvent):
147
159
 
148
160
  class NotificationSentEvent(ClipEvent):
149
161
  """Notification sent successfully."""
162
+
150
163
  event_type: Literal["notification_sent"] = "notification_sent"
151
164
  notifier_name: str
152
165
  dedupe_key: str
@@ -155,6 +168,7 @@ class NotificationSentEvent(ClipEvent):
155
168
 
156
169
  class NotificationFailedEvent(ClipEvent):
157
170
  """Notification send failed."""
171
+
158
172
  event_type: Literal["notification_failed"] = "notification_failed"
159
173
  notifier_name: str
160
174
  error_message: str
homesec/models/filter.py CHANGED
@@ -2,9 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Literal
6
-
7
- from pydantic import BaseModel, Field, model_validator
5
+ from pydantic import BaseModel, Field
8
6
 
9
7
 
10
8
  class FilterResult(BaseModel):
homesec/models/vlm.py CHANGED
@@ -4,8 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from typing import Literal
6
6
 
7
- from pydantic import BaseModel, Field, model_validator
8
-
7
+ from pydantic import BaseModel, Field
9
8
 
10
9
  RiskLevel = Literal["low", "medium", "high", "critical"]
11
10
 
homesec/pipeline/core.py CHANGED
@@ -16,8 +16,8 @@ from homesec.models.clip import Clip
16
16
  from homesec.models.config import Config
17
17
  from homesec.models.filter import FilterResult
18
18
  from homesec.models.vlm import AnalysisResult
19
- from homesec.repository import ClipRepository
20
19
  from homesec.plugins.notifiers.multiplex import NotifierEntry
20
+ from homesec.repository import ClipRepository
21
21
  from homesec.storage_paths import build_clip_path
22
22
 
23
23
  if TYPE_CHECKING:
@@ -42,7 +42,7 @@ class UploadOutcome:
42
42
 
43
43
  class ClipPipeline:
44
44
  """Orchestrates clip processing through all pipeline stages.
45
-
45
+
46
46
  Implements error-as-value pattern: stage methods return Result | Error
47
47
  instead of raising. This enables partial failures (e.g., upload fails
48
48
  but filter+notify still run).
@@ -56,7 +56,7 @@ class ClipPipeline:
56
56
  filter_plugin: ObjectFilter,
57
57
  vlm_plugin: VLMAnalyzer,
58
58
  notifier: Notifier,
59
- alert_policy: "AlertPolicy",
59
+ alert_policy: AlertPolicy,
60
60
  notifier_entries: list[NotifierEntry] | None = None,
61
61
  ) -> None:
62
62
  """Initialize pipeline with all dependencies."""
@@ -66,9 +66,7 @@ class ClipPipeline:
66
66
  self._filter = filter_plugin
67
67
  self._vlm = vlm_plugin
68
68
  self._notifier = notifier
69
- self._notifier_entries = self._resolve_notifier_entries(
70
- notifier, notifier_entries
71
- )
69
+ self._notifier_entries = self._resolve_notifier_entries(notifier, notifier_entries)
72
70
  self._alert_policy = alert_policy
73
71
 
74
72
  # Track in-flight processing
@@ -79,7 +77,7 @@ class ClipPipeline:
79
77
  self._sem_upload = asyncio.Semaphore(config.concurrency.upload_workers)
80
78
  self._sem_filter = asyncio.Semaphore(config.concurrency.filter_workers)
81
79
  self._sem_vlm = asyncio.Semaphore(config.concurrency.vlm_workers)
82
-
80
+
83
81
  # Event loop for thread-safe callback handling
84
82
  self._loop: asyncio.AbstractEventLoop | None = None
85
83
 
@@ -95,7 +93,7 @@ class ClipPipeline:
95
93
 
96
94
  def set_event_loop(self, loop: asyncio.AbstractEventLoop) -> None:
97
95
  """Set event loop for thread-safe callback handling.
98
-
96
+
99
97
  Must be called before registering with ClipSource if source
100
98
  runs in a different thread.
101
99
  """
@@ -119,7 +117,7 @@ class ClipPipeline:
119
117
 
120
118
  def on_new_clip(self, clip: Clip) -> None:
121
119
  """Callback for ClipSource when new clip is ready.
122
-
120
+
123
121
  Thread-safe: can be called from any thread. Uses stored event loop
124
122
  if available, otherwise tries to get current loop.
125
123
  """
@@ -146,7 +144,7 @@ class ClipPipeline:
146
144
 
147
145
  async def _process_clip(self, clip: Clip) -> None:
148
146
  """Process a single clip through all stages.
149
-
147
+
150
148
  Flow:
151
149
  1. Parallel: upload + filter
152
150
  2. Conditional: VLM (if filter detects trigger classes)
@@ -214,9 +212,7 @@ class ClipPipeline:
214
212
  analysis_result.activity_type,
215
213
  )
216
214
  case _:
217
- raise TypeError(
218
- f"Unexpected VLM result type: {type(vlm_result).__name__}"
219
- )
215
+ raise TypeError(f"Unexpected VLM result type: {type(vlm_result).__name__}")
220
216
  else:
221
217
  await self._repository.record_vlm_skipped(
222
218
  clip.clip_id,
@@ -286,8 +282,7 @@ class ClipPipeline:
286
282
  op: Callable[[], Awaitable[TResult]],
287
283
  on_attempt_start: Callable[[int], Awaitable[None]] | None = None,
288
284
  on_attempt_success: Callable[[TResult, int, int], Awaitable[None]] | None = None,
289
- on_attempt_failure: Callable[[Exception, int, bool, int], Awaitable[None]]
290
- | None = None,
285
+ on_attempt_failure: Callable[[Exception, int, bool, int], Awaitable[None]] | None = None,
291
286
  ) -> TResult:
292
287
  """Run stage with retry logic and event emission."""
293
288
  max_attempts = max(1, int(self._config.retry.max_attempts))
@@ -432,9 +427,7 @@ class ClipPipeline:
432
427
 
433
428
  async def attempt() -> AnalysisResult:
434
429
  async with self._sem_vlm:
435
- return await self._vlm.analyze(
436
- clip.local_path, filter_result, self._config.vlm
437
- )
430
+ return await self._vlm.analyze(clip.local_path, filter_result, self._config.vlm)
438
431
 
439
432
  async def on_attempt_start(attempt_num: int) -> None:
440
433
  await self._repository.record_vlm_started(clip.clip_id, attempt=attempt_num)
@@ -501,10 +494,7 @@ class ClipPipeline:
501
494
  vlm_failed=vlm_failed,
502
495
  )
503
496
 
504
- tasks = [
505
- self._notify_with_entry(entry, alert)
506
- for entry in self._notifier_entries
507
- ]
497
+ tasks = [self._notify_with_entry(entry, alert) for entry in self._notifier_entries]
508
498
  results = await asyncio.gather(*tasks)
509
499
 
510
500
  errors: list[NotifyError] = []
@@ -515,9 +505,7 @@ class ClipPipeline:
515
505
  case None:
516
506
  continue
517
507
  case _:
518
- raise TypeError(
519
- f"Unexpected notify result type: {type(result).__name__}"
520
- )
508
+ raise TypeError(f"Unexpected notify result type: {type(result).__name__}")
521
509
 
522
510
  if errors:
523
511
  return errors[0]
@@ -530,9 +518,7 @@ class ClipPipeline:
530
518
  ) -> None | NotifyError:
531
519
  notifier_name = entry.name
532
520
 
533
- async def on_attempt_success(
534
- _result: object, attempt_num: int, _duration_ms: int
535
- ) -> None:
521
+ async def on_attempt_success(_result: object, attempt_num: int, _duration_ms: int) -> None:
536
522
  await self._repository.record_notification_sent(
537
523
  alert.clip_id,
538
524
  notifier_name=notifier_name,
@@ -609,13 +595,10 @@ class ClipPipeline:
609
595
  storage_uri = outcome.storage_uri
610
596
  view_url = outcome.view_url
611
597
  case _:
612
- raise TypeError(
613
- f"Unexpected upload result type: {type(upload_result).__name__}"
614
- )
598
+ raise TypeError(f"Unexpected upload result type: {type(upload_result).__name__}")
615
599
  logger.info("Upload complete for %s: %s", clip.clip_id, storage_uri)
616
600
  return storage_uri, view_url, False
617
601
 
618
-
619
602
  async def shutdown(self, timeout: float = 30.0) -> None:
620
603
  """Graceful shutdown of pipeline.
621
604
 
@@ -3,8 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ from collections.abc import Callable
6
7
  from dataclasses import dataclass
7
- from typing import Callable, TypeVar
8
+ from typing import TypeVar
8
9
 
9
10
  from pydantic import BaseModel
10
11
 
@@ -14,9 +15,7 @@ from homesec.models.config import AlertPolicyOverrides
14
15
  logger = logging.getLogger(__name__)
15
16
 
16
17
 
17
- AlertPolicyFactory = Callable[
18
- [BaseModel, dict[str, AlertPolicyOverrides], list[str]], AlertPolicy
19
- ]
18
+ AlertPolicyFactory = Callable[[BaseModel, dict[str, AlertPolicyOverrides], list[str]], AlertPolicy]
20
19
 
21
20
 
22
21
  @dataclass(frozen=True)
@@ -83,8 +83,9 @@ class DefaultAlertPolicy(AlertPolicy):
83
83
 
84
84
  # Plugin registration
85
85
  from pydantic import BaseModel
86
- from homesec.plugins.alert_policies import AlertPolicyPlugin, alert_policy_plugin
86
+
87
87
  from homesec.interfaces import AlertPolicy
88
+ from homesec.plugins.alert_policies import AlertPolicyPlugin, alert_policy_plugin
88
89
 
89
90
 
90
91
  @alert_policy_plugin(name="default")
@@ -94,7 +95,7 @@ def default_alert_policy_plugin() -> AlertPolicyPlugin:
94
95
  Returns:
95
96
  AlertPolicyPlugin for default risk-based alert policy
96
97
  """
97
- from homesec.models.config import AlertPolicyOverrides, DefaultAlertPolicySettings
98
+ from homesec.models.config import DefaultAlertPolicySettings
98
99
 
99
100
  def factory(
100
101
  cfg: BaseModel,
@@ -32,9 +32,8 @@ class NoopAlertPolicy(AlertPolicy):
32
32
 
33
33
 
34
34
  # Plugin registration
35
- from pydantic import BaseModel
36
- from homesec.plugins.alert_policies import AlertPolicyPlugin, alert_policy_plugin
37
35
  from homesec.interfaces import AlertPolicy
36
+ from homesec.plugins.alert_policies import AlertPolicyPlugin, alert_policy_plugin
38
37
 
39
38
 
40
39
  @alert_policy_plugin(name="noop")
@@ -3,8 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ from collections.abc import Callable
6
7
  from dataclasses import dataclass
7
- from typing import Callable, TYPE_CHECKING, TypeVar
8
+ from typing import TYPE_CHECKING, TypeVar
8
9
 
9
10
  from pydantic import BaseModel
10
11
 
@@ -93,9 +94,7 @@ def load_vlm_plugin(config: VLMConfig) -> VLMAnalyzer:
93
94
 
94
95
  if plugin_name not in VLM_REGISTRY:
95
96
  available = ", ".join(sorted(VLM_REGISTRY.keys()))
96
- raise ValueError(
97
- f"Unknown VLM plugin: '{plugin_name}'. Available: {available}"
98
- )
97
+ raise ValueError(f"Unknown VLM plugin: '{plugin_name}'. Available: {available}")
99
98
 
100
99
  plugin = VLM_REGISTRY[plugin_name]
101
100