osc-lib 3.2.0__py3-none-any.whl → 4.0.1__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.
osc_lib/utils/__init__.py CHANGED
@@ -15,25 +15,33 @@
15
15
 
16
16
  """Common client utilities"""
17
17
 
18
+ import argparse
18
19
  import copy
19
20
  import functools
20
21
  import getpass
21
22
  import logging
22
23
  import os
23
24
  import time
25
+ import typing as ty
24
26
  import warnings
25
27
 
26
28
  from cliff import columns as cliff_columns
29
+ from openstack import resource
27
30
  from oslo_utils import importutils
28
31
 
29
32
  from osc_lib import exceptions
30
33
  from osc_lib.i18n import _
31
34
 
32
-
33
35
  LOG = logging.getLogger(__name__)
34
36
 
37
+ _T = ty.TypeVar('_T')
38
+
35
39
 
36
- def backward_compat_col_lister(column_headers, columns, column_map):
40
+ def backward_compat_col_lister(
41
+ column_headers: ty.List[str],
42
+ columns: ty.List[str],
43
+ column_map: ty.Dict[str, str],
44
+ ) -> ty.List[str]:
37
45
  """Convert the column headers to keep column backward compatibility.
38
46
 
39
47
  Replace the new column name of column headers by old name, so that
@@ -65,7 +73,11 @@ def backward_compat_col_lister(column_headers, columns, column_map):
65
73
  return column_headers
66
74
 
67
75
 
68
- def backward_compat_col_showone(show_object, columns, column_map):
76
+ def backward_compat_col_showone(
77
+ show_object: ty.MutableMapping[str, _T],
78
+ columns: ty.List[str],
79
+ column_map: ty.Dict[str, str],
80
+ ) -> ty.MutableMapping[str, _T]:
69
81
  """Convert the output object to keep column backward compatibility.
70
82
 
71
83
  Replace the new column name of output object by old name, so that
@@ -95,7 +107,7 @@ def backward_compat_col_showone(show_object, columns, column_map):
95
107
  return show_object
96
108
 
97
109
 
98
- def build_kwargs_dict(arg_name, value):
110
+ def build_kwargs_dict(arg_name: str, value: _T) -> ty.Dict[str, _T]:
99
111
  """Return a dictionary containing `arg_name` if `value` is set."""
100
112
  kwargs = {}
101
113
  if value:
@@ -103,7 +115,11 @@ def build_kwargs_dict(arg_name, value):
103
115
  return kwargs
104
116
 
105
117
 
106
- def calculate_header_and_attrs(column_headers, attrs, parsed_args):
118
+ def calculate_header_and_attrs(
119
+ column_headers: ty.Sequence[str],
120
+ attrs: ty.Sequence[str],
121
+ parsed_args: argparse.Namespace,
122
+ ) -> ty.Tuple[ty.Sequence[str], ty.Sequence[str]]:
107
123
  """Calculate headers and attribute names based on parsed_args.column.
108
124
 
109
125
  When --column (-c) option is specified, this function calculates
@@ -138,7 +154,7 @@ def calculate_header_and_attrs(column_headers, attrs, parsed_args):
138
154
  return column_headers, attrs
139
155
 
140
156
 
141
- def env(*vars, **kwargs):
157
+ def env(*vars: str, **kwargs: ty.Any) -> ty.Optional[str]:
142
158
  """Search for the first defined of possibly many env vars
143
159
 
144
160
  Returns the first environment variable defined in vars, or
@@ -148,10 +164,18 @@ def env(*vars, **kwargs):
148
164
  value = os.environ.get(v, None)
149
165
  if value:
150
166
  return value
151
- return kwargs.get('default', '')
152
167
 
168
+ if 'default' in kwargs and kwargs['default'] is not None:
169
+ return str(kwargs['default'])
170
+
171
+ return None
153
172
 
154
- def find_min_match(items, sort_attr, **kwargs):
173
+
174
+ def find_min_match(
175
+ items: ty.Sequence[_T],
176
+ sort_attr: str,
177
+ **kwargs: ty.Any,
178
+ ) -> ty.Sequence[_T]:
155
179
  """Find all resources meeting the given minimum constraints
156
180
 
157
181
  :param items: A List of objects to consider
@@ -160,7 +184,7 @@ def find_min_match(items, sort_attr, **kwargs):
160
184
  :rtype: A list of resources osrted by sort_attr that meet the minimums
161
185
  """
