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.
Files changed (74) hide show
  1. slcli/__init__.py +1 -0
  2. slcli/__main__.py +23 -0
  3. slcli/_version.py +4 -0
  4. slcli/asset_click.py +1289 -0
  5. slcli/cli_formatters.py +218 -0
  6. slcli/cli_utils.py +504 -0
  7. slcli/comment_click.py +602 -0
  8. slcli/completion_click.py +418 -0
  9. slcli/config.py +81 -0
  10. slcli/config_click.py +498 -0
  11. slcli/dff_click.py +979 -0
  12. slcli/dff_decorators.py +24 -0
  13. slcli/example_click.py +404 -0
  14. slcli/example_loader.py +274 -0
  15. slcli/example_provisioner.py +2777 -0
  16. slcli/examples/README.md +134 -0
  17. slcli/examples/_schema/schema-v1.0.json +169 -0
  18. slcli/examples/demo-complete-workflow/README.md +323 -0
  19. slcli/examples/demo-complete-workflow/config.yaml +638 -0
  20. slcli/examples/demo-test-plans/README.md +132 -0
  21. slcli/examples/demo-test-plans/config.yaml +154 -0
  22. slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
  23. slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
  24. slcli/examples/exercise-7-1-test-plans/README.md +93 -0
  25. slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
  26. slcli/examples/spec-compliance-notebooks/README.md +140 -0
  27. slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
  28. slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
  29. slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
  30. slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
  31. slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
  32. slcli/feed_click.py +892 -0
  33. slcli/file_click.py +932 -0
  34. slcli/function_click.py +1400 -0
  35. slcli/function_templates.py +85 -0
  36. slcli/main.py +406 -0
  37. slcli/mcp_click.py +269 -0
  38. slcli/mcp_server.py +748 -0
  39. slcli/notebook_click.py +1770 -0
  40. slcli/platform.py +345 -0
  41. slcli/policy_click.py +679 -0
  42. slcli/policy_utils.py +411 -0
  43. slcli/profiles.py +411 -0
  44. slcli/response_handlers.py +359 -0
  45. slcli/routine_click.py +763 -0
  46. slcli/skill_click.py +253 -0
  47. slcli/skills/slcli/SKILL.md +713 -0
  48. slcli/skills/slcli/references/analysis-recipes.md +474 -0
  49. slcli/skills/slcli/references/filtering.md +236 -0
  50. slcli/skills/systemlink-webapp/SKILL.md +744 -0
  51. slcli/skills/systemlink-webapp/references/deployment.md +123 -0
  52. slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
  53. slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
  54. slcli/ssl_trust.py +93 -0
  55. slcli/system_click.py +2216 -0
  56. slcli/table_utils.py +124 -0
  57. slcli/tag_click.py +794 -0
  58. slcli/templates_click.py +599 -0
  59. slcli/testmonitor_click.py +1667 -0
  60. slcli/universal_handlers.py +305 -0
  61. slcli/user_click.py +1218 -0
  62. slcli/utils.py +832 -0
  63. slcli/web_editor.py +295 -0
  64. slcli/webapp_click.py +981 -0
  65. slcli/workflow_preview.py +287 -0
  66. slcli/workflows_click.py +988 -0
  67. slcli/workitem_click.py +2258 -0
  68. slcli/workspace_click.py +576 -0
  69. slcli/workspace_utils.py +206 -0
  70. systemlink_cli-1.3.1.dist-info/METADATA +20 -0
  71. systemlink_cli-1.3.1.dist-info/RECORD +74 -0
  72. systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
  73. systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
  74. 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