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.
- d365fo_client/__init__.py +305 -0
- d365fo_client/auth.py +93 -0
- d365fo_client/cli.py +700 -0
- d365fo_client/client.py +1454 -0
- d365fo_client/config.py +304 -0
- d365fo_client/crud.py +200 -0
- d365fo_client/exceptions.py +49 -0
- d365fo_client/labels.py +528 -0
- d365fo_client/main.py +502 -0
- d365fo_client/mcp/__init__.py +16 -0
- d365fo_client/mcp/client_manager.py +276 -0
- d365fo_client/mcp/main.py +98 -0
- d365fo_client/mcp/models.py +371 -0
- d365fo_client/mcp/prompts/__init__.py +43 -0
- d365fo_client/mcp/prompts/action_execution.py +480 -0
- d365fo_client/mcp/prompts/sequence_analysis.py +349 -0
- d365fo_client/mcp/resources/__init__.py +15 -0
- d365fo_client/mcp/resources/database_handler.py +555 -0
- d365fo_client/mcp/resources/entity_handler.py +176 -0
- d365fo_client/mcp/resources/environment_handler.py +132 -0
- d365fo_client/mcp/resources/metadata_handler.py +283 -0
- d365fo_client/mcp/resources/query_handler.py +135 -0
- d365fo_client/mcp/server.py +432 -0
- d365fo_client/mcp/tools/__init__.py +17 -0
- d365fo_client/mcp/tools/connection_tools.py +175 -0
- d365fo_client/mcp/tools/crud_tools.py +579 -0
- d365fo_client/mcp/tools/database_tools.py +813 -0
- d365fo_client/mcp/tools/label_tools.py +189 -0
- d365fo_client/mcp/tools/metadata_tools.py +766 -0
- d365fo_client/mcp/tools/profile_tools.py +706 -0
- d365fo_client/metadata_api.py +793 -0
- d365fo_client/metadata_v2/__init__.py +59 -0
- d365fo_client/metadata_v2/cache_v2.py +1372 -0
- d365fo_client/metadata_v2/database_v2.py +585 -0
- d365fo_client/metadata_v2/global_version_manager.py +573 -0
- d365fo_client/metadata_v2/search_engine_v2.py +423 -0
- d365fo_client/metadata_v2/sync_manager_v2.py +819 -0
- d365fo_client/metadata_v2/version_detector.py +439 -0
- d365fo_client/models.py +862 -0
- d365fo_client/output.py +181 -0
- d365fo_client/profile_manager.py +342 -0
- d365fo_client/profiles.py +178 -0
- d365fo_client/query.py +162 -0
- d365fo_client/session.py +60 -0
- d365fo_client/utils.py +196 -0
- d365fo_client-0.1.0.dist-info/METADATA +1084 -0
- d365fo_client-0.1.0.dist-info/RECORD +51 -0
- d365fo_client-0.1.0.dist-info/WHEEL +5 -0
- d365fo_client-0.1.0.dist-info/entry_points.txt +3 -0
- d365fo_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- d365fo_client-0.1.0.dist-info/top_level.txt +1 -0
d365fo_client/output.py
ADDED
@@ -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
|
+
)
|