Skip to content

fix(reports): apply chart number and currency formatting to tables sent as text - #42820

Open
sadpandajoe wants to merge 13 commits into
masterfrom
fix-reports-text-number-formatting
Open

fix(reports): apply chart number and currency formatting to tables sent as text#42820
sadpandajoe wants to merge 13 commits into
masterfrom
fix-reports-text-number-formatting

Conversation

@sadpandajoe

Copy link
Copy Markdown
Member

SUMMARY

When an Alert/Report sends a Table or Pivot Table chart as text (embedded in the email body), the number and currency formatting configured on the chart was not applied, so the email showed raw values.

superset/charts/client_processing.py reproduces the chart's client-side formatting on the server for reports. For Table it attempted only d3NumberFormat, by pasting the d3 format string straight into Python's str.format mini-language — a different grammar — wrapped in a bare except: pass whose comment reads "if we can't format the column for any reason, send as is".

The two grammars overlap only narrowly, so most specifiers raised and silently fell back to the raw value: .1s, $,.2f, ~g and the default SMART_NUMBER all fail outright. Even ,d works only for int — it raises Unknown format code 'd' on float, and pandas metric columns are float64, so it failed for ordinary counts and sums too. Specs valid in both grammars, such as ,.2f, were the only ones that came through. Nothing was logged, so the failure was invisible rather than merely wrong.

currencyFormat was never read on that path at all, and pivot_table_v2() applied no formatting whatsoever.

This adds superset/utils/number_format.py, a Python port of the frontend formatters — d3-format, createSmartNumberFormatter.ts and CurrencyFormatter.ts — reusing Babel (already a dependency) for currency symbols, and wires it into table() and pivot_table_v2().

This supersedes #41028, which was opened against a fork that is no longer maintained. The original work is carried here with attribution, plus the review feedback that PR had accumulated. Thanks to @massucattoj for the original implementation.

WHAT'S INCLUDED BEYOND THE ORIGINAL

The commits are grouped by intent and are best read in order.

Review feedback from #41028

  • Saved metric formats are no longer dropped. Both frontend plugins read per-metric formats from the datasource's column_formats, while the server read only columnFormats from form data — and table() ignored the datasource entirely. Datasource formats are now merged with chart-level overrides, chart winning, matching plugin-chart-table/src/transformProps.ts and PivotTableChart.tsx.
  • Currency symbol placement respects the configured locale instead of a hard-coded en_US, so reports agree with Explore on non-English deployments. Reports run in a Celery worker with no request context, so resolution falls back through BABEL_DEFAULT_LOCALE to a safe default.
  • Per-row/per-cell currency context is supported. The query already returns the currency column and the payload retains it; the association was being lost in pivot_df. A parallel pivot now carries the set of contributing currencies through cells, totals and subtotals. A cell backed by a single currency renders that symbol, a cell mixing currencies stays neutral, and an empty context follows the existing fallback.

Columns relying on the implicit default

The browser gives every numeric metric column a formatter even when nothing is configured — getNumberFormatter(undefined) resolves to the registry default, SMART_NUMBER. Report text previously formatted only columns with an explicit format, so an aggregate metric with no configured format showed 1.23M in Explore and 1234567 in the email. That is the same symptom this PR targets, in its most common form.

Table now applies the same defaults as the plugin: PERCENT_3_POINT for percent metrics, SMART_NUMBER for numeric metrics with no explicit format. Numeric dimension columns are deliberately left untouched, matching transformProps.ts — there is a test asserting a numeric dimension stays raw while its sibling metric formats.

This gap is Table-specific in practice. The Pivot Table's valueFormat control carries a SMART_NUMBER default that is persisted into saved form data, so pivots created through the UI were already covered; Table's is a per-column formatter the browser synthesizes at render time, so nothing is ever persisted for it. Pivot still gets a small defensive fallback for charts that never persisted the key (API-created or older), which is a no-op wherever valueFormat is present.

A crash, not just a formatting miss

Carrying per-row currency through a pivot left the parallel currency structure with NaN in the empty cells of a sparse cross-product, which raised TypeError outside any error handling and failed report generation entirely. A missing or non-iterable currency context is now coerced to the empty-context path, which resolves the same way it always did (single currency → that symbol, mixed → neutral, empty → detected fallback).

d3 parity fixes

These came from diffing the port against regenerated d3-format output rather than from reading the Python:

  • whole-valued floats no longer gain a trailing .0 under , and +, — pandas metric columns are float64, so this affected the common case of counts and sums
  • f/%/e/s/r use d3-compatible rounding rather than Python's half-to-even, so money presets like ,.2f and $,.2f match the browser
  • default/, formatting follows d3's exponent thresholds instead of forcing fixed-point
  • ~g keeps small values in fixed notation instead of switching to scientific
  • .0s clamps to one significant digit rather than rendering 0k

