ntermqt 0.1.5__py3-none-any.whl → 0.1.7__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.
@@ -0,0 +1,418 @@
1
+ """
2
+ nterm Interactive REPL
3
+
4
+ Launch with: api.repl()
5
+
6
+ Provides a safe, policy-controlled interface to network devices.
7
+ Same interface used by both humans (IPython) and MCP tools.
8
+ """
9
+
10
+ from nterm.scripting.repl import NTermREPL, REPLPolicy
11
+ from .api import NTermAPI
12
+ from typing import Optional
13
+ import sys
14
+ import getpass
15
+
16
+
17
+ def start_repl(api: Optional[NTermAPI] = None, policy: Optional[REPLPolicy] = None):
18
+ """
19
+ Start interactive REPL in IPython or terminal.
20
+
21
+ Args:
22
+ api: NTermAPI instance (creates default if None)
23
+ policy: REPLPolicy (uses read_only default if None)
24
+
25
+ Examples:
26
+ # Default read-only mode
27
+ api.repl()
28
+
29
+ # Operations mode (allows config changes)
30
+ policy = REPLPolicy(mode="ops")
31
+ api.repl(policy=policy)
32
+
33
+ # Custom policy
34
+ policy = REPLPolicy(
35
+ mode="read_only",
36
+ deny_substrings=["reload", "wr"],
37
+ allow_prefixes=["show", "display"],
38
+ )
39
+ api.repl(policy=policy)
40
+ """
41
+ if api is None:
42
+ from nterm.scripting import api as default_api
43
+ api = default_api
44
+
45
+ repl = NTermREPL(api=api, policy=policy)
46
+
47
+ print()
48
+ print("=" * 60)
49
+ print("nterm REPL - Safe Network Automation Interface")
50
+ print("=" * 60)
51
+ print()
52
+ print(f"Policy: {repl.state.policy.mode}")
53
+ print(f"Output: {repl.state.output_mode} ({repl.state.output_format})")
54
+ print(f"Vault: {'unlocked' if api.vault_unlocked else 'locked'}")
55
+
56
+ # Check TextFSM database health
57
+ try:
58
+ db_info = api.db_info()
59
+ db_size = db_info.get('db_size', 0)
60
+ if not db_info.get('db_exists'):
61
+ print(f"\n⚠️ TextFSM database not found!")
62
+ print(f" Parsing will be unavailable. Use :dbinfo for details.")
63
+ elif db_size == 0:
64
+ print(f"\n⚠️ TextFSM database is empty (0 bytes)!")
65
+ print(f" Parsing will fail. Use :dbinfo for details.")
66
+ elif db_size < 100000:
67
+ print(f"\n⚠️ TextFSM database seems small ({db_info.get('db_size_mb', 0):.1f} MB)")
68
+ print(f" Expected ~0.3 MB. Use :dbinfo to check.")
69
+ except Exception:
70
+ # Don't crash startup if db_info fails
71
+ pass
72
+
73
+ print()
74
+ print("Type :help for commands, :exit to quit")
75
+ print()
76
+
77
+ # Interactive loop
78
+ try:
79
+ while True:
80
+ # Show prompt with mode indicator
81
+ if repl.state.connected_device:
82
+ mode_indicator = "📊" if repl.state.output_mode == "parsed" else "📄"
83
+ hint = f"[{repl.state.platform_hint}]" if repl.state.platform_hint else ""
84
+ prompt = f"{mode_indicator} {repl.state.connected_device}{hint}> "
85
+ else:
86
+ prompt = "nterm> "
87
+
88
+ try:
89
+ line = input(prompt)
90
+ except EOFError:
91
+ # Ctrl+D
92
+ break
93
+ except KeyboardInterrupt:
94
+ # Ctrl+C
95
+ print()
96
+ continue
97
+
98
+ # Handle command
99
+ result = repl.handle_line(line)
100
+
101
+ # Display result
102
+ if not result.get("ok"):
103
+ print(f"Error: {result.get('error')}")
104
+ continue
105
+
106
+ data = result.get("data", {})
107
+ cmd_type = data.get("type")
108
+
109
+ if cmd_type == "exit":
110
+ break
111
+ elif cmd_type == "unlock_prompt":
112
+ # Securely prompt for password
113
+ try:
114
+ password = getpass.getpass("Enter vault password: ")
115
+ unlock_result = repl.do_unlock(password)
116
+ if unlock_result.get("ok"):
117
+ unlock_data = unlock_result.get("data", {})
118
+ if unlock_data.get("vault_unlocked"):
119
+ print("Vault unlocked")
120
+ else:
121
+ print("Unlock failed - incorrect password")
122
+ else:
123
+ print(f"Error: {unlock_result.get('error')}")
124
+ except KeyboardInterrupt:
125
+ print("\nUnlock cancelled")
126
+ continue
127
+ elif cmd_type == "help":
128
+ print(data.get("text", ""))
129
+ elif cmd_type == "unlock":
130
+ status = "unlocked" if data.get("vault_unlocked") else "failed"
131
+ print(f"Vault: {status}")
132
+ elif cmd_type == "lock":
133
+ print("Vault locked")
134
+ elif cmd_type == "credentials":
135
+ creds = data.get("credentials", [])
136
+ if not creds:
137
+ print("No credentials found")
138
+ else:
139
+ print(f"\n{'Name':<20} {'Username':<20} {'Type':<15}")
140
+ print("-" * 55)
141
+ for cred in creds:
142
+ print(f"{cred['name']:<20} {cred.get('username', ''):<20} {cred.get('cred_type', 'ssh'):<15}")
143
+ print()
144
+ elif cmd_type == "devices":
145
+ devices = data.get("devices", [])
146
+ if not devices:
147
+ print("No devices found")
148
+ else:
149
+ print(f"\n{'Name':<20} {'Hostname':<20} {'Folder':<15}")
150
+ print("-" * 55)
151
+ for dev in devices:
152
+ print(f"{dev['name']:<20} {dev['hostname']:<20} {dev.get('folder', ''):<15}")
153
+ print()
154
+ elif cmd_type == "connect":
155
+ print(f"Connected to {data['device']} ({data['hostname']}:{data['port']})")
156
+ print(f"Platform: {data.get('platform', 'unknown')}")
157
+ print(f"Prompt: {data.get('prompt', '')}")
158
+ elif cmd_type == "disconnect":
159
+ print("Disconnected")
160
+ elif cmd_type == "policy":
161
+ print(f"Policy mode: {data.get('mode')}")
162
+ elif cmd_type == "mode":
163
+ mode = data.get('mode')
164
+ hint = data.get('platform_hint')
165
+ if mode:
166
+ print(f"Output mode: {mode}")
167
+ else:
168
+ print(f"Current mode: {mode}")
169
+ if hint:
170
+ print(f"Platform hint: {hint}")
171
+ elif cmd_type == "format":
172
+ fmt = data.get('format')
173
+ print(f"Output format: {fmt}")
174
+ elif cmd_type == "set_hint":
175
+ print(f"Platform hint set to: {data.get('platform_hint')}")
176
+ elif cmd_type == "clear_hint":
177
+ print("Platform hint cleared (using auto-detection)")
178
+ elif cmd_type == "debug":
179
+ status = "ON" if data.get("debug_mode") else "OFF"
180
+ print(f"Debug mode: {status}")
181
+ elif cmd_type == "dbinfo":
182
+ db_info = data.get("db_info", {})
183
+ print("\nTextFSM Database Info:")
184
+ print("=" * 60)
185
+ print(f"Engine Available: {db_info.get('engine_available', False)}")
186
+ print(f"Database Path: {db_info.get('db_path', 'unknown')}")
187
+ print(f"Database Exists: {db_info.get('db_exists', False)}")
188
+
189
+ if db_info.get('db_exists'):
190
+ db_size = db_info.get('db_size', 0)
191
+ db_size_mb = db_info.get('db_size_mb', 0.0)
192
+ print(f"Database Size: {db_size:,} bytes ({db_size_mb:.1f} MB)")
193
+ print(f"Absolute Path: {db_info.get('db_absolute_path', 'unknown')}")
194
+
195
+ # Health checks
196
+ if db_size == 0:
197
+ print("\n⚠️ WARNING: Database file is empty (0 bytes)!")
198
+ print(" Parsing will fail until you download templates.")
199
+ print(" Run: api.download_templates() or use the templates installer.")
200
+ elif db_size < 100000: # Less than 100KB
201
+ print(f"\n⚠️ WARNING: Database seems too small ({db_size_mb:.1f} MB)")
202
+ print(" Expected size is ~0.3 MB. May be corrupted or incomplete.")
203
+ else:
204
+ print("\n✓ Database appears healthy")
205
+ else:
206
+ print("\n❌ ERROR: Database file not found!")
207
+ print(" Run: api.download_templates() to create it.")
208
+
209
+ print()
210
+ elif cmd_type == "result":
211
+ result_data = data.get("result", {})
212
+
213
+ # Debug mode: show full result dict
214
+ if repl.state.debug_mode:
215
+ print("\n[DEBUG - Full Result Dict]")
216
+ print("-" * 60)
217
+ import json
218
+ # Don't print raw_output in debug to avoid clutter
219
+ debug_data = {k: v for k, v in result_data.items() if k != "raw_output"}
220
+ print(json.dumps(debug_data, indent=2))
221
+ print("-" * 60)
222
+
223
+ # Show parsed data if available
224
+ parsed = result_data.get("parsed_data")
225
+ parse_success = result_data.get("parse_success", False)
226
+ platform = result_data.get("platform", "")
227
+
228
+ # Display based on mode and format
229
+ if repl.state.output_mode == "parsed":
230
+ if parsed and parse_success:
231
+ print(f"\n[Parsed with {platform} - format: {repl.state.output_format}]")
232
+ print("-" * 60)
233
+ _display_parsed_result(parsed, repl.state.output_format)
234
+ print()
235
+ elif parse_success and not parsed:
236
+ # Parsing succeeded but returned empty/no data
237
+ print(f"\n[Parsed with {platform} - no structured data]")
238
+ raw = result_data.get("raw_output", "")
239
+ print(raw)
240
+ elif parsed is None and not parse_success:
241
+ # Parsing failed or wasn't attempted
242
+ print(f"\n[Parse failed - showing raw output]")
243
+ raw = result_data.get("raw_output", "")
244
+ print(raw)
245
+ else:
246
+ # Fallback
247
+ raw = result_data.get("raw_output", "")
248
+ print(raw)
249
+ else:
250
+ # Raw mode - just show output
251
+ raw = result_data.get("raw_output", "")
252
+ print(raw)
253
+
254
+ # Show timing
255
+ elapsed = result_data.get("elapsed_seconds", 0)
256
+ print(f"\n[{elapsed}s]")
257
+ elif cmd_type == "noop":
258
+ pass
259
+ else:
260
+ # Unknown type, show raw data
261
+ import json
262
+ print(json.dumps(data, indent=2))
263
+
264
+ finally:
265
+ # Clean up
266
+ if repl.state.session:
267
+ print("\nDisconnecting...")
268
+ repl._safe_disconnect()
269
+ print("\nREPL closed")
270
+
271
+
272
+ def _print_parsed_data(data, max_rows=20):
273
+ """Pretty print parsed data (list of dicts) in text format."""
274
+ if not data:
275
+ print("(empty)")
276
+ return
277
+
278
+ if not isinstance(data, list):
279
+ import json
280
+ print(json.dumps(data, indent=2))
281
+ return
282
+
283
+ # Get all unique keys
284
+ all_keys = set()
285
+ for row in data:
286
+ if isinstance(row, dict):
287
+ all_keys.update(row.keys())
288
+
289
+ keys = sorted(all_keys)
290
+
291
+ if not keys:
292
+ import json
293
+ print(json.dumps(data, indent=2))
294
+ return
295
+
296
+ # Calculate column widths
297
+ col_widths = {}
298
+ for key in keys:
299
+ col_widths[key] = len(key)
300
+
301
+ for row in data[:max_rows]:
302
+ if isinstance(row, dict):
303
+ for key in keys:
304
+ val = str(row.get(key, ""))
305
+ col_widths[key] = max(col_widths[key], len(val))
306
+
307
+ # Cap widths
308
+ for key in keys:
309
+ col_widths[key] = min(col_widths[key], 30)
310
+
311
+ # Print header
312
+ header = " | ".join(key[:col_widths[key]].ljust(col_widths[key]) for key in keys)
313
+ print(header)
314
+ print("-" * len(header))
315
+
316
+ # Print rows
317
+ shown = 0
318
+ for row in data:
319
+ if not isinstance(row, dict):
320
+ continue
321
+ if shown >= max_rows:
322
+ remaining = len(data) - shown
323
+ print(f"... ({remaining} more rows)")
324
+ break
325
+
326
+ values = []
327
+ for key in keys:
328
+ val = str(row.get(key, ""))
329
+ if len(val) > col_widths[key]:
330
+ val = val[:col_widths[key] - 3] + "..."
331
+ values.append(val.ljust(col_widths[key]))
332
+
333
+ print(" | ".join(values))
334
+ shown += 1
335
+
336
+
337
+ def _print_parsed_data_rich(data, max_rows=20):
338
+ """Pretty print parsed data using Rich library."""
339
+ try:
340
+ from rich.console import Console
341
+ from rich.table import Table
342
+ except ImportError:
343
+ print("⚠️ Rich library not available, falling back to text format")
344
+ _print_parsed_data(data, max_rows)
345
+ return
346
+
347
+ if not data:
348
+ print("(empty)")
349
+ return
350
+
351
+ if not isinstance(data, list):
352
+ import json
353
+ print(json.dumps(data, indent=2))
354
+ return
355
+
356
+ # Get all unique keys
357
+ all_keys = set()
358
+ for row in data:
359
+ if isinstance(row, dict):
360
+ all_keys.update(row.keys())
361
+
362
+ keys = sorted(all_keys)
363
+
364
+ if not keys:
365
+ import json
366
+ print(json.dumps(data, indent=2))
367
+ return
368
+
369
+ # Create rich table
370
+ console = Console()
371
+ table = Table(show_header=True, header_style="bold cyan")
372
+
373
+ # Add columns
374
+ for key in keys:
375
+ table.add_column(key, style="white", no_wrap=False, max_width=30)
376
+
377
+ # Add rows
378
+ shown = 0
379
+ for row in data:
380
+ if not isinstance(row, dict):
381
+ continue
382
+ if shown >= max_rows:
383
+ remaining = len(data) - shown
384
+ console.print(f"[yellow]... ({remaining} more rows)[/yellow]")
385
+ break
386
+
387
+ values = [str(row.get(key, "")) for key in keys]
388
+ table.add_row(*values)
389
+ shown += 1
390
+
391
+ console.print(table)
392
+
393
+
394
+ def _print_parsed_data_json(data):
395
+ """Print parsed data as JSON."""
396
+ import json
397
+ print(json.dumps(data, indent=2))
398
+
399
+
400
+ def _display_parsed_result(data, output_format, max_rows=20):
401
+ """Display parsed data in the specified format."""
402
+ if output_format == "json":
403
+ _print_parsed_data_json(data)
404
+ elif output_format == "rich":
405
+ _print_parsed_data_rich(data, max_rows)
406
+ else: # text or fallback
407
+ _print_parsed_data(data, max_rows)
408
+
409
+
410
+ # Convenience function to add to API
411
+ def add_repl_to_api(api_instance):
412
+ """Add repl() method to API instance."""
413
+
414
+ def repl(policy: Optional[REPLPolicy] = None):
415
+ """Start interactive REPL."""
416
+ start_repl(api=api_instance, policy=policy)
417
+
418
+ api_instance.repl = repl
@@ -20,6 +20,7 @@ logger = logging.getLogger(__name__)
20
20
  IPYTHON_STARTUP = '''
21
21
  from nterm.scripting import api
22
22
  print("\\n\\033[1;36mnterm API loaded.\\033[0m")
23
+ print(" api.repl() _ Start CLI/repl")
23
24
  print(" api.devices() - List saved devices")
24
25
  print(" api.search(query) - Search devices")
25
26
  print(" api.credentials() - List credentials (after api.unlock())")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ntermqt
3
- Version: 0.1.5
3
+ Version: 0.1.7
4
4
  Summary: Modern SSH terminal widget for PyQt6 with credential vault and jump host support
5
5
  Author: Scott Peterman
6
6
  License: GPL-3.0
@@ -29,6 +29,7 @@ Requires-Dist: click>=8.0.0
29
29
  Requires-Dist: ipython>=8.0.0
30
30
  Requires-Dist: requests>=2.10.0
31
31
  Requires-Dist: textfsm>=2.0.0
32
+ Requires-Dist: rich>=14.0.0
32
33
  Requires-Dist: pexpect>=4.8.0; sys_platform != "win32"
33
34
  Requires-Dist: pywinpty>=2.0.0; sys_platform == "win32"
34
35
  Provides-Extra: keyring
@@ -110,6 +111,8 @@ nterm includes a built-in development console accessible via **Dev → IPython**
110
111
 
111
112
  ![IPython Console](https://raw.githubusercontent.com/scottpeterman/nterm/main/screenshots/ipython.png)
112
113
 
114
+ ![IPython Console](https://raw.githubusercontent.com/scottpeterman/nterm/main/screenshots/repl.png)
115
+
113
116
  The IPython console runs in the same Python environment as nterm, with the scripting API pre-loaded. Query your device inventory, inspect credentials, and prototype automation workflows without leaving the app.
114
117
 
115
118
  ```python
@@ -18,15 +18,17 @@ nterm/parser/api_help_dialog.py,sha256=qcmgNKjge8xwVNZeKZFu47Zn0SxZjyzE7cv9h91XG
18
18
  nterm/parser/ntc_download_dialog.py,sha256=TGaMCxKBTIOhGNUoLEJLnD0uAnwYWdbHdb9PRZEY604,14151
19
19
  nterm/parser/tfsm_engine.py,sha256=6p4wrNa9tQRuCmWgsR4E3rZTprpLmii5PNjoGpCQBCw,7954
20
20
  nterm/parser/tfsm_fire.py,sha256=AHbN6p4HlgcYDjLWb67CF9YfMSTk-3aetMswmEZyRVc,9222
21
- nterm/parser/tfsm_fire_tester.py,sha256=chIoZMrvjAmtayuqdRC9FEHKihLKV4rSYrOVO2QdnQQ,85804
22
- nterm/scripting/__init__.py,sha256=4WvwvJfJNMwXW6jas8wFreIzKBgjvAhMQnR2cnA_mEE,967
23
- nterm/scripting/api.py,sha256=WEx8jNACyM3rdOC3RJJelMK-leBIUhQxEK-Gzp3sroE,46609
21
+ nterm/parser/tfsm_fire_tester.py,sha256=h2CAqTS6ZNHMUr4di2DBRHAWbBGiUTliOvm5jVG4ltI,79146
22
+ nterm/scripting/__init__.py,sha256=vxbODaXR0IPneja3BuDHmjsHzQg03tFWtHO4Rc6vCTk,1099
23
+ nterm/scripting/api.py,sha256=9Gnxyscu3ZYNagYWGn_-5zX6O0-j8UJ_gM6PEbdHuEA,48540
24
24
  nterm/scripting/cli.py,sha256=W2DK4ZnuutaArye_to7CBchg0ogClURxVbGsMdnj1y0,9187
25
+ nterm/scripting/repl.py,sha256=VebSJu6dML7Ef0J4wLL8szdPESvsS2G8T5moyuF7nnU,14335
26
+ nterm/scripting/repl_interactive.py,sha256=adpwRsbSfALS_0bwZPFayKUCyQefgvnO5uZwIWqKNFY,14880
25
27
  nterm/session/__init__.py,sha256=FkgHF1WPz78JBOWHSC7LLynG2NqoR6aanNTRlEzsO6I,1612
26
28
  nterm/session/askpass_ssh.py,sha256=U-frmLBIXwE2L5ZCEtai91G1dVRSWKLCtxn88t_PqGs,14083
27
29
  nterm/session/base.py,sha256=NNFt2uy-rTkwigrHcdnfREk_QZDxNe0CoP16C-7oIWs,2475
28
30
  nterm/session/interactive_ssh.py,sha256=qBhVGFdkx4hRyEzx0ZdBZZeiuwCav6BR4UtKqPnCssM,17846
29
- nterm/session/local_terminal.py,sha256=sG2lFAOpItMiT93dYCi05nrGRS-MB52XG4J-iZbcoLM,7066
31
+ nterm/session/local_terminal.py,sha256=CkxkROOuw0TfY8MtCq9y9CaXm7ds76YbV-7T_A_EOT8,7113
30
32
  nterm/session/pty_transport.py,sha256=QwSFqKKuJhgcLWzv1CUKf3aCGDGbbkmmGwIB1L1A2PU,17176
31
33
  nterm/session/ssh.py,sha256=sGOxjBa9FX6GjVwkmfiKsupoLVsrPVk-LSREjlNmAdE,20942
32
34
  nterm/terminal/__init__.py,sha256=uFnG366Z166pK-ijT1dZanVSSFVZCiMGeNKXvss_sDg,184
@@ -59,8 +61,8 @@ nterm/vault/manager_ui.py,sha256=qle-W40j6L_pOR0AaOCeyU8myizFTRkISNrloCn0H_Y,345
59
61
  nterm/vault/profile.py,sha256=qM9TJf68RKdjtxo-sJehO7wS4iTi2G26BKbmlmHLA5M,6246
60
62
  nterm/vault/resolver.py,sha256=GWB2YR9H1MH98RGQBKvitIsjWT_-wSMLuddZNz4wbns,7800
61
63
  nterm/vault/store.py,sha256=_0Lfe0WKjm3uSAtxgn9qAPlpBOLCuq9SVgzqsE_qaGQ,21199
62
- ntermqt-0.1.5.dist-info/METADATA,sha256=CRqoeB5fr5f9j9WouRuQcpx4gjME8CZs9r_PHcEDmho,13495
63
- ntermqt-0.1.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
64
- ntermqt-0.1.5.dist-info/entry_points.txt,sha256=Gunr-_3w-aSpfqoMuGKM2PJSCRo9hZ7K1BksUtp1yd8,130
65
- ntermqt-0.1.5.dist-info/top_level.txt,sha256=bZdnNLTHNRNqo9jsOQGUWF7h5st0xW_thH0n2QOxWUo,6
66
- ntermqt-0.1.5.dist-info/RECORD,,
64
+ ntermqt-0.1.7.dist-info/METADATA,sha256=Q1Dz7d9n2AEzMW048T195_Zp062eYKSicxtqH1e1n9o,13624
65
+ ntermqt-0.1.7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
66
+ ntermqt-0.1.7.dist-info/entry_points.txt,sha256=Gunr-_3w-aSpfqoMuGKM2PJSCRo9hZ7K1BksUtp1yd8,130
67
+ ntermqt-0.1.7.dist-info/top_level.txt,sha256=bZdnNLTHNRNqo9jsOQGUWF7h5st0xW_thH0n2QOxWUo,6
68
+ ntermqt-0.1.7.dist-info/RECORD,,