# License: BSD-3-Clause
"""
`util` module provides utility functions and classes to support various
data manipulation and analysis tasks
"""
import os
import re
import shutil
import warnings
import numpy as np
import pandas as pd
[docs]
class TerminalSize:
"""
A utility class to get the size of the terminal window. This class provides
methods to obtain the terminal size across different platforms, ensuring
compatibility and providing a fallback size when necessary.
Attributes
----------
DEFAULT_SIZE : tuple
A default terminal size to fallback to when the actual terminal size
cannot be determined. The default is (80, 20).
Methods
-------
get_terminal_size()
Get the size of the terminal window as (columns, rows). Attempts multiple
methods to ensure compatibility across different platforms.
_get_terminal_size_windows()
Get the terminal size for Windows platforms using ctypes.
_get_terminal_size_unix()
Get the terminal size for Unix-like platforms using ioctl syscall.
Notes
-----
The `get_terminal_size` method first tries to use `shutil.get_terminal_size`
with a fallback to `DEFAULT_SIZE`. If that fails, it checks the operating
system and attempts to use platform-specific methods. On Windows, it uses
the `ctypes` library to query the terminal size. On Unix-like systems, it
uses the `ioctl` syscall.
Examples
--------
>>> from fusionlab.api.util import TerminalSize
>>> TerminalSize.get_terminal_size()
(80, 25)
See Also
--------
shutil.get_terminal_size : Get the size of the terminal window.
References
----------
.. [1] Python Software Foundation. Python Language Reference, version 3.9.
Available at http://www.python.org
.. [2] Python `shutil` module documentation. Available at
https://docs.python.org/3/library/shutil.html#get_terminal_size
"""
DEFAULT_SIZE = (80, 20)
[docs]
@staticmethod
def get_terminal_size():
"""
Get the size of the terminal window as (columns, rows).
Attempts multiple methods to ensure compatibility across different
platforms.
Returns
-------
tuple
The size of the terminal as (columns, rows).
"""
try:
size = shutil.get_terminal_size(
fallback=TerminalSize.DEFAULT_SIZE
)
return size.columns, size.lines
except Exception:
pass # Ignore and try other methods
if os.name == "nt":
return TerminalSize._get_terminal_size_windows()
elif os.name == "posix":
return TerminalSize._get_terminal_size_unix()
return TerminalSize.DEFAULT_SIZE
[docs]
@staticmethod
def _get_terminal_size_windows():
"""
Get the terminal size for Windows platforms using ctypes.
Returns
-------
tuple
The size of the terminal as (columns, rows).
"""
import struct
from ctypes import create_string_buffer, windll
try:
h = windll.kernel32.GetStdHandle(-12)
csbi = create_string_buffer(22)
res = windll.kernel32.GetConsoleScreenBufferInfo(h, csbi)
if res:
(_, _, _, _, _, left, top, right, bottom, _, _) = (
struct.unpack("hhhhHhhhhhh", csbi.raw)
)
sizex = right - left + 1
sizey = bottom - top + 1
return sizex, sizey
except Exception:
pass # Ignore and fallback to default
return TerminalSize.DEFAULT_SIZE
[docs]
@staticmethod
def _get_terminal_size_unix():
"""
Get the terminal size for Unix-like platforms using ioctl syscall.
Returns
-------
tuple
The size of the terminal as (columns, rows).
"""
import fcntl
import struct
import termios
def ioctl_GWINSZ(fd):
try:
return struct.unpack(
"hh", fcntl.ioctl(fd, termios.TIOCGWINSZ, "1234")
)
except Exception:
return None
size = ioctl_GWINSZ(0) or ioctl_GWINSZ(1) or ioctl_GWINSZ(2)
if not size:
try:
with open(os.ctermid(), "rb") as fd:
size = ioctl_GWINSZ(fd.fileno())
except Exception:
pass # Ignore and fallback to default
if size:
return size[1], size[0]
return TerminalSize.DEFAULT_SIZE
[docs]
def apply_precision(value, precision=4):
"""
Applies precision to a numerical value only if the decimal part is
larger than the specified precision. Otherwise, returns the value
as is.
Parameters
----------
value : int, float, np.integer, np.floating
The numerical value to be formatted.
precision : int, optional
The number of decimal places to format floating-point numbers.
Default is 4.
Returns
-------
int, float
The formatted value if the decimal part is larger than the
specified precision; otherwise, the value as is.
Examples
--------
>>> from fusionlab.api.util import apply_precision
>>> apply_precision(123.456789, 2)
123.46
>>> apply_precision(123.4, 2)
123.4
>>> apply_precision(np.int32(456), 2)
456
>>> apply_precision(np.float64(456.78), 2)
456.78
>>> apply_precision(123, 2)
123
>>> apply_precision(123.00, 2)
123.0
"""
precision = validate_precision(precision)
if isinstance(value, (int, np.integer)):
return int(value)
elif isinstance(value, (float, np.floating)):
rounded_value = round(value, precision)
if value == rounded_value:
return value
return rounded_value
return value
[docs]
def validate_precision(precision, /):
"""
Validates and converts the precision parameter to ensure it is a
non-negative integer.
Parameters
----------
precision : int, float, np.integer, np.floating
The precision value to be validated.
Returns
-------
int
The validated and converted precision value.
Raises
------
ValueError
If the precision is not a non-negative number or cannot be
converted to a non-negative integer.
Examples
--------
>>> validate_precision(3)
3
>>> validate_precision(3.0)
3
>>> validate_precision(np.int32(2))
2
>>> validate_precision(np.float64(2.0))
2
>>> validate_precision(-1)
Traceback (most recent call last):
...
ValueError: Precision must be a non-negative integer.
>>> validate_precision('three')
Traceback (most recent call last):
...
ValueError: Precision must be a non-negative integer.
"""
precision = precision or 4
try:
precision = int(precision)
if precision < 0:
raise ValueError
except (ValueError, TypeError):
raise ValueError("Precision must be a non-negative integer.")
return precision
[docs]
def parse_component_kind(pc_list, kind):
"""
Extracts specific principal component's feature names and their importance
values from a list based on a given component identifier.
Parameters
----------
pc_list : list of tuples
A list where each tuple contains ('pc{i}', feature_names,
sorted_component_values),
corresponding to each principal component. 'pc{i}' is a string label
like 'pc1', 'feature_names' is an array of feature names sorted by
their importance, and 'sorted_component_values' are the corresponding
sorted values of component loadings.
kind : str
A string that identifies the principal component number to extract,
e.g., 'pc1'. The string should contain a numeric part that corresponds
to the component index in `pc_list`.
Returns
-------
tuple
A tuple containing two elements:
- An array of feature names for the specified principal component.
- An array of sorted component values for the specified principal
component.
Raises
------
ValueError
If the `kind` parameter does not contain a valid component number or if the
specified component number is out of the range of available components
in `pc_list`.
Examples
--------
>>> from fusionlab.api.extension import parse_component_kind
>>> pc_list = [
... ('pc1', ['feature1', 'feature2', 'feature3'], [0.8, 0.5, 0.3]),
... ('pc2', ['feature1', 'feature2', 'feature3'], [0.6, 0.4, 0.2])
... ]
>>> feature_names, importances = parse_component_kind(pc_list, 'pc1')
>>> print(feature_names)
['feature1', 'feature2', 'feature3']
>>> print(importances)
[0.8, 0.5, 0.3]
Notes
-----
The function requires that the `kind` parameter include a numeric value
that accurately represents a valid index in `pc_list`. The index is derived
from the numeric part of the `kind` string and is expected to be 1-based.
If no valid index is found or if the index is out of range, the function
raises a `ValueError`.
"""
match = re.search(r"\d+", str(kind))
if match:
# Convert 1-based index from `kind` to 0-based index for list access
index = int(match.group()) - 1
if index < len(pc_list) and index >= 0:
return pc_list[index][1], pc_list[index][2]
else:
raise ValueError(
f"Component index {index + 1} is out of the"
" range of available components."
)
else:
raise ValueError(
"The 'kind' parameter must include an integer"
" indicating the desired principal component."
)
[docs]
def find_maximum_table_width(summary_contents, header_marker="="):
"""
Calculates the maximum width of tables in a summary string based on header lines.
This function parses a multi-table summary string, identifying lines that represent
the top or bottom borders of tables (header lines). It determines the maximum width
of these tables by measuring the length of these header lines. The function assumes
that the header lines consist of repeated instances of a specific marker character.
Parameters
----------
summary_contents : str
A string containing the summarized representation of one or more tables.
This string should include header lines made up of repeated header markers
that denote the start and end of each table's border.
header_marker : str, optional
The character used to construct the header lines in the summary_contents.
Defaults to '=', the common character for denoting table borders in ASCII
table representations.
Returns
-------
int
The maximum width of the tables found in summary_contents, measured as the
length of the longest header line. If no header lines are found, returns 0.
Examples
--------
>>> from fusionlab.api.util import find_maximum_table_width
>>> summary = '''Model Performance
... ===============
... Estimator : SVC
... Accuracy : 0.9500
... Precision : 0.8900
... Recall : 0.9300
... ===============
... Model Performance
... =================
... Estimator : RandomForest
... Accuracy : 0.9500
... Precision : 0.8900
... Recall : 0.9300
... ================='''
>>> find_maximum_table_width(summary)
18
This example shows how the function can be used to find the maximum table width
in a string containing summaries of model performances, where '=' is used as
the header marker.
"""
# Split the input string into lines
lines = summary_contents.split("\n")
# Filter out lines that consist only of the header
# marker, and measure their lengths
header_line_lengths = [
len(line) for line in lines if line.strip(header_marker) == ""
]
# Return the maximum of these lengths, or 0 if the list is empty
return max(header_line_lengths, default=0)
[docs]
def format_text(
text,
key=None,
key_length=15,
max_char_text=50,
add_frame_lines=False,
border_line="=",
buffer_space=4,
):
"""
Formats a block of text to fit within a specified maximum character width,
optionally prefixing it with a key. If the text exceeds the maximum width,
it wraps to a new line, aligning with the key or the specified indentation.
Parameters
----------
text : str
The text to be formatted.
key : str, optional
An optional key to prefix the text. Defaults to None.
key_length : int, optional
The length reserved for the key, including following spaces.
If `key` is provided but `key_length` is None, the length of the
`key` plus one space is used. Defaults to 15.
max_char_text : int, optional
The maximum number of characters for the text width, including the key
if present. Defaults to 50.
add_frame_lines: bool, False
If True, frame the text with '=' line (top and bottom)
border_line: str, optional
The border line to frame the text. Default is '='
buffer_space: int, default=4
The extra space to force breaken the text. Using a large value will
provide nice reading and force terminating the line sentence with no
preprosition like `a`, 'of' or `the` etc.
Returns
-------
str
The formatted text with line breaks added to ensure that no line exceeds
`max_char_text` characters. If a `key` is provided, it is included only
on the first line, with subsequent lines aligned accordingly.
Examples
--------
>>> from fusionlab.api.util import format_text
>>> text_example = ("This is an example text that is supposed to wrap"
"around after a certain number of characters.")
>>> print(format_text(text_example, key="Note"))
Note : This is an example text that is supposed to
wrap around after a certain number of
characters.
Notes
-----
- The function dynamically adjusts the text to fit within `max_char_text`,
taking into account the length of `key` if provided.
- Text that exceeds the `max_char_text` limit is wrapped to new lines, with
proper alignment to match the initial line's formatting.
"""
if key is not None:
# If key_length is None, use the length of the key + 1
# for the space after the key
if key_length is None:
key_length = len(key) # + 1
key_str = f"{key.ljust(key_length)} : "
elif key_length is not None:
# If key is None but key_length is specified, use spaces
key_str = " " * key_length + " : "
else:
# If both key and key_length are None, there's no key part
key_str = ""
# Adjust max_char_text based on the length of the key part: +3 for " : ".
effective_max_char_text = (
max_char_text - len(key_str) + buffer_space
if key_str
else max_char_text
)
formatted_text = ""
text = str(text)
while text:
# If the remaining text is shorter than the effective
# max length, or if there's no key part, add it as is
if len(text) <= effective_max_char_text - buffer_space or not key_str:
formatted_text += key_str + text
break
else:
# Find the space to break the line, ensuring it doesn't
# exceed effective_max_char_text
break_point = text.rfind(
" ", 0, effective_max_char_text - buffer_space
)
if break_point == -1: # No spaces found, force break
break_point = effective_max_char_text - buffer_space
# Add the line to formatted_text
formatted_text += key_str + text[:break_point].rstrip() + "\n"
# Remove the added part from text
text = text[break_point:].lstrip()
# After the first line, the key part is just spaces
key_str = " " * len(key_str)
if add_frame_lines:
frame_lines = (
border_line * max_char_text
) # (effective_max_char_text + 1 )
formatted_text = (
frame_lines + "\n" + formatted_text + "\n" + frame_lines
)
return formatted_text
[docs]
def get_frame_chars(frame_char):
"""
Retrieve framing characters based on the input frame indicator.
Parameters
----------
frame_char : str
A single character that indicates the desired framing style.
Returns
-------
tuple
A tuple containing the close character and the open-close pair
for framing index values.
Examples
--------
>>> from fusionlab.api.util import get_frame_chars
>>> get_frame_chars('[')
(']', '[', ']')
>>> get_frame_chars('{')
('}', '{', '}')
"""
pairs = {
"[": ("]", "[", "]"),
"{": ("}", "{", "}"),
"(": (")", "(", ")"),
"<": (">", "<", ">"),
}
return pairs.get(frame_char, (".", ".", "."))
[docs]
def get_table_width_from(
formatted_str,
/,
border_char="=",
check_first=True,
deep_check=True,
width_strategy="max",
error="warn",
):
"""
Calculate the maximum table width from the given formatted string based on
the border style.
Parameters
----------
formatted_str : str
The string containing the formatted table.
border_char : str, optional
The character used for the table border. Default is '='.
check_first : bool, optional
If True, check the width of the first border line found and return it.
If False, check for additional border lines if the first one is found.
Default is True.
deep_check : bool, optional
If True, evaluate all lines containing the border character to determine
the table width. If False, follow the `check_first` strategy. Default is
True.
width_strategy : {'max', 'min', 'average'}, optional
Strategy to determine the table width if `deep_check` is True. 'max'
returns the longest border line. 'min' returns the shortest border line.
'average' returns the average width of all border lines. Default is 'max'.
error : {'warn', 'raise', 'ignore'}, optional
How to handle cases where no border line is found. 'warn' logs a warning
message. 'raise' raises an exception. 'ignore' does nothing and returns
None. Default is 'warn'.
Returns
-------
int or None
The calculated table width, or None if no border line is found and error
handling is set to 'ignore'.
Notes
-----
This function identifies lines in the `formatted_str` containing the
`border_char` and calculates the width of the table based on the specified
strategies and checks.
The calculation follows these steps:
1. Identify lines in `formatted_str` containing `border_char`.
2. If no such lines are found, handle the error based on `error` parameter.
3. If `deep_check` is False:
- If `check_first` is True, return the length of the first border line.
- Otherwise, return the length of the last border line.
4. If `deep_check` is True:
- Compute the lengths of all border lines.
- If `width_strategy` is 'average', return the average length of border
lines:
.. math::
\text{average\\_width} = \frac{\\sum_{i=1}^{n} \text{length}_i}{n}
- If `width_strategy` is 'min', return the minimum length of border lines.
- If `width_strategy` is 'max', return the maximum length of border lines.
Examples
--------
>>> from fusionlab.api.util import get_table_width_from
>>> formatted_str = "=======\n| Col |\n=======\n"
>>> get_table_width_from(formatted_str)
7
>>> formatted_str = "=====\n| C |\n=====\n"
>>> get_table_width_from(formatted_str, border_char='=')
5
See Also
--------
pandas.DataFrame : DataFrame structure used in pandas for data manipulation
and analysis.
References
----------
.. [1] McKinney, Wes. "Data Structures for Statistical Computing in Python."
Proceedings of the 9th Python in Science Conference. 2010.
"""
border_lines = [
line for line in formatted_str.splitlines() if border_char in line
]
if not border_lines:
if error == "warn":
warnings.warn(
" No border line found in the formatted string.", stacklevel=2
)
elif error == "raise":
raise ValueError(
"Error: No border line found in the formatted string."
)
return None
if not deep_check:
return len(border_lines[0]) if check_first else len(border_lines[-1])
line_lengths = [len(line) for line in border_lines]
if width_strategy == "average":
return sum(line_lengths) // len(line_lengths)
elif width_strategy == "min":
return min(line_lengths)
else: # width_strategy == 'max'
return max(line_lengths)
[docs]
def generate_legend(
custom_markers=None,
no_corr_placeholder="...",
hide_diag=True,
max_width=50,
add_frame_lines=True,
border_line=".",
):
"""
Generates a legend for a table (dataframe) matrix visualization, formatted
according to specified parameters.
This function supports both numeric and symbolic representations of table
values. Symbolic representations, which are used primarily for visual clarity,
include the following symbols:
- ``'++'``: Represents a strong positive relationship.
- ``'--'``: Represents a strong negative relationship.
- ``'-+'``: Represents a moderate relationship.
- ``'o'``: Used exclusively for diagonal elements, typically representing
a perfect relationship in correlation matrices (value of 1.0).
Parameters
----------
custom_markers : dict, optional
A dictionary mapping table symbols to their descriptions. If provided,
it overrides the default markers. Default is None.
no_corr_placeholder : str, optional
Placeholder text for table values that do not meet the minimum threshold.
Default is '...'.
hide_diag : bool, optional
If True, omits the diagonal entries from the legend. These are typically
frame of a variable with itself (1.0). Default is True.
max_width : int, optional
The maximum width of the formatted legend text, influences the centering
of the title. Default is 50.
add_frame_lines : bool, optional
If True, adds a frame around the legend using the specified `border_line`.
Default is True.
border_line : str, optional
The character used to create the border of the frame if `add_frame_lines`
is True. Default is '.'.
Returns
-------
str
The formatted legend text, potentially framed, centered according to the
specified width, and including custom or default descriptions of correlation
values.
Examples
--------
>>> from fusionlab.api.util import generate_legend
>>> custom_markers = {"++": "High Positive", "--": "High Negative"}
>>> print(generate_legend(custom_markers=custom_markers, max_width=60))
............................................................
Legend : ...: Non-correlated, ++: High Positive, --: High
Negative, -+: Moderate
............................................................
>>> print(generate_legend(hide_diag=False, max_width=70))
......................................................................
Legend : ...: Non-correlated, ++: Strong positive, --: Strong negative,
-+: Moderate, o: Diagonal
......................................................................
>>> custom_markers = {"++": "Highly positive", "--": "Highly negative"}
>>> legend = generate_legend(custom_markers=custom_markers,
... no_corr_placeholder='N/A', hide_diag=False,
... border_line ='=')
>>> print(legend)
==================================================
Legend : N/A: Non-correlated, ++: Highly positive,
--: Highly negative, -+: Moderate, o:
Diagonal
==================================================
"""
# Default markers and their descriptions
default_markers = {
no_corr_placeholder: "Non-correlated",
"++": "Strong positive",
"--": "Strong negative",
"-+": "Moderate",
"o": "Diagonal", # only used if hide_diag is False
}
if custom_markers is not None and not isinstance(custom_markers, dict):
raise TypeError(
"The 'custom_markers' parameter must be a dictionary."
f" Received type: {type(custom_markers).__name__}. Please provide a dictionary"
" where keys are the legend symbols and values"
" are their descriptions."
)
# Update default markers with any custom markers provided
markers = {**default_markers, **(custom_markers or {})}
# If no correlation placeholder, then remove it from the markers.
if not no_corr_placeholder:
markers.pop(no_corr_placeholder)
# Create legend entries
legend_entries = [
f"{key}: {value}"
for key, value in markers.items()
if not (key == "o" and hide_diag)
]
# Join entries with commas and format the legend text
legend_text = ", ".join(legend_entries)
legend = "\n\n" + format_text(
legend_text,
key="Legend",
key_length=len("Legend"),
max_char_text=max_width, # + len('Legend'),
add_frame_lines=add_frame_lines,
border_line=border_line,
)
return legend
[docs]
def to_snake_case(name, mode="standard"):
"""
Converts a string to snake_case.
Parameters
----------
name : str
The string to convert to snake_case.
mode : str, optional
If 'soft', extra whitespace and case inconsistencies are
handled to produce clean snake_case.
Returns
-------
str
The snake_case version of the input string.
"""
name = str(name)
if mode == "soft":
# Convert to lowercase and replace multiple spaces
# or non-word characters with a single underscore
name = re.sub(
r"\W+", " ", name
) # Replace non-word characters with spaces
name = re.sub(r"\s+", " ", name).strip() # Normalize whitespace
name = name.lower().replace(" ", "_") # Convert spaces to underscores
else:
# Standard snake_case conversion without additional processing
name = re.sub(
r"(?<!^)(?=[A-Z])", "_", name
).lower() # Convert CamelCase to snake_case
name = re.sub(
r"\W+", "_", name
) # Replace non-word characters with '_'
name = re.sub(
r"_+", "_", name
) # Replace multiple underscores with a single '_'
return name.strip("_") # Remove any leading or trailing underscores
[docs]
def generate_column_name_mapping(columns):
"""
Generates a mapping from snake_case column names to their original names.
Parameters
----------
columns : List[str]
The list of column names to convert and map.
Returns
-------
dict
A dictionary mapping snake_case column names to their original names.
"""
return {to_snake_case(col): col for col in columns}
[docs]
def series_to_dataframe(series):
"""
Transforms a pandas Series into a DataFrame where the columns are the index
of the Series. If the Series' index is numeric, the index values are converted
to strings and used as column names.
Parameters
----------
series : pandas.Series
The Series to be transformed into a DataFrame.
Returns
-------
pandas.DataFrame
A DataFrame where each column represents a value from the Series,
with column names corresponding to the Series' index values.
Raises
------
TypeError
If the input is not a pandas Series.
Examples
--------
>>> import pandas as pd
>>> from fusionlab.api.util import series_to_dataframe
>>> series = pd.Series(data=[1, 2, 3], index=['a', 'b', 'c'])
>>> df = series_to_dataframe(series)
>>> print(df)
a b c
0 1 2 3
>>> series_numeric_index = pd.Series(data=[4, 5, 6], index=[10, 20, 30])
>>> df_numeric = series_to_dataframe(series_numeric_index)
>>> print(df_numeric)
10 20 30
0 4 5 6
"""
if not isinstance(series, pd.Series):
raise TypeError("Input must be a pandas Series.")
# Convert index to string if it's numeric
if (
series.index.dtype.kind in "iufc"
): # Checks for int, unsigned int, float, complex
index_as_str = series.index.astype(str)
else:
index_as_str = series.index
# Create a DataFrame with a single row populated with the Series' values
# and columns named after the Series' index.
df = pd.DataFrame([series.values], columns=index_as_str)
return df
[docs]
def get_table_size(width="auto", error="warn", return_height=False):
"""
Determines the appropriate width (and optionally height) for table display
based on terminal size, with options for manual width adjustment.
Parameters
----------
width : int or str, optional
The desired width for the table. If set to 'auto', the terminal width
is used. If an integer is provided, it will be used as the width,
default is 'auto'.
error : str, optional
Error handling strategy when specified width exceeds terminal
width: 'warn' or 'ignore'.
Default is 'warn'.
return_height : bool, optional
If True, the function also returns the height of the table.
Default is False.
Returns
-------
int or tuple
The width of the table as an integer, or a tuple of (width, height)
if return_height is True.
Examples
--------
>>> table_width = get_table_size()
>>> print("Table width:", table_width)
>>> table_width, table_height = get_table_size(return_height=True)
>>> print("Table width:", table_width, "Table height:", table_height)
"""
auto_width, auto_height = get_terminal_size()
if width == "auto":
width = auto_width
else:
try:
width = int(width)
if width > auto_width:
if error == "warn":
warnings.warn(
f"Specified width {width} exceeds terminal width {auto_width}. "
"This may cause display issues.",
stacklevel=2,
)
except ValueError:
raise ValueError(
"Width must be 'auto' or an integer; got {type(width).__name__!r}"
)
if return_height:
return (width, auto_height)
return width
[docs]
def get_terminal_size():
"""
Retrieves the current terminal size (width and height) to help dynamically
set the maximum width for displaying data columns.
Returns
-------
tuple
A tuple containing two integers:
- The width of the terminal in characters.
- The height of the terminal in lines.
Examples
--------
>>> from fusionlab.api.util import get_terminal_size
>>> terminal_width, terminal_height = get_terminal_size()
>>> print("Terminal Width:", terminal_width)
>>> print("Terminal Height:", terminal_height)
"""
# Use shutil.get_terminal_size if available (Python 3.3+)
# This provides a fallback of (80, 24) which is a common default size
if hasattr(shutil, "get_terminal_size"):
size = shutil.get_terminal_size(fallback=(80, 24))
else:
# Fallback for Python versions before 3.3
try:
# UNIX-based systems
size = os.popen("stty size", "r").read().split()
return int(size[1]), int(size[0])
except Exception:
# Default fallback size
size = (80, 24)
return size.columns, size.lines
[docs]
def to_camel_case(text, delimiter=None, use_regex=False):
"""
Converts a given string to CamelCase. The function handles strings with or
without delimiters, and can optionally use regex for splitting based on
non-alphanumeric characters.
Parameters
----------
text : str
The string to convert to CamelCase.
delimiter : str, optional
A character or string used as a delimiter to split the input string.
Common delimiters include underscores ('_') or spaces (' '). If None
and use_regex is ``False``, the function tries to automatically detect
common delimiters like spaces or underscores.
If `use_regex` is ``True``, it splits the string at any non-alphabetic
character.
use_regex : bool, optional
Specifies whether to use regex for splitting the string on non-alphabetic
characters.
Defaults to ``False``.
Returns
-------
str
The CamelCase version of the input string.
Examples
--------
>>> from fusionlab.api.util import to_camel_case
>>> to_camel_case('outlier_results', '_')
'OutlierResults'
>>> to_camel_case('outlier results', ' ')
'OutlierResults'
>>> to_camel_case('outlierresults')
'Outlierresults'
>>> to_camel_case('data science rocks')
'DataScienceRocks'
>>> to_camel_case('data_science_rocks')
'DataScienceRocks'
>>> to_camel_case('multi@var_analysis', use_regex=True)
'MultiVarAnalysis'
>>> to_camel_case('OutlierResults')
'OutlierResults'
>>> to_camel_case('BoxFormatter')
'BoxFormatter'
>>> to_camel_case('MultiFrameFormatter')
'MultiFrameFormatter'
"""
# Remove any leading/trailing whitespace
text = str(text).strip()
# Check if text is already in CamelCase and return it as is
if text and text[0].isupper() and not text[1:].islower():
return text
if use_regex:
# Split text using any non-alphabetic character as a delimiter
words = re.split("[^a-zA-Z]", text)
elif delimiter is None:
if " " in text and "_" in text:
# Both space and underscore are present, replace '_' with ' ' then split
text = text.replace("_", " ")
words = text.split()
elif " " in text:
words = text.split(" ")
elif "_" in text:
words = text.split("_")
else:
# No common delimiter found, handle as a single word
words = [text]
else:
# Use the specified delimiter
words = text.split(delimiter)
# Capitalize the first letter of each word and join them without spaces
# Ensure empty strings from split are ignored
return "".join(word.capitalize() for word in words if word)
[docs]
def beautify_dict(d, space=4, key=None, max_char=None):
"""
Format a dictionary with custom indentation and aligned keys and values.
Parameters:
----------
d : dict
The dictionary to format.
space : int, optional
The number of spaces to indent the dictionary entries.
key : str, optional
An optional key to nest the dictionary under.
max_char : int, optional
Maximum characters a value can have before being truncated.
Returns:
-------
str
A string representation of the dictionary with custom formatting.
Examples:
--------
>>> from fusionlab.api.util import beautify_dict
>>> dictionary = {
... 3: 'Home & Garden',
... 2: 'Health & Beauty',
... 4: 'Sports',
... 0: 'Electronics',
... 1: 'Fashion'
... }
>>> print(beautify_dict(dictionary, space=4))
"""
if not isinstance(d, dict):
raise TypeError(
"Expected input to be a 'dict',"
f" received '{type(d).__name__}' instead."
)
if max_char is None:
# get it automatically
max_char, _ = get_terminal_size()
# Determine the longest key for alignment
if len(d) == 0:
max_key_length = 0
else:
max_key_length = max(len(str(k)) for k in d.keys())
# Create a list of formatted rows
formatted_rows = []
for dkey, value in sorted(d.items()):
value_str = str(value)
if max_char is not None and len(value_str) > max_char:
value_str = value_str[:max_char] + "..."
# Ensure all keys are right-aligned to the longest key length
formatted_row = f"{str(dkey):>{max_key_length}}: '{value_str}'"
formatted_rows.append(formatted_row)
# Join all rows into a single string with custom indentation
indent = " " * space
inner_join = ",\n" + indent
formatted_dict = "{\n" + indent + inner_join.join(formatted_rows) + "\n}"
if key:
# Prepare outer key indentation and format
# Slightly less than the main indent
outer_indent = " " * (space - 2 + len(key) + max_key_length)
# Construct a new header with the key
formatted_dict = f"{key} : {formatted_dict}"
# Split lines and indent properly to align with the key
lines = formatted_dict.split("\n")
for i in range(1, len(lines)):
lines[i] = outer_indent + lines[i]
if max_char is not None and len(lines[i]) > max_char:
lines[i] = lines[i][:max_char] + "..."
# format lins -1
# lines [-1] = outer_indent + lines [-1]
formatted_dict = "\n".join(lines)
return formatted_dict
[docs]
def count_functions(
module_name,
include_class=False,
return_counts=True,
include_private=False,
include_local=False,
):
"""
Count and list the number of functions and classes in a specified module.
Parameters
----------
module_name : str
The name of the module to inspect, in the format `package.module`.
include_class : bool, optional
Whether to include classes in the count and listing. Default is
`False`.
return_counts : bool, optional
Whether to return only the count of functions and classes (if
``include_class`` is `True`). If `False`, returns a list of functions
and classes in alphabetical order. Default is `True`.
include_private : bool, optional
Whether to include private functions and classes (those starting with
`_`). Default is `False`.
include_local : bool, optional
Whether to include local (nested) functions in the count and listing.
Default is `False`.
Returns
-------
int or list
If ``return_counts`` is `True`, returns the count of functions and
classes (if ``include_class`` is `True`). If ``return_counts`` is
`False`, returns a list of function and class names (if
``include_class`` is `True`) in alphabetical order.
Notes
-----
This function dynamically imports the specified module and analyzes its
Abstract Syntax Tree (AST) to count and list functions and classes. It
provides flexibility to include or exclude private and local functions
based on the parameters provided.
The process can be summarized as:
.. math::
\text{total\\_count} =
\text{len(functions)} + \text{len(classes)}
where:
- :math:`\text{functions}` is the list of functions found in the module.
- :math:`\text{classes}` is the list of classes found in the module
(if ``include_class`` is `True`).
Examples
--------
>>> from fusionlab.api.util import count_functions_classes
>>> count_functions_classes('fusionlab.api.util', include_class=True,
return_counts=True)
10
>>> count_functions('fusionlab.api.util', include_class=True,
return_counts=False)
['ClassA', 'ClassB', 'func1', 'func2', 'func3']
>>> count_functions('fusionlab.api.util', include_class=False,
return_counts=True, include_private=True)
15
>>> count_functions('fusionlab.api.util', include_class=False,
return_counts=False, include_private=True)
['_private_func1', '_private_func2', 'func1', 'func2']
See Also
--------
ast : Abstract Syntax Tree (AST) module for parsing Python source code.
References
----------
.. [1] Python Software Foundation. Python Language Reference, version 3.9.
Available at http://www.python.org
.. [2] Python `ast` module documentation. Available at
https://docs.python.org/3/library/ast.html
"""
try:
import ast
except ImportError as e: # Catch the specific ImportError exception
raise ImportError(
"The 'ast' module could not be imported. This module is essential"
" for analyzing Python source code to count functions and classes."
" Ensure that you are using a standard Python distribution, which"
" includes the 'ast' module by default."
) from e
import importlib
import inspect
# Import the module dynamically
module = importlib.import_module(module_name)
# Get the source code of the module
source = inspect.getsource(module)
# Parse the source code into an AST
tree = ast.parse(source)
# Initialize lists to store function and class names
functions = []
classes = []
def is_local_function(node):
"""Determine if the function is local (nested)."""
while node:
if isinstance(node, ast.FunctionDef):
return True
node = getattr(node, "parent", None)
return False
# Add parent references to each node
for node in ast.walk(tree):
for child in ast.iter_child_nodes(node):
child.parent = node
# Traverse the AST to find function and class definitions
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef):
if (include_private or not node.name.startswith("_")) and (
include_local or not is_local_function(node.parent)
):
functions.append(node.name)
elif isinstance(node, ast.ClassDef) and include_class:
if include_private or not node.name.startswith("_"):
classes.append(node.name)
# Combine and sort the lists if needed
if include_class:
result = sorted(functions + classes)
else:
result = sorted(functions)
if return_counts:
return len(result)
else:
return result
[docs]
def round_numeric_values(df, precision=4):
"""
Rounds numeric floating values in a DataFrame to the specified
precision. Integers and non-numeric values are not modified.
Parameters
----------
df : pandas.DataFrame
The DataFrame whose numeric floating values are to be rounded.
The DataFrame can contain mixed data types, including integers,
floating-point numbers, and non-numeric values.
precision : int, optional
The number of decimal places to round to. Defaults to 4.
- If `precision` is set to a positive integer, it specifies
the number of decimal places.
- If `precision` is set to a negative integer, it specifies
the number of positions to the left of the decimal point.
Returns
-------
pandas.DataFrame
A DataFrame with numeric floating values rounded to the
specified precision. Integers and non-numeric values remain
unchanged.
Raises
------
ValueError
If `precision` is not an integer.
Notes
-----
This function applies rounding only to floating-point numbers.
Integers and non-numeric values remain unchanged. The function
uses pandas' `applymap` to apply the rounding operation element-wise
across the DataFrame.
The mathematical formulation for the rounding operation is given by:
.. math::
y =
\begin{cases}
\text{round}(x, \text{precision}) & \text{if } x \\in \\mathbb{R} \\
x & \text{otherwise} \\
\\end{cases}
Where:
- :math:`x` is the input value
- :math:`y` is the output value after rounding
- :math:`\text{precision}` is the specified number of decimal places
Examples
--------
>>> from fusionlab.api.util import round_numeric_values
>>> import pandas as pd
>>> df = pd.DataFrame({
... 'A': [1.12345, 2.6789, 3],
... 'B': [4, 5.98765, 'text'],
... 'C': [7.123456, 8.654321, 9.0]
... })
>>> round_numeric_values(df, precision=2)
A B C
0 1.12 4 7.12
1 2.68 5.99 8.65
2 3 text 9
See Also
--------
pandas.DataFrame.applymap : Element-wise operation on DataFrame.
References
----------
.. [1] "NumPy Documentation", https://numpy.org/doc/stable/
"""
def round_if_float(x):
if isinstance(x, float):
return round(x, precision)
return x
return df.applymap(round_if_float)