162
186
 
163
- def minimum_pieces_of_flair(item):
187
+ def minimum_pieces_of_flair(item: _T) -> bool:
164
188
  """Find lowest value greater than the minumum"""
165
189
 
166
190
  result = True
@@ -169,10 +193,17 @@ def find_min_match(items, sort_attr, **kwargs):
169
193
  result = result and kwargs[k] <= get_field(item, k)
170
194
  return result
171
195
 
172
- return sort_items(filter(minimum_pieces_of_flair, items), sort_attr)
196
+ return sort_items(list(filter(minimum_pieces_of_flair, items)), sort_attr)
173
197
 
174
198
 
175
- def find_resource(manager, name_or_id, **kwargs):
199
+ # TODO(stephenfin): We should return a proper type, but how to do so without
200
+ # using generics? We should also deprecate this but there are a lot of users
201
+ # still.
202
+ def find_resource(
203
+ manager: ty.Any,
204
+ name_or_id: str,
205
+ **kwargs: ty.Any,
206
+ ) -> ty.Any:
176
207
  """Helper for the _find_* methods.
177
208
 
178
209
  :param manager: A client manager class
@@ -200,7 +231,7 @@ def find_resource(manager, name_or_id, **kwargs):
200
231
  # enough information, and domain information is necessary.
201
232
  try:
202
233
  return manager.get(name_or_id)
203
- except Exception:
234
+ except Exception: # noqa: S110
204
235
  pass
205
236
 
206
237
  if kwargs:
@@ -208,7 +239,7 @@ def find_resource(manager, name_or_id, **kwargs):
208
239
  # for example: /projects/demo&domain_id=30524568d64447fbb3fa8b7891c10dd
209
240
  try:
210
241
  return manager.get(name_or_id, **kwargs)
211
- except Exception:
242
+ except Exception: # noqa: S110
212
243
  pass
213
244
 
214
245
  # Case 3: Try to get entity as integer id. Keystone does not have integer
@@ -242,7 +273,7 @@ def find_resource(manager, name_or_id, **kwargs):
242
273
  kwargs[manager.resource_class.NAME_ATTR] = name_or_id
243
274
  else:
244
275
  kwargs['name'] = name_or_id
245
- except Exception:
276
+ except Exception: # noqa: S110
246
277
  pass
247
278
 
248
279
  # finally try to find entity by name
@@ -279,26 +310,25 @@ def find_resource(manager, name_or_id, **kwargs):
279
310
  # Case 5: For client with no find function, list all resources and hope
280
311
  # to find a matching name or ID.
281
312
  count = 0
282
- for resource in manager.list():
283
- if (
284
- resource.get('id') == name_or_id
285
- or resource.get('name') == name_or_id
286
- ):
313
+ for res in manager.list():
314
+ if res.get('id') == name_or_id or res.get('name') == name_or_id:
287
315
  count += 1
288
- _resource = resource
316
+ _res = res
289
317
  if count == 0:
290
318
  # we found no match, report back this error:
291
319
  msg = _("Could not find resource %s")
292
320
  raise exceptions.CommandError(msg % name_or_id)
293
321
  elif count == 1:
294
- return _resource
322
+ return _res
295
323
  else:
296
324
  # we found multiple matches, report back this error
297
325
  msg = _("More than one resource exists with the name or ID '%s'.")
298
326
  raise exceptions.CommandError(msg % name_or_id)
299
327
 
300
328
 
301
- def format_dict(data, prefix=None):
329
+ def format_dict(
330
+ data: ty.Dict[str, ty.Any], prefix: ty.Optional[str] = None
331
+ ) -> str:
302
332
  """Return a formatted string of key value pairs
303
333
 
304
334
  :param data: a dict
@@ -326,11 +356,13 @@ def format_dict(data, prefix=None):
326
356
  return output[:-2]
327
357
 
328
358
 
