d365fo-client 0.2.1__tar.gz → 0.2.2__tar.gz

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 (57) hide show
  1. {d365fo_client-0.2.1/src/d365fo_client.egg-info → d365fo_client-0.2.2}/PKG-INFO +1 -1
  2. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/pyproject.toml +1 -1
  3. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/__init__.py +4 -48
  4. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/client.py +44 -24
  5. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/models.py +2 -2
  6. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/connection_tools.py +7 -0
  7. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/metadata_api.py +1 -1
  8. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/cache_v2.py +15 -10
  9. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/database_v2.py +93 -0
  10. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/global_version_manager.py +60 -0
  11. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/models.py +19 -10
  12. {d365fo_client-0.2.1 → d365fo_client-0.2.2/src/d365fo_client.egg-info}/PKG-INFO +1 -1
  13. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/LICENSE +0 -0
  14. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/README.md +0 -0
  15. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/setup.cfg +0 -0
  16. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/auth.py +0 -0
  17. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/cli.py +0 -0
  18. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/config.py +0 -0
  19. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/crud.py +0 -0
  20. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/exceptions.py +0 -0
  21. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/labels.py +0 -0
  22. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/main.py +0 -0
  23. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/__init__.py +0 -0
  24. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/client_manager.py +0 -0
  25. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/main.py +0 -0
  26. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/prompts/__init__.py +0 -0
  27. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/prompts/action_execution.py +0 -0
  28. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/prompts/sequence_analysis.py +0 -0
  29. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/__init__.py +0 -0
  30. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/database_handler.py +0 -0
  31. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/entity_handler.py +0 -0
  32. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/environment_handler.py +0 -0
  33. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/metadata_handler.py +0 -0
  34. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/resources/query_handler.py +0 -0
  35. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/server.py +0 -0
  36. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/__init__.py +0 -0
  37. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/crud_tools.py +0 -0
  38. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/database_tools.py +0 -0
  39. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/label_tools.py +0 -0
  40. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/metadata_tools.py +0 -0
  41. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/mcp/tools/profile_tools.py +0 -0
  42. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/__init__.py +0 -0
  43. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/label_utils.py +0 -0
  44. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/search_engine_v2.py +0 -0
  45. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/sync_manager_v2.py +0 -0
  46. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/metadata_v2/version_detector.py +0 -0
  47. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/output.py +0 -0
  48. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/profile_manager.py +0 -0
  49. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/profiles.py +0 -0
  50. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/query.py +0 -0
  51. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/session.py +0 -0
  52. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client/utils.py +0 -0
  53. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client.egg-info/SOURCES.txt +0 -0
  54. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client.egg-info/dependency_links.txt +0 -0
  55. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client.egg-info/entry_points.txt +0 -0
  56. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client.egg-info/requires.txt +0 -0
  57. {d365fo_client-0.2.1 → d365fo_client-0.2.2}/src/d365fo_client.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: d365fo-client
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Microsoft Dynamics 365 Finance & Operations client
5
5
  Author-email: Muhammad Afzaal <mo@thedataguy.pro>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "d365fo-client"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "Microsoft Dynamics 365 Finance & Operations client"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -174,55 +174,13 @@ from .main import main
174
174
  # MCP Server
175
175
  from .mcp import D365FOClientManager, D365FOMCPServer
176
176
 
177
- # Legacy Metadata Cache (deprecated - use metadata_v2)
178
- # REMOVED: Legacy classes have been replaced with V2 implementations
179
- # from .metadata_cache import MetadataCache, MetadataSearchEngine
180
-
181
177
  # V2 Metadata Cache (recommended - now the only implementation)
182
178
  from .metadata_v2 import MetadataCacheV2, VersionAwareSearchEngine
183
179
 
184
180
  # Provide backward compatibility with immediate import errors
