Skip to content

Commit 59fdabb

Browse files
authored
Merge pull request #281 from derek73/fix/265-arabic-comma
Recognize Arabic and fullwidth CJK comma as name-part delimiters
2 parents 17d1946 + 89a4635 commit 59fdabb

5 files changed

Lines changed: 67 additions & 5 deletions

File tree

docs/release_log.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
Release Log
22
===========
3+
* 1.4.0 - Unreleased
4+
5+
- Fix the ``"Lastname, Firstname"`` comma format not being recognized when the input uses the Arabic comma ``،`` (U+060C, the standard comma in Arabic/Persian/Urdu text) or the fullwidth CJK comma ```` (U+FF0C) instead of the ASCII comma: both variants now also split the format and no longer leak into the parsed output (closes #265)
6+
37
* 1.3.1 - July 11, 2026
48

59
- Fix invisible Unicode bidirectional control characters (LRM/RLM/ALM, the embedding/override marks, and the isolates U+2066–U+2069) surviving parsing and sticking to ``first``/``last``/etc., so a copy-pasted right-to-left name silently failed equality and dedup. They are now stripped in preprocessing like emoji; disable via ``CONSTANTS.regexes.bidi = False`` (closes #266)

nameparser/config/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -590,8 +590,9 @@ class Constants:
590590
``None`` (no additional splitting beyond the standard comma split).
591591
592592
Note: setting this to ``","`` or ``", "`` has no additional effect —
593-
the full name is already split on bare commas first, and each resulting
594-
part is stripped of surrounding whitespace before this step runs.
593+
the full name is already split on comma characters first (including the
594+
Arabic ``،`` and fullwidth ``,`` variants), and each resulting part is
595+
stripped of surrounding whitespace before this step runs.
595596
596597
The delimiter is only applied to parts once they've been identified as
597598
a suffix group, so it never leaks into a first- or middle-name part. For

nameparser/config/regexes.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929
"bidi": re_bidi,
3030
"phd": re.compile(r'\s(ph\.?\s+d\.?)', re.I),
3131
"space_before_comma": re.compile(r'\s+,'),
32+
# ASCII comma plus its Arabic (U+060C) and fullwidth CJK (U+FF0C)
33+
# counterparts, used to split "Last, First" format and to strip a
34+
# trailing comma before parsing (#265).
35+
"commas": re.compile(r'[,،,]'),
3236
"east_slavic_patronymic": re.compile(
3337
r'(ovich|ovna|evich|evna|ichna|ilyich|kuzmich|lukich|fomich|fokich)$',
3438
re.I,

nameparser/parser.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -923,7 +923,7 @@ def _apply_full_name(self, value: str | bytes, *, stacklevel: int) -> None:
923923
def collapse_whitespace(self, string: str) -> str:
924924
# collapse multiple spaces into single space
925925
string = self.C.regexes.spaces.sub(" ", string.strip())
926-
if string.endswith(","):
926+
if string and self.C.regexes.commas.fullmatch(string[-1]):
927927
string = string[:-1]
928928
return string
929929

@@ -1195,8 +1195,14 @@ def parse_full_name(self) -> None:
11951195

11961196
self._full_name = self.collapse_whitespace(self._full_name)
11971197

1198-
# break up full_name by commas
1199-
parts = [x.strip() for x in self._full_name.split(",")]
1198+
# break up full_name by commas. A missing "commas" key in a custom
1199+
# regexes dict falls back to RegexTupleManager's EMPTY_REGEX, whose
1200+
# .split() matches between every character rather than not
1201+
# splitting at all -- guard against that so a custom regexes dict
1202+
# that omits "commas" disables the comma split instead of shattering
1203+
# the name into single characters.
1204+
commas = self.C.regexes.commas
1205+
parts = [x.strip() for x in (commas.split(self._full_name) if commas.pattern else [self._full_name])]
12001206
self._had_comma = len(parts) > 1
12011207

12021208
log.debug("full_name: %s", self._full_name)

tests/test_comma_variants.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from nameparser import HumanName
2+
from nameparser.config import Constants
3+
from nameparser.config.regexes import REGEXES
4+
5+
from tests.base import HumanNameTestBase
6+
7+
8+
class HumanNameCommaVariantsTests(HumanNameTestBase):
9+
"""Non-ASCII comma characters should split "Last, First" the same as ',' (#265)."""
10+
11+
def test_arabic_comma_splits_lastname_format(self) -> None:
12+
hn = HumanName("سلمان، محمد")
13+
self.m(hn.first, "محمد", hn)
14+
self.m(hn.last, "سلمان", hn)
15+
16+
def test_fullwidth_comma_splits_lastname_format(self) -> None:
17+
hn = HumanName("Smith,John")
18+
self.m(hn.first, "John", hn)
19+
self.m(hn.last, "Smith", hn)
20+
21+
def test_arabic_comma_does_not_pollute_output(self) -> None:
22+
hn = HumanName("سلمان، محمد")
23+
self.assertNotIn("،", hn.last)
24+
self.assertNotIn("،", str(hn))
25+
26+
def test_trailing_arabic_comma_stripped(self) -> None:
27+
# matches ASCII behavior: a single word with a trailing comma has
28+
# nothing after the comma, so it's a bare name, not "Last,"
29+
hn = HumanName("سلمان،")
30+
self.m(hn.first, "سلمان", hn)
31+
32+
def test_custom_regexes_without_commas_key_does_not_shatter_name(self) -> None:
33+
# A custom regexes dict that omits "commas" entirely must not fall
34+
# back to RegexTupleManager's EMPTY_REGEX default for splitting --
35+
# re.compile('').split(...) matches between every character, which
36+
# explodes any name into single-char pieces instead of leaving it
37+
# unsplit (the EMPTY_REGEX convention elsewhere in this codebase
38+
# means "feature disabled", not "split on every character").
39+
# With comma splitting disabled, "Smith, John" is tokenized like any
40+
# other no-comma input (word tokenizing drops the punctuation),
41+
# yielding a plain first/last pair -- not the inverted "Last, First"
42+
# reading, and definitely not single-character pieces.
43+
custom = {k: v for k, v in REGEXES.items() if k != 'commas'}
44+
c = Constants(regexes=custom)
45+
hn = HumanName("Smith, John", constants=c)
46+
self.m(hn.first, "Smith", hn)
47+
self.m(hn.last, "John", hn)

0 commit comments

Comments
 (0)