329
- def format_dict_of_list(data, separator='; '):
359
+ def format_dict_of_list(
360
+ data: ty.Optional[ty.Dict[str, ty.List[ty.Any]]], separator: str = '; '
361
+ ) -> ty.Optional[str]:
330
362
  """Return a formatted string of key value pair
331
363
 
332
364
  :param data: a dict, key is string, value is a list of string, for example:
333
- {u'public': [u'2001:db8::8', u'172.24.4.6']}
365
+ {'public': ['2001:db8::8', '172.24.4.6']}
334
366
  :param separator: the separator to use between key/value pair
335
367
  (default: '; ')
336
368
  :return: a string formatted to {'key1'=['value1', 'value2']} with separated
@@ -351,7 +383,9 @@ def format_dict_of_list(data, separator='; '):
351
383
  return separator.join(output)
352
384
 
353
385
 
354
- def format_list(data, separator=', '):
386
+ def format_list(
387
+ data: ty.Optional[ty.List[ty.Any]], separator: str = ', '
388
+ ) -> ty.Optional[str]:
355
389
  """Return a formatted strings
356
390
 
357
391
  :param data: a list of strings
@@ -364,7 +398,9 @@ def format_list(data, separator=', '):
364
398
  return separator.join(sorted(data))
365
399
 
366
400
 
367
- def format_list_of_dicts(data):
401
+ def format_list_of_dicts(
402
+ data: ty.Optional[ty.List[ty.Dict[str, ty.Any]]],
403
+ ) -> ty.Optional[str]:
368
404
  """Return a formatted string of key value pairs for each dict
369
405
 
370
406
  :param data: a list of dicts
@@ -376,10 +412,10 @@ def format_list_of_dicts(data):
376
412
  return '\n'.join(format_dict(i) for i in data)
377
413
 
378
414
 
379
- def format_size(size):
415
+ def format_size(size: ty.Union[int, float, None]) -> str:
380
416
  """Display size of a resource in a human readable format
381
417
 
382
- :param string size:
418
+ :param size:
383
419
  The size of the resource in bytes.
384
420
 
385
421
  :returns:
@@ -394,19 +430,22 @@ def format_size(size):
394
430
  base = 1000.0
395
431
  index = 0
396
432
 
397
- if size is None:
398
- size = 0
399
- while size >= base:
433
+ size_ = float(size) if size is not None else 0.0
434
+ while size_ >= base:
400
435
  index = index + 1
401
- size = size / base
436
+ size_ = size_ / base
402
437
 
403
- padded = f'{size:.1f}'
438
+ padded = f'{size_:.1f}'
404
439
  stripped = padded.rstrip('0').rstrip('.')
405
440
 
406
441
  return f'{stripped}{suffix[index]}'
407
442
 
408
443
 
409
- def get_client_class(api_name, version, version_map):
444
+ def get_client_class(
445
+ api_name: str,
446
+ version: ty.Union[str, int, float],
447
+ version_map: ty.Dict[str, ty.Type[_T]],
448
+ ) -> ty.Any:
410
449
  """Returns the client class for the requested API version
411
450
 
412
451
  :param api_name: the name of the API, e.g. 'compute', 'image', etc
@@ -414,6 +453,12 @@ def get_client_class(api_name, version, version_map):
414
453
  :param version_map: a dict of client classes keyed by version
415
454
  :rtype: a client class for the requested API version
416
455
  """
456
+ warnings.warn(
457
+ "This function is deprecated and is not necessary with openstacksdk."
458
+ "Consider vendoring this if necessary.",
459
+ category=DeprecationWarning,
460
+ )
461
+
417
462
  try:
418
463
  client_path = version_map[str(version)]
419
464
  except (KeyError, ValueError):
@@ -436,7 +481,14 @@ def get_client_class(api_name, version, version_map):
436
481
  return importutils.import_class(client_path)
437
482
 
438
483
 
