netconverter-cli 0.4.0__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,365 @@
1
+ """Inline keyboard choices. No model controls paths, settings, or credentials.
2
+
3
+ Every menu is built from the server catalog (``Controller.catalog``): the full
4
+ API offers every vendor and path it accepts; a 0.1.x pilot key keeps the
5
+ ASA → Palo Alto settings the pilot API requires.
6
+ """
7
+ import json
8
+ import re
9
+ from pathlib import Path
10
+
11
+ from .catalog import label
12
+ from .workspace import WorkspaceError
13
+
14
+ # Downloaded artifacts are named "<job id>.<file>"; they are outputs, not sources.
15
+ _ARTIFACT = re.compile(r'(?:[a-z]{3}_[A-Za-z0-9]{6,}|[0-9a-f]{8}-[0-9a-f-]{27})\.')
16
+ _SKIP_SUFFIXES = {'.zip', '.xlsx', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.docx', '.pptx', '.gz', '.tgz', '.whl'}
17
+ # First match wins; only codes the server offers are ever suggested.
18
+ _SIGNATURES = [
19
+ ('cisco_asa', re.compile(r'^(?:ASA Version|: Saved|access-list \S+ extended|object-group network)', re.M)),
20
+ ('fortinet', re.compile(r'^config (?:firewall|system) ', re.M)),
21
+ ('palo_alto_panorama', re.compile(r'<(?:device-group|panorama)>')),
22
+ ('palo_alto_xml', re.compile(r'<config\b')),
23
+ ('palo_alto_set', re.compile(r'^set (?:deviceconfig|rulebase|address|zone|network|shared) ', re.M)),
24
+ ('juniper_set_', re.compile(r'^set (?:system|interfaces|protocols|security|vlans|routing-options) ', re.M)),
25
+ ('juniper_junos_', re.compile(r'^(?:system|interfaces|protocols|security|vlans) \{', re.M)),
26
+ ('aruba_cx_switch', re.compile(r'^(?:!Version ArubaOS-CX|vsx$|interface 1/1/)', re.M)),
27
+ ('aruba_os_switch', re.compile(r'Configuration Editor|^vlan \d+\s*\n\s+name ', re.M)),
28
+ ('cisco_ios_xe_', re.compile(r'^(?:Building configuration|version 1[5-7]\.|interface GigabitEthernet|ip routing)', re.M)),
29
+ ('cisco_fmc', re.compile(r'"(?:accessPolicies|deviceRecords|objects)"\s*:')),
30
+ ]
31
+
32
+
33
+ def guess_vendor(text, candidates):
34
+ """A suggested source vendor, read locally from the file head; None when unsure."""
35
+ for code, pattern in _SIGNATURES:
36
+ if pattern.search(text):
37
+ for candidate in candidates:
38
+ if candidate == code or (code.endswith('_') and candidate.startswith(code)):
39
+ return candidate
40
+ return None
41
+
42
+
43
+ class Guided:
44
+ def __init__(self, app):
45
+ self.app = app
46
+ self.root = app.controller.workspace.root
47
+ self.options = {}
48
+ self.job_type = None
49
+ self.filename = None
50
+ self.vendor = None
51
+ self.model_name = None
52
+ self.model_id = None
53
+
54
+ @property
55
+ def controller(self):
56
+ return self.app.controller
57
+
58
+ @property
59
+ def catalog(self):
60
+ return self.app.controller.catalog
61
+
62
+ def choose(self, title, choices, callback, highlight=0):
63
+ self.app.ask_choice(title, choices, callback, highlight=highlight)
64
+
65
+ def text(self, title, callback, *, secret=False, hint=''):
66
+ self.app.ask_text(title, callback, secret=secret, hint=hint)
67
+
68
+ # -- entry points ----------------------------------------------------------
69
+
70
+ def start(self, job_type):
71
+ """/convert, /validate, /optimize (job_type) or /open (None)."""
72
+ if not self.catalog.loaded:
73
+ self.app.load_catalog(lambda: self.start(job_type))
74
+ return
75
+ if job_type and not self.catalog.supports(job_type):
76
+ self.app.show_error(f'{label(job_type)} is not available for this credential yet. '
77
+ f'Available: {", ".join(label(t) for t in self.catalog.job_types)}.')
78
+ return
79
+ self.job_type = job_type
80
+ self.options = {}
81
+ selected = self.selected()
82
+ if job_type and selected and selected.get('vendor') in self.catalog.sources(job_type):
83
+ name = selected.get('display_name') or selected.get('path')
84
+ self.choose(f'{label(job_type)} which configuration?', [
85
+ (f'{name} · {self.catalog.name(selected["vendor"])}', True),
86
+ ('Choose another file', False)],
87
+ lambda keep: self.after_open(job_type) if keep else self.browse())
88
+ return
89
+ if job_type and selected.get('vendor'):
90
+ self.app.write_note(f'{self.catalog.name(selected["vendor"])} is not a {label(job_type).lower()} source '
91
+ f'on this server; choose the source file.')
92
+ self.browse()
93
+
94
+ def convert(self):
95
+ self.start('convert')
96
+
97
+ def open(self, following=None, folder=None):
98
+ self.job_type = following if following in {'convert', 'validate', 'optimize'} else None
99
+ self.browse(folder)
100
+
101
+ def selected(self):
102
+ state = self.controller.state
103
+ return state.get('configurations', {}).get(state.get('selected'), {})
104
+
105
+ # -- file and source vendor ------------------------------------------------
106
+
107
+ def browse(self, folder=None):
108
+ folder = Path(folder or self.root)
109
+ limit = self.controller.max_bytes()
110
+ choices = []
111
+ if folder != self.root:
112
+ choices.append(('↰ ..', folder.parent))
113
+ try:
114
+ entries = sorted(folder.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
115
+ for path in entries:
116
+ if path.name.startswith(('.', '~$')) or path.is_symlink() or _ARTIFACT.match(path.name):
117
+ continue
118
+ if not path.resolve().is_relative_to(self.root):
119
+ continue
120
+ if path.is_dir():
121
+ choices.append((path.name + '/', path))
122
+ elif path.suffix.lower() not in _SKIP_SUFFIXES:
123
+ size = path.stat().st_size
124
+ if size:
125
+ over = f' · over the {_size(limit)} limit' if size > limit else ''
126
+ choices.append((f'{path.name} · {_size(size)}{over}', path))
127
+ except OSError:
128
+ self.app.show_error('Cannot list this folder. Check its permissions.')
129
+ choices = choices[:200] + [('Type a relative file path', None)]
130
+ title = {'convert': 'Convert which file?', 'validate': 'Validate which file?',
131
+ 'optimize': 'Optimize which file?'}.get(self.job_type, 'Choose a configuration')
132
+ self.choose(title, choices, lambda path: self.file_chosen(path, folder))
133
+
134
+ def file_chosen(self, path, folder=None):
135
+ if path is None:
136
+ self.text('Relative configuration path',
137
+ lambda value: self.file_chosen(self.root / value.strip().strip('"'), folder))
138
+ return
139
+ try:
140
+ path = self.controller.workspace._inside(Path(path))
141
+ if path.is_dir():
142
+ self.browse(path)
143
+ return
144
+ _, content, _ = self.controller.workspace.snapshot(str(path.relative_to(self.root)),
145
+ self.controller.max_bytes())
146
+ except (WorkspaceError, OSError):
147
+ self.app.show_error(f'Choose a UTF-8 text configuration inside this folder, '
148
+ f'up to {self.controller.max_bytes():,} bytes.')
149
+ self.browse(folder)
150
+ return
151
+ self.filename = str(path.relative_to(self.root))
152
+ sources = self.catalog.sources(self.job_type or 'validate')
153
+ if len(sources) == 1:
154
+ self.vendor_chosen(sources[0])
155
+ return
156
+ choices = self.catalog.grouped(sources)
157
+ guess = guess_vendor(content[:65536].decode('utf-8', 'replace'), [c for _, c in choices])
158
+ highlight = 0
159
+ if guess:
160
+ highlight = [c for _, c in choices].index(guess)
161
+ choices[highlight] = (choices[highlight][0] + ' · detected', guess)
162
+ self.choose(f'What does {Path(self.filename).name} contain?', choices, self.vendor_chosen,
163
+ highlight=highlight)
164
+
165
+ def vendor_chosen(self, vendor):
166
+ self.vendor = vendor
167
+ if self.controller.external:
168
+ # Nothing leaves this machine until the review's explicit Submit.
169
+ self.app.open_selected((self.filename, vendor, self.job_type))
170
+ return
171
+ self.choose(f'Upload {self.filename} as {self.catalog.name(vendor)}?', [
172
+ ('Upload this file directly to NetConverter', True), ('Choose another file', False)],
173
+ lambda approved: self.app.open_selected((self.filename, vendor, self.job_type))
174
+ if approved else self.browse())
175
+
176
+ def after_open(self, following):
177
+ """Called once the selected configuration is pinned (uploaded for pilot keys)."""
178
+ self.job_type = following
179
+ self.options = {}
180
+ if following == 'convert':
181
+ self.target()
182
+ elif following in {'validate', 'optimize'}:
183
+ self.review()
184
+
185
+ # -- conversion settings ---------------------------------------------------
186
+
187
+ def target(self):
188
+ source = self.selected().get('vendor')
189
+ targets = self.catalog.targets(source)
190
+ if not targets:
191
+ self.app.show_error(f'{self.catalog.name(source)} has no conversion target on this server. '
192
+ f'Use /validate to check it instead.')
193
+ return
194
+
195
+ def chosen(value):
196
+ self.options['target_vendor'] = value
197
+ if self.catalog.profile == 'quick':
198
+ self.quick_fields([('panos_target_version', 'panos_versions', 'PAN-OS version'),
199
+ ('routing_engine', 'routing_engines', 'Routing engine'),
200
+ ('pipeline_mode', 'pipeline_modes', 'Conversion mode')])
201
+ else:
202
+ self.review()
203
+ self.choose(f'Convert {self.catalog.name(source)} to', self.catalog.grouped(targets), chosen)
204
+
205
+ def quick_fields(self, fields):
206
+ """Pilot keys: the pilot API requires these Palo Alto settings explicitly."""
207
+ if not fields:
208
+ self.panorama()
209
+ return
210
+ field, key, title = fields[0]
211
+ values = self.catalog.quick.get(key) or []
212
+ if not values:
213
+ self.app.show_error('Load supported settings with /allowance, then use /convert.')
214
+ return
215
+
216
+ def selected(value):
217
+ self.options[field] = value
218
+ self.quick_fields(fields[1:])
219
+ self.choose(title, [(label(v), v) for v in values], selected)
220
+
221
+ def panorama(self):
222
+ if self.options['target_vendor'] == 'palo_alto_panorama':
223
+ self.required_text('Panorama device group', 'panorama_device_group',
224
+ lambda: self.required_text('Panorama template', 'panorama_template', self.cleanup))
225
+ else:
226
+ self.cleanup()
227
+
228
+ def required_text(self, title, field, following):
229
+ def answer(value):
230
+ if not value.strip():
231
+ self.app.show_error('This value is required.')
232
+ self.required_text(title, field, following)
233
+ else:
234
+ self.options[field] = value.strip()
235
+ following()
236
+ self.text(title, answer)
237
+
238
+ def cleanup(self):
239
+ def answer(value):
240
+ self.options['remove_unused_objects'] = value
241
+ self.choose('Advanced settings', [('Use the selected mode’s server presets', False),
242
+ ('Customize App-ID, profiles, logging and mappings', True)],
243
+ lambda customize: self.advanced() if customize else self.review())
244
+ self.choose('Remove unused objects during validated conversion?',
245
+ [('Keep existing objects', False), ('Remove verified unused objects', True)], answer)
246
+
247
+ def advanced(self, index=0):
248
+ choices = [
249
+ ('enable_appid', 'App-ID', [('Use mode preset', None), ('Enable', True), ('Disable', False)]),
250
+ ('app_id_implementation', 'App-ID implementation',
251
+ [('Use mode preset', None), ('Stack above original', 'dual_stack'), ('Replace original', 'force')]),
252
+ ('add_security_profiles', 'Security profiles', [('Use mode preset', None), ('Enable', True), ('Disable', False)]),
253
+ ('log_all_rules', 'Log all rules', [('Use mode preset', None), ('Enable', True), ('Disable', False)]),
254
+ ('implicit_deny', 'Explicit deny-all',
255
+ [('Use mode preset', None), ('Enable', 'explicit-deny-all'), ('Disable', 'none')])]
256
+ if index == len(choices):
257
+ self.text('Security profile group (Enter to use preset)', self.profile_group)
258
+ return
259
+ field, title, options = choices[index]
260
+
261
+ def answer(value):
262
+ if value is not None:
263
+ self.options[field] = value
264
+ self.advanced(index + 1)
265
+ self.choose(title, options, answer)
266
+
267
+ def profile_group(self, value):
268
+ if value.strip():
269
+ self.options['default_profile_group'] = value.strip()
270
+ self.text('Zone mapping JSON (Enter for no override)', self.zone_mapping,
271
+ hint='Example: {"inside":"Trust"}. Values go directly to NetConverter.')
272
+
273
+ def zone_mapping(self, value):
274
+ if value.strip():
275
+ try:
276
+ mapping = json.loads(value)
277
+ if not isinstance(mapping, dict) or not all(isinstance(k, str) and isinstance(v, str)
278
+ for k, v in mapping.items()):
279
+ raise ValueError()
280
+ self.options['zone_mapping'] = mapping
281
+ except ValueError:
282
+ self.app.show_error('Use a JSON object of source and target zone names.')
283
+ self.text('Zone mapping JSON (Enter for no override)', self.zone_mapping)
284
+ return
285
+ self.review()
286
+
287
+ # -- review and submit -----------------------------------------------------
288
+
289
+ def review(self):
290
+ from .presentation import card, details_view, literal
291
+ selected = self.selected()
292
+ from rich.console import Group
293
+ from .palette import MUTED
294
+ summary = {'job_type': self.job_type, 'file': selected.get('display_name') or selected.get('path'),
295
+ 'source_vendor': self.catalog.name(selected.get('vendor'))}
296
+ for field, value in self.options.items():
297
+ summary[field] = self.catalog.name(value) if field == 'target_vendor' else value
298
+ cost = {'convert': 'one conversion', 'validate': 'one analysis', 'optimize': 'one analysis'}[self.job_type]
299
+ sends = ('Submit sends the file directly to NetConverter over HTTPS'
300
+ if self.controller.external else 'Submit starts the job on NetConverter')
301
+ self.app.write(card('Review', Group(details_view(summary), literal(''),
302
+ literal(f'{sends} and uses {cost}.', MUTED))))
303
+ self.choose('Submit this job?',
304
+ [('Submit to NetConverter', True), ('Start over', False)],
305
+ lambda submit: self.app.submit_job(self.job_type, self.options) if submit
306
+ else self.start(self.job_type))
307
+
308
+ # -- model ------------------------------------------------------------------
309
+
310
+ def model(self):
311
+ self.choose('Model provider', [('OpenAI', 'openai'), ('Anthropic', 'anthropic'), ('Gemini', 'gemini'),
312
+ ('Ollama · local', 'ollama'), ('Guided commands · no model', 'none')],
313
+ self.provider)
314
+
315
+ def provider(self, name):
316
+ self.model_name = name
317
+ if name == 'none':
318
+ self.app.connect_model_inline(name, '', '')
319
+ else:
320
+ self.text('Exact API model ID', self.model_value, hint='Use a model available to your API account.')
321
+
322
+ def model_value(self, value):
323
+ if not value.strip():
324
+ self.app.show_error('Enter an explicit model ID.')
325
+ self.provider(self.model_name)
326
+ return
327
+ self.model_id = value.strip()
328
+ if self.model_name == 'ollama':
329
+ self.app.connect_model_inline(self.model_name, self.model_id, '')
330
+ else:
331
+ self.text(f'{self.model_name} API key · hidden',
332
+ lambda key: self.app.connect_model_inline(self.model_name, self.model_id, key.strip()),
333
+ secret=True, hint='Paste here; Enter uses a saved key if available. '
334
+ 'This value is never added to the conversation.')
335
+
336
+ # -- AI clients --------------------------------------------------------------
337
+
338
+ def connect(self):
339
+ from .connect import CLIENTS, configured_clients, plan
340
+ done = configured_clients(self.root)
341
+ self.choose('Connect which AI assistant to this folder?',
342
+ [(spec.title + (' · already connected' if spec.title in done else ''), key)
343
+ for key, spec in CLIENTS.items()],
344
+ lambda key: self.connect_review(plan(key, self.root)))
345
+
346
+ def connect_review(self, change):
347
+ from .presentation import card, literal
348
+ from .palette import MUTED
349
+ from rich.console import Group
350
+ from .connect import CLIENTS
351
+ heading = f'Connect {change.title}' if change.key in CLIENTS else change.title
352
+ self.app.write(card(heading, Group(literal(change.summary), literal(change.preview, MUTED))))
353
+ if not change.can_apply:
354
+ self.app.write_note(change.manual)
355
+ return
356
+ self.choose(f'Apply this change to {change.where}?', [('Apply', True), ('Cancel', False)],
357
+ lambda ok: self.app.apply_connect(change) if ok else self.app.write_note('Nothing was changed.'))
358
+
359
+
360
+ def _size(value):
361
+ if value >= 1024 * 1024:
362
+ return f'{value / 1024 / 1024:.1f} MB'
363
+ if value >= 1024:
364
+ return f'{value / 1024:.0f} KB'
365
+ return f'{value} B'