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.
- homesec/__init__.py +1 -1
- homesec/app.py +38 -84
- homesec/cli.py +6 -10
- homesec/config/validation.py +38 -12
- homesec/interfaces.py +50 -2
- homesec/maintenance/cleanup_clips.py +4 -4
- homesec/models/__init__.py +6 -5
- homesec/models/alert.py +3 -2
- homesec/models/clip.py +4 -2
- homesec/models/config.py +62 -17
- homesec/models/enums.py +114 -0
- homesec/models/events.py +19 -18
- homesec/models/filter.py +13 -3
- homesec/models/source.py +3 -0
- homesec/models/vlm.py +18 -7
- homesec/plugins/__init__.py +7 -33
- homesec/plugins/alert_policies/__init__.py +34 -59
- homesec/plugins/alert_policies/default.py +20 -45
- homesec/plugins/alert_policies/noop.py +14 -29
- homesec/plugins/analyzers/__init__.py +20 -105
- homesec/plugins/analyzers/openai.py +70 -53
- homesec/plugins/filters/__init__.py +18 -102
- homesec/plugins/filters/yolo.py +103 -66
- homesec/plugins/notifiers/__init__.py +20 -56
- homesec/plugins/notifiers/mqtt.py +22 -30
- homesec/plugins/notifiers/sendgrid_email.py +34 -32
- homesec/plugins/registry.py +160 -0
- homesec/plugins/sources/__init__.py +45 -0
- homesec/plugins/sources/ftp.py +25 -0
- homesec/plugins/sources/local_folder.py +30 -0
- homesec/plugins/sources/rtsp.py +27 -0
- homesec/plugins/storage/__init__.py +18 -88
- homesec/plugins/storage/dropbox.py +36 -37
- homesec/plugins/storage/local.py +8 -29
- homesec/plugins/utils.py +8 -4
- homesec/repository/clip_repository.py +20 -14
- homesec/sources/base.py +24 -2
- homesec/sources/local_folder.py +57 -78
- homesec/state/postgres.py +46 -17
- {homesec-1.1.1.dist-info → homesec-1.1.2.dist-info}/METADATA +1 -1
- homesec-1.1.2.dist-info/RECORD +68 -0
- homesec-1.1.1.dist-info/RECORD +0 -62
- {homesec-1.1.1.dist-info → homesec-1.1.2.dist-info}/WHEEL +0 -0
- {homesec-1.1.1.dist-info → homesec-1.1.2.dist-info}/entry_points.txt +0 -0
- {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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
101
|
-
f"Missing '{
|
|
102
|
-
f"Add 'storage.{
|
|
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
|
|
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
|
|
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
|
|
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=
|
|
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
|
-
|
|
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 =
|
|
164
|
+
commit = dbx.files.CommitInfo(
|
|
137
165
|
path=dest_path,
|
|
138
|
-
mode=
|
|
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
|
-
)
|
homesec/plugins/storage/local.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|