ntermqt 0.1.1__py3-none-any.whl → 0.1.3__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.
nterm/scripting/api.py ADDED
@@ -0,0 +1,447 @@
1
+ """
2
+ nterm/scripting/api.py
3
+
4
+ Scripting API for nterm - usable from IPython, CLI, or MCP tools.
5
+ """
6
+
7
+ from __future__ import annotations
8
+ import fnmatch
9
+ from typing import Optional, List, Dict, Any
10
+ from dataclasses import dataclass, asdict
11
+
12
+ from ..manager.models import SessionStore, SavedSession, SessionFolder
13
+ from ..vault.resolver import CredentialResolver
14
+ from ..vault.store import StoredCredential
15
+
16
+
17
+ @dataclass
18
+ class DeviceInfo:
19
+ """Simplified device view for scripting."""
20
+ name: str
21
+ hostname: str
22
+ port: int
23
+ folder: Optional[str] = None
24
+ credential: Optional[str] = None
25
+ last_connected: Optional[str] = None
26
+ connect_count: int = 0
27
+
28
+ @classmethod
29
+ def from_session(cls, session: SavedSession, folder_name: str = None) -> 'DeviceInfo':
30
+ return cls(
31
+ name=session.name,
32
+ hostname=session.hostname,
33
+ port=session.port,
34
+ folder=folder_name,
35
+ credential=session.credential_name,
36
+ last_connected=str(session.last_connected) if session.last_connected else None,
37
+ connect_count=session.connect_count,
38
+ )
39
+
40
+ def to_dict(self) -> Dict[str, Any]:
41
+ return asdict(self)
42
+
43
+ def __repr__(self) -> str:
44
+ cred = f", cred={self.credential}" if self.credential else ""
45
+ folder = f", folder={self.folder}" if self.folder else ""
46
+ return f"Device({self.name}, {self.hostname}:{self.port}{cred}{folder})"
47
+
48
+
49
+ @dataclass
50
+ class CredentialInfo:
51
+ """Simplified credential view for scripting (no secrets exposed)."""
52
+ name: str
53
+ username: str
54
+ has_password: bool
55
+ has_key: bool
56
+ match_hosts: List[str]
57
+ match_tags: List[str]
58
+ jump_host: Optional[str] = None
59
+ is_default: bool = False
60
+
61
+ def to_dict(self) -> Dict[str, Any]:
62
+ return asdict(self)
63
+
64
+ def __repr__(self) -> str:
65
+ auth = []
66
+ if self.has_password:
67
+ auth.append("password")
68
+ if self.has_key:
69
+ auth.append("key")
70
+ auth_str = "+".join(auth) if auth else "none"
71
+ default = " [default]" if self.is_default else ""
72
+ return f"Credential({self.name}, user={self.username}, auth={auth_str}{default})"
73
+
74
+
75
+ class NTermAPI:
76
+ """
77
+ Scripting interface for nterm.
78
+
79
+ Provides read access to saved sessions and credentials,
80
+ and connection/command execution capabilities.
81
+
82
+ Usage:
83
+ api = NTermAPI()
84
+
85
+ # List and search devices
86
+ api.devices()
87
+ api.search("leaf")
88
+ api.devices("Lab-*")
89
+
90
+ # Credentials (requires unlocked vault)
91
+ api.credentials()
92
+ api.credential("lab-admin")
93
+
94
+ # Connect and execute (future)
95
+ session = api.connect("eng-leaf-1")
96
+ output = api.send(session, "show version")
97
+ """
98
+
99
+ def __init__(
100
+ self,
101
+ session_store: SessionStore = None,
102
+ credential_resolver: CredentialResolver = None,
103
+ ):
104
+ self._sessions = session_store or SessionStore()
105
+ self._resolver = credential_resolver or CredentialResolver()
106
+ self._folder_cache: Dict[int, str] = {}
107
+ self._active_sessions: Dict[str, Any] = {} # Future: track open sessions
108
+
109
+ # -------------------------------------------------------------------------
110
+ # Device / Session listing
111
+ # -------------------------------------------------------------------------
112
+
113
+ def devices(self, pattern: str = None, folder: str = None) -> List[DeviceInfo]:
114
+ """
115
+ List saved devices/sessions.
116
+
117
+ Args:
118
+ pattern: Optional glob pattern to filter by name (e.g., "eng-*", "*leaf*")
119
+ folder: Optional folder name to filter by
120
+
121
+ Returns:
122
+ List of DeviceInfo objects
123
+
124
+ Examples:
125
+ api.devices() # All devices
126
+ api.devices("eng-*") # All devices starting with "eng-"
127
+ api.devices(folder="Lab-ENG") # All devices in Lab-ENG folder
128
+ """
129
+ self._refresh_folder_cache()
130
+ sessions = self._sessions.list_all_sessions()
131
+
132
+ results = []
133
+ for session in sessions:
134
+ folder_name = self._folder_cache.get(session.folder_id)
135
+
136
+ # Filter by folder if specified
137
+ if folder and folder_name != folder:
138
+ continue
139
+
140
+ # Filter by pattern if specified
141
+ if pattern and not fnmatch.fnmatch(session.name, pattern):
142
+ continue
143
+
144
+ results.append(DeviceInfo.from_session(session, folder_name))
145
+
146
+ return results
147
+
148
+ def search(self, query: str) -> List[DeviceInfo]:
149
+ """
150
+ Search devices by name, hostname, or description.
151
+
152
+ Args:
153
+ query: Search string (partial match)
154
+
155
+ Returns:
156
+ List of matching DeviceInfo objects
157
+
158
+ Examples:
159
+ api.search("leaf") # Find devices with "leaf" in name/hostname
160
+ api.search("192.168") # Find devices by IP prefix
161
+ """
162
+ self._refresh_folder_cache()
163
+ sessions = self._sessions.search_sessions(query)
164
+
165
+ return [
166
+ DeviceInfo.from_session(s, self._folder_cache.get(s.folder_id))
167
+ for s in sessions
168
+ ]
169
+
170
+ def device(self, name: str) -> Optional[DeviceInfo]:
171
+ """
172
+ Get a specific device by exact name.
173
+
174
+ Args:
175
+ name: Device/session name
176
+
177
+ Returns:
178
+ DeviceInfo or None if not found
179
+
180
+ Examples:
181
+ api.device("eng-leaf-1")
182
+ """
183
+ self._refresh_folder_cache()
184
+ sessions = self._sessions.list_all_sessions()
185
+
186
+ for session in sessions:
187
+ if session.name == name:
188
+ return DeviceInfo.from_session(
189
+ session,
190
+ self._folder_cache.get(session.folder_id)
191
+ )
192
+ return None
193
+
194
+ def folders(self) -> List[str]:
195
+ """
196
+ List all folder names.
197
+
198
+ Returns:
199
+ List of folder names
200
+ """
201
+ self._refresh_folder_cache()
202
+ return list(self._folder_cache.values())
203
+
204
+ def _refresh_folder_cache(self):
205
+ """Refresh folder ID -> name mapping."""
206
+ tree = self._sessions.get_tree()
207
+ self._folder_cache = {f.id: f.name for f in tree["folders"]}
208
+
209
+ # -------------------------------------------------------------------------
210
+ # Credential access
211
+ # -------------------------------------------------------------------------
212
+
213
+ @property
214
+ def vault_unlocked(self) -> bool:
215
+ """Check if credential vault is unlocked."""
216
+ return self._resolver.store.is_unlocked if self._resolver.is_initialized() else False
217
+
218
+ @property
219
+ def vault_initialized(self) -> bool:
220
+ """Check if vault exists."""
221
+ return self._resolver.is_initialized()
222
+
223
+ def unlock(self, password: str) -> bool:
224
+ """
225
+ Unlock the credential vault.
226
+
227
+ Args:
228
+ password: Vault master password
229
+
230
+ Returns:
231
+ True if unlocked successfully
232
+ """
233
+ return self._resolver.unlock_vault(password)
234
+
235
+ def lock(self) -> None:
236
+ """Lock the credential vault."""
237
+ self._resolver.lock_vault()
238
+
239
+ def credentials(self, pattern: str = None) -> List[CredentialInfo]:
240
+ """
241
+ List available credentials (names and metadata only, no secrets).
242
+
243
+ Args:
244
+ pattern: Optional glob pattern to filter by name
245
+
246
+ Returns:
247
+ List of CredentialInfo objects
248
+
249
+ Raises:
250
+ RuntimeError: If vault is locked
251
+
252
+ Examples:
253
+ api.credentials() # All credentials
254
+ api.credentials("*admin*") # Credentials with "admin" in name
255
+ """
256
+ if not self.vault_unlocked:
257
+ raise RuntimeError("Vault is locked. Call api.unlock(password) first.")
258
+
259
+ creds = self._resolver.list_credentials()
260
+ results = []
261
+
262
+ for cred in creds:
263
+ if pattern and not fnmatch.fnmatch(cred.name, pattern):
264
+ continue
265
+
266
+ results.append(CredentialInfo(
267
+ name=cred.name,
268
+ username=cred.username,
269
+ has_password=bool(cred.password),
270
+ has_key=bool(cred.ssh_key),
271
+ match_hosts=cred.match_hosts or [],
272
+ match_tags=cred.match_tags or [],
273
+ jump_host=cred.jump_host,
274
+ is_default=cred.is_default,
275
+ ))
276
+
277
+ return results
278
+
279
+ def credential(self, name: str) -> Optional[CredentialInfo]:
280
+ """
281
+ Get credential info by name (no secrets exposed).
282
+
283
+ Args:
284
+ name: Credential name
285
+
286
+ Returns:
287
+ CredentialInfo or None
288
+
289
+ Raises:
290
+ RuntimeError: If vault is locked
291
+ """
292
+ if not self.vault_unlocked:
293
+ raise RuntimeError("Vault is locked. Call api.unlock(password) first.")
294
+
295
+ cred = self._resolver.get_credential(name)
296
+ if not cred:
297
+ return None
298
+
299
+ return CredentialInfo(
300
+ name=cred.name,
301
+ username=cred.username,
302
+ has_password=bool(cred.password),
303
+ has_key=bool(cred.ssh_key),
304
+ match_hosts=cred.match_hosts or [],
305
+ match_tags=cred.match_tags or [],
306
+ jump_host=cred.jump_host,
307
+ is_default=cred.is_default,
308
+ )
309
+
310
+ def resolve_credential(self, hostname: str, tags: List[str] = None) -> Optional[str]:
311
+ """
312
+ Find which credential would be used for a hostname.
313
+
314
+ Args:
315
+ hostname: Target hostname
316
+ tags: Optional device tags
317
+
318
+ Returns:
319
+ Credential name that would match, or None
320
+
321
+ Raises:
322
+ RuntimeError: If vault is locked
323
+ """
324
+ if not self.vault_unlocked:
325
+ raise RuntimeError("Vault is locked.")
326
+
327
+ try:
328
+ profile = self._resolver.resolve_for_device(hostname, tags)
329
+ # Extract credential name from profile name format "hostname (cred_name)"
330
+ if "(" in profile.name and ")" in profile.name:
331
+ return profile.name.split("(")[1].rstrip(")")
332
+ return None
333
+ except Exception:
334
+ return None
335
+
336
+ # -------------------------------------------------------------------------
337
+ # Connection operations (future expansion)
338
+ # -------------------------------------------------------------------------
339
+
340
+ def connect(self, device: str, credential: str = None):
341
+ """
342
+ Connect to a device.
343
+
344
+ Args:
345
+ device: Device name (from saved sessions) or hostname
346
+ credential: Optional credential name (auto-resolved if not specified)
347
+
348
+ Returns:
349
+ Session handle for sending commands
350
+ """
351
+ # TODO: Implement actual connection logic
352
+ raise NotImplementedError("Connection support coming soon")
353
+
354
+ def send(self, session, command: str, timeout: int = 30) -> str:
355
+ """
356
+ Send command to a connected session.
357
+
358
+ Args:
359
+ session: Session handle from connect()
360
+ command: Command to execute
361
+ timeout: Timeout in seconds
362
+
363
+ Returns:
364
+ Command output
365
+ """
366
+ raise NotImplementedError("Connection support coming soon")
367
+
368
+ def disconnect(self, session) -> None:
369
+ """Disconnect a session."""
370
+ raise NotImplementedError("Connection support coming soon")
371
+
372
+ # -------------------------------------------------------------------------
373
+ # Convenience / REPL helpers
374
+ # -------------------------------------------------------------------------
375
+
376
+ def __repr__(self) -> str:
377
+ device_count = len(self._sessions.list_all_sessions())
378
+ vault_status = "unlocked" if self.vault_unlocked else "locked"
379
+ return f"<NTermAPI: {device_count} devices, vault {vault_status}>"
380
+
381
+ def status(self) -> Dict[str, Any]:
382
+ """
383
+ Get API status summary.
384
+
385
+ Returns:
386
+ Dict with device count, folder count, credential count, vault status
387
+ """
388
+ sessions = self._sessions.list_all_sessions()
389
+ folders = self._sessions.get_tree()["folders"]
390
+
391
+ cred_count = 0
392
+ if self.vault_unlocked:
393
+ cred_count = len(self._resolver.list_credentials())
394
+
395
+ return {
396
+ "devices": len(sessions),
397
+ "folders": len(folders),
398
+ "credentials": cred_count,
399
+ "vault_initialized": self.vault_initialized,
400
+ "vault_unlocked": self.vault_unlocked,
401
+ "active_sessions": len(self._active_sessions),
402
+ }
403
+
404
+ def help(self) -> None:
405
+ """Print available commands."""
406
+ print("""
407
+ nterm API Commands
408
+ ==================
409
+
410
+ Devices:
411
+ api.devices() List all devices
412
+ api.devices("pattern*") Filter by glob pattern
413
+ api.devices(folder="Lab-ENG") Filter by folder
414
+ api.search("query") Search by name/hostname/description
415
+ api.device("name") Get specific device
416
+ api.folders() List all folders
417
+
418
+ Credentials (requires unlocked vault):
419
+ api.unlock("password") Unlock vault
420
+ api.lock() Lock vault
421
+ api.credentials() List all credentials
422
+ api.credentials("*admin*") Filter by pattern
423
+ api.credential("name") Get specific credential
424
+ api.resolve_credential("host") Find matching credential
425
+
426
+ Status:
427
+ api.status() Get summary
428
+ api.vault_unlocked Check vault status
429
+ """)
430
+
431
+
432
+ # Singleton for convenience in IPython
433
+ _default_api: Optional[NTermAPI] = None
434
+
435
+ def get_api() -> NTermAPI:
436
+ """Get or create default API instance."""
437
+ global _default_api
438
+ if _default_api is None:
439
+ _default_api = NTermAPI()
440
+ return _default_api
441
+
442
+
443
+ def reset_api() -> NTermAPI:
444
+ """Reset and return fresh API instance."""
445
+ global _default_api
446
+ _default_api = NTermAPI()
447
+ return _default_api