dataface 0.3.1.dev22__py3-none-any.whl → 0.3.1.dev78__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.
- d3_format/format.py +265 -265
- d3_format/spec.py +32 -34
- dataface/agent_api/board_artifact.py +160 -0
- dataface/agent_api/describe.py +2 -2
- dataface/agent_api/project_session.py +23 -0
- dataface/ai/llm.py +43 -3
- dataface/ai/skills/analyst-runbook/SKILL.md +7 -0
- dataface/cli/commands/board_artifact.py +85 -0
- dataface/cli/filesystem_project.py +2 -1
- dataface/cli/main.py +111 -0
- dataface/core/compile/board_artifact.py +202 -0
- dataface/core/compile/channel.py +4 -4
- dataface/core/compile/compiler.py +26 -4
- dataface/core/compile/enrich.py +7 -7
- dataface/core/compile/format.py +9 -2
- dataface/core/compile/merge.py +5 -3
- dataface/core/compile/models/face/resolved.py +0 -1
- dataface/core/compile/models/query/normalized.py +62 -50
- dataface/core/compile/models/style/theme/marks.py +8 -8
- dataface/core/compile/models/variable/authored.py +14 -2
- dataface/core/compile/models/vega_lite/config.py +4 -2
- dataface/core/compile/palette.py +5 -5
- dataface/core/compile/resolve/_resolve.py +3 -5
- dataface/core/compile/schema_renderers/board_resolved.py +100 -0
- dataface/core/compile/single_series_allocation.py +6 -18
- dataface/core/compile/source_map.py +126 -1
- dataface/core/compile/sql_guard.py +32 -2
- dataface/core/compile/style_cascade.py +39 -10
- dataface/core/compile/typography.py +4 -5
- dataface/core/compile/validate_formats.py +171 -0
- dataface/core/compile/vega_lite/mapping.py +2 -3
- dataface/core/dashboard.py +19 -5
- dataface/core/defaults/themes/_base.yaml +17 -6
- dataface/core/diagnostics/codes_compile.py +24 -1
- dataface/core/diagnostics/codes_render.py +59 -0
- dataface/core/diagnostics/execution.py +40 -2
- dataface/core/diagnostics/hints.py +9 -0
- dataface/core/execute/adapters/base.py +13 -1
- dataface/core/execute/adapters/duckdb_adapter.py +11 -0
- dataface/core/execute/chart_data_provider.py +75 -0
- dataface/core/execute/executor.py +12 -4
- dataface/core/execute/recording.py +99 -0
- dataface/core/execute/replay_provider.py +108 -0
- dataface/core/project.py +4 -0
- dataface/core/render/board_replay.py +136 -0
- dataface/core/render/chart/__init__.py +3 -2
- dataface/core/render/chart/emitters/_channels.py +2 -2
- dataface/core/render/chart/emitters/heatmap.py +1 -2
- dataface/core/render/chart/emitters/scatter.py +5 -9
- dataface/core/render/chart/features/baseline.py +2 -2
- dataface/core/render/chart/features/click_interactivity.py +3 -8
- dataface/core/render/chart/features/endpoint_labels.py +1 -1
- dataface/core/render/chart/features/value_labels.py +1 -3
- dataface/core/render/chart/labels.py +2 -2
- dataface/core/render/chart/presentation.py +6 -9
- dataface/core/render/chart/rendering.py +9 -9
- dataface/core/render/chart/table.py +3 -3
- dataface/core/render/chart/table_support.py +1 -1
- dataface/core/render/chart/translate.py +3 -7
- dataface/core/render/chart/type_inference.py +3 -19
- dataface/core/render/chart/vl_conditions.py +2 -2
- dataface/core/render/controls.py +9 -9
- dataface/core/render/converters/chart.py +2 -1
- dataface/core/render/face_resolve.py +4 -4
- dataface/core/render/face_to_dict.py +7 -1
- dataface/core/render/faces.py +10 -10
- dataface/core/render/format_utils.py +8 -22
- dataface/core/render/layout_sizing.py +4 -3
- dataface/core/render/layouts.py +9 -9
- dataface/core/render/renderer.py +5 -5
- dataface/core/render/sizing.py +69 -22
- dataface/core/render/svg_utils.py +2 -0
- dataface/core/render/variables_strip.py +3 -3
- dataface/core/text/format_d3.py +39 -0
- dataface/data/schemas/board-resolved/board-resolved.schema.json +22760 -0
- {dataface-0.3.1.dev22.dist-info → dataface-0.3.1.dev78.dist-info}/METADATA +3 -1
- {dataface-0.3.1.dev22.dist-info → dataface-0.3.1.dev78.dist-info}/RECORD +81 -71
- mdsvg/images.py +121 -1
- {dataface-0.3.1.dev22.dist-info → dataface-0.3.1.dev78.dist-info}/WHEEL +0 -0
- {dataface-0.3.1.dev22.dist-info → dataface-0.3.1.dev78.dist-info}/entry_points.txt +0 -0
- {dataface-0.3.1.dev22.dist-info → dataface-0.3.1.dev78.dist-info}/licenses/LICENSE +0 -0
d3_format/format.py
CHANGED
|
@@ -7,13 +7,18 @@ Key deviations from Python's standard number formatting:
|
|
|
7
7
|
- SI type uses significant-digit precision (not decimal-place precision).
|
|
8
8
|
- `d` type rounds to nearest integer using round-half-up.
|
|
9
9
|
- `n` type is locale-aware `g` with grouping.
|
|
10
|
+
|
|
11
|
+
``_apply_spec`` follows the shape of d3's own ``format(value)`` closure --
|
|
12
|
+
prefix/suffix assembly, the integer/fraction split, grouping, then padding --
|
|
13
|
+
because the order of those steps is observable: zero-fill is grouped along with
|
|
14
|
+
the digits, and a currency symbol displaces the percent sign.
|
|
10
15
|
"""
|
|
11
16
|
|
|
12
17
|
from __future__ import annotations
|
|
13
18
|
|
|
14
19
|
import math
|
|
15
20
|
from collections.abc import Callable
|
|
16
|
-
from decimal import ROUND_HALF_UP, Decimal
|
|
21
|
+
from decimal import ROUND_HALF_UP, Decimal, localcontext
|
|
17
22
|
from typing import overload
|
|
18
23
|
|
|
19
24
|
from d3_format.spec import FormatSpec, parse
|
|
@@ -45,6 +50,32 @@ _SI_PREFIXES = [
|
|
|
45
50
|
]
|
|
46
51
|
_SI_PREFIX_OFFSET = 8 # index of the '' (10^0) entry
|
|
47
52
|
|
|
53
|
+
# Type letters that select a formatter. Every other letter the grammar admits
|
|
54
|
+
# falls back to ".12~g" (see _resolve_type); "n" is rewritten before this is
|
|
55
|
+
# consulted, so it is deliberately absent.
|
|
56
|
+
_KNOWN_TYPES = frozenset("%bcdefgoprsxX")
|
|
57
|
+
|
|
58
|
+
# Types whose precision counts significant digits (clamped to [1, 21]) rather
|
|
59
|
+
# than digits after the decimal point (clamped to [0, 20]).
|
|
60
|
+
_SIGNIFICANT_PRECISION_TYPES = frozenset("gprs")
|
|
61
|
+
|
|
62
|
+
# Types whose body may carry a fraction or exponent that must be held out of
|
|
63
|
+
# thousands-grouping and zero-padding (d3's `maybeSuffix`).
|
|
64
|
+
_SPLIT_SUFFIX_TYPES = frozenset("defgprs%")
|
|
65
|
+
|
|
66
|
+
# Types the "#" alternate form marks with a radix prefix.
|
|
67
|
+
_ALTERNATE_FORM_TYPES = frozenset("boxX")
|
|
68
|
+
|
|
69
|
+
# Radix types → (base, uppercase output).
|
|
70
|
+
_RADIX_TYPES: dict[str, tuple[int, bool]] = {
|
|
71
|
+
"b": (2, False),
|
|
72
|
+
"o": (8, False),
|
|
73
|
+
"x": (16, False),
|
|
74
|
+
"X": (16, True),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_RADIX_CONVERTERS: dict[int, Callable[[int], str]] = {2: bin, 8: oct, 16: hex}
|
|
78
|
+
|
|
48
79
|
|
|
49
80
|
def _round_half_up(x: float, decimals: int) -> float:
|
|
50
81
|
"""Round x to `decimals` decimal places using round-half-up (like JS Math.round).
|
|
@@ -60,7 +91,44 @@ def _round_half_up(x: float, decimals: int) -> float:
|
|
|
60
91
|
# Use Decimal(x) — the exact IEEE-754 binary value — so rounding matches d3/JS.
|
|
61
92
|
# Decimal(repr(x)) would round "2.55" up to "2.6", but d3 rounds it down to "2.5"
|
|
62
93
|
# because the binary value is 2.5499..., not 2.55.
|
|
63
|
-
|
|
94
|
+
d = Decimal(x)
|
|
95
|
+
with localcontext() as ctx:
|
|
96
|
+
# quantize raises InvalidOperation when the result needs more digits
|
|
97
|
+
# than the context allows, which the default 28 does not for a large
|
|
98
|
+
# magnitude (1e30 as an integer) or a deep precision (.20f).
|
|
99
|
+
ctx.prec = max(ctx.prec, d.adjusted() + decimals + 2)
|
|
100
|
+
return float(d.quantize(quant, rounding=ROUND_HALF_UP))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _js_number_to_string(v: float) -> str:
|
|
104
|
+
"""Replicate JavaScript's ``String(number)``.
|
|
105
|
+
|
|
106
|
+
Python's ``repr`` yields the same shortest-round-trip digits but lays them
|
|
107
|
+
out with different thresholds (``1e-06`` where JS writes ``0.000001``,
|
|
108
|
+
``255.0`` where JS writes ``255``), so the digits are re-rendered per
|
|
109
|
+
ECMA-262 Number::toString: exponential only outside 10^-6 .. 10^21.
|
|
110
|
+
"""
|
|
111
|
+
if math.isnan(v):
|
|
112
|
+
return "NaN"
|
|
113
|
+
if math.isinf(v):
|
|
114
|
+
return "Infinity" if v > 0 else "-Infinity"
|
|
115
|
+
if v == 0:
|
|
116
|
+
return "0" # JS String(-0) is "0"
|
|
117
|
+
sign = "-" if v < 0 else ""
|
|
118
|
+
parts = Decimal(repr(abs(v))).normalize().as_tuple()
|
|
119
|
+
digits = "".join(str(d) for d in parts.digits)
|
|
120
|
+
k = len(digits)
|
|
121
|
+
# ECMA writes the value as 0.<digits> x 10^n.
|
|
122
|
+
n = int(parts.exponent) + k
|
|
123
|
+
if k <= n <= 21:
|
|
124
|
+
return sign + digits + "0" * (n - k)
|
|
125
|
+
if 0 < n <= 21:
|
|
126
|
+
return sign + digits[:n] + "." + digits[n:]
|
|
127
|
+
if -6 < n <= 0:
|
|
128
|
+
return sign + "0." + "0" * -n + digits
|
|
129
|
+
mantissa = digits if k == 1 else digits[0] + "." + digits[1:]
|
|
130
|
+
exp = n - 1
|
|
131
|
+
return f"{sign}{mantissa}e{'+' if exp >= 0 else '-'}{abs(exp)}"
|
|
64
132
|
|
|
65
133
|
|
|
66
134
|
def _fmt_fixed(abs_val: float, decimals: int) -> str:
|
|
@@ -69,35 +137,33 @@ def _fmt_fixed(abs_val: float, decimals: int) -> str:
|
|
|
69
137
|
return f"{rounded:.{decimals}f}"
|
|
70
138
|
|
|
71
139
|
|
|
72
|
-
def
|
|
73
|
-
"""
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
parts = [digits[:first]]
|
|
77
|
-
i = first
|
|
78
|
-
while i < n:
|
|
79
|
-
parts.append(digits[i : i + 3])
|
|
80
|
-
i += 3
|
|
81
|
-
return separator.join(parts)
|
|
140
|
+
def _to_radix(abs_val: float, base: int, upper: bool) -> str:
|
|
141
|
+
"""Replicate JS ``Math.round(x).toString(base)`` for base 2, 8 or 16."""
|
|
142
|
+
digits = _RADIX_CONVERTERS[base](int(_round_half_up(abs_val, 0)))[2:]
|
|
143
|
+
return digits.upper() if upper else digits
|
|
82
144
|
|
|
83
145
|
|
|
84
|
-
def
|
|
85
|
-
"""
|
|
146
|
+
def _group(value: str, width: float) -> str:
|
|
147
|
+
"""Replicate d3's formatGroup for grouping [3] with a ',' separator.
|
|
86
148
|
|
|
87
|
-
|
|
149
|
+
``width`` caps the grouped length; d3 passes infinity except when zero-fill
|
|
150
|
+
padding is grouped along with the digits, where it bounds the total.
|
|
88
151
|
"""
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
152
|
+
i = len(value)
|
|
153
|
+
parts: list[str] = []
|
|
154
|
+
g = 3
|
|
155
|
+
length = 0
|
|
156
|
+
while i > 0 and g > 0:
|
|
157
|
+
if length + g + 1 > width:
|
|
158
|
+
g = max(1, int(width - length))
|
|
159
|
+
i -= g
|
|
160
|
+
parts.append(value[max(0, i) : i + g])
|
|
161
|
+
length += g + 1
|
|
162
|
+
if length > width:
|
|
163
|
+
break
|
|
164
|
+
g = 3
|
|
165
|
+
parts.reverse()
|
|
166
|
+
return ",".join(parts)
|
|
101
167
|
|
|
102
168
|
|
|
103
169
|
def _apply_trim(body: str) -> str:
|
|
@@ -233,7 +299,7 @@ def _js_to_precision(abs_val: float, precision: int) -> str:
|
|
|
233
299
|
|
|
234
300
|
|
|
235
301
|
def _format_rounded(abs_val: float, precision: int) -> str:
|
|
236
|
-
"""Replicate d3's formatRounded(x, p)
|
|
302
|
+
"""Replicate d3's formatRounded(x, p), used by the 'r' and 'p' types.
|
|
237
303
|
|
|
238
304
|
Always uses fixed notation (no e), rounds to p significant digits.
|
|
239
305
|
"""
|
|
@@ -252,273 +318,205 @@ def _format_rounded(abs_val: float, precision: int) -> str:
|
|
|
252
318
|
return coefficient + "0" * (i - n)
|
|
253
319
|
|
|
254
320
|
|
|
255
|
-
def
|
|
256
|
-
"""
|
|
257
|
-
|
|
321
|
+
def _format_exponential(abs_val: float, precision: int) -> str:
|
|
322
|
+
"""Replicate JS Number.prototype.toExponential(p) for the 'e' type."""
|
|
323
|
+
if abs_val == 0.0:
|
|
324
|
+
mantissa = ("0." + "0" * precision) if precision > 0 else "0"
|
|
325
|
+
return mantissa + "e+0"
|
|
326
|
+
coefficient, exponent = _format_decimal_parts(abs_val, precision + 1) # type: ignore[misc]
|
|
327
|
+
# Pad to precision+1 digits in case trailing zeros were stripped
|
|
328
|
+
coefficient = coefficient.ljust(precision + 1, "0")
|
|
329
|
+
if precision > 0:
|
|
330
|
+
mantissa = coefficient[0] + "." + coefficient[1 : precision + 1]
|
|
331
|
+
else:
|
|
332
|
+
mantissa = coefficient[0]
|
|
333
|
+
return f"{mantissa}e{'+' if exponent >= 0 else '-'}{abs(exponent)}"
|
|
258
334
|
|
|
259
335
|
|
|
260
|
-
def
|
|
261
|
-
"""
|
|
262
|
-
|
|
336
|
+
def _format_decimal(abs_val: float) -> str:
|
|
337
|
+
"""Replicate d3's formatDecimal, used by the 'd' type."""
|
|
338
|
+
if abs_val >= 1e21:
|
|
339
|
+
# JS formatDecimal uses x.toLocaleString("en") for |x| >= 1e21.
|
|
340
|
+
# str(x) gives the shortest round-trip repr; Decimal(str()) expands
|
|
341
|
+
# exponential form to the "clean" integer string matching JS.
|
|
342
|
+
s = str(abs_val)
|
|
343
|
+
return str(int(Decimal(s))) if ("e" in s or "E" in s) else str(int(abs_val))
|
|
344
|
+
return str(int(_round_half_up(abs_val, 0)))
|
|
263
345
|
|
|
264
346
|
|
|
265
|
-
def
|
|
266
|
-
"""
|
|
347
|
+
def _format_type(spec: FormatSpec, abs_val: float, precision: int) -> tuple[str, str]:
|
|
348
|
+
"""Replicate d3's ``formatTypes[type](abs_val, precision)``.
|
|
267
349
|
|
|
268
|
-
Returns (
|
|
269
|
-
|
|
270
|
-
- body: formatted digit string
|
|
271
|
-
- suffix: SI prefix char, "%" or ""
|
|
350
|
+
Returns (body, si_prefix); si_prefix mirrors d3's prefixExponent side
|
|
351
|
+
channel and is empty for every type but 's'.
|
|
272
352
|
"""
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
is_neg_zero = is_neg and abs_val == 0.0
|
|
280
|
-
if is_neg_zero and spec.sign != "+":
|
|
281
|
-
is_neg = False
|
|
282
|
-
|
|
283
|
-
if is_neg:
|
|
284
|
-
sign_char = _MINUS
|
|
285
|
-
elif spec.sign == "+":
|
|
286
|
-
sign_char = "+"
|
|
287
|
-
elif spec.sign == " ":
|
|
288
|
-
sign_char = " "
|
|
289
|
-
else:
|
|
290
|
-
sign_char = ""
|
|
353
|
+
if math.isinf(abs_val):
|
|
354
|
+
# d3 has no infinity branch — each formatter stringifies it. Only 'd'
|
|
355
|
+
# (via toLocaleString) and 'X' (via toUpperCase) diverge from "Infinity".
|
|
356
|
+
if spec.type == "d":
|
|
357
|
+
return _INF_SYMBOL, ""
|
|
358
|
+
return ("INFINITY" if spec.type == "X" else "Infinity"), ""
|
|
291
359
|
|
|
292
360
|
t = spec.type
|
|
293
|
-
precision = spec.precision
|
|
294
|
-
|
|
295
361
|
if t == "f":
|
|
296
|
-
prec = precision if precision is not None else 6
|
|
297
|
-
# JS Number.prototype.toFixed() falls back to toString() for |x| >= 10^21.
|
|
298
|
-
body = str(abs_val) if abs_val >= 1e21 else _fmt_fixed(abs_val, prec)
|
|
299
|
-
suffix = ""
|
|
300
|
-
|
|
301
|
-
elif t == "%":
|
|
302
|
-
prec = precision if precision is not None else 6
|
|
303
|
-
scaled = abs_val * 100.0
|
|
304
|
-
# toFixed fallback: |x * 100| >= 10^21 → toString(x * 100)
|
|
305
|
-
body = str(scaled) if scaled >= 1e21 else _fmt_fixed(scaled, prec)
|
|
306
|
-
suffix = "%"
|
|
307
|
-
|
|
308
|
-
elif t == "e":
|
|
309
|
-
prec = precision if precision is not None else 6
|
|
310
|
-
if abs_val == 0.0:
|
|
311
|
-
body = ("0." + "0" * prec if prec > 0 else "0") + "e+0"
|
|
312
|
-
else:
|
|
313
|
-
d = _format_decimal_parts(abs_val, prec + 1)
|
|
314
|
-
coefficient, exponent = d # type: ignore[misc]
|
|
315
|
-
# Pad to prec+1 digits in case trailing zeros were stripped
|
|
316
|
-
coefficient = coefficient.ljust(prec + 1, "0")
|
|
317
|
-
if prec > 0:
|
|
318
|
-
mantissa = coefficient[0] + "." + coefficient[1 : prec + 1]
|
|
319
|
-
else:
|
|
320
|
-
mantissa = coefficient[0]
|
|
321
|
-
exp_sign = "+" if exponent >= 0 else "-"
|
|
322
|
-
body = f"{mantissa}e{exp_sign}{abs(exponent)}"
|
|
323
|
-
suffix = ""
|
|
324
|
-
|
|
325
|
-
elif t in ("g", ""):
|
|
326
|
-
# d3's formatDefault (empty type) uses toPrecision(12) as the default precision.
|
|
327
|
-
# 'g' type uses the spec precision (default 6).
|
|
328
|
-
prec = precision if precision is not None else (12 if t == "" else 6)
|
|
329
|
-
body = _format_general(abs_val, prec)
|
|
330
|
-
suffix = ""
|
|
331
|
-
|
|
332
|
-
elif t == "r":
|
|
333
|
-
prec = precision if precision is not None else 6
|
|
334
|
-
body = _format_rounded(abs_val, prec)
|
|
335
|
-
suffix = ""
|
|
336
|
-
|
|
337
|
-
elif t == "s":
|
|
338
|
-
prec = precision if precision is not None else 6
|
|
339
|
-
body, si_suffix = _format_si(abs_val, prec)
|
|
340
|
-
suffix = si_suffix
|
|
341
|
-
|
|
342
|
-
elif t == "d":
|
|
343
362
|
if abs_val >= 1e21:
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
def _assemble(
|
|
369
|
-
spec: FormatSpec,
|
|
370
|
-
sign_char: str,
|
|
371
|
-
body: str,
|
|
372
|
-
suffix: str,
|
|
373
|
-
) -> str:
|
|
374
|
-
"""Assemble sign + symbol + body + suffix, apply width/fill/align."""
|
|
375
|
-
currency = "$" if spec.symbol == "$" else ""
|
|
376
|
-
|
|
377
|
-
# Parens sign mode.
|
|
378
|
-
if spec.sign == "(" and sign_char == _MINUS:
|
|
379
|
-
paren_open = "("
|
|
380
|
-
paren_close = ")"
|
|
381
|
-
sign_char = ""
|
|
382
|
-
else:
|
|
383
|
-
paren_open = ""
|
|
384
|
-
paren_close = ""
|
|
363
|
+
return (
|
|
364
|
+
_js_number_to_string(abs_val),
|
|
365
|
+
"",
|
|
366
|
+
) # JS toFixed falls back to toString
|
|
367
|
+
return _fmt_fixed(abs_val, precision), ""
|
|
368
|
+
if t == "%":
|
|
369
|
+
scaled = abs_val * 100.0
|
|
370
|
+
if scaled >= 1e21:
|
|
371
|
+
return _js_number_to_string(scaled), ""
|
|
372
|
+
return _fmt_fixed(scaled, precision), ""
|
|
373
|
+
if t == "p":
|
|
374
|
+
return _format_rounded(abs_val * 100.0, precision), ""
|
|
375
|
+
if t == "e":
|
|
376
|
+
return _format_exponential(abs_val, precision), ""
|
|
377
|
+
if t == "g":
|
|
378
|
+
return _js_to_precision(abs_val, precision), ""
|
|
379
|
+
if t == "r":
|
|
380
|
+
return _format_rounded(abs_val, precision), ""
|
|
381
|
+
if t == "s":
|
|
382
|
+
return _format_si(abs_val, precision)
|
|
383
|
+
if t == "d":
|
|
384
|
+
return _format_decimal(abs_val), ""
|
|
385
|
+
base, upper = _RADIX_TYPES[t]
|
|
386
|
+
return _to_radix(abs_val, base, upper), ""
|
|
385
387
|
|
|
386
|
-
# Assemble the non-padded string.
|
|
387
|
-
if paren_open:
|
|
388
|
-
full = paren_open + currency + body + suffix + paren_close
|
|
389
|
-
else:
|
|
390
|
-
full = sign_char + currency + body + suffix
|
|
391
388
|
|
|
392
|
-
|
|
393
|
-
|
|
389
|
+
def _body_is_zero(body: str) -> bool:
|
|
390
|
+
"""True when JS's ``+body`` coercion would be 0 (d3's rounds-to-zero test).
|
|
394
391
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
392
|
+
A body JS coerces to NaN — a radix body like "ff", or "Infinity", or "∞" —
|
|
393
|
+
is not zero, so an unparseable body keeps its sign.
|
|
394
|
+
"""
|
|
395
|
+
try:
|
|
396
|
+
return float(body) == 0.0
|
|
397
|
+
except ValueError:
|
|
398
|
+
return False
|
|
400
399
|
|
|
401
|
-
if spec.zero:
|
|
402
|
-
# Zero-pad: sign+currency, then zeros, then body+suffix.
|
|
403
|
-
# parens+zero is rejected at parse time, so paren_open is never set here.
|
|
404
|
-
prefix = sign_char + currency
|
|
405
|
-
rest = body + suffix
|
|
406
|
-
return prefix + "0" * needed + rest
|
|
407
400
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
return
|
|
401
|
+
def _prefix(spec: FormatSpec) -> str:
|
|
402
|
+
"""The leading unit: a currency symbol, or the alternate-form radix marker."""
|
|
403
|
+
if spec.symbol == "$":
|
|
404
|
+
return "$"
|
|
405
|
+
if spec.symbol == "#" and spec.type in _ALTERNATE_FORM_TYPES:
|
|
406
|
+
return "0" + spec.type.lower()
|
|
407
|
+
return ""
|
|
412
408
|
|
|
413
|
-
if spec.align == "<":
|
|
414
|
-
return full + spec.fill * needed
|
|
415
|
-
elif spec.align == ">":
|
|
416
|
-
return spec.fill * needed + full
|
|
417
|
-
else: # "^"
|
|
418
|
-
left = needed // 2
|
|
419
|
-
right = needed - left
|
|
420
|
-
return spec.fill * left + full + spec.fill * right
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
def _assemble_special(
|
|
424
|
-
spec: FormatSpec,
|
|
425
|
-
text: str,
|
|
426
|
-
is_neg: bool,
|
|
427
|
-
) -> str:
|
|
428
|
-
"""Assemble NaN or Infinity string with sign/currency/width.
|
|
429
|
-
|
|
430
|
-
NaN: sign chars from spec apply (+ shows +, space shows space).
|
|
431
|
-
Infinity: actual sign from is_neg.
|
|
432
|
-
"""
|
|
433
|
-
if is_neg:
|
|
434
|
-
sign_char = _MINUS
|
|
435
|
-
elif spec.sign == "+":
|
|
436
|
-
sign_char = "+"
|
|
437
|
-
elif spec.sign == " ":
|
|
438
|
-
sign_char = " "
|
|
439
|
-
else:
|
|
440
|
-
sign_char = ""
|
|
441
409
|
|
|
442
|
-
|
|
410
|
+
def _value_suffix(spec: FormatSpec) -> str:
|
|
411
|
+
"""The trailing unit. A currency symbol displaces the percent sign."""
|
|
412
|
+
if spec.symbol == "$":
|
|
413
|
+
return ""
|
|
414
|
+
return "%" if spec.type in ("%", "p") else ""
|
|
443
415
|
|
|
444
|
-
# Parens mode for negative infinity.
|
|
445
|
-
if spec.sign == "(" and is_neg:
|
|
446
|
-
full = "(" + currency + text + ")"
|
|
447
|
-
sign_char = ""
|
|
448
|
-
else:
|
|
449
|
-
full = sign_char + currency + text
|
|
450
416
|
|
|
451
|
-
|
|
452
|
-
|
|
417
|
+
def _pad(spec: FormatSpec, value_prefix: str, value: str, value_suffix: str) -> str:
|
|
418
|
+
"""Group the digits, then pad to width and align — d3's closing steps.
|
|
453
419
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
420
|
+
Grouping appears twice for a reason: without zero-fill d3 groups the digits
|
|
421
|
+
alone, with zero-fill it groups the padding *and* the digits together so the
|
|
422
|
+
separators land inside the padded field.
|
|
423
|
+
"""
|
|
424
|
+
if spec.comma and not spec.zero:
|
|
425
|
+
value = _group(value, math.inf)
|
|
426
|
+
|
|
427
|
+
length = len(value_prefix) + len(value) + len(value_suffix)
|
|
428
|
+
if spec.width is not None and spec.width > length:
|
|
429
|
+
padding = spec.fill * (spec.width - length)
|
|
430
|
+
else:
|
|
431
|
+
padding = ""
|
|
459
432
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
433
|
+
if spec.comma and spec.zero:
|
|
434
|
+
width = (spec.width - len(value_suffix)) if padding and spec.width else math.inf
|
|
435
|
+
value = _group(padding + value, width)
|
|
436
|
+
padding = ""
|
|
464
437
|
|
|
465
438
|
if spec.align == "<":
|
|
466
|
-
return
|
|
467
|
-
|
|
468
|
-
return
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
439
|
+
return value_prefix + value + value_suffix + padding
|
|
440
|
+
if spec.align == "=":
|
|
441
|
+
return value_prefix + padding + value + value_suffix
|
|
442
|
+
if spec.align == "^":
|
|
443
|
+
half = len(padding) // 2
|
|
444
|
+
return padding[:half] + value_prefix + value + value_suffix + padding[half:]
|
|
445
|
+
return padding + value_prefix + value + value_suffix
|
|
473
446
|
|
|
474
447
|
|
|
475
|
-
def
|
|
476
|
-
"""Apply
|
|
477
|
-
v = float(v) # coerce int, Decimal, etc.; preserves -0 sign via IEEE 754
|
|
448
|
+
def _resolve_type(spec: FormatSpec) -> None:
|
|
449
|
+
"""Apply d3's type aliases in place, before any formatting decision.
|
|
478
450
|
|
|
479
|
-
|
|
480
|
-
|
|
451
|
+
``n`` is ``,g``. The empty type and any letter that selects no formatter are
|
|
452
|
+
alike ``.12~g`` — d3 does not reject an unrecognised type letter, it falls
|
|
453
|
+
back to a general-purpose format, so neither do we.
|
|
454
|
+
"""
|
|
455
|
+
if spec.type == "n":
|
|
456
|
+
spec.comma = True
|
|
457
|
+
spec.type = "g"
|
|
458
|
+
elif spec.type not in _KNOWN_TYPES:
|
|
459
|
+
if spec.precision is None:
|
|
460
|
+
spec.precision = 12
|
|
461
|
+
spec.trim = True
|
|
462
|
+
spec.type = "g"
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _resolve_precision(spec: FormatSpec) -> int:
|
|
466
|
+
"""d3's precision default and per-type clamp. Call after ``_resolve_type``."""
|
|
467
|
+
if spec.precision is None:
|
|
468
|
+
return 6
|
|
469
|
+
if spec.type in _SIGNIFICANT_PRECISION_TYPES:
|
|
470
|
+
return max(1, min(21, spec.precision))
|
|
471
|
+
return max(0, min(20, spec.precision))
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _apply_spec(spec: FormatSpec, precision: int, v: float | int | Decimal) -> str:
|
|
475
|
+
"""Apply a resolved FormatSpec to a numeric value, mirroring d3's format()."""
|
|
476
|
+
value_prefix = _prefix(spec)
|
|
477
|
+
value_suffix = _value_suffix(spec)
|
|
478
|
+
x = float(v) # coerce int, Decimal, etc.; preserves -0 sign via IEEE 754
|
|
479
|
+
|
|
480
|
+
if spec.type == "c":
|
|
481
|
+
# Character data: the value is stringified verbatim and carries its own
|
|
482
|
+
# ASCII hyphen, so the sign, trim and split steps are all skipped.
|
|
483
|
+
return _pad(spec, value_prefix, "", _js_number_to_string(x) + value_suffix)
|
|
484
|
+
|
|
485
|
+
if math.isnan(x):
|
|
486
|
+
# d3 short-circuits NaN ahead of the type function, which is why 'X'
|
|
487
|
+
# does not uppercase it the way it uppercases "INFINITY".
|
|
488
|
+
is_negative = False
|
|
489
|
+
body, si_prefix = "NaN", ""
|
|
490
|
+
else:
|
|
491
|
+
is_negative = x < 0 or math.copysign(1.0, x) < 0
|
|
492
|
+
body, si_prefix = _format_type(spec, abs(x), precision)
|
|
481
493
|
|
|
482
|
-
|
|
483
|
-
|
|
494
|
+
if spec.trim:
|
|
495
|
+
body = _apply_trim(body)
|
|
484
496
|
|
|
485
|
-
#
|
|
486
|
-
|
|
487
|
-
|
|
497
|
+
# A negative value that formats to zero loses its sign unless the spec asks
|
|
498
|
+
# for an explicit one.
|
|
499
|
+
if is_negative and spec.sign != "+" and _body_is_zero(body):
|
|
500
|
+
is_negative = False
|
|
488
501
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
return _assemble_special(spec, text, is_neg=is_neg)
|
|
502
|
+
if is_negative:
|
|
503
|
+
value_prefix = ("(" if spec.sign == "(" else _MINUS) + value_prefix
|
|
504
|
+
elif spec.sign not in ("-", "("):
|
|
505
|
+
value_prefix = spec.sign + value_prefix
|
|
494
506
|
|
|
495
|
-
|
|
496
|
-
|
|
507
|
+
value_suffix = si_prefix + value_suffix
|
|
508
|
+
if is_negative and spec.sign == "(":
|
|
509
|
+
value_suffix += ")"
|
|
497
510
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
511
|
+
if spec.type in _SPLIT_SUFFIX_TYPES:
|
|
512
|
+
# Hold the fraction/exponent out of grouping and zero-padding.
|
|
513
|
+
for i, char in enumerate(body):
|
|
514
|
+
if not "0" <= char <= "9":
|
|
515
|
+
value_suffix = body[i:] + value_suffix
|
|
516
|
+
body = body[:i]
|
|
517
|
+
break
|
|
502
518
|
|
|
503
|
-
|
|
504
|
-
# For 's' type, comma is applied to the mantissa (matters for very large SI bodies).
|
|
505
|
-
use_comma = spec.comma or spec.type == "n"
|
|
506
|
-
if use_comma:
|
|
507
|
-
if spec.zero and spec.width is not None:
|
|
508
|
-
# d3 applies comma grouping to the zero-padded digits.
|
|
509
|
-
# Pre-pad body to the largest digit count that fits the target width.
|
|
510
|
-
sign_len = len(sign_char) + (1 if spec.symbol == "$" else 0)
|
|
511
|
-
available = spec.width - sign_len - len(suffix)
|
|
512
|
-
d = len(body)
|
|
513
|
-
while True:
|
|
514
|
-
nd = d + 1
|
|
515
|
-
if nd + (nd - 1) // 3 > available:
|
|
516
|
-
break
|
|
517
|
-
d = nd
|
|
518
|
-
body = body.zfill(d)
|
|
519
|
-
body = _apply_grouping(body, ",", ".")
|
|
520
|
-
|
|
521
|
-
return _assemble(spec, sign_char, body, suffix)
|
|
519
|
+
return _pad(spec, value_prefix, body, value_suffix)
|
|
522
520
|
|
|
523
521
|
|
|
524
522
|
@overload
|
|
@@ -548,12 +546,14 @@ def format( # noqa: A001
|
|
|
548
546
|
A ``Callable[[float], str]`` if value is None, else the formatted string.
|
|
549
547
|
|
|
550
548
|
Raises:
|
|
551
|
-
D3FormatError: If spec_str
|
|
549
|
+
D3FormatError: If spec_str does not match the d3-format grammar.
|
|
552
550
|
"""
|
|
553
551
|
spec = parse(spec_str)
|
|
552
|
+
_resolve_type(spec)
|
|
553
|
+
precision = _resolve_precision(spec)
|
|
554
554
|
|
|
555
555
|
def _fmt(v: float | int | Decimal) -> str:
|
|
556
|
-
return _apply_spec(spec, v)
|
|
556
|
+
return _apply_spec(spec, precision, v)
|
|
557
557
|
|
|
558
558
|
if value is None:
|
|
559
559
|
return _fmt
|