d365fo-client 0.1.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 (51) hide show
  1. d365fo_client/__init__.py +305 -0
  2. d365fo_client/auth.py +93 -0
  3. d365fo_client/cli.py +700 -0
  4. d365fo_client/client.py +1454 -0
  5. d365fo_client/config.py +304 -0
  6. d365fo_client/crud.py +200 -0
  7. d365fo_client/exceptions.py +49 -0
  8. d365fo_client/labels.py +528 -0
  9. d365fo_client/main.py +502 -0
  10. d365fo_client/mcp/__init__.py +16 -0
  11. d365fo_client/mcp/client_manager.py +276 -0
  12. d365fo_client/mcp/main.py +98 -0
  13. d365fo_client/mcp/models.py +371 -0
  14. d365fo_client/mcp/prompts/__init__.py +43 -0
  15. d365fo_client/mcp/prompts/action_execution.py +480 -0
  16. d365fo_client/mcp/prompts/sequence_analysis.py +349 -0
  17. d365fo_client/mcp/resources/__init__.py +15 -0
  18. d365fo_client/mcp/resources/database_handler.py +555 -0
  19. d365fo_client/mcp/resources/entity_handler.py +176 -0
  20. d365fo_client/mcp/resources/environment_handler.py +132 -0
  21. d365fo_client/mcp/resources/metadata_handler.py +283 -0
  22. d365fo_client/mcp/resources/query_handler.py +135 -0
  23. d365fo_client/mcp/server.py +432 -0
  24. d365fo_client/mcp/tools/__init__.py +17 -0
  25. d365fo_client/mcp/tools/connection_tools.py +175 -0
  26. d365fo_client/mcp/tools/crud_tools.py +579 -0
  27. d365fo_client/mcp/tools/database_tools.py +813 -0
  28. d365fo_client/mcp/tools/label_tools.py +189 -0
  29. d365fo_client/mcp/tools/metadata_tools.py +766 -0
  30. d365fo_client/mcp/tools/profile_tools.py +706 -0
  31. d365fo_client/metadata_api.py +793 -0
  32. d365fo_client/metadata_v2/__init__.py +59 -0
  33. d365fo_client/metadata_v2/cache_v2.py +1372 -0
  34. d365fo_client/metadata_v2/database_v2.py +585 -0
  35. d365fo_client/metadata_v2/global_version_manager.py +573 -0
  36. d365fo_client/metadata_v2/search_engine_v2.py +423 -0
  37. d365fo_client/metadata_v2/sync_manager_v2.py +819 -0
  38. d365fo_client/metadata_v2/version_detector.py +439 -0
  39. d365fo_client/models.py +862 -0
  40. d365fo_client/output.py +181 -0
  41. d365fo_client/profile_manager.py +342 -0
  42. d365fo_client/profiles.py +178 -0
  43. d365fo_client/query.py +162 -0
  44. d365fo_client/session.py +60 -0
  45. d365fo_client/utils.py +196 -0
  46. d365fo_client-0.1.0.dist-info/METADATA +1084 -0
  47. d365fo_client-0.1.0.dist-info/RECORD +51 -0
  48. d365fo_client-0.1.0.dist-info/WHEEL +5 -0
  49. d365fo_client-0.1.0.dist-info/entry_points.txt +3 -0
  50. d365fo_client-0.1.0.dist-info/licenses/LICENSE +21 -0
  51. d365fo_client-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,423 @@
