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,862 @@
1
+ """Data models and data classes for D365 F&O client."""
2
+
3
+ import hashlib
4
+ import json
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime, timezone
7
+ from enum import Enum
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
10
+
11
+ from .utils import get_environment_cache_directory
12
+
13
+ if TYPE_CHECKING:
14
+ from typing import ForwardRef
15
+
16
+
17
+ class EntityCategory(Enum):
18
+ """D365 F&O Entity Categories"""
19
+
20
+ MASTER = "Master"
21
+ CONFIGURATION = "Configuration"
22
+ TRANSACTION = "Transaction"
23
+ REFERENCE = "Reference"
24
+ DOCUMENT = "Document"
25
+ PARAMETERS = "Parameters"
26
+
27
+
28
+ class ODataXppType(Enum):
29
+ """D365 F&O OData XPP Types"""
30
+
31
+ CONTAINER = "Container"
32
+ DATE = "Date"
33
+ ENUM = "Enum"
34
+ GUID = "Guid"
35
+ INT32 = "Int32"
36
+ INT64 = "Int64"
37
+ REAL = "Real"
38
+ RECORD = "Record"
39
+ STRING = "String"
40
+ TIME = "Time"
41
+ UTC_DATETIME = "UtcDateTime"
42
+ VOID = "Void"
43
+
44
+
45
+ class ODataBindingKind(Enum):
46
+ """D365 F&O Action Binding Types"""
47
+
48
+ BOUND_TO_ENTITY_INSTANCE = "BoundToEntityInstance"
49
+ BOUND_TO_ENTITY_SET = "BoundToEntitySet"
50
+ UNBOUND = "Unbound"
51
+
52
+
53
+ class SyncStrategy(Enum):
54
+ """Metadata synchronization strategies"""
55
+
56
+ FULL = "full"
57
+ INCREMENTAL = "incremental"
58
+ ENTITIES_ONLY = "entities_only"
59
+ SHARING_MODE = "sharing_mode"
60
+
61
+
62
+ class Cardinality(Enum):
63
+ """Navigation Property Cardinality"""
64
+
65
+ SINGLE = "Single"
66
+ MULTIPLE = "Multiple"
67
+
68
+
69
+ @dataclass
70
+ class FOClientConfig:
71
+ """Configuration for F&O Client"""
72
+
73
+ base_url: str
74
+ client_id: Optional[str] = None
75
+ client_secret: Optional[str] = None
76
+ tenant_id: Optional[str] = None
77
+ use_default_credentials: bool = True
78
+ verify_ssl: bool = False
79
+ metadata_cache_dir: str = None
80
+ timeout: int = 30
81
+ # Label cache configuration
82
+ use_label_cache: bool = True
83
+ label_cache_expiry_minutes: int = 60
84
+ # Metadata cache configuration
85
+ enable_metadata_cache: bool = True
86
+ metadata_sync_interval_minutes: int = 60
87
+ cache_ttl_seconds: int = 300
88
+ enable_fts_search: bool = True
89
+ max_memory_cache_size: int = 1000
90
+ # Cache-first behavior configuration
91
+ use_cache_first: bool = True
92
+
93
+ def __post_init__(self):
94
+ """Post-initialization to set default cache directory if not provided."""
95
+ if self.metadata_cache_dir is None:
96
+ cache_dir = (
97
+ Path.home() / ".d365fo-client"
98
+ ) # get_environment_cache_directory(self.base_url)
99
+ cache_dir.mkdir(exist_ok=True)
100
+ self.metadata_cache_dir = str(cache_dir)
101
+
102
+
103
+ @dataclass
104
+ class QueryOptions:
105
+ """OData query options"""
106
+
107
+ select: Optional[List[str]] = None
108
+ filter: Optional[str] = None
109
+ expand: Optional[List[str]] = None
110
+ orderby: Optional[List[str]] = None
111
+ top: Optional[int] = None
112
+ skip: Optional[int] = None
113
+ count: bool = False
114
+ search: Optional[str] = None
115
+
116
+
117
+ @dataclass
118
+ class LabelInfo:
119
+ """Information about a label"""
120
+
121
+ id: str
122
+ language: str
123
+ value: str
124
+
125
+ def to_dict(self) -> Dict[str, str]:
126
+ return {"id": self.id, "language": self.language, "value": self.value}
127
+
128
+
129
+ @dataclass
130
+ class ActionParameterTypeInfo:
131
+ """Type information for action parameters"""
132
+
133
+ type_name: str
134
+ is_collection: bool = False
135
+ odata_xpp_type: Optional[str] = None
136
+
137
+ def to_dict(self) -> Dict[str, Any]:
138
+ return {
139
+ "type_name": self.type_name,
140
+ "is_collection": self.is_collection,
141
+ "odata_xpp_type": self.odata_xpp_type,
142
+ }
143
+
144
+
145
+ @dataclass
146
+ class ActionParameterInfo:
147
+ """Information about an action parameter"""
148
+
149
+ name: str
150
+ type: ActionParameterTypeInfo
151
+ parameter_order: int = 0
152
+
153
+ def to_dict(self) -> Dict[str, Any]:
154
+ return {
155
+ "name": self.name,
156
+ "type": self.type.to_dict(),
157
+ "parameter_order": self.parameter_order,
158
+ }
159
+
160
+
161
+ @dataclass
162
+ class ActionReturnTypeInfo:
163
+ """Return type information for actions"""
164
+
165
+ type_name: str
166
+ is_collection: bool = False
167
+ odata_xpp_type: Optional[str] = None
168
+
169
+ def to_dict(self) -> Dict[str, Any]:
170
+ return {
171
+ "type_name": self.type_name,
172
+ "is_collection": self.is_collection,
173
+ "odata_xpp_type": self.odata_xpp_type,
174
+ }
175
+
176
+
177
+ @dataclass
178
+ class PublicEntityActionInfo:
179
+ """Detailed action information from PublicEntities endpoint"""
180
+
181
+ name: str
182
+ binding_kind: ODataBindingKind
183
+ parameters: List[ActionParameterInfo] = None
184
+ return_type: Optional[ActionReturnTypeInfo] = None
185
+ field_lookup: Optional[str] = None
186
+
187
+ def __post_init__(self):
188
+ if self.parameters is None:
189
+ self.parameters = []
190
+
191
+ def to_dict(self) -> Dict[str, Any]:
192
+ return {
193
+ "name": self.name,
194
+ "binding_kind": self.binding_kind.value, # Convert enum to string value
195
+ "parameters": [param.to_dict() for param in self.parameters],
196
+ "return_type": self.return_type.to_dict() if self.return_type else None,
197
+ "field_lookup": self.field_lookup,
198
+ }
199
+
200
+
201
+ @dataclass
202
+ class DataEntityInfo:
203
+ """Information about a data entity from DataEntities endpoint"""
204
+
205
+ name: str
206
+ public_entity_name: str
207
+ public_collection_name: str
208
+ label_id: Optional[str] = None
209
+ label_text: Optional[str] = None
210
+ data_service_enabled: bool = True
211
+ data_management_enabled: bool = True
212
+ entity_category: Optional[EntityCategory] = None
213
+ is_read_only: bool = False
214
+
215
+ def to_dict(self) -> Dict[str, Any]:
216
+ return {
217
+ "name": self.name,
218
+ "public_entity_name": self.public_entity_name,
219
+ "public_collection_name": self.public_collection_name,
220
+ "label_id": self.label_id,
221
+ "label_text": self.label_text,
222
+ "data_service_enabled": self.data_service_enabled,
223
+ "data_management_enabled": self.data_management_enabled,
224
+ "entity_category": self.entity_category,
225
+ "is_read_only": self.is_read_only,
226
+ }
227
+
228
+
229
+ @dataclass
230
+ class PublicEntityPropertyInfo:
231
+ """Detailed property information from PublicEntities endpoint"""
232
+
233
+ name: str
234
+ type_name: str
235
+ data_type: str
236
+ odata_xpp_type: Optional[str] = None # Map to D365 internal types
237
+ label_id: Optional[str] = None
238
+ label_text: Optional[str] = None
239
+ is_key: bool = False
240
+ is_mandatory: bool = False
241
+ configuration_enabled: bool = True
242
+ allow_edit: bool = True
243
+ allow_edit_on_create: bool = True
244
+ is_dimension: bool = False
245
+ dimension_relation: Optional[str] = None
246
+ is_dynamic_dimension: bool = False
247
+ dimension_legal_entity_property: Optional[str] = None
248
+ dimension_type_property: Optional[str] = None
249
+ property_order: int = 0
250
+
251
+ def to_dict(self) -> Dict[str, Any]:
252
+ return {
253
+ "name": self.name,
254
+ "type_name": self.type_name,
255
+ "data_type": self.data_type,
256
+ "odata_xpp_type": self.odata_xpp_type,
257
+ "label_id": self.label_id,
258
+ "label_text": self.label_text,
259
+ "is_key": self.is_key,
260
+ "is_mandatory": self.is_mandatory,
261
+ "configuration_enabled": self.configuration_enabled,
262
+ "allow_edit": self.allow_edit,
263
+ "allow_edit_on_create": self.allow_edit_on_create,
264
+ "is_dimension": self.is_dimension,
265
+ "dimension_relation": self.dimension_relation,
266
+ "is_dynamic_dimension": self.is_dynamic_dimension,
267
+ "dimension_legal_entity_property": self.dimension_legal_entity_property,
268
+ "dimension_type_property": self.dimension_type_property,
269
+ "property_order": self.property_order,
270
+ }
271
+
272
+
273
+ @dataclass
274
+ class PublicEntityInfo:
275
+ """Enhanced entity information from PublicEntities endpoint"""
276
+
277
+ name: str
278
+ entity_set_name: str
279
+ label_id: Optional[str] = None
280
+ label_text: Optional[str] = None
281
+ is_read_only: bool = False
282
+ configuration_enabled: bool = True
283
+ properties: List[PublicEntityPropertyInfo] = field(default_factory=list)
284
+ navigation_properties: List["NavigationPropertyInfo"] = field(default_factory=list)
285
+ property_groups: List["PropertyGroupInfo"] = field(default_factory=list)
286
+ actions: List["PublicEntityActionInfo"] = field(default_factory=list)
287
+
288
+ def to_dict(self) -> Dict[str, Any]:
289
+ return {
290
+ "name": self.name,
291
+ "entity_set_name": self.entity_set_name,
292
+ "label_id": self.label_id,
293
+ "label_text": self.label_text,
294
+ "is_read_only": self.is_read_only,
295
+ "configuration_enabled": self.configuration_enabled,
296
+ "properties": [prop.to_dict() for prop in self.properties],
297
+ "navigation_properties": [
298
+ nav.to_dict() for nav in self.navigation_properties
299
+ ],
300
+ "property_groups": [group.to_dict() for group in self.property_groups],
301
+ "actions": [action.to_dict() for action in self.actions],
302
+ }
303
+
304
+
305
+ @dataclass
306
+ class EnumerationMemberInfo:
307
+ """Information about an enumeration member"""
308
+
309
+ name: str
310
+ value: int
311
+ label_id: Optional[str] = None
312
+ label_text: Optional[str] = None
313
+ configuration_enabled: bool = True
314
+ member_order: int = 0
315
+
316
+ def to_dict(self) -> Dict[str, Any]:
317
+ return {
318
+ "name": self.name,
319
+ "value": self.value,
320
+ "label_id": self.label_id,
321
+ "label_text": self.label_text,
322
+ "configuration_enabled": self.configuration_enabled,
323
+ "member_order": self.member_order,
324
+ }
325
+
326
+
327
+ @dataclass
328
+ class EnumerationInfo:
329
+ """Information about an enumeration from PublicEnumerations endpoint"""
330
+
331
+ name: str
332
+ label_id: Optional[str] = None
333
+ label_text: Optional[str] = None
334
+ members: List[EnumerationMemberInfo] = None
335
+
336
+ def __post_init__(self):
337
+ if self.members is None:
338
+ self.members = []
339
+
340
+ def to_dict(self) -> Dict[str, Any]:
341
+ return {
342
+ "name": self.name,
343
+ "label_id": self.label_id,
344
+ "label_text": self.label_text,
345
+ "members": [member.to_dict() for member in self.members],
346
+ }
347
+
348
+
349
+ # Enhanced Complex Type Models
350
+
351
+
352
+ @dataclass
353
+ class RelationConstraintInfo:
354
+ """Base relation constraint information"""
355
+
356
+ constraint_type: str = field(init=False) # "Referential"|"Fixed"|"RelatedFixed" - set by __post_init__ in subclasses
357
+
358
+ def to_dict(self) -> Dict[str, Any]:
359
+ return {"constraint_type": self.constraint_type}
360
+
361
+
362
+ @dataclass
363
+ class ReferentialConstraintInfo(RelationConstraintInfo):
364
+ """Referential constraint (foreign key relationship)"""
365
+
366
+ property: str
367
+ referenced_property: str
368
+
369
+ def __post_init__(self):
370
+ self.constraint_type = "Referential"
371
+
372
+ def to_dict(self) -> Dict[str, Any]:
373
+ result = super().to_dict()
374
+ result.update(
375
+ {"property": self.property, "referenced_property": self.referenced_property}
376
+ )
377
+ return result
378
+
379
+
380
+ @dataclass
381
+ class FixedConstraintInfo(RelationConstraintInfo):
382
+ """Fixed value constraint"""
383
+
384
+ property: str
385
+ value: Optional[int] = None
386
+ value_str: Optional[str] = None
387
+
388
+ def __post_init__(self):
389
+ self.constraint_type = "Fixed"
390
+
391
+ def to_dict(self) -> Dict[str, Any]:
392
+ result = super().to_dict()
393
+ result.update(
394
+ {
395
+ "property": self.property,
396
+ "value": self.value,
397
+ "value_str": self.value_str,
398
+ }
399
+ )
400
+ return result
401
+
402
+
403
+ @dataclass
404
+ class RelatedFixedConstraintInfo(RelationConstraintInfo):
405
+ """Related fixed constraint"""
406
+
407
+ related_property: str
408
+ value: Optional[int] = None
409
+ value_str: Optional[str] = None
410
+
411
+ def __post_init__(self):
412
+ self.constraint_type = "RelatedFixed"
413
+
414
+ def to_dict(self) -> Dict[str, Any]:
415
+ result = super().to_dict()
416
+ result.update(
417
+ {
418
+ "related_property": self.related_property,
419
+ "value": self.value,
420
+ "value_str": self.value_str,
421
+ }
422
+ )
423
+ return result
424
+
425
+
426
+ @dataclass
427
+ class NavigationPropertyInfo:
428
+ """Navigation property with full constraint support"""
429
+
430
+ name: str
431
+ related_entity: str
432
+ related_relation_name: Optional[str] = None
433
+ cardinality: Cardinality = Cardinality.SINGLE
434
+ constraints: List["RelationConstraintInfo"] = field(default_factory=list)
435
+
436
+ def to_dict(self) -> Dict[str, Any]:
437
+ return {
438
+ "name": self.name,
439
+ "related_entity": self.related_entity,
440
+ "related_relation_name": self.related_relation_name,
441
+ "cardinality": self.cardinality.value, # Convert enum to string value
442
+ "constraints": [constraint.to_dict() for constraint in self.constraints],
443
+ }
444
+
445
+
446
+ @dataclass
447
+ class PropertyGroupInfo:
448
+ """Property group information"""
449
+
450
+ name: str
451
+ properties: List[str] = field(default_factory=list)
452
+
453
+ def to_dict(self) -> Dict[str, Any]:
454
+ return {"name": self.name, "properties": self.properties}
455
+
456
+
457
+ @dataclass
458
+ class ActionTypeInfo:
459
+ """Action type with D365-specific type mapping"""
460
+
461
+ type_name: str
462
+ is_collection: bool = False
463
+ odata_xpp_type: Optional[str] = None
464
+
465
+ def to_dict(self) -> Dict[str, Any]:
466
+ return {
467
+ "type_name": self.type_name,
468
+ "is_collection": self.is_collection,
469
+ "odata_xpp_type": self.odata_xpp_type,
470
+ }
471
+
472
+
473
+ @dataclass
474
+ class ActionParameterInfo:
475
+ """Enhanced action parameter information"""
476
+
477
+ name: str
478
+ type: "ActionTypeInfo"
479
+ parameter_order: int = 0
480
+
481
+ def to_dict(self) -> Dict[str, Any]:
482
+ return {
483
+ "name": self.name,
484
+ "type": self.type.to_dict(),
485
+ "parameter_order": self.parameter_order,
486
+ }
487
+
488
+
489
+ @dataclass
490
+ class ActionInfo:
491
+ """Complete action information with binding support"""
492
+
493
+ name: str
494
+ binding_kind: ODataBindingKind = ODataBindingKind.BOUND_TO_ENTITY_SET
495
+ entity_name: Optional[str] = None # For bound actions (public entity name)
496
+ entity_set_name: Optional[str] = (
497
+ None # For bound actions (entity set name for OData URLs)
498
+ )
499
+ parameters: List["ActionParameterInfo"] = field(default_factory=list)
500
+ return_type: Optional["ActionReturnTypeInfo"] = None
501
+ field_lookup: Optional[str] = None
502
+
503
+ def to_dict(self) -> Dict[str, Any]:
504
+ return {
505
+ "name": self.name,
506
+ "binding_kind": self.binding_kind,
507
+ "entity_name": self.entity_name,
508
+ "entity_set_name": self.entity_set_name,
509
+ "parameters": [param.to_dict() for param in self.parameters],
510
+ "return_type": self.return_type.to_dict() if self.return_type else None,
511
+ "field_lookup": self.field_lookup,
512
+ }
513
+
514
+
515
+ # Cache and Search Models
516
+
517
+
518
+ @dataclass
519
+ class MetadataVersionInfo:
520
+ """Metadata version information"""
521
+
522
+ environment_id: int
523
+ version_hash: str
524
+ application_version: Optional[str] = None
525
+ platform_version: Optional[str] = None
526
+ package_info: Optional[List[Dict[str, Any]]] = None
527
+ created_at: Optional[datetime] = None
528
+ is_active: bool = True
529
+
530
+
531
+ @dataclass
532
+ class SearchQuery:
533
+ """Advanced search query parameters"""
534
+
535
+ text: str
536
+ entity_types: Optional[List[str]] = (
537
+ None # data_entity|public_entity|enumeration|action
538
+ )
539
+ filters: Optional[Dict[str, Any]] = None
540
+ limit: int = 50
541
+ offset: int = 0
542
+ use_fulltext: bool = True
543
+ include_properties: bool = False
544
+ include_actions: bool = False
545
+
546
+
547
+ @dataclass
548
+ class SearchResult:
549
+ """Individual search result"""
550
+
551
+ name: str
552
+ entity_type: str
553
+ description: Optional[str] = None
554
+ relevance: float = 0.0
555
+ snippet: Optional[str] = None
556
+ entity_set_name: Optional[str] = None
557
+ label_text: Optional[str] = None
558
+
559
+ def to_dict(self) -> Dict[str, Any]:
560
+ return {
561
+ "name": self.name,
562
+ "entity_type": self.entity_type,
563
+ "description": self.description,
564
+ "relevance": self.relevance,
565
+ "snippet": self.snippet,
566
+ "entity_set_name": self.entity_set_name,
567
+ "label_text": self.label_text,
568
+ }
569
+
570
+
571
+ @dataclass
572
+ class SearchResults:
573
+ """Search results container"""
574
+
575
+ results: List[SearchResult]
576
+ total_count: int = 0
577
+ query_time_ms: float = 0.0
578
+ cache_hit: bool = False
579
+
580
+ def to_dict(self) -> Dict[str, Any]:
581
+ return {
582
+ "results": [result.to_dict() for result in self.results],
583
+ "total_count": self.total_count,
584
+ "query_time_ms": self.query_time_ms,
585
+ "cache_hit": self.cache_hit,
586
+ }
587
+
588
+
589
+ @dataclass
590
+ class SyncResult:
591
+ """Metadata synchronization result"""
592
+
593
+ sync_type: str # full|incremental|skipped
594
+ entities_synced: int = 0
595
+ actions_synced: int = 0
596
+ enumerations_synced: int = 0
597
+ labels_synced: int = 0
598
+ duration_ms: float = 0.0
599
+ success: bool = True
600
+ errors: List[str] = field(default_factory=list)
601
+ reason: Optional[str] = None
602
+
603
+
604
+ # ============================================================================
605
+ # Enhanced V2 Models for Advanced Metadata Caching
606
+ # ============================================================================
607
+
608
+
609
+ @dataclass
610
+ class ModuleVersionInfo:
611
+ """Information about installed D365 module based on GetInstalledModules response"""
612
+
613
+ name: str # Module name (e.g., "AccountsPayableMobile")
614
+ version: str # Version string (e.g., "10.34.2105.34092")
615
+ module_id: str # Module identifier (e.g., "AccountsPayableMobile")
616
+ publisher: str # Publisher (e.g., "Microsoft Corporation")
617
+ display_name: str # Human-readable name (e.g., "Accounts Payable Mobile")
618
+
619
+ @classmethod
620
+ def parse_from_string(cls, module_string: str) -> "ModuleVersionInfo":
621
+ """Parse module info from GetInstalledModules string format
622
+
623
+ Args:
624
+ module_string: String in format "Name: X | Version: Y | Module: Z | Publisher: W | DisplayName: V"
625
+
626
+ Returns:
627
+ ModuleVersionInfo instance
628
+
629
+ Raises:
630
+ ValueError: If string format is invalid
631
+ """
632
+ try:
633
+ parts = module_string.split(" | ")
634
+ if len(parts) != 5:
635
+ raise ValueError(
636
+ f"Invalid module string format: expected 5 parts, got {len(parts)}"
637
+ )
638
+
639
+ name = parts[0].replace("Name: ", "")
640
+ version = parts[1].replace("Version: ", "")
641
+ module_id = parts[2].replace("Module: ", "")
642
+ publisher = parts[3].replace("Publisher: ", "")
643
+ display_name = parts[4].replace("DisplayName: ", "")
644
+
645
+ # Validate that all parts are non-empty
646
+ if not all([name, version, module_id, publisher, display_name]):
647
+ raise ValueError("All module string parts must be non-empty")
648
+
649
+ return cls(
650
+ name=name,
651
+ version=version,
652
+ module_id=module_id,
653
+ publisher=publisher,
654
+ display_name=display_name,
655
+ )
656
+ except Exception as e:
657
+ raise ValueError(f"Failed to parse module string '{module_string}': {e}")
658
+
659
+ def to_dict(self) -> Dict[str, str]:
660
+ """Convert to dictionary for JSON serialization"""
661
+ return {
662
+ "name": self.name,
663
+ "version": self.version,
664
+ "module_id": self.module_id,
665
+ "publisher": self.publisher,
666
+ "display_name": self.display_name,
667
+ }
668
+
669
+
670
+ @dataclass
671
+ class EnvironmentVersionInfo:
672
+ """Enhanced environment version with precise module tracking"""
673
+
674
+ environment_id: int
675
+ version_hash: str # Fast hash based on all module versions
676
+ modules_hash: str # Hash of sorted module list for deduplication
677
+ application_version: Optional[str] = None # Fallback version info
678
+ platform_version: Optional[str] = None # Fallback version info
679
+ modules: List[ModuleVersionInfo] = field(default_factory=list)
680
+ computed_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
681
+ is_active: bool = True
682
+
683
+ def __post_init__(self):
684
+ """Ensure version hashes are computed if not provided"""
685
+ if not self.modules_hash and self.modules:
686
+ self.modules_hash = self._compute_modules_hash()
687
+ if not self.version_hash:
688
+ self.version_hash = self.modules_hash[
689
+ :16
690
+ ] # Use first 16 chars for compatibility
691
+
692
+ def _compute_modules_hash(self) -> str:
693
+ """Compute hash based on sorted module versions for consistent deduplication"""
694
+ if not self.modules:
695
+ return hashlib.sha256("empty".encode()).hexdigest()
696
+
697
+ # Sort modules by module_id for consistent hashing
698
+ sorted_modules = sorted(self.modules, key=lambda m: m.module_id)
699
+
700
+ # Create hash input from essential version data
701
+ hash_data = []
702
+ for module in sorted_modules:
703
+ hash_data.append(f"{module.module_id}:{module.version}")
704
+
705
+ hash_input = "|".join(hash_data)
706
+ return hashlib.sha256(hash_input.encode()).hexdigest()
707
+
708
+ def to_dict(self) -> Dict[str, Any]:
709
+ """Convert to dictionary for JSON serialization"""
710
+ return {
711
+ "environment_id": self.environment_id,
712
+ "version_hash": self.version_hash,
713
+ "modules_hash": self.modules_hash,
714
+ "application_version": self.application_version,
715
+ "platform_version": self.platform_version,
716
+ "modules": [module.to_dict() for module in self.modules],
717
+ "computed_at": self.computed_at.isoformat(),
718
+ "is_active": self.is_active,
719
+ }
720
+
721
+
722
+ @dataclass
723
+ class GlobalVersionInfo:
724
+ """Global version registry for cross-environment sharing"""
725
+
726
+ id: int
727
+ version_hash: str
728
+ modules_hash: str
729
+ first_seen_at: datetime
730
+ last_used_at: datetime
731
+ reference_count: int
732
+ sample_modules: List[ModuleVersionInfo] = field(
733
+ default_factory=list
734
+ ) # Sample for debugging
735
+
736
+ def to_dict(self) -> Dict[str, Any]:
737
+ """Convert to dictionary for JSON serialization"""
738
+ return {
739
+ "id": self.id,
740
+ "version_hash": self.version_hash,
741
+ "modules_hash": self.modules_hash,
742
+ "first_seen_at": self.first_seen_at.isoformat(),
743
+ "last_used_at": self.last_used_at.isoformat(),
744
+ "reference_count": self.reference_count,
745
+ "sample_modules": [module.to_dict() for module in self.sample_modules],
746
+ }
747
+
748
+
749
+ @dataclass
750
+ class CacheStatistics:
751
+ """Enhanced cache statistics with version sharing metrics"""
752
+
753
+ total_environments: int
754
+ unique_versions: int
755
+ shared_versions: int
756
+ cache_hit_ratio: float
757
+ storage_efficiency: float # Ratio of shared vs duplicate storage
758
+ last_sync_times: Dict[str, datetime]
759
+ version_distribution: Dict[str, int] # version_hash -> environment_count
760
+
761
+ def to_dict(self) -> Dict[str, Any]:
762
+ """Convert to dictionary for JSON serialization"""
763
+ return {
764
+ "total_environments": self.total_environments,
765
+ "unique_versions": self.unique_versions,
766
+ "shared_versions": self.shared_versions,
767
+ "cache_hit_ratio": self.cache_hit_ratio,
768
+ "storage_efficiency": self.storage_efficiency,
769
+ "last_sync_times": {
770
+ k: v.isoformat() for k, v in self.last_sync_times.items()
771
+ },
772
+ "version_distribution": self.version_distribution,
773
+ }
774
+
775
+
776
+ @dataclass
777
+ class VersionDetectionResult:
778
+ """Result of version detection operation"""
779
+
780
+ success: bool
781
+ version_info: Optional[EnvironmentVersionInfo] = None
782
+ error_message: Optional[str] = None
783
+ detection_time_ms: float = 0.0
784
+ modules_count: int = 0
785
+ cache_hit: bool = False
786
+
787
+ def to_dict(self) -> Dict[str, Any]:
788
+ """Convert to dictionary for JSON serialization"""
789
+ return {
790
+ "success": self.success,
791
+ "version_info": self.version_info.to_dict() if self.version_info else None,
792
+ "error_message": self.error_message,
793
+ "detection_time_ms": self.detection_time_ms,
794
+ "modules_count": self.modules_count,
795
+ "cache_hit": self.cache_hit,
796
+ }
797
+
798
+
799
+ @dataclass
800
+ class SyncResultV2:
801
+ """Enhanced synchronization result for v2 with sharing metrics"""
802
+
803
+ sync_type: str # full|incremental|linked|skipped|failed
804
+ entities_synced: int = 0
805
+ actions_synced: int = 0
806
+ enumerations_synced: int = 0
807
+ labels_synced: int = 0
808
+ duration_ms: float = 0.0
809
+ success: bool = True
810
+ errors: List[str] = field(default_factory=list)
811
+ reason: Optional[str] = None
812
+ # V2 specific fields
813
+ global_version_id: Optional[int] = None
814
+ was_shared: bool = False
815
+ reference_count: int = 1
816
+ storage_saved_bytes: int = 0
817
+
818
+ def to_dict(self) -> Dict[str, Any]:
819
+ """Convert to dictionary for JSON serialization"""
820
+ return {
821
+ "sync_type": self.sync_type,
822
+ "entities_synced": self.entities_synced,
823
+ "actions_synced": self.actions_synced,
824
+ "enumerations_synced": self.enumerations_synced,
825
+ "labels_synced": self.labels_synced,
826
+ "duration_ms": self.duration_ms,
827
+ "success": self.success,
828
+ "errors": self.errors,
829
+ "reason": self.reason,
830
+ "global_version_id": self.global_version_id,
831
+ "was_shared": self.was_shared,
832
+ "reference_count": self.reference_count,
833
+ "storage_saved_bytes": self.storage_saved_bytes,
834
+ }
835
+
836
+
837
+ @dataclass
838
+ class SyncProgress:
839
+ """Sync progress tracking"""
840
+
841
+ global_version_id: int
842
+ strategy: SyncStrategy
843
+ phase: str
844
+ total_steps: int
845
+ completed_steps: int
846
+ current_operation: str
847
+ start_time: datetime
848
+ estimated_completion: Optional[datetime] = None
849
+ error: Optional[str] = None
850
+
851
+
852
+ @dataclass
853
+ class SyncResult:
854
+ """Sync operation result"""
855
+
856
+ success: bool
857
+ error: Optional[str]
858
+ duration_ms: int
859
+ entity_count: int
860
+ action_count: int
861
+ enumeration_count: int
862
+ label_count: int