iflow-mcp_enuno-unifi-mcp-server 0.2.1__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 (81) hide show
  1. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/METADATA +1282 -0
  2. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/RECORD +81 -0
  3. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/WHEEL +4 -0
  4. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/entry_points.txt +2 -0
  5. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/licenses/LICENSE +201 -0
  6. src/__init__.py +3 -0
  7. src/__main__.py +6 -0
  8. src/api/__init__.py +5 -0
  9. src/api/client.py +727 -0
  10. src/api/site_manager_client.py +176 -0
  11. src/cache.py +483 -0
  12. src/config/__init__.py +5 -0
  13. src/config/config.py +321 -0
  14. src/main.py +2234 -0
  15. src/models/__init__.py +126 -0
  16. src/models/acl.py +41 -0
  17. src/models/backup.py +272 -0
  18. src/models/client.py +74 -0
  19. src/models/device.py +53 -0
  20. src/models/dpi.py +50 -0
  21. src/models/firewall_policy.py +123 -0
  22. src/models/firewall_zone.py +28 -0
  23. src/models/network.py +62 -0
  24. src/models/qos_profile.py +458 -0
  25. src/models/radius.py +141 -0
  26. src/models/reference_data.py +34 -0
  27. src/models/site.py +59 -0
  28. src/models/site_manager.py +120 -0
  29. src/models/topology.py +138 -0
  30. src/models/traffic_flow.py +137 -0
  31. src/models/traffic_matching_list.py +56 -0
  32. src/models/voucher.py +42 -0
  33. src/models/vpn.py +73 -0
  34. src/models/wan.py +48 -0
  35. src/models/zbf_matrix.py +49 -0
  36. src/resources/__init__.py +8 -0
  37. src/resources/clients.py +111 -0
  38. src/resources/devices.py +102 -0
  39. src/resources/networks.py +93 -0
  40. src/resources/site_manager.py +64 -0
  41. src/resources/sites.py +86 -0
  42. src/tools/__init__.py +25 -0
  43. src/tools/acls.py +328 -0
  44. src/tools/application.py +42 -0
  45. src/tools/backups.py +1173 -0
  46. src/tools/client_management.py +505 -0
  47. src/tools/clients.py +203 -0
  48. src/tools/device_control.py +325 -0
  49. src/tools/devices.py +354 -0
  50. src/tools/dpi.py +241 -0
  51. src/tools/dpi_tools.py +89 -0
  52. src/tools/firewall.py +417 -0
  53. src/tools/firewall_policies.py +430 -0
  54. src/tools/firewall_zones.py +515 -0
  55. src/tools/network_config.py +388 -0
  56. src/tools/networks.py +190 -0
  57. src/tools/port_forwarding.py +263 -0
  58. src/tools/qos.py +1070 -0
  59. src/tools/radius.py +763 -0
  60. src/tools/reference_data.py +107 -0
  61. src/tools/site_manager.py +466 -0
  62. src/tools/site_vpn.py +95 -0
  63. src/tools/sites.py +187 -0
  64. src/tools/topology.py +406 -0
  65. src/tools/traffic_flows.py +1062 -0
  66. src/tools/traffic_matching_lists.py +371 -0
  67. src/tools/vouchers.py +249 -0
  68. src/tools/vpn.py +76 -0
  69. src/tools/wans.py +30 -0
  70. src/tools/wifi.py +498 -0
  71. src/tools/zbf_matrix.py +326 -0
  72. src/utils/__init__.py +88 -0
  73. src/utils/audit.py +213 -0
  74. src/utils/exceptions.py +114 -0
  75. src/utils/helpers.py +159 -0
  76. src/utils/logger.py +105 -0
  77. src/utils/sanitize.py +244 -0
  78. src/utils/validators.py +160 -0
  79. src/webhooks/__init__.py +6 -0
  80. src/webhooks/handlers.py +196 -0
  81. src/webhooks/receiver.py +290 -0
