|
1 | 1 | from __future__ import annotations
|
2 | 2 |
|
3 | 3 | import datetime
|
| 4 | +import importlib.util |
4 | 5 | import itertools
|
5 | 6 | import logging
|
| 7 | +import threading |
| 8 | +import typing |
6 | 9 | from dataclasses import dataclass
|
7 | 10 | from decimal import Decimal
|
8 | 11 | from typing import Iterable, Iterator, List, Optional, Sequence, Tuple, Type
|
|
15 | 18 | from metricflow.data_table.column_types import CellValue, InputCellValue, row_cell_types
|
16 | 19 | from metricflow.data_table.mf_column import ColumnDescription
|
17 | 20 |
|
| 21 | +if typing.TYPE_CHECKING: |
| 22 | + from mypy.moduleinspect import ModuleType |
| 23 | + |
| 24 | + |
18 | 25 | logger = logging.getLogger(__name__)
|
19 | 26 |
|
20 | 27 |
|
@@ -157,16 +164,12 @@ def text_format(self, float_decimals: Optional[int] = None) -> str:
|
157 | 164 | column_alignment.append("decimal")
|
158 | 165 | else:
|
159 | 166 | column_alignment.append("left")
|
160 |
| - |
161 | 167 | headers = tuple(column_description.column_name for column_description in self.column_descriptions)
|
162 | 168 |
|
163 |
| - return tabulate.tabulate( |
| 169 | + return _IsolatedTabulateRunner.tabulate( |
164 | 170 | tabular_data=str_rows,
|
165 | 171 | headers=headers,
|
166 |
| - # `tabulate.tabulate` can detect numeric values and apply special formatting rules. This can |
167 |
| - # result in unexpected values when coupled with the `--decimals` option, so disabling this feature. |
168 |
| - disable_numparse=True, |
169 |
| - colalign=column_alignment, |
| 172 | + column_alignment=column_alignment, |
170 | 173 | )
|
171 | 174 |
|
172 | 175 | def with_lower_case_column_names(self) -> MetricFlowDataTable:
|
@@ -291,3 +294,66 @@ def add_row(self, row: Sequence[InputCellValue], parse_strings: bool = False) ->
|
291 | 294 |
|
292 | 295 | def build(self) -> MetricFlowDataTable: # noqa: D102
|
293 | 296 | return self._build_table_from_rows()
|
| 297 | + |
| 298 | + |
| 299 | +class _IsolatedTabulateRunner: |
| 300 | + """Helps run `tabulate` with different module options. |
| 301 | +
|
| 302 | + The `tabulate.tabulate` method uses some options defined in the module instead of being provided as arguments to |
| 303 | + the function. This runner is used to change those options in isolation by loading a copy of the `tabulate` module. |
| 304 | + This helps to ensure that other calls to `tabulate.tabulate` don't see unexpected results. |
| 305 | + """ |
| 306 | + |
| 307 | + _TABULATE_MODULE_COPY: Optional[ModuleType] = None |
| 308 | + _STATE_LOCK = threading.Lock() |
| 309 | + |
| 310 | + @classmethod |
| 311 | + def tabulate( |
| 312 | + cls, |
| 313 | + tabular_data: Sequence[Sequence[CellValue]], |
| 314 | + headers: Sequence[str], |
| 315 | + column_alignment: Optional[Sequence[str]], |
| 316 | + ) -> str: |
| 317 | + """Produce a text table from the given data. Also see class docstring.""" |
| 318 | + with _IsolatedTabulateRunner._STATE_LOCK: |
| 319 | + try: |
| 320 | + if _IsolatedTabulateRunner._TABULATE_MODULE_COPY is None: |
| 321 | + tabulate_module_spec = importlib.util.find_spec("tabulate") |
| 322 | + if tabulate_module_spec is None: |
| 323 | + raise RuntimeError("Unable to find spec for `tabulate`.") |
| 324 | + tabulate_module_copy = importlib.util.module_from_spec(tabulate_module_spec) |
| 325 | + if tabulate_module_copy is None: |
| 326 | + raise RuntimeError(f"Unable to load module using {tabulate_module_spec=}") |
| 327 | + if tabulate_module_spec.loader is None: |
| 328 | + raise RuntimeError(f"Loader missing for {tabulate_module_spec=}") |
| 329 | + tabulate_module_spec.loader.exec_module(tabulate_module_copy) |
| 330 | + tabulate_module_copy.PRESERVE_WHITESPACE = True # type: ignore[attr-defined] |
| 331 | + _IsolatedTabulateRunner._TABULATE_MODULE_COPY = tabulate_module_copy |
| 332 | + except Exception: |
| 333 | + logger.exception( |
| 334 | + "Failed to load a copy of the `tabulate` module. This means that some table-formatting options " |
| 335 | + "can't be applied. This is a bug and should be investigated." |
| 336 | + ) |
| 337 | + |
| 338 | + # `tabulate.tabulate` can detect numeric values and apply special formatting rules. This can |
| 339 | + # result in unexpected values when coupled with the `--decimals` option, so disabling that feature. |
| 340 | + disable_numparse = True |
| 341 | + |
| 342 | + if _IsolatedTabulateRunner._TABULATE_MODULE_COPY is None: |
| 343 | + logger.warning( |
| 344 | + "Generating text table without required options set as there was an error loading the " |
| 345 | + "`tabulate` module." |
| 346 | + ) |
| 347 | + return tabulate.tabulate( |
| 348 | + tabular_data=tabular_data, |
| 349 | + headers=headers, |
| 350 | + disable_numparse=disable_numparse, |
| 351 | + colalign=column_alignment, |
| 352 | + ) |
| 353 | + |
| 354 | + return _IsolatedTabulateRunner._TABULATE_MODULE_COPY.tabulate( |
| 355 | + tabular_data=tabular_data, |
| 356 | + headers=headers, |
| 357 | + disable_numparse=disable_numparse, |
| 358 | + colalign=column_alignment, |
| 359 | + ) |
0 commit comments