From cb2f24fe3a16ab18a38c566563a25ffd1f364181 Mon Sep 17 00:00:00 2001 From: Eric Hills <53243273+ebhills@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:48:29 -0500 Subject: [PATCH 1/3] Add standardize.clean and namespace support for standardize Introduces a local text-cleaning wrangle (`standardize.clean`) powered by `ftfy`, with options for encoding fixes, Unicode normalization, control/whitespace cleanup, and shape-preserving behavior for strings/lists. Adds explicit `standardize.custom` while keeping legacy `standardize` callable, wires callable namespace behavior through recipe/dataframe/schema discovery, documents the new usage in README, and adds comprehensive recipe/core tests for compatibility and edge cases. --- README.md | 35 +++ requirements.txt | 3 + schema/generate_recipe_schema.py | 26 +- tests/recipes/wrangles/test_standardize.py | 349 +++++++++++++++++++++ wrangles/dataframe.py | 28 +- wrangles/recipe_wrangles/__init__.py | 8 + wrangles/recipe_wrangles/standardize.py | 224 +++++++++++++ wrangles/standardize.py | 119 +++++++ 8 files changed, 782 insertions(+), 10 deletions(-) create mode 100644 tests/recipes/wrangles/test_standardize.py create mode 100644 wrangles/recipe_wrangles/standardize.py diff --git a/README.md b/README.md index d071f7c1b..4d6a9f392 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,41 @@ Wrangles broadly accept a single input string, or a list of strings. If a list i ] ``` +#### Integrated text cleaning + +`standardize.clean` repairs common mojibake, HTML character references, +Unicode inconsistencies, control characters, and whitespace locally. It accepts +a string or list and preserves that shape: + +```python +>>> wrangles.standardize.clean(["Français", " AT&T "]) +['Français', 'AT&T'] +``` + +In a recipe, one input maps to one output and an omitted output overwrites the +input. Multiple inputs map positionally to equally many outputs, or concatenate +row values when a single output is given. Wildcard-expanded inputs follow the +same rules. + +```yaml +wrangles: + - standardize.clean: + input: Description * + output: Clean Description + separator: " | " + normalization: NFKC + preserve_line_breaks: true +``` + +Common options include `fix_encoding`, `unescape_html`, `normalization`, +`fix_character_width`, `uncurl_quotes`, `remove_control_chars`, +`collapse_whitespace`, `preserve_line_breaks`, and `trim`. Less-common +`ftfy.fix_text` options are forwarded by name. This wrangle does not detect raw +byte encodings or remove HTML tags. + +The existing model-backed `standardize` name remains supported and is also +available explicitly as `standardize.custom`. + ### Recipes Recipes are written in YAML and allow a series of Wrangles to be run as an automated sequence. diff --git a/requirements.txt b/requirements.txt index a1a978b61..546d3c9e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,9 @@ lorem pydantic>=2.12.0 pyyaml +# Text cleanup +ftfy>=6.3.1,<7 + # API & auth requests PyJWT diff --git a/schema/generate_recipe_schema.py b/schema/generate_recipe_schema.py index 01380ead6..ad929f09f 100644 --- a/schema/generate_recipe_schema.py +++ b/schema/generate_recipe_schema.py @@ -136,23 +136,31 @@ def getMethodDocs(schema_wrangles, obj, path): Recursively loop through all non-hidden function and look for appropriately formatted docstrings """ - non_hidden_methods = [conn for conn in dir(obj) if not conn.startswith('_')] - - if len(non_hidden_methods) > 0: - for method in non_hidden_methods: - # Prevent including methods twice that are referenced at the root - if method not in ('main', 'pandas'): - getMethodDocs(schema_wrangles, getattr(obj, method), '.'.join([path, method])) - else: + # A wrangle can be both callable itself and a namespace for dotted child + # wrangles (for example standardize, standardize.custom, and + # standardize.clean). Capture the callable's own schema before walking its + # children so the legacy root entry remains available. + if callable(obj): try: schema_wrangle = yaml.safe_load(obj.__doc__) - if 'type' in schema_wrangle.keys() or 'anyOf' in schema_wrangle.keys(): + if ( + isinstance(schema_wrangle, dict) + and ('type' in schema_wrangle or 'anyOf' in schema_wrangle) + ): schema_wrangles[ reserved_word_replacements.get(path[1:], path[1:]) ] = schema_wrangle except Exception as e: logging.warning(f'{obj} description={e}') + non_hidden_methods = [conn for conn in dir(obj) if not conn.startswith('_')] + for method in non_hidden_methods: + # Prevent including methods twice that are referenced at the root + if method not in ('main', 'pandas'): + child = getattr(obj, method) + if child is not obj: + getMethodDocs(schema_wrangles, child, '.'.join([path, method])) + getMethodDocs(schema['wrangles'], wrangles.recipe._recipe_wrangles, '') diff --git a/tests/recipes/wrangles/test_standardize.py b/tests/recipes/wrangles/test_standardize.py new file mode 100644 index 000000000..db02120b6 --- /dev/null +++ b/tests/recipes/wrangles/test_standardize.py @@ -0,0 +1,349 @@ +import importlib +import logging + +import pandas as pd +import pytest +import yaml + +import wrangles + + +class TestStandardizeNamespace: + def test_core_legacy_function_remains_callable_namespace(self, monkeypatch): + standardize_module = importlib.import_module('wrangles.standardize') + calls = [] + + def fake_standardize(input, model_id, case_sensitive=False, **kwargs): + calls.append((input, model_id, case_sensitive, kwargs)) + return 'standardized' + + monkeypatch.setattr( + standardize_module, + 'standardize', + fake_standardize + ) + + result = wrangles.standardize.custom( + 'value', + '12345678-1234-1234', + case_sensitive=True, + custom_option='kept' + ) + + assert result == 'standardized' + assert calls == [( + 'value', + '12345678-1234-1234', + True, + {'custom_option': 'kept'} + )] + + def test_legacy_and_custom_recipe_names_share_implementation(self, monkeypatch): + recipe_main = importlib.import_module('wrangles.recipe_wrangles.main') + calls = [] + + def fake_standardize(values, model_id, case_sensitive=False, **kwargs): + calls.append((values, model_id, case_sensitive, kwargs)) + return [f'clean:{value}' for value in values] + + monkeypatch.setattr(recipe_main, '_standardize', fake_standardize) + source = pd.DataFrame({'raw': ['one', 'two']}) + + legacy = wrangles.recipe.run( + { + 'wrangles': [{ + 'standardize': { + 'input': 'raw', + 'output': 'result', + 'model_id': '12345678-1234-1234', + 'case_sensitive': True, + 'custom_option': 'kept' + } + }] + }, + dataframe=source.copy() + ) + explicit = wrangles.recipe.run( + { + 'wrangles': [{ + 'standardize.custom': { + 'input': 'raw', + 'output': 'result', + 'model_id': '12345678-1234-1234', + 'case_sensitive': True, + 'custom_option': 'kept' + } + }] + }, + dataframe=source.copy() + ) + + assert legacy.equals(explicit) + assert legacy['result'].to_list() == ['clean:one', 'clean:two'] + assert calls == [ + ( + ['one', 'two'], + '12345678-1234-1234', + True, + {'custom_option': 'kept'} + ), + ( + ['one', 'two'], + '12345678-1234-1234', + True, + {'custom_option': 'kept'} + ) + ] + + def test_recipe_namespace_exposes_parseable_schemas(self): + namespace = wrangles.recipe._recipe_wrangles.standardize + + assert callable(namespace) + assert callable(namespace.custom) + assert callable(namespace.clean) + for function in (namespace, namespace.custom, namespace.clean): + schema = yaml.safe_load(function.__doc__) + assert schema['type'] == 'object' + + def test_dataframe_accessor_preserves_legacy_call_and_dotted_names( + self, + monkeypatch + ): + recipe_main = importlib.import_module('wrangles.recipe_wrangles.main') + + monkeypatch.setattr( + recipe_main, + '_standardize', + lambda values, model_id, case_sensitive=False, **kwargs: [ + f'model:{value}' for value in values + ] + ) + source = wrangles.DataFrame({'raw': [' one ']}) + + legacy = source.wrangles.standardize( + input='raw', + output='result', + model_id='12345678-1234-1234' + ) + explicit = source.wrangles.standardize.custom( + input='raw', + output='result', + model_id='12345678-1234-1234' + ) + cleaned = source.wrangles.standardize.clean( + input='raw', + output='result' + ) + + assert legacy['result'].to_list() == ['model: one '] + assert explicit.equals(legacy) + assert cleaned['result'].to_list() == ['one'] + assert source.columns.to_list() == ['raw'] + + +class TestStandardizeCleanCore: + def test_scalar_and_list_shapes_are_preserved(self): + assert wrangles.standardize.clean('') == '' + assert wrangles.standardize.clean(' clean ') == 'clean' + assert wrangles.standardize.clean([' one ', 'two', 3, None]) == [ + 'one', + 'two', + 3, + None + ] + assert wrangles.standardize.clean([]) == [] + + def test_repairs_mojibake_and_html_character_references(self): + assert wrangles.standardize.clean('Français') == 'Français' + assert wrangles.standardize.clean('é') == 'é' + assert wrangles.standardize.clean('AT&T ') == 'AT&T' + assert ( + wrangles.standardize.clean('AT&T') + == 'AT&T' + ) + + def test_normalization_controls_and_advanced_ftfy_kwargs(self): + assert wrangles.standardize.clean('e\u0301') == 'é' + assert wrangles.standardize.clean('①', normalization='NFC') == '①' + assert wrangles.standardize.clean('①', normalization='NFKC') == '1' + assert wrangles.standardize.clean('ffi') == 'ffi' + assert ( + wrangles.standardize.clean('ffi', fix_latin_ligatures=False) + == 'ffi' + ) + assert wrangles.standardize.clean('A') == 'A' + assert ( + wrangles.standardize.clean('A', fix_character_width=False) + == 'A' + ) + + def test_controls_quotes_surrogates_and_idempotence(self): + dirty = '\x00\x81“Hello”\x7f\ud800' + cleaned = wrangles.standardize.clean(dirty) + + assert cleaned == '"Hello"�' + assert wrangles.standardize.clean(cleaned) == cleaned + assert wrangles.standardize.clean('\x1b[31mred\x1b[0m') == 'red' + assert ( + wrangles.standardize.clean('“Hello”', uncurl_quotes=False) + == '“Hello”' + ) + + def test_whitespace_modes(self): + dirty = ' one\u00a0\t two\r\n three \n\n four ' + + assert wrangles.standardize.clean(dirty) == 'one two three four' + assert wrangles.standardize.clean( + dirty, + preserve_line_breaks=True + ) == 'one two\nthree\n\nfour' + assert wrangles.standardize.clean( + ' one two ', + trim=False + ) == ' one two ' + assert wrangles.standardize.clean( + ' one two ', + collapse_whitespace=False, + trim=False + ) == ' one two ' + + def test_invalid_inputs_and_kwargs_fail_clearly(self): + with pytest.raises(TypeError, match='string or a list'): + wrangles.standardize.clean({'value': 'text'}) + + with pytest.raises(TypeError, match='unexpected field names'): + wrangles.standardize.clean('text', unknown_ftfy_option=True) + + +class TestStandardizeCleanRecipe: + def test_scalar_output_and_overwrite_in_place(self): + source = pd.DataFrame({ + 'raw': [' Français  ', ' already clean '] + }) + + output = wrangles.recipe.run( + { + 'wrangles': [{ + 'standardize.clean': { + 'input': 'raw', + 'output': 'clean' + } + }] + }, + dataframe=source.copy() + ) + overwritten = wrangles.recipe.run( + { + 'wrangles': [{ + 'standardize.clean': {'input': 'raw'} + }] + }, + dataframe=source.copy() + ) + + assert output['clean'].to_list() == ['Français', 'already clean'] + assert overwritten['raw'].to_list() == ['Français', 'already clean'] + + def test_multiple_inputs_map_to_equal_outputs_and_warn_once(self, caplog): + wrapper = wrangles.recipe._recipe_wrangles.standardize.clean + source = pd.DataFrame({ + 'first': [' café ', 7], + 'second': [None, ' A '] + }) + + with caplog.at_level(logging.WARNING): + result = wrapper( + source, + input=['first', 'second'], + output=['first clean', 'second clean'] + ) + + assert result['first clean'].to_list() == ['café', 7] + assert result['second clean'].to_list() == [None, 'A'] + assert sum( + 'preserved non-string values' in message + for message in caplog.messages + ) == 1 + + def test_multiple_inputs_concatenate_to_one_output(self): + result = wrangles.recipe.run( + { + 'wrangles': [{ + 'standardize.clean': { + 'input': ['first', 'second'], + 'output': 'clean', + 'separator': ' | ' + } + }] + }, + dataframe=pd.DataFrame({ + 'first': [' A ', None, pd.NA], + 'second': [2, ' B ', ' C '] + }) + ) + + assert result['clean'].to_list() == ['A | 2', 'B', 'C'] + + def test_mismatched_multiple_outputs_raise(self): + wrapper = wrangles.recipe._recipe_wrangles.standardize.clean + source = pd.DataFrame({'one': ['a'], 'two': ['b']}) + + with pytest.raises(ValueError, match='same number of columns'): + wrapper( + source, + input=['one', 'two'], + output=['one clean', 'two clean', 'extra'] + ) + + def test_wildcard_inputs_follow_concatenation_rule(self): + result = wrangles.recipe.run( + """ + wrangles: + - standardize.clean: + input: Raw * + output: Clean + separator: " / " + """, + dataframe=pd.DataFrame({ + 'Raw A': [' one '], + 'Raw B': [' two  '], + 'Other': ['kept'] + }) + ) + + assert result.iloc[0].to_dict() == { + 'Raw A': ' one ', + 'Raw B': ' two  ', + 'Other': 'kept', + 'Clean': 'one / two' + } + + def test_where_and_empty_dataframe_behavior(self): + filtered = wrangles.recipe.run( + """ + wrangles: + - standardize.clean: + input: Raw + output: Clean + where: Apply = true + """, + dataframe=pd.DataFrame({ + 'Raw': [' one ', ' two '], + 'Apply': [True, False] + }) + ) + empty = wrangles.recipe.run( + { + 'wrangles': [{ + 'standardize.clean': { + 'input': 'Raw', + 'output': 'Clean' + } + }] + }, + dataframe=pd.DataFrame({'Raw': []}) + ) + + assert filtered['Clean'].to_list() == ['one', ''] + assert empty.empty + assert empty.columns.to_list() == ['Raw', 'Clean'] diff --git a/wrangles/dataframe.py b/wrangles/dataframe.py index e4cba92d6..0e181a3de 100644 --- a/wrangles/dataframe.py +++ b/wrangles/dataframe.py @@ -41,6 +41,18 @@ def method(self, *args, **kwargs): # **kwargs # )[output] + +class _callable_wrangles_accessor(_wrangles_accessor): + """Expose a wrangle that is both callable and a dotted namespace.""" + + def __init__(self, df, wrangle): + self._wrangle = wrangle + super().__init__(df, wrangle) + + def __call__(self, *args, **kwargs): + return self._wrangle(self._df.copy(), *args, **kwargs) + + class _wrangles: """ A class to hold wrangles-related methods and properties. @@ -61,7 +73,21 @@ def method(self, *args, **kwargs): method.__doc__ = target_func.__doc__ return method - setattr(self, name, make_method(name).__get__(self)) + target = getattr(_recipe_wrangles.main, name) + child_wrangles = [ + child + for child in dir(target) + if not child.startswith('_') + and callable(getattr(target, child)) + ] + if child_wrangles: + setattr( + self, + name, + _callable_wrangles_accessor(self._df, target) + ) + else: + setattr(self, name, make_method(name).__get__(self)) @property def compare(self): diff --git a/wrangles/recipe_wrangles/__init__.py b/wrangles/recipe_wrangles/__init__.py index 0d9e1908e..46f1e5e25 100644 --- a/wrangles/recipe_wrangles/__init__.py +++ b/wrangles/recipe_wrangles/__init__.py @@ -15,6 +15,8 @@ - convert.case: """ +from .standardize import clean as _standardize_clean +from .standardize import custom as _standardize_custom from .main import * from .pandas import * from . import convert @@ -28,3 +30,9 @@ from . import generate from . import compute from . import search + + +# The legacy recipe function stays callable while also exposing the new dotted +# standardize namespace to recipe resolution and schema discovery. +standardize.clean = _standardize_clean +standardize.custom = _standardize_custom diff --git a/wrangles/recipe_wrangles/standardize.py b/wrangles/recipe_wrangles/standardize.py new file mode 100644 index 000000000..feedacc1d --- /dev/null +++ b/wrangles/recipe_wrangles/standardize.py @@ -0,0 +1,224 @@ +"""Dataframe wrappers for standardization wrangles.""" + +import logging as _logging +from typing import Union as _Union + +import pandas as _pd + +from ..standardize import clean as _clean + + +def _is_missing(value) -> bool: + """Return whether a scalar value should be skipped during concatenation.""" + try: + result = _pd.isna(value) + except (TypeError, ValueError): + return False + + # pandas returns an array for list-like cells; those are values, not a + # scalar missing marker. + if hasattr(result, '__len__'): + return False + return bool(result) + + +def _concatenate_row(values, separator: str) -> str: + """Join non-missing row values as text.""" + return separator.join( + str(value) + for value in values + if ( + not _is_missing(value) + and (not isinstance(value, str) or value != '') + ) + ) + + +def clean( + df: _pd.DataFrame, + input: _Union[str, int, list], + output: _Union[str, int, list] = None, + fix_encoding: bool = True, + unescape_html: _Union[str, bool] = 'auto', + normalization: str = 'NFC', + fix_character_width: bool = True, + uncurl_quotes: bool = True, + remove_control_chars: bool = True, + collapse_whitespace: bool = True, + preserve_line_breaks: bool = False, + trim: bool = True, + separator: str = ' ', + **kwargs +) -> _pd.DataFrame: + """ + type: object + description: Repair common encoding, Unicode, HTML character reference, control character, and whitespace problems locally. + required: + - input + properties: + input: + type: + - string + - integer + - array + description: Name or list of input columns. + output: + type: + - string + - integer + - array + description: Name or list of output columns. Defaults to overwriting input. + fix_encoding: + type: boolean + default: true + description: Repair mojibake and other reversible encoding errors. + unescape_html: + anyOf: + - type: boolean + - type: string + enum: + - auto + default: auto + description: Decode HTML character references. Auto avoids decoding text that appears to contain HTML markup. + normalization: + type: + - string + - "null" + enum: + - NFC + - NFKC + - NFD + - NFKD + - null + default: NFC + description: Unicode normalization form. + fix_character_width: + type: boolean + default: true + description: Normalize fullwidth and halfwidth characters. + uncurl_quotes: + type: boolean + default: true + description: Replace typographic quotes with straight quotes. + remove_control_chars: + type: boolean + default: true + description: Remove C0 and C1 control characters. + collapse_whitespace: + type: boolean + default: true + description: Collapse runs of Unicode whitespace. + preserve_line_breaks: + type: boolean + default: false + description: Preserve line breaks while collapsing other whitespace. + trim: + type: boolean + default: true + description: Remove leading and trailing whitespace. + separator: + type: string + default: " " + description: Text used to join multiple input columns into one output. + """ + if output is None: + output = input + + if not isinstance(input, list): + input = [input] + if not isinstance(output, list): + output = [output] + + if not isinstance(separator, str): + raise TypeError('separator must be a string.') + + clean_options = { + 'fix_encoding': fix_encoding, + 'unescape_html': unescape_html, + 'normalization': normalization, + 'fix_character_width': fix_character_width, + 'uncurl_quotes': uncurl_quotes, + 'remove_control_chars': remove_control_chars, + 'collapse_whitespace': collapse_whitespace, + 'preserve_line_breaks': preserve_line_breaks, + 'trim': trim, + **kwargs + } + + if len(input) > 1 and len(output) == 1: + combined = [ + _concatenate_row(values, separator) + for values in df[input].itertuples(index=False, name=None) + ] + df[output[0]] = _clean(combined, **clean_options) + return df + + if len(input) != len(output): + raise ValueError( + 'standardize.clean must output to a single column or the same ' + 'number of columns as input.' + ) + + warned_for_non_strings = False + for input_column, output_column in zip(input, output): + values = df[input_column].tolist() + if not warned_for_non_strings and any( + not isinstance(value, str) + for value in values + ): + _logging.warning( + ': standardize.clean preserved non-string values in mapped input columns.' + ) + warned_for_non_strings = True + + df[output_column] = _clean(values, **clean_options) + + return df + + +def custom( + df: _pd.DataFrame, + input: _Union[str, int, list], + model_id: _Union[str, list], + output: _Union[str, list] = None, + case_sensitive: bool = False, + **kwargs +) -> _pd.DataFrame: + """ + type: object + description: Standardize data using a DIY or bespoke standardization wrangle. Requires WrangleWorks Account and Subscription. + required: + - input + properties: + input: + type: + - string + - integer + - array + description: Name or list of input columns. + output: + type: + - string + - array + description: Name or list of output columns + model_id: + type: + - string + - array + description: The ID of the wrangle to use (do not include 'find' and 'replace') + case_sensitive: + type: boolean + description: Allows the wrangle to be case sensitive if set to True, default is False. + """ + # Import lazily to avoid a package initialization cycle. Delegating keeps + # the existing recipe implementation as the single compatibility path. + from .main import standardize as _legacy_standardize + + return _legacy_standardize( + df=df, + input=input, + model_id=model_id, + output=output, + case_sensitive=case_sensitive, + **kwargs + ) diff --git a/wrangles/standardize.py b/wrangles/standardize.py index 37447c578..9da3dbf7f 100644 --- a/wrangles/standardize.py +++ b/wrangles/standardize.py @@ -1,5 +1,8 @@ from typing import Union as _Union import logging as _logging +import re as _re +import unicodedata as _unicodedata +from ftfy import fix_text as _fix_text from . import config as _config from . import data as _data from . import batching as _batching @@ -59,3 +62,119 @@ def standardize( if isinstance(input, str): results = results[0] return results + + +def clean( + input: _Union[str, list], + fix_encoding: bool = True, + unescape_html: _Union[str, bool] = 'auto', + normalization: str = 'NFC', + fix_character_width: bool = True, + uncurl_quotes: bool = True, + remove_control_chars: bool = True, + collapse_whitespace: bool = True, + preserve_line_breaks: bool = False, + trim: bool = True, + **kwargs +) -> _Union[str, list]: + """ + Repair common Unicode and encoding problems, then normalize whitespace. + + Strings are cleaned directly. Lists retain their length and order, and + non-string list elements are returned unchanged so dataframe wrappers can + preserve mixed-type cells. + + :param input: A string or list of values to clean. + :param fix_encoding: Repair mojibake and other reversible encoding errors. + :param unescape_html: Decode HTML character references. ``'auto'`` avoids + decoding references in text that appears to contain HTML markup. + :param normalization: Unicode normalization form, such as NFC or NFKC. + :param fix_character_width: Normalize fullwidth and halfwidth characters. + :param uncurl_quotes: Replace typographic quotes with straight quotes. + :param remove_control_chars: Remove C0 and C1 control characters. + :param collapse_whitespace: Collapse runs of Unicode whitespace. + :param preserve_line_breaks: Preserve line breaks while collapsing other + whitespace. + :param trim: Remove leading and trailing whitespace. + :param kwargs: Additional options forwarded to ``ftfy.fix_text``. + :return: A cleaned string or shape-preserving list. + """ + if isinstance(input, str): + values = [input] + scalar_input = True + elif isinstance(input, list): + values = input + scalar_input = False + else: + raise TypeError( + 'Invalid input data provided. The input must be either a string or a list.' + ) + + results = [] + for value in values: + if not isinstance(value, str): + results.append(value) + continue + + cleaned = _fix_text( + value, + fix_encoding=fix_encoding, + unescape_html=unescape_html, + normalization=normalization, + fix_character_width=fix_character_width, + uncurl_quotes=uncurl_quotes, + remove_control_chars=remove_control_chars, + **kwargs + ) + + if remove_control_chars: + cleaned = ''.join( + char + for char in cleaned + if char in '\t\r\n' or _unicodedata.category(char) != 'Cc' + ) + + if collapse_whitespace: + if preserve_line_breaks: + cleaned = _re.sub(r'[^\S\r\n]+', ' ', cleaned) + cleaned = _re.sub(r' *(\r\n|\r|\n) *', r'\1', cleaned) + else: + cleaned = _re.sub(r'\s+', ' ', cleaned) + + if trim: + cleaned = cleaned.strip() + + results.append(cleaned) + + return results[0] if scalar_input else results + + +def custom( + input: _Union[str, list], + model_id: str, + case_sensitive: bool = False, + **kwargs +) -> _Union[str, list]: + """ + Explicit name for the model-backed standardization wrangle. + + This delegates to :func:`standardize` so existing Python callers and the + new ``standardize.custom`` entry point share exactly the same behavior. + + :param input: A string or list of strings to be standardized. + :param model_id: The model to be used. + :param case_sensitive: Allows setting the model to be case sensitive. + :return: A string or list with the updated text. + """ + return standardize( + input=input, + model_id=model_id, + case_sensitive=case_sensitive, + **kwargs + ) + + +# ``standardize`` remains callable for backwards compatibility while also +# acting as the namespace used by dotted core calls. +standardize.clean = clean +standardize.custom = custom From 6fb331f01be4aaa27c0680c4e038a9ca5258fcf3 Mon Sep 17 00:00:00 2001 From: Eric Hills <53243273+ebhills@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:00:24 -0500 Subject: [PATCH 2/3] Address output column name overlap with input names Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- wrangles/recipe_wrangles/standardize.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wrangles/recipe_wrangles/standardize.py b/wrangles/recipe_wrangles/standardize.py index feedacc1d..353ad8f07 100644 --- a/wrangles/recipe_wrangles/standardize.py +++ b/wrangles/recipe_wrangles/standardize.py @@ -160,8 +160,8 @@ def clean( ) warned_for_non_strings = False - for input_column, output_column in zip(input, output): - values = df[input_column].tolist() + source_values = [df[input_column].tolist() for input_column in input] + for values, output_column in zip(source_values, output): if not warned_for_non_strings and any( not isinstance(value, str) for value in values From 6a2a118c06f2dfcc4170dd5e66d49ff2b41287c6 Mon Sep 17 00:00:00 2001 From: Eric Hills <53243273+ebhills@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:11:08 -0500 Subject: [PATCH 3/3] Normalize invalid ftfy kwargs errors --- tests/recipes/wrangles/test_standardize.py | 10 +++++++++- wrangles/standardize.py | 10 ++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/recipes/wrangles/test_standardize.py b/tests/recipes/wrangles/test_standardize.py index db02120b6..0a565868f 100644 --- a/tests/recipes/wrangles/test_standardize.py +++ b/tests/recipes/wrangles/test_standardize.py @@ -207,10 +207,18 @@ def test_whitespace_modes(self): trim=False ) == ' one two ' - def test_invalid_inputs_and_kwargs_fail_clearly(self): + def test_invalid_inputs_and_kwargs_fail_clearly(self, monkeypatch): with pytest.raises(TypeError, match='string or a list'): wrangles.standardize.clean({'value': 'text'}) + standardize_module = importlib.import_module('wrangles.standardize') + monkeypatch.setattr( + standardize_module, + '_fix_text', + lambda *args, **kwargs: pytest.fail( + 'unknown kwargs must be rejected before calling ftfy' + ) + ) with pytest.raises(TypeError, match='unexpected field names'): wrangles.standardize.clean('text', unknown_ftfy_option=True) diff --git a/wrangles/standardize.py b/wrangles/standardize.py index 9da3dbf7f..913b40f28 100644 --- a/wrangles/standardize.py +++ b/wrangles/standardize.py @@ -2,12 +2,18 @@ import logging as _logging import re as _re import unicodedata as _unicodedata +from ftfy import TextFixerConfig as _TextFixerConfig from ftfy import fix_text as _fix_text from . import config as _config from . import data as _data from . import batching as _batching +_SUPPORTED_FTFY_KWARGS = frozenset(_TextFixerConfig._fields) | { + 'fix_entities' +} + + def standardize( input: _Union[str, list], model_id: str, @@ -99,6 +105,10 @@ def clean( :param kwargs: Additional options forwarded to ``ftfy.fix_text``. :return: A cleaned string or shape-preserving list. """ + unexpected_kwargs = sorted(set(kwargs) - _SUPPORTED_FTFY_KWARGS) + if unexpected_kwargs: + raise TypeError(f'Got unexpected field names: {unexpected_kwargs}') + if isinstance(input, str): values = [input] scalar_input = True