"""Sanitise person display names ingested from upstream HRIS strings. SuccessFactors composes ``displayName`` as ``" "`` (e.g. ``"孟凡生 Jason Meng"``). When the English half is absent for an employee, some SF configurations serialise the missing component as the literal token ``"null"`` rather than omitting it — so the field arrives as ``"张东晖 null"``. Stored verbatim, that sentinel then leaks everywhere the display name is denormalised: directory search + pinyin, feed celebration snapshots, software-bill user-name snapshots, and — via the ``latin_name`` outbound transform — the English name we push to Feishu. The fix is to drop the sentinel at ingestion. Matching is strict: * only WHOLE whitespace-delimited tokens are removed, never substrings, so a legitimate name like ``"Knull Hu"`` (which merely *contains* "null") is left untouched; * comparison is case-insensitive so ``"null"`` / ``"NULL"`` / ``"None"`` (a Python ``str(None)`` leak) are all caught; * the token set is limited to values that are never a real person name. """ from __future__ import annotations import re # Sentinel tokens produced by upstream string concatenation over a missing # value. Kept deliberately small — every entry must be a string that cannot # legitimately stand alone as part of a person's name. _SENTINEL_NAME_TOKENS = frozenset({"null", "none", "undefined"}) # Contiguous CJK run (+ the ``·`` used in some transliterated names). Mirrors # the ``_CJK_NAME_RE`` used by the identity_sync ``latin_name``/``cjk_name`` # outbound transforms so this shared helper splits bilingual display names # ("孟凡生 Jason Meng") the same way the vendor-push path does. _CJK_NAME_RE = re.compile(r"[㐀-鿿·]+") def strip_sentinel_name_tokens(name: str | None) -> str | None: """Remove standalone sentinel tokens from a person name. Returns the cleaned name, or ``None`` when nothing survives (so callers' existing ``... or fallback`` chains still fire). A name with no sentinel tokens is returned with its internal whitespace collapsed to single spaces and trimmed — idempotent for already-clean input. Examples: >>> strip_sentinel_name_tokens("张东晖 null") '张东晖' >>> strip_sentinel_name_tokens("Knull Hu") 'Knull Hu' >>> strip_sentinel_name_tokens("孟凡生 Jason Meng") '孟凡生 Jason Meng' >>> strip_sentinel_name_tokens("null") is None True """ if not name: return name kept = [tok for tok in name.split() if tok.casefold() not in _SENTINEL_NAME_TOKENS] return " ".join(kept) or None def chinese_name(display_name: str | None) -> str: """CJK portion of a bilingual display name. ``"孟凡生 Jason Meng"`` → ``"孟凡生"``. When there is no CJK run at all (foreign staff, e.g. ``"John Smith"``) the whole trimmed name is returned so the caller never renders an empty string. Examples: >>> chinese_name("孟凡生 Jason Meng") '孟凡生' >>> chinese_name("John Smith") 'John Smith' >>> chinese_name("杜元元 Doris Du") '杜元元' """ if not display_name: return "" dn = " ".join(display_name.split()) runs = _CJK_NAME_RE.findall(dn) return "".join(runs) if runs else dn def english_name(display_name: str | None) -> str: """Latin portion of a bilingual display name. ``"孟凡生 Jason Meng"`` → ``"Jason Meng"``. When there is no Latin remainder (a pure-CJK name with no English form) the whole trimmed name is returned so the caller never renders an empty string. Examples: >>> english_name("孟凡生 Jason Meng") 'Jason Meng' >>> english_name("John Smith") 'John Smith' >>> english_name("张三") '张三' """ if not display_name: return "" dn = " ".join(display_name.split()) latin = " ".join(_CJK_NAME_RE.sub(" ", dn).split()) return latin or dn def localized_name(display_name: str | None, *, is_en: bool) -> str: """Language-matched form of a bilingual display name: the Latin part for English readers, the CJK part for Chinese readers. Each falls back to the whole name when its half is absent (see :func:`english_name` / :func:`chinese_name`).""" return english_name(display_name) if is_en else chinese_name(display_name)