439
- def get_dict_properties(item, fields, mixed_case_fields=None, formatters=None):
484
+ def get_dict_properties(
485
+ item: ty.Dict[str, _T],
486
+ fields: ty.Sequence[str],
487
+ mixed_case_fields: ty.Optional[ty.Sequence[str]] = None,
488
+ formatters: ty.Optional[ # type: ignore
489
+ ty.Dict[str, ty.Type[cliff_columns.FormattableColumn]]
490
+ ] = None,
491
+ ) -> ty.Tuple[ty.Any, ...]:
440
492
  """Return a tuple containing the item properties.
441
493
 
442
494
  :param item: a single dict resource
@@ -457,7 +509,7 @@ def get_dict_properties(item, fields, mixed_case_fields=None, formatters=None):
457
509
  field_name = field.replace(' ', '_')
458
510
  else:
459
511
  field_name = field.lower().replace(' ', '_')
460
- data = item[field_name] if field_name in item else ''
512
+ data: ty.Any = item[field_name] if field_name in item else ''
461
513
  if field in formatters:
462
514
  formatter = formatters[field]
463
515
  # columns must be either a subclass of FormattableColumn
@@ -472,16 +524,7 @@ def get_dict_properties(item, fields, mixed_case_fields=None, formatters=None):
472
524
  and issubclass(formatter.func, cliff_columns.FormattableColumn)
473
525
  ):
474
526
  data = formatter(data)
475
- # otherwise it's probably a legacy-style function
476
- elif callable(formatter):
477
- warnings.warn(
478
- 'The usage of formatter functions is now discouraged. '
479
- 'Consider using cliff.columns.FormattableColumn instead. '
480
- 'See reviews linked with bug 1687955 for more detail.',
481
- category=DeprecationWarning,
482
- )
483
- if data is not None:
484
- data = formatter(data)
527
+ # otherwise it's invalid
485
528
  else:
486
529
  msg = "Invalid formatter provided."
487
530
  raise exceptions.CommandError(msg)
@@ -490,31 +533,14 @@ def get_dict_properties(item, fields, mixed_case_fields=None, formatters=None):
490
533
  return tuple(row)
491
534
 
492
535
 
493
- def get_effective_log_level():
494
- """Returns the lowest logging level considered by logging handlers
495
-
496
- Retrieve and return the smallest log level set among the root
497
- logger's handlers (in case of multiple handlers).
498
- """
499
- root_log = logging.getLogger()
500
- min_log_lvl = logging.CRITICAL
501
- for handler in root_log.handlers:
502
- min_log_lvl = min(min_log_lvl, handler.level)
503
- return min_log_lvl
504
-
505
-
506
- def get_field(item, field):
507
- try:
508
- if isinstance(item, dict):
509
- return item[field]
510
- else:
511
- return getattr(item, field)
512
- except Exception:
513
- msg = _("Resource doesn't have field %s")
514
- raise exceptions.CommandError(msg % field)
515
-
516
-
517
- def get_item_properties(item, fields, mixed_case_fields=None, formatters=None):
536
+ def get_item_properties(
537
+ item: ty.Dict[str, _T],
538
+ fields: ty.Sequence[str],
539
+ mixed_case_fields: ty.Optional[ty.Sequence[str]] = None,
540
+ formatters: ty.Optional[ # type: ignore
541
+ ty.Dict[str, ty.Type[cliff_columns.FormattableColumn]]
542
+ ] = None,
543
+ ) -> ty.Tuple[ty.Any, ...]:
518
544
  """Return a tuple containing the item properties.
519
545
 
520
546
  :param item: a single item resource (e.g. Server, Project, etc)
@@ -538,19 +564,19 @@ def get_item_properties(item, fields, mixed_case_fields=None, formatters=None):
538
564
  data = getattr(item, field_name, '')
539
565
  if field in formatters:
540
566
  formatter = formatters[field]
567
+ # columns must be either a subclass of FormattableColumn
541
568
  if isinstance(formatter, type) and issubclass(
542
569
  formatter, cliff_columns.FormattableColumn
543
570
  ):
544
571
  data = formatter(data)
545
- elif callable(formatter):
546
- warnings.warn(
547
- 'The usage of formatter functions is now discouraged. '
548
- 'Consider using cliff.columns.FormattableColumn instead. '
549
- 'See reviews linked with bug 1687955 for more detail.',
550
- category=DeprecationWarning,
551
- )
552
- if data is not None:
553
- data = formatter(data)
572
+ # or a partial wrapping one (to allow us to pass extra parameters)
573
+ elif (
574
+ isinstance(formatter, functools.partial)
575
+ and isinstance(formatter.func, type)
576
+ and issubclass(formatter.func, cliff_columns.FormattableColumn)
577
+ ):
578
+ data = formatter(data)
579
+ # otherwise it's invalid
554
580
  else:
555
581
  msg = "Invalid formatter provided."
556
582
  raise exceptions.CommandError(msg)
@@ -559,7 +585,35 @@ def get_item_properties(item, fields, mixed_case_fields=None, formatters=None):
559
585
  return tuple(row)
560
586
 
561
587
 