Failing loudly instead of quietly

The specifier parser previously accepted d3 grammar it did not implement, which is the worst case: a valid user-entered format silently rendering a confidently wrong number. Accounting parentheses and space-sign are now implemented; fill, align, zero-pad and width are rejected explicitly. Rejected formats preserve the raw value, which is visible and correctable.

KNOWN LIMITATION

The specialized preset families — DURATION, DURATION_SUB, DURATION_COL, MEMORY_DECIMAL, MEMORY_BINARY, MEMORY_TRANSFER_RATE_* and the length formatters — are not ported. They are rejected explicitly and fall back to the raw value, so a chart using one of them still shows an unformatted number in report text.

This is deliberate: those are locale-aware formatter factories rather than d3 format strings, and porting them faithfully is a separate piece of work. They are called out here rather than left to be discovered, since this PR does not make report formatting complete — it makes the d3 and currency formats work and makes the rest fail visibly instead of silently.

TESTING INSTRUCTIONS

  1. Create a Table or Pivot Table chart and configure a number format (e.g. .1s, ,.2f) and/or a currency on a column.
  2. Open the endpoint reports use to build the embedded table:
    /api/v1/chart/<chart_id>/data/?type=post_processed&format=json
    Numeric columns with a configured format now render formatted (8k, $ 1,234.50) instead of raw.
  3. Full path: configure an Alert/Report to send the chart as text and confirm the email body table is formatted.
  4. Unit tests:
    pytest tests/unit_tests/utils/number_format_test.py tests/unit_tests/charts/test_client_processing.py
    
    163 tests. The parity suite asserts against values captured from real d3-format output rather than from this implementation.

CSV/XLSX attachments keep raw numeric values and column types for downstream analysis — formatting applies to the JSON path only. This is a behavior change from before (the old code formatted regardless of result format) and is noted in UPDATING.md.

ADDITIONAL INFORMATION

  • Has associated issue:
  • Required feature flags:
  • Changes UI
  • Includes DB Migration (follow approval process in SIP-59)
    • Migration is atomic, supports rollback & is backwards-compatible
    • Confirm DB migration upgrade and downgrade tested
    • Runtime estimates and downtime expectations provided
  • Introduces new feature or API
  • Removes existing feature or API

sadpandajoe and others added 10 commits August 6, 2026 00:16
…nt as text

When an Alert/Report sends a Table or Pivot Table chart as text embedded in
the email body, the number and currency formats configured on the chart were
not applied, so the email showed raw values.

superset/charts/client_processing.py reproduces the chart's client-side
formatting on the server for reports. For Table it attempted only
d3NumberFormat, pasting the d3 format string straight into Python's
str.format mini-language -- a different grammar -- inside a bare except.
So ",d" happened to work while the common cases (SMART_NUMBER, SI like ".1s",
currency like "$,.2f") raised and silently fell back to the raw value, and
currencyFormat was never read. Pivot Table applied no formatting at all.

Adds superset/utils/number_format.py, a Python port of the frontend
d3-format, createSmartNumberFormatter and CurrencyFormatter behavior, reusing
Babel for currency symbols, and wires it into table() and pivot_table_v2().

Originally authored in #41028; rebuilt here on a maintained branch.
Co-authored-by: Jean Massucatto <massucattoj@gmail.com>
Addresses review feedback left unresolved on #41028.

Saved metric formats were dropped: both frontend plugins read per-metric
formats from datasource column_formats, while the server read columnFormats
from form data only, and table() ignored datasource entirely. The datasource
formats are now merged with chart-level overrides, with the chart override
winning, matching plugin-chart-table transformProps and PivotTableChart.

Default formatting now follows d3's exponent thresholds instead of forcing
fixed-point, so very small and very large values render as they do in the
browser.

Currency symbol placement is resolved from the request or configured locale
rather than a hard-coded en_US, so reports agree with Explore on non-English
deployments. Reports execute in a Celery worker with no request context, so
resolution falls back through BABEL_DEFAULT_LOCALE to a safe default.
Parity gaps found by diffing the port against regenerated d3-format output:

- whole-valued floats no longer gain a trailing ".0" under "," and "+,";
  pandas metric columns are float64, so this affected the common case
- f/%/e/s/r now use d3-compatible binary-float rounding, so money presets
  such as ",.2f" and "$,.2f" match the browser
- "~g" keeps small values in fixed notation instead of switching to
  scientific
- ".0s" clamps to one significant digit rather than rendering "0k"

Extends the parity matrix with float-valued inputs and boundary cases.
The specifier parser accepted d3 grammar it did not implement, so valid
user-entered formats were silently mis-rendered.

