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