homesec 1.1.1__py3-none-any.whl → 1.1.2__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 (45) hide show
  1. homesec/__init__.py +1 -1
  2. homesec/app.py +38 -84
  3. homesec/cli.py +6 -10
  4. homesec/config/validation.py +38 -12
  5. homesec/interfaces.py +50 -2
  6. homesec/maintenance/cleanup_clips.py +4 -4
  7. homesec/models/__init__.py +6 -5
  8. homesec/models/alert.py +3 -2
  9. homesec/models/clip.py +4 -2
  10. homesec/models/config.py +62 -17
  11. homesec/models/enums.py +114 -0
  12. homesec/models/events.py +19 -18
  13. homesec/models/filter.py +13 -3
  14. homesec/models/source.py +3 -0
  15. homesec/models/vlm.py +18 -7
  16. homesec/plugins/__init__.py +7 -33
  17. homesec/plugins/alert_policies/__init__.py +34 -59
  18. homesec/plugins/alert_policies/default.py +20 -45
  19. homesec/plugins/alert_policies/noop.py +14 -29
  20. homesec/plugins/analyzers/__init__.py +20 -105
  21. homesec/plugins/analyzers/openai.py +70 -53
  22. homesec/plugins/filters/__init__.py +18 -102
  23. homesec/plugins/filters/yolo.py +103 -66
  24. homesec/plugins/notifiers/__init__.py +20 -56
  25. homesec/plugins/notifiers/mqtt.py +22 -30
  26. homesec/plugins/notifiers/sendgrid_email.py +34 -32
  27. homesec/plugins/registry.py +160 -0
  28. homesec/plugins/sources/__init__.py +45 -0
  29. homesec/plugins/sources/ftp.py +25 -0
  30. homesec/plugins/sources/local_folder.py +30 -0
  31. homesec/plugins/sources/rtsp.py +27 -0
  32. homesec/plugins/storage/__init__.py +18 -88
  33. homesec/plugins/storage/dropbox.py +36 -37
  34. homesec/plugins/storage/local.py +8 -29
  35. homesec/plugins/utils.py +8 -4
  36. homesec/repository/clip_repository.py +20 -14
  37. homesec/sources/base.py +24 -2
  38. homesec/sources/local_folder.py +57 -78
  39. homesec/state/postgres.py +46 -17
  40. {homesec-1.1.1.dist-info → homesec-1.1.2.dist-info}/METADATA +1 -1
  41. homesec-1.1.2.dist-info/RECORD +68 -0
  42. homesec-1.1.1.dist-info/RECORD +0 -62
  43. {homesec-1.1.1.dist-info → homesec-1.1.2.dist-info}/WHEEL +0 -0
  44. {homesec-1.1.1.dist-info → homesec-1.1.2.dist-info}/entry_points.txt +0 -0
  45. {homesec-1.1.1.dist-info → homesec-1.1.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,160 @@
1
+ """Unified Plugin Registry for HomeSec.
2
+
3
+ This module provides the core infrastructure for the Class-Based Plugin Architecture.
4
+ It handles plugin registration, discovery, and strict configuration validation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from collections.abc import Callable
11
+ from enum import Enum
12
+ from typing import Any, Generic, Protocol, TypeVar, cast
13
+
14
+ from pydantic import BaseModel
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class PluginType(str, Enum):
20
+ """Categorization of plugin types."""
21
+
22
+ SOURCE = "source"
23
+ FILTER = "filter"
24
+ ANALYZER = "analyzer"
25
+ STORAGE = "storage"
26
+ NOTIFIER = "notifier"
27
+ ALERT_POLICY = "alert_policy"
28
+
29
+
30
+ ConfigT = TypeVar("ConfigT", bound=BaseModel)
31
+ PluginInterfaceT = TypeVar("PluginInterfaceT", bound=object, covariant=True)
32
+
33
+
34
+ class PluginProtocol(Protocol[ConfigT, PluginInterfaceT]):
35
+ """Protocol defining the structure of a valid HomeSec plugin class."""
36
+
37
+ config_cls: type[ConfigT]
38
+
39
+ @classmethod
40
+ def create(cls, config: ConfigT) -> PluginInterfaceT:
41
+ """Factory method to create the plugin instance."""
42
+ ...
43
+
44
+
45
+ class PluginRegistry(Generic[ConfigT, PluginInterfaceT]):
46
+ """Generic registry for a specific type of plugin."""
47
+
48
+ def __init__(self, plugin_type: PluginType) -> None:
49
+ self.plugin_type = plugin_type
50
+ self._plugins: dict[str, type[PluginProtocol[ConfigT, PluginInterfaceT]]] = {}
51
+
52
+ def register(
53
+ self, name: str, plugin_cls: type[PluginProtocol[ConfigT, PluginInterfaceT]]
54
+ ) -> None:
55
+ """Register a plugin class."""
56
+ if name in self._plugins:
57
+ raise ValueError(f"{self.plugin_type} plugin '{name}' is already registered.")
58
+
59
+ self._plugins[name] = plugin_cls
60
+ logger.debug("Registered %s plugin: %s", self.plugin_type, name)
61
+
62
+ def load(
63
+ self, name: str, config_dict: dict[str, Any], **runtime_context: Any
64
+ ) -> PluginInterfaceT:
65
+ """Load and instantiate a plugin.
66
+
67
+ Args:
68
+ name: The name of the plugin to load.
69
+ config_dict: Raw configuration dictionary (from YAML/JSON).
70
+ **runtime_context: Key-value pairs to inject into the config *before* validation.
71
+ (e.g. camera_name="front_door")
72
+
73
+ Returns:
74
+ An instantiated and configured plugin object.
75
+
76
+ Raises:
77
+ ValueError: If the plugin name is unknown.
78
+ ValidationError: If configuration is invalid.
79
+ """
80
+ if name not in self._plugins:
81
+ available = ", ".join(sorted(self._plugins.keys()))
82
+ raise ValueError(f"Unknown {self.plugin_type} plugin: '{name}'. Available: {available}")
83
+
84
+ plugin_cls = self._plugins[name]
85
+
86
+ # 1. Inject runtime context into config (if the config model supports those fields)
87
+ # We merge it into the raw dict so Pydantic can validate it.
88
+ # This allows injecting "camera_name" into SourceConfig, etc.
89
+ merged_config = config_dict.copy()
90
+ merged_config.update(runtime_context)
91
+
92
+ # 2. Validate configuration
93
+ validated_config = plugin_cls.config_cls.model_validate(merged_config)
94
+
95
+ # 3. Create instance
96
+ return plugin_cls.create(validated_config)
97
+
98
+ def get_all(self) -> dict[str, type[PluginProtocol[ConfigT, PluginInterfaceT]]]:
99
+ """Return all registered plugins."""
100
+ return self._plugins.copy()
101
+
102
+
103
+ # Global Registry Storage
104
+ # We keep separate registries per type for strict typing
105
+ _REGISTRIES: dict[PluginType, PluginRegistry[Any, Any]] = {t: PluginRegistry(t) for t in PluginType}
106
+
107
+
108
+ def plugin(plugin_type: PluginType, name: str) -> Callable[[type], type]:
109
+ """Decorator to register a class as a plugin.
110
+
111
+ Args:
112
+ plugin_type: The category of plugin (SOURCE, FILTER, etc.)
113
+ name: The unique name for this plugin (e.g., "rtsp", "yolo")
114
+ """
115
+
116
+ def decorator(cls: type) -> type:
117
+ # Runtime verification could happen here, or we trust static analysis/Protocol
118
+ if not hasattr(cls, "config_cls"):
119
+ raise TypeError(f"Plugin class {cls.__name__} must define 'config_cls'")
120
+ if not hasattr(cls, "create"):
121
+ raise TypeError(f"Plugin class {cls.__name__} must define 'create' classmethod")
122
+
123
+ registry = _REGISTRIES[plugin_type]
124
+ # We cast because the decorator is generic but _REGISTRIES holds specific types
125
+ registry.register(name, cls)
126
+
127
+ # Attach metadata for inspection if needed
128
+ cast(Any, cls).__plugin_name__ = name
129
+ cast(Any, cls).__plugin_type__ = plugin_type
130
+
131
+ return cls
132
+
133
+ return decorator
134
+
135
+
136
+ def load_plugin(
137
+ plugin_type: PluginType, name: str, config: dict[str, Any] | BaseModel, **runtime_context: Any
138
+ ) -> Any:
139
+ """Public API to load any plugin.
140
+
141
+ Args:
142
+ plugin_type: The enum type of plugin to load.
143
+ name: The plugin name (e.g. "rtsp").
144
+ config: The raw config dict OR an already-validated BaseModel.
145
+ runtime_context: Dependencies to inject (camera_name, etc.)
146
+ """
147
+ registry = _REGISTRIES[plugin_type]
148
+
149
+ # Handle case where config is already a BaseModel (e.g. nested in AppConfig)
150
+ if isinstance(config, BaseModel):
151
+ config_dict = config.model_dump()
152
+ else:
153
+ config_dict = config
154
+
155
+ return registry.load(name, config_dict, **runtime_context)
156
+
157
+
158
+ def get_plugin_names(plugin_type: PluginType) -> list[str]:
159
+ """Get list of registered plugin names for a given type."""
160
+ return sorted(_REGISTRIES[plugin_type].get_all().keys())
@@ -0,0 +1,45 @@
1
+ """Source plugins and registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import cast
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from homesec.interfaces import ClipSource
11
+ from homesec.plugins.registry import PluginType, load_plugin
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def load_source_plugin(
17
+ source_type: str,
18
+ config: dict[str, object] | BaseModel,
19
+ camera_name: str,
20
+ ) -> ClipSource:
21
+ """Load and instantiate a source plugin.
22
+
23
+ Args:
24
+ source_type: Name of the source plugin (e.g., "rtsp", "local_folder")
25
+ config: Raw config dict or validated BaseModel
26
+ camera_name: Name of the camera (runtime context)
27
+
28
+ Returns:
29
+ Instantiated ClipSource
30
+
31
+ Raises:
32
+ ValueError: If source_type is unknown or config validation fails
33
+ """
34
+ return cast(
35
+ ClipSource,
36
+ load_plugin(
37
+ PluginType.SOURCE,
38
+ source_type,
39
+ config,
40
+ camera_name=camera_name,
41
+ ),
42
+ )
43
+
44
+
45
+ __all__ = ["load_source_plugin"]
@@ -0,0 +1,25 @@
1
+ """FTP source plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from homesec.interfaces import ClipSource
6
+ from homesec.models.source import FtpSourceConfig
7
+ from homesec.plugins.registry import PluginType, plugin
8
+
9
+ # Import the actual implementation from sources module
10
+ from homesec.sources.ftp import FtpSource as FtpSourceImpl
11
+
12
+
13
+ @plugin(plugin_type=PluginType.SOURCE, name="ftp")
14
+ class FtpPluginSource(FtpSourceImpl):
15
+ """FTP source plugin wrapper."""
16
+
17
+ config_cls = FtpSourceConfig
18
+
19
+ @classmethod
20
+ def create(cls, config: FtpSourceConfig) -> ClipSource:
21
+ # FtpSourceImpl expects config and camera_name
22
+ # config.camera_name is populated by registry
23
+ if config.camera_name is None:
24
+ raise ValueError("camera_name is required for FtpSource")
25
+ return cls(config=config, camera_name=config.camera_name)
@@ -0,0 +1,30 @@
1
+ """Local folder source plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from homesec.interfaces import ClipSource
6
+ from homesec.models.source import LocalFolderSourceConfig
7
+ from homesec.plugins.registry import PluginType, plugin
8
+ from homesec.sources.local_folder import LocalFolderSource as LocalFolderSourceImpl
9
+
10
+
11
+ @plugin(plugin_type=PluginType.SOURCE, name="local_folder")
12
+ class LocalFolderPlugin(LocalFolderSourceImpl):
13
+ """Register local_folder source plugin."""
14
+
15
+ config_cls = LocalFolderSourceConfig
16
+
17
+ @classmethod
18
+ def create(cls, config: LocalFolderSourceConfig) -> ClipSource:
19
+ # Note: LocalFolderSourceImpl takes (config) in __init__?
20
+ # Let's check LocalFolderSourceImpl signature.
21
+ # It inherits from ClipSource.
22
+ # Wait, usually implementation classes take specific args, not the config object?
23
+ # I need to check src/homesec/sources/local_folder.py.
24
+ # But assuming refactor, I should match what create does.
25
+ # Old create: LocalFolderSourceImpl(config=config, camera_name=context.camera_name)
26
+ # config now has camera_name injected.
27
+ return cls(config=config, camera_name=config.camera_name)
28
+
29
+
30
+ __all__ = ["LocalFolderPlugin"]
@@ -0,0 +1,27 @@
1
+ """RTSP source plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from homesec.interfaces import ClipSource
8
+ from homesec.models.source import RTSPSourceConfig
9
+ from homesec.plugins.registry import PluginType, plugin
10
+ from homesec.sources.rtsp import RTSPSource as RTSPSourceImpl
11
+
12
+ if TYPE_CHECKING:
13
+ pass
14
+
15
+
16
+ @plugin(plugin_type=PluginType.SOURCE, name="rtsp")
17
+ class RTSPPluginSource(RTSPSourceImpl):
18
+ """RTSP source plugin wrapper."""
19
+
20
+ config_cls = RTSPSourceConfig
21
+
22
+ @classmethod
23
+ def create(cls, config: RTSPSourceConfig) -> ClipSource:
24
+ # RTSPSourceImpl expects config and camera_name
25
+ if config.camera_name is None:
26
+ raise ValueError("camera_name is required for RTSPSource")
27
+ return cls(config=config, camera_name=config.camera_name)
@@ -3,78 +3,17 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
- from collections.abc import Callable
7
- from dataclasses import dataclass
8
- from typing import TypeVar
9
-
10
- from pydantic import BaseModel
6
+ from typing import cast
11
7
 
12
8
  from homesec.interfaces import StorageBackend
13
9
  from homesec.models.config import StorageConfig
10
+ from homesec.plugins.registry import PluginType, load_plugin
14
11
 
15
12
  logger = logging.getLogger(__name__)
16
13
 
17
- # Type alias for clarity
18
- StorageFactory = Callable[[BaseModel], StorageBackend]
19
-
20
-
21
- @dataclass(frozen=True)
22
- class StoragePlugin:
23
- """Metadata for a storage backend plugin."""
24
-
25
- name: str
26
- config_model: type[BaseModel]
27
- factory: StorageFactory
28
-
29
-
30
- STORAGE_REGISTRY: dict[str, StoragePlugin] = {}
31
-
32
-
33
- def register_storage(plugin: StoragePlugin) -> None:
34
- """Register a storage plugin with collision detection.
35
-
36
- Args:
37
- plugin: Storage plugin to register
38
-
39
- Raises:
40
- ValueError: If a plugin with the same name is already registered
41
- """
42
- if plugin.name in STORAGE_REGISTRY:
43
- raise ValueError(
44
- f"Storage plugin '{plugin.name}' is already registered. "
45
- f"Plugin names must be unique across all storage plugins."
46
- )
47
- STORAGE_REGISTRY[plugin.name] = plugin
48
-
49
14
 
50
- T = TypeVar("T", bound=Callable[[], StoragePlugin])
51
-
52
-
53
- def storage_plugin(name: str) -> Callable[[T], T]:
54
- """Decorator to register a storage backend plugin.
55
-
56
- Usage:
57
- @storage_plugin(name="my_storage")
58
- def my_storage_plugin() -> StoragePlugin:
59
- return StoragePlugin(...)
60
-
61
- Args:
62
- name: Plugin name (for validation only - must match plugin.name)
63
-
64
- Returns:
65
- Decorator function that registers the plugin
66
- """
67
-
68
- def decorator(factory_fn: T) -> T:
69
- plugin = factory_fn()
70
- register_storage(plugin)
71
- return factory_fn
72
-
73
- return decorator
74
-
75
-
76
- def create_storage(config: StorageConfig) -> StorageBackend:
77
- """Create storage backend from config using plugin registry.
15
+ def load_storage_plugin(config: StorageConfig) -> StorageBackend:
16
+ """Load and instantiate a storage backend plugin.
78
17
 
79
18
  Args:
80
19
  config: Storage configuration with backend name and backend-specific settings
@@ -83,33 +22,24 @@ def create_storage(config: StorageConfig) -> StorageBackend:
83
22
  Instantiated storage backend
84
23
 
85
24
  Raises:
86
- RuntimeError: If backend is unknown or backend-specific config is missing
25
+ ValueError: If backend is unknown or backend-specific config is missing
87
26
  """
88
- backend_name = config.backend.lower()
89
-
90
- if backend_name not in STORAGE_REGISTRY:
91
- available = ", ".join(sorted(STORAGE_REGISTRY.keys()))
92
- raise RuntimeError(f"Unknown storage backend: '{backend_name}'. Available: {available}")
93
-
94
- plugin = STORAGE_REGISTRY[backend_name]
95
-
96
27
  # Extract backend-specific config using attribute access
97
- # e.g., config.dropbox, config.local
98
- specific_config = getattr(config, backend_name, None)
28
+ specific_config = getattr(config, config.backend.lower(), None)
99
29
  if specific_config is None:
100
- raise RuntimeError(
101
- f"Missing '{backend_name}' config in storage section. "
102
- f"Add 'storage.{backend_name}' to your config."
30
+ raise ValueError(
31
+ f"Missing '{config.backend.lower()}' config in storage section. "
32
+ f"Add 'storage.{config.backend.lower()}' to your config."
103
33
  )
104
34
 
105
- return plugin.factory(specific_config)
35
+ return cast(
36
+ StorageBackend,
37
+ load_plugin(
38
+ PluginType.STORAGE,
39
+ config.backend,
40
+ specific_config,
41
+ ),
42
+ )
106
43
 
107
44
 
108
- __all__ = [
109
- "StoragePlugin",
110
- "StorageFactory",
111
- "STORAGE_REGISTRY",
112
- "register_storage",
113
- "storage_plugin",
114
- "create_storage",
115
- ]
45
+ __all__ = ["load_storage_plugin"]
@@ -6,19 +6,29 @@ import asyncio
6
6
  import logging
7
7
  import os
8
8
  from pathlib import Path, PurePosixPath
9
- from typing import BinaryIO
9
+ from typing import Any, BinaryIO
10
+
11
+ dropbox: Any
12
+
13
+ try:
14
+ import dropbox as _dropbox # type: ignore[import-untyped]
15
+ except Exception:
16
+ dropbox = None
17
+ else:
18
+ dropbox = _dropbox
10
19
 
11
- import dropbox # type: ignore
12
20
 
13
21
  from homesec.interfaces import StorageBackend
14
22
  from homesec.models.config import DropboxStorageConfig
15
23
  from homesec.models.storage import StorageUploadResult
24
+ from homesec.plugins.registry import PluginType, plugin
16
25
 
17
26
  logger = logging.getLogger(__name__)
18
27
 
19
28
  CHUNK_SIZE = 4 * 1024 * 1024
20
29
 
21
30
 
31
+ @plugin(plugin_type=PluginType.STORAGE, name="dropbox")
22
32
  class DropboxStorage(StorageBackend):
23
33
  """Dropbox storage backend.
24
34
 
@@ -30,6 +40,12 @@ class DropboxStorage(StorageBackend):
30
40
  2. Refresh token flow: Set DROPBOX_APP_KEY, DROPBOX_APP_SECRET, DROPBOX_REFRESH_TOKEN
31
41
  """
32
42
 
43
+ config_cls = DropboxStorageConfig
44
+
45
+ @classmethod
46
+ def create(cls, config: DropboxStorageConfig) -> StorageBackend:
47
+ return cls(config)
48
+
33
49
  def __init__(self, config: DropboxStorageConfig) -> None:
34
50
  """Initialize Dropbox storage with config validation.
35
51
 
@@ -47,6 +63,8 @@ class DropboxStorage(StorageBackend):
47
63
  self.web_url_prefix = str(config.web_url_prefix)
48
64
 
49
65
  # Initialize Dropbox client using env vars
66
+ if dropbox is None:
67
+ raise RuntimeError("Missing dependency: dropbox. Install with: uv pip install dropbox")
50
68
  self.client = self._create_client(config)
51
69
  self._shutdown_called = False
52
70
 
@@ -57,12 +75,16 @@ class DropboxStorage(StorageBackend):
57
75
 
58
76
  Tries simple token first, then falls back to refresh token flow.
59
77
  """
78
+ if dropbox is None:
79
+ raise RuntimeError("Missing dependency: dropbox. Install with: uv pip install dropbox")
80
+ dbx = dropbox
81
+
60
82
  # Try simple token auth first
61
83
  token_var = str(config.token_env)
62
84
  token = os.getenv(token_var)
63
85
  if token:
64
86
  logger.info("Using Dropbox simple token auth")
65
- return dropbox.Dropbox(token)
87
+ return dbx.Dropbox(token)
66
88
 
67
89
  # Try refresh token flow
68
90
  app_key_var = str(config.app_key_env)
@@ -75,7 +97,7 @@ class DropboxStorage(StorageBackend):
75
97
 
76
98
  if app_key and app_secret and refresh_token:
77
99
  logger.info("Using Dropbox refresh token auth")
78
- return dropbox.Dropbox(
100
+ return dbx.Dropbox(
79
101
  app_key=app_key,
80
102
  app_secret=app_secret,
81
103
  oauth2_refresh_token=refresh_token,
@@ -110,13 +132,16 @@ class DropboxStorage(StorageBackend):
110
132
 
111
133
  def _upload_file(self, local_path: Path, dest_path: str) -> None:
112
134
  """Upload file (blocking operation)."""
135
+ if dropbox is None:
136
+ raise RuntimeError("Missing dependency: dropbox. Install with: uv pip install dropbox")
137
+ dbx = dropbox
113
138
  file_size = local_path.stat().st_size
114
139
  with open(local_path, "rb") as f:
115
140
  if file_size <= CHUNK_SIZE:
116
141
  self.client.files_upload(
117
142
  f.read(),
118
143
  dest_path,
119
- mode=dropbox.files.WriteMode.overwrite,
144
+ mode=dbx.files.WriteMode.overwrite,
120
145
  )
121
146
  else:
122
147
  self._upload_file_chunked(f, dest_path, file_size)
@@ -129,13 +154,16 @@ class DropboxStorage(StorageBackend):
129
154
  raise ValueError("Cannot upload empty file")
130
155
 
131
156
  session = self.client.files_upload_session_start(chunk)
132
- cursor = dropbox.files.UploadSessionCursor(
157
+ if dropbox is None:
158
+ raise RuntimeError("Missing dependency: dropbox. Install with: uv pip install dropbox")
159
+ dbx = dropbox
160
+ cursor = dbx.files.UploadSessionCursor(
133
161
  session_id=session.session_id,
134
162
  offset=file_handle.tell(),
135
163
  )
136
- commit = dropbox.files.CommitInfo(
164
+ commit = dbx.files.CommitInfo(
137
165
  path=dest_path,
138
- mode=dropbox.files.WriteMode.overwrite,
166
+ mode=dbx.files.WriteMode.overwrite,
139
167
  )
140
168
 
141
169
  while file_handle.tell() < file_size:
@@ -244,32 +272,3 @@ class DropboxStorage(StorageBackend):
244
272
  if path.is_absolute() or ".." in path.parts:
245
273
  raise ValueError(f"Invalid dest_path: {dest_path}")
246
274
  return f"{self.root}/{path}"
247
-
248
-
249
- # Plugin registration
250
- from typing import cast
251
-
252
- from pydantic import BaseModel
253
-
254
- from homesec.interfaces import StorageBackend
255
- from homesec.plugins.storage import StoragePlugin, storage_plugin
256
-
257
-
258
- @storage_plugin(name="dropbox")
259
- def dropbox_storage_plugin() -> StoragePlugin:
260
- """Dropbox storage plugin factory.
261
-
262
- Returns:
263
- StoragePlugin for Dropbox cloud storage
264
- """
265
- from homesec.models.config import DropboxStorageConfig
266
-
267
- def factory(cfg: BaseModel) -> StorageBackend:
268
- # Config is already validated by pydantic when loaded
269
- return DropboxStorage(cast(DropboxStorageConfig, cfg))
270
-
271
- return StoragePlugin(
272
- name="dropbox",
273
- config_model=DropboxStorageConfig,
274
- factory=factory,
275
- )
@@ -10,13 +10,21 @@ from pathlib import Path, PurePosixPath
10
10
  from homesec.interfaces import StorageBackend
11
11
  from homesec.models.config import LocalStorageConfig
12
12
  from homesec.models.storage import StorageUploadResult
13
+ from homesec.plugins.registry import PluginType, plugin
13
14
 
14
15
  logger = logging.getLogger(__name__)
15
16
 
16
17
 
18
+ @plugin(plugin_type=PluginType.STORAGE, name="local")
17
19
  class LocalStorage(StorageBackend):
18
20
  """Local storage backend for development and tests."""
19
21
 
22
+ config_cls = LocalStorageConfig
23
+
24
+ @classmethod
25
+ def create(cls, config: LocalStorageConfig) -> StorageBackend:
26
+ return cls(config)
27
+
20
28
  def __init__(self, config: LocalStorageConfig) -> None:
21
29
  self.root = Path(config.root).expanduser().resolve()
22
30
  self.root.mkdir(parents=True, exist_ok=True)
@@ -79,32 +87,3 @@ class LocalStorage(StorageBackend):
79
87
  if path.is_absolute() or ".." in path.parts:
80
88
  raise ValueError(f"Invalid dest_path: {dest_path}")
81
89
  return self.root.joinpath(*path.parts)
82
-
83
-
84
- # Plugin registration
85
- from typing import cast
86
-
87
- from pydantic import BaseModel
88
-
89
- from homesec.interfaces import StorageBackend
90
- from homesec.plugins.storage import StoragePlugin, storage_plugin
91
-
92
-
93
- @storage_plugin(name="local")
94
- def local_storage_plugin() -> StoragePlugin:
95
- """Local storage plugin factory.
96
-
97
- Returns:
98
- StoragePlugin for local filesystem storage
99
- """
100
- from homesec.models.config import LocalStorageConfig
101
-
102
- def factory(cfg: BaseModel) -> StorageBackend:
103
- # Config is already validated by pydantic when loaded
104
- return LocalStorage(cast(LocalStorageConfig, cfg))
105
-
106
- return StoragePlugin(
107
- name="local",
108
- config_model=LocalStorageConfig,
109
- factory=factory,
110
- )
homesec/plugins/utils.py CHANGED
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from collections.abc import Iterable
6
6
  from importlib import metadata
7
- from typing import TypeVar, cast
7
+ from typing import TypeVar
8
8
 
9
9
  PluginT = TypeVar("PluginT")
10
10
 
@@ -20,10 +20,14 @@ def iter_entry_points(group: str) -> Iterable[metadata.EntryPoint]:
20
20
  """
21
21
  entry_points = metadata.entry_points()
22
22
  if hasattr(entry_points, "select"):
23
- # Python 3.10+ API
23
+ # Python 3.10+ API - SelectableGroups with .select() method
24
24
  return entry_points.select(group=group)
25
- # Python 3.9 API
26
- return cast(dict[str, list[metadata.EntryPoint]], entry_points).get(group, [])
25
+ # Python 3.9 API - dict[str, list[EntryPoint]]
26
+ # Type validated at runtime: entry_points is dict-like in Python 3.9
27
+ if isinstance(entry_points, dict):
28
+ return entry_points.get(group, [])
29
+ # Fallback for unexpected types - return empty
30
+ return []
27
31
 
28
32
 
29
33
  def load_plugin_from_entry_point(