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 = '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 45 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().
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.
NFKC → strip bidi/format → strip invisibles (#413) → strip control → strip zero-width → collapse whitespace → cap combining marks (anti-zalgo, #429) → NFC → confusables → NFC (the confusable fold is NFC-sandwiched and iterated to a fixed point for idempotency, #416/#434).
ml_normalize ¶
ml_normalize(*, lang: str | None = None, emoji: str = 'cldr', fold_case: bool = True) -> Text
Apply the ml_normalize precompiled pipeline.
NFKC → emoji→text → [transliterate] → strip_accents → [fold_case] → strip_control → strip_zero_width → collapse_whitespace.
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: NFKC → strip bidi/format,
zero-width and control → strip invisibles (#413) → strip_zalgo →
confusables → collapse whitespace → NFC. Like canonicalize but
also strips zalgo, and unlike the key presets it does not transliterate —
the original script is preserved.
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: NFKC → strip_zalgo(0) → strip bidi → strip zero-width → demojize → strip invisibles (#413) → confusables → strip_accents → strip control → collapse whitespace. Strips all combining marks (zalgo and accents) and resolves homoglyphs by visual similarity, but preserves case and does not transliterate.
is_case_fold_stable ¶
is_case_fold_stable() -> bool
True if full case folding and str.lower() agree, so the value is a
stable identity key. 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 = '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).
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.
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.
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:
- Normalize → 2. Confusables → 3. Demojize → 4. Strip accents → 5. Transliterate → 6. Fold case → 7. Collapse whitespace
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"