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/web_editor.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Web editor utilities for custom fields configuration editing."""
|
|
2
|
+
|
|
3
|
+
import http.server
|
|
4
|
+
import json
|
|
5
|
+
import secrets
|
|
6
|
+
import socketserver
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import webbrowser
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional, Any
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
from .utils import ExitCodes, get_base_url, get_headers, get_ssl_verify
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DFFWebEditor:
|
|
21
|
+
"""Web-based editor for custom fields configurations."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, port: int = 8080):
|
|
24
|
+
"""Initialize the DFF web editor.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
port: Port number for the HTTP server
|
|
28
|
+
"""
|
|
29
|
+
self.port = port
|
|
30
|
+
self._editor_dir = self._resolve_editor_directory()
|
|
31
|
+
|
|
32
|
+
def launch(self, file: Optional[str] = None, open_browser: bool = True) -> None:
|
|
33
|
+
"""Launch the web editor with optional file loading.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
file: Optional JSON file to load initially
|
|
37
|
+
open_browser: Whether to automatically open browser
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
import tempfile
|
|
41
|
+
|
|
42
|
+
# Create a per-session temp directory for runtime files (config, uploaded files)
|
|
43
|
+
# Keep it alive for the server lifetime
|
|
44
|
+
self._temp_dir = tempfile.TemporaryDirectory(prefix="slcli-dff-")
|
|
45
|
+
self._temp_path = Path(self._temp_dir.name)
|
|
46
|
+
|
|
47
|
+
# Generate per-session secret for proxy auth
|
|
48
|
+
self._secret = secrets.token_urlsafe(24)
|
|
49
|
+
self._write_editor_config(file)
|
|
50
|
+
self._start_server(open_browser)
|
|
51
|
+
except Exception as exc:
|
|
52
|
+
click.echo(f"✗ Error starting editor: {exc}", err=True)
|
|
53
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
54
|
+
finally:
|
|
55
|
+
# Clean up temp directory on exit
|
|
56
|
+
if hasattr(self, "_temp_dir"):
|
|
57
|
+
self._temp_dir.cleanup()
|
|
58
|
+
|
|
59
|
+
def _resolve_editor_directory(self) -> Path:
|
|
60
|
+
"""Resolve the editor directory from the install location.
|
|
61
|
+
|
|
62
|
+
Priority:
|
|
63
|
+
1) PyInstaller MEIPASS (onefile extraction dir)
|
|
64
|
+
2) Frozen onedir next to the executable
|
|
65
|
+
3) Site-packages root containing dff-editor (pip/Poetry install)
|
|
66
|
+
4) Source tree relative to this file (development)
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Path to the dff-editor directory
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
FileNotFoundError: If dff-editor directory cannot be found
|
|
73
|
+
"""
|
|
74
|
+
candidates = []
|
|
75
|
+
|
|
76
|
+
# PyInstaller onefile mode
|
|
77
|
+
meipass = getattr(sys, "_MEIPASS", None)
|
|
78
|
+
if meipass:
|
|
79
|
+
candidates.append(Path(meipass) / "dff-editor")
|
|
80
|
+
|
|
81
|
+
# Frozen onedir layout
|
|
82
|
+
if getattr(sys, "frozen", False):
|
|
83
|
+
candidates.append(Path(sys.executable).resolve().parent / "dff-editor")
|
|
84
|
+
|
|
85
|
+
# pip/Poetry install: dff-editor alongside slcli package
|
|
86
|
+
site_packages_dir = Path(__file__).resolve().parent.parent
|
|
87
|
+
candidates.append(site_packages_dir / "dff-editor")
|
|
88
|
+
|
|
89
|
+
# Development/source tree
|
|
90
|
+
candidates.append(Path(__file__).resolve().parent.parent / "dff-editor")
|
|
91
|
+
|
|
92
|
+
for candidate in candidates:
|
|
93
|
+
if candidate.exists() and (candidate / "index.html").exists():
|
|
94
|
+
return candidate
|
|
95
|
+
|
|
96
|
+
# No editor directory found
|
|
97
|
+
raise FileNotFoundError(
|
|
98
|
+
"DFF editor assets not found. Please ensure the installation is complete. "
|
|
99
|
+
"Try reinstalling: pip install --force-reinstall slcli"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _write_editor_config(self, file: Optional[str]) -> None:
|
|
103
|
+
"""Write the editor configuration consumed by the frontend.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
file: Optional initial file to load
|
|
107
|
+
"""
|
|
108
|
+
config: dict[str, Any] = {
|
|
109
|
+
"serverUrl": get_base_url().rstrip("/"),
|
|
110
|
+
"secret": getattr(self, "_secret", None),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# If a file was provided, copy it to the temp directory for the editor to load
|
|
114
|
+
if file:
|
|
115
|
+
import shutil
|
|
116
|
+
|
|
117
|
+
source_path = Path(file)
|
|
118
|
+
if source_path.exists():
|
|
119
|
+
dest_path = self._temp_path / "config.json"
|
|
120
|
+
shutil.copy(source_path, dest_path)
|
|
121
|
+
config["configFile"] = "config.json"
|
|
122
|
+
|
|
123
|
+
config_path = self._temp_path / "slcli-config.json"
|
|
124
|
+
config_path.write_text(json.dumps(config, indent=2))
|
|
125
|
+
|
|
126
|
+
def _start_server(self, open_browser: bool) -> None:
|
|
127
|
+
"""Start the HTTP server and optionally open browser.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
open_browser: Whether to automatically open browser
|
|
131
|
+
"""
|
|
132
|
+
editor_dir = self._editor_dir # Capture for closure
|
|
133
|
+
temp_path = self._temp_path # Capture for closure
|
|
134
|
+
api_base = get_base_url().rstrip("/")
|
|
135
|
+
default_headers = get_headers()
|
|
136
|
+
ssl_verify = get_ssl_verify()
|
|
137
|
+
secret = self._secret
|
|
138
|
+
|
|
139
|
+
class EditorTCPServer(socketserver.TCPServer):
|
|
140
|
+
allow_reuse_address = True
|
|
141
|
+
|
|
142
|
+
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
143
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
144
|
+
super().__init__(*args, directory=str(editor_dir), **kwargs)
|
|
145
|
+
|
|
146
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
147
|
+
# Suppress server logs
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
def _proxy_request(self, method: str) -> bool:
|
|
151
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
152
|
+
|
|
153
|
+
# Serve slcli-config.json from temp directory
|
|
154
|
+
if parsed.path == "/slcli-config.json" and method == "GET":
|
|
155
|
+
config_file = temp_path / "slcli-config.json"
|
|
156
|
+
if config_file.exists():
|
|
157
|
+
self.send_response(200)
|
|
158
|
+
self.send_header("Content-Type", "application/json")
|
|
159
|
+
self.end_headers()
|
|
160
|
+
self.wfile.write(config_file.read_bytes())
|
|
161
|
+
return True
|
|
162
|
+
else:
|
|
163
|
+
self.send_error(404, "Config not found")
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
# Serve config.json (the DFF configuration) from temp directory
|
|
167
|
+
if parsed.path == "/config.json" and method == "GET":
|
|
168
|
+
config_file = temp_path / "config.json"
|
|
169
|
+
if config_file.exists():
|
|
170
|
+
self.send_response(200)
|
|
171
|
+
self.send_header("Content-Type", "application/json")
|
|
172
|
+
self.end_headers()
|
|
173
|
+
self.wfile.write(config_file.read_bytes())
|
|
174
|
+
return True
|
|
175
|
+
else:
|
|
176
|
+
self.send_error(404, "Config file not found")
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
# Handle API proxying
|
|
180
|
+
path_map = {
|
|
181
|
+
"/api/dff/configurations": "/nidynamicformfields/v1/configurations",
|
|
182
|
+
"/api/dff/update-configurations": "/nidynamicformfields/v1/update-configurations",
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if parsed.path in path_map:
|
|
186
|
+
target_path = path_map[parsed.path]
|
|
187
|
+
elif parsed.path.startswith("/nidynamicformfields/v1/"):
|
|
188
|
+
target_path = parsed.path
|
|
189
|
+
elif parsed.path.startswith("/niuser/v1/workspaces"):
|
|
190
|
+
target_path = parsed.path
|
|
191
|
+
else:
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
# Require per-session secret on all proxied routes
|
|
195
|
+
req_secret = self.headers.get("X-Editor-Secret")
|
|
196
|
+
if not secret or req_secret != secret:
|
|
197
|
+
self.send_error(403, "Forbidden: Missing or invalid editor secret")
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
target_url = f"{api_base}{target_path}"
|
|
201
|
+
if parsed.query:
|
|
202
|
+
target_url = f"{target_url}?{parsed.query}"
|
|
203
|
+
|
|
204
|
+
headers = dict(default_headers)
|
|
205
|
+
data = None
|
|
206
|
+
|
|
207
|
+
if method == "POST":
|
|
208
|
+
content_length = int(self.headers.get("Content-Length", "0"))
|
|
209
|
+
data = self.rfile.read(content_length) if content_length > 0 else b""
|
|
210
|
+
headers["Content-Type"] = self.headers.get("Content-Type", "application/json")
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
resp = requests.request(
|
|
214
|
+
method=method,
|
|
215
|
+
url=target_url,
|
|
216
|
+
headers=headers,
|
|
217
|
+
data=data,
|
|
218
|
+
verify=ssl_verify,
|
|
219
|
+
)
|
|
220
|
+
except requests.RequestException as exc: # pragma: no cover
|
|
221
|
+
self.send_error(502, f"Proxy error: {exc}")
|
|
222
|
+
return True
|
|
223
|
+
|
|
224
|
+
self.send_response(resp.status_code)
|
|
225
|
+
content_type = resp.headers.get("Content-Type")
|
|
226
|
+
if content_type:
|
|
227
|
+
self.send_header("Content-Type", content_type)
|
|
228
|
+
self.end_headers()
|
|
229
|
+
self.wfile.write(resp.content)
|
|
230
|
+
return True
|
|
231
|
+
|
|
232
|
+
def do_GET(self) -> None: # noqa: N802
|
|
233
|
+
if self._proxy_request("GET"):
|
|
234
|
+
return
|
|
235
|
+
super().do_GET()
|
|
236
|
+
|
|
237
|
+
def do_POST(self) -> None: # noqa: N802
|
|
238
|
+
if self._proxy_request("POST"):
|
|
239
|
+
return
|
|
240
|
+
# SimpleHTTPRequestHandler lacks POST; return 405 when not proxied.
|
|
241
|
+
self.send_error(405, "Method Not Allowed")
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
with EditorTCPServer(("127.0.0.1", self.port), Handler) as httpd:
|
|
245
|
+
server_url = f"http://127.0.0.1:{self.port}"
|
|
246
|
+
|
|
247
|
+
# Start server in background thread
|
|
248
|
+
server_thread = threading.Thread(target=httpd.serve_forever)
|
|
249
|
+
server_thread.daemon = True
|
|
250
|
+
server_thread.start()
|
|
251
|
+
|
|
252
|
+
click.echo(f"✓ Starting Custom Fields editor at {server_url}")
|
|
253
|
+
click.echo(f"✓ Loading editor from: {editor_dir}")
|
|
254
|
+
|
|
255
|
+
if open_browser:
|
|
256
|
+
click.echo("✓ Opening in your default browser...")
|
|
257
|
+
webbrowser.open(server_url)
|
|
258
|
+
|
|
259
|
+
click.echo("\nPress Ctrl+C to stop the editor server")
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
# Keep the server running
|
|
263
|
+
while True:
|
|
264
|
+
threading.Event().wait(1)
|
|
265
|
+
except KeyboardInterrupt:
|
|
266
|
+
click.echo("\n✓ Editor server stopped")
|
|
267
|
+
httpd.shutdown()
|
|
268
|
+
httpd.server_close()
|
|
269
|
+
server_thread.join(timeout=2)
|
|
270
|
+
|
|
271
|
+
except OSError as e:
|
|
272
|
+
if "Address already in use" in str(e):
|
|
273
|
+
click.echo(
|
|
274
|
+
f"✗ Port {self.port} is already in use. Try a different port with --port",
|
|
275
|
+
err=True,
|
|
276
|
+
)
|
|
277
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
278
|
+
else:
|
|
279
|
+
raise
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def launch_dff_editor(
|
|
283
|
+
file: Optional[str] = None,
|
|
284
|
+
port: int = 8080,
|
|
285
|
+
open_browser: bool = True,
|
|
286
|
+
) -> None:
|
|
287
|
+
"""Launch the DFF web editor with specified configuration.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
file: Optional JSON file to edit
|
|
291
|
+
port: Port for local HTTP server
|
|
292
|
+
open_browser: Whether to auto-open browser
|
|
293
|
+
"""
|
|
294
|
+
editor = DFFWebEditor(port=port)
|
|
295
|
+
editor.launch(file=file, open_browser=open_browser)
|