Accounting parentheses and space-sign are now implemented. Fill, align,
zero-pad and width flags are rejected explicitly instead of being parsed and
ignored, and the specialized duration, memory, transfer-rate and length
preset families are rejected explicitly rather than silently mis-formatting.
Rejected specifiers preserve the raw value, which is visible and correctable,
instead of returning a confidently wrong number.
format_column and apply_pivot_number_formats were only exercised indirectly
through pivot_table_v2, and resolve_symbol_position had no coverage at all.

Adds direct tests for the happy path, the error path and the documented
fallback behavior of each, including currency symbol position for locales
where placement differs.
Adds docstrings to the formatter functions naming the frontend behavior each
one mirrors, so the correspondence survives future changes on either side,
and adds the missing type annotations on new functions and constants.
AUTO currency resolved only to the query-wide detected currency, so a chart
carrying per-row currency context rendered differently in a report than in
Explore.

The query already returns the currency column and the payload retains it;
the association was being lost in pivot_df. A parallel pivot now carries the
set of contributing currencies through cells, totals and subtotals. A cell
backed by a single currency renders that symbol, a cell mixing currencies
stays neutral, and an empty context follows the existing fallback.
A pivot with both `groupbyRows` and `groupbyColumns` has empty cross-product
cells. `pivot_table` never runs the currency `union` aggregator for a missing
combination, so it fills those cells with a scalar `NaN` rather than an empty
tuple. That `NaN` reached `resolve_auto_currency`, whose `list(currency_context)`
raised `TypeError: 'float' object is not iterable`. Because the call sits outside
the formatting try/except, it propagated out of the post-processor and failed the
whole report instead of degrading to the raw value like every other formatting
path.

