ctdb-scripts: Improve update and listing code
[samba4-gss.git] / python / samba / netcmd / __init__.py
blob7d74352620715f721ec7560fe6a3471b6fcc8b20
1 # Unix SMB/CIFS implementation.
2 # Copyright (C) Jelmer Vernooij <jelmer@samba.org> 2009-2012
3 # Copyright (C) Theresa Halloran <theresahalloran@gmail.com> 2011
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 3 of the License, or
8 # (at your option) any later version.
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
15 # You should have received a copy of the GNU General Public License
16 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19 import json
20 import optparse
21 import sys
22 import textwrap
23 import traceback
25 import samba
26 from ldb import ERR_INVALID_CREDENTIALS, ERR_INSUFFICIENT_ACCESS_RIGHTS, LdbError
27 from samba import colour
28 from samba.auth import system_session
29 from samba.getopt import Option, OptionParser
30 from samba.logger import get_samba_logger
31 from samba.samdb import SamDB
32 from samba.dcerpc.security import SDDLValueError
34 from .encoders import JSONEncoder
37 class PlainHelpFormatter(optparse.IndentedHelpFormatter):
38 """This help formatter does text wrapping and preserves newlines."""
40 def format_description(self, description=""):
41 desc_width = self.width - self.current_indent
42 indent = " " * self.current_indent
43 paragraphs = description.split('\n')
44 wrapped_paragraphs = [
45 textwrap.fill(p,
46 desc_width,
47 initial_indent=indent,
48 subsequent_indent=indent)
49 for p in paragraphs]
50 result = "\n".join(wrapped_paragraphs) + "\n"
51 return result
53 def format_epilog(self, epilog):
54 if epilog:
55 return "\n" + epilog + "\n"
56 else:
57 return ""
60 class Command(object):
61 """A samba-tool command."""
63 def _get_short_description(self):
64 return self.__doc__.splitlines()[0].rstrip("\n")
66 short_description = property(_get_short_description)
68 def _get_full_description(self):
69 lines = self.__doc__.split("\n")
70 return lines[0] + "\n" + textwrap.dedent("\n".join(lines[1:]))
72 full_description = property(_get_full_description)
74 def _get_name(self):
75 name = self.__class__.__name__
76 if name.startswith("cmd_"):
77 return name[4:]
78 return name
80 name = property(_get_name)
82 # synopsis must be defined in all subclasses in order to provide the
83 # command usage
84 synopsis = None
85 takes_args = ()
86 takes_options = ()
87 takes_optiongroups = {}
89 hidden = False
90 use_colour = True
91 requested_colour = None
93 raw_argv = None
94 raw_args = None
95 raw_kwargs = None
96 preferred_output_format = None
98 def _set_files(self, outf=None, errf=None):
99 if outf is not None:
100 self.outf = outf
101 if errf is not None:
102 self.errf = errf
104 def __init__(self, outf=sys.stdout, errf=sys.stderr):
105 self._set_files(outf, errf)
107 def usage(self, prog=None):
108 parser, _ = self._create_parser(prog or self.command_name)
109 parser.print_usage()
111 def _print_error(self, msg, evalue=None, klass=None):
112 if self.preferred_output_format == 'json':
113 if evalue is None:
114 evalue = 1
115 else:
116 msg = f"{msg} - {evalue}"
117 if klass is not None:
118 kwargs = {'error class': klass}
119 else:
120 kwargs = {}
122 self.print_json_status(evalue, msg, **kwargs)
123 return
125 err = colour.c_DARK_RED("ERROR")
126 klass = '' if klass is None else f'({klass})'
128 if evalue is None:
129 print(f"{err}{klass}: {msg}", file=self.errf)
130 else:
131 print(f"{err}{klass}: {msg} - {evalue}", file=self.errf)
133 def _print_sddl_value_error(self, e):
134 generic_msg, specific_msg, position, sddl = e.args
135 print(f"{colour.c_DARK_RED('ERROR')}: {generic_msg}\n",
136 file=self.errf)
137 print(f' {sddl}', file=self.errf)
138 # If the SDDL contains non-ascii characters, the byte offset
139 # provided by the exception won't agree with the visual offset
140 # because those characters will be encoded as multiple bytes.
142 # To account for this we'll attempt to measure the string
143 # length of the specified number of bytes. That is not quite
144 # the same as the visual length, because the SDDL could
145 # contain zero-width, full-width, or combining characters, but
146 # it is closer.
147 try:
148 position = len((sddl.encode()[:position]).decode())
149 except ValueError:
150 # use the original position
151 pass
153 print(f"{colour.c_DARK_YELLOW('^'):>{position + 2}}", file=self.errf)
154 print(f' {specific_msg}', file=self.errf)
156 def ldb_connect(self, hostopts, sambaopts, credopts):
157 """Helper to connect to Ldb database using command line opts."""
158 lp = sambaopts.get_loadparm()
159 creds = credopts.get_credentials(lp)
160 return SamDB(hostopts.H, credentials=creds,
161 session_info=system_session(lp), lp=lp)
163 def print_json(self, data):
164 """Print json on the screen using consistent formatting and sorting.
166 A custom JSONEncoder class is used to help with serializing unknown
167 objects such as Dn for example.
169 json.dump(data, self.outf, cls=JSONEncoder, indent=2, sort_keys=True)
170 self.outf.write("\n")
172 def print_json_status(self, error=None, message=None, **kwargs):
173 """For commands that really have nothing to say when they succeed
174 (`samba-tool foo delete --json`), we can still emit
175 '{"status": "OK"}\n'. And if they fail they can say:
176 '{"status": "error"}\n'.
177 This function hopes to keep things consistent.
179 If error is true-ish but not True, it is stringified and added
180 as a message. For example, if error is an LdbError with an
181 OBJECT_NOT_FOUND code, self.print_json_status(error) results
182 in this:
184 '{"status": "error", "message": "object not found"}\n'
186 unless an explicit message is added, in which case that is
187 used. A message can be provided on success, like this:
189 '{"status": "OK", "message": "thanks for asking!"}\n'
191 Extra keywords can be added too.
193 In summary, you might go:
195 try:
196 samdb.delete(dn)
197 except Exception as e:
198 print_json_status(e)
199 return
200 print_json_status()
202 data = {}
203 if error:
204 data['status'] = 'error'
205 if error is not True:
206 data['message'] = str(error)
207 else:
208 data['status'] = 'OK'
210 if message is not None:
211 data['message'] = message
213 data.update(kwargs)
214 self.print_json(data)
216 def show_command_error(self, e):
217 """display a command error"""
218 if isinstance(e, CommandError):
219 (etype, evalue, etraceback) = e.exception_info
220 inner_exception = e.inner_exception
221 message = e.message
222 force_traceback = False
223 else:
224 (etype, evalue, etraceback) = sys.exc_info()
225 inner_exception = e
226 message = "uncaught exception"
227 force_traceback = True
229 if isinstance(e, optparse.OptParseError):
230 print(evalue, file=self.errf)
231 self.usage()
232 force_traceback = False
234 elif isinstance(inner_exception, LdbError):
235 (ldb_ecode, ldb_emsg) = inner_exception.args
236 if ldb_ecode == ERR_INVALID_CREDENTIALS:
237 print("Invalid username or password", file=self.errf)
238 force_traceback = False
239 elif ldb_emsg == 'LDAP client internal error: NT_STATUS_NETWORK_UNREACHABLE':
240 print("Could not reach remote server", file=self.errf)
241 force_traceback = False
242 elif ldb_emsg.startswith("Unable to open tdb "):
243 self._print_error(message, ldb_emsg, 'ldb')
244 force_traceback = False
245 elif ldb_ecode == ERR_INSUFFICIENT_ACCESS_RIGHTS:
246 self._print_error("User has insufficient access rights")
247 force_traceback = False
248 else:
249 self._print_error(message, ldb_emsg, 'ldb')
251 elif isinstance(inner_exception, SDDLValueError):
252 self._print_sddl_value_error(inner_exception)
253 force_traceback = False
255 elif isinstance(inner_exception, AssertionError):
256 self._print_error(message, klass='assert')
257 force_traceback = True
258 elif isinstance(inner_exception, RuntimeError):
259 self._print_error(message, evalue, 'runtime')
260 elif type(inner_exception) is Exception:
261 self._print_error(message, evalue, 'exception')
262 force_traceback = True
263 elif inner_exception is None:
264 self._print_error(message)
265 else:
266 self._print_error(message, evalue, str(etype))
268 if force_traceback or samba.get_debug_level() >= 3:
269 traceback.print_tb(etraceback, file=self.errf)
271 def _create_parser(self, prog=None, epilog=None):
272 parser = OptionParser(
273 usage=self.synopsis,
274 description=self.full_description,
275 formatter=PlainHelpFormatter(),
276 prog=prog,
277 epilog=epilog,
278 option_class=Option)
279 parser.add_options(self.takes_options)
280 optiongroups = {}
281 for name in sorted(self.takes_optiongroups.keys()):
282 optiongroup = self.takes_optiongroups[name]
283 optiongroups[name] = optiongroup(parser)
284 parser.add_option_group(optiongroups[name])
285 if self.use_colour:
286 parser.add_option("--color",
287 help="use colour if available (default: auto)",
288 metavar="always|never|auto",
289 default="auto")
291 return parser, optiongroups
293 def message(self, text):
294 self.outf.write(text + "\n")
296 def _resolve(self, path, *argv, outf=None, errf=None):
297 """This is a leaf node, the command that will actually run."""
298 self._set_files(outf, errf)
299 self.command_name = path
300 return (self, argv)
302 def _run(self, *argv):
303 parser, optiongroups = self._create_parser(self.command_name)
305 # Handle possible validation errors raised by parser
306 try:
307 opts, args = parser.parse_args(list(argv))
308 except Exception as e:
309 self.show_command_error(e)
310 return -1
312 # Filter out options from option groups
313 kwargs = dict(opts.__dict__)
314 for option_group in parser.option_groups:
315 for option in option_group.option_list:
316 if option.dest is not None and option.dest in kwargs:
317 del kwargs[option.dest]
318 kwargs.update(optiongroups)
320 if kwargs.get('output_format') == 'json':
321 self.preferred_output_format = 'json'
322 else:
323 # we need to reset this for the tests that reuse the
324 # samba-tool object.
325 self.preferred_output_format = None
327 if self.use_colour:
328 self.apply_colour_choice(kwargs.pop('color', 'auto'))
330 # Check for a min a max number of allowed arguments, whenever possible
331 # The suffix "?" means zero or one occurrence
332 # The suffix "+" means at least one occurrence
333 # The suffix "*" means zero or more occurrences
334 min_args = 0
335 max_args = 0
336 undetermined_max_args = False
337 for i, arg in enumerate(self.takes_args):
338 if arg[-1] != "?" and arg[-1] != "*":
339 min_args += 1
340 if arg[-1] == "+" or arg[-1] == "*":
341 undetermined_max_args = True
342 else:
343 max_args += 1
344 if (len(args) < min_args) or (not undetermined_max_args and len(args) > max_args):
345 parser.print_usage()
346 return -1
348 self.raw_argv = list(argv)
349 self.raw_args = args
350 self.raw_kwargs = kwargs
352 try:
353 return self.run(*args, **kwargs)
354 except Exception as e:
355 self.show_command_error(e)
356 return -1
358 def run(self, *args, **kwargs):
359 """Run the command. This should be overridden by all subclasses."""
360 raise NotImplementedError(f"'{self.command_name}' run method not implemented")
362 def get_logger(self, name="", verbose=False, quiet=False, **kwargs):
363 """Get a logger object."""
364 return get_samba_logger(
365 name=name or self.name, stream=self.errf,
366 verbose=verbose, quiet=quiet,
367 **kwargs)
369 def apply_colour_choice(self, requested):
370 """Heuristics to work out whether the user wants colour output, from a
371 --color=yes|no|auto option. This alters the ANSI 16 bit colour
372 "constants" in the colour module to be either real colours or empty
373 strings.
375 self.requested_colour = requested
376 try:
377 colour.colour_if_wanted(self.outf,
378 self.errf,
379 hint=requested)
380 except ValueError as e:
381 raise CommandError(f"Unknown --color option: {requested} "
382 "please choose from always|never|auto")
385 class SuperCommand(Command):
386 """A samba-tool command with subcommands."""
388 synopsis = "%prog <subcommand>"
390 subcommands = {}
392 def _resolve(self, path, *args, outf=None, errf=None):
393 """This is an internal node. We need to consume one of the args and
394 find the relevant child, returning an instance of that Command.
396 If there are no children, this SuperCommand will be returned
397 and its _run() will do a --help like thing.
399 self.command_name = path
400 self._set_files(outf, errf)
402 # We collect up certain option arguments and pass them to the
403 # leaf, which is why we iterate over args, though we really
404 # expect to return in the first iteration.
405 deferred_args = []
407 for i, a in enumerate(args):
408 if a in self.subcommands:
409 sub_args = args[i + 1:] + tuple(deferred_args)
410 sub_path = f'{path} {a}'
412 sub = self.subcommands[a]
413 return sub._resolve(sub_path, *sub_args, outf=outf, errf=errf)
415 elif a in ['--help', 'help', None, '-h', '-V', '--version']:
416 # we pass these to the leaf node.
417 if a == 'help':
418 a = '--help'
419 deferred_args.append(a)
420 continue
422 # they are talking nonsense
423 print("%s: no such subcommand: %s\n" % (path, a), file=self.outf)
424 return (self, [])
426 # We didn't find a subcommand, but maybe we found e.g. --version
427 print("%s: missing subcommand\n" % (path), file=self.outf)
428 return (self, deferred_args)
430 def _run(self, *argv):
431 epilog = "\nAvailable subcommands:\n"
433 subcmds = sorted(self.subcommands.keys())
434 max_length = max([len(c) for c in subcmds], default=0)
435 for cmd_name in subcmds:
436 cmd = self.subcommands[cmd_name]
437 if cmd.hidden:
438 continue
439 epilog += " %*s - %s\n" % (
440 -max_length, cmd_name, cmd.short_description)
442 epilog += ("\nFor more help on a specific subcommand, please type: "
443 f"{self.command_name} <subcommand> (-h|--help)\n")
445 parser, optiongroups = self._create_parser(self.command_name, epilog=epilog)
446 opts, args = parser.parse_args(list(argv))
448 # note: if argv had --help, parser.parse_args() will have
449 # already done the .print_help() and attempted to exit with
450 # return code 0, so we won't get here.
451 parser.print_help()
452 return -1
455 class CommandError(Exception):
456 """An exception class for samba-tool Command errors."""
458 def __init__(self, message, inner_exception=None):
459 self.message = message
460 self.inner_exception = inner_exception
461 self.exception_info = sys.exc_info()
463 def __repr__(self):
464 return "CommandError(%s)" % self.message