@@ -0,0 +1,114 @@
1
+ """Custom exception classes for UniFi MCP Server."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ class UniFiMCPException(Exception):
7
+ """Base exception for UniFi MCP Server."""
8
+
9
+ def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
10
+ """Initialize exception with message and optional details.
11
+
12
+ Args:
13
+ message: Human-readable error message
14
+ details: Additional context about the error
15
+ """
16
+ super().__init__(message)
17
+ self.message = message
18
+ self.details = details or {}
19
+
20
+
21
+ class ConfigurationError(UniFiMCPException):
22
+ """Raised when configuration is invalid or missing."""
23
+
24
+ pass
25
+
26
+
27
+ class AuthenticationError(UniFiMCPException):
28
+ """Raised when authentication fails."""
29
+
30
+ pass
31
+
32
+
33
+ class APIError(UniFiMCPException):
34
+ """Raised when UniFi API returns an error."""
35
+
36
+ def __init__(
37
+ self,
38
+ message: str,
39
+ status_code: int | None = None,
40
+ response_data: dict[str, Any] | None = None,
41
+ ) -> None:
42
+ """Initialize API error.
43
+
44
+ Args:
45
+ message: Human-readable error message
46
+ status_code: HTTP status code
47
+ response_data: Raw API response data
48
+ """
49
+ super().__init__(message, {"status_code": status_code, "response": response_data})
50
+ self.status_code = status_code
51
+ self.response_data = response_data
52
+
53
+
54
+ class RateLimitError(APIError):
55
+ """Raised when API rate limit is exceeded."""
56
+
57
+ def __init__(
58
+ self,
59
+ message: str = "Rate limit exceeded",
60
+ retry_after: int | None = None,
61
+ ) -> None:
62
+ """Initialize rate limit error.
63
+
64
+ Args:
65
+ message: Human-readable error message
66
+ retry_after: Seconds until rate limit resets
67
+ """
68
+ super().__init__(message, status_code=429, response_data={"retry_after": retry_after})
69
+ self.retry_after = retry_after
70
+
71
+
72
+ class ResourceNotFoundError(APIError):
73
+ """Raised when requested resource is not found."""
74
+
75
+ def __init__(self, resource_type: str, resource_id: str) -> None:
76
+ """Initialize resource not found error.
77
+
78
+ Args:
79
+ resource_type: Type of resource (site, device, client, etc.)
80
+ resource_id: ID of the resource
81
+ """
82
+ message = f"{resource_type} '{resource_id}' not found"
83
+ super().__init__(message, status_code=404)
84
+ self.resource_type = resource_type
85
+ self.resource_id = resource_id
86
+
87
+
88
+ class ValidationError(UniFiMCPException):
89
+ """Raised when input validation fails."""
90
+
91
+ pass
92
+
93
+
94
+ class NetworkError(UniFiMCPException):
95
+ """Raised when network communication fails."""
96
+
97
+ pass
98
+
99
+
100
+ class ConfirmationRequiredError(UniFiMCPException):
101
+ """Raised when mutating operation requires confirmation."""
102
+
103
+ def __init__(self, operation: str) -> None:
104
+ """Initialize confirmation required error.
105
+
106
+ Args:
107
+ operation: Name of the operation requiring confirmation
108
+ """
109
+ message = (
110
+ f"Operation '{operation}' requires confirmation. "
111
+ "Set confirm=true to proceed with this mutating operation."
112
+ )
113
+ super().__init__(message, {"operation": operation})
114
+ self.operation = operation
src/utils/helpers.py ADDED
@@ -0,0 +1,159 @@
1
+ """Helper utility functions for UniFi MCP Server."""
2
+
3
+ import time
4
+ from datetime import datetime, timezone
5
+ from typing import Any
6
+
7
+
8
+ def get_timestamp() -> int:
9
+ """Get current Unix timestamp in seconds.
10
+
11
+ Returns:
12
+ Current timestamp
13
+ """
14
+ return int(time.time())
15
+
16
+
17
+ def get_iso_timestamp() -> str:
18
+ """Get current ISO 8601 formatted timestamp.
19
+
20
+ Returns:
21
+ ISO formatted timestamp string
22
+ """
23
+ return datetime.now(timezone.utc).isoformat()
24
+
25
+
26
+ def format_uptime(uptime_seconds: int) -> str:
27
+ """Format uptime seconds into human-readable string.
28
+
29
+ Args:
30
+ uptime_seconds: Uptime in seconds
31
+
32
+ Returns:
33
+ Formatted uptime string (e.g., "2d 4h 30m")
34
+ """
35
+ days = uptime_seconds // 86400
36
+ hours = (uptime_seconds % 86400) // 3600
37
+ minutes = (uptime_seconds % 3600) // 60
38
+
39
+ parts = []
40
+ if days > 0:
41
+ parts.append(f"{days}d")
42
+ parts.append(f"{hours}h")
43
+ parts.append(f"{minutes}m")
44
+ elif hours > 0:
45
+ parts.append(f"{hours}h")
46
+ parts.append(f"{minutes}m")
47
+ else:
48
+ parts.append(f"{minutes}m")
49
+
50
+ return " ".join(parts)
51
+
52
+
53
+ def format_bytes(bytes_value: int, precision: int = 2) -> str:
54
+ """Format bytes into human-readable string.
55
+
56
+ Args:
57
+ bytes_value: Number of bytes
58
+ precision: Decimal precision
59
+
60
+ Returns:
61
+ Formatted bytes string (e.g., "1.23 GB")
62
+ """
63
+ bytes_float = float(bytes_value)
64
+ for unit in ["B", "KB", "MB", "GB", "TB", "PB"]:
65
+ if bytes_float < 1024.0:
66
+ return f"{bytes_float:.{precision}f} {unit}"
67
+ bytes_float /= 1024.0
68
+ return f"{bytes_float:.{precision}f} PB"
69
+
70
+
71
+ def format_percentage(value: float, precision: int = 1) -> str:
72
+ """Format value as percentage string.
73
+
74
+ Args:
75
+ value: Decimal value (0.0 to 1.0 or 0 to 100)
76
+ precision: Decimal precision
77
+
78
+ Returns:
79
+ Formatted percentage string (e.g., "45.3%")
80
+ """
81
+ # Handle both 0-1 and 0-100 ranges
82
+ pct = value if value > 1 else value * 100
83
+ return f"{pct:.{precision}f}%"
84
+
85
+
86
+ def sanitize_dict(data: dict[str, Any], exclude_keys: list[str] | None = None) -> dict[str, Any]:
87
+ """Remove sensitive keys from dictionary.
88
+
89
+ Args:
90
+ data: Dictionary to sanitize
91
+ exclude_keys: List of keys to remove (default: common sensitive keys)
92
+
93
+ Returns:
94
+ Sanitized dictionary copy
95
+ """
96
+ if exclude_keys is None:
97
+ exclude_keys = ["password", "api_key", "secret", "token", "x_api_key", "x-api-key"]
98
+
99
+ return {k: v for k, v in data.items() if k.lower() not in [e.lower() for e in exclude_keys]}
100
+
101
+
102
+ def merge_dicts(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
103
+ """Merge two dictionaries, with override taking precedence.
104
+
105
+ Args:
106
+ base: Base dictionary
107
+ override: Override dictionary
108
+
109
+ Returns:
110
+ Merged dictionary
111
+ """
112
+ result = base.copy()
113
+ result.update(override)
114
+ return result
115
+
116
+
117
+ def parse_device_type(model: str) -> str:
118
+ """Parse device type from model string.
119
+
120
+ Args:
121
+ model: Device model string
122
+
123
+ Returns:
124
+ Device type (ap, switch, gateway, etc.)
125
+ """
126
+ model_lower = model.lower()
127
+
128
+ if "uap" in model_lower or "u6" in model_lower or "u7" in model_lower:
129
+ return "ap"
130
+ elif "usw" in model_lower or "switch" in model_lower:
131
+ return "switch"
132
+ elif "usg" in model_lower or "udm" in model_lower or "uxg" in model_lower:
133
+ return "gateway"
134
+ elif "unvr" in model_lower or "nvr" in model_lower:
135
+ return "nvr"
136
+ else:
137
+ return "unknown"
138
+
139
+
140
+ def build_uri(scheme: str, *parts: str, query: dict[str, Any] | None = None) -> str:
141
+ """Build a URI with optional query parameters.
142
+
143
+ Args:
144
+ scheme: URI scheme (e.g., "sites")
145
+ *parts: URI path parts
146
+ query: Optional query parameters
147
+
148
+ Returns:
149
+ Complete URI string
150
+ """
151
+ path = "/".join(str(p) for p in parts if p)
152
+ uri = f"{scheme}://{path}" if path else f"{scheme}://"
153
+
154
+ if query:
155
+ query_str = "&".join(f"{k}={v}" for k, v in query.items() if v is not None)
156
+ if query_str:
157
+ uri += f"?{query_str}"
158
+
159
+ return uri
src/utils/logger.py ADDED
@@ -0,0 +1,105 @@
1
+ """Structured logging configuration for UniFi MCP Server."""
2
+
3
+ import logging
4
+ import sys
5
+ from typing import Any
6
+
7
+ # Configure logging format
8
+ LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
9
+ DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
10
+
11
+
12
+ def get_logger(name: str, level: str | None = None) -> logging.Logger:
13
+ """Get a configured logger instance.
14
+
15
+ Args:
16
+ name: Logger name (typically __name__ of the module)
17
+ level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
18
+
19
+ Returns:
20
+ Configured logger instance
21
+ """
22
+ logger = logging.getLogger(name)
23
+
24
+ # Set log level from parameter or environment variable
25
+ log_level = level or "INFO"
26
+ logger.setLevel(getattr(logging, log_level.upper()))
27
+
28
+ # Avoid duplicate handlers
29
+ if not logger.handlers:
30
+ # Console handler
31
+ handler = logging.StreamHandler(sys.stderr)
32
+ handler.setLevel(logging.DEBUG)
33
+
34
+ # Formatter
35
+ formatter = logging.Formatter(LOG_FORMAT, DATE_FORMAT)
36
+ handler.setFormatter(formatter)
37
+
38
+ logger.addHandler(handler)
39
+
40
+ return logger
41
+
42
+
43
+ def log_api_request(
44
+ logger: logging.Logger,
45
+ method: str,
46
+ url: str,
47
+ status_code: int | None = None,
48
+ duration_ms: float | None = None,
49
+ **kwargs: Any,
50
+ ) -> None:
51
+ """Log API request details in structured format.
52
+
53
+ Args:
54
+ logger: Logger instance
55
+ method: HTTP method
56
+ url: Request URL
57
+ status_code: Response status code
58
+ duration_ms: Request duration in milliseconds
59
+ **kwargs: Additional context to log
60
+ """
61
+ context = {
62
+ "method": method,
63
+ "url": url,
64
+ "status_code": status_code,
65
+ "duration_ms": duration_ms,
66
+ **kwargs,
67
+ }
68
+
69
+ # Remove None values
70
+ context = {k: v for k, v in context.items() if v is not None}
71
+
72
+ if status_code and status_code >= 400:
73
+ logger.error(f"API request failed: {context}")
74
+ else:
75
+ logger.info(f"API request: {context}")
76
+
77
+
78
+ def log_audit_event(
79
+ logger: logging.Logger,
80
+ operation: str,
81
+ resource_type: str,
82
+ resource_id: str,
83
+ success: bool,
84
+ **kwargs: Any,
85
+ ) -> None:
86
+ """Log audit event for mutating operations.
87
+
88
+ Args:
89
+ logger: Logger instance
90
+ operation: Operation performed (create, update, delete, etc.)
91
+ resource_type: Type of resource affected
92
+ resource_id: ID of the resource
93
+ success: Whether operation succeeded
94
+ **kwargs: Additional context to log
95
+ """
96
+ context = {
97
+ "operation": operation,
98
+ "resource_type": resource_type,
99
+ "resource_id": resource_id,
100
+ "success": success,
101
+ **kwargs,
102
+ }
103
+
104
+ level = logging.INFO if success else logging.ERROR
105
+ logger.log(level, f"Audit event: {context}")
src/utils/sanitize.py ADDED
@@ -0,0 +1,244 @@
1
+ """Data sanitization utilities for logging and audit trails.
2
+
3
+ Provides functions to sanitize sensitive data before logging to prevent
4
+ exposure of private information such as MAC addresses, IP addresses,
5
+ client identifiers, and network configuration details.
6
+ """
7
+
8
+ import re
9
+ from typing import Any
10
+
11
+ # Sensitive field patterns
12
+ SENSITIVE_FIELDS = {
13
+ # Network identifiers
14
+ "mac",
15
+ "mac_address",
16
+ "client_mac",
17
+ "device_mac",
18
+ "bssid",
19
+ "oui",
20
+ # IP and network info
21
+ "ip",
22
+ "ip_address",
23
+ "fixed_ip",
24
+ "network_id",
25
+ "subnet",
26
+ "gateway",
27
+ "dns",
28
+ "dhcp_start",
29
+ "dhcp_stop",
30
+ # Device identifiers
31
+ "device_id",
32
+ "client_id",
33
+ "user_id",
34
+ "site_id",
35
+ "serial",
36
+ "serial_number",
37
+ # Authentication
38
+ "password",
39
+ "passphrase",
40
+ "psk",
41
+ "key",
42
+ "secret",
43
+ "token",
44
+ "api_key",
45
+ # Personal information
46
+ "email",
47
+ "hostname",
48
+ "name",
49
+ "username",
50
+ "user",
51
+ # Location
52
+ "latitude",
53
+ "longitude",
54
+ "location",
55
+ }
56
+
57
+ # Partial redaction patterns (show last N characters)
58
+ PARTIAL_REDACT_FIELDS = {
59
+ "mac",
60
+ "mac_address",
61
+ "client_mac",
62
+ "device_mac",
63
+ "ip",
64
+ "ip_address",
65
+ }
66
+
67
+
68
+ def _redact_value(key: str, value: Any, partial: bool = True) -> str:
69
+ """Redact a sensitive value.
70
+
71
+ Args:
72
+ key: Field name
73
+ value: Value to redact
74
+ partial: If True, show last few characters for debugging
75
+
76
+ Returns:
77
+ Redacted string representation
78
+ """
79
+ if value is None:
80
+ return "None"
81
+
82
+ str_value = str(value)
83
+
84
+ # For MAC addresses and IPs, show last segment for debugging
85
+ if partial and key.lower() in PARTIAL_REDACT_FIELDS:
86
+ # MAC address: show last octet (XX:XX:XX:XX:XX:AB)
87
+ if ":" in str_value and len(str_value) == 17:
88
+ return f"**:**:**:**:**:{str_value[-2:]}"
89
+ # IP address: show last octet (XXX.XXX.XXX.123)
90
+ if "." in str_value and re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", str_value):
91
+ return f"***.***.***.{str_value.split('.')[-1]}"
92
+
93
+ # Full redaction
94
+ if len(str_value) <= 4:
95
+ return "***"
96
+ return f"***{str_value[-2:]}" if partial else "***"
97
+
98
+
99
+ def sanitize_dict(data: dict[str, Any], partial: bool = True) -> dict[str, Any]:
100
+ """Sanitize sensitive fields in a dictionary.
101
+
102
+ Args:
103
+ data: Dictionary potentially containing sensitive data
104
+ partial: If True, partially redact some fields for debugging
105
+
106
+ Returns:
107
+ Dictionary with sensitive fields redacted
108
+ """
109
+ if not isinstance(data, dict):
110
+ return data
111
+
112
+ sanitized = {}
113
+ for key, value in data.items():
114
+ key_lower = key.lower()
115
+
116
+ # Check if this key is sensitive
117
+ is_sensitive = any(pattern in key_lower for pattern in SENSITIVE_FIELDS)
118
+
119
+ if is_sensitive:
120
+ # Redact the value
121
+ sanitized[key] = _redact_value(key_lower, value, partial)
122
+ elif isinstance(value, dict):
123
+ # Recursively sanitize nested dictionaries
124
+ sanitized[key] = sanitize_dict(value, partial)
125
+ elif isinstance(value, list):
126
+ # Sanitize lists
127
+ sanitized[key] = [
128
+ sanitize_dict(item, partial) if isinstance(item, dict) else item for item in value
129
+ ]
130
+ else:
131
+ # Keep non-sensitive values as-is
132
+ sanitized[key] = value
133
+
134
+ return sanitized
135
+
136
+
137
+ def sanitize_list(data: list[Any], partial: bool = True) -> list[Any]:
138
+ """Sanitize sensitive fields in a list of dictionaries.
139
+
140
+ Args:
141
+ data: List of dictionaries potentially containing sensitive data
142
+ partial: If True, partially redact some fields for debugging
143
+
144
+ Returns:
145
+ List with sensitive fields redacted
146
+ """
147
+ if not isinstance(data, list):
148
+ return data
149
+
150
+ return [sanitize_dict(item, partial) if isinstance(item, dict) else item for item in data]
151
+
152
+
153
+ def sanitize_log_message(message: str, context: dict[str, Any] | None = None) -> str:
154
+ """Sanitize a log message and optional context.
155
+
156
+ Args:
157
+ message: Log message to sanitize
158
+ context: Optional context dictionary to include
159
+
160
+ Returns:
161
+ Sanitized log message with context
162
+ """
163
+ # Sanitize common patterns in the message itself
164
+ sanitized_msg = message
165
+
166
+ # Redact MAC addresses (XX:XX:XX:XX:XX:XX)
167
+ sanitized_msg = re.sub(
168
+ r"\b([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})\b",
169
+ lambda m: f"**:**:**:**:**:{m.group(2)}",
170
+ sanitized_msg,
171
+ )
172
+
173
+ # Redact IP addresses (XXX.XXX.XXX.XXX)
174
+ sanitized_msg = re.sub(
175
+ r"\b(\d{1,3}\.){3}(\d{1,3})\b",
176
+ lambda m: f"***.***.***.{m.group(2)}" if m.group(0) != "0.0.0.0" else m.group(0),
177
+ sanitized_msg,
178
+ )
179
+
180
+ # Add sanitized context if provided
181
+ if context:
182
+ sanitized_context = sanitize_dict(context)
183
+ sanitized_msg = f"{sanitized_msg} | Context: {sanitized_context}"
184
+
185
+ return sanitized_msg
186
+
187
+
188
+ def is_production() -> bool:
189
+ """Check if running in production environment.
190
+
191
+ Returns:
192
+ True if production, False otherwise
193
+ """
194
+ import os
195
+
196
+ return os.getenv("ENVIRONMENT", "development").lower() in ("production", "prod")
197
+
198
+
199
+ def sanitize_for_logging(
200
+ data: dict[str, Any] | list[Any] | str,
201
+ force_sanitize: bool = False,
202
+ ) -> dict[str, Any] | list[Any] | str:
203
+ """Sanitize data for logging based on environment.
204
+
205
+ In production, always sanitizes. In development, only sanitizes if forced.
206
+
207
+ Args:
208
+ data: Data to potentially sanitize
209
+ force_sanitize: Force sanitization even in development
210
+
211
+ Returns:
212
+ Sanitized or original data based on environment
213
+ """
214
+ # Always sanitize in production or when forced
215
+ if is_production() or force_sanitize:
216
+ if isinstance(data, dict):
217
+ return sanitize_dict(data)
218
+ elif isinstance(data, list):
219
+ return sanitize_list(data)
220
+ elif isinstance(data, str):
221
+ return sanitize_log_message(data)
222
+
223
+ # In development, return original data for debugging
224
+ return data
225
+
226
+
227
+ # Convenience function for backward compatibility
228
+ def sanitize_sensitive_data(
229
+ data: dict[str, Any] | list[Any], partial: bool = True
230
+ ) -> dict[str, Any] | list[Any]:
231
+ """Sanitize sensitive data (legacy function name).
232
+
233
+ Args:
234
+ data: Data to sanitize
235
+ partial: If True, partially redact some fields for debugging
236
+
237
+ Returns:
238
+ Sanitized data
239
+ """
240
+ if isinstance(data, dict):
241
+ return sanitize_dict(data, partial)
242
+ elif isinstance(data, list):
243
+ return sanitize_list(data, partial)
244
+ return data