Revision 9bcef16f

b/Makefile.am
244 244
	lib/client/gnt_instance.py \
245 245
	lib/client/gnt_job.py \
246 246
	lib/client/gnt_node.py \
247
	lib/client/gnt_os.py
247
	lib/client/gnt_os.py \
248
	lib/client/gnt_storage.py
248 249

  
249 250
hypervisor_PYTHON = \
250 251
	lib/hypervisor/__init__.py \
......
483 484
	scripts/gnt-instance \
484 485
	scripts/gnt-job \
485 486
	scripts/gnt-node \
486
	scripts/gnt-os
487
	scripts/gnt-os \
488
	scripts/gnt-storage
487 489

  
488 490
PYTHON_BOOTSTRAP_SBIN = \
489 491
	daemons/ganeti-masterd \
b/autotools/build-bash-completion
341 341
          WriteCompReply(sw, "-W \"$(_ganeti_instances)\"", cur=cur)
342 342
        elif suggest == cli.OPT_COMPL_ONE_OS:
343 343
          WriteCompReply(sw, "-W \"$(_ganeti_os)\"", cur=cur)
344
        elif suggest == cli.OPT_COMPL_ONE_EXTSTORAGE:
345
          WriteCompReply(sw, "-W \"$(_ganeti_extstorage)\"", cur=cur)
344 346
        elif suggest == cli.OPT_COMPL_ONE_IALLOCATOR:
345 347
          WriteCompReply(sw, "-W \"$(_ganeti_iallocator)\"", cur=cur)
346 348
        elif suggest == cli.OPT_COMPL_ONE_NODEGROUP:
......
446 448
          choices = "$(_ganeti_jobs)"
447 449
        elif isinstance(arg, cli.ArgOs):
448 450
          choices = "$(_ganeti_os)"
451
        elif isinstance(arg, cli.ArgExtStorage):
452
          choices = "$(_ganeti_extstorage)"
449 453
        elif isinstance(arg, cli.ArgFile):
450 454
          choices = ""
451 455
          compgenargs.append("-f")
b/lib/backend.py
2443 2443
  return result
2444 2444

  
2445 2445

  
2446
def DiagnoseExtStorage(top_dirs=None):
2447
  """Compute the validity for all ExtStorage Providers.
2448

  
2449
  @type top_dirs: list
2450
  @param top_dirs: the list of directories in which to
2451
      search (if not given defaults to
2452
      L{constants.ES_SEARCH_PATH})
2453
  @rtype: list of L{objects.ExtStorage}
2454
  @return: a list of tuples (name, path, status, diagnose, parameters)
2455
      for all (potential) ExtStorage Providers under all
2456
      search paths, where:
2457
          - name is the (potential) ExtStorage Provider
2458
          - path is the full path to the ExtStorage Provider
2459
          - status True/False is the validity of the ExtStorage Provider
2460
          - diagnose is the error message for an invalid ExtStorage Provider,
2461
            otherwise empty
2462
          - parameters is a list of (name, help) parameters, if any
2463

  
2464
  """
2465
  if top_dirs is None:
2466
    top_dirs = constants.ES_SEARCH_PATH
2467

  
2468
  result = []
2469
  for dir_name in top_dirs:
2470
    if os.path.isdir(dir_name):
2471
      try:
2472
        f_names = utils.ListVisibleFiles(dir_name)
2473
      except EnvironmentError, err:
2474
        logging.exception("Can't list the ExtStorage directory %s: %s",
2475
                          dir_name, err)
2476
        break
2477
      for name in f_names:
2478
        es_path = utils.PathJoin(dir_name, name)
2479
        status, es_inst = bdev.ExtStorageFromDisk(name, base_dir=dir_name)
2480
        if status:
2481
          diagnose = ""
2482
          parameters = es_inst.supported_parameters
2483
        else:
2484
          diagnose = es_inst
2485
          parameters = []
2486
        result.append((name, es_path, status, diagnose, parameters))
2487

  
2488
  return result
