ebk 0.4.4__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 (87) hide show
  1. ebk/__init__.py +35 -0
  2. ebk/ai/__init__.py +23 -0
  3. ebk/ai/knowledge_graph.py +450 -0
  4. ebk/ai/llm_providers/__init__.py +26 -0
  5. ebk/ai/llm_providers/anthropic.py +209 -0
  6. ebk/ai/llm_providers/base.py +295 -0
  7. ebk/ai/llm_providers/gemini.py +285 -0
  8. ebk/ai/llm_providers/ollama.py +294 -0
  9. ebk/ai/metadata_enrichment.py +394 -0
  10. ebk/ai/question_generator.py +328 -0
  11. ebk/ai/reading_companion.py +224 -0
  12. ebk/ai/semantic_search.py +433 -0
  13. ebk/ai/text_extractor.py +393 -0
  14. ebk/calibre_import.py +66 -0
  15. ebk/cli.py +6433 -0
  16. ebk/config.py +230 -0
  17. ebk/db/__init__.py +37 -0
  18. ebk/db/migrations.py +507 -0
  19. ebk/db/models.py +725 -0
  20. ebk/db/session.py +144 -0
  21. ebk/decorators.py +1 -0
  22. ebk/exports/__init__.py +0 -0
  23. ebk/exports/base_exporter.py +218 -0
  24. ebk/exports/echo_export.py +279 -0
  25. ebk/exports/html_library.py +1743 -0
  26. ebk/exports/html_utils.py +87 -0
  27. ebk/exports/hugo.py +59 -0
  28. ebk/exports/jinja_export.py +286 -0
  29. ebk/exports/multi_facet_export.py +159 -0
  30. ebk/exports/opds_export.py +232 -0
  31. ebk/exports/symlink_dag.py +479 -0
  32. ebk/exports/zip.py +25 -0
  33. ebk/extract_metadata.py +341 -0
  34. ebk/ident.py +89 -0
  35. ebk/library_db.py +1440 -0
  36. ebk/opds.py +748 -0
  37. ebk/plugins/__init__.py +42 -0
  38. ebk/plugins/base.py +502 -0
  39. ebk/plugins/hooks.py +442 -0
  40. ebk/plugins/registry.py +499 -0
  41. ebk/repl/__init__.py +9 -0
  42. ebk/repl/find.py +126 -0
  43. ebk/repl/grep.py +173 -0
  44. ebk/repl/shell.py +1677 -0
  45. ebk/repl/text_utils.py +320 -0
  46. ebk/search_parser.py +413 -0
  47. ebk/server.py +3608 -0
  48. ebk/services/__init__.py +28 -0
  49. ebk/services/annotation_extraction.py +351 -0
  50. ebk/services/annotation_service.py +380 -0
  51. ebk/services/export_service.py +577 -0
  52. ebk/services/import_service.py +447 -0
  53. ebk/services/personal_metadata_service.py +347 -0
  54. ebk/services/queue_service.py +253 -0
  55. ebk/services/tag_service.py +281 -0
  56. ebk/services/text_extraction.py +317 -0
  57. ebk/services/view_service.py +12 -0
  58. ebk/similarity/__init__.py +77 -0
  59. ebk/similarity/base.py +154 -0
  60. ebk/similarity/core.py +471 -0
  61. ebk/similarity/extractors.py +168 -0
  62. ebk/similarity/metrics.py +376 -0
  63. ebk/skills/SKILL.md +182 -0
  64. ebk/skills/__init__.py +1 -0
  65. ebk/vfs/__init__.py +101 -0
  66. ebk/vfs/base.py +298 -0
  67. ebk/vfs/library_vfs.py +122 -0
  68. ebk/vfs/nodes/__init__.py +54 -0
  69. ebk/vfs/nodes/authors.py +196 -0
  70. ebk/vfs/nodes/books.py +480 -0
  71. ebk/vfs/nodes/files.py +155 -0
  72. ebk/vfs/nodes/metadata.py +385 -0
  73. ebk/vfs/nodes/root.py +100 -0
  74. ebk/vfs/nodes/similar.py +165 -0
  75. ebk/vfs/nodes/subjects.py +184 -0
  76. ebk/vfs/nodes/tags.py +371 -0
  77. ebk/vfs/resolver.py +228 -0
  78. ebk/vfs_router.py +275 -0
  79. ebk/views/__init__.py +32 -0
  80. ebk/views/dsl.py +668 -0
  81. ebk/views/service.py +619 -0
  82. ebk-0.4.4.dist-info/METADATA +755 -0
  83. ebk-0.4.4.dist-info/RECORD +87 -0
  84. ebk-0.4.4.dist-info/WHEEL +5 -0
  85. ebk-0.4.4.dist-info/entry_points.txt +2 -0
  86. ebk-0.4.4.dist-info/licenses/LICENSE +21 -0
  87. ebk-0.4.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,499 @@