562
- def get_password(stdin, prompt=None, confirm=True):
588
+ def get_effective_log_level() -> int:
589
+ """Returns the lowest logging level considered by logging handlers
590
+
591
+ Retrieve and return the smallest log level set among the root
592
+ logger's handlers (in case of multiple handlers).
593
+ """
594
+ root_log = logging.getLogger()
595
+ min_log_lvl = logging.CRITICAL
596
+ for handler in root_log.handlers:
597
+ min_log_lvl = min(min_log_lvl, handler.level)
598
+ return min_log_lvl
599
+
600
+
601
+ def get_field(item: _T, field: str) -> ty.Any:
602
+ try:
603
+ if isinstance(item, dict):
604
+ return item[field]
605
+ else:
606
+ return getattr(item, field)
607
+ except Exception:
608
+ msg = _("Resource doesn't have field %s")
609
+ raise exceptions.CommandError(msg % field)
610
+
611
+
612
+ def get_password(
613
+ stdin: ty.TextIO,
614
+ prompt: ty.Optional[str] = None,
615
+ confirm: bool = True,
616
+ ) -> str:
563
617
  message = prompt or "User Password:"
564
618
  if hasattr(stdin, 'isatty') and stdin.isatty():
565
619
  try:
@@ -579,19 +633,18 @@ def get_password(stdin, prompt=None, confirm=True):
579
633
  raise exceptions.CommandError(msg)
580
634
 
581
635
 
582
- def is_ascii(string):
636
+ def is_ascii(string: ty.Union[str, bytes]) -> bool:
583
637
  try:
584
- (
638
+ if isinstance(string, bytes):
585
639
  string.decode('ascii')
586
- if isinstance(string, bytes)
587
- else string.encode('ascii')
588
- )
640
+ else:
641
+ string.encode('ascii')
589
642
  return True
590
643
  except (UnicodeEncodeError, UnicodeDecodeError):
591
644
  return False
592
645
 
593
646
 
594
- def read_blob_file_contents(blob_file):
647
+ def read_blob_file_contents(blob_file: str) -> str:
595
648
  try:
596
649
  with open(blob_file) as file:
597
650
  blob = file.read().strip()
@@ -601,7 +654,11 @@ def read_blob_file_contents(blob_file):
601
654
  raise exceptions.CommandError(msg % blob_file)
602
655
 
603
656
 
604
- def sort_items(items, sort_str, sort_type=None):
657
+ def sort_items(
658
+ items: ty.Sequence[_T],
659
+ sort_str: str,
660
+ sort_type: ty.Optional[ty.Type[ty.Any]] = None,
661
+ ) -> ty.Sequence[_T]:
605
662
  """Sort items based on sort keys and sort directions given by sort_str.
606
663
 
607
664
  :param items: a list or generator object of items
@@ -640,7 +697,7 @@ def sort_items(items, sort_str, sort_type=None):
640
697
  if direction == 'desc':
641
698
  reverse = True
642
699
 
643
- def f(x):
700
+ def f(x: ty.Any) -> ty.Any:
644
701
  # Attempts to convert items to same 'sort_type' if provided.
645
702
  # This is due to Python 3 throwing TypeError if you attempt to
646
703
  # compare different types
@@ -659,15 +716,15 @@ def sort_items(items, sort_str, sort_type=None):
659
716
 
660
717
 
661
718
  def wait_for_delete(
662
- manager,
663
- res_id,
664
- status_field='status',
665
- error_status=['error'],
666
- exception_name=['NotFound'],
667
- sleep_time=5,
668
- timeout=300,
669
- callback=None,
670
- ):
719
+ manager: ty.Any,
720
+ res_id: str,
721
+ status_field: str = 'status',
722
+ error_status: ty.Sequence[str] = ['error'],
723
+ exception_name: ty.Sequence[str] = ['NotFound'],
724
+ sleep_time: int = 5,
725
+ timeout: int = 300,
726
+ callback: ty.Optional[ty.Callable[[int], None]] = None,
727
+ ) -> bool:
671
728
  """Wait for resource deletion
672
729
 
673
730
  :param manager: the manager from which we can get the resource
