systemlink-cli 1.3.1__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.
- slcli/__init__.py +1 -0
- slcli/__main__.py +23 -0
- slcli/_version.py +4 -0
- slcli/asset_click.py +1289 -0
- slcli/cli_formatters.py +218 -0
- slcli/cli_utils.py +504 -0
- slcli/comment_click.py +602 -0
- slcli/completion_click.py +418 -0
- slcli/config.py +81 -0
- slcli/config_click.py +498 -0
- slcli/dff_click.py +979 -0
- slcli/dff_decorators.py +24 -0
- slcli/example_click.py +404 -0
- slcli/example_loader.py +274 -0
- slcli/example_provisioner.py +2777 -0
- slcli/examples/README.md +134 -0
- slcli/examples/_schema/schema-v1.0.json +169 -0
- slcli/examples/demo-complete-workflow/README.md +323 -0
- slcli/examples/demo-complete-workflow/config.yaml +638 -0
- slcli/examples/demo-test-plans/README.md +132 -0
- slcli/examples/demo-test-plans/config.yaml +154 -0
- slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
- slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
- slcli/examples/exercise-7-1-test-plans/README.md +93 -0
- slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
- slcli/examples/spec-compliance-notebooks/README.md +140 -0
- slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
- slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
- slcli/feed_click.py +892 -0
- slcli/file_click.py +932 -0
- slcli/function_click.py +1400 -0
- slcli/function_templates.py +85 -0
- slcli/main.py +406 -0
- slcli/mcp_click.py +269 -0
- slcli/mcp_server.py +748 -0
- slcli/notebook_click.py +1770 -0
- slcli/platform.py +345 -0
- slcli/policy_click.py +679 -0
- slcli/policy_utils.py +411 -0
- slcli/profiles.py +411 -0
- slcli/response_handlers.py +359 -0
- slcli/routine_click.py +763 -0
- slcli/skill_click.py +253 -0
- slcli/skills/slcli/SKILL.md +713 -0
- slcli/skills/slcli/references/analysis-recipes.md +474 -0
- slcli/skills/slcli/references/filtering.md +236 -0
- slcli/skills/systemlink-webapp/SKILL.md +744 -0
- slcli/skills/systemlink-webapp/references/deployment.md +123 -0
- slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
- slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
- slcli/ssl_trust.py +93 -0
- slcli/system_click.py +2216 -0
- slcli/table_utils.py +124 -0
- slcli/tag_click.py +794 -0
- slcli/templates_click.py +599 -0
- slcli/testmonitor_click.py +1667 -0
- slcli/universal_handlers.py +305 -0
- slcli/user_click.py +1218 -0
- slcli/utils.py +832 -0
- slcli/web_editor.py +295 -0
- slcli/webapp_click.py +981 -0
- slcli/workflow_preview.py +287 -0
- slcli/workflows_click.py +988 -0
- slcli/workitem_click.py +2258 -0
- slcli/workspace_click.py +576 -0
- slcli/workspace_utils.py +206 -0
- systemlink_cli-1.3.1.dist-info/METADATA +20 -0
- systemlink_cli-1.3.1.dist-info/RECORD +74 -0
- systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
- systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
- systemlink_cli-1.3.1.dist-info/licenses/LICENSE +21 -0
slcli/profiles.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""Profile management for slcli multi-environment configuration.
|
|
2
|
+
|
|
3
|
+
This module provides AWS CLI-style profile management, allowing users to
|
|
4
|
+
configure multiple SystemLink environments (dev, test, prod) and switch
|
|
5
|
+
between them easily.
|
|
6
|
+
|
|
7
|
+
Configuration is stored in ~/.config/slcli/config.json with the following structure:
|
|
8
|
+
{
|
|
9
|
+
"current-profile": "dev",
|
|
10
|
+
"profiles": {
|
|
11
|
+
"dev": {
|
|
12
|
+
"server": "https://dev-api.example.com",
|
|
13
|
+
"web-url": "https://dev.example.com",
|
|
14
|
+
"api-key": "xxx",
|
|
15
|
+
"platform": "SLE",
|
|
16
|
+
"workspace": "Development"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import stat
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Dict, List, Optional
|
|
28
|
+
|
|
29
|
+
import click
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Profile:
|
|
34
|
+
"""A SystemLink connection profile."""
|
|
35
|
+
|
|
36
|
+
name: str
|
|
37
|
+
server: str
|
|
38
|
+
api_key: str
|
|
39
|
+
web_url: Optional[str] = None
|
|
40
|
+
platform: Optional[str] = None
|
|
41
|
+
workspace: Optional[str] = None
|
|
42
|
+
readonly: bool = False
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
45
|
+
"""Convert profile to dictionary for serialization."""
|
|
46
|
+
result: Dict[str, Any] = {
|
|
47
|
+
"server": self.server,
|
|
48
|
+
"api-key": self.api_key,
|
|
49
|
+
}
|
|
50
|
+
if self.web_url:
|
|
51
|
+
result["web-url"] = self.web_url
|
|
52
|
+
if self.platform:
|
|
53
|
+
result["platform"] = self.platform
|
|
54
|
+
if self.workspace:
|
|
55
|
+
result["workspace"] = self.workspace
|
|
56
|
+
if self.readonly:
|
|
57
|
+
result["readonly"] = self.readonly
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_dict(cls, name: str, data: Dict[str, Any]) -> "Profile":
|
|
62
|
+
"""Create a Profile from a dictionary."""
|
|
63
|
+
return cls(
|
|
64
|
+
name=name,
|
|
65
|
+
server=data.get("server", ""),
|
|
66
|
+
api_key=data.get("api-key", ""),
|
|
67
|
+
web_url=data.get("web-url"),
|
|
68
|
+
platform=data.get("platform"),
|
|
69
|
+
workspace=data.get("workspace"),
|
|
70
|
+
readonly=data.get("readonly", False),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class ProfileConfig:
|
|
76
|
+
"""Configuration file manager for profiles."""
|
|
77
|
+
|
|
78
|
+
current_profile: Optional[str] = None
|
|
79
|
+
profiles: Dict[str, Profile] = field(default_factory=dict)
|
|
80
|
+
|
|
81
|
+
# Additional non-profile settings (e.g., function_service_url)
|
|
82
|
+
settings: Dict[str, Any] = field(default_factory=dict)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def get_config_path(cls) -> Path:
|
|
86
|
+
"""Get the path to the configuration file."""
|
|
87
|
+
# Support override via environment variable
|
|
88
|
+
if "SLCLI_CONFIG" in os.environ:
|
|
89
|
+
return Path(os.environ["SLCLI_CONFIG"])
|
|
90
|
+
|
|
91
|
+
# Use XDG_CONFIG_HOME if set, otherwise use ~/.config
|
|
92
|
+
if "XDG_CONFIG_HOME" in os.environ:
|
|
93
|
+
config_dir = Path(os.environ["XDG_CONFIG_HOME"]) / "slcli"
|
|
94
|
+
else:
|
|
95
|
+
config_dir = Path.home() / ".config" / "slcli"
|
|
96
|
+
|
|
97
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
return config_dir / "config.json"
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def load(cls) -> "ProfileConfig":
|
|
102
|
+
"""Load configuration from file."""
|
|
103
|
+
config_path = cls.get_config_path()
|
|
104
|
+
|
|
105
|
+
if not config_path.exists():
|
|
106
|
+
return cls()
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
110
|
+
data = json.load(f)
|
|
111
|
+
except (json.JSONDecodeError, OSError):
|
|
112
|
+
# If config file is corrupted or unreadable, return empty config
|
|
113
|
+
return cls()
|
|
114
|
+
|
|
115
|
+
# Parse profiles
|
|
116
|
+
profiles: Dict[str, Profile] = {}
|
|
117
|
+
profiles_data = data.get("profiles", {})
|
|
118
|
+
for name, profile_data in profiles_data.items():
|
|
119
|
+
profiles[name] = Profile.from_dict(name, profile_data)
|
|
120
|
+
|
|
121
|
+
# Extract settings (non-profile data)
|
|
122
|
+
settings: Dict[str, Any] = {}
|
|
123
|
+
for key, value in data.items():
|
|
124
|
+
if key not in ("current-profile", "profiles"):
|
|
125
|
+
settings[key] = value
|
|
126
|
+
|
|
127
|
+
return cls(
|
|
128
|
+
current_profile=data.get("current-profile"),
|
|
129
|
+
profiles=profiles,
|
|
130
|
+
settings=settings,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def save(self) -> None:
|
|
134
|
+
"""Save configuration to file with secure permissions."""
|
|
135
|
+
config_path = self.get_config_path()
|
|
136
|
+
|
|
137
|
+
data: Dict[str, Any] = {}
|
|
138
|
+
|
|
139
|
+
if self.current_profile:
|
|
140
|
+
data["current-profile"] = self.current_profile
|
|
141
|
+
|
|
142
|
+
if self.profiles:
|
|
143
|
+
data["profiles"] = {name: profile.to_dict() for name, profile in self.profiles.items()}
|
|
144
|
+
|
|
145
|
+
# Include additional settings
|
|
146
|
+
data.update(self.settings)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
with open(config_path, "w", encoding="utf-8") as f:
|
|
150
|
+
json.dump(data, f, indent=2)
|
|
151
|
+
|
|
152
|
+
# Set restrictive permissions (600 - owner read/write only)
|
|
153
|
+
# This is important because the file contains API keys
|
|
154
|
+
try:
|
|
155
|
+
config_path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
156
|
+
except OSError:
|
|
157
|
+
# On some systems (e.g., Windows), chmod may not work as expected
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
except OSError as e:
|
|
161
|
+
raise RuntimeError(f"Failed to save configuration: {e}")
|
|
162
|
+
|
|
163
|
+
def get_profile(self, name: str) -> Optional[Profile]:
|
|
164
|
+
"""Get a profile by name."""
|
|
165
|
+
return self.profiles.get(name)
|
|
166
|
+
|
|
167
|
+
def get_current_profile(self) -> Optional[Profile]:
|
|
168
|
+
"""Get the currently active profile."""
|
|
169
|
+
if not self.current_profile:
|
|
170
|
+
return None
|
|
171
|
+
return self.profiles.get(self.current_profile)
|
|
172
|
+
|
|
173
|
+
def set_current_profile(self, name: str) -> None:
|
|
174
|
+
"""Set the current profile."""
|
|
175
|
+
if name not in self.profiles:
|
|
176
|
+
raise ValueError(f"Profile '{name}' does not exist")
|
|
177
|
+
self.current_profile = name
|
|
178
|
+
|
|
179
|
+
def add_profile(self, profile: Profile, set_current: bool = False) -> None:
|
|
180
|
+
"""Add or update a profile.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
profile: The profile to add or update.
|
|
184
|
+
set_current: If True, set this profile as the current profile.
|
|
185
|
+
Note: If there is no current profile configured yet, the first
|
|
186
|
+
profile added will become the current profile even if
|
|
187
|
+
set_current is False. If a current profile already exists and
|
|
188
|
+
set_current is False, the current profile will not be changed.
|
|
189
|
+
"""
|
|
190
|
+
self.profiles[profile.name] = profile
|
|
191
|
+
if set_current or not self.current_profile:
|
|
192
|
+
self.current_profile = profile.name
|
|
193
|
+
|
|
194
|
+
def delete_profile(self, name: str) -> bool:
|
|
195
|
+
"""Delete a profile. Returns True if deleted, False if not found."""
|
|
196
|
+
if name not in self.profiles:
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
del self.profiles[name]
|
|
200
|
+
|
|
201
|
+
# If we deleted the current profile, clear it or set to another
|
|
202
|
+
if self.current_profile == name:
|
|
203
|
+
if self.profiles:
|
|
204
|
+
self.current_profile = next(iter(self.profiles.keys()))
|
|
205
|
+
else:
|
|
206
|
+
self.current_profile = None
|
|
207
|
+
|
|
208
|
+
return True
|
|
209
|
+
|
|
210
|
+
def list_profiles(self) -> List[Profile]:
|
|
211
|
+
"""List all profiles."""
|
|
212
|
+
return list(self.profiles.values())
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# Global state for profile override (set via --profile CLI option)
|
|
216
|
+
_profile_override: Optional[str] = None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def set_profile_override(profile_name: Optional[str]) -> None:
|
|
220
|
+
"""Set a profile override for the current command."""
|
|
221
|
+
global _profile_override
|
|
222
|
+
_profile_override = profile_name
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def get_profile_override() -> Optional[str]:
|
|
226
|
+
"""Get the current profile override."""
|
|
227
|
+
return _profile_override
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def get_active_profile() -> Optional[Profile]:
|
|
231
|
+
"""Get the currently active profile, considering overrides.
|
|
232
|
+
|
|
233
|
+
Priority order:
|
|
234
|
+
1. CLI --profile option (stored in _profile_override)
|
|
235
|
+
2. SLCLI_PROFILE environment variable
|
|
236
|
+
3. current-profile from config file
|
|
237
|
+
"""
|
|
238
|
+
config = ProfileConfig.load()
|
|
239
|
+
|
|
240
|
+
# Check for override from CLI option
|
|
241
|
+
override = get_profile_override()
|
|
242
|
+
if override:
|
|
243
|
+
profile = config.get_profile(override)
|
|
244
|
+
if not profile:
|
|
245
|
+
raise click.ClickException(f"Profile '{override}' not found")
|
|
246
|
+
return profile
|
|
247
|
+
|
|
248
|
+
# Check for environment variable override
|
|
249
|
+
env_profile = os.environ.get("SLCLI_PROFILE")
|
|
250
|
+
if env_profile:
|
|
251
|
+
profile = config.get_profile(env_profile)
|
|
252
|
+
if not profile:
|
|
253
|
+
raise click.ClickException(f"Profile '{env_profile}' not found (from SLCLI_PROFILE)")
|
|
254
|
+
return profile
|
|
255
|
+
|
|
256
|
+
# Use current profile from config
|
|
257
|
+
return config.get_current_profile()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def get_active_profile_name() -> Optional[str]:
|
|
261
|
+
"""Get the name of the currently active profile."""
|
|
262
|
+
profile = get_active_profile()
|
|
263
|
+
return profile.name if profile else None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def get_default_workspace() -> Optional[str]:
|
|
267
|
+
"""Get the default workspace from the active profile."""
|
|
268
|
+
profile = get_active_profile()
|
|
269
|
+
return profile.workspace if profile else None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def is_active_profile_readonly() -> bool:
|
|
273
|
+
"""Check if the currently active profile is in readonly mode."""
|
|
274
|
+
profile = get_active_profile()
|
|
275
|
+
return profile.readonly if profile else False
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def has_profiles_configured() -> bool:
|
|
279
|
+
"""Check if any profiles are configured."""
|
|
280
|
+
config = ProfileConfig.load()
|
|
281
|
+
return bool(config.profiles)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def migrate_from_keyring(
|
|
285
|
+
profile_name: str = "default", delete_keyring: bool = False
|
|
286
|
+
) -> Optional[Profile]:
|
|
287
|
+
"""Migrate credentials from keyring to config file.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
profile_name: Name for the migrated profile.
|
|
291
|
+
delete_keyring: If True, delete keyring entries after migration.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
The migrated Profile if successful, None if no credentials found.
|
|
295
|
+
|
|
296
|
+
Raises:
|
|
297
|
+
json.JSONDecodeError: If keyring config is invalid JSON.
|
|
298
|
+
"""
|
|
299
|
+
import keyring
|
|
300
|
+
|
|
301
|
+
api_url: Optional[str] = None
|
|
302
|
+
api_key: Optional[str] = None
|
|
303
|
+
web_url: Optional[str] = None
|
|
304
|
+
platform: Optional[str] = None
|
|
305
|
+
|
|
306
|
+
# Try combined config first
|
|
307
|
+
try:
|
|
308
|
+
combined = keyring.get_password("systemlink-cli", "SYSTEMLINK_CONFIG")
|
|
309
|
+
if combined:
|
|
310
|
+
data = json.loads(combined)
|
|
311
|
+
api_url = data.get("api_url")
|
|
312
|
+
api_key = data.get("api_key")
|
|
313
|
+
web_url = data.get("web_url")
|
|
314
|
+
platform = data.get("platform")
|
|
315
|
+
except (json.JSONDecodeError, Exception):
|
|
316
|
+
# Ignore keyring read errors or invalid JSON
|
|
317
|
+
pass
|
|
318
|
+
|
|
319
|
+
# Fall back to individual entries
|
|
320
|
+
if not api_url:
|
|
321
|
+
api_url = keyring.get_password("systemlink-cli", "SYSTEMLINK_API_URL")
|
|
322
|
+
if not api_key:
|
|
323
|
+
api_key = keyring.get_password("systemlink-cli", "SYSTEMLINK_API_KEY")
|
|
324
|
+
if not web_url:
|
|
325
|
+
web_url = keyring.get_password("systemlink-cli", "SYSTEMLINK_WEB_URL")
|
|
326
|
+
|
|
327
|
+
if not api_url or not api_key:
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
# Create profile
|
|
331
|
+
profile = Profile(
|
|
332
|
+
name=profile_name,
|
|
333
|
+
server=api_url,
|
|
334
|
+
api_key=api_key,
|
|
335
|
+
web_url=web_url,
|
|
336
|
+
platform=platform,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Load config and add profile
|
|
340
|
+
cfg = ProfileConfig.load()
|
|
341
|
+
cfg.add_profile(profile, set_current=True)
|
|
342
|
+
cfg.save()
|
|
343
|
+
|
|
344
|
+
# Optionally delete keyring entries
|
|
345
|
+
if delete_keyring:
|
|
346
|
+
try:
|
|
347
|
+
keyring.delete_password("systemlink-cli", "SYSTEMLINK_API_KEY")
|
|
348
|
+
except Exception:
|
|
349
|
+
pass
|
|
350
|
+
try:
|
|
351
|
+
keyring.delete_password("systemlink-cli", "SYSTEMLINK_API_URL")
|
|
352
|
+
except Exception:
|
|
353
|
+
pass
|
|
354
|
+
try:
|
|
355
|
+
keyring.delete_password("systemlink-cli", "SYSTEMLINK_WEB_URL")
|
|
356
|
+
except Exception:
|
|
357
|
+
pass
|
|
358
|
+
try:
|
|
359
|
+
keyring.delete_password("systemlink-cli", "SYSTEMLINK_CONFIG")
|
|
360
|
+
except Exception:
|
|
361
|
+
pass
|
|
362
|
+
|
|
363
|
+
return profile
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def has_keyring_credentials() -> bool:
|
|
367
|
+
"""Check if credentials exist in the system keyring.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
True if keyring credentials are found, False otherwise.
|
|
371
|
+
"""
|
|
372
|
+
import keyring
|
|
373
|
+
|
|
374
|
+
try:
|
|
375
|
+
keyring_config = keyring.get_password("systemlink-cli", "SYSTEMLINK_CONFIG")
|
|
376
|
+
keyring_url = keyring.get_password("systemlink-cli", "SYSTEMLINK_API_URL")
|
|
377
|
+
keyring_key = keyring.get_password("systemlink-cli", "SYSTEMLINK_API_KEY")
|
|
378
|
+
return bool(keyring_config or (keyring_url and keyring_key))
|
|
379
|
+
except Exception:
|
|
380
|
+
return False
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def check_config_file_permissions() -> Optional[str]:
|
|
384
|
+
"""Check if config file has appropriate permissions.
|
|
385
|
+
|
|
386
|
+
Returns a warning message if permissions are too open, None otherwise.
|
|
387
|
+
Note: On Windows, this check is skipped as Windows uses a different permission model.
|
|
388
|
+
"""
|
|
389
|
+
import platform
|
|
390
|
+
|
|
391
|
+
# Skip permission check on Windows - Windows uses ACLs, not Unix permissions
|
|
392
|
+
if platform.system() == "Windows":
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
config_path = ProfileConfig.get_config_path()
|
|
396
|
+
if not config_path.exists():
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
mode = config_path.stat().st_mode
|
|
401
|
+
# Check if group or others have any permissions
|
|
402
|
+
if mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
403
|
+
return (
|
|
404
|
+
f"Warning: Config file {config_path} has overly permissive permissions. "
|
|
405
|
+
"Consider running: chmod 600 " + str(config_path)
|
|
406
|
+
)
|
|
407
|
+
except OSError:
|
|
408
|
+
# Cannot read file permissions (transient OS error), skip warning
|
|
409
|
+
pass
|
|
410
|
+
|
|
411
|
+
return None
|