2489

  
2490

  
2446 2491
def BlockdevGrow(disk, amount, dryrun):
2447 2492
  """Grow a stack of block devices.
2448 2493

  
b/lib/cli.py
248 248
  "ArgJobId",
249 249
  "ArgNode",
250 250
  "ArgOs",
251
  "ArgExtStorage",
251 252
  "ArgSuggest",
252 253
  "ArgUnknown",
253 254
  "OPT_COMPL_INST_ADD_NODES",
......
257 258
  "OPT_COMPL_ONE_NODE",
258 259
  "OPT_COMPL_ONE_NODEGROUP",
259 260
  "OPT_COMPL_ONE_OS",
261
  "OPT_COMPL_ONE_EXTSTORAGE",
260 262
  "cli_option",
261 263
  "SplitNodeOption",
262 264
  "CalculateOSNames",
......
390 392
  """
391 393

  
392 394

  
395
class ArgExtStorage(_Argument):
396
  """ExtStorage argument.
397

  
398
  """
399

  
400

  
393 401
ARGS_NONE = []
394 402
ARGS_MANY_INSTANCES = [ArgInstance()]
395 403
ARGS_MANY_NODES = [ArgNode()]
......
636 644
 OPT_COMPL_ONE_NODE,
637 645
 OPT_COMPL_ONE_INSTANCE,
638 646
 OPT_COMPL_ONE_OS,
647
 OPT_COMPL_ONE_EXTSTORAGE,
639 648
 OPT_COMPL_ONE_IALLOCATOR,
640 649
 OPT_COMPL_INST_ADD_NODES,
641
 OPT_COMPL_ONE_NODEGROUP) = range(100, 107)
650
 OPT_COMPL_ONE_NODEGROUP) = range(100, 108)
