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 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
+ )