Classes¶
Stateful objects and builders for repeated or specialized text processing.
Text¶
Text ¶
Immutable wrapper for fluent Unicode text processing.
Wrap a string, chain transforms in any order, extract with .value
or str().
Examples:
>>> from disarm import Text
>>> Text("Straße").fold_case().value
'strasse'
>>> Text(" hello world ").collapse_whitespace().value
'hello world'
>>> str(Text("café").strip_accents())
'cafe'
normalize ¶
normalize(*, form: NormalizationForm = 'NFC') -> Text
Unicode normalization (NFC, NFD, NFKC, NFKD).
normalize_confusables ¶
normalize_confusables(*, target_script: str | Script = 'latin', digit_policy: str = 'numeric') -> Text
Replace confusable homoglyphs with target-script equivalents.
digit_policy="tr39" selects upstream's digit targets, which fold most
non-Latin digits to a Latin letter instead of the ASCII digit (#561). Three of
the 47 rows land elsewhere: two fold to . and one to the two characters
rn. Scoped to target_script="latin"; with any other target it is a
no-op.
digit_policy="preserve" leaves the digit alone (#648), since the other two
both produce a mixed-script numeral: २०२४ becomes २0२४ or २o२४.
It applies under every target script.
transliterate ¶
transliterate(*, lang: str | None = None, target: str | None = None, errors: ErrorMode = 'replace', replace_with: str = '[?]', strict_iso9: bool = False, gost7034: bool = False, tones: bool = False, context: bool = False) -> Text
Unicode → ASCII transliteration.
fold_case ¶
fold_case() -> Text
Full Unicode case folding per CaseFolding.txt (1,557 mappings).
Covers Latin, Greek, Cyrillic, Armenian, Georgian, Cherokee,
Adlam, Deseret, Osage, Warang Citi, fullwidth Latin, and all
ligature expansions. Equivalent to str.casefold() on a Python whose
unicodedata is Unicode 16.0; the table is disarm's, not the host's (see
disarm.fold_case).
collapse_whitespace ¶
collapse_whitespace() -> Text
Fold whitespace runs to single ASCII spaces, trimming the ends (#433).
Folds whitespace only — it does not delete control or zero-width
characters. The line controls (CR/VT/FF/NEL/…) fold to a space rather
than being deleted, so "a\rb" becomes "a b".
strip_control_chars ¶
strip_control_chars() -> Text
Remove control characters that are not whitespace (#433).
The controls collapse_whitespace folds — TAB, LF, VT, FF, CR, the
information separators and NEL — are preserved so the fold can turn them
into a space; deleting them would join the tokens either side.
strip_zero_width_chars ¶
strip_zero_width_chars() -> Text
Remove zero-width characters (ZWSP, ZWNJ, ZWJ, word joiner, BOM, …).
slugify ¶
slugify(*, 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) -> Text
Generate a URL-safe slug.
default is the fallback when the slug would be empty; it is
sanitized through the same slug pipeline before being returned (#169,
193). See slugify.¶
sanitize_filename ¶
sanitize_filename(*, separator: str = '_', max_length: int = 255, platform: Platform = 'universal', lang: str | None = None, preserve_extension: bool = True) -> Text
Sanitize into a safe filename.
demojize ¶
demojize(*, strip_modifiers: bool = False, errors: ErrorMode = 'replace', replace_with: str = '[?]', provider: EmojiProvider | None = None) -> Text
Expand emoji to CLDR short-name text descriptions.
canonicalize ¶
canonicalize() -> Text
Apply the canonicalize precompiled pipeline.
resolve deletions → NFKC → strip bidi/format → strip invisibles (#413) →
strip control → strip zero-width → collapse whitespace → drop repeated marks →
cap combining marks (anti-zalgo, #429) → NFC → confusables and NFC to a fixed
point → drop repeated marks → cap combining marks again (the confusable fold is
iterated with NFC for
idempotency, #416/#434). PRESETS lists the steps exactly.
ml_normalize ¶
ml_normalize(*, lang: str | None = None, emoji: str = 'cldr', fold_case: bool = True) -> Text
Apply the ml_normalize precompiled pipeline.
resolve deletions → NFKC → emoji→text → [transliterate] → strip_accents → emoji→text → [fold_case] → strip_control → strip_zero_width → collapse_whitespace → NFC.
fold_case=False drops the fold step for a cased downstream model (#559);
every other stage still runs, so accents are still stripped.
strip_format ¶
strip_format() -> Text
Apply the strip_format precompiled pipeline.
Strip bidi/format and invisibles, collapse whitespace, strip control and zero-width characters.
canonicalize_strict ¶
canonicalize_strict() -> Text
Apply the canonicalize_strict precompiled pipeline.
Strict Unicode hygiene for untrusted input: resolve deletions → NFKC → strip
bidi/format, zero-width and control → strip invisibles (#413) → confusables
and NFC, iterated with the cross-script mark strip → drop repeated marks →
strip_zalgo → collapse whitespace → NFC. Like canonicalize but also strips
zalgo, and unlike the key presets it has no transliteration step — though the
confusable fold still rewrites individual non-Latin letters (#907).
normalize_user_input ¶
normalize_user_input() -> Text
Deprecated alias for canonicalize_strict (#430); removed in 1.0.
strip_obfuscation ¶
strip_obfuscation() -> Text
Apply the strip_obfuscation precompiled pipeline.
Maximum-strength deobfuscation: resolve deletions → NFKC → strip_zalgo(0) → strip bidi → strip zero-width → strip invisibles (#413) → confusables → strip_accents → strip control → collapse whitespace → NFC. Strips all combining marks (zalgo and accents) and resolves homoglyphs by visual similarity, but preserves case, does not transliterate, and leaves emoji where they stand (#910).
is_case_fold_stable ¶
is_case_fold_stable() -> bool
True if full case folding and a simple lowercase agree, so the value is a
stable identity key. Both are compiled into disarm rather than taken from the
host, so this is not always fold_case(t) == t.lower() (see
disarm.is_case_fold_stable). False means another string folds to the same
thing (groß.txt / gross.txt) — a fact, not an accusation.
is_canonical ¶
is_canonical(*, preset: str = 'canonicalize') -> bool
True if the value is already its own canonical form under preset.
The verification-path counterpart to the presets (#730). Not the same
question as has_anomalies, which stays silent on 5,292 non-PUA
code points that are not their own canonical form.
is_normalized ¶
is_normalized(*, form: NormalizationForm = 'NFC') -> bool
True if already in the specified normalization form.
is_confusable ¶
is_confusable(*, target_script: str | Script = 'latin') -> bool
True if text contains confusable homoglyphs.
is_mixed_script ¶
is_mixed_script() -> bool
True if text contains characters from multiple Unicode scripts.
has_bidi_conflict ¶
has_bidi_conflict() -> bool
True if text mixes strong left-to-right and strong right-to-left characters.
has_bidi_control ¶
has_bidi_control() -> bool
True if text contains any of the twelve explicit bidi formatting characters.
Distinct from has_bidi_conflict, which is about mixing strong LTR and strong
RTL content. This one asks only whether a control is present.
detect_scripts ¶
detect_scripts() -> list[Script]
Return Unicode scripts present, in order of first appearance.
grapheme_truncate ¶
grapheme_truncate(max_graphemes: int) -> Text
Truncate to at most max_graphemes grapheme clusters.
catalog_key ¶
catalog_key(*, lang: str | None = None, strict_iso9: bool = False) -> Text
Library catalog key generation for bibliographic deduplication.
Usage¶
from disarm import Text
result = (
Text("Ünïcödé Café ☕")
.normalize(form="NFKC")
.demojize()
.transliterate()
.strip_accents()
.fold_case()
.value
)
assert result == "unicode cafe hot beverage"
Each transform method returns a new Text instance (immutable semantics, matching Python str). Predicates return their native type (bool, list) and do not chain.
Chainable transforms¶
All core transforms are available as methods:
| Method | Returns | Description |
|---|---|---|
.normalize(form=) |
Text |
Unicode normalization |
.normalize_confusables() |
Text |
Replace confusable homoglyphs |
.strip_accents() |
Text |
Remove diacritical marks |
.transliterate(lang=, ...) |
Text |
Unicode → ASCII |
.fold_case() |
Text |
Full Unicode case folding |
.collapse_whitespace() |
Text |
Normalize whitespace |
.slugify(...) |
Text |
Generate URL-safe slug |
.sanitize_filename(...) |
Text |
Safe filename |
.demojize(...) |
Text |
Emoji → text descriptions |
.strip_bidi() |
Text |
Strip bidi overrides |
.canonicalize() |
Text |
Security pipeline |
.ml_normalize(...) |
Text |
ML/NLP pipeline |
.strip_format() |
Text |
Display cleanup pipeline |
.catalog_key(...) |
Text |
Catalog key pipeline |
.grapheme_truncate(n) |
Text |
Truncate to n graphemes |
Non-chaining predicates¶
| Method | Returns | Description |
|---|---|---|
.is_ascii() |
bool |
All characters are ASCII |
.is_canonical(preset=) |
bool |
Already its own canonical form (#730) |
.is_normalized(form=) |
bool |
Already in normalization form |
.is_confusable() |
bool |
Contains confusable homoglyphs |
.is_mixed_script() |
bool |
Multiple Unicode scripts |
.detect_scripts() |
list[Script] |
Scripts present |
.grapheme_len() |
int |
User-perceived character count |
.grapheme_split() |
list[str] |
Split into grapheme clusters |
Result extraction¶
Use .value or str() to extract the underlying string:
from disarm import Text
text = Text("café").strip_accents()
assert text.value == "cafe"
assert str(text) == "cafe"
Text supports ==, hash(), len(), and bool() — comparing against the underlying string value.
Slugifier¶
Slugifier ¶
Reusable configured slugifier. Call instance as slugifier(text) -> str.
Examples:
>>> s = Slugifier(separator="_", lang="de")
>>> s("Ärger im Büro")
'aerger_im_buero'
Usage¶
from disarm import Slugifier
slug = Slugifier(separator="_", lang="de", max_length=50)
assert slug("Ärger im Büro") == "aerger_im_buero"
assert slug("Über den Wolken") == "ueber_den_wolken"
# Auto-detect language from script
auto_slug = Slugifier(lang="auto")
assert auto_slug("Москва") == "moskva"
Accepts all the same parameters as slugify(). Construct once, call many times.
UniqueSlugifier¶
UniqueSlugifier ¶
Stateful slugifier that tracks previously generated slugs.
Appends incrementing suffixes for uniqueness. Optional check callback for external uniqueness (e.g. database lookup).
With max_length set, a suffixed slug is cut to fit by shortening the
base, never the suffix, and the cut is the slug's own: no trailing
separator or joiner is left before the suffix, and at least one character
of the base is kept. When the suffix leaves no room for one,
InvalidArgumentError is raised rather than returning -1.
An input with nothing sluggable gives the empty slug, every time: it is
not suffixed, not recorded, and check is not called for it. Pass
default to get a unique fallback instead.
One instance can be shared between threads: calls are serialised, so each
waits for the one in progress, check included. check must not call
the same instance; that raises RuntimeError.
Examples:
>>> u = UniqueSlugifier()
>>> u("My Post")
'my-post'
>>> u("My Post")
'my-post-1'
Usage¶
from disarm import UniqueSlugifier
unique = UniqueSlugifier()
assert unique("My Post") == "my-post"
assert unique("My Post") == "my-post-1"
assert unique("My Post") == "my-post-2"
unique.reset() # clear seen slugs
assert unique("My Post") == "my-post"
External uniqueness check¶
def exists_in_db(slug: str) -> bool:
return db.slugs.filter(slug=slug).exists()
unique = UniqueSlugifier(check=exists_in_db)
The check callback is called for each candidate slug. If it returns True, the slugifier increments the suffix and tries again. An empty slug (an input with nothing sluggable) is returned as it is, without calling check.
TextPipeline¶
TextPipeline ¶
Composable, pre-compiled text cleaning pipeline.
Operations execute in fixed optimal order regardless of construction order.
Two security-focused steps run early in the order: strip_zalgo caps
excessive combining marks (strip_zalgo=max_marks), and strip_bidi
removes bidirectional override/format characters. Both run right after
normalize and before demojize.
strip_zalgo is the one flag here whose off switch is not False, and
0 is not it (#958). The value is a cap on combining marks per base
character, so strip_zalgo=0 permits none and removes every diacritic in the
text — café leaves as cafe. Off is None, the default, which omits
the step from the compiled pipeline entirely. A threshold that leaves ordinary
accented text alone and still cuts a zalgo stack is a small positive number,
which is what the bare strip_zalgo function defaults to. The same literal
reads the other way in PRESETS: ("strip_zalgo", None) there names a step
that runs, at that default cap.
This constructor takes individual step flags only; there is no
preset= argument. To obtain a pre-configured pipeline for a named policy
profile (e.g. scholarly_cyrillic_iso9), call get_pipeline
instead — it returns a ready-to-use TextPipeline. A profile runs its steps
again until the output stops changing; a pipeline built here runs them once, as
composed, so the two agree wherever one pass is already a fixed point.
digit_policy is the policy the confusables step folds digits under
("numeric", "tr39" or "preserve"), fixed here at construction the way
get_pipeline fixes it for a profile (#646). It is rejected, not ignored, unless
confusables=True: a setting that would never run is refused rather than kept.
Examples:
>>> pipe = TextPipeline(normalize="NFC", fold_case=True, collapse_whitespace=True)
>>> pipe(" Héllo WÖRLD ")
'héllo wörld'
>>> TextPipeline(confusables=True, digit_policy="tr39")("g੦ogle")
'google'
purpose
property
¶
purpose: str | None
What this profile is for, in one sentence — or None if hand-built (#860).
list_profiles returns names and steps says what a pipeline does; neither says
what it is for, which made the profiles the one part of the public surface a reader
could not evaluate without leaving the REPL. It matters most where two profiles look
alike and are not: rag_ingest has no confusables step — its recovery is
transliteration — so a Cyrillic look-alike of paypal romanizes to raural,
where llm_guardrail folds it to paypal. Choosing wrong there fails silently
and in the unsafe direction.
A TextPipeline assembled from flags returns None: the caller composed it and
knows why.
Examples:
>>> get_pipeline("rag_ingest").purpose
'Normalizing retrieved documents for a RAG index, romanizing legitimate non-Latin text rather than folding homoglyphs onto Latin.'
>>> TextPipeline(fold_case=True).purpose is None
True
The list-with-purposes case is one line:
>>> {p: get_pipeline(p).purpose for p in list_profiles()}
{'code_context': ...}
steps
property
¶
steps: list[tuple[str, str | None]]
Return the ordered list of active pipeline steps.
Each entry is a (step_name, parameter) tuple. Steps are listed
in execution order. parameter is None for parameterless
steps (e.g. fold_case), or a string value for steps that accept
one (e.g. ("normalize", "NFC")).
Examples:
>>> pipe = TextPipeline(normalize="NFC", fold_case=True)
>>> pipe.steps
[('normalize', 'NFC'), ('fold_case', None)]
explain ¶
explain() -> str
Return a human-readable description of the pipeline.
Examples:
>>> pipe = TextPipeline(normalize="NFC", fold_case=True)
>>> print(pipe.explain())
TextPipeline with 2 steps:
1. normalize (NFC)
2. fold_case
Usage¶
from disarm import TextPipeline
pipe = TextPipeline(
normalize="NFC",
confusables=True,
strip_accents=True,
fold_case=True,
collapse_whitespace=True,
)
assert pipe(" Héllo Wörld ") == "hello world"
strip_zalgo is a cap, and 0 is not "off"¶
Every other step here is switched with a boolean. strip_zalgo takes the maximum
number of combining marks to allow per base character, so 0 allows none and removes
every diacritic in the text — including from words that were never obfuscated. Off is
None, which leaves the step out of the compiled pipeline altogether (#958):
from disarm import TextPipeline
text = "Čeština, naïve café"
# None — the default. The step is not compiled in at all.
off = TextPipeline(strip_zalgo=None)
assert off.steps == []
assert off(text) == text
# 0 — a cap of zero marks. Every diacritic goes.
assert TextPipeline(strip_zalgo=0)(text) == "Cestina, naive cafe"
# A small positive cap runs the step and still leaves ordinary accents alone.
assert TextPipeline(strip_zalgo=3)(text) == text
The same literal reads the other way in PRESETS, where
("strip_zalgo", None) names a step that does run, at its default cap.
Execution order¶
Operations execute in this fixed order regardless of construction order, which is the
order steps reports:
- Resolve deletions → 2. Normalize → 3. Strip zalgo → 4. Strip bidi → 5. Strip Plane 14 → 6. Demojize → 7. Strip accents → 8. Transliterate → 9. Confusables → 10. Fold case →
- Confusables again and 12. fold case again (only when both are on) → 13. Strip control → 14. Strip zero-width → 15. Strip PUA → 16. Collapse whitespace
A TextPipeline runs its steps once. A named profile from get_pipeline runs them again
until the output stops changing, so the two agree wherever one pass is already a fixed
point (see get_pipeline).
Performance¶
The pipeline is pre-compiled at construction. Enabled steps are stored as a bitflag set — only enabled steps execute at call time.
Compatibility aliases (awesome-slugify)¶
These classes provide drop-in replacements for awesome-slugify's Slugify and UniqueSlugify. They accept awesome-slugify's parameter names and map them to native disarm parameters.
See the migration guide for full details.
Slugify¶
Slugify ¶
awesome-slugify-compatible Slugify class.
Accepts both awesome-slugify parameter names (to_lower, stop_words,
safe_chars, capitalize, pretranslate) and native disarm names.
Usage::
from disarm import Slugify
custom = Slugify(to_lower=True)
custom("Hello World") # => "hello-world"
This is a drop-in replacement for from slugify import Slugify.
from disarm import Slugify
# Same API as awesome-slugify
custom = Slugify(to_lower=True)
assert custom("Hello World") == "hello-world"
# Attribute-style configuration (awesome-slugify pattern)
s = Slugify()
s.to_lower = True
s.stop_words = ("the", "a")
s.max_length = 200
assert s("The Big Fox") == "big-fox"
Accepts both awesome-slugify parameter names (to_lower, stop_words, safe_chars, capitalize, pretranslate) and native disarm names (lowercase, stopwords, replacements).
Defaults to to_lower=False (matching awesome-slugify). For python-slugify compatibility (which defaults to lowercase=True), use the native Slugifier class or the slugify() function.
UniqueSlugify¶
UniqueSlugify ¶
Bases: Slugify
awesome-slugify-compatible UniqueSlugify class.
Tracks previously generated slugs and appends numeric suffixes to guarantee uniqueness.
Usage::
from disarm import UniqueSlugify
unique = UniqueSlugify()
unique("My Post") # => "My-Post"
unique("My Post") # => "My-Post-1"
This is a drop-in replacement for from slugify import UniqueSlugify.
from disarm import UniqueSlugify
unique = UniqueSlugify(to_lower=True)
assert unique("My Post") == "my-post"
assert unique("My Post") == "my-post-1"
unique.reset()
assert unique("My Post") == "my-post"
Extends Slugify with uniqueness tracking. Accepts uids and unique_check parameters from awesome-slugify.
Preconfigured instances¶
Drop-in replacements for awesome-slugify's preconfigured slugifiers:
from disarm import (
slugify_url, # lowercase, strips articles, max 200 chars
slugify_filename, # underscore separator, preserves -., max 255 chars
slugify_unicode, # keeps non-ASCII letters
slugify_ru, # Russian transliteration
slugify_de, # German transliteration (ä→ae, ö→oe, ü→ue)
slugify_el, # Greek transliteration
)
assert slugify_url("The Big Fox") == "big-fox"
assert slugify_de("Ärger im Büro") == "Aerger-im-Buero"
assert slugify_filename("My Report.pdf") == "My_Report.pdf"