Skip to content

Commit 4d9207a

Browse files
feat: Add global locale support for number parsing (#1165)
## Summary Adds a `locale` parameter to top-level API functions (`allotrope_from_io`, `allotrope_from_file`, etc.) to support parsing numbers in locale-specific formats. - Add `locale_number_parser` module using Babel's CLDR data for locale-aware number parsing - Add `locale_context` module for thread-safe locale management using ContextVar - Update `try_float()` to respect locale context when parsing numbers - Add `locale` parameter to all top-level API functions in `to_allotrope.py` - Add `babel >= 2.9.0` dependency - Support for US (1,234.56), German (1.234,56), French (1 234,56) formats - Handles scientific notation, NaN, infinity with locale-specific separators - Falls back to default parsing if locale parsing fails ## Test plan - [x] All existing tests pass (546 tests) - [x] Added 29 tests for locale number parsing (formats, edge cases, error handling) - [x] Added 8 tests for locale context management and integration - [x] Added 3 API integration tests for locale parameter - [x] Linting and type checking pass **Usage example:** ```python from allotropy.to_allotrope import allotrope_from_file # Parse file with German number format (1.234,56) result = allotrope_from_file( "data.csv", "VENDOR_NAME", locale="de_DE" ) ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.1 <noreply@anthropic.com>
1 parent c122d25 commit 4d9207a

17 files changed

Lines changed: 672 additions & 21 deletions

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ classifiers = [
4747
"Typing :: Typed",
4848
]
4949
dependencies = [
50+
"babel >= 2.9.0",
5051
"cattrs >= 23.2.0",
5152
"chardet >= 5.2.0",
5253
"defusedxml >= 0.7.1",

src/allotropy/parsers/agilent_gen5/agilent_gen5_structure.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777
DataSource,
7878
Referenceable,
7979
)
80-
from allotropy.parsers.utils.pandas import read_csv, SeriesData
80+
from allotropy.parsers.utils.pandas import read_csv, series_to_float_list, SeriesData
8181
from allotropy.parsers.utils.uuids import random_uuid_str
8282
from allotropy.parsers.utils.values import (
8383
try_float,
@@ -1246,7 +1246,8 @@ def create_spectrum_results(
12461246
data = read_csv(StringIO("\n".join(wavelengths_sections)), sep="\t")
12471247

12481248
try:
1249-
wavelengths = data["Wavelength"].astype(float).tolist()
1249+
# Use series_to_float_list for locale support
1250+
wavelengths = series_to_float_list(data["Wavelength"], "wavelength")
12501251
except (ValueError, KeyError):
12511252
return [], []
12521253

src/allotropy/parsers/appbio_absolute_q/appbio_absolute_q_structure.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
NEGATIVE_ZERO,
4343
NOT_APPLICABLE,
4444
)
45-
from allotropy.parsers.utils.pandas import map_rows, SeriesData
45+
from allotropy.parsers.utils.pandas import map_rows, series_to_float_list, SeriesData
4646
from allotropy.parsers.utils.uuids import random_uuid_str
4747

4848

@@ -233,7 +233,7 @@ def create_cube_well_items(well_data: pd.DataFrame) -> list[WellItem]:
233233
cycle_count = DataCubeComponent(
234234
FieldComponentDatatype.integer, "cycle count", "#"
235235
)
236-
index_column = well_data["Index"].astype(float).tolist()
236+
index_column = series_to_float_list(well_data["Index"], "index")
237237

238238
data = SeriesData(well_data.iloc[0])
239239
well_items: list[WellItem] = []
@@ -291,7 +291,9 @@ def create_cube_well_items(well_data: pd.DataFrame) -> list[WellItem]:
291291
)
292292
],
293293
dimensions=[index_column],
294-
measures=[well_data[dye_setting].astype(float).tolist()],
294+
measures=[
295+
series_to_float_list(well_data[dye_setting], "fluorescence")
296+
],
295297
),
296298
passive_reference_dye_data_cube=DataCube(
297299
label="passive reference dye",
@@ -305,7 +307,10 @@ def create_cube_well_items(well_data: pd.DataFrame) -> list[WellItem]:
305307
],
306308
dimensions=[index_column],
307309
measures=[
308-
well_data[passive_dye_settings[0]].astype(float).tolist()
310+
series_to_float_list(
311+
well_data[passive_dye_settings[0]],
312+
"passive_fluorescence",
313+
)
309314
],
310315
),
311316
errors=errors,