642 651

  
643 652
OPT_COMPL_ALL = frozenset([
644 653
  OPT_COMPL_MANY_NODES,
645 654
  OPT_COMPL_ONE_NODE,
646 655
  OPT_COMPL_ONE_INSTANCE,
647 656
  OPT_COMPL_ONE_OS,
657
  OPT_COMPL_ONE_EXTSTORAGE,
648 658
  OPT_COMPL_ONE_IALLOCATOR,
649 659
  OPT_COMPL_INST_ADD_NODES,
650 660
  OPT_COMPL_ONE_NODEGROUP,
b/lib/client/gnt_storage.py
1
#
2
#
3

  
4
# Copyright (C) 2012 Google Inc.
5
#
6
# This program is free software; you can redistribute it and/or modify
7
# it under the terms of the GNU General Public License as published by
8
# the Free Software Foundation; either version 2 of the License, or
9
# (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful, but
12
# WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
14
# General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
19
# 02110-1301, USA.
20

  
21
"""External Storage related commands"""
22

  
23
# pylint: disable=W0401,W0613,W0614,C0103
24
# W0401: Wildcard import ganeti.cli
25
# W0613: Unused argument, since all functions follow the same API
26
# W0614: Unused import %s from wildcard import (since we need cli)
27
# C0103: Invalid name gnt-storage
28

  
29
from ganeti.cli import *
30
from ganeti import constants
31
from ganeti import opcodes
32
from ganeti import utils
33

  
34

  
35
def ShowExtStorageInfo(opts, args):
36
  """List detailed information about ExtStorage providers.
37

  
38
  @param opts: the command line options selected by the user
39
  @type args: list
40
  @param args: empty list or list of ExtStorage providers' names
41
  @rtype: int
42
  @return: the desired exit code
43

  
44
  """
45
  op = opcodes.OpExtStorageDiagnose(output_fields=["name", "nodegroup_status",
46
                                                   "parameters"],
47
                                    names=[])
48

  
49
  result = SubmitOpCode(op, opts=opts)
50

  
51
  if not result:
52
    ToStderr("Can't get the ExtStorage providers list")
53
    return 1
54

  
55
  do_filter = bool(args)
56

  
57
  for (name, nodegroup_data, parameters) in result:
58
    if do_filter:
59
      if name not in args:
60
        continue
61
      else:
62
        args.remove(name)
63

  
64
    nodegroups_valid = []
65
    for nodegroup_name, nodegroup_status in nodegroup_data.iteritems():
66
      if nodegroup_status:
67
        nodegroups_valid.append(nodegroup_name)
68

  
69
    ToStdout("%s:", name)
70

  
71
    if nodegroups_valid != []:
72
      ToStdout("  - Valid for nodegroups:")
73
      for ndgrp in utils.NiceSort(nodegroups_valid):
74
        ToStdout("      %s", ndgrp)
75
      ToStdout("  - Supported parameters:")
76
      for pname, pdesc in parameters:
77
        ToStdout("      %s: %s", pname, pdesc)
78
    else:
79
      ToStdout("  - Invalid for all nodegroups")
80

  
81
    ToStdout("")
82

  
83
  if args:
84
    for name in args:
85
      ToStdout("%s: Not Found", name)
86
      ToStdout("")
87

  
88
  return 0
89

  
90

  
91
def _ExtStorageStatus(status, diagnose):
92
  """Beautifier function for ExtStorage status.
93

  
94
  @type status: boolean
95
  @param status: is the ExtStorage provider valid
96
  @type diagnose: string
97
  @param diagnose: the error message for invalid ExtStorages
98
  @rtype: string
99
  @return: a formatted status
100

  
101
  """
102
  if status:
103
    return "valid"
104
  else:
105
    return "invalid - %s" % diagnose
106

  
107

  
108
def DiagnoseExtStorage(opts, args):
109
  """Analyse all ExtStorage providers.
110

  
111
  @param opts: the command line options selected by the user
112
  @type args: list
113
  @param args: should be an empty list
114
  @rtype: int
115
  @return: the desired exit code
116

  
117
  """
118
  op = opcodes.OpExtStorageDiagnose(output_fields=["name", "node_status",
119
                                                   "nodegroup_status"],
120
                                    names=[])
121

  
122
  result = SubmitOpCode(op, opts=opts)
123

  
124
  if not result:
125
    ToStderr("Can't get the list of ExtStorage providers")
126
    return 1
127

  
128
  for provider_name, node_data, nodegroup_data in result:
129

  
130
    nodes_valid = {}
131
    nodes_bad = {}
132
    nodegroups_valid = {}
133
    nodegroups_bad = {}
134

  
135
    # Per node diagnose
136
    for node_name, node_info in node_data.iteritems():
137
      if node_info: # at least one entry in the per-node list
138
        (fo_path, fo_status, fo_msg, fo_params) = node_info.pop(0)
139
        fo_msg = "%s (path: %s)" % (_ExtStorageStatus(fo_status, fo_msg),
140
                                    fo_path)
141
        if fo_params:
142
          fo_msg += (" [parameters: %s]" %
143
                     utils.CommaJoin([v[0] for v in fo_params]))
144
        else:
145
          fo_msg += " [no parameters]"
146
        if fo_status:
147
          nodes_valid[node_name] = fo_msg
148
        else:
149
          nodes_bad[node_name] = fo_msg
150
      else:
151
        nodes_bad[node_name] = "ExtStorage provider not found"
152

  
153
    # Per nodegroup diagnose
154
    for nodegroup_name, nodegroup_status in nodegroup_data.iteritems():
155
      status = nodegroup_status
156
      if status:
157
        nodegroups_valid[nodegroup_name] = "valid"
158
      else:
159
        nodegroups_bad[nodegroup_name] = "invalid"
160

  
161
    def _OutputPerNodegroupStatus(msg_map):
162
      map_k = utils.NiceSort(msg_map.keys())
163
      for nodegroup in map_k:
164
        ToStdout("  For nodegroup: %s --> %s", nodegroup,
165
                 msg_map[nodegroup])
166

  
167
    def _OutputPerNodeStatus(msg_map):
168
      map_k = utils.NiceSort(msg_map.keys())
169
      for node_name in map_k:
170
        ToStdout("  Node: %s, status: %s", node_name, msg_map[node_name])
171

  
172
    # Print the output
173
    st_msg = "Provider: %s"  % provider_name
174
    ToStdout(st_msg)
175
    ToStdout("---")
176
    _OutputPerNodeStatus(nodes_valid)
177
    _OutputPerNodeStatus(nodes_bad)
178
    ToStdout("  --")
179
    _OutputPerNodegroupStatus(nodegroups_valid)
180
    _OutputPerNodegroupStatus(nodegroups_bad)
181
    ToStdout("")
182

  
183
  return 0
184

  
185

  
186
commands = {
187
  "diagnose": (
188
    DiagnoseExtStorage, ARGS_NONE, [PRIORITY_OPT],
189
    "", "Diagnose all ExtStorage providers"),
190
  "info": (
191
    ShowExtStorageInfo, [ArgOs()], [PRIORITY_OPT],
192
    "", "Show info about ExtStorage providers"),
193
  }
194

  
195

  
196
def Main():
197
  return GenericMain(commands)
b/lib/cmdlib.py
4930 4930
    return self.oq.OldStyleQuery(self)
4931 4931

  
4932 4932

  
4933
class _ExtStorageQuery(_QueryBase):
4934
  FIELDS = query.EXTSTORAGE_FIELDS
4935

  
4936
  def ExpandNames(self, lu):
4937
    # Lock all nodes in shared mode
4938
    # Temporary removal of locks, should be reverted later
4939
    # TODO: reintroduce locks when they are lighter-weight
4940
    lu.needed_locks = {}
4941
    #self.share_locks[locking.LEVEL_NODE] = 1
4942
    #self.needed_locks[locking.LEVEL_NODE] = locking.ALL_SET
4943

  
4944
    # The following variables interact with _QueryBase._GetNames
4945
    if self.names:
4946
      self.wanted = self.names
4947
    else:
4948
      self.wanted = locking.ALL_SET
4949

  
4950
    self.do_locking = self.use_locking
4951

  
4952
  def DeclareLocks(self, lu, level):
4953
    pass
4954

  
4955
  @staticmethod
4956
  def _DiagnoseByProvider(rlist):
4957
    """Remaps a per-node return list into an a per-provider per-node dictionary
4958

  
4959
    @param rlist: a map with node names as keys and ExtStorage objects as values
4960

  
4961
    @rtype: dict
4962
    @return: a dictionary with extstorage providers as keys and as
4963
        value another map, with nodes as keys and tuples of
4964
        (path, status, diagnose, parameters) as values, eg::
4965

  
4966
          {"provider1": {"node1": [(/usr/lib/..., True, "", [])]
4967
                         "node2": [(/srv/..., False, "missing file")]
4968
                         "node3": [(/srv/..., True, "", [])]
4969
          }
4970

  
4971
    """
4972
    all_es = {}
4973
    # we build here the list of nodes that didn't fail the RPC (at RPC
4974
    # level), so that nodes with a non-responding node daemon don't
4975
    # make all OSes invalid
4976
    good_nodes = [node_name for node_name in rlist
4977
                  if not rlist[node_name].fail_msg]
4978
    for node_name, nr in rlist.items():
4979
      if nr.fail_msg or not nr.payload:
4980
        continue
4981
      for (name, path, status, diagnose, params) in nr.payload:
4982
        if name not in all_es:
4983
          # build a list of nodes for this os containing empty lists
4984
          # for each node in node_list
4985
          all_es[name] = {}
4986
          for nname in good_nodes:
4987
            all_es[name][nname] = []
4988
        # convert params from [name, help] to (name, help)
4989
        params = [tuple(v) for v in params]
4990
        all_es[name][node_name].append((path, status, diagnose, params))
4991
    return all_es
4992

  
4993
  def _GetQueryData(self, lu):
4994
    """Computes the list of nodes and their attributes.
4995

  
4996
    """
4997
    # Locking is not used
4998
    assert not (compat.any(lu.glm.is_owned(level)
4999
                           for level in locking.LEVELS
5000
                           if level != locking.LEVEL_CLUSTER) or
5001
                self.do_locking or self.use_locking)
5002

  
5003
    valid_nodes = [node.name
5004
                   for node in lu.cfg.GetAllNodesInfo().values()
5005
                   if not node.offline and node.vm_capable]
5006
    pol = self._DiagnoseByProvider(lu.rpc.call_extstorage_diagnose(valid_nodes))
5007

  
5008
    data = {}
5009

  
5010
    nodegroup_list = lu.cfg.GetNodeGroupList()
5011

  
5012
    for (es_name, es_data) in pol.items():
5013
      # For every provider compute the nodegroup validity.
5014
      # To do this we need to check the validity of each node in es_data
5015
      # and then construct the corresponding nodegroup dict:
5016
      #      { nodegroup1: status
5017
      #        nodegroup2: status
5018
      #      }
5019
      ndgrp_data = {}
5020
      for nodegroup in nodegroup_list:
5021
        ndgrp = lu.cfg.GetNodeGroup(nodegroup)
5022

  
5023
        nodegroup_nodes = ndgrp.members
5024
        nodegroup_name = ndgrp.name
5025
        node_statuses = []
5026

  
5027
        for node in nodegroup_nodes:
5028
          if node in valid_nodes:
5029
            if es_data[node] != []:
5030
              node_status = es_data[node][0][1]
5031
              node_statuses.append(node_status)
5032
            else:
5033
              node_statuses.append(False)
5034

  
5035
        if False in node_statuses:
5036
          ndgrp_data[nodegroup_name] = False
5037
        else:
5038
          ndgrp_data[nodegroup_name] = True
5039

  
5040
      # Compute the provider's parameters
5041
      parameters = set()
5042
      for idx, esl in enumerate(es_data.values()):
5043
        valid = bool(esl and esl[0][1])
5044
        if not valid:
5045
          break
5046

  
5047
        node_params = esl[0][3]
5048
        if idx == 0:
5049
          # First entry
5050
          parameters.update(node_params)
5051
        else:
5052
          # Filter out inconsistent values
5053
          parameters.intersection_update(node_params)
5054

  
5055
      params = list(parameters)
5056

  
5057
      # Now fill all the info for this provider
5058
      info = query.ExtStorageInfo(name=es_name, node_status=es_data,
5059
                                  nodegroup_status=ndgrp_data,
5060
                                  parameters=params)
5061

  
5062
      data[es_name] = info
5063

  
5064
    # Prepare data in requested order
5065
    return [data[name] for name in self._GetNames(lu, pol.keys(), None)
5066
            if name in data]
5067

  
5068

  
5069
class LUExtStorageDiagnose(NoHooksLU):
5070
  """Logical unit for ExtStorage diagnose/query.
5071

  
5072
  """
5073
  REQ_BGL = False
5074

  
5075
  def CheckArguments(self):
5076
    self.eq = _ExtStorageQuery(qlang.MakeSimpleFilter("name", self.op.names),
5077
                               self.op.output_fields, False)
5078

  
5079
  def ExpandNames(self):
5080
    self.eq.ExpandNames(self)
5081

  
5082
  def Exec(self, feedback_fn):
5083
    return self.eq.OldStyleQuery(self)
5084

  
5085

  
4933 5086
class LUNodeRemove(LogicalUnit):
4934 5087
  """Logical unit for removing a node.
4935 5088

  
......
15426 15579
  constants.QR_NODE: _NodeQuery,
15427 15580
  constants.QR_GROUP: _GroupQuery,
15428 15581
  constants.QR_OS: _OsQuery,
15582
  constants.QR_EXTSTORAGE: _ExtStorageQuery,
15429 15583
  constants.QR_EXPORT: _ExportQuery,
15430 15584
  }
15431 15585

  
b/lib/constants.py
1658 1658
QR_OS = "os"
1659 1659
QR_JOB = "job"
1660 1660
QR_EXPORT = "export"
1661
QR_EXTSTORAGE = "extstorage"
1661 1662

  
1662 1663
#: List of resources which can be queried using L{opcodes.OpQuery}
1663 1664
QR_VIA_OP = frozenset([
......
1667 1668
  QR_GROUP,
1668 1669
  QR_OS,
1669 1670
  QR_EXPORT,
1671
  QR_EXTSTORAGE,
1670 1672
  ])
1671 1673

  
1672 1674
#: List of resources which can be queried using Local UniX Interface
b/lib/opcodes.py
1762 1762
  OP_RESULT = _TOldQueryResult
1763 1763

  
1764 1764

  
1765
# ExtStorage opcodes
1766
class OpExtStorageDiagnose(OpCode):
1767
  """Compute the list of external storage providers."""
1768
  OP_PARAMS = [
1769
    _POutputFields,
1770
    ("names", ht.EmptyList, ht.TListOf(ht.TNonEmptyString),
1771
     "Which ExtStorage Provider to diagnose"),
1772
    ]
1773

  
1774

  
1765 1775
# Exports opcodes
1766 1776
class OpBackupQuery(OpCode):
1767 1777
  """Compute the list of exported images."""
b/lib/query.py
2180 2180
  return _PrepareFieldList(fields, [])
2181 2181

  
2182 2182

  
2183
class ExtStorageInfo(objects.ConfigObject):
2184
  __slots__ = [
2185
    "name",
2186
    "node_status",
2187
    "nodegroup_status",
2188
    "parameters",
2189
    ]
2190

  
2191

  
2192
def _BuildExtStorageFields():
2193
  """Builds list of fields for extstorage provider queries.
2194

  
2195
  """
2196
  fields = [
2197
    (_MakeField("name", "Name", QFT_TEXT, "ExtStorage provider name"),
2198
     None, 0, _GetItemAttr("name")),
2199
    (_MakeField("node_status", "NodeStatus", QFT_OTHER,
2200
                "Status from node"),
2201
     None, 0, _GetItemAttr("node_status")),
2202
    (_MakeField("nodegroup_status", "NodegroupStatus", QFT_OTHER,
2203
                "Overall Nodegroup status"),
2204
     None, 0, _GetItemAttr("nodegroup_status")),
2205
    (_MakeField("parameters", "Parameters", QFT_OTHER,
2206
                "ExtStorage provider parameters"),
2207
     None, 0, _GetItemAttr("parameters")),
2208
    ]
2209

  
2210
  return _PrepareFieldList(fields, [])
2211

  
2212

  
2183 2213
def _JobUnavailInner(fn, ctx, (job_id, job)): # pylint: disable=W0613
2184 2214
  """Return L{_FS_UNAVAIL} if job is None.
2185 2215

  
......
2419 2449
#: Fields available for operating system queries
2420 2450
OS_FIELDS = _BuildOsFields()
2421 2451

  
2452
#: Fields available for extstorage provider queries
2453
EXTSTORAGE_FIELDS = _BuildExtStorageFields()
2454

  
2422 2455
#: Fields available for job queries
2423 2456
JOB_FIELDS = _BuildJobFields()
2424 2457

  
......
2433 2466
  constants.QR_LOCK: LOCK_FIELDS,
2434 2467
  constants.QR_GROUP: GROUP_FIELDS,
2435 2468
  constants.QR_OS: OS_FIELDS,
2469
  constants.QR_EXTSTORAGE: EXTSTORAGE_FIELDS,
2436 2470
  constants.QR_JOB: JOB_FIELDS,
2437 2471
  constants.QR_EXPORT: EXPORT_FIELDS,
2438 2472
  }
b/lib/rpc_defs.py
435 435
    ], None, _OsGetPostProc, "Returns an OS definition"),