@@ -685,6 +742,12 @@ def wait_for_delete(
685
742
  :rtype: True on success, False if the resource has gone to error state or
686
743
  the timeout has been reached
687
744
  """
745
+ warnings.warn(
746
+ "This function is deprecated as it does not support openstacksdk "
747
+ "clients. Consider vendoring this if necessary.",
748
+ category=DeprecationWarning,
749
+ )
750
+
688
751
  total_time = 0
689
752
  while total_time < timeout:
690
753
  try:
@@ -712,14 +775,14 @@ def wait_for_delete(
712
775
 
713
776
 
714
777
  def wait_for_status(
715
- status_f,
716
- res_id,
717
- status_field='status',
718
- success_status=['active'],
719
- error_status=['error'],
720
- sleep_time=5,
721
- callback=None,
722
- ):
778
+ status_f: ty.Callable[[str], object],
779
+ res_id: str,
780
+ status_field: str = 'status',
781
+ success_status: ty.Sequence[str] = ['active'],
782
+ error_status: ty.Sequence[str] = ['error'],
783
+ sleep_time: int = 5,
784
+ callback: ty.Optional[ty.Callable[[int], None]] = None,
785
+ ) -> bool:
723
786
  """Wait for status change on a resource during a long-running operation
724
787
 
725
788
  :param status_f: a status function that takes a single id argument
@@ -731,6 +794,12 @@ def wait_for_status(
731
794
  :param callback: called per sleep cycle, useful to display progress
732
795
  :rtype: True on success
733
796
  """
797
+ warnings.warn(
798
+ "This function is deprecated as it does not support openstacksdk "
799
+ "clients. Consider vendoring this if necessary.",
800
+ category=DeprecationWarning,
801
+ )
802
+
734
803
  while True:
735
804
  res = status_f(res_id)
736
805
  status = getattr(res, status_field, '').lower()
@@ -748,8 +817,10 @@ def wait_for_status(
748
817
 
749
818
 
750
819
  def get_osc_show_columns_for_sdk_resource(
751
- sdk_resource, osc_column_map, invisible_columns=None
752
- ):
820
+ sdk_resource: resource.Resource,
821
+ osc_column_map: ty.Dict[str, str],
822
+ invisible_columns: ty.Optional[ty.Sequence[str]] = None,
823
+ ) -> ty.Tuple[ty.Tuple[str, ...], ty.Tuple[str, ...]]:
753
824
  """Get and filter the display and attribute columns for an SDK resource.
754
825
 
755
826
  Common utility function for preparing the output of an OSC show command.
osc_lib/utils/columns.py CHANGED
@@ -11,13 +11,16 @@
11
11
  # under the License.
12
12
 
13
13
  import operator
14
+ import typing as ty
14
15
 
15
16
  LIST_BOTH = 'both'
16
17
  LIST_SHORT_ONLY = 'short_only'
17
18
  LIST_LONG_ONLY = 'long_only'
18
19
 
19
20
 
20
- def get_column_definitions(attr_map, long_listing):
21
+ def get_column_definitions(
22
+ attr_map: ty.List[ty.Tuple[str, str, str]], long_listing: bool
23
+ ) -> ty.Tuple[ty.List[str], ty.List[str]]:
21
24
  """Return table headers and column names for a listing table.
22
25
 
23
26
  An attribute map (attr_map) is a list of table entry definitions
@@ -73,7 +76,10 @@ def get_column_definitions(attr_map, long_listing):
73
76
  return headers, columns
74
77
 
75
78
 
76
- def get_columns(item, attr_map=None):
79
+ def get_columns(
80
+ item: ty.Dict[str, ty.Any],
81
+ attr_map: ty.Optional[ty.List[ty.Tuple[str, str, str]]] = None,
82
+ ) -> ty.Tuple[ty.Tuple[str, ...], ty.Tuple[str, ...]]:
77
83
  """Return pair of resource attributes and corresponding display names.
78
84
 
79
85
  :param item: a dictionary which represents a resource.
@@ -82,7 +88,12 @@ def get_columns(item, attr_map=None):
82
88
 
83
89
  .. code-block:: python
84
90
 
85
- {'id': 'myid', 'name': 'myname', 'foo': 'bar', 'tenant_id': 'mytenan'}
91
+ {
92
+ 'id': 'myid',
93
+ 'name': 'myname',
94
+ 'foo': 'bar',
95
+ 'tenant_id': 'mytenan',
96
+ }
86
97
 
87
98
  :param attr_map: a list of mapping from attribute to display name.
88
99
  The same format is used as for get_column_definitions attr_map.
@@ -106,7 +117,7 @@ def get_columns(item, attr_map=None):
106
117
  in the alphabetical order.
107
118
  Attributes not found in a given attr_map are kept as-is.
108
119
  """
109
- attr_map = attr_map or tuple([])
120
+ attr_map = attr_map or []
110
121
  _attr_map_dict = dict((col, hdr) for col, hdr, listing_mode in attr_map)
111
122
 
112
123
  columns = [