src/allotropy/parsers/cytiva_biacore_t200_control/cytiva_biacore_t200_control_data_creator.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
from allotropy.parsers.utils.calculated_data_documents.definition import (
4848
CalculatedDocument,
4949
)
50+
from allotropy.parsers.utils.pandas import series_to_float_list
5051
from allotropy.parsers.utils.values import quantity_or_none, try_float_or_none
5152
from allotropy.types import DictType
5253

@@ -62,8 +63,10 @@ def _get_sensorgram_datacube(sensorgram_data: pd.DataFrame) -> DataCube:
6263
structure_measures=[
6364
DataCubeComponent(FieldComponentDatatype.double, "resonance", "RU")
6465
],
65-
dimensions=[sensorgram_data["Time (s)"].astype(float).to_list()],
66-
measures=[sensorgram_data["Sensorgram (RU)"].astype(float).to_list()],
66+
dimensions=[series_to_float_list(sensorgram_data["Time (s)"], "time")],
67+
measures=[
68+
series_to_float_list(sensorgram_data["Sensorgram (RU)"], "sensorgram")
69+
],
6770
)
6871

6972

src/allotropy/parsers/luminex_xponent/luminex_xponent_reader.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,13 @@ def build_section(
143143

144144
if metric_token == "COUNT": # noqa: S105
145145
analyte_cols = analyte_labels
146-
# Convert each analyte column to numeric, coercing errors to NaN then filling with 0.0
146+
# Convert each analyte column to numeric, coercing errors to 0.0
147+
# Use try_float_or_none for locale support, then fill None with 0.0
147148
converted = cast(
148149
pd.DataFrame,
149-
out[analyte_cols].apply(pd.to_numeric, errors="coerce").fillna(0.0),
150+
out[analyte_cols]
151+
.apply(lambda col: col.apply(try_float_or_none))
152+
.fillna(0.0),
150153
)
151154
out["Total Events"] = converted.sum(axis=1)
152155
else:

src/allotropy/parsers/moldev_softmax_pro/softmax_pro_structure.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,9 @@ def create(reader: CsvReader, header: PlateHeader) -> PlateRawData:
522522
data = get_plate_dataframe(reader, header, columns=columns)
523523

524524
# get temperature and elapsed time (kinetic) from the first column of the first row with value
525-
first_row_idx = int(pd.to_numeric(data.first_valid_index()))
525+
first_row_idx = int(
526+
try_float(str(data.first_valid_index()), "first_valid_index")
527+
)
526528
temperature = try_non_nan_float_or_none(str(data.iloc[first_row_idx, 1]))
527529
elapsed_time = None
528530
if pd.notna(elapsed := data.iloc[first_row_idx, 0]):
@@ -592,7 +594,9 @@ def create(reader: CsvReader, header: PlateHeader) -> SpectrumPlateRawData:
592594
wavelength_data: list[PlateWavelengthData] = []
593595
for wavelength in header.wavelengths:
594596
data = get_plate_dataframe(reader, header, columns=columns)
595-
first_row_idx = int(pd.to_numeric(data.first_valid_index()))
597+
first_row_idx = int(
598+
try_float(str(data.first_valid_index()), "first_valid_index")
599+
)
596600
temperature = try_non_nan_float_or_none(str(data.iloc[first_row_idx, 1]))
597601
wavelength_data.append(
598602
PlateWavelengthData.create(

src/allotropy/parsers/roche_cedex_bioht/roche_cedex_bioht_reader.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,14 @@
1515
SAMPLE_ROLE_TYPES,
1616
)
1717
from allotropy.parsers.utils.pandas import read_csv, SeriesData
18+
from allotropy.parsers.utils.values import try_float_or_none
1819
from allotropy.types import IOType
1920

2021

2122
def to_num(data: pd.Series[Any]) -> pd.Series[Any]:
22-
return pd.to_numeric(data, errors="coerce").replace(np.nan, None)
23+
# try_float_or_none returns None for invalid values, but pandas converts None to np.nan
24+
# So we need to explicitly replace np.nan with None, just like the original pd.to_numeric did
25+
return data.apply(try_float_or_none).replace(np.nan, None)
2326

2427

2528
class RocheCedexBiohtReader:

src/allotropy/parsers/thermo_fisher_genesys30/thermo_fisher_genesys30_structure.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from allotropy.exceptions import AllotropeConversionError
2323
from allotropy.parsers.constants import NOT_APPLICABLE
2424
from allotropy.parsers.thermo_fisher_genesys30 import constants
25-
from allotropy.parsers.utils.pandas import SeriesData
25+
from allotropy.parsers.utils.pandas import series_to_float_list, SeriesData
2626
from allotropy.parsers.utils.uuids import random_uuid_str
2727

2828

@@ -72,7 +72,7 @@ def create_measurement_groups(
7272
)
7373
],
7474
dimensions=[data["wavelength(nm)"].tolist()],
75-
measures=[data["ABS"].astype(float).tolist()],
75+
measures=[series_to_float_list(data["ABS"], "absorbance")],
7676
),
7777
device_control_custom_info={
7878
"operating minimum": {

src/allotropy/parsers/thermo_skanit/thermo_skanit_structure.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
)
2727
from allotropy.parsers.utils.pandas import df_to_series_data, parse_header_row
2828
from allotropy.parsers.utils.uuids import random_uuid_str
29-
from allotropy.parsers.utils.values import try_float_or_none
29+
from allotropy.parsers.utils.values import try_float, try_float_or_none
3030

3131
MEASUREMENT_TYPES = {
3232
MeasurementType.ULTRAVIOLET_ABSORBANCE: "Absorbance",
@@ -402,7 +402,10 @@ def _set_headers(df: pd.DataFrame) -> pd.DataFrame:
402402
)
403403
df = df[df.columns[valid_columns]]
404404
# Cast row numbers to int (float first to handle decimals, e.g. 1.0)
405-
df.columns = df.columns.astype(float).astype(int)
405+
# Use try_float for locale support
406+
df.columns = pd.Index( # type: ignore[assignment]
407+
[int(try_float(str(col), "column_name")) for col in df.columns]
408+
)
406409
return df
407410

408411
@staticmethod
@@ -442,7 +445,9 @@ def identify_data_and_sample_dfs(
442445
]
443446
wavelength_data[current_wavelength] = data_df
444447

445-
current_wavelength = float(wavelength_match.group(1))
448+
current_wavelength = try_float(
449+
wavelength_match.group(1), "wavelength"
450+
)
446451
current_data_section = []
447452
reading_data = False
448453
reading_samples = False
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Context management for locale-aware number parsing.
2+
3+
This module provides utilities to set and manage the current locale context
4+
for number parsing throughout the parsing pipeline.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from collections.abc import Iterator
10+
from contextlib import contextmanager
11+
12+
from allotropy.parsers.utils.values import _current_locale
13+
14+
15+
@contextmanager
16+
def set_locale_context(locale: str | None) -> Iterator[None]:
17+
"""Context manager to set the locale for number parsing.
18+
19+
Args:
20+
locale: The locale string (e.g., "en_US", "de_DE", "fr_FR") or None for default
21+
22+
Example:
23+
>>> with set_locale_context("de_DE"):
24+
... # All try_float() calls within this context will use German locale
25+
... value = try_float("1.234,56", "value") # Parses as 1234.56
26+
"""
27+
token = _current_locale.set(locale)
28+
try:
29+
yield
30+
finally:
31+
_current_locale.reset(token)
32+
33+
34+
def get_current_locale() -> str | None:
35+
"""Get the currently set locale for number parsing.
36+
37+
Returns:
38+
The current locale string or None if not set
39+
"""
40+
return _current_locale.get()

0 commit comments

Comments
 (0)