436 436
  ]
437 437

  
438
_EXTSTORAGE_CALLS = [
439
  ("extstorage_diagnose", MULTI, None, TMO_FAST, [], None, None,
440
   "Request a diagnose of ExtStorage Providers"),
441
  ]
442

  
438 443
_NODE_CALLS = [
439 444
  ("node_has_ip_address", SINGLE, None, TMO_FAST, [
440 445
    ("address", None, "IP address"),
......
503 508
  "RpcClientDefault": \
504 509
    _Prepare(_IMPEXP_CALLS + _X509_CALLS + _OS_CALLS + _NODE_CALLS +
505 510
             _FILE_STORAGE_CALLS + _MISC_CALLS + _INSTANCE_CALLS +
506
             _BLOCKDEV_CALLS + _STORAGE_CALLS),
511
             _BLOCKDEV_CALLS + _STORAGE_CALLS + _EXTSTORAGE_CALLS),
507 512
  "RpcClientJobQueue": _Prepare([
508 513
    ("jobqueue_update", MULTI, None, TMO_URGENT, [
509 514
      ("file_name", None, None),
b/lib/server/noded.py
830 830
    required, name, checks, params = params
831 831
    return backend.ValidateOS(required, name, checks, params)
832 832

  
833
  # extstorage -----------------------
834

  
835
  @staticmethod
836
  def perspective_extstorage_diagnose(params):
837
    """Query detailed information about existing extstorage providers.
838

  
839
    """
840
    return backend.DiagnoseExtStorage()
841

  
833 842
  # hooks -----------------------
834 843

  
835 844
  @staticmethod

Also available in: Unified diff