185
- import warnings
186
-
187
- class _DeprecatedMetadataCache:
188
- """Deprecated placeholder for MetadataCache - raises error on any access"""
189
- def __init__(self, *args, **kwargs):
190
- warnings.warn(
191
- "MetadataCache is deprecated and has been removed. "
192
- "Use MetadataCacheV2 from d365fo_client.metadata_v2 instead.",
193
- DeprecationWarning,
194
- stacklevel=2
195
- )
196
- raise ImportError(
197
- "MetadataCache has been removed. Use MetadataCacheV2 from d365fo_client.metadata_v2 instead."
198
- )
199
-
200
- def __getattr__(self, name):
201
- raise ImportError(
202
- "MetadataCache has been removed. Use MetadataCacheV2 from d365fo_client.metadata_v2 instead."
203
- )
204
-
205
- class _DeprecatedMetadataSearchEngine:
206
- """Deprecated placeholder for MetadataSearchEngine - raises error on any access"""
207
- def __init__(self, *args, **kwargs):
208
- warnings.warn(
209
- "MetadataSearchEngine is deprecated and has been removed. "
210
- "Use VersionAwareSearchEngine from d365fo_client.metadata_v2 instead.",
211
- DeprecationWarning,
212
- stacklevel=2
213
- )
214
- raise ImportError(
215
- "MetadataSearchEngine has been removed. Use VersionAwareSearchEngine from d365fo_client.metadata_v2 instead."
216
- )
217
-
218
- def __getattr__(self, name):
219
- raise ImportError(
220
- "MetadataSearchEngine has been removed. Use VersionAwareSearchEngine from d365fo_client.metadata_v2 instead."
221
- )
222
-
223
- # Create deprecated placeholder classes
224
- MetadataCache = _DeprecatedMetadataCache
225
- MetadataSearchEngine = _DeprecatedMetadataSearchEngine
181
+
182
+
183
+
226
184
  from .models import (
227
185
  ActionInfo,
228
186
  DataEntityInfo,
@@ -255,9 +213,7 @@ __all__ = [
255
213
  # Main client
256
214
  "FOClient",
257
215
  "create_client",
258
- # Legacy caching (deprecated placeholders - raise errors when used)
259
- "MetadataCache",
260
- "MetadataSearchEngine",
216
+
261
217
  # V2 caching (now the primary implementation)
262
218
  "MetadataCacheV2",
263
219
  "VersionAwareSearchEngine",
@@ -113,23 +113,27 @@ class FOClient:
113
113
  self.config.enable_metadata_cache = False
114
114
 
115
115
  async def _trigger_background_sync_if_needed(self):
116
- """Trigger background sync if metadata is stale or missing"""
116
+ """Trigger background sync if metadata is stale or missing (non-blocking)"""
117
117
  if not self.config.enable_metadata_cache or not self._metadata_initialized:
118
118
  return
119
119
 
120
+ # Don't trigger sync if already running
121
+ if self._is_background_sync_running():
122
+ return
123
+
120
124
  try:
121
125
  # Check if we need to sync using the new v2 API
126
+ # This should be a quick check, not actual sync work
122
127
  sync_needed, global_version_id = (
123
128
  await self.metadata_cache.check_version_and_sync(self.metadata_api_ops)
124
129
  )
125
130
 
126
131
  if sync_needed and global_version_id:
127
- # Only start sync if not already running
128
- if not self._background_sync_task or self._background_sync_task.done():
129
- self._background_sync_task = asyncio.create_task(
130
- self._background_sync_worker(global_version_id)
131
- )
132
- self.logger.debug("Background metadata sync triggered")
132
+ # Start sync in background without awaiting it
133
+ self._background_sync_task = asyncio.create_task(
134
+ self._background_sync_worker(global_version_id)
135
+ )
136
+ self.logger.debug("Background metadata sync triggered")
133
137
  except Exception as e:
134
138
  self.logger.warning(f"Failed to check sync status: {e}")
135
139
 
@@ -222,7 +226,8 @@ class FOClient:
222
226
 
223
227
  # If cache returns empty result, trigger sync and try fallback
224
228
  if not result or (isinstance(result, list) and len(result) == 0):
225
- await self._trigger_background_sync_if_needed()
229
+ # Trigger background sync without awaiting (fire-and-forget)
230
+ asyncio.create_task(self._trigger_background_sync_if_needed())
226
231
  return (
227
232
  await fallback_method(*args, **kwargs)
228
233
  if asyncio.iscoroutinefunction(fallback_method)
@@ -233,8 +238,8 @@ class FOClient:
233
238
 
234
239
  except Exception as e:
235
240
  self.logger.warning(f"Cache lookup failed, using fallback: {e}")
236
- # Trigger sync if cache failed
237
- await self._trigger_background_sync_if_needed()
241
+ # Trigger sync if cache failed (fire-and-forget)
242
+ asyncio.create_task(self._trigger_background_sync_if_needed())
238
243
  return (
239
244
  await fallback_method(*args, **kwargs)
240
245
  if asyncio.iscoroutinefunction(fallback_method)
@@ -1037,29 +1042,44 @@ class FOClient:
1037
1042
  try:
1038
1043
  import asyncio
1039
1044
 
1040
- # Get the current event loop or create a new one
1045
+ # Always check if we're in an async context first
1041
1046
  try:
1042
- loop = asyncio.get_event_loop()
1043
- except RuntimeError:
1044
- loop = asyncio.new_event_loop()
1045
- asyncio.set_event_loop(loop)
1046
-
1047
- if loop.is_running():
1048
- # If we're in an async context, we can't run async code synchronously
1047
+ loop = asyncio.get_running_loop()
1048
+ # We're in an async context, can't run async code synchronously
1049
1049
  return {
1050
1050
  "enabled": True,
1051
1051
  "cache_type": "metadata_v2",
1052
1052
  "message": "Label caching enabled with v2 cache (statistics available via async method)",
1053
1053
  }
1054
- else:
1055
- # If we're not in an async context, we can get the statistics
1056
- stats = loop.run_until_complete(
1057
- self.metadata_cache.get_label_cache_statistics()
1058
- )
1054
+ except RuntimeError:
1055
+ # No running loop, we can safely create one
1056
+ pass
1057
+
1058
+ # Create a new event loop for synchronous execution
1059
+ try:
1060
+ loop = asyncio.new_event_loop()
1061
+ asyncio.set_event_loop(loop)
1062
+ try:
1063
+ stats = loop.run_until_complete(
1064
+ self.metadata_cache.get_label_cache_statistics()
1065
+ )
1066
+ return {
1067
+ "enabled": True,
1068
+ "cache_type": "metadata_v2",
1069
+ "statistics": stats,
1070
+ }
1071
+ finally:
1072
+ loop.close()
1073
+ # Remove the loop to clean up
1074
+ try:
1075
+ asyncio.set_event_loop(None)
1076
+ except:
1077
+ pass
1078
+ except Exception as e:
1059
1079
  return {
1060
1080
  "enabled": True,
1061
1081
  "cache_type": "metadata_v2",
1062
- "statistics": stats,
1082
+ "error": f"Error getting statistics: {e}",
1063
1083
  }
1064
1084
  except Exception as e:
1065
1085
  return {
@@ -2,7 +2,7 @@
2
2
 
3
3
  from dataclasses import dataclass
4
4
  from datetime import datetime
5
- from enum import Enum
5
+ from enum import StrEnum
6
6
  from typing import Any, Dict, List, Optional, Union
7
7
 
8
8
  # Resource Models
@@ -37,7 +37,7 @@ class EntityResourceContent:
37
37
  last_updated: Optional[str] = None
38
38
 
39
39
 
40
- class MetadataType(Enum):
40
+ class MetadataType(StrEnum):
41
41
  ENTITIES = "entities"
42
42
  ACTIONS = "actions"
43
43
  ENUMERATIONS = "enumerations"
@@ -8,6 +8,7 @@ from typing import List
8
8
  from mcp import Tool
9
9
  from mcp.types import TextContent
10
10
 
11
+ from ... import __version__
11
12
  from ..client_manager import D365FOClientManager
12
13
 
13
14
  logger = logging.getLogger(__name__)
@@ -95,6 +96,7 @@ class ConnectionTools:
95
96
  response = {
96
97
  "success": success,
97
98
  "profile": profile,
99
+ "clientVersion": __version__,
98
100
  "endpoints": {
99
101
  "data": success,
100
102
  "metadata": success, # Simplification for now
@@ -113,6 +115,7 @@ class ConnectionTools:
113
115
  error_response = {
114
116
  "success": False,
115
117
  "profile": arguments.get("profile", "default"),
118
+ "clientVersion": __version__,
116
119
  "endpoints": {"data": False, "metadata": False},
117
120
  "responseTime": 0.0,
118
121
  "error": str(e),
@@ -124,6 +127,7 @@ class ConnectionTools:
124
127
  error_response = {
125
128
  "success": False,
126
129
  "profile": arguments.get("profile", "default"),
130
+ "clientVersion": __version__,
127
131
  "endpoints": {"data": False, "metadata": False},
128
132
  "responseTime": 0.0,
129
133
  "error": str(e),
@@ -146,6 +150,7 @@ class ConnectionTools:
146
150
  # Format response according to specification with enhanced metadata info
147
151
  response = {
148
152
  "baseUrl": env_info["base_url"],
153
+ "clientVersion": __version__,
149
154
  "versions": env_info["versions"],
150
155
  "connectivity": env_info["connectivity"],
151
156
  "metadataInfo": env_info["metadata_info"],
@@ -160,6 +165,7 @@ class ConnectionTools:
160
165
  )
161
166
  error_response = {
162
167
  "error": str(e),
168
+ "clientVersion": __version__,
163
169
  "tool": "d365fo_get_environment_info",
164
170
  "arguments": arguments,
165
171
  "suggestion": "Please create a profile or set a default profile using the profile management tools.",
@@ -169,6 +175,7 @@ class ConnectionTools:
169
175
  logger.error(f"Get environment info failed: {e}")
170
176
  error_response = {
171
177
  "error": str(e),
178
+ "clientVersion": __version__,
172
179
  "tool": "d365fo_get_environment_info",
173
180
  "arguments": arguments,
174
181
  }
@@ -940,7 +940,7 @@ class MetadataAPIOperations:
940
940
  continue
941
941
 
942
942
  # Filter by binding kind
943
- if binding_kind and action.binding_kind.value != binding_kind:
943
+ if binding_kind and action.binding_kind != binding_kind:
944
944
  continue
945
945
 
946
946
  # Create ActionInfo with entity context
@@ -237,7 +237,7 @@ class MetadataCacheV2:
237
237
  entity.public_collection_name,
238
238
  entity.label_id,
239
239
  processed_label_text, # Use processed label text
240
- entity.entity_category.value if entity.entity_category else None,
240
+ entity.entity_category if entity.entity_category else None,
241
241
  entity.data_service_enabled,
242
242
  entity.data_management_enabled,
243
243
  entity.is_read_only,
@@ -481,7 +481,7 @@ class MetadataCacheV2:
481
481
  nav_prop.name,
482
482
  nav_prop.related_entity,
483
483
  nav_prop.related_relation_name,
484
- nav_prop.cardinality.value, # Convert enum to string value
484
+ nav_prop.cardinality, # StrEnum automatically converts to string
485
485
  ),
486
486
  )
487
487
 
@@ -519,7 +519,7 @@ class MetadataCacheV2:
519
519
  entity_id,
520
520
  global_version_id,
521
521
  action.name,
522
- action.binding_kind.value, # Convert enum to string value
522
+ action.binding_kind, # StrEnum automatically converts to string
523
523
  entity_schema.name,
524
524
  entity_schema.entity_set_name,
525
525
  action.return_type.type_name if action.return_type else None,
@@ -1495,19 +1495,24 @@ class MetadataCacheV2:
1495
1495
  """Get cache statistics
1496
1496
 
1497
1497
  Returns:
1498
- Dictionary with cache statistics
1498
+ Dictionary with cache statistics scoped to the current environment
1499
1499
  """
1500
+ await self.initialize()
1501
+
1502
+ if self._environment_id is None:
1503
+ raise ValueError("Environment not initialized")
1504
+
1500
1505
  stats = {}
1501
1506
 
1502
- # Database statistics
1503
- db_stats = await self.database.get_database_statistics()
1507
+ # Environment-scoped database statistics
1508
+ db_stats = await self.database.get_environment_database_statistics(self._environment_id)
1504
1509
  stats.update(db_stats)
1505
1510
 
1506
- # Version statistics
1507
- version_stats = await self.version_manager.get_version_statistics()
1511
+ # Environment-scoped version statistics
1512
+ version_stats = await self.version_manager.get_environment_version_statistics(self._environment_id)
1508
1513
  stats["version_manager"] = version_stats
1509
1514
 
1510
- # Current version info
1515
+ # Current version info (already environment-scoped)
1511
1516
  current_version = await self._get_current_global_version_id()
1512
1517
  if current_version:
1513
1518
  version_info = await self.version_manager.get_global_version_info(
@@ -1521,7 +1526,7 @@ class MetadataCacheV2:
1521
1526
  "reference_count": version_info.reference_count,
1522
1527
  }
1523
1528
 
1524
- # Label cache statistics
1529
+ # Label cache statistics (already environment-scoped via current_version)
1525
1530
  label_stats = await self.get_label_cache_statistics(current_version)
1526
1531
  stats["label_cache"] = label_stats
1527
1532
 
@@ -542,6 +542,99 @@ class MetadataDatabaseV2:
542
542
 
543
543
  return stats
544
544
 
545
+ async def get_statistics(self) -> Dict[str, Any]:
546
+ """Alias for get_database_statistics for backward compatibility
547
+
548
+ Returns:
549
+ Dictionary with database statistics
550
+ """
551
+ return await self.get_database_statistics()
552
+
553
+ async def get_environment_database_statistics(self, environment_id: int) -> Dict[str, Any]:
554
+ """Get database statistics scoped to a specific environment
555
+
556
+ Args:
557
+ environment_id: Environment ID to get statistics for
558
+
559
+ Returns:
560
+ Dictionary with environment-scoped database statistics
561
+ """
562
+ async with aiosqlite.connect(self.db_path) as db:
563
+ stats = {}
564
+
565
+ # Get active global versions for this environment
566
+ cursor = await db.execute(
567
+ """SELECT DISTINCT global_version_id
568
+ FROM environment_versions
569
+ WHERE environment_id = ? AND is_active = 1""",
570
+ (environment_id,)
571
+ )
572
+ active_versions = [row[0] for row in await cursor.fetchall()]
573
+
574
+ if not active_versions:
575
+ # No active versions, return zero counts
576
+ return {
577
+ "data_entities_count": 0,
578
+ "public_entities_count": 0,
579
+ "entity_properties_count": 0,
580
+ "navigation_properties_count": 0,
581
+ "entity_actions_count": 0,
582
+ "enumerations_count": 0,
583
+ "labels_cache_count": 0,
584
+ "environment_statistics": {
585
+ "total_environments": 1,
586
+ "linked_versions": 0,
587
+ },
588
+ "database_size_bytes": None,
589
+ "database_size_mb": None,
590
+ }
591
+
592
+ # Create placeholders for SQL IN clause
593
+ version_placeholders = ",".join("?" for _ in active_versions)
594
+
595
+ # Environment-scoped metadata counts
596
+ tables = [
597
+ ("data_entities", "entities"),
598
+ ("public_entities", "public_entities"),
599
+ ("entity_properties", "properties"),
600
+ ("navigation_properties", "navigation_properties"),
601
+ ("entity_actions", "actions"),
602
+ ("enumerations", "enumerations"),
603
+ ("labels_cache", "labels"),
604
+ ]
605
+
606
+ for table, key in tables:
607
+ cursor = await db.execute(
608
+ f"SELECT COUNT(*) FROM {table} WHERE global_version_id IN ({version_placeholders})",
609
+ active_versions
610
+ )
611
+ stats[f"{table}_count"] = (await cursor.fetchone())[0]
612
+
613
+ # Environment-specific statistics
614
+ cursor = await db.execute(
615
+ """SELECT
616
+ COUNT(DISTINCT ev.global_version_id) as linked_versions
617
+ FROM environment_versions ev
618
+ WHERE ev.environment_id = ? AND ev.is_active = 1""",
619
+ (environment_id,)
620
+ )
621
+ env_stats = await cursor.fetchone()
622
+ stats["environment_statistics"] = {
623
+ "total_environments": 1, # Current environment only
624
+ "linked_versions": env_stats[0] or 0,
625
+ }
626
+
627
+ # Database file size (shared across all environments)
628
+ try:
629
+ db_size = self.db_path.stat().st_size
630
+ stats["database_size_bytes"] = db_size
631
+ stats["database_size_mb"] = round(db_size / (1024 * 1024), 2)
632
+ except Exception:
633
+ stats["database_size_bytes"] = None
634
+ stats["database_size_mb"] = None
635
+
636
+ return stats
637
+
545
638
  async def vacuum_database(self) -> bool:
546
639
  """Vacuum database to reclaim space
547
640
 
@@ -571,3 +571,63 @@ class GlobalVersionManager:
571
571
  }
572
572
 
573
573
  return stats
574
+
575
+ async def get_environment_version_statistics(self, environment_id: int) -> Dict[str, Any]:
576
+ """Get version statistics scoped to a specific environment
577
+
578
+ Args:
579
+ environment_id: Environment ID to get statistics for
580
+
581
+ Returns:
582
+ Dictionary with environment-scoped version statistics
583
+ """
584
+ async with aiosqlite.connect(self.db_path) as db:
585
+ stats = {}
586
+
587
+ # Get versions for this specific environment
588
+ cursor = await db.execute(
589
+ """SELECT COUNT(DISTINCT global_version_id)
590
+ FROM environment_versions
591
+ WHERE environment_id = ? AND is_active = 1""",
592
+ (environment_id,)
593
+ )
594
+ stats["total_versions"] = (await cursor.fetchone())[0]
595
+
596
+ # This environment only
597
+ stats["total_environments"] = 1
598
+
599
+ # Reference statistics for this environment's versions
600
+ cursor = await db.execute(
601
+ """SELECT
602
+ SUM(gv.reference_count) as total_references,
603
+ AVG(gv.reference_count) as avg_references,
604
+ MAX(gv.reference_count) as max_references,
605
+ COUNT(*) as versions_with_refs
606
+ FROM global_versions gv
607
+ INNER JOIN environment_versions ev ON gv.id = ev.global_version_id
608
+ WHERE ev.environment_id = ? AND ev.is_active = 1 AND gv.reference_count > 0""",
609
+ (environment_id,)
610
+ )
611
+ ref_stats = await cursor.fetchone()
612
+ stats["reference_statistics"] = {
613
+ "total_references": ref_stats[0] or 0,
614
+ "avg_references": round(ref_stats[1] or 0, 2),
615
+ "max_references": ref_stats[2] or 0,
616
+ "versions_with_references": ref_stats[3] or 0,
617
+ }
618
+
619
+ # Version age statistics for this environment
620
+ cursor = await db.execute(
621
+ """SELECT
622
+ COUNT(*) as recent_versions
623
+ FROM global_versions gv
624
+ INNER JOIN environment_versions ev ON gv.id = ev.global_version_id
625
+ WHERE ev.environment_id = ? AND ev.is_active = 1
626
+ AND gv.last_used_at >= datetime('now', '-7 days')""",
627
+ (environment_id,)
628
+ )
629
+ stats["recent_activity"] = {
630
+ "versions_used_last_7_days": (await cursor.fetchone())[0]
631
+ }
632
+
633
+ return stats
@@ -4,7 +4,7 @@ import hashlib
4
4
  import json
5
5
  from dataclasses import dataclass, field
6
6
  from datetime import datetime, timezone
7
- from enum import Enum
7
+ from enum import Enum, StrEnum
8
8
  from pathlib import Path
9
9
  from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
10
10
 
@@ -14,7 +14,16 @@ if TYPE_CHECKING:
14
14
  from typing import ForwardRef
15
15
 
16
16
 
17
- class EntityCategory(Enum):
17
+ def _ensure_str_for_json(field):
18
+ """Ensure field is JSON-serializable as string.
19
+
20
+ StrEnum fields automatically serialize as strings, but this handles
21
+ the edge case where a field might be None or already a string.
22
+ """
23
+ return field # StrEnum automatically converts to string, None stays None
24
+
25
+
26
+ class EntityCategory(StrEnum):
18
27
  """D365 F&O Entity Categories"""
19
28
 
20
29
  MASTER = "Master"
@@ -25,7 +34,7 @@ class EntityCategory(Enum):
25
34
  PARAMETERS = "Parameters"
26
35
 
27
36
 
28
- class ODataXppType(Enum):
37
+ class ODataXppType(StrEnum):
29
38
  """D365 F&O OData XPP Types"""
30
39
 
31
40
  CONTAINER = "Container"
@@ -42,7 +51,7 @@ class ODataXppType(Enum):
42
51
  VOID = "Void"
43
52
 
44
53
 
45
- class ODataBindingKind(Enum):
54
+ class ODataBindingKind(StrEnum):
46
55
  """D365 F&O Action Binding Types"""
47
56
 
48
57
  BOUND_TO_ENTITY_INSTANCE = "BoundToEntityInstance"
@@ -50,7 +59,7 @@ class ODataBindingKind(Enum):
50
59
  UNBOUND = "Unbound"
51
60
 
52
61
 
53
- class SyncStrategy(Enum):
62
+ class SyncStrategy(StrEnum):
54
63
  """Metadata synchronization strategies"""
55
64
 
56
65
  FULL = "full"
@@ -59,7 +68,7 @@ class SyncStrategy(Enum):
59
68
  SHARING_MODE = "sharing_mode"
60
69
 
61
70
 
62
- class Cardinality(Enum):
71
+ class Cardinality(StrEnum):
63
72
  """Navigation Property Cardinality"""
64
73
 
65
74
  SINGLE = "Single"
@@ -191,7 +200,7 @@ class PublicEntityActionInfo:
191
200
  def to_dict(self) -> Dict[str, Any]:
192
201
  return {
193
202
  "name": self.name,
194
- "binding_kind": self.binding_kind.value, # Convert enum to string value
203
+ "binding_kind": self.binding_kind, # StrEnum automatically serializes as string
195
204
  "parameters": [param.to_dict() for param in self.parameters],
196
205
  "return_type": self.return_type.to_dict() if self.return_type else None,
197
206
  "field_lookup": self.field_lookup,
@@ -221,7 +230,7 @@ class DataEntityInfo:
221
230
  "label_text": self.label_text,
222
231
  "data_service_enabled": self.data_service_enabled,
223
232
  "data_management_enabled": self.data_management_enabled,
224
- "entity_category": self.entity_category.value if self.entity_category else None,
233
+ "entity_category": self.entity_category, # StrEnum automatically serializes as string
225
234
  "is_read_only": self.is_read_only,
226
235
  }
227
236
 
@@ -438,7 +447,7 @@ class NavigationPropertyInfo:
438
447
  "name": self.name,
439
448
  "related_entity": self.related_entity,
440
449
  "related_relation_name": self.related_relation_name,
441
- "cardinality": self.cardinality.value, # Convert enum to string value
450
+ "cardinality": self.cardinality, # StrEnum automatically serializes as string
442
451
  "constraints": [constraint.to_dict() for constraint in self.constraints],
443
452
  }
444
453
 
@@ -503,7 +512,7 @@ class ActionInfo:
503
512
  def to_dict(self) -> Dict[str, Any]:
504
513
  return {
505
514
  "name": self.name,
506
- "binding_kind": self.binding_kind.value, # Serialize enum as string value
515
+ "binding_kind": self.binding_kind, # StrEnum automatically serializes as string
507
516
  "entity_name": self.entity_name,
508
517
  "entity_set_name": self.entity_set_name,
509
518
  "parameters": [param.to_dict() for param in self.parameters],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: d365fo-client
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Microsoft Dynamics 365 Finance & Operations client
5
5
  Author-email: Muhammad Afzaal <mo@thedataguy.pro>
6
6
  License-Expression: MIT
File without changes
File without changes
File without changes