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,181 @@
1
+ """Output formatting module for d365fo-client CLI."""
2
+
3
+ import csv
4
+ import io
5
+ import json
6
+ from typing import Any, Dict, List, Optional, Union
7
+
8
+ import yaml
9
+ from tabulate import tabulate
10
+
11
+
12
+ class OutputFormatter:
13
+ """Handles formatting of output data in various formats."""
14
+
15
+ SUPPORTED_FORMATS = ["json", "table", "csv", "yaml"]
16
+
17
+ def __init__(self, format_type: str = "table"):
18
+ """Initialize the output formatter.
19
+
20
+ Args:
21
+ format_type: Output format type (json, table, csv, yaml)
22
+ """
23
+ if format_type not in self.SUPPORTED_FORMATS:
24
+ raise ValueError(
25
+ f"Unsupported format: {format_type}. Supported: {self.SUPPORTED_FORMATS}"
26
+ )
27
+
28
+ self.format_type = format_type
29
+
30
+ def format_output(self, data: Any, headers: Optional[List[str]] = None) -> str:
31
+ """Format data according to the specified output format.
32
+
33
+ Args:
34
+ data: Data to format
35
+ headers: Optional headers for table format
36
+
37
+ Returns:
38
+ Formatted string output
39
+ """
40
+ if data is None:
41
+ return ""
42
+
43
+ if self.format_type == "json":
44
+ return self._format_json(data)
45
+ elif self.format_type == "table":
46
+ return self._format_table(data, headers)
47
+ elif self.format_type == "csv":
48
+ return self._format_csv(data, headers)
49
+ elif self.format_type == "yaml":
50
+ return self._format_yaml(data)
51
+ else:
52
+ # Fallback to JSON
53
+ return self._format_json(data)
54
+
55
+ def _format_json(self, data: Any) -> str:
56
+ """Format data as JSON."""
57
+ return json.dumps(data, indent=2, default=str, ensure_ascii=False)
58
+
59
+ def _format_yaml(self, data: Any) -> str:
60
+ """Format data as YAML."""
61
+ return yaml.dump(data, default_flow_style=False, allow_unicode=True, indent=2)
62
+
63
+ def _format_table(self, data: Any, headers: Optional[List[str]] = None) -> str:
64
+ """Format data as a table using tabulate."""
65
+ if not data:
66
+ return "No data to display."
67
+
68
+ # Handle different data types
69
+ if isinstance(data, dict):
70
+ # For single record or key-value pairs
71
+ if headers:
72
+ # If headers provided, try to extract those fields
73
+ rows = [[data.get(h, "") for h in headers]]
74
+ return tabulate(rows, headers=headers, tablefmt="grid")
75
+ else:
76
+ # Show as key-value pairs
77
+ rows = [[k, v] for k, v in data.items()]
78
+ return tabulate(rows, headers=["Property", "Value"], tablefmt="grid")
79
+
80
+ elif isinstance(data, list):
81
+ if not data:
82
+ return "No data to display."
83
+
84
+ # For list of records
85
+ if isinstance(data[0], dict):
86
+ # Extract headers from first item if not provided
87
+ if not headers:
88
+ headers = list(data[0].keys())
89
+
90
+ # Extract rows
91
+ rows = []
92
+ for item in data:
93
+ row = []
94
+ for header in headers:
95
+ value = item.get(header, "")
96
+ # Handle nested objects/lists by converting to string
97
+ if isinstance(value, (dict, list)):
98
+ value = str(value)
99
+ row.append(value)
100
+ rows.append(row)
101
+
102
+ return tabulate(rows, headers=headers, tablefmt="grid")
103
+ else:
104
+ # List of simple values
105
+ rows = [[item] for item in data]
106
+ return tabulate(rows, headers=["Value"], tablefmt="grid")
107
+
108
+ else:
109
+ # Single value
110
+ return str(data)
111
+
112
+ def _format_csv(self, data: Any, headers: Optional[List[str]] = None) -> str:
113
+ """Format data as CSV."""
114
+ if not data:
115
+ return ""
116
+
117
+ output = io.StringIO()
118
+ writer = csv.writer(output)
119
+
120
+ if isinstance(data, dict):
121
+ # Single record
122
+ if headers:
123
+ writer.writerow(headers)
124
+ writer.writerow([data.get(h, "") for h in headers])
125
+ else:
126
+ # Key-value pairs
127
+ writer.writerow(["Property", "Value"])
128
+ for k, v in data.items():
129
+ writer.writerow([k, v])
130
+
131
+ elif isinstance(data, list):
132
+ if not data:
133
+ return ""
134
+
135
+ if isinstance(data[0], dict):
136
+ # List of records
137
+ if not headers:
138
+ headers = list(data[0].keys())
139
+
140
+ writer.writerow(headers)
141
+ for item in data:
142
+ row = []
143
+ for header in headers:
144
+ value = item.get(header, "")
145
+ # Handle nested objects by converting to JSON string
146
+ if isinstance(value, (dict, list)):
147
+ value = json.dumps(value, default=str)
148
+ row.append(value)
149
+ writer.writerow(row)
150
+ else:
151
+ # List of simple values
152
+ writer.writerow(["Value"])
153
+ for item in data:
154
+ writer.writerow([item])
155
+
156
+ else:
157
+ # Single value
158
+ writer.writerow(["Value"])
159
+ writer.writerow([data])
160
+
161
+ return output.getvalue()
162
+
163
+
164
+ def format_success_message(message: str) -> str:
165
+ """Format a success message with checkmark."""
166
+ return f"✅ {message}"
167
+
168
+
169
+ def format_error_message(message: str) -> str:
170
+ """Format an error message with X mark."""
171
+ return f"❌ {message}"
172
+
173
+
174
+ def format_info_message(message: str) -> str:
175
+ """Format an info message with info icon."""
176
+ return f"ℹ️ {message}"
177
+
178
+
179
+ def format_warning_message(message: str) -> str:
180
+ """Format a warning message with warning icon."""
181
+ return f"⚠️ {message}"
@@ -0,0 +1,342 @@
1
+ """Profile management for d365fo-client.
2
+
3
+ Provides centralized profile management functionality that can be used by both CLI and MCP.
4
+ """
5
+
6
+ import logging
7
+ import os
8
+ from dataclasses import asdict
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ import yaml
13
+
14
+ from .config import ConfigManager
15
+ from .models import FOClientConfig
16
+ from .profiles import Profile
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Legacy alias for backward compatibility
21
+ EnvironmentProfile = Profile
22
+
23
+
24
+ class ProfileManager:
25
+ """Manages environment profiles for D365FO connections."""
26
+
27
+ def __init__(self, config_path: Optional[str] = None):
28
+ """Initialize profile manager.
29
+
30
+ Args:
31
+ config_path: Path to configuration file. If None, uses default.
32
+ """
33
+ # Use CLI ConfigManager as the underlying storage
34
+ self.config_manager = ConfigManager(config_path)
35
+
36
+ def list_profiles(self) -> Dict[str, Profile]:
37
+ """List all available profiles.
38
+
39
+ Returns:
40
+ Dictionary of profile name to Profile instances
41
+ """
42
+ try:
43
+ return self.config_manager.list_profiles()
44
+ except Exception as e:
45
+ logger.error(f"Error listing profiles: {e}")
46
+ return {}
47
+
48
+ def get_profile(self, profile_name: str) -> Optional[Profile]:
49
+ """Get a specific profile.
50
+
51
+ Args:
52
+ profile_name: Name of the profile to retrieve
53
+
54
+ Returns:
55
+ Profile instance or None if not found
56
+ """
57
+ try:
58
+ return self.config_manager.get_profile(profile_name)
59
+ except Exception as e:
60
+ logger.error(f"Error getting profile {profile_name}: {e}")
61
+ return None
62
+
63
+ def create_profile(
64
+ self,
65
+ name: str,
66
+ base_url: str,
67
+ auth_mode: str = "default",
68
+ client_id: Optional[str] = None,
69
+ client_secret: Optional[str] = None,
70
+ tenant_id: Optional[str] = None,
71
+ verify_ssl: bool = True,
72
+ timeout: int = 60,
73
+ use_label_cache: bool = True,
74
+ label_cache_expiry_minutes: int = 60,
75
+ use_cache_first: bool = True,
76
+ language: str = "en-US",
77
+ cache_dir: Optional[str] = None,
78
+ description: Optional[str] = None,
79
+ ) -> bool:
80
+ """Create a new profile.
81
+
82
+ Args:
83
+ name: Profile name
84
+ base_url: D365FO base URL
85
+ auth_mode: Authentication mode
86
+ client_id: Azure client ID (optional)
87
+ client_secret: Azure client secret (optional)
88
+ tenant_id: Azure tenant ID (optional)
89
+ verify_ssl: Whether to verify SSL certificates
90
+ timeout: Request timeout in seconds
91
+ use_label_cache: Whether to enable label caching
92
+ label_cache_expiry_minutes: Label cache expiry in minutes
93
+ language: Default language code
94
+ cache_dir: Cache directory path
95
+ description: Profile description (stored separately from CLI profile)
96
+
97
+ Returns:
98
+ True if created successfully
99
+ """
100
+ try:
101
+ # Check if profile already exists
102
+ if self.config_manager.get_profile(name):
103
+ logger.error(f"Profile already exists: {name}")
104
+ return False
105
+
106
+ # Create unified profile
107
+ profile = Profile(
108
+ name=name,
109
+ base_url=base_url,
110
+ auth_mode=auth_mode,
111
+ client_id=client_id,
112
+ client_secret=client_secret,
113
+ tenant_id=tenant_id,
114
+ verify_ssl=verify_ssl,
115
+ timeout=timeout,
116
+ use_label_cache=use_label_cache,
117
+ label_cache_expiry_minutes=label_cache_expiry_minutes,
118
+ use_cache_first=use_cache_first,
119
+ language=language,
120
+ cache_dir=cache_dir,
121
+ description=description,
122
+ output_format="table", # Default for CLI compatibility
123
+ )
124
+
125
+ self.config_manager.save_profile(profile)
126
+
127
+ logger.info(f"Created profile: {name}")
128
+ return True
129
+
130
+ except Exception as e:
131
+ logger.error(f"Error creating profile {name}: {e}")
132
+ return False
133
+
134
+ def update_profile(self, name: str, **kwargs) -> bool:
135
+ """Update an existing profile.
136
+
137
+ Args:
138
+ name: Profile name
139
+ **kwargs: Profile attributes to update
140
+
141
+ Returns:
142
+ True if updated successfully
143
+ """
144
+ try:
145
+ # Get existing profile
146
+ profile = self.get_profile(name)
147
+ if not profile:
148
+ logger.error(f"Profile not found: {name}")
149
+ return False
150
+
151
+ # Create updated profile with new attributes
152
+ from dataclasses import replace
153
+
154
+ updated_profile = replace(profile, **kwargs)
155
+
156
+ # Save updated profile
157
+ self.config_manager.save_profile(updated_profile)
158
+
159
+ logger.info(f"Updated profile: {name}")
160
+ return True
161
+
162
+ except Exception as e:
163
+ logger.error(f"Error updating profile {name}: {e}")
164
+ return False
165
+
166
+ def delete_profile(self, profile_name: str) -> bool:
167
+ """Delete a profile.
168
+
169
+ Args:
170
+ profile_name: Name of the profile to delete
171
+
172
+ Returns:
173
+ True if deleted successfully
174
+ """
175
+ try:
176
+ success = self.config_manager.delete_profile(profile_name)
177
+ if success:
178
+ logger.info(f"Deleted profile: {profile_name}")
179
+ else:
180
+ logger.error(f"Profile not found: {profile_name}")
181
+ return success
182
+ except Exception as e:
183
+ logger.error(f"Error deleting profile {profile_name}: {e}")
184
+ return False
185
+
186
+ def get_default_profile(self) -> Optional[Profile]:
187
+ """Get the default profile.
188
+
189
+ Returns:
190
+ Default Profile instance or None if not set
191
+ """
192
+ try:
193
+ return self.config_manager.get_default_profile()
194
+ except Exception as e:
195
+ logger.error(f"Error getting default profile: {e}")
196
+ return None
197
+
198
+ def set_default_profile(self, profile_name: str) -> bool:
199
+ """Set the default profile.
200
+
201
+ Args:
202
+ profile_name: Name of the profile to set as default
203
+
204
+ Returns:
205
+ True if set successfully
206
+ """
207
+ try:
208
+ success = self.config_manager.set_default_profile(profile_name)
209
+ if success:
210
+ logger.info(f"Set default profile: {profile_name}")
211
+ else:
212
+ logger.error(f"Profile not found: {profile_name}")
213
+ return success
214
+ except Exception as e:
215
+ logger.error(f"Error setting default profile {profile_name}: {e}")
216
+ return False
217
+
218
+ def profile_to_client_config(self, profile: Profile) -> FOClientConfig:
219
+ """Convert a Profile to FOClientConfig.
220
+
221
+ Args:
222
+ profile: Profile instance
223
+
224
+ Returns:
225
+ FOClientConfig instance
226
+ """
227
+ return profile.to_client_config()
228
+
229
+ def get_effective_profile(
230
+ self, profile_name: Optional[str] = None
231
+ ) -> Optional[Profile]:
232
+ """Get the effective profile to use.
233
+
234
+ Args:
235
+ profile_name: Specific profile name, or None to use default
236
+
237
+ Returns:
238
+ Profile instance or None if not found
239
+ """
240
+ if profile_name:
241
+ return self.get_profile(profile_name)
242
+ else:
243
+ return self.get_default_profile()
244
+
245
+ def validate_profile(self, profile: Profile) -> List[str]:
246
+ """Validate a profile configuration.
247
+
248
+ Args:
249
+ profile: Profile to validate
250
+
251
+ Returns:
252
+ List of validation error messages (empty if valid)
253
+ """
254
+ return profile.validate()
255
+
256
+ def get_profile_names(self) -> List[str]:
257
+ """Get list of all profile names.
258
+
259
+ Returns:
260
+ List of profile names
261
+ """
262
+ return list(self.list_profiles().keys())
263
+
264
+ def export_profiles(self, file_path: str) -> bool:
265
+ """Export all profiles to a file.
266
+
267
+ Args:
268
+ file_path: Path to export file
269
+
270
+ Returns:
271
+ True if exported successfully
272
+ """
273
+ try:
274
+ profiles = self.list_profiles()
275
+ export_data = {"version": "1.0", "profiles": {}}
276
+
277
+ for name, profile in profiles.items():
278
+ export_data["profiles"][name] = asdict(profile)
279
+
280
+ with open(file_path, "w", encoding="utf-8") as f:
281
+ yaml.dump(export_data, f, default_flow_style=False, allow_unicode=True)
282
+
283
+ logger.info(f"Exported {len(profiles)} profiles to {file_path}")
284
+ return True
285
+
286
+ except Exception as e:
287
+ logger.error(f"Error exporting profiles to {file_path}: {e}")
288
+ return False
289
+
290
+ def import_profiles(
291
+ self, file_path: str, overwrite: bool = False
292
+ ) -> Dict[str, bool]:
293
+ """Import profiles from a file.
294
+
295
+ Args:
296
+ file_path: Path to import file
297
+ overwrite: Whether to overwrite existing profiles
298
+
299
+ Returns:
300
+ Dictionary of profile name to import success status
301
+ """
302
+ results = {}
303
+
304
+ try:
305
+ with open(file_path, "r", encoding="utf-8") as f:
306
+ import_data = yaml.safe_load(f)
307
+
308
+ if not import_data or "profiles" not in import_data:
309
+ logger.error("Invalid import file format")
310
+ return results
311
+
312
+ for name, profile_data in import_data["profiles"].items():
313
+ try:
314
+ # Check if profile exists
315
+ if self.get_profile(name) and not overwrite:
316
+ logger.warning(f"Profile {name} already exists, skipping")
317
+ results[name] = False
318
+ continue
319
+
320
+ # If overwrite is enabled, delete existing profile first
321
+ if overwrite and self.get_profile(name):
322
+ self.delete_profile(name)
323
+
324
+ # Extract description if present
325
+ description = profile_data.pop("description", None)
326
+
327
+ # Create profile
328
+ success = self.create_profile(
329
+ description=description, **profile_data
330
+ )
331
+ results[name] = success
332
+
333
+ except Exception as e:
334
+ logger.error(f"Error importing profile {name}: {e}")
335
+ results[name] = False
336
+
337
+ logger.info(f"Imported profiles from {file_path}: {results}")
338
+ return results
339
+
340
+ except Exception as e:
341
+ logger.error(f"Error importing profiles from {file_path}: {e}")
342
+ return results
@@ -0,0 +1,178 @@
1
+ """Unified profile management for d365fo-client."""
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
6
+
7
+ if TYPE_CHECKING:
8
+ from .models import FOClientConfig
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ @dataclass
14
+ class Profile:
15
+ """Unified profile for CLI and MCP operations."""
16
+
17
+ # Core identification
18
+ name: str
19
+ description: Optional[str] = None
20
+
21
+ # Connection settings
22
+ base_url: str = ""
23
+ auth_mode: str = "default"
24
+ client_id: Optional[str] = None
25
+ client_secret: Optional[str] = None
26
+ tenant_id: Optional[str] = None
27
+ verify_ssl: bool = True
28
+ timeout: int = 60
29
+
30
+ # Cache settings
31
+ use_label_cache: bool = True
32
+ label_cache_expiry_minutes: int = 60
33
+ use_cache_first: bool = True
34
+ cache_dir: Optional[str] = None
35
+
36
+ # Localization
37
+ language: str = "en-US"
38
+
39
+ # CLI-specific settings (with defaults for MCP)
40
+ output_format: str = "table"
41
+
42
+ def to_client_config(self) -> "FOClientConfig":
43
+ """Convert profile to FOClientConfig."""
44
+ from .models import FOClientConfig
45
+
46
+ return FOClientConfig(
47
+ base_url=self.base_url,
48
+ client_id=self.client_id,
49
+ client_secret=self.client_secret,
50
+ tenant_id=self.tenant_id,
51
+ use_default_credentials=self.auth_mode == "default",
52
+ timeout=self.timeout,
53
+ verify_ssl=self.verify_ssl,
54
+ use_label_cache=self.use_label_cache,
55
+ label_cache_expiry_minutes=self.label_cache_expiry_minutes,
56
+ use_cache_first=self.use_cache_first,
57
+ metadata_cache_dir=self.cache_dir,
58
+ )
59
+
60
+ def validate(self) -> List[str]:
61
+ """Validate profile configuration."""
62
+ errors = []
63
+
64
+ if not self.base_url:
65
+ errors.append("Base URL is required")
66
+
67
+ if self.auth_mode == "client_credentials":
68
+ if not self.client_id:
69
+ errors.append("Client ID is required for client_credentials auth mode")
70
+ if not self.client_secret:
71
+ errors.append(
72
+ "Client secret is required for client_credentials auth mode"
73
+ )
74
+ if not self.tenant_id:
75
+ errors.append("Tenant ID is required for client_credentials auth mode")
76
+
77
+ if self.timeout <= 0:
78
+ errors.append("Timeout must be greater than 0")
79
+
80
+ if self.label_cache_expiry_minutes <= 0:
81
+ errors.append("Label cache expiry must be greater than 0")
82
+
83
+ return errors
84
+
85
+ @classmethod
86
+ def from_dict(cls, name: str, data: Dict[str, Any]) -> "Profile":
87
+ """Create Profile from dictionary data with migration support."""
88
+
89
+ # Handle parameter migration from legacy formats
90
+ migrated_data = cls._migrate_legacy_parameters(data.copy())
91
+
92
+ # Ensure name is set
93
+ migrated_data["name"] = name
94
+
95
+ # Add defaults for missing parameters
96
+ defaults = {
97
+ "description": None,
98
+ "base_url": "",
99
+ "auth_mode": "default",
100
+ "client_id": None,
101
+ "client_secret": None,
102
+ "tenant_id": None,
103
+ "verify_ssl": True,
104
+ "timeout": 60,
105
+ "use_label_cache": True,
106
+ "label_cache_expiry_minutes": 60,
107
+ "use_cache_first": True,
108
+ "cache_dir": None,
109
+ "language": "en-US",
110
+ "output_format": "table",
111
+ }
112
+
113
+ for key, default_value in defaults.items():
114
+ if key not in migrated_data:
115
+ migrated_data[key] = default_value
116
+
117
+ # Filter out any unknown parameters
118
+ valid_params = {
119
+ k: v for k, v in migrated_data.items() if k in cls.__dataclass_fields__
120
+ }
121
+
122
+ try:
123
+ return cls(**valid_params)
124
+ except Exception as e:
125
+ logger.error(f"Error creating profile {name}: {e}")
126
+ logger.error(f"Data: {valid_params}")
127
+ raise
128
+
129
+ @classmethod
130
+ def _migrate_legacy_parameters(cls, data: Dict[str, Any]) -> Dict[str, Any]:
131
+ """Migrate legacy parameter names to current format."""
132
+
133
+ # Map old parameter names to new ones
134
+ parameter_migrations = {
135
+ "label_cache": "use_label_cache",
136
+ "label_expiry": "label_cache_expiry_minutes",
137
+ }
138
+
139
+ for old_name, new_name in parameter_migrations.items():
140
+ if old_name in data and new_name not in data:
141
+ data[new_name] = data.pop(old_name)
142
+ logger.debug(f"Migrated parameter {old_name} -> {new_name}")
143
+
144
+ return data
145
+
146
+ def to_dict(self) -> Dict[str, Any]:
147
+ """Convert profile to dictionary for storage."""
148
+ from dataclasses import asdict
149
+
150
+ # Convert to dict and remove name (stored as key)
151
+ data = asdict(self)
152
+ data.pop("name", None)
153
+
154
+ return data
155
+
156
+ def clone(self, name: str, **overrides) -> "Profile":
157
+ """Create a copy of this profile with a new name and optional overrides."""
158
+ from dataclasses import replace
159
+
160
+ # Create a copy with new name
161
+ new_profile = replace(self, name=name)
162
+
163
+ # Apply any overrides
164
+ if overrides:
165
+ new_profile = replace(new_profile, **overrides)
166
+
167
+ return new_profile
168
+
169
+ def __str__(self) -> str:
170
+ """String representation of the profile."""
171
+ return f"Profile(name='{self.name}', base_url='{self.base_url}', auth_mode='{self.auth_mode}')"
172
+
173
+ def __repr__(self) -> str:
174
+ """Detailed string representation of the profile."""
175
+ return (
176
+ f"Profile(name='{self.name}', base_url='{self.base_url}', "
177
+ f"auth_mode='{self.auth_mode}', description='{self.description}')"
178
+ )