Core Transforms¶
Functions that transform text. All are pure functions — they never mutate the input.
transliterate¶
slugify¶
slugify ¶
slugify(text: str, *, separator: str = ..., lowercase: bool = ..., max_length: int = ..., word_boundary: bool = ..., save_order: bool = ..., stopwords: Iterable[str] = ..., regex_pattern: str | None = ..., replacements: Iterable[tuple[str, str]] = ..., allow_unicode: bool = ..., lang: str | None = ..., entities: bool = ..., decimal: bool = ..., hexadecimal: bool = ..., default: str | None = ...) -> strslugify(text: list[str], *, separator: str = ..., lowercase: bool = ..., max_length: int = ..., word_boundary: bool = ..., save_order: bool = ..., stopwords: Iterable[str] = ..., regex_pattern: str | None = ..., replacements: Iterable[tuple[str, str]] = ..., allow_unicode: bool = ..., lang: str | None = ..., entities: bool = ..., decimal: bool = ..., hexadecimal: bool = ..., default: str | None = ...) -> list[str] slugify(text: str | list[str], *, separator: str = '-', lowercase: bool = True, max_length: int = 0, word_boundary: bool = False, save_order: bool = False, stopwords: Iterable[str] = (), regex_pattern: str | None = None, replacements: Iterable[tuple[str, str]] = (), allow_unicode: bool = False, lang: str | None = None, entities: bool = True, decimal: bool = True, hexadecimal: bool = True, default: str | None = None) -> str | list[str]
Generate a URL-safe slug from Unicode text.
Full pipeline: decode entities → transliterate → lowercase → strip non-alphanumeric → collapse separators → apply stopwords/max_length.
Shares python-slugify's core keyword parameters (separator,
max_length, word_boundary, save_order, stopwords,
lowercase, etc.), so slugify(text, ...) calls port directly. Note
that disarm makes every parameter past text keyword-only, whereas
python-slugify accepts some positionally.
| Parameters: |
|
|---|
| Returns: |
|
|---|
| Raises: |
|
|---|
Examples:
>>> slugify("Hello World!")
'hello-world'
>>> slugify("Straße nach München", lang="de")
'strasse-nach-muenchen'
>>> slugify("My Title", separator="_")
'my_title'
>>> slugify("The Big Fox", stopwords=["the"])
'big-fox'
>>> slugify("Very Long Title Here", max_length=10, word_boundary=True)
'very-long'
>>> slugify("🔥🔥🔥")
''
>>> slugify("🔥🔥🔥", default="n-a")
'n-a'
>>> slugify("🔥", default="N/A") # default is sanitized, not returned raw
'n-a'
The output can be the empty string (#728).
Measured at Unicode 15.0.0, 243,399 single
characters reduce to "" here (105,931 excluding the Private Use
Area), and so does every string built from them. A caller keying a table
on this has all of them, plus "no value", competing for one slot.
There is no on_empty here: this returns text rather than a key. The
four key builders take one.
normalize¶
normalize ¶
normalize(text: str, *, form: NormalizationForm = ...) -> strnormalize(text: list[str], *, form: NormalizationForm = ...) -> list[str] normalize(text: str | list[str], *, form: NormalizationForm | NF = 'NFC') -> str | list[str]
Unicode normalization.
Accepts a single string or a list of strings.
Note
Unicode version. disarm implements UCD 17.0.0. Results differ from
the standard library's unicodedata.normalize for code points assigned
after the host interpreter's unicodedata.unidata_version — one code
point on a UCD 16.0.0 host, more on an older one. Every divergence is
disarm being more current, never wrong, but a pipeline that canonicalizes
with one and validates with the other will disagree about which strings are
normalized. disarm.UNICODE_VERSION reports which UCD this build normalizes
against (#645), so the comparison against unicodedata.unidata_version
can be made at runtime rather than inferred from behaviour.
| Parameters: |
|
|---|
| Returns: |
|
|---|
Examples:
>>> normalize("é", form="NFC")
'é'
>>> normalize(["é", "ño"], form="NFC")
['é', 'ño']
normalize_confusables¶
normalize_confusables ¶
normalize_confusables(text: str, *, target_script: str | Script = 'latin', digit_policy: str = 'numeric') -> str
Replace Unicode confusable homoglyphs with target-script equivalents.
Uses Unicode TR39 confusables table. Characters without a confusable equivalent in the target script pass through unchanged (visual mapping only, not transliteration).
Warning
Folds confusables and nothing else. Bidi controls, zero-width
characters, control characters and private-use characters all pass
through untouched — a right-to-left override goes in and comes back out.
This is the first thing an API search for homoglyph finds, so it is
worth saying plainly: it is one transform, not a screen. Use
canonicalize or strip_obfuscation when the input is
untrusted rather than merely mixed-script.
Warning
target_script folds toward a script; it does not protect one (#907).
Passing a script does not mean "leave this script alone". It means "send
confusables to this script's letters", and a word written in some third
script is rewritten into the target::
normalize_confusables("Москва", target_script="arabic")
# 'Мهсква' — U+0647 ARABIC LETTER HEH replacing the Cyrillic о
That is an Arabic letter inside a Cyrillic word. Text already in the target
survives because its characters are not sources in that table, which is a
side effect rather than a policy. There is also no Greek target — the four
are latin, cyrillic, arabic and hebrew — so Greek text has no
value that preserves it by design, and survives arabic or hebrew only
because those tables happen not to map Greek.
Declaring which scripts a caller considers legitimate is a different question
with a different answer. It is tracked as allowed_scripts in #900.
Warning
The presets fold a different table, because NFKC runs first (#834). Every preset and profile that folds confusables normalizes to NFKC before doing it, so the fold sees a decomposed image of the input and 68 code points get a different answer than they do here (8 for the Cyrillic target)::
normalize_confusables("ſ") # 'f' — TR39: a long s looks like an f
canonicalize("ſ") # 's' — NFKC decomposed it first
Neither order is right everywhere, which is why both ship: 44 of the 68
favour the preset answer (⑴ is (1) rather than (l), and
the mathematical m is m rather than rn), 15 favour this one
(´ is ' here and a space plus a combining acute there), and
9 are judgment calls. What matters is that they differ.
The consequence for keys: this function alone is not a canonical
skeleton. ⑴ folds to (l) while ASCII (1) stays (1),
because the table has only three ASCII sources (#725) — so two strings
a reader cannot tell apart get different keys here and the same key
under any preset. Build keys with canonicalize or search_key.
Note
Stability. A patch upgrade never changes this function's output; a
minor upgrade may, and is a possible reindex event (#644, #733). Read the
Upgrade notes of any minor release before deploying it against stored
values. The contract, and what has moved so far, is in docs/RUST_API.md
under Key stability.
| Parameters: |
|
|---|
| Returns: |
|
|---|
| Raises: |
|
|---|
Examples:
>>> normalize_confusables("Ηello") # Greek Η looks like Latin H
'Hello'
>>> normalize_confusables("раypal") # Cyrillic р/а look like Latin p/a
'paypal'
>>> normalize_confusables("paypal", target_script="cyrillic")
'раураӏ'
>>> normalize_confusables("g००gle") # Devanagari zeros stay numeric
'g00gle'
>>> normalize_confusables("२०२४", digit_policy="preserve") # keep the script
'२०२४'
>>> normalize_confusables("g००gle", digit_policy="tr39") # …or collide
'google'
sanitize_filename¶
sanitize_filename ¶
sanitize_filename(text: str, *, separator: str = '_', max_length: int = 255, platform: Platform = 'universal', lang: str | None = None, preserve_extension: bool = True, replacement_text: str | None = None, max_len: int | None = None) -> str
Sanitize a string into a safe filename.
Transliterate → strip OS-illegal chars → collapse separators → handle reserved names (CON, NUL, etc.) → truncate respecting extension.
| Parameters: |
|
|---|
| Returns: |
|
|---|
| Raises: |
|
|---|
Examples:
>>> sanitize_filename("My Report (final).pdf")
'My_Report_(final).pdf'
>>> sanitize_filename("CON.txt") # reserved on Windows
'_CON.txt'
>>> sanitize_filename("résumé.docx", lang="fr")
'resume.docx'
Warning
A safe filename is not a safe URL path segment. % is legal in a
filename on every supported platform, so a % the caller typed is kept:
sanitize_filename("..%2Fetc") returns "%2Fetc", with the literal
.. collapsed and the percent-encoded spelling of the same traversal
left alone. A consumer that percent-decodes the result must validate
after decoding.
What the sanitizer will not do is manufacture one. Compatibility folding
maps five code points to % (؉ U+0609, ؊ U+060A, ٪ U+066A,
﹪ U+FE6A, % U+FF05), which used to assemble %2E%2E%2F out of
input containing no % at all. The rule is now exact: % never
appears in the output unless it appeared in the input (#721).
sanitize_filename("%2E%2E%2Fetc.txt") '_2E_2E_2Fetc.txt'
strip_accents¶
strip_accents ¶
strip_accents(text: str) -> strstrip_accents(text: list[str]) -> list[str] strip_accents(text: str | list[str]) -> str | list[str]
Remove diacritical marks while preserving base characters.
NFD decompose → strip combining marks → NFC recompose. Accepts a single string or a list of strings.
Destructive wherever a combining mark carries meaning, which is not only the
Indic scripts (#624, #761). A Latin acute and a Devanagari vowel sign are both
general category Mn, so both are removed — but in Latin an Mn is
decoration and elsewhere it is part of the letter. José → Jose is
readable. These are not::
বাংলা → বল Bengali, the vowel signs carry the word
हिन्दी → हनद Devanagari
မြန်မာ → မနမ Myanmar
かばん → かはん Japanese: the dakuten is the difference between
ば /ba/ and は /ha/, so this is a different word
Чайковский → Чаиковскии Russian: й is a letter, not и with a mark; ё → е
Kana and Cyrillic are the two an "Indic scripts" warning sends a reader past.
In kana the dakuten and handakuten are voicing, not decoration; in Cyrillic
й and ё are letters of the alphabet that happen to decompose.
Use this for identifiers, filenames and search keys — where a deliberate
many-to-one collapse is the point — and not for body text in any script whose
marks are load-bearing. See Limitations (docs/limitations.md).
| Parameters: |
|
|---|
| Returns: |
|
|---|
Examples:
>>> strip_accents("café résumé naïve")
'cafe resume naive'
>>> strip_accents(["café", "naïve"])
['cafe', 'naive']
fold_case¶
fold_case ¶
fold_case(text: str) -> str
Full Unicode case folding per CaseFolding.txt (Unicode 16.0).
Unlike str.lower(), this implements the complete Unicode Case Folding
algorithm with all 1,557 status-C and status-F mappings. Covers Latin
(ß→ss, ſ→s, İ→i̇), Greek (ς→σ, variant forms ϐ→β, ϑ→θ, ϕ→φ, ϖ→π,
ϰ→κ, ϱ→ρ), Cyrillic, Armenian (ligature և→եւ), Georgian Mtavruli,
Cherokee, Adlam, Deseret, Osage, Warang Citi, fullwidth Latin,
and all Latin ligature expansions (fi→fi, fl→fl, ff→ff, ffi→ffi,
ffl→ffl, ſt→st, st→st).
Equivalent to str.casefold() but executed in Rust via a
compile-time PHF (perfect hash function) table. Pure-ASCII strings
take a branchless fast path with no table lookup.
Note
Stability. A patch upgrade never changes this function's output; a
minor upgrade may, and is a possible reindex event (#644, #733). Read the
Upgrade notes of any minor release before deploying it against stored
values. The contract, and what has moved so far, is in docs/RUST_API.md
under Key stability.
| Parameters: |
|
|---|
| Returns: |
|
|---|
Examples:
>>> fold_case("Straße")
'strasse'
>>> fold_case("ΣΟΦΙΑ")
'σοφια'
>>> fold_case("find")
'find'
collapse_whitespace¶
collapse_whitespace ¶
collapse_whitespace(text: str) -> str
Fold all Unicode whitespace runs to single ASCII spaces, trimming the ends.
Folds whitespace only (#433): the line controls (TAB/LF/VT/FF/CR), the
information separators (U+001C–U+001F), NEL, the Zs/Zl/Zp spaces,
and the blank-rendering set (Braille blank, the Hangul fillers) each fold to a
single space. It does not delete control or zero-width characters — for
that, call strip_control_chars / strip_zero_width_chars, or
use a preset that sequences them ahead of the fold (canonicalize and
canonicalize_strict both do).
Folding the line controls (rather than deleting them) means a carriage return
between two tokens becomes a space, never a silent join: "a\rb" →
"a b".
| Parameters: |
|
|---|
| Returns: |
|
|---|
Examples:
>>> collapse_whitespace(" hello world ")
'hello world'
>>> collapse_whitespace("tabs\there\ttoo")
'tabs here too'
>>> collapse_whitespace("a\rb") # carriage return folds, not deletes
'a b'
demojize¶
demojize ¶
demojize(text: str, *, replacement: str | None = None, strip_modifiers: bool = False, errors: ErrorMode = 'replace', replace_with: str = '[?]', provider: EmojiProvider | None = None, delimiters: tuple[str, str] | None = None) -> str
Name every emoji, or replace every emoji with one string.
Two modes, and they read different tables because they answer different questions.
Naming (the default) asks what does CLDR call this?, so its domain is the CLDR
name table — which is wider than the emoji: demojize("x™y") is "x trade mark y"
because CLDR annotates U+2122.
Replacing (replacement=...) asks is this an emoji by the UCD's properties?,
so its domain is the emoji-presentation set: Emoji_Presentation=Yes, an
Emoji=Yes base carrying U+FE0F, and the ZWJ, modifier, keycap and flag
sequences built on those. Nothing else moves — © and ™ stay, where naming
would have written a word over them (#972).
| Parameters: |
|
|---|
| Returns: |
|
|---|
| Raises: |
|
|---|
| Warns: |
|
|---|
Examples:
>>> demojize("I ❤️ Python 🐍")
'I red heart Python snake'
>>> demojize("aa🔥bb", replacement="")
'aabb'
>>> demojize("stop🛑now", replacement=" ")
'stop now'
>>> demojize("x©y", replacement="")
'x©y'
replace_emoji¶
replace_emoji ¶
replace_emoji(text: str, replacement: str = '') -> str
Replace every emoji with replacement, verbatim (#972).
The counterpart to demojize, and a different question of a different table.
demojize asks what does CLDR call this?, so its domain is the CLDR name table,
which is wider than the emoji: demojize("x™y") is "x trade mark y". This asks
is this an emoji by the UCD's properties?, so its domain is the emoji-presentation
set — Emoji_Presentation=Yes, an Emoji=Yes base carrying U+FE0F, and the
ZWJ, modifier, keycap and flag sequences built on those. Nothing else moves.
Identical to demojize(text, replacement=...); this is the spelling every other
binding carries, and the one to reach for when the operation is the point rather than
a mode of naming.
| Parameters: |
|
|---|
| Returns: |
|
|---|
Note
No shipped preset or profile does this. llm_guardrail keeps a visible emoji on
a measured decision (#910): naming writes attacker-chosen English into screened
text, and removing fuses the words an emoji separates. Which is right depends on
whether the caller's emoji sit inside a word or between two.
Examples:
>>> replace_emoji("aa🔥bb")
'aabb'
>>> replace_emoji("stop🛑now", " ")
'stop now'
>>> replace_emoji("x©y")
'x©y'
Three treatments, and which profile uses which¶
An emoji can be named, replaced or kept, and disarm does all three. Which one is right is a property of the caller's text, not of the library:
| treatment | call | what it is for |
|---|---|---|
| name | demojize(text), TextPipeline(demojize=True) |
a reader or a model that needs the words |
| replace | replace_emoji(text, s), TextPipeline(demojize=s) |
text where an emoji is a delimiter |
| keep | every shipped preset and profile | screening, comparison and key building |
Naming and replacing read different tables, which is why they are separate functions
rather than one with a flag. Naming asks what does CLDR call this? and its domain is
the CLDR name table — wider than the emoji, so demojize("x™y") is "x trade mark y".
Replacing asks is this an emoji by the UCD's properties? and its domain is the
emoji-presentation set, so ™ and © are left alone:
from disarm import demojize, replace_emoji
assert demojize("x™y") == "x trade mark y"
assert replace_emoji("x™y") == "x™y"
# An emoji inside a word splits it for a subword tokenizer; removing it closes the split.
assert replace_emoji("aa🔥bb") == "aabb"
# Between two words, the same rule fuses them — so the caller picks the separator.
assert replace_emoji("stop🛑now", " ") == "stop now"
Why no profile removes emoji¶
llm_guardrail keeps a visible emoji, and that is a measured decision rather than an
omission (#910). Naming inside a guardrail writes attacker-chosen English into the text
being screened — 1,272 distinct words across the Emoji_Presentation set. Removing costs
the opposite: over 144 emoji with the probe stop<emoji>now, removal fused the two words
144 times out of 144.
The attack this parameter answers is intra-word, where removal is the defence. #910's probe is inter-word, where removal is the damage. No local rule tells the two apart, so neither is a default and the caller says which their text is.
What it reaches, and the vintage that decides¶
Removal covers the bundled Emoji_Presentation table, which is UCD 15.1.0 —
1,205 code points (docs/provenance.md). Every one of them is removed from between two
words; 14 code points assigned in later UCD releases are not in the table and survive,
U+1FAE9 among them. That number moves with a table refresh rather than with this code,
and the refresh is its own change: bumping to 17.0.0 also narrows Extended_Pictographic
from 3,537 to 2,848 and moves three code points out, which other steps read.
set_emoji_provider¶
set_emoji_provider ¶
set_emoji_provider(provider: EmojiProvider | None = None) -> None
Set a global emoji provider for all demojize calls.
The provider must implement the EmojiProvider protocol.
Pass None to reset to the built-in default (latest English CLDR).
Note
Sequence-length cap (#199). The provider's lookup() is offered a
look-ahead window of at most 9 codepoints — the length of the longest
built-in CLDR emoji sequence. A provider cannot match a sequence longer
than 9 codepoints: the extra codepoints fall through to the built-in
tables / per-codepoint handling. This cap is fixed (it sizes a
stack-allocated scan window, so widening it would cost every demojize
call); design custom mappings to key on ≤ 9 codepoints. Skin-tone and
variation-selector modifiers trailing a matched sequence are consumed
separately and do not count toward the 9.
| Parameters: |
|
|---|
Examples:
>>> set_emoji_provider(None) # reset to default provider
strip_bidi¶
strip_bidi ¶
strip_bidi(text: str) -> str
Strip bidirectional override and formatting characters (UAX #9).
Removes: soft hyphen (U+00AD), Arabic Letter Mark (U+061C), LRM/RLM (U+200E/F), bidi embeddings/overrides (U+202A–U+202E), bidi isolates (U+2066–U+2069).
Keeps the logical order. This is a pure filter: the controls are deleted
and the code-point order is untouched, so the result is the order the bytes
are in, not the order a reader saw.
"\u202e" + "paypal"[::-1] + "\u202c" renders as paypal and comes
back as lapyap.
That is correct for a compiler, a filesystem or an identifier comparison, which all read logical order — the Trojan Source direction (CVE-2021-42574). It is the wrong answer for a search index, an NLP model or content moderation, which want what was displayed. disarm has no surface that returns display order; see "Stripping preserves logical order, not display order" in the limitations page (#740).
| Parameters: |
|
|---|
| Returns: |
|
|---|
Examples:
>>> strip_bidi("hello\u200eworld") # remove LRM
'helloworld'
>>> strip_bidi("hello\u061cworld") # remove Arabic Letter Mark
'helloworld'
>>> strip_bidi("safe text") # no bidi chars → unchanged
'safe text'
fold_punctuation¶
fold_punctuation ¶
fold_punctuation(text: str) -> str
Fold typographic punctuation to its ASCII spelling (#703).
The dash family and the minus sign become -; the curly and low-9 quotes and the
primes become ' / "; the ellipsis becomes ...; the non-standard spaces
become a space. Nothing else in disarm does this as a stated purpose: canonicalize
folds five dashes and skips the em dash and the horizontal bar, transliterate folds
those two and rejects the other four, and a key built from either treats a—b and
a-b as distinct while treating a–b and a-b as the same. A separate
primitive rather than a change to either, because canonicalize is a security fold
entitled to map “ to '' — a confusable skeleton for a double quote and a poor
replacement for one.
Not covered, on purpose. U+3002 IDEOGRAPHIC FULL STOP and U+060C ARABIC
COMMA are those scripts' own full stop and comma; the middle dot U+00B7 is a
letter in Catalan l·l; the bullet stays. Spaces fold rather than delete, so words
do not glue together.
Idempotent, and the identity on ASCII. Form-preserving, like the targeted strips: it folds one character class and composes nothing, so a decomposed letter leaves as it arrived. Compose it with a preset when boundary normalization is wanted too.
Examples:
>>> fold_punctuation("He said “ok” — then…")
'He said "ok" - then...'
>>> fold_punctuation("l·l") # Catalan: a letter, not punctuation
'l·l'
strip_tags¶
strip_tags ¶
strip_tags(text: str) -> str
Strip the Unicode Tags block (U+E0000–U+E007F) — the "ASCII smuggling" channel.
Preserves well-formed emoji subdivision flag sequences (U+1F3F4 + tag
letters + U+E007F, e.g. the Scotland flag); stray tag characters
(including the deprecated language tag U+E0001) are removed.
Examples:
>>> strip_tags("hi\U000e0050\U000e0057\U000e004e") # tag-encoded "PWN"
'hi'
strip_variation_selectors¶
strip_variation_selectors ¶
strip_variation_selectors(text: str) -> str
Strip every variation selector (VS1–VS16 and VS17–VS256).
These are the arbitrary-byte smuggling channel. Use strip_format if you
need to keep the VS15/VS16 presentation selectors for rendering.
Examples:
>>> strip_variation_selectors("g\ufe01data") # VS2
'gdata'
strip_noncharacters¶
strip_noncharacters ¶
strip_noncharacters(text: str) -> str
Strip every Unicode noncharacter (U+FDD0–U+FDEF, and U+xFFFE/U+xFFFF per plane).
Examples:
>>> strip_noncharacters("a\ufffeb")
'ab'
strip_pua¶
strip_pua ¶
strip_pua(text: str) -> str
Strip every Private Use Area code point (BMP and planes 15/16).
PUA renders as arbitrary, font-defined glyphs (icon fonts, platform logos).
Stripped by the comparison presets; use this helper to apply the same policy
directly, or strip_format to preserve PUA for rendering.
Examples:
>>> strip_pua("a\ue000b")
'ab'
strip_zalgo¶
strip_zalgo ¶
strip_zalgo(text: str, *, max_marks: int = 3) -> str
Strip excessive combining marks, preserving legitimate diacritics.
Caps the number of combining marks per base character at max_marks. Operates in NFD space and recomposes to NFC.
The default equals is_zalgo's threshold on purpose (#788). It was 2 while the
threshold was 3, so this stripped from text the library had just declined to call
suspicious: pointed and cantillated Hebrew routinely carries a vowel, a dot and an
accent on one consonant, is_zalgo correctly returns False for it, and this
removed the accent anyway. Three marks is ordinary text in Hebrew and Arabic; the
Vietnamese ệ that set the original figure has two.
| Parameters: |
|
|---|
| Returns: |
|
|---|
Examples:
>>> strip_zalgo("café") # 1 combining mark — preserved
'café'
>>> strip_zalgo("Việt Nam") # 2 marks — preserved
'Việt Nam'
Caps the number of combining marks per base character, preserving legitimate diacritics (é, ñ, ệ) while removing zalgo stacking abuse.
from disarm import strip_zalgo
assert strip_zalgo("café") == "café"
assert strip_zalgo("Việt Nam") == "Việt Nam"
# Strip all combining marks (like strip_accents)
assert strip_zalgo("café", max_marks=0) == "cafe"
List input (batch processing)¶
transliterate, slugify, normalize, and strip_accents accept either a single str or a list[str]. When a list is passed, all strings are processed in a single Rust call, amortizing the Python → Rust boundary overhead. The return type matches the input type.
Two transliterate modes are the exception and instead process a list item by item: reverse transliteration (target=...) and context-aware transliteration (context=True).
from disarm import transliterate, slugify
titles = ["café résumé", "Straße nach München", "Москва"]
assert transliterate(titles) == ["cafe resume", "Strasse nach Munchen", "Moskva"]
assert slugify(titles, lang="de") == ["cafe-resume", "strasse-nach-muenchen", "moskva"]
For large datasets, passing a list is significantly faster than calling the function in a Python loop. See Performance for benchmarks.
Compatibility aliases¶
The following aliases are provided for migration convenience:
| Alias | Target | Matches |
|---|---|---|
unidecode |
transliterate |
Unidecode / text-unidecode |
ascii_fold |
transliterate |
Elasticsearch ICU folding |
casefold |
fold_case |
str.casefold() |
remove_accents |
strip_accents |
sklearn / ML ecosystems |
from disarm import unidecode, casefold, remove_accents
assert unidecode("café") == "cafe"
assert casefold("Straße") == "strasse"
assert remove_accents("café") == "cafe"