Coerce a non-iterable or `NaN` context to an empty context inside
`resolve_auto_currency` itself, where no caller can bypass it, so AUTO resolution
follows the existing empty-context path (single currency -> that symbol, mixed ->
neutral, empty -> detected fallback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Table plugin gives every metric column a formatter even when nothing is
configured: `getNumberFormatter(undefined)` resolves through the registry's
default key to SMART_NUMBER, and percent metrics default to PERCENT_3_POINT
(see `plugin-chart-table/src/transformProps.ts`). The report port only iterated
columns that had a saved format, saved currency, or `column_config` entry, so an
aggregate metric with no explicit format rendered `1.2M` in Explore but the raw
`1234567` in the email body -- the exact "raw numbers in the email" symptom this
change set exists to fix, in its most common (default) form.

Rewrite the `table()` column loop to mirror the plugin's per-column selection:
percent metrics default to PERCENT_3_POINT, every numeric metric gets a formatter
defaulting to SMART_NUMBER, and other numeric columns are formatted only when an
explicit format or currency is set -- so numeric dimensions the browser leaves
alone stay raw. `pivot_table_v2` had the same hole: the pivot component's
`getNumberFormatter(valueFormat)` also defaults to SMART_NUMBER, but an empty
`valueFormat` with no per-metric format left cells raw, so apply the same default
in `apply_pivot_number_formats`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The pre-fix `table()` applied `d3NumberFormat` to every result format, so CSV and
XLSX exports contained pre-formatted strings. The report path now returns early
for CSV/XLSX before formatting, keeping numeric values and column types (better
for downstream analysis) while the rendered email body still gets the chart's
formatting. Document this user-visible change under the unreleased "Next" section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 16.38418% with 296 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.26%. Comparing base (fe06ebe) to head (ec2b949).
⚠️ Report is 6 commits behind head on master.

Files with missing lines Patch % Lines
superset/utils/number_format.py 19.06% 174 Missing ⚠️
superset/charts/client_processing.py 12.23% 122 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #42820      +/-   ##
==========================================
- Coverage   66.37%   66.26%   -0.11%     
==========================================
  Files        2857     2858       +1     
  Lines      161048   161390     +342     
  Branches    37046    37113      +67     
==========================================
+ Hits       106892   106947      +55     
- Misses      52141    52427     +286     
- Partials     2015     2016       +1     
Flag Coverage Δ
hive 38.17% <16.38%> (-0.10%) ⬇️
mysql 57.57% <16.38%> (-0.18%) ⬇️
postgres 57.62% <16.38%> (-0.19%) ⬇️
presto 40.12% <16.38%> (-0.11%) ⬇️
python 59.01% <16.38%> (-0.19%) ⬇️
sqlite 57.24% <16.38%> (-0.18%) ⬇️
unit 100.00% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@sadpandajoe
sadpandajoe marked this pull request as ready for review August 6, 2026 00:50
@dosubot dosubot Bot added alert-reports Namespace | Anything related to the Alert & Reports feature viz:charts:pivot Related to the Pivot Table charts viz:charts:table Related to the Table chart labels Aug 6, 2026
`resolve_auto_currency` declared `currency_context: Iterable[Any] | None`, but
the sparse-pivot guard exists precisely because pandas hands the function a
scalar `NaN` float for a missing cross-product cell. mypy correctly flagged the
test that passes that NaN as an incompatible `float` argument -- the annotation
described a contract the runtime no longer honours.

Widen the parameter to `Iterable[Any] | float | None` (narrow, so it documents
that the only non-iterable that actually occurs on the live path is the pandas
NaN) and reshape the guard to test positively for an iterable
(`list(context) if isinstance(context, Iterable) else []`). That keeps mypy
happy -- it narrows the union to the iterable arm, which a bare try/except
could not -- while restoring defense-in-depth: any non-iterable sentinel
(`np.nan`, `pd.NA`, `pd.NaT`) falls to the empty-context path rather than
raising and taking down the whole report. Document the behavior in the parameter
docstring where a future maintainer would look before touching the guard. No
behavior change on the live path; the failing test keeps calling the function
the way pandas does, and now also pins the `pd.NA`/`pd.NaT` sentinels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sadpandajoe
sadpandajoe marked this pull request as draft August 6, 2026 01:50
@sadpandajoe
sadpandajoe marked this pull request as ready for review August 6, 2026 06:13
@dosubot dosubot Bot added the change:backend Requires changing the backend label Aug 6, 2026
@bito-code-review

bito-code-review Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #f42b66

Actionable Suggestions - 0
Additional Suggestions - 2
  • superset/utils/number_format.py - 1
    • Dead code in production module · Line 344-344
      Function `format_default` at line 344 is defined but never called from any production source file. It appears to be a helper that was planned but never integrated — possibly superseded by the `format_general` call at line 298.
  • superset/charts/client_processing.py - 1
    • Dead code: unused import · Line 28-28
      The `functools.partial` import is added but never used — grep confirms zero `partial(` function calls in the file. Unused imports are dead code that increase cognitive load and slow static analysis. Remove the import to keep the file clean.
Filtered by Review Rules

Bito filtered these suggestions based on rules created automatically for your feedback. Manage rules.

  • superset/charts/client_processing.py - 1
    • Percent metrics bypass apply_number_format guard · Line 689-689
  • superset/utils/number_format.py - 1
Review Details
  • Files reviewed - 4 · Commit Range: 58a3c7c..2b49fe9
    • superset/charts/client_processing.py
    • superset/utils/number_format.py
    • tests/unit_tests/charts/test_client_processing.py
    • tests/unit_tests/utils/number_format_test.py
  • Files skipped - 1
    • UPDATING.md - Reason: Filter setting
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@bito-code-review

bito-code-review Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #f42b66

Actionable Suggestions - 0
Filtered by Review Rules

Bito filtered these suggestions based on rules created automatically for your feedback. Manage rules.

  • superset/charts/client_processing.py - 1
  • superset/utils/number_format.py - 1
Review Details
  • Files reviewed - 4 · Commit Range: 58a3c7c..2b49fe9
    • superset/charts/client_processing.py
    • superset/utils/number_format.py
    • tests/unit_tests/charts/test_client_processing.py
    • tests/unit_tests/utils/number_format_test.py
  • Files skipped - 1
    • UPDATING.md - Reason: Filter setting
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@sadpandajoe
sadpandajoe force-pushed the fix-reports-text-number-formatting branch from 2b49fe9 to ec2b949 Compare August 7, 2026 16:02
@bito-code-review

bito-code-review Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #ba02ca

Actionable Suggestions - 0
Additional Suggestions - 2
  • superset/utils/number_format.py - 2
    • Missing test for internal helper · Line 494-502
      `decimals_for_significant` is an internal helper called exclusively by `format_significant` (line 460). While it is exercised indirectly via `format_significant` tests, it has no dedicated test. Rule [11730] requires comprehensive unit tests for new features covering edge cases. Without a direct test, a regression in the decimal-place logic (e.g. off-by-one in the `max(0, ...)` boundary) could go undetected.
    • Missing test for internal helper · Line 514-520
      `normalize_exponent` is called on one code path in `format_d3_magnitude` (line 307, when type is `f`/`%` and value >= 1e21). It has no dedicated unit test. Rule [11730] requires edge-case coverage for new functions. Without a direct test, a regex regression (e.g. missing uppercase `E` handling) would only surface on large-value percent formats.
Review Details
  • Files reviewed - 4 · Commit Range: 58a3c7c..ec2b949
    • superset/charts/client_processing.py
    • superset/utils/number_format.py
    • tests/unit_tests/charts/test_client_processing.py
    • tests/unit_tests/utils/number_format_test.py
  • Files skipped - 1
    • UPDATING.md - Reason: Filter setting
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

alert-reports Namespace | Anything related to the Alert & Reports feature change:backend Requires changing the backend size/XXL viz:charts:pivot Related to the Pivot Table charts viz:charts:table Related to the Table chart

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant