pywebguard 0.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 (43) hide show
  1. pywebguard/__init__.py +87 -0
  2. pywebguard/cli.py +1022 -0
  3. pywebguard/core/__init__.py +0 -0
  4. pywebguard/core/base.py +792 -0
  5. pywebguard/core/config.py +455 -0
  6. pywebguard/filters/__init__.py +0 -0
  7. pywebguard/filters/base.py +44 -0
  8. pywebguard/filters/ip_filter.py +233 -0
  9. pywebguard/filters/user_agent.py +102 -0
  10. pywebguard/frameworks/__init__.py +0 -0
  11. pywebguard/frameworks/_fastapi.py +191 -0
  12. pywebguard/frameworks/_flask.py +182 -0
  13. pywebguard/limiters/__init__.py +0 -0
  14. pywebguard/limiters/base.py +46 -0
  15. pywebguard/limiters/rate_limit.py +415 -0
  16. pywebguard/logging/__init__.py +0 -0
  17. pywebguard/logging/backends/__init__.py +17 -0
  18. pywebguard/logging/backends/_elasticsearch.py +307 -0
  19. pywebguard/logging/backends/_meilisearch.py +310 -0
  20. pywebguard/logging/backends/_mongodb.py +303 -0
  21. pywebguard/logging/base.py +117 -0
  22. pywebguard/logging/logger.py +408 -0
  23. pywebguard/security/__init__.py +0 -0
  24. pywebguard/security/base.py +44 -0
  25. pywebguard/security/cors.py +204 -0
  26. pywebguard/security/penetration.py +265 -0
  27. pywebguard/storage/__init__.py +0 -0
  28. pywebguard/storage/_mongodb.py +396 -0
  29. pywebguard/storage/_postgresql.py +539 -0
  30. pywebguard/storage/_redis.py +314 -0
  31. pywebguard/storage/_sqlite.py +374 -0
  32. pywebguard/storage/_tinydb.py +271 -0
  33. pywebguard/storage/base.py +327 -0
  34. pywebguard/storage/memory.py +245 -0
  35. pywebguard/utils/__init__.py +0 -0
  36. pywebguard/utils/ip.py +84 -0
  37. pywebguard/utils/request.py +65 -0
  38. pywebguard-0.0.0.dist-info/LICENSE +21 -0
  39. pywebguard-0.0.0.dist-info/METADATA +368 -0
  40. pywebguard-0.0.0.dist-info/RECORD +43 -0
  41. pywebguard-0.0.0.dist-info/WHEEL +5 -0
  42. pywebguard-0.0.0.dist-info/entry_points.txt +2 -0
  43. pywebguard-0.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,182 @@
1
+ """
2
+ Flask integration for PyWebGuard.
3
+
4
+ This module provides an extension for integrating PyWebGuard with Flask applications.
5
+ It handles request interception, security checks, and response processing.
6
+
7
+ Classes:
8
+ FlaskGuard: Flask extension for PyWebGuard
9
+ """
10
+
11
+ from typing import Optional, Callable, Dict, Any, List, Union, cast
12
+ from functools import wraps
13
+
14
+ # Check if Flask is installed
15
+ try:
16
+ from flask import Flask, request, Response, g, jsonify
17
+
18
+ FLASK_AVAILABLE = True
19
+ except ImportError:
20
+ FLASK_AVAILABLE = False
21
+
22
+ # Create dummy classes for type checking
23
+ class Flask:
24
+ pass
25
+
26
+ class Response:
27
+ pass
28
+
29
+
30
+ from pywebguard.core.base import Guard
31
+ from pywebguard.core.config import GuardConfig
32
+ from pywebguard.storage.base import BaseStorage
33
+
34
+
35
+ class FlaskGuard:
36
+ """
37
+ Flask extension for PyWebGuard.
38
+
39
+ This extension integrates PyWebGuard with Flask applications to provide
40
+ security features like IP filtering, rate limiting, and more.
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ app: Optional[Flask] = None,
46
+ config: Optional[GuardConfig] = None,
47
+ storage: Optional[BaseStorage] = None,
48
+ route_rate_limits: Optional[List[Dict[str, Any]]] = None,
49
+ ):
50
+ """
51
+ Initialize the Flask extension.
52
+
53
+ Args:
54
+ app: Flask application (optional, can be initialized later with init_app)
55
+ config: GuardConfig object with security settings
56
+ storage: Storage backend for persistent data
57
+ route_rate_limits: List of dictionaries with route-specific rate limits
58
+ Each dict should have: endpoint, requests_per_minute, burst_size, auto_ban_threshold (optional)
59
+
60
+ Raises:
61
+ ImportError: If Flask is not installed
62
+ """
63
+ if not FLASK_AVAILABLE:
64
+ raise ImportError(
65
+ "Flask is not installed. Install it with 'pip install pywebguard[flask]' "
66
+ "or 'pip install flask>=2.0.0'"
67
+ )
68
+
69
+ self.guard = Guard(
70
+ config=config, storage=storage, route_rate_limits=route_rate_limits
71
+ )
72
+ # Inject the Flask-specific request info extractor
73
+ self.guard._extract_request_info = self._extract_request_info
74
+
75
+ if app is not None:
76
+ self.init_app(app)
77
+
78
+ def init_app(self, app: Flask) -> None:
79
+ """
80
+ Initialize the extension with a Flask application.
81
+
82
+ Args:
83
+ app: Flask application
84
+ """
85
+
86
+ # Register before_request handler
87
+ @app.before_request
88
+ def before_request() -> Optional[Response]:
89
+ """
90
+ Check if request should be allowed before processing.
91
+
92
+ Returns:
93
+ Response object if request is blocked, None otherwise
94
+ """
95
+ # Check if request should be allowed
96
+ result = self.guard.check_request(request)
97
+
98
+ if not result["allowed"]:
99
+ # Return error response if request is blocked
100
+ return (
101
+ jsonify(
102
+ {
103
+ "detail": "Request blocked by security policy",
104
+ "reason": result["details"],
105
+ }
106
+ ),
107
+ 403,
108
+ )
109
+
110
+ return None
111
+
112
+ # Register after_request handler
113
+ @app.after_request
114
+ def after_request(response: Response) -> Response:
115
+ """
116
+ Process response after request handling.
117
+
118
+ Args:
119
+ response: Flask response object
120
+
121
+ Returns:
122
+ Processed response object
123
+ """
124
+ # Update metrics based on request and response
125
+ self.guard.update_metrics(request, response)
126
+
127
+ # Add CORS headers if needed
128
+ if self.guard.cors_handler.config.enabled:
129
+ self.guard.cors_handler.add_cors_headers(request, response)
130
+
131
+ return response
132
+
133
+ def _extract_request_info(self, request) -> Dict[str, Any]:
134
+ """
135
+ Extract information from a Flask request object.
136
+
137
+ Args:
138
+ request: The Flask request object
139
+
140
+ Returns:
141
+ Dict with request information
142
+ """
143
+ # Get the real IP from headers if available
144
+ client_host = request.remote_addr
145
+ forwarded_for = request.headers.get("X-Forwarded-For")
146
+ if forwarded_for:
147
+ client_host = forwarded_for.split(",")[0].strip()
148
+
149
+ # Always use the string representation of request.user_agent
150
+ user_agent = str(request.user_agent)
151
+
152
+ # Build headers from environ to avoid UserAgent object
153
+ safe_headers = {}
154
+ for k, v in request.environ.items():
155
+ if k.startswith("HTTP_"):
156
+ header = k[5:].replace("_", "-").title()
157
+ safe_headers[header] = str(v)
158
+ # Add Content-Type and Content-Length if present
159
+ if "CONTENT_TYPE" in request.environ:
160
+ safe_headers["Content-Type"] = str(request.environ["CONTENT_TYPE"])
161
+ if "CONTENT_LENGTH" in request.environ:
162
+ safe_headers["Content-Length"] = str(request.environ["CONTENT_LENGTH"])
163
+
164
+ request_info = {
165
+ "ip": client_host,
166
+ "user_agent": str(user_agent),
167
+ "method": request.method,
168
+ "path": request.path,
169
+ "query": request.args.to_dict(),
170
+ "headers": safe_headers,
171
+ }
172
+ return self._sanitize_for_json(request_info)
173
+
174
+ def _sanitize_for_json(self, obj):
175
+ if isinstance(obj, dict):
176
+ return {k: self._sanitize_for_json(v) for k, v in obj.items()}
177
+ elif isinstance(obj, list):
178
+ return [self._sanitize_for_json(v) for v in obj]
179
+ elif isinstance(obj, (str, int, float, bool)) or obj is None:
180
+ return obj
181
+ else:
182
+ return str(obj)
File without changes
@@ -0,0 +1,46 @@
1
+ """
2
+ Base limiter interfaces for PyWebGuard with both sync and async support.
3
+ """
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Dict, Any
7
+
8
+
9
+ class BaseLimiter(ABC):
10
+ """
11
+ Abstract base class for synchronous limiters.
12
+ """
13
+
14
+ @abstractmethod
15
+ def check_limit(self, identifier: str, path: str = None) -> Dict[str, Any]:
16
+ """
17
+ Check if a request should be limited.
18
+
19
+ Args:
20
+ identifier: The identifier to check
21
+ path: The request path (for route-specific rate limiting)
22
+
23
+ Returns:
24
+ Dict with allowed status and details
25
+ """
26
+ pass
27
+
28
+
29
+ class AsyncBaseLimiter(ABC):
30
+ """
31
+ Abstract base class for asynchronous limiters.
32
+ """
33
+
34
+ @abstractmethod
35
+ async def check_limit(self, identifier: str, path: str = None) -> Dict[str, Any]:
36
+ """
37
+ Check if a request should be limited asynchronously.
38
+
39
+ Args:
40
+ identifier: The identifier to check
41
+ path: The request path (for route-specific rate limiting)
42
+
43
+ Returns:
44
+ Dict with allowed status and details
45
+ """
46
+ pass
@@ -0,0 +1,415 @@
1
+ """
2
+ Rate limiting functionality for PyWebGuard with both sync and async support.
3
+ Includes support for per-route rate limiting configurations.
4
+ """
5
+
6
+ from typing import Dict, Any, Optional, Union
7
+ import time
8
+ from pywebguard.core.config import RateLimitConfig
9
+ from pywebguard.storage.base import BaseStorage, AsyncBaseStorage
10
+ from pywebguard.limiters.base import BaseLimiter, AsyncBaseLimiter
11
+
12
+
13
+ class RateLimiter(BaseLimiter):
14
+ """
15
+ Limit request rates based on IP address or other identifiers (synchronous).
16
+ Supports both global and per-route rate limiting.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ config: RateLimitConfig,
22
+ storage: BaseStorage,
23
+ ):
24
+ """
25
+ Initialize the rate limiter.
26
+
27
+ Args:
28
+ config: Rate limit configuration (global default)
29
+ storage: Storage backend for persistent data
30
+ """
31
+ self.config = config
32
+ self.storage = storage
33
+ self.route_configs = {} # Maps route patterns to custom RateLimitConfig objects
34
+
35
+ def add_route_config(
36
+ self, route_pattern: str, config: Union[RateLimitConfig, Dict[str, Any]]
37
+ ):
38
+ """
39
+ Add a custom rate limit configuration for a specific route pattern.
40
+
41
+ Args:
42
+ route_pattern: The route pattern to match (can include wildcards like * and **)
43
+ config: Custom rate limit configuration for this route
44
+ """
45
+ if isinstance(config, dict):
46
+ # Convert dict to RateLimitConfig
47
+ route_config = RateLimitConfig(**config)
48
+ else:
49
+ route_config = config
50
+
51
+ self.route_configs[route_pattern] = route_config
52
+
53
+ def get_config_for_route(self, path: str) -> RateLimitConfig:
54
+ """
55
+ Get the appropriate rate limit configuration for a given path.
56
+
57
+ Args:
58
+ path: The request path
59
+
60
+ Returns:
61
+ The route-specific config if matched, otherwise the default config
62
+ """
63
+ # Check for exact match first
64
+ if path in self.route_configs:
65
+ return self.route_configs[path]
66
+
67
+ # Check for pattern matches
68
+ for pattern, config in self.route_configs.items():
69
+ if self._match_route_pattern(pattern, path):
70
+ return config
71
+
72
+ # Fall back to default config
73
+ return self.config
74
+
75
+ def _match_route_pattern(self, pattern: str, path: str) -> bool:
76
+ """
77
+ Check if a path matches a route pattern.
78
+
79
+ Args:
80
+ pattern: The route pattern (can include wildcards)
81
+ path: The request path
82
+
83
+ Returns:
84
+ True if the path matches the pattern, False otherwise
85
+ """
86
+ # Handle trailing slashes consistently
87
+ if pattern.endswith("/") and not path.endswith("/"):
88
+ pattern = pattern[:-1]
89
+ elif not pattern.endswith("/") and path.endswith("/"):
90
+ path = path[:-1]
91
+
92
+ # Simple wildcard matching implementation
93
+ if pattern == "*":
94
+ return True
95
+
96
+ if pattern.endswith("/**"):
97
+ # Match any path that starts with the pattern prefix
98
+ prefix = pattern[:-3]
99
+ return path.startswith(prefix)
100
+
101
+ if pattern.endswith("/*"):
102
+ # Match any path that starts with the pattern prefix and has no additional segments
103
+ prefix = pattern[:-2]
104
+ if not path.startswith(prefix):
105
+ return False
106
+ remaining = path[len(prefix) :]
107
+ return remaining == "" or (
108
+ remaining.startswith("/") and "/" not in remaining[1:]
109
+ )
110
+
111
+ # Exact match
112
+ return pattern == path
113
+
114
+ def check_limit(self, identifier: str, path: str = None) -> Dict[str, Any]:
115
+ """
116
+ Check if a request should be rate limited.
117
+
118
+ Args:
119
+ identifier: The identifier to check (usually IP address)
120
+ path: The request path (for route-specific rate limiting)
121
+
122
+ Returns:
123
+ Dict with allowed status, remaining requests, and reset time
124
+ """
125
+ # Get the appropriate config for this route
126
+ config = self.config
127
+ matched_pattern = None
128
+ if path is not None:
129
+ # Check for exact match first
130
+ if path in self.route_configs:
131
+ config = self.route_configs[path]
132
+ matched_pattern = path
133
+ else:
134
+ # Check for pattern matches
135
+ for pattern, route_config in self.route_configs.items():
136
+ if self._match_route_pattern(pattern, path):
137
+ config = route_config
138
+ matched_pattern = pattern
139
+ break
140
+
141
+ if not config.enabled:
142
+ return {"allowed": True, "remaining": -1, "reset": -1}
143
+
144
+ current_time = int(time.time())
145
+ current_minute = current_time // 60 # Use minute-based window
146
+
147
+ # Use matched pattern in the rate limit key if found
148
+ path_suffix = f":{matched_pattern}" if matched_pattern else ""
149
+ window_key = f"ratelimit:{identifier}{path_suffix}:{current_minute}"
150
+
151
+ # Get current count for this window
152
+ count = self.storage.get(window_key) or 0
153
+ if count < config.requests_per_minute:
154
+ new_count = self.storage.increment(window_key, 1, 60) # 60 second TTL
155
+ remaining = max(0, config.requests_per_minute - new_count)
156
+ reset_time = (current_minute + 1) * 60 # Next minute
157
+ result = {
158
+ "allowed": True,
159
+ "remaining": remaining,
160
+ "reset": reset_time,
161
+ "limit": config.requests_per_minute,
162
+ "reason": None,
163
+ }
164
+ return result
165
+ else:
166
+ # Check if burst is enabled and available
167
+ if config.burst_size > 0:
168
+ burst_key = f"ratelimit:burst:{identifier}{path_suffix}"
169
+ burst_count = self.storage.get(burst_key) or 0
170
+ if burst_count < config.burst_size:
171
+ self.storage.increment(burst_key, 1, 3600)
172
+ reset_time = (current_minute + 1) * 60 # Next minute
173
+ result = {
174
+ "allowed": True,
175
+ "remaining": 0,
176
+ "reset": reset_time,
177
+ "limit": config.requests_per_minute,
178
+ "reason": "Using burst allowance",
179
+ }
180
+ return result
181
+
182
+ # Block the request if no burst available or burst not enabled
183
+ reset_time = (current_minute + 1) * 60
184
+ if config.auto_ban_threshold > 0:
185
+ violation_key = f"ratelimit:violations:{identifier}{path_suffix}"
186
+ violations = self.storage.increment(
187
+ violation_key, 1, 86400
188
+ ) # 24 hour TTL
189
+ if violations >= config.auto_ban_threshold:
190
+ ban_key = f"banned_ip:{identifier}"
191
+ self.storage.set(
192
+ ban_key,
193
+ {
194
+ "reason": f"Rate limit exceeded for {path or 'global'}",
195
+ "timestamp": current_time,
196
+ },
197
+ config.auto_ban_duration_minutes * 60,
198
+ )
199
+ result = {
200
+ "allowed": False,
201
+ "remaining": 0,
202
+ "reset": reset_time,
203
+ "limit": config.requests_per_minute,
204
+ "reason": f"Rate limit exceeded for {path or 'global'}",
205
+ }
206
+ return result
207
+
208
+
209
+ class AsyncRateLimiter(AsyncBaseLimiter):
210
+ """
211
+ Limit request rates based on IP address or other identifiers (asynchronous).
212
+ Supports both global and per-route rate limiting.
213
+ """
214
+
215
+ def __init__(
216
+ self,
217
+ config: RateLimitConfig,
218
+ storage: AsyncBaseStorage,
219
+ ):
220
+ """
221
+ Initialize the async rate limiter.
222
+
223
+ Args:
224
+ config: Rate limit configuration (global default)
225
+ storage: Async storage backend for persistent data
226
+ """
227
+ self.config = config
228
+ self.storage = storage
229
+ self.route_configs = {} # Maps route patterns to custom RateLimitConfig objects
230
+
231
+ def add_route_config(
232
+ self, route_pattern: str, config: Union[RateLimitConfig, Dict[str, Any]]
233
+ ):
234
+ """
235
+ Add a custom rate limit configuration for a specific route pattern.
236
+
237
+ Args:
238
+ route_pattern: The route pattern to match (can include wildcards like * and **)
239
+ config: Custom rate limit configuration for this route
240
+ """
241
+ if isinstance(config, dict):
242
+ # Convert dict to RateLimitConfig
243
+ route_config = RateLimitConfig(**config)
244
+ else:
245
+ route_config = config
246
+
247
+ self.route_configs[route_pattern] = route_config
248
+
249
+ def get_config_for_route(self, path: str) -> RateLimitConfig:
250
+ """
251
+ Get the appropriate rate limit configuration for a given path.
252
+
253
+ Args:
254
+ path: The request path
255
+
256
+ Returns:
257
+ The route-specific config if matched, otherwise the default config
258
+ """
259
+ # Check for exact match first
260
+ if path in self.route_configs:
261
+ return self.route_configs[path]
262
+
263
+ # Check for pattern matches
264
+ for pattern, config in self.route_configs.items():
265
+ if self._match_route_pattern(pattern, path):
266
+ return config
267
+
268
+ # Fall back to default config
269
+ return self.config
270
+
271
+ def _match_route_pattern(self, pattern: str, path: str) -> bool:
272
+ """
273
+ Check if a path matches a route pattern.
274
+
275
+ Args:
276
+ pattern: The route pattern (can include wildcards)
277
+ path: The request path
278
+
279
+ Returns:
280
+ True if the path matches the pattern, False otherwise
281
+ """
282
+ # Handle trailing slashes consistently
283
+ if pattern.endswith("/") and not path.endswith("/"):
284
+ pattern = pattern[:-1]
285
+ elif not pattern.endswith("/") and path.endswith("/"):
286
+ path = path[:-1]
287
+
288
+ # Simple wildcard matching implementation
289
+ if pattern == "*":
290
+ return True
291
+
292
+ if pattern.endswith("/**"):
293
+ # Match any path that starts with the pattern prefix
294
+ prefix = pattern[:-3]
295
+ return path.startswith(prefix)
296
+
297
+ if pattern.endswith("/*"):
298
+ # Match any path that starts with the pattern prefix and has no additional segments
299
+ prefix = pattern[:-2]
300
+ if not path.startswith(prefix):
301
+ return False
302
+ remaining = path[len(prefix) :]
303
+ return remaining == "" or (
304
+ remaining.startswith("/") and "/" not in remaining[1:]
305
+ )
306
+
307
+ # Exact match
308
+ return pattern == path
309
+
310
+ async def check_limit(self, identifier: str, path: str = None) -> Dict[str, Any]:
311
+ """
312
+ Check if a request should be rate limited asynchronously.
313
+
314
+ Args:
315
+ identifier: The identifier to check (usually IP address)
316
+ path: The request path (for route-specific rate limiting)
317
+
318
+ Returns:
319
+ Dict with allowed status, remaining requests, and reset time
320
+ """
321
+ # Get the appropriate config for this route
322
+ config = self.config
323
+ matched_pattern = None
324
+ if path is not None:
325
+ # Check for exact match first
326
+ if path in self.route_configs:
327
+ config = self.route_configs[path]
328
+ matched_pattern = path
329
+ else:
330
+ # Check for pattern matches
331
+ for pattern, route_config in self.route_configs.items():
332
+ if self._match_route_pattern(pattern, path):
333
+ config = route_config
334
+ matched_pattern = pattern
335
+ break
336
+
337
+ if not config.enabled:
338
+ return {"allowed": True, "remaining": -1, "reset": -1}
339
+
340
+ current_time = int(time.time())
341
+ current_minute = current_time // 60 # Use minute-based window
342
+
343
+ # Use a consistent window key format that includes the pattern for route-specific limits
344
+ if matched_pattern:
345
+ window_key = f"ratelimit:{identifier}:{matched_pattern}:{current_minute}"
346
+ else:
347
+ window_key = f"ratelimit:{identifier}:global:{current_minute}"
348
+
349
+ # Get current count for this window
350
+ count = await self.storage.get(window_key) or 0
351
+
352
+ # Check if we're under the limit
353
+ if count < config.requests_per_minute:
354
+ # Increment the counter with a TTL that extends to the next minute
355
+ next_minute = (current_minute + 1) * 60
356
+ ttl = next_minute - current_time
357
+ new_count = await self.storage.increment(window_key, 1, ttl)
358
+ remaining = max(0, config.requests_per_minute - new_count)
359
+ reset_time = next_minute
360
+ result = {
361
+ "allowed": True,
362
+ "remaining": remaining,
363
+ "reset": reset_time,
364
+ "limit": config.requests_per_minute,
365
+ "reason": None,
366
+ }
367
+ return result
368
+ else:
369
+ # Check if burst is enabled and available
370
+ if config.burst_size > 0:
371
+ burst_key = (
372
+ f"ratelimit:burst:{identifier}:{matched_pattern or 'global'}"
373
+ )
374
+ burst_count = await self.storage.get(burst_key) or 0
375
+ if burst_count < config.burst_size:
376
+ await self.storage.increment(
377
+ burst_key, 1, 3600
378
+ ) # 1 hour TTL for burst
379
+ reset_time = (current_minute + 1) * 60 # Next minute
380
+ result = {
381
+ "allowed": True,
382
+ "remaining": 0,
383
+ "reset": reset_time,
384
+ "limit": config.requests_per_minute,
385
+ "reason": "Using burst allowance",
386
+ }
387
+ return result
388
+
389
+ # Block the request if no burst available or burst not enabled
390
+ reset_time = (current_minute + 1) * 60
391
+ if config.auto_ban_threshold > 0:
392
+ violation_key = (
393
+ f"ratelimit:violations:{identifier}:{matched_pattern or 'global'}"
394
+ )
395
+ violations = await self.storage.increment(
396
+ violation_key, 1, 86400
397
+ ) # 24 hour TTL
398
+ if violations >= config.auto_ban_threshold:
399
+ ban_key = f"banned_ip:{identifier}"
400
+ await self.storage.set(
401
+ ban_key,
402
+ {
403
+ "reason": f"Rate limit exceeded for {path or 'global'}",
404
+ "timestamp": current_time,
405
+ },
406
+ config.auto_ban_duration_minutes * 60,
407
+ )
408
+ result = {
409
+ "allowed": False,
410
+ "remaining": 0,
411
+ "reset": reset_time,
412
+ "limit": config.requests_per_minute,
413
+ "reason": f"Rate limit exceeded for {path or 'global'} try again in {reset_time - current_time} seconds",
414
+ }
415
+ return result
File without changes
@@ -0,0 +1,17 @@
1
+ """
2
+ Logging backends for PyWebGuard.
3
+ """
4
+
5
+ from ._meilisearch import MeilisearchBackend, AsyncMeilisearchBackend
6
+
7
+ # from ._elasticsearch import ElasticsearchBackend, AsyncElasticsearchBackend
8
+ # from ._mongodb import MongoDBBackend, AsyncMongoDBBackend
9
+
10
+ __all__ = [
11
+ "MeilisearchBackend",
12
+ "AsyncMeilisearchBackend",
13
+ # "ElasticsearchBackend",
14
+ # "AsyncElasticsearchBackend",
15
+ # "MongoDBBackend",
16
+ # "AsyncMongoDBBackend",
17
+ ]