1
+ """
2
+ Plugin registry and discovery system for EBK.
3
+
4
+ This module handles plugin registration, discovery, and management.
5
+ """
6
+
7
+ import importlib
8
+ import importlib.metadata
9
+ import inspect
10
+ import logging
11
+ import pkgutil
12
+ from pathlib import Path
13
+ from typing import Dict, List, Optional, Type, Any
14
+
15
+ from .base import Plugin
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class PluginRegistry:
21
+ """Central registry for all EBK plugins."""
22
+
23
+ def __init__(self):
24
+ self._plugins: Dict[str, List[Plugin]] = {}
25
+ self._plugin_classes: Dict[str, Type[Plugin]] = {}
26
+ self._plugin_instances: Dict[str, Plugin] = {}
27
+ self._config: Dict[str, Dict[str, Any]] = {}
28
+ self._enabled: Dict[str, bool] = {}
29
+
30
+ def discover_plugins(self,
31
+ search_paths: Optional[List[Path]] = None,
32
+ entry_point_group: str = "ebk.plugins") -> None:
33
+ """
34
+ Discover plugins from various sources.
35
+
36
+ Args:
37
+ search_paths: Additional paths to search for plugins
38
+ entry_point_group: Entry point group name for installed plugins
39
+ """
40
+ # 1. Discover from entry points (installed packages)
41
+ self._discover_entry_points(entry_point_group)
42
+
43
+ # 2. Discover from local plugins directory
44
+ self._discover_local_plugins()
45
+
46
+ # 3. Discover from additional search paths
47
+ if search_paths:
48
+ for path in search_paths:
49
+ self._discover_path_plugins(path)
50
+
51
+ # 4. Discover from environment variable
52
+ self._discover_env_plugins()
53
+
54
+ logger.info(f"Discovered {len(self._plugin_instances)} plugins")
55
+
56
+ def _discover_entry_points(self, group: str) -> None:
57
+ """
58
+ Discover plugins via setuptools entry points.
59
+
60
+ Args:
61
+ group: Entry point group name
62
+ """
63
+ try:
64
+ # Get entry points for the group
65
+ if hasattr(importlib.metadata, 'entry_points'):
66
+ # Python 3.10+
67
+ eps = importlib.metadata.entry_points()
68
+ if hasattr(eps, 'select'):
69
+ # Python 3.10+
70
+ entry_points = eps.select(group=group)
71
+ else:
72
+ # Python 3.9
73
+ entry_points = eps.get(group, [])
74
+ else:
75
+ # Fallback for older versions
76
+ entry_points = []
77
+
78
+ for ep in entry_points:
79
+ try:
80
+ plugin_class = ep.load()
81
+ if self._is_valid_plugin_class(plugin_class):
82
+ self._register_class(plugin_class)
83
+ logger.info(f"Loaded plugin from entry point: {ep.name}")
84
+ except Exception as e:
85
+ logger.error(f"Failed to load plugin {ep.name}: {e}")
86
+
87
+ except Exception as e:
88
+ logger.warning(f"Could not discover entry point plugins: {e}")
89
+
90
+ def _discover_local_plugins(self) -> None:
91
+ """Discover plugins in the local plugins directory."""
92
+ plugins_dir = Path(__file__).parent
93
+ self._discover_path_plugins(plugins_dir)
94
+
95
+ def _discover_path_plugins(self, path: Path) -> None:
96
+ """
97
+ Discover plugins in a specific directory.
98
+
99
+ Args:
100
+ path: Directory to search for plugins
101
+ """
102
+ if not path.exists() or not path.is_dir():
103
+ return
104
+
105
+ # Skip __pycache__ and other special directories
106
+ if path.name.startswith('__'):
107
+ return
108
+
109
+ # Look for Python modules
110
+ for module_info in pkgutil.iter_modules([str(path)]):
111
+ if module_info.name.startswith('_'):
112
+ continue
113
+
114
+ try:
115
+ # Import the module
116
+ if path.parent in Path(__file__).parents:
117
+ # It's within the ebk package
118
+ module_path = f"ebk.plugins.{module_info.name}"
119
+ else:
120
+ # It's an external path
121
+ module_path = module_info.name
122
+
123
+ module = importlib.import_module(module_path)
124
+
125
+ # Find plugin classes in the module
126
+ for name, obj in inspect.getmembers(module):
127
+ if self._is_valid_plugin_class(obj):
128
+ self._register_class(obj)
129
+ logger.info(f"Loaded plugin class: {name} from {module_path}")
130
+
131
+ except Exception as e:
132
+ logger.error(f"Failed to load plugin module {module_info.name}: {e}")
133
+
134
+ def _discover_env_plugins(self) -> None:
135
+ """Discover plugins from environment variable."""
136
+ import os
137
+ plugin_paths = os.environ.get('EBK_PLUGIN_PATH', '')
138
+
139
+ if plugin_paths:
140
+ for path_str in plugin_paths.split(':'):
141
+ path = Path(path_str).expanduser()
142
+ if path.exists():
143
+ self._discover_path_plugins(path)
144
+
145
+ def _is_valid_plugin_class(self, obj: Any) -> bool:
146
+ """
147
+ Check if an object is a valid plugin class.
148
+
149
+ Args:
150
+ obj: Object to check
151
+
152
+ Returns:
153
+ True if it's a valid plugin class
154
+ """
155
+ return (
156
+ inspect.isclass(obj) and
157
+ issubclass(obj, Plugin) and
158
+ obj is not Plugin and
159
+ not inspect.isabstract(obj) and
160
+ obj.__module__ != 'ebk.plugins.base' # Skip base classes
161
+ )
162
+
163
+ def _register_class(self, plugin_class: Type[Plugin]) -> None:
164
+ """
165
+ Register a plugin class.
166
+
167
+ Args:
168
+ plugin_class: Plugin class to register
169
+ """
170
+ try:
171
+ # Create an instance to get the name
172
+ instance = plugin_class()
173
+ name = instance.name
174
+
175
+ if name in self._plugin_classes:
176
+ logger.warning(f"Plugin {name} already registered, skipping")
177
+ return
178
+
179
+ self._plugin_classes[name] = plugin_class
180
+
181
+ # Determine plugin type from base class
182
+ plugin_type = self._get_plugin_type(plugin_class)
183
+ if plugin_type:
184
+ if plugin_type not in self._plugins:
185
+ self._plugins[plugin_type] = []
186
+
187
+ # Store the class for lazy instantiation
188
+ self._plugins[plugin_type].append(instance)
189
+ self._plugin_instances[name] = instance
190
+
191
+ logger.debug(f"Registered plugin: {name} (type: {plugin_type})")
192
+
193
+ except Exception as e:
194
+ logger.error(f"Failed to register plugin class {plugin_class.__name__}: {e}")
195
+
196
+ def _get_plugin_type(self, plugin_class: Type[Plugin]) -> Optional[str]:
197
+ """
198
+ Determine the plugin type from its base class.
199
+
200
+ Args:
201
+ plugin_class: Plugin class
202
+
203
+ Returns:
204
+ Plugin type name or None
205
+ """
206
+ from . import base
207
+
208
+ # Map base classes to type names
209
+ type_map = {
210
+ base.MetadataExtractor: 'metadata_extractor',
211
+ base.TagSuggester: 'tag_suggester',
212
+ base.ContentAnalyzer: 'content_analyzer',
213
+ base.SimilarityFinder: 'similarity_finder',
214
+ base.Deduplicator: 'deduplicator',
215
+ base.Validator: 'validator',
216
+ base.Exporter: 'exporter'
217
+ }
218
+
219
+ for base_class, type_name in type_map.items():
220
+ if issubclass(plugin_class, base_class):
221
+ return type_name
222
+
223
+ return None
224
+
225
+ def register(self, plugin: Plugin) -> None:
226
+ """
227
+ Register a plugin instance.
228
+
229
+ Args:
230
+ plugin: Plugin instance to register
231
+ """
232
+ name = plugin.name
233
+
234
+ if name in self._plugin_instances:
235
+ logger.warning(f"Plugin {name} already registered, replacing")
236
+
237
+ self._plugin_instances[name] = plugin
238
+
239
+ # Determine plugin type
240
+ plugin_type = self._get_plugin_type(type(plugin))
241
+ if plugin_type:
242
+ if plugin_type not in self._plugins:
243
+ self._plugins[plugin_type] = []
244
+
245
+ # Remove old instance if exists
246
+ self._plugins[plugin_type] = [
247
+ p for p in self._plugins[plugin_type] if p.name != name
248
+ ]
249
+ self._plugins[plugin_type].append(plugin)
250
+
251
+ logger.info(f"Registered plugin instance: {name}")
252
+
253
+ def unregister(self, name: str) -> bool:
254
+ """
255
+ Unregister a plugin.
256
+
257
+ Args:
258
+ name: Plugin name
259
+
260
+ Returns:
261
+ True if plugin was unregistered
262
+ """
263
+ if name not in self._plugin_instances:
264
+ return False
265
+
266
+ plugin = self._plugin_instances[name]
267
+ del self._plugin_instances[name]
268
+
269
+ # Remove from type list
270
+ for plugin_list in self._plugins.values():
271
+ plugin_list[:] = [p for p in plugin_list if p.name != name]
272
+
273
+ # Cleanup
274
+ try:
275
+ plugin.cleanup()
276
+ except Exception as e:
277
+ logger.error(f"Error during plugin cleanup: {e}")
278
+
279
+ logger.info(f"Unregistered plugin: {name}")
280
+ return True
281
+
282
+ def get_plugins(self, plugin_type: str) -> List[Plugin]:
283
+ """
284
+ Get all plugins of a specific type.
285
+
286
+ Args:
287
+ plugin_type: Type of plugins to get
288
+
289
+ Returns:
290
+ List of plugin instances
291
+ """
292
+ plugins = self._plugins.get(plugin_type, [])
293
+
294
+ # Filter by enabled status
295
+ return [p for p in plugins if self._enabled.get(p.name, True)]
296
+
297
+ def get_plugin(self, name: str) -> Optional[Plugin]:
298
+ """
299
+ Get a specific plugin by name.
300
+
301
+ Args:
302
+ name: Plugin name
303
+
304
+ Returns:
305
+ Plugin instance or None
306
+ """
307
+ plugin = self._plugin_instances.get(name)
308
+
309
+ # Check if enabled
310
+ if plugin and not self._enabled.get(name, True):
311
+ return None
312
+
313
+ return plugin
314
+
315
+ def configure_plugin(self, name: str, config: Dict[str, Any]) -> bool:
316
+ """
317
+ Configure a plugin.
318
+
319
+ Args:
320
+ name: Plugin name
321
+ config: Configuration dictionary
322
+
323
+ Returns:
324
+ True if configuration was successful
325
+ """
326
+ plugin = self._plugin_instances.get(name)
327
+ if not plugin:
328
+ logger.error(f"Plugin {name} not found")
329
+ return False
330
+
331
+ try:
332
+ self._config[name] = config
333
+ plugin.initialize(config)
334
+
335
+ if not plugin.validate_config():
336
+ logger.error(f"Invalid configuration for plugin {name}")
337
+ return False
338
+
339
+ logger.info(f"Configured plugin: {name}")
340
+ return True
341
+
342
+ except Exception as e:
343
+ logger.error(f"Failed to configure plugin {name}: {e}")
344
+ return False
345
+
346
+ def enable_plugin(self, name: str) -> bool:
347
+ """
348
+ Enable a plugin.
349
+
350
+ Args:
351
+ name: Plugin name
352
+
353
+ Returns:
354
+ True if plugin was enabled
355
+ """
356
+ if name not in self._plugin_instances:
357
+ logger.error(f"Plugin {name} not found")
358
+ return False
359
+
360
+ self._enabled[name] = True
361
+ logger.info(f"Enabled plugin: {name}")
362
+ return True
363
+
364
+ def disable_plugin(self, name: str) -> bool:
365
+ """
366
+ Disable a plugin.
367
+
368
+ Args:
369
+ name: Plugin name
370
+
371
+ Returns:
372
+ True if plugin was disabled
373
+ """
374
+ if name not in self._plugin_instances:
375
+ logger.error(f"Plugin {name} not found")
376
+ return False
377
+
378
+ self._enabled[name] = False
379
+ logger.info(f"Disabled plugin: {name}")
380
+ return True
381
+
382
+ def list_plugins(self) -> Dict[str, List[str]]:
383
+ """
384
+ List all registered plugins by type.
385
+
386
+ Returns:
387
+ Dictionary mapping plugin types to plugin names
388
+ """
389
+ result = {}
390
+ for plugin_type, plugins in self._plugins.items():
391
+ result[plugin_type] = [p.name for p in plugins]
392
+ return result
393
+
394
+ def get_plugin_info(self, name: str) -> Optional[Dict[str, Any]]:
395
+ """
396
+ Get information about a plugin.
397
+
398
+ Args:
399
+ name: Plugin name
400
+
401
+ Returns:
402
+ Plugin information dictionary
403
+ """
404
+ plugin = self._plugin_instances.get(name)
405
+ if not plugin:
406
+ return None
407
+
408
+ return {
409
+ 'name': plugin.name,
410
+ 'version': plugin.version,
411
+ 'description': plugin.description,
412
+ 'author': plugin.author,
413
+ 'type': self._get_plugin_type(type(plugin)),
414
+ 'enabled': self._enabled.get(name, True),
415
+ 'configured': name in self._config,
416
+ 'requires': plugin.requires
417
+ }
418
+
419
+ def cleanup(self) -> None:
420
+ """Cleanup all plugins."""
421
+ for plugin in self._plugin_instances.values():
422
+ try:
423
+ plugin.cleanup()
424
+ except Exception as e:
425
+ logger.error(f"Error cleaning up plugin {plugin.name}: {e}")
426
+
427
+ self._plugins.clear()
428
+ self._plugin_instances.clear()
429
+ self._plugin_classes.clear()
430
+ self._config.clear()
431
+ self._enabled.clear()
432
+
433
+
434
+ # Global plugin registry instance
435
+ plugin_registry = PluginRegistry()
436
+
437
+
438
+ def register_plugin(plugin_or_class):
439
+ """
440
+ Decorator or function to register a plugin.
441
+
442
+ Can be used as:
443
+ - @register_plugin on a class
444
+ - register_plugin(plugin_instance)
445
+
446
+ Args:
447
+ plugin_or_class: Plugin class or instance
448
+
449
+ Returns:
450
+ The plugin class (for decorator usage)
451
+ """
452
+ if inspect.isclass(plugin_or_class):
453
+ # Used as decorator on a class
454
+ plugin_registry._register_class(plugin_or_class)
455
+ return plugin_or_class
456
+ else:
457
+ # Used as function with instance
458
+ plugin_registry.register(plugin_or_class)
459
+ return plugin_or_class
460
+
461
+
462
+ def get_plugins(plugin_type: str) -> List[Plugin]:
463
+ """
464
+ Get all plugins of a specific type.
465
+
466
+ Args:
467
+ plugin_type: Type of plugins to get
468
+
469
+ Returns:
470
+ List of plugin instances
471
+ """
472
+ return plugin_registry.get_plugins(plugin_type)
473
+
474
+
475
+ def get_plugin(name: str) -> Optional[Plugin]:
476
+ """
477
+ Get a specific plugin by name.
478
+
479
+ Args:
480
+ name: Plugin name
481
+
482
+ Returns:
483
+ Plugin instance or None
484
+ """
485
+ return plugin_registry.get_plugin(name)
486
+
487
+
488
+ def configure_plugin(name: str, config: Dict[str, Any]) -> bool:
489
+ """
490
+ Configure a plugin.
491
+
492
+ Args:
493
+ name: Plugin name
494
+ config: Configuration dictionary
495
+
496
+ Returns:
497
+ True if configuration was successful
498
+ """
499
+ return plugin_registry.configure_plugin(name, config)
ebk/repl/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """REPL shell for interactive library navigation.
2
+
3
+ This module provides an interactive shell for navigating and managing
4
+ the ebook library through a virtual filesystem interface.
5
+ """
6
+
7
+ from ebk.repl.shell import LibraryShell
8
+
9
+ __all__ = ["LibraryShell"]
ebk/repl/find.py ADDED
@@ -0,0 +1,126 @@
1
+ """Find command implementation for REPL shell."""
2
+
3
+ from typing import List, Dict, Any, Optional
4
+ from ebk.library_db import Library
5
+ from ebk.db.models import Book
6
+
7
+
8
+ class FindQuery:
9
+ """Book finder with metadata filters."""
10
+
11
+ def __init__(self, library: Library):
12
+ """Initialize find query.
13
+
14
+ Args:
15
+ library: Library instance
16
+ """
17
+ self.library = library
18
+
19
+ def find(self, filters: Dict[str, Any]) -> List[Book]:
20
+ """Find books matching filters.
21
+
22
+ Args:
23
+ filters: Dictionary of field:value filters
24
+ Supported fields:
25
+ - title: Book title (partial match)
26
+ - author: Author name (partial match)
27
+ - subject: Subject/tag (partial match)
28
+ - text: Full-text search (FTS5 across title, description, extracted text)
29
+ - language: Language code (exact match)
30
+ - year: Publication year (exact match)
31
+ - publisher: Publisher name (partial match)
32
+ - format: File format (exact match, e.g., pdf, epub)
33
+ - limit: Maximum results (default: 50)
34
+
35
+ Returns:
36
+ List of matching books
37
+ """
38
+ query = self.library.query()
39
+
40
+ # Apply filters
41
+ if "title" in filters:
42
+ query = query.filter_by_title(filters["title"])
43
+
44
+ if "author" in filters:
45
+ query = query.filter_by_author(filters["author"])
46
+
47
+ if "subject" in filters:
48
+ query = query.filter_by_subject(filters["subject"])
49
+
50
+ if "language" in filters:
51
+ query = query.filter_by_language(filters["language"])
52
+
53
+ if "year" in filters:
54
+ try:
55
+ year = int(filters["year"])
56
+ query = query.filter_by_year(year)
57
+ except ValueError:
58
+ pass # Skip invalid year
59
+
60
+ if "publisher" in filters:
61
+ query = query.filter_by_publisher(filters["publisher"])
62
+
63
+ if "format" in filters:
64
+ query = query.filter_by_format(filters["format"])
65
+
66
+ if "text" in filters:
67
+ query = query.filter_by_text(filters["text"])
68
+
69
+ # Apply limit
70
+ limit = filters.get("limit", 50)
71
+ try:
72
+ # Convert to int if it's a string
73
+ if isinstance(limit, str):
74
+ limit = int(limit)
75
+ if isinstance(limit, int):
76
+ query = query.limit(limit)
77
+ except (ValueError, TypeError):
78
+ # Invalid limit, use default
79
+ query = query.limit(50)
80
+
81
+ # Execute query
82
+ return query.all()
83
+
84
+ def parse_filters(self, args: List[str]) -> Dict[str, Any]:
85
+ """Parse command-line arguments into filter dictionary.
86
+
87
+ Args:
88
+ args: List of filter arguments in format "field:value"
89
+
90
+ Returns:
91
+ Dictionary of filters
92
+
93
+ Raises:
94
+ ValueError: If argument format is invalid
95
+ """
96
+ filters = {}
97
+
98
+ for arg in args:
99
+ if ":" not in arg:
100
+ raise ValueError(f"Invalid filter format: {arg}. Use field:value")
101
+
102
+ field, value = arg.split(":", 1)
103
+ field = field.lower().strip()
104
+ value = value.strip()
105
+
106
+ # Validate field
107
+ valid_fields = {
108
+ "title",
109
+ "author",
110
+ "subject",
111
+ "text",
112
+ "language",
113
+ "year",
114
+ "publisher",
115
+ "format",
116
+ "limit",
117
+ }
118
+
119
+ if field not in valid_fields:
120
+ raise ValueError(
121
+ f"Unknown field: {field}. Valid fields: {', '.join(sorted(valid_fields))}"
122
+ )
123
+
124
+ filters[field] = value
125
+
126
+ return filters