1
+ """Version-aware metadata search engine for MetadataCacheV2."""
2
+
3
+ import hashlib
4
+ import logging
5
+ import threading
6
+ import time
7
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
8
+
9
+ import aiosqlite
10
+
11
+ from ..exceptions import MetadataError
12
+ from ..models import SearchQuery, SearchResult, SearchResults
13
+
14
+ if TYPE_CHECKING:
15
+ from .cache_v2 import MetadataCacheV2
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class VersionAwareSearchEngine:
21
+ """Advanced metadata search engine with version awareness and FTS5 support.
22
+
23
+ This search engine is designed to work with MetadataCacheV2 and provides:
24
+ - Version-aware search across multiple environments
25
+ - FTS5 full-text search capabilities
26
+ - Pattern-based search for simple queries
27
+ - Multi-tier caching (memory cache)
28
+ - Cross-environment search support
29
+ """
30
+
31
+ def __init__(self, metadata_cache: "MetadataCacheV2"):
32
+ """Initialize version-aware search engine.
33
+
34
+ Args:
35
+ metadata_cache: MetadataCacheV2 instance
36
+ """
37
+ self.cache = metadata_cache
38
+ self._search_cache = {}
39
+ self._search_cache_lock = threading.RLock()
40
+ self._cache_ttl_seconds = 300 # 5 minutes cache TTL
41
+
42
+ async def rebuild_search_index(self, global_version_id: Optional[int] = None):
43
+ """Rebuild the FTS5 search index for a specific version.
44
+
45
+ Args:
46
+ global_version_id: Specific version to rebuild index for.
47
+ If None, rebuilds for current environment version.
48
+ """
49
+ if not self.cache._environment_id:
50
+ await self.cache.initialize()
51
+
52
+ # Get version to rebuild for
53
+ if global_version_id is None:
54
+ # Get current environment's active global version
55
+ async with aiosqlite.connect(self.cache.db_path) as db:
56
+ cursor = await db.execute(
57
+ """SELECT global_version_id FROM environment_versions
58
+ WHERE environment_id = ? AND is_active = 1
59
+ ORDER BY detected_at DESC LIMIT 1""",
60
+ (self.cache._environment_id,)
61
+ )
62
+ row = await cursor.fetchone()
63
+ if not row:
64
+ logger.warning("No active version found for current environment")
65
+ return
66
+ global_version_id = row[0]
67
+
68
+ await self._rebuild_fts_index_for_version(global_version_id)
69
+
70
+ async def _rebuild_fts_index_for_version(self, global_version_id: int):
71
+ """Rebuild FTS5 index for specific global version."""
72
+ async with aiosqlite.connect(self.cache.db_path) as db:
73
+ logger.info(f"Rebuilding FTS5 search index for version {global_version_id}")
74
+
75
+ # Clear existing entries for this version
76
+ await db.execute(
77
+ "DELETE FROM metadata_search_v2 WHERE global_version_id = ?",
78
+ (global_version_id,)
79
+ )
80
+
81
+ # Index data entities
82
+ await db.execute(
83
+ """INSERT INTO metadata_search_v2
84
+ (name, entity_type, description, properties, labels, global_version_id, entity_id)
85
+ SELECT
86
+ de.name,
87
+ 'data_entity',
88
+ COALESCE(de.label_text, de.label_id, de.name),
89
+ de.name || ' ' || COALESCE(de.public_entity_name, '') || ' ' || COALESCE(de.public_collection_name, ''),
90
+ COALESCE(de.label_text, ''),
91
+ de.global_version_id,
92
+ de.id
93
+ FROM data_entities de
94
+ WHERE de.global_version_id = ?""",
95
+ (global_version_id,)
96
+ )
97
+
98
+ # Index public entities
99
+ await db.execute(
100
+ """INSERT INTO metadata_search_v2
101
+ (name, entity_type, description, properties, labels, global_version_id, entity_id)
102
+ SELECT
103
+ pe.name,
104
+ 'public_entity',
105
+ COALESCE(pe.label_text, pe.label_id, pe.name),
106
+ pe.name || ' ' || COALESCE(pe.entity_set_name, ''),
107
+ COALESCE(pe.label_text, ''),
108
+ pe.global_version_id,
109
+ pe.id
110
+ FROM public_entities pe
111
+ WHERE pe.global_version_id = ?""",
112
+ (global_version_id,)
113
+ )
114
+
115
+ # Index enumerations
116
+ await db.execute(
117
+ """INSERT INTO metadata_search_v2
118
+ (name, entity_type, description, properties, labels, global_version_id, entity_id)
119
+ SELECT
120
+ e.name,
121
+ 'enumeration',
122
+ COALESCE(e.label_text, e.label_id, e.name),
123
+ e.name,
124
+ COALESCE(e.label_text, ''),
125
+ e.global_version_id,
126
+ e.id
127
+ FROM enumerations e
128
+ WHERE e.global_version_id = ?""",
129
+ (global_version_id,)
130
+ )
131
+
132
+ await db.commit()
133
+ logger.info(f"FTS5 search index rebuilt for version {global_version_id}")
134
+
135
+ async def search(self, query: SearchQuery) -> SearchResults:
136
+ """Execute version-aware metadata search.
137
+
138
+ Args:
139
+ query: Search query parameters
140
+
141
+ Returns:
142
+ Search results with version awareness
143
+ """
144
+ start_time = time.time()
145
+
146
+ # Build cache key
147
+ cache_key = self._build_search_cache_key(query)
148
+
149
+ # Check cache
150
+ with self._search_cache_lock:
151
+ cached = self._search_cache.get(cache_key)
152
+ if cached and time.time() - cached["timestamp"] < self._cache_ttl_seconds:
153
+ cached["results"].cache_hit = True
154
+ cached["results"].query_time_ms = (time.time() - start_time) * 1000
155
+ return cached["results"]
156
+
157
+ # Execute search
158
+ if query.use_fulltext:
159
+ results = await self._fts_search(query)
160
+ else:
161
+ results = await self._pattern_search(query)
162
+
163
+ # Calculate timing
164
+ results.query_time_ms = (time.time() - start_time) * 1000
165
+ results.cache_hit = False
166
+
167
+ # Cache results
168
+ with self._search_cache_lock:
169
+ self._search_cache[cache_key] = {
170
+ "results": results,
171
+ "timestamp": time.time(),
172
+ }
173
+
174
+ # Limit cache size
175
+ if len(self._search_cache) > 100:
176
+ oldest_key = min(
177
+ self._search_cache.keys(),
178
+ key=lambda k: self._search_cache[k]["timestamp"],
179
+ )
180
+ del self._search_cache[oldest_key]
181
+
182
+ return results
183
+
184
+ def _build_search_cache_key(self, query: SearchQuery) -> str:
185
+ """Build cache key for search query."""
186
+ key_parts = [
187
+ str(self.cache._environment_id or ""),
188
+ query.text,
189
+ "|".join(query.entity_types or []),
190
+ str(query.limit),
191
+ str(query.offset),
192
+ str(query.use_fulltext),
193
+ str(query.include_properties),
194
+ str(query.include_actions),
195
+ ]
196
+
197
+ if query.filters:
198
+ for k, v in sorted(query.filters.items()):
199
+ key_parts.append(f"{k}:{v}")
200
+
201
+ return hashlib.md5("|".join(key_parts).encode()).hexdigest()
202
+
203
+ async def _fts_search(self, query: SearchQuery) -> SearchResults:
204
+ """Full-text search using FTS5 with version awareness."""
205
+ if not self.cache._environment_id:
206
+ return SearchResults(results=[], total_count=0)
207
+
208
+ search_query = self._build_fts_query(query.text)
209
+
210
+ async with aiosqlite.connect(self.cache.db_path) as db:
211
+ # Get current environment's active global version
212
+ cursor = await db.execute(
213
+ """SELECT global_version_id FROM environment_versions
214
+ WHERE environment_id = ? AND is_active = 1
215
+ ORDER BY detected_at DESC LIMIT 1""",
216
+ (self.cache._environment_id,)
217
+ )
218
+ version_row = await cursor.fetchone()
219
+
220
+ if not version_row:
221
+ logger.warning("No active version found for FTS search")
222
+ return SearchResults(results=[], total_count=0)
223
+
224
+ global_version_id = version_row[0]
225
+
226
+ # Execute FTS5 search
227
+ sql = """
228
+ SELECT name, entity_type, description, labels,
229
+ bm25(metadata_search_v2) as relevance,
230
+ snippet(metadata_search_v2, 0, '<mark>', '</mark>', '...', 32) as snippet
231
+ FROM metadata_search_v2
232
+ WHERE metadata_search_v2 MATCH ? AND global_version_id = ?
233
+ """
234
+
235
+ params = [search_query, global_version_id]
236
+
237
+ # Add entity type filter
238
+ if query.entity_types:
239
+ placeholders = ",".join("?" * len(query.entity_types))
240
+ sql += f" AND entity_type IN ({placeholders})"
241
+ params.extend(query.entity_types)
242
+
243
+ sql += " ORDER BY bm25(metadata_search_v2) LIMIT ? OFFSET ?"
244
+ params.extend([query.limit, query.offset])
245
+
246
+ cursor = await db.execute(sql, params)
247
+ rows = await cursor.fetchall()
248
+
249
+ results = []
250
+ for row in rows:
251
+ result = SearchResult(
252
+ name=row[0],
253
+ entity_type=row[1],
254
+ entity_set_name="", # Will be populated if needed
255
+ description=row[2],
256
+ relevance=row[4],
257
+ snippet=row[5],
258
+ )
259
+ results.append(result)
260
+
261
+ # Get total count
262
+ count_sql = """
263
+ SELECT COUNT(*) FROM metadata_search_v2
264
+ WHERE metadata_search_v2 MATCH ? AND global_version_id = ?
265
+ """
266
+ count_params = [search_query, global_version_id]
267
+
268
+ if query.entity_types:
269
+ placeholders = ",".join("?" * len(query.entity_types))
270
+ count_sql += f" AND entity_type IN ({placeholders})"
271
+ count_params.extend(query.entity_types)
272
+
273
+ count_cursor = await db.execute(count_sql, count_params)
274
+ total_count = (await count_cursor.fetchone())[0]
275
+
276
+ return SearchResults(results=results, total_count=total_count)
277
+
278
+ async def _pattern_search(self, query: SearchQuery) -> SearchResults:
279
+ """Pattern-based search for simple queries with version awareness."""
280
+ if not self.cache._environment_id:
281
+ return SearchResults(results=[], total_count=0)
282
+
283
+ pattern = f"%{query.text.lower()}%"
284
+
285
+ async with aiosqlite.connect(self.cache.db_path) as db:
286
+ # Get current environment's active global version
287
+ cursor = await db.execute(
288
+ """SELECT global_version_id FROM environment_versions
289
+ WHERE environment_id = ? AND is_active = 1
290
+ ORDER BY detected_at DESC LIMIT 1""",
291
+ (self.cache._environment_id,)
292
+ )
293
+ version_row = await cursor.fetchone()
294
+
295
+ if not version_row:
296
+ logger.warning("No active version found for pattern search")
297
+ return SearchResults(results=[], total_count=0)
298
+
299
+ global_version_id = version_row[0]
300
+
301
+ # Search across multiple entity types
302
+ union_queries = []
303
+ params = []
304
+
305
+ if not query.entity_types or "data_entity" in query.entity_types:
306
+ union_queries.append(
307
+ """
308
+ SELECT de.name as entity_name, 'data_entity' as entity_type,
309
+ de.public_collection_name as entity_set_name,
310
+ COALESCE(de.label_text, de.label_id) as description,
311
+ 0.5 as relevance,
312
+ de.name as snippet
313
+ FROM data_entities de
314
+ WHERE LOWER(de.name) LIKE ? AND de.global_version_id = ?
315
+ """
316
+ )
317
+ params.extend([pattern, global_version_id])
318
+
319
+ if not query.entity_types or "public_entity" in query.entity_types:
320
+ union_queries.append(
321
+ """
322
+ SELECT pe.name as entity_name, 'public_entity' as entity_type,
323
+ pe.entity_set_name,
324
+ COALESCE(pe.label_text, pe.label_id) as description,
325
+ 0.5 as relevance,
326
+ pe.name as snippet
327
+ FROM public_entities pe
328
+ WHERE LOWER(pe.name) LIKE ? AND pe.global_version_id = ?
329
+ """
330
+ )
331
+ params.extend([pattern, global_version_id])
332
+
333
+ if not query.entity_types or "enumeration" in query.entity_types:
334
+ union_queries.append(
335
+ """
336
+ SELECT e.name as entity_name, 'enumeration' as entity_type,
337
+ e.name as entity_set_name,
338
+ COALESCE(e.label_text, e.label_id) as description,
339
+ 0.5 as relevance,
340
+ e.name as snippet
341
+ FROM enumerations e
342
+ WHERE LOWER(e.name) LIKE ? AND e.global_version_id = ?
343
+ """
344
+ )
345
+ params.extend([pattern, global_version_id])
346
+
347
+ if not union_queries:
348
+ return SearchResults(results=[], total_count=0)
349
+
350
+ sql = " UNION ALL ".join(union_queries)
351
+ sql += " ORDER BY relevance DESC, entity_name LIMIT ? OFFSET ?"
352
+ params.extend([query.limit, query.offset])
353
+
354
+ cursor = await db.execute(sql, params)
355
+ rows = await cursor.fetchall()
356
+
357
+ results = []
358
+ for row in rows:
359
+ result = SearchResult(
360
+ name=row[0],
361
+ entity_type=row[1],
362
+ entity_set_name=row[2] or "",
363
+ description=row[3] or "",
364
+ relevance=row[4],
365
+ snippet=row[5],
366
+ )
367
+ results.append(result)
368
+
369
+ return SearchResults(
370
+ results=results,
371
+ total_count=len(results), # Simplified count for pattern search
372
+ )
373
+
374
+ def _build_fts_query(self, text: str) -> str:
375
+ """Build FTS5 query from user input."""
376
+ # Simple FTS query building - can be enhanced with more sophisticated parsing
377
+ # Handle basic operators and quoted phrases
378
+
379
+ # If already quoted or contains operators, use as-is
380
+ if '"' in text or any(op in text for op in ["AND", "OR", "NOT", "*"]):
381
+ return text
382
+
383
+ # For simple terms, create a phrase query with prefix matching
384
+ terms = text.strip().split()
385
+ if len(terms) == 1:
386
+ return f'"{terms[0]}"*'
387
+ else:
388
+ return f'"{" ".join(terms)}"'
389
+
390
+ async def search_entities_fts(self, search_text: str, entity_types: Optional[List[str]] = None, limit: int = 10) -> List[Dict[str, Any]]:
391
+ """Simplified FTS search for entities (for MCP compatibility).
392
+
393
+ Args:
394
+ search_text: Text to search for
395
+ entity_types: Optional list of entity types to filter by
396
+ limit: Maximum number of results
397
+
398
+ Returns:
399
+ List of entity dictionaries
400
+ """
401
+ query = SearchQuery(
402
+ text=search_text,
403
+ entity_types=entity_types or ["data_entity"],
404
+ limit=limit,
405
+ use_fulltext=True
406
+ )
407
+
408
+ results = await self.search(query)
409
+
410
+ # Convert to dictionary format for compatibility
411
+ entities = []
412
+ for result in results.results:
413
+ entity_dict = {
414
+ "name": result.name,
415
+ "entity_type": result.entity_type,
416
+ "entity_set_name": result.entity_set_name,
417
+ "description": result.description,
418
+ "relevance": result.relevance,
419
+ "snippet": result.snippet
420
+ }
421
+ entities.append(entity_dict)
422
+
423
+ return entities