diff --git a/.gitignore b/.gitignore index 108754e..ede8b3d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ __pycache__/ *.so .idea/* tests/tables_files/* +tests/quarto_doc/* +tests/*.quarto_ipynb tests/.* # Distribution / packaging .Python diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..a46a99a --- /dev/null +++ b/README.rst @@ -0,0 +1,36 @@ +.. image:: https://img.shields.io/readthedocs/greater_tables_project + :alt: Read the Docs + +Release Notes +=============== + +1.0.0 +------ + +* Allow input via list of lists, or markdown table +* Specify overall float format for whole table +* Specify column alingment with 'llrc' style string +* ``show_index`` option +* Added more tests +* Docs updated +* Set tabs for width; use of width in HTML format. + + +0.6.0 +------ + +* Initial release + +Early development +------------------- + +* 0.1.0 - 0.5.0: Early development +* tikz code from great.pres_manager + +TODO +===== + +* Index aligners + + +https://shields.io/badges/read-the-docs \ No newline at end of file diff --git a/greater_tables/__init__.py b/greater_tables/__init__.py index 4b8d053..c125f64 100644 --- a/greater_tables/__init__.py +++ b/greater_tables/__init__.py @@ -1,4 +1,4 @@ -__version__ = '0.6.0' +__version__ = '1.0.0' __project__ = 'greater_tables' __author__ = 'Stephen J Mildenhall' diff --git a/greater_tables/greater_tables.py b/greater_tables/greater_tables.py index 1e164e4..604ac5a 100644 --- a/greater_tables/greater_tables.py +++ b/greater_tables/greater_tables.py @@ -49,10 +49,12 @@ class GT(object): aligners=None, ratio_cols=None, year_cols=None, + show_index=True, default_integer_str='{x:,d}', default_float_str='{x:,.3f}', default_date_str='%Y-%m-%d', default_ratio_str='{x:.1%}', + table_float_format=None, table_hrule_width=1, table_vrule_width=1, hrule_widths=None, @@ -69,6 +71,9 @@ class GT(object): pef_lower=-3, pef_upper=6, cast_to_floats=True, + header_row=True, + tabs=None, + equal=False, debug=False): """ Create a greater_tables formatting object. @@ -79,15 +84,17 @@ class GT(object): Recommended usage is to derive from GT and set defaults suitable to your particular application. In that way you can maintain a "house-style" - :param df: target DataFrame + :param df: target DataFrame or list of lists or markdown table string :param caption: table caption, optional :param aligners: None or dict (type or colname) -> left | center | right :param ratio_cols: None, or "all" or list of column names treated as ratios. Set defaults in derived class suitable to application. :param year_cols: None, or "all" or list of column names treated as years (no commas, no decimals). Set defaults in derived class suitable to application. + :param show_index: if True, show the index columns, default True :param default_integer_str: format f-string for integers, default '{x:,d}' :param default_float_str: format f-string for floats, default '{x:,.3f}' :param default_date_str: format f-string for dates, default '%Y-%m-%d'. NOTE: no braces or x! :param default_ratio_str: format f-string for ratios, default '{x:.1%}' + :param table_float_format: None or format string for floats in the table format function, applied to entire table, default None :param cast_to_floats: if True, try to cast all non-integer, non-date columns to floats :param table_hrule_width: width of the table top, botton and header hrule, default 1 :param table_vrule_width: width of the table vrule, separating the index from the body, default 1 @@ -104,8 +111,32 @@ class GT(object): :param pef_precision: precision (digits after period) for pandas engineering format, default 3. :param pef_lower: apply engineering format to floats with absolute value < 10**pef_lower; default -3. :param pef_upper: apply engineering format to floats with absolute value > 10**pef_upper; default 6. + :param header_row: True: use first row as headers; False no headings. Default True + :param tabs: None or list of column widths in characters or a common int or float width. (It is converted into em; one character is about 0.5em on average; digits are exactly 0.5em.) If None, will be calculated. Default None. + :param equal: if True, set all column widths equal. Default False. :param debug: if True, add id to caption and use colored lines in table, default False. """ + # deal with alternative input modes + if isinstance(df, pd.DataFrame): + # usual use case + pass + elif isinstance(df, pd.Series): + df = df.to_frame() + elif isinstance(df, list): + df = pd.DataFrame(df) + # override this selection come what may + show_index = False + if header_row: + # Set first row as column names + df.columns = df.iloc[0] + # Drop first row and reset index + df = df[1:].reset_index(drop=True) + elif isinstance(df, str): + df, aligners = GT.md_to_df(df) + show_index = False + else: + raise ValueError('df must be a DataFrame, a list of lists, or a markdown table string') + if not df.columns.is_unique: raise ValueError('df column names are not unique') self.df = df.copy(deep=True) # the object being formatted @@ -118,14 +149,16 @@ class GT(object): self.caption = caption + (' (id: ' + self.df_id + ')' if self.debug else '') # before messing - self.nindex = self.df.index.nlevels + self.show_index = show_index + self.nindex = self.df.index.nlevels if self.show_index else 0 self.ncolumns = self.df.columns.nlevels self.ncols = self.df.shape[1] self.dt = self.df.dtypes with warnings.catch_warnings(): - warnings.simplefilter("ignore", category=pd.errors.PerformanceWarning) - self.df = self.df.reset_index(drop=False, col_level=self.df.columns.nlevels - 1) + if self.show_index: + warnings.simplefilter("ignore", category=pd.errors.PerformanceWarning) + self.df = self.df.reset_index(drop=False, col_level=self.df.columns.nlevels - 1) # want the new index to be ints - that is not default if old was multiindex self.df.index = np.arange(self.df.shape[0], dtype=int) self.index_change_level = GT.changed_column(self.df.iloc[:, :self.nindex]) @@ -203,16 +236,20 @@ class GT(object): if aligners is None: # not using aligners = [] + elif isinstance(aligners, str): + # lrc for each column + aligners = {c: a for c, a in zip(self.df.columns, aligners)} self.df_aligners = [] lrc = {'l': 'grt-left', 'r': 'grt-right', 'c': 'grt-center'} - # FIX INDEX ALIGNERS HERE + # TODO: index aligners for i, c in enumerate(self.df.columns): - if i < self.nindex: + # test aligners BEFORE index! + if c in aligners: + self.df_aligners.append(lrc.get(aligners[c], 'grt-center')) + elif i < self.nindex: # index -> left self.df_aligners.append('grt-left') - elif c in aligners: - self.df_aligners.append(lrc.get(aligners[c], 'grt-center')) elif c in self.ratio_cols or i in self.float_col_indices or i in self.integer_col_indices: # number -> right self.df_aligners.append('grt-right') @@ -236,6 +273,8 @@ class GT(object): self.pef_lower = pef_lower self.pef_upper = pef_upper self._pef = None + self.table_float_format = table_float_format + self.default_float_formatter = None self.hrule_widths = hrule_widths or (0, 0, 0) self.vrule_widths = vrule_widths or (0, 0, 0) self.table_hrule_width = table_hrule_width @@ -245,6 +284,15 @@ class GT(object): self.font_caption = font_caption self.font_bold_index = font_bold_index self.sparsify_columns = sparsify_columns + if tabs is None: + self.tabs = None + elif isinstance(tabs, (int, float)): + self.tabs = (tabs,) + elif isinstance(tabs, (np.ndarray, list, tuple)): + self.tabs = tabs # Already iterable, self.tabs = as is + else: + self.tabs = [tabs] # Fallback for anything else + self.equal = equal if padding_trbl is None: if spacing == 'tight': @@ -313,12 +361,17 @@ class GT(object): def default_formatter(self, x): """Universal formatter for other types.""" try: - i = int(x) f = float(x) + if self.default_float_formatter: + return self.default_float_formatter(f) + try: + i = int(x) + except ValueError: + i = int(f) if i == f: return self.default_integer_str.format(x=i) else: - # TODo BEEF UP? + # TODO BEEF UP? return self.default_float_str.format(x=f) except (TypeError, ValueError): return str(x) @@ -373,10 +426,10 @@ class GT(object): return fmt.format(x=x) # well and good but results in ugly differences # by entries in a column - if x == int(x) and np.abs(x) < pu: - return f'{x:,.0f}.' - else: - return fmt.format(x=x) + # if x == int(x) and np.abs(x) < pu: + # return f'{x:,.0f}.' + # else: + # return fmt.format(x=x) except (ValueError, TypeError): return str(x) return ff @@ -391,6 +444,32 @@ class GT(object): each column. """ # because of non-unique indexes, index by position not name + if self.table_float_format is not None: + if callable(self.table_float_format): + # wrap in error protections + def ff(x): + try: + return self.table_float_format(x=x) + except ValueError: + return str(x) + except Exception as e: + logger.error(f'Custom float function raised {e=}') + self.default_float_formatter = ff + else: + if type(self.table_float_format) != str: + raise ValueError('table_float_format must be a string or a function') + fmt = self.table_float_format + def ff(x): + try: + return fmt.format(x=x) + except ValueError: + return str(x) + except Exception as e: + logger.error(f'Custom float format string raised {e=}') + self.default_float_formatter = ff + else: + self.default_float_formatter = False + if self._df_formatters is None: self._df_formatters = [] for i, c in enumerate(self.df.columns): @@ -408,7 +487,7 @@ class GT(object): self._df_formatters.append(self.default_integer_formatter) elif i in self.float_col_indices: # trickier approach... - self._df_formatters.append(self.make_float_formatter(self.df.iloc[:, i])) + self._df_formatters.append(self.default_float_formatter or self.make_float_formatter(self.df.iloc[:, i])) else: # print(f'{i} default') self._df_formatters.append(self.default_formatter) @@ -429,7 +508,8 @@ class GT(object): """ return self.html - def make_style(self): + def make_style(self, tabs): + """Write out custom CSS for the table.""" if self.debug: head_tb = '#0ff' body_b = '#f0f' @@ -457,13 +537,16 @@ class GT(object): # for local use padt, padr, padb, padl = self.padt, self.padr, self.padb, self.padl - style = f''' + style = [f''' -''' - return style +'''] + for i, w in enumerate(tabs): + style.append(f' #{self.df_id} .grt-c-{i} {{ width: {w}em; }}') + style.append('') + logger.info('CREATED CSS') + return '\n'.join(style) def make_html(self): - """Convert a pandas DataFrame to an HTML table with sparsification.""" + """Convert a pandas DataFrame to an HTML table.""" index_name_to_level = dict(zip(self.raw_df.index.names, range(self.nindex))) index_change_level = self.index_change_level.map(index_name_to_level) # this is easier and computed in the init @@ -564,6 +650,36 @@ class GT(object): idx_header = bit.iloc[:self.nindex, :self.ncolumns] columns = bit.iloc[self.nindex:, :self.ncolumns] + colw, tabs = GT.estimate_column_widths(self.df, nc_index=self.nindex, scale=1, equal=self.equal) + if self.debug: + print(f'Input {self.tabs=}\nComputed {tabs=}') + if self.tabs is not None: + if len(tabs) == len(self.tabs): + tabs = self.tabs + elif len(self.tabs) == 1: + tabs = self.tabs * len(tabs) + else: + logger.error(f'{self.tabs=} must be None, a single number, or a list of numbers of the correct length. Ignoring.') + # print('HTML ' + ', '.join([f'{c:,.2f}' for c in tabs])) + + # set column widths; tabs returns lengths of strings in each column + # for proportional fonts, average char is 0.4 to 0.5 em but numbers with + # tabular-nums are fixed 0.5, so use that + # scale: want tables about 150-200 char wide, 1 char = 0.5 px size of font + # so what 75-100 em wide in total + # add the padding + # TODO FONT SIZE + # /4 works well for the tests (handles dates) but seems a bit illogical... + tabs = np.array(tabs) + (self.padl + self.padr) / 12 # guessing font size... + # em_per_char = 0.5; true exactly for tabular-nums + em_per_char = 0.6 + tabs = tabs * em_per_char + # this gets stripped out by quarto, so make part of style + html.append('
| index | +level_0 | +level_1 | +2025 | +2026 | +2027 | +
|---|---|---|---|---|---|
| 0 | +GAAP | +Underwriting Result | ++ | -394.81 | ++ |
| 1 | +GAAP | +Net Investment Income | ++ | 60.52 | +66.57 | +
| 2 | +GAAP | +Operating Result | ++ | -334.29 | +66.57 | +
| 3 | +GAAP | +Dividends | ++ | + | + |
Some text above the table.
+ +| + | A | +B | +C | +||
|---|---|---|---|---|---|
| years! | +Int | +Float | +Float | +3 | +Longer Text | +
| 2000 | +-100,000 | +2.389p | +-1,601.00 | +2025-03-14 | +once upon a time, once upon a time, once upon a time, once upon a time | +
| 2001 | +-91,667 | +22.217p | +-1,367.62 | +2025-03-26 | +risk is hard to define | +
| 2002 | +-83,333 | +206.619p | +-1,134.25 | +2025-04-07 | +not in Kansas anymore | +
| 2003 | +-75,000 | +1.922n | +-900.88 | +2025-04-19 | +neutrinos are hard to detect | +
| 2004 | +-66,667 | +17.870n | +-667.50 | +2025-05-01 | +Adam Smith is the father of economics | +
| 2005 | +-58,333 | +166.196n | +-434.12 | +2025-05-13 | +once upon a time | +
| Footer 1 stuff. This is very long. This is very long. This is very long. This is very long. | +Footer 2 stuff. | + +||||
Some text below the table.
+ + +