coeftable 0.1.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.
- coeftable/__init__.py +66 -0
- coeftable/_version.py +24 -0
- coeftable/format.py +237 -0
- coeftable/frame.py +442 -0
- coeftable/render.py +153 -0
- coeftable/spec.py +437 -0
- coeftable/svg.py +210 -0
- coeftable/theme.py +187 -0
- coeftable-0.1.0.dist-info/METADATA +205 -0
- coeftable-0.1.0.dist-info/RECORD +12 -0
- coeftable-0.1.0.dist-info/WHEEL +4 -0
- coeftable-0.1.0.dist-info/licenses/LICENSE +21 -0
coeftable/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Publication-quality summary tables for estimates with uncertainty."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from coeftable.format import CIStyle, Currency, Number, Percent
|
|
6
|
+
from coeftable.spec import (
|
|
7
|
+
CoefTable,
|
|
8
|
+
ColumnNotFoundError,
|
|
9
|
+
Estimate,
|
|
10
|
+
Forest,
|
|
11
|
+
Passthrough,
|
|
12
|
+
SpecError,
|
|
13
|
+
)
|
|
14
|
+
from coeftable.theme import Theme, role_for
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
__version__ = version("coeftable")
|
|
18
|
+
except PackageNotFoundError: # pragma: no cover
|
|
19
|
+
__version__ = "0.0.0.dev0"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"CIStyle",
|
|
23
|
+
"CoefTable",
|
|
24
|
+
"ColumnNotFoundError",
|
|
25
|
+
"Currency",
|
|
26
|
+
"Estimate",
|
|
27
|
+
"Forest",
|
|
28
|
+
"Number",
|
|
29
|
+
"Passthrough",
|
|
30
|
+
"Percent",
|
|
31
|
+
"SpecError",
|
|
32
|
+
"Theme",
|
|
33
|
+
"__version__",
|
|
34
|
+
"role_for",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def __getattr__(name: str):
|
|
39
|
+
"""Intercept deprecated theme imports and issue warnings.
|
|
40
|
+
|
|
41
|
+
Only DEFAULT, COLORBLIND, and MONO ever lived at the top level before
|
|
42
|
+
themes moved to `coeftable.theme`; BLUE and TEXTUAL never did, so they
|
|
43
|
+
are not covered here -- import them from `coeftable.theme` directly.
|
|
44
|
+
"""
|
|
45
|
+
import warnings
|
|
46
|
+
|
|
47
|
+
from coeftable.theme import COLORBLIND, DEFAULT, MONO
|
|
48
|
+
|
|
49
|
+
_deprecated_themes = {"DEFAULT": DEFAULT, "COLORBLIND": COLORBLIND, "MONO": MONO}
|
|
50
|
+
if name in _deprecated_themes:
|
|
51
|
+
if name == "DEFAULT":
|
|
52
|
+
message = (
|
|
53
|
+
"Importing DEFAULT from coeftable is deprecated. DEFAULT is now an "
|
|
54
|
+
"alias for the TEXTUAL theme; 'from coeftable.theme import DEFAULT' "
|
|
55
|
+
"will give you TEXTUAL, not the original blue theme. Use "
|
|
56
|
+
"'from coeftable.theme import BLUE' to keep the prior appearance, "
|
|
57
|
+
"or 'from coeftable.theme import TEXTUAL' to use it explicitly."
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
message = (
|
|
61
|
+
f"Importing {name} from coeftable is deprecated. "
|
|
62
|
+
f"Use 'from coeftable.theme import {name}' instead."
|
|
63
|
+
)
|
|
64
|
+
warnings.warn(message, DeprecationWarning, stacklevel=2)
|
|
65
|
+
return _deprecated_themes[name]
|
|
66
|
+
raise AttributeError(f"module 'coeftable' has no attribute '{name}'")
|
coeftable/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
coeftable/format.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Number and confidence-interval formatting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from coeftable.theme import Theme
|
|
11
|
+
|
|
12
|
+
type Format = Callable[[float], str]
|
|
13
|
+
type Layout = Literal["stacked", "inline", "value_only"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_missing(value: float | None) -> bool:
|
|
17
|
+
"""Return True when *value* is None or NaN.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
value
|
|
22
|
+
Candidate value.
|
|
23
|
+
|
|
24
|
+
Returns
|
|
25
|
+
-------
|
|
26
|
+
bool
|
|
27
|
+
True when the value carries no information.
|
|
28
|
+
"""
|
|
29
|
+
return value is None or (isinstance(value, float) and math.isnan(value))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def compact_number(value: float) -> str:
|
|
33
|
+
"""Format a magnitude compactly, e.g. ``1.4k``, ``2.3M``, ``2.4B``.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
value
|
|
38
|
+
Value to format. The sign is preserved.
|
|
39
|
+
|
|
40
|
+
Returns
|
|
41
|
+
-------
|
|
42
|
+
str
|
|
43
|
+
Compact representation.
|
|
44
|
+
"""
|
|
45
|
+
av = abs(value)
|
|
46
|
+
if av >= 1_000_000_000:
|
|
47
|
+
scaled = round(av / 1_000_000_000, 1)
|
|
48
|
+
if scaled >= 1000:
|
|
49
|
+
return f"{value / 1_000_000_000_000:.1f}T"
|
|
50
|
+
return f"{value / 1_000_000_000:.1f}B"
|
|
51
|
+
if av >= 1_000_000:
|
|
52
|
+
scaled = round(av / 1_000_000, 1)
|
|
53
|
+
if scaled >= 1000:
|
|
54
|
+
return f"{value / 1_000_000_000:.1f}B"
|
|
55
|
+
return f"{value / 1_000_000:.1f}M"
|
|
56
|
+
if av >= 1_000:
|
|
57
|
+
scaled = round(av / 1_000, 1)
|
|
58
|
+
if scaled >= 1000:
|
|
59
|
+
return f"{value / 1_000_000:.1f}M"
|
|
60
|
+
return f"{value / 1_000:.1f}k"
|
|
61
|
+
if av >= 1:
|
|
62
|
+
scaled = round(av, 1)
|
|
63
|
+
if scaled >= 1000:
|
|
64
|
+
return f"{value / 1_000:.1f}k"
|
|
65
|
+
return f"{value:.1f}"
|
|
66
|
+
return f"{value:.2f}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class Number:
|
|
71
|
+
"""Format a float as a number.
|
|
72
|
+
|
|
73
|
+
Parameters
|
|
74
|
+
----------
|
|
75
|
+
decimals
|
|
76
|
+
Digits after the decimal point. Ignored when *compact* is True.
|
|
77
|
+
compact
|
|
78
|
+
Use ``1.4k`` / ``2.3M`` style abbreviation.
|
|
79
|
+
signed
|
|
80
|
+
Prefix positive values with ``+``. Negatives always carry ``-``.
|
|
81
|
+
prefix
|
|
82
|
+
Text placed after the sign and before the digits, e.g. ``$``.
|
|
83
|
+
suffix
|
|
84
|
+
Text placed after the digits, e.g. ``x``.
|
|
85
|
+
thousands
|
|
86
|
+
Insert thousands separators.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
decimals: int = 2
|
|
90
|
+
compact: bool = False
|
|
91
|
+
signed: bool = False
|
|
92
|
+
prefix: str = ""
|
|
93
|
+
suffix: str = ""
|
|
94
|
+
thousands: bool = True
|
|
95
|
+
|
|
96
|
+
def __call__(self, value: float) -> str:
|
|
97
|
+
"""Format *value*.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
value
|
|
102
|
+
Value to format.
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
str
|
|
107
|
+
Formatted value.
|
|
108
|
+
"""
|
|
109
|
+
magnitude = abs(value)
|
|
110
|
+
if self.compact:
|
|
111
|
+
body = compact_number(magnitude)
|
|
112
|
+
else:
|
|
113
|
+
spec = f",.{self.decimals}f" if self.thousands else f".{self.decimals}f"
|
|
114
|
+
body = format(magnitude, spec)
|
|
115
|
+
if value < 0:
|
|
116
|
+
sign = "-"
|
|
117
|
+
elif self.signed and value > 0:
|
|
118
|
+
sign = "+"
|
|
119
|
+
else:
|
|
120
|
+
sign = ""
|
|
121
|
+
return f"{sign}{self.prefix}{body}{self.suffix}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class Percent(Number):
|
|
126
|
+
"""Format a float as a percentage.
|
|
127
|
+
|
|
128
|
+
Parameters
|
|
129
|
+
----------
|
|
130
|
+
scale
|
|
131
|
+
Multiplier applied before formatting. Leave at ``1.0`` when the data is
|
|
132
|
+
already in percentage points; use ``100.0`` when it is a fraction.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
decimals: int = 2
|
|
136
|
+
signed: bool = True
|
|
137
|
+
suffix: str = "%"
|
|
138
|
+
thousands: bool = False
|
|
139
|
+
scale: float = 1.0
|
|
140
|
+
|
|
141
|
+
def __call__(self, value: float) -> str:
|
|
142
|
+
"""Format *value* as a percentage.
|
|
143
|
+
|
|
144
|
+
Parameters
|
|
145
|
+
----------
|
|
146
|
+
value
|
|
147
|
+
Value to format.
|
|
148
|
+
|
|
149
|
+
Returns
|
|
150
|
+
-------
|
|
151
|
+
str
|
|
152
|
+
Formatted percentage.
|
|
153
|
+
"""
|
|
154
|
+
return super().__call__(value * self.scale)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class Currency(Number):
|
|
159
|
+
"""Format a float as currency, with the symbol inside the sign."""
|
|
160
|
+
|
|
161
|
+
prefix: str = "$"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True)
|
|
165
|
+
class CIStyle:
|
|
166
|
+
"""Control how a point estimate and its interval are assembled.
|
|
167
|
+
|
|
168
|
+
Parameters
|
|
169
|
+
----------
|
|
170
|
+
layout
|
|
171
|
+
``"stacked"`` puts the interval on a muted second line, ``"inline"``
|
|
172
|
+
keeps it on one line, ``"value_only"`` drops it.
|
|
173
|
+
brackets
|
|
174
|
+
Bracket pair for a two-sided interval. An unbounded side always uses a
|
|
175
|
+
parenthesis regardless of this setting.
|
|
176
|
+
separator
|
|
177
|
+
Text between the two bounds.
|
|
178
|
+
unbounded
|
|
179
|
+
Symbol used for an absent bound.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
layout: Layout = "stacked"
|
|
183
|
+
brackets: tuple[str, str] = ("[", "]")
|
|
184
|
+
separator: str = ", "
|
|
185
|
+
unbounded: str = "∞"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def render_interval(
|
|
189
|
+
value: float | None,
|
|
190
|
+
lower: float | None,
|
|
191
|
+
upper: float | None,
|
|
192
|
+
*,
|
|
193
|
+
fmt: Format,
|
|
194
|
+
style: CIStyle,
|
|
195
|
+
theme: Theme,
|
|
196
|
+
) -> str:
|
|
197
|
+
"""Render an estimate and its interval as an HTML fragment.
|
|
198
|
+
|
|
199
|
+
Parameters
|
|
200
|
+
----------
|
|
201
|
+
value
|
|
202
|
+
Point estimate. A missing value renders *theme*. ``na_text``.
|
|
203
|
+
lower, upper
|
|
204
|
+
Interval bounds. A missing bound renders as unbounded on that side.
|
|
205
|
+
fmt
|
|
206
|
+
Callable applied to the estimate and each bound.
|
|
207
|
+
style
|
|
208
|
+
Assembly options.
|
|
209
|
+
theme
|
|
210
|
+
Supplies typography, muted colour and the missing-value text.
|
|
211
|
+
|
|
212
|
+
Returns
|
|
213
|
+
-------
|
|
214
|
+
str
|
|
215
|
+
HTML fragment safe to pass through **great_tables** markdown formatting.
|
|
216
|
+
"""
|
|
217
|
+
if is_missing(value):
|
|
218
|
+
return theme.na_text
|
|
219
|
+
assert value is not None # noqa: S101 - narrowed by is_missing
|
|
220
|
+
point = f'<span style="font-size:{theme.value_size};font-weight:600">{fmt(value)}</span>'
|
|
221
|
+
lower = None if is_missing(lower) else lower
|
|
222
|
+
upper = None if is_missing(upper) else upper
|
|
223
|
+
if style.layout == "value_only" or (lower is None and upper is None):
|
|
224
|
+
return point
|
|
225
|
+
open_bracket = "(" if lower is None else style.brackets[0]
|
|
226
|
+
close_bracket = ")" if upper is None else style.brackets[1]
|
|
227
|
+
low_text = f"-{style.unbounded}" if lower is None else fmt(lower)
|
|
228
|
+
high_text = style.unbounded if upper is None else fmt(upper)
|
|
229
|
+
interval = f"{open_bracket}{low_text}{style.separator}{high_text}{close_bracket}"
|
|
230
|
+
if style.layout == "inline":
|
|
231
|
+
return (
|
|
232
|
+
f'<span style="white-space:nowrap">{point} '
|
|
233
|
+
f'<span style="color:{theme.muted}">{interval}</span></span>'
|
|
234
|
+
)
|
|
235
|
+
return (
|
|
236
|
+
f'{point}<br><span style="font-size:{theme.ci_size};color:{theme.muted}">{interval}</span>'
|
|
237
|
+
)
|