pysofra 0.1.0a1__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.
- pysofra/__init__.py +82 -0
- pysofra/core/__init__.py +14 -0
- pysofra/core/compose.py +167 -0
- pysofra/core/format.py +155 -0
- pysofra/core/frames.py +69 -0
- pysofra/core/schema.py +128 -0
- pysofra/core/table.py +924 -0
- pysofra/io/__init__.py +1 -0
- pysofra/models/__init__.py +6 -0
- pysofra/models/extract.py +249 -0
- pysofra/models/pool.py +119 -0
- pysofra/models/regression.py +507 -0
- pysofra/models/survival.py +395 -0
- pysofra/models/uvregression.py +438 -0
- pysofra/notebook/__init__.py +6 -0
- pysofra/plot/__init__.py +23 -0
- pysofra/plot/_backend.py +32 -0
- pysofra/plot/forest.py +159 -0
- pysofra/plot/inline.py +171 -0
- pysofra/plot/km.py +249 -0
- pysofra/render/__init__.py +28 -0
- pysofra/render/_zip_determinism.py +57 -0
- pysofra/render/base.py +22 -0
- pysofra/render/docx.py +286 -0
- pysofra/render/html.py +442 -0
- pysofra/render/image.py +130 -0
- pysofra/render/latex.py +253 -0
- pysofra/render/markdown.py +128 -0
- pysofra/render/pptx.py +340 -0
- pysofra/render/xlsx.py +226 -0
- pysofra/summary/__init__.py +6 -0
- pysofra/summary/calibrate.py +214 -0
- pysofra/summary/design.py +246 -0
- pysofra/summary/effect_size.py +187 -0
- pysofra/summary/extras.py +745 -0
- pysofra/summary/smd.py +133 -0
- pysofra/summary/stats.py +135 -0
- pysofra/summary/tbl_cross.py +339 -0
- pysofra/summary/tbl_one.py +1220 -0
- pysofra/summary/tbl_summary.py +51 -0
- pysofra/summary/tests.py +370 -0
- pysofra/summary/typing.py +129 -0
- pysofra/summary/weights.py +161 -0
- pysofra/themes/__init__.py +5 -0
- pysofra/themes/registry.py +272 -0
- pysofra-0.1.0a1.dist-info/METADATA +301 -0
- pysofra-0.1.0a1.dist-info/RECORD +50 -0
- pysofra-0.1.0a1.dist-info/WHEEL +4 -0
- pysofra-0.1.0a1.dist-info/licenses/LICENSE +674 -0
- pysofra-0.1.0a1.dist-info/licenses/NOTICE +18 -0
pysofra/__init__.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""PySofra — the missing statistical reporting layer for Python.
|
|
2
|
+
|
|
3
|
+
PySofra transforms datasets and statistical model outputs into
|
|
4
|
+
publication-ready tables across HTML, Markdown, DOCX, LaTeX, PPTX, XLSX,
|
|
5
|
+
and PNG. The same underlying :class:`SofraTable` object renders
|
|
6
|
+
beautifully in Jupyter and exports identically to disk.
|
|
7
|
+
|
|
8
|
+
Quick start
|
|
9
|
+
-----------
|
|
10
|
+
|
|
11
|
+
>>> import pandas as pd
|
|
12
|
+
>>> import pysofra as ps
|
|
13
|
+
>>> df = pd.DataFrame({
|
|
14
|
+
... "age": [55, 62, 47, 68, 51],
|
|
15
|
+
... "sex": ["F", "M", "F", "M", "F"],
|
|
16
|
+
... "arm": ["A", "A", "B", "B", "A"],
|
|
17
|
+
... })
|
|
18
|
+
>>> tbl = (
|
|
19
|
+
... ps.tbl_one(df, by="arm")
|
|
20
|
+
... .add_p()
|
|
21
|
+
... .add_smd()
|
|
22
|
+
... .theme("clinical")
|
|
23
|
+
... )
|
|
24
|
+
>>> _ = tbl.to_html()
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from .core.compose import tbl_merge, tbl_stack
|
|
30
|
+
from .core.schema import CellPart
|
|
31
|
+
from .core.table import SofraTable
|
|
32
|
+
from .models.pool import pool
|
|
33
|
+
from .models.regression import tbl_regression
|
|
34
|
+
from .models.survival import tbl_survival
|
|
35
|
+
from .models.uvregression import tbl_uvregression
|
|
36
|
+
from .summary.calibrate import design_effect, post_stratify, rake
|
|
37
|
+
from .summary.design import SurveyDesign
|
|
38
|
+
from .summary.effect_size import (
|
|
39
|
+
auto_effect_size,
|
|
40
|
+
cohen_d,
|
|
41
|
+
cramers_v,
|
|
42
|
+
eta_squared,
|
|
43
|
+
hedges_g,
|
|
44
|
+
omega_squared,
|
|
45
|
+
phi_coefficient,
|
|
46
|
+
)
|
|
47
|
+
from .summary.tbl_cross import tbl_cross
|
|
48
|
+
from .summary.tbl_one import tbl_one
|
|
49
|
+
from .summary.tbl_summary import tbl_summary
|
|
50
|
+
from .summary.tests import available_tests
|
|
51
|
+
from .themes.registry import available_themes, register_theme
|
|
52
|
+
|
|
53
|
+
__version__ = "0.1.0a1"
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"CellPart",
|
|
57
|
+
"SofraTable",
|
|
58
|
+
"SurveyDesign",
|
|
59
|
+
"__version__",
|
|
60
|
+
"auto_effect_size",
|
|
61
|
+
"available_tests",
|
|
62
|
+
"available_themes",
|
|
63
|
+
"cohen_d",
|
|
64
|
+
"cramers_v",
|
|
65
|
+
"design_effect",
|
|
66
|
+
"eta_squared",
|
|
67
|
+
"hedges_g",
|
|
68
|
+
"omega_squared",
|
|
69
|
+
"phi_coefficient",
|
|
70
|
+
"pool",
|
|
71
|
+
"post_stratify",
|
|
72
|
+
"rake",
|
|
73
|
+
"register_theme",
|
|
74
|
+
"tbl_cross",
|
|
75
|
+
"tbl_merge",
|
|
76
|
+
"tbl_one",
|
|
77
|
+
"tbl_regression",
|
|
78
|
+
"tbl_stack",
|
|
79
|
+
"tbl_summary",
|
|
80
|
+
"tbl_survival",
|
|
81
|
+
"tbl_uvregression",
|
|
82
|
+
]
|
pysofra/core/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Core types: :class:`SofraTable` and its schema."""
|
|
2
|
+
|
|
3
|
+
from .schema import Cell, HeaderCell, HeaderRow, Row, SpanningHeader
|
|
4
|
+
from .table import SofraTable, TableSpec
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Cell",
|
|
8
|
+
"HeaderCell",
|
|
9
|
+
"HeaderRow",
|
|
10
|
+
"Row",
|
|
11
|
+
"SofraTable",
|
|
12
|
+
"SpanningHeader",
|
|
13
|
+
"TableSpec",
|
|
14
|
+
]
|
pysofra/core/compose.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Table composition: :func:`tbl_merge` and :func:`tbl_stack`.
|
|
2
|
+
|
|
3
|
+
Both functions take a sequence of :class:`SofraTable` objects and combine
|
|
4
|
+
them into a single SofraTable. Merging glues tables side-by-side (sharing
|
|
5
|
+
the first / label column by default); stacking concatenates them vertically.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from itertools import zip_longest
|
|
11
|
+
|
|
12
|
+
from .schema import Cell, HeaderCell, HeaderRow, Row, SpanningHeader
|
|
13
|
+
from .table import SofraTable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def tbl_merge(
|
|
17
|
+
tables: list[SofraTable] | tuple[SofraTable, ...],
|
|
18
|
+
*,
|
|
19
|
+
tab_spanners: list[str] | None = None,
|
|
20
|
+
share_first_column: bool = True,
|
|
21
|
+
) -> SofraTable:
|
|
22
|
+
"""Merge tables side-by-side.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
tables
|
|
27
|
+
Two or more :class:`SofraTable` objects with the same number of
|
|
28
|
+
body rows.
|
|
29
|
+
tab_spanners
|
|
30
|
+
Optional list of spanning-header labels, one per input table.
|
|
31
|
+
share_first_column
|
|
32
|
+
When ``True`` (default) and every input has the same first column
|
|
33
|
+
in every row, the duplicate label columns are dropped from the
|
|
34
|
+
2nd-Nth tables.
|
|
35
|
+
"""
|
|
36
|
+
tables = list(tables)
|
|
37
|
+
if len(tables) < 2:
|
|
38
|
+
raise ValueError("tbl_merge requires at least two tables.")
|
|
39
|
+
n_rows_sets = {len(t.rows) for t in tables}
|
|
40
|
+
if len(n_rows_sets) != 1:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"tbl_merge requires all tables to have the same number of rows; got {n_rows_sets}."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
drop_first = (
|
|
46
|
+
share_first_column
|
|
47
|
+
and all(t.rows for t in tables)
|
|
48
|
+
and all(
|
|
49
|
+
all(t.rows[i].cells[0].text == tables[0].rows[i].cells[0].text
|
|
50
|
+
for i in range(len(t.rows)))
|
|
51
|
+
for t in tables[1:]
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Headers — pick the deepest header rows across inputs.
|
|
56
|
+
max_header_depth = max(len(t.headers) for t in tables)
|
|
57
|
+
merged_headers: list[HeaderRow] = []
|
|
58
|
+
for level in range(max_header_depth):
|
|
59
|
+
header_row_cells: list[HeaderCell] = []
|
|
60
|
+
for i, t in enumerate(tables):
|
|
61
|
+
hr = t.headers[level] if level < len(t.headers) else None
|
|
62
|
+
if hr is None:
|
|
63
|
+
# pad with empty header cells
|
|
64
|
+
base = tables[0].headers[level] if level < len(tables[0].headers) else None
|
|
65
|
+
width = len(t.headers[0].cells) if t.headers else 1
|
|
66
|
+
hr = HeaderRow(cells=tuple(HeaderCell(text="") for _ in range(width)))
|
|
67
|
+
del base
|
|
68
|
+
row_cells: list[HeaderCell] = list(hr.cells)
|
|
69
|
+
if drop_first and i > 0 and row_cells:
|
|
70
|
+
row_cells = row_cells[1:]
|
|
71
|
+
header_row_cells.extend(row_cells)
|
|
72
|
+
merged_headers.append(HeaderRow(cells=tuple(header_row_cells)))
|
|
73
|
+
|
|
74
|
+
# Spanning headers from tab_spanners (if provided).
|
|
75
|
+
spanning: list[SpanningHeader] = []
|
|
76
|
+
if tab_spanners:
|
|
77
|
+
if len(tab_spanners) != len(tables):
|
|
78
|
+
raise ValueError("tab_spanners must have one entry per table.")
|
|
79
|
+
col = 0
|
|
80
|
+
for i, (t, label) in enumerate(zip(tables, tab_spanners, strict=True)):
|
|
81
|
+
width = len(t.headers[0].cells) if t.headers else len(t.rows[0].cells)
|
|
82
|
+
if drop_first and i > 0:
|
|
83
|
+
width -= 1
|
|
84
|
+
spanning.append(SpanningHeader(label=label, start=col, end=col + width - 1))
|
|
85
|
+
col += width
|
|
86
|
+
|
|
87
|
+
# Body rows — concatenate cells horizontally.
|
|
88
|
+
n_rows = next(iter(n_rows_sets))
|
|
89
|
+
merged_rows: list[Row] = []
|
|
90
|
+
for i in range(n_rows):
|
|
91
|
+
body_cells: list[Cell] = []
|
|
92
|
+
for j, t in enumerate(tables):
|
|
93
|
+
body_row_cells: list[Cell] = list(t.rows[i].cells)
|
|
94
|
+
if drop_first and j > 0 and body_row_cells:
|
|
95
|
+
body_row_cells = body_row_cells[1:]
|
|
96
|
+
body_cells.extend(body_row_cells)
|
|
97
|
+
merged_rows.append(Row(cells=tuple(body_cells),
|
|
98
|
+
is_group_header=tables[0].rows[i].is_group_header))
|
|
99
|
+
|
|
100
|
+
footnotes: list[str] = []
|
|
101
|
+
for t in tables:
|
|
102
|
+
for f in t.footnotes:
|
|
103
|
+
if f not in footnotes:
|
|
104
|
+
footnotes.append(f)
|
|
105
|
+
|
|
106
|
+
return SofraTable(
|
|
107
|
+
rows=tuple(merged_rows),
|
|
108
|
+
headers=tuple(merged_headers),
|
|
109
|
+
spanning_headers=tuple(spanning),
|
|
110
|
+
footnotes=tuple(footnotes),
|
|
111
|
+
caption=tables[0].caption,
|
|
112
|
+
theme_name=tables[0].theme_name,
|
|
113
|
+
metadata={"merged_from": [t.metadata.get("builder", "?") for t in tables]},
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def tbl_stack(
|
|
118
|
+
tables: list[SofraTable] | tuple[SofraTable, ...],
|
|
119
|
+
*,
|
|
120
|
+
group_labels: list[str] | None = None,
|
|
121
|
+
) -> SofraTable:
|
|
122
|
+
"""Stack tables vertically.
|
|
123
|
+
|
|
124
|
+
All inputs must share the same column count and header structure.
|
|
125
|
+
Optional ``group_labels`` introduce a group-header row between blocks.
|
|
126
|
+
"""
|
|
127
|
+
tables = list(tables)
|
|
128
|
+
if len(tables) < 2:
|
|
129
|
+
raise ValueError("tbl_stack requires at least two tables.")
|
|
130
|
+
|
|
131
|
+
ncols = len(tables[0].headers[0].cells) if tables[0].headers else len(tables[0].rows[0].cells)
|
|
132
|
+
for t in tables[1:]:
|
|
133
|
+
nc = len(t.headers[0].cells) if t.headers else len(t.rows[0].cells)
|
|
134
|
+
if nc != ncols:
|
|
135
|
+
raise ValueError(
|
|
136
|
+
f"tbl_stack requires equal column counts; got {ncols} and {nc}."
|
|
137
|
+
)
|
|
138
|
+
if group_labels is not None and len(group_labels) != len(tables):
|
|
139
|
+
raise ValueError("group_labels must have one entry per table.")
|
|
140
|
+
|
|
141
|
+
rows: list[Row] = []
|
|
142
|
+
for i, t in enumerate(tables):
|
|
143
|
+
if group_labels:
|
|
144
|
+
header_cells = [Cell(text=group_labels[i], bold=True, align="left")]
|
|
145
|
+
header_cells.extend(Cell(text="") for _ in range(ncols - 1))
|
|
146
|
+
rows.append(Row(cells=tuple(header_cells), is_group_header=True))
|
|
147
|
+
rows.extend(t.rows)
|
|
148
|
+
|
|
149
|
+
footnotes: list[str] = []
|
|
150
|
+
for t in tables:
|
|
151
|
+
for f in t.footnotes:
|
|
152
|
+
if f not in footnotes:
|
|
153
|
+
footnotes.append(f)
|
|
154
|
+
|
|
155
|
+
return SofraTable(
|
|
156
|
+
rows=tuple(rows),
|
|
157
|
+
headers=tables[0].headers,
|
|
158
|
+
spanning_headers=tables[0].spanning_headers,
|
|
159
|
+
footnotes=tuple(footnotes),
|
|
160
|
+
caption=tables[0].caption,
|
|
161
|
+
theme_name=tables[0].theme_name,
|
|
162
|
+
metadata={"stacked_from": [t.metadata.get("builder", "?") for t in tables]},
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# Silence unused
|
|
167
|
+
_ = zip_longest
|
pysofra/core/format.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Formatting helpers for numbers, p-values, percents, and confidence intervals.
|
|
2
|
+
|
|
3
|
+
These are deterministic, locale-agnostic, and unit-testable. All rounding
|
|
4
|
+
uses banker's-rounding-free conventional half-up via Python's ``format`` mini
|
|
5
|
+
language so output matches what most statistical journals expect.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from typing import Final
|
|
12
|
+
|
|
13
|
+
NA_STRING: Final[str] = "—"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def fmt_number(value: float | int | None, digits: int = 2) -> str:
|
|
17
|
+
"""Format a numeric value to ``digits`` decimal places.
|
|
18
|
+
|
|
19
|
+
``None``, ``NaN``, infinite, and anything that can't be coerced to a
|
|
20
|
+
``float`` render as :data:`NA_STRING`. Both IEEE-754 negative zero
|
|
21
|
+
AND small negative numbers that round to all-zero at ``digits``
|
|
22
|
+
precision are normalised so cells never display as ``"-0.00"``
|
|
23
|
+
(which is confusing and uninformative).
|
|
24
|
+
"""
|
|
25
|
+
if value is None:
|
|
26
|
+
return NA_STRING
|
|
27
|
+
try:
|
|
28
|
+
v = float(value)
|
|
29
|
+
except (TypeError, ValueError):
|
|
30
|
+
return NA_STRING
|
|
31
|
+
if math.isnan(v) or math.isinf(v):
|
|
32
|
+
return NA_STRING
|
|
33
|
+
out = f"{v:.{digits}f}"
|
|
34
|
+
# If the formatted result is a "negative zero" representation —
|
|
35
|
+
# leading minus on a string of zeros and a decimal point — drop
|
|
36
|
+
# the sign. Covers both IEEE -0.0 (renders as "-0.00") and small
|
|
37
|
+
# negative inputs that round to zero at this precision (e.g.
|
|
38
|
+
# -0.001 at 2dp renders as "-0.00"). The information loss is
|
|
39
|
+
# already in the round-to-2dp step; preserving the sign on what
|
|
40
|
+
# the reader sees as zero would be misleading.
|
|
41
|
+
if out.startswith("-") and set(out[1:]) <= {"0", "."}:
|
|
42
|
+
out = out[1:]
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def fmt_int(value: float | int | None) -> str:
|
|
47
|
+
if value is None:
|
|
48
|
+
return NA_STRING
|
|
49
|
+
try:
|
|
50
|
+
v = float(value)
|
|
51
|
+
except (TypeError, ValueError):
|
|
52
|
+
return NA_STRING
|
|
53
|
+
if math.isnan(v) or math.isinf(v):
|
|
54
|
+
return NA_STRING
|
|
55
|
+
return f"{int(round(v))}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def fmt_percent(value: float | None, digits: int = 1) -> str:
|
|
59
|
+
"""Format a fraction (0–1) as a percent. Pass 0.234 → '23.4'.
|
|
60
|
+
|
|
61
|
+
Negative-zero output ("-0.0") is normalised to "0.0" for the same
|
|
62
|
+
reason :func:`fmt_number` does (it's confusing in publication tables).
|
|
63
|
+
"""
|
|
64
|
+
if value is None:
|
|
65
|
+
return NA_STRING
|
|
66
|
+
if isinstance(value, float) and (math.isnan(value) or math.isinf(value)):
|
|
67
|
+
return NA_STRING
|
|
68
|
+
out = f"{100.0 * float(value):.{digits}f}"
|
|
69
|
+
if out.startswith("-") and set(out[1:]) <= {"0", "."}:
|
|
70
|
+
out = out[1:]
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def fmt_n_pct(n: int, total: int, digits: int = 1) -> str:
|
|
75
|
+
"""Render ``n (xx.x%)``. If ``total`` is zero, returns ``n (—)``."""
|
|
76
|
+
if total <= 0:
|
|
77
|
+
return f"{int(n)} ({NA_STRING})"
|
|
78
|
+
pct = 100.0 * n / total
|
|
79
|
+
return f"{int(n)} ({pct:.{digits}f}%)"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def fmt_mean_sd(mean: float | None, sd: float | None, digits: int = 2) -> str:
|
|
83
|
+
"""Render ``mean (sd)`` in journal style."""
|
|
84
|
+
return f"{fmt_number(mean, digits)} ({fmt_number(sd, digits)})"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def fmt_median_iqr(
|
|
88
|
+
median: float | None,
|
|
89
|
+
q1: float | None,
|
|
90
|
+
q3: float | None,
|
|
91
|
+
digits: int = 2,
|
|
92
|
+
) -> str:
|
|
93
|
+
"""Render ``median (Q1, Q3)``."""
|
|
94
|
+
return f"{fmt_number(median, digits)} ({fmt_number(q1, digits)}, {fmt_number(q3, digits)})"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def fmt_range(lo: float | None, hi: float | None, digits: int = 2) -> str:
|
|
98
|
+
return f"{fmt_number(lo, digits)}, {fmt_number(hi, digits)}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def fmt_ci(
|
|
102
|
+
lo: float | None,
|
|
103
|
+
hi: float | None,
|
|
104
|
+
digits: int = 2,
|
|
105
|
+
*,
|
|
106
|
+
sep: str = ", ",
|
|
107
|
+
) -> str:
|
|
108
|
+
"""Render a confidence interval as ``lo, hi``."""
|
|
109
|
+
return f"{fmt_number(lo, digits)}{sep}{fmt_number(hi, digits)}"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def fmt_estimate_ci(
|
|
113
|
+
estimate: float | None,
|
|
114
|
+
lo: float | None,
|
|
115
|
+
hi: float | None,
|
|
116
|
+
digits: int = 2,
|
|
117
|
+
) -> str:
|
|
118
|
+
"""Render ``estimate (lo, hi)``."""
|
|
119
|
+
return f"{fmt_number(estimate, digits)} ({fmt_ci(lo, hi, digits)})"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def fmt_p_value(p: float | None, digits: int = 3) -> str:
|
|
123
|
+
"""Journal-style p-value formatting.
|
|
124
|
+
|
|
125
|
+
Rules:
|
|
126
|
+
* ``None`` / ``NaN`` / infinite → :data:`NA_STRING`
|
|
127
|
+
* out-of-range (``p < 0`` or ``p > 1``) → :data:`NA_STRING`
|
|
128
|
+
(silently coercing an invalid p-value would mask a real bug in
|
|
129
|
+
the upstream computation)
|
|
130
|
+
* ``p < 10^-digits`` → ``"<0.001"`` (for ``digits=3``)
|
|
131
|
+
* ``p > 0.99`` → ``">0.99"``
|
|
132
|
+
* otherwise → ``"0.xxx"``
|
|
133
|
+
"""
|
|
134
|
+
if p is None:
|
|
135
|
+
return NA_STRING
|
|
136
|
+
if isinstance(p, float) and (math.isnan(p) or math.isinf(p)):
|
|
137
|
+
return NA_STRING
|
|
138
|
+
p = float(p)
|
|
139
|
+
if p < 0.0 or p > 1.0:
|
|
140
|
+
return NA_STRING
|
|
141
|
+
threshold = 10 ** (-digits)
|
|
142
|
+
if p < threshold:
|
|
143
|
+
return f"<{threshold:.{digits}f}"
|
|
144
|
+
if p > 0.99:
|
|
145
|
+
return ">0.99"
|
|
146
|
+
return f"{p:.{digits}f}"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def fmt_smd(smd: float | None, digits: int = 3) -> str:
|
|
150
|
+
"""Format a standardized mean difference. Always signed magnitude."""
|
|
151
|
+
if smd is None:
|
|
152
|
+
return NA_STRING
|
|
153
|
+
if isinstance(smd, float) and (math.isnan(smd) or math.isinf(smd)):
|
|
154
|
+
return NA_STRING
|
|
155
|
+
return f"{float(smd):.{digits}f}"
|
pysofra/core/frames.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""DataFrame adaptation.
|
|
2
|
+
|
|
3
|
+
PySofra's public API accepts any object with the pandas DataFrame shape —
|
|
4
|
+
``pandas.DataFrame``, ``polars.DataFrame``, or ``polars.LazyFrame``. The
|
|
5
|
+
adapter in this module normalises the input to pandas internally so the
|
|
6
|
+
statistical engines have one type to reason about.
|
|
7
|
+
|
|
8
|
+
We keep the dependency on polars *optional* — importing this module does
|
|
9
|
+
not require polars to be installed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import pandas as pd
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def to_pandas(data: Any) -> pd.DataFrame:
|
|
20
|
+
"""Convert a DataFrame-like input to a pandas DataFrame.
|
|
21
|
+
|
|
22
|
+
Accepted inputs:
|
|
23
|
+
|
|
24
|
+
* ``pandas.DataFrame`` — returned as-is (no copy).
|
|
25
|
+
* ``polars.DataFrame`` — converted via ``.to_pandas()``.
|
|
26
|
+
* ``polars.LazyFrame`` — collected first, then converted.
|
|
27
|
+
* Any object exposing ``.to_pandas()`` — invoked and validated.
|
|
28
|
+
|
|
29
|
+
Raises ``TypeError`` for unrecognised inputs.
|
|
30
|
+
"""
|
|
31
|
+
if isinstance(data, pd.DataFrame):
|
|
32
|
+
return data
|
|
33
|
+
|
|
34
|
+
# Duck-typed polars detection — don't import polars unless we see it.
|
|
35
|
+
cls = type(data)
|
|
36
|
+
qualname = f"{cls.__module__}.{cls.__name__}"
|
|
37
|
+
if qualname.startswith("polars."):
|
|
38
|
+
# LazyFrame needs an explicit ``.collect()`` first.
|
|
39
|
+
if cls.__name__ == "LazyFrame":
|
|
40
|
+
data = data.collect()
|
|
41
|
+
try:
|
|
42
|
+
pandas_df = data.to_pandas()
|
|
43
|
+
except (ImportError, ModuleNotFoundError): # pragma: no cover
|
|
44
|
+
# ``polars.DataFrame.to_pandas`` routes through pyarrow by
|
|
45
|
+
# default. The ``pysofra[polars]`` and ``pysofra[all]``
|
|
46
|
+
# extras now declare ``pyarrow``, so this fallback is
|
|
47
|
+
# exercised only by users who hand-pin polars without it.
|
|
48
|
+
# Falls back to a column-wise conversion that needs only
|
|
49
|
+
# the standard library + pandas.
|
|
50
|
+
pandas_df = pd.DataFrame(
|
|
51
|
+
{col: data[col].to_list() for col in data.columns}
|
|
52
|
+
)
|
|
53
|
+
if not isinstance(pandas_df, pd.DataFrame): # pragma: no cover
|
|
54
|
+
raise TypeError(
|
|
55
|
+
"polars to_pandas() did not return a pandas DataFrame; "
|
|
56
|
+
f"got {type(pandas_df).__name__}."
|
|
57
|
+
)
|
|
58
|
+
return pandas_df
|
|
59
|
+
|
|
60
|
+
# Generic fallback: any object that knows how to give us pandas.
|
|
61
|
+
if hasattr(data, "to_pandas"):
|
|
62
|
+
result = data.to_pandas()
|
|
63
|
+
if isinstance(result, pd.DataFrame):
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
raise TypeError(
|
|
67
|
+
f"Unsupported DataFrame type {qualname!r}. "
|
|
68
|
+
"PySofra accepts pandas.DataFrame and polars.DataFrame / LazyFrame."
|
|
69
|
+
)
|
pysofra/core/schema.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Internal schema for SofraTable.
|
|
2
|
+
|
|
3
|
+
This module defines the backend-agnostic representation of a statistical
|
|
4
|
+
table. Every renderer (HTML, Markdown, DOCX, PPTX, LaTeX) consumes the same
|
|
5
|
+
schema; statistical engines (Table 1, summary, regression) produce it.
|
|
6
|
+
|
|
7
|
+
The schema is intentionally simple and immutable. Cells carry both the
|
|
8
|
+
*display* string and the *raw* value when known, so renderers can choose
|
|
9
|
+
the appropriate format (e.g., right-alignment for numeric cells).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any, Literal
|
|
16
|
+
|
|
17
|
+
CellKind = Literal[
|
|
18
|
+
"text", "numeric", "p_value", "q_value", "ci", "header_label", "group_label"
|
|
19
|
+
]
|
|
20
|
+
Alignment = Literal["left", "center", "right"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class CellPart:
|
|
25
|
+
"""A typographically distinct run inside a single cell.
|
|
26
|
+
|
|
27
|
+
Used by ``SofraTable.compose()`` to embed multi-format content
|
|
28
|
+
(bold + italic + colour) inside one cell. Renderers honour
|
|
29
|
+
``CellPart.bold``, ``italic``, ``superscript``, ``subscript``,
|
|
30
|
+
``code``, ``color``, and ``link`` where the backend supports them;
|
|
31
|
+
unsupported flags degrade to plain text.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
text: str
|
|
35
|
+
bold: bool = False
|
|
36
|
+
italic: bool = False
|
|
37
|
+
superscript: bool = False
|
|
38
|
+
subscript: bool = False
|
|
39
|
+
code: bool = False
|
|
40
|
+
color: str | None = None
|
|
41
|
+
link: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Cell:
|
|
46
|
+
"""One cell of a SofraTable.
|
|
47
|
+
|
|
48
|
+
`value` carries the raw underlying value when meaningful (e.g., a float
|
|
49
|
+
p-value). `text` is the rendered string used by all backends. Renderers
|
|
50
|
+
may consult `kind` and `value` for additional formatting decisions.
|
|
51
|
+
|
|
52
|
+
`style` is an optional mapping of renderer-specific overrides:
|
|
53
|
+
|
|
54
|
+
- ``html`` — extra inline-CSS declarations applied to the ``<td>``.
|
|
55
|
+
- ``docx`` — keys like ``padding_pt``, ``shading_hex``, ``borders``.
|
|
56
|
+
- ``xlsx`` — keys like ``bg_color``, ``num_format`` (forwarded to xlsxwriter).
|
|
57
|
+
- ``latex`` — prepended raw LaTeX (e.g. ``\\rowcolor{...}``).
|
|
58
|
+
|
|
59
|
+
All renderers ignore keys they don't understand.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
text: str
|
|
63
|
+
value: Any = None
|
|
64
|
+
kind: CellKind = "text"
|
|
65
|
+
align: Alignment | None = None
|
|
66
|
+
bold: bool = False
|
|
67
|
+
italic: bool = False
|
|
68
|
+
indent: int = 0
|
|
69
|
+
style: dict[str, Any] | None = None
|
|
70
|
+
parts: tuple[CellPart, ...] | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class Row:
|
|
75
|
+
"""One row of the table body."""
|
|
76
|
+
|
|
77
|
+
cells: tuple[Cell, ...]
|
|
78
|
+
is_group_header: bool = False
|
|
79
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class HeaderCell:
|
|
84
|
+
text: str
|
|
85
|
+
align: Alignment = "center"
|
|
86
|
+
bold: bool = True
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class HeaderRow:
|
|
91
|
+
"""A column-header row. Tables may have multiple stacked header rows."""
|
|
92
|
+
|
|
93
|
+
cells: tuple[HeaderCell, ...]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class SpanningHeader:
|
|
98
|
+
"""A spanning header above the column headers.
|
|
99
|
+
|
|
100
|
+
`start` and `end` are 0-indexed column indices, inclusive.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
label: str
|
|
104
|
+
start: int
|
|
105
|
+
end: int
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def make_cell(
|
|
109
|
+
text: str,
|
|
110
|
+
value: Any = None,
|
|
111
|
+
kind: CellKind = "text",
|
|
112
|
+
align: Alignment | None = None,
|
|
113
|
+
bold: bool = False,
|
|
114
|
+
italic: bool = False,
|
|
115
|
+
indent: int = 0,
|
|
116
|
+
style: dict[str, Any] | None = None,
|
|
117
|
+
) -> Cell:
|
|
118
|
+
"""Convenience constructor used internally."""
|
|
119
|
+
return Cell(
|
|
120
|
+
text=text,
|
|
121
|
+
value=value,
|
|
122
|
+
kind=kind,
|
|
123
|
+
align=align,
|
|
124
|
+
bold=bold,
|
|
125
|
+
italic=italic,
|
|
126
|
+
indent=indent,
|
|
127
|
+
style=style,
|
|
128
|
+
)
|