Skip to content

MathiasPaulenko/pluralio

Repository files navigation

pluralio

Pluralization and singularization for Python

English · Spanish · Portuguese · French · Italian · Esperanto · Zero dependencies · Type-safe · Extensible

PyPI version Python versions CI Coverage License Code style: ruff Type checker: mypy Downloads Docs


Features

  • Zero dependencies — pure Python standard library, nothing else to install
  • Type-safe — full type hints, py.typed marker included (PEP 561)
  • 100% test coverage — 7,233 tests, every line is verified
  • 6 languages — English, Spanish, Portuguese, French, Italian, Esperanto
  • Extensible at runtime — add irregulars, rules, uncountables, or entire languages without touching source code
  • Utility functionsjoin(), ordinal(), template() for common UI tasks
  • Case preservation"Library""Libraries", "BOX""BOXES"
  • Count-awarepluralize("item", count=1)"item"
  • Hyphenated words"mother-in-law""mothers-in-law"
  • Unicode normalization — NFC/NFD inputs handled transparently
  • Sphinx documentation — full API reference at mathiaspaulenko.github.io/pluralio
  • Python 3.10+ — tested on 3.10, 3.11, 3.12, 3.13, and 3.14

Table of contents

Installation

pip install pluralio

Or with uv:

uv add pluralio

Quick start

import pluralio

# ── English (default) ──────────────────────────────────────────
pluralio.pluralize("cat")              # "cats"
pluralio.pluralize("box")              # "boxes"
pluralio.pluralize("child")            # "children"
pluralio.pluralize("city")             # "cities"
pluralio.singularize("cities")         # "city"
pluralio.singularize("mice")           # "mouse"
pluralio.singularize("children")       # "child"

# ── Spanish ────────────────────────────────────────────────────
pluralio.pluralize("gato", lang="es")         # "gatos"
pluralio.pluralize("lápiz", lang="es")        # "lápices"
pluralio.pluralize("examen", lang="es")       # "exámenes"
pluralio.singularize("lápices", lang="es")    # "lápiz"
pluralio.singularize("alemanes", lang="es")   # "alemán"

# ── Portuguese ────────────────────────────────────────────────
pluralio.pluralize("casa", lang="pt")         # "casas"
pluralio.pluralize("coração", lang="pt")      # "corações"
pluralio.pluralize("papel", lang="pt")        # "papéis"
pluralio.pluralize("flor", lang="pt")         # "flores"
pluralio.singularize("corações", lang="pt")   # "coração"
pluralio.singularize("papéis", lang="pt")     # "papel"

# ── French ────────────────────────────────────────────────────
pluralio.pluralize("chat", lang="fr")         # "chats"
pluralio.pluralize("cheval", lang="fr")       # "chevaux"
pluralio.pluralize("bateau", lang="fr")       # "bateaux"
pluralio.pluralize("travail", lang="fr")      # "travaux"
pluralio.pluralize("bijou", lang="fr")        # "bijoux"
pluralio.singularize("chevaux", lang="fr")    # "cheval"
pluralio.singularize("travaux", lang="fr")    # "travail"

# ── Italian ────────────────────────────────────────────────────
pluralio.pluralize("gatto", lang="it")         # "gatti"
pluralio.pluralize("amico", lang="it")         # "amici"
pluralio.pluralize("libro", lang="it")         # "libri"
pluralio.singularize("amici", lang="it")       # "amico"
pluralio.singularize("libri", lang="it")       # "libro"

# ── Esperanto ──────────────────────────────────────────────────
pluralio.pluralize("libro", lang="eo")         # "libroj"
pluralio.pluralize("librojn", lang="eo")       # "librojn" (already plural)
pluralio.singularize("libroj", lang="eo")      # "libro"

# ── Count-aware ────────────────────────────────────────────────
pluralio.pluralize("item", count=1)    # "item"  (singular)
pluralio.pluralize("item", count=0)    # "items" (plural)
pluralio.pluralize("item", count=5)    # "items" (plural)

# ── Case preservation ─────────────────────────────────────────
pluralio.pluralize("Library")          # "Libraries"
pluralio.pluralize("LIBRARY")          # "LIBRARIES"
pluralio.singularize("Libraries")      # "Library"

# ── Hyphenated words ──────────────────────────────────────────
pluralio.pluralize("mother-in-law")    # "mothers-in-law"
pluralio.singularize("mothers-in-law") # "mother-in-law"

# ── Unicode normalization ─────────────────────────────────────
import unicodedata
nfd = unicodedata.normalize("NFD", "lápiz")  # NFD form
pluralio.pluralize(nfd, lang="es")     # "lápices" (NFC output)

# ── List supported languages ──────────────────────────────────
pluralio.supported_languages()         # ["en", "eo", "es", "fr", "it", "pt"]

# ── Inspect word form ─────────────────────────────────────────
pluralio.is_plural("cats")             # True
pluralio.is_singular("cat")           # True
pluralio.is_plural("sheep")            # True   (uncountable — valid as both)
pluralio.is_singular("sheep")          # True   (uncountable — valid as both)
pluralio.is_plural("gatos", lang="es") # True

# ── Join words into a list ────────────────────────────────────
pluralio.join(["apple", "banana", "carrot"])              # "apple, banana, and carrot"
pluralio.join(["apple", "banana", "carrot"], final_sep="") # "apple, banana and carrot"
pluralio.join(["apple", "banana", "carrot"], conjunction="or") # "apple, banana, or carrot"

# ── Ordinals ──────────────────────────────────────────────────
pluralio.ordinal(1)    # "1st"
pluralio.ordinal(2)    # "2nd"
pluralio.ordinal(11)   # "11th"
pluralio.ordinal(21)   # "21st"

# ── Template interpolation ────────────────────────────────────
pluralio.template("I have {count} {word:pluralize}", count=5, word="cat")  # "I have 5 cats"
pluralio.template("I have {count} {word:pluralize}", count=1, word="cat")  # "I have 1 cat"
pluralio.template("The {word:singularize} is here", word="mice")          # "The mouse is here"

How it works

Every word goes through a three-step priority chain:

┌─────────────────────────────────────────────────────────────┐
│                    Input word (stripped)                     │
└───────────────────────────┬─────────────────────────────────┘
                            ▼
              ┌─────────────────────────┐
              │  1. Uncountable check   │  ← highest priority
              │     word in set?        │
              └────────────┬────────────┘
                           │ no
                           ▼
              ┌─────────────────────────┐
              │  2. Irregular lookup    │
              │     word in dict?       │
              └────────────┬────────────┘
                           │ no
                           ▼
              ┌─────────────────────────┐
              │  3. Regex rules         │  ← first match wins
              │     (ordered list)      │
              └────────────┬────────────┘
                           │ no match
                           ▼
              ┌─────────────────────────┐
              │  Return word unchanged  │
              └─────────────────────────┘
  • Uncountable words (e.g. "sheep", "information") are returned as-is.
  • Irregular words (e.g. "man""men") are looked up in a dictionary.
  • Regex rules are applied in order — the first matching pattern wins.
  • The casing of the input is always preserved in the output.

API reference

Core functions

Function Description
pluralize(word, lang="en", count=None) Convert a word to its plural form.
singularize(word, lang="en") Convert a word to its singular form.
is_plural(word, lang="en") Check if a word is in plural form.
is_singular(word, lang="en") Check if a word is in singular form.
supported_languages() Return a sorted list of registered language codes.

Utility functions

Function Description
join(words, *, conjunction="and", final_sep=", ", sep=", ") Join words into a natural-language list.
ordinal(number) Convert a number to its ordinal string (1"1st").
template(text, **kwargs) Interpolate pluralize/singularize into string templates.

Extensibility functions

Function Description
add_irregular(singular, plural, lang="en") Add an irregular pair (both directions).
add_plural(singular, plural, lang="en") Add only the singular → plural direction.
add_singular(plural, singular, lang="en") Add only the plural → singular direction.
add_uncountable(word, lang="en") Mark a word as invariable.
add_plural_rule(pattern, replacement, lang="en") Insert a pluralization regex rule (top priority).
add_singular_rule(pattern, replacement, lang="en") Insert a singularization regex rule (top priority).
register_language(lang, *, plural_rules=..., ...) Register a completely new language.

Low-level registry

Function Description
LanguageRules Dataclass holding all rules for a language.
register(rules) Register a LanguageRules instance.
get_rules(lang) Retrieve rules for a language (raises ValueError if not found).

Extending rules

pluralio is designed to be extended at runtime — no need to modify the source code.

Add an irregular word

Registers both pluralize and singularize directions:

pluralio.add_irregular("person", "people")

pluralio.pluralize("person")      # "people"
pluralio.singularize("people")    # "person"

Add only plural or singular direction

When you need just one direction (e.g. Spanish accent restoration):

# Only pluralize direction
pluralio.add_plural("joven", "jóvenes", lang="es")
pluralio.pluralize("joven", lang="es")    # "jóvenes"

# Only singularize direction (accent restoration)
pluralio.add_singular("alemanes", "alemán", lang="es")
pluralio.singularize("alemanes", lang="es")  # "alemán"

Add an uncountable / invariable word

pluralio.add_uncountable("data")

pluralio.pluralize("data")      # "data"
pluralio.singularize("data")    # "data"

Add a regex rule

Custom rules are inserted at the top of the rule list (highest priority — first match wins):

pluralio.add_plural_rule(r"us$", "i")
pluralio.pluralize("cactus")    # "cacti"

pluralio.add_singular_rule(r"i$", "us")
pluralio.singularize("cacti")   # "cactus"

Register a new language

pluralio.register_language(
    "fr",
    plural_rules=[(r"$", "s")],
    singular_rules=[(r"s$", "")],
    irregular_plurals={"cheval": "chevaux"},
    uncountable={"information"},
)

pluralio.pluralize("chat", lang="fr")        # "chats"
pluralio.pluralize("cheval", lang="fr")      # "chevaux"
pluralio.singularize("chevaux", lang="fr")   # "cheval"
pluralio.pluralize("information", lang="fr") # "information"

Comparison

How does pluralio compare to other Python inflection libraries?

Feature pluralio inflect pluralsingular pluralizer
Spanish pluralization ✅ 100% ❌ 39% ⚠️ 74% ❌ 40%
Spanish singularization ✅ 100% ❌ 29% ⚠️ 87% ❌ N/A
Portuguese pluralization ✅ 100%
French pluralization ✅ 100%
Italian pluralization ✅ 100%
Esperanto pluralization ✅ 100%
Singularize
Count-aware (count=1)
Case preservation
Hyphenated words
Unicode normalization (NFC/NFD)
Idempotency (ES)
Uncountables (ES) ✅ ~87
Accent restoration (ES) ⚠️ Partial
Runtime extensibility
Add custom languages
Zero dependencies
Type hints (py.typed)
Sphinx documentation
Python 3.10+
Test coverage 100% ~95% ~60% ~70%
Number to words
Ordinals (11st)
Indefinite articles (a/an)
Join words into a list
Present participle
Classical pluralization modes
Part-of-speech inflection
Template interpolation
English-only maturity 6 languages Deep EN EN only EN only

Performance

pluralio is fast — no regex compilation at call time, lru_cache on regex application, and islower() short-circuit for the common case:

pluralize:    ~1.8 µs/call
singularize:  ~1.8 µs/call

Benchmark: 100,000 calls across 13 mixed-language words (English, Spanish, Portuguese, French, Italian) on Python 3.14.

Supported languages

Language Code Regex rules Irregulars Uncountables Status
English en 7 + 22 684 219 ✅ Complete
Spanish es 9 + 8 352 + 81 extra singles 92 ✅ Complete
Portuguese pt 8 + 13 388 + 88 extra singles 88 ✅ Complete
French fr 6 + 4 104 + 27 extra singles 81 ✅ Complete
Italian it 19 + 12 239 + 0 extra singles 144 ✅ Complete
Esperanto eo 2 + 2 0 33 ✅ Complete

Roadmap

Version Goal Status
1.0.0 English + Spanish, core engine, extensibility API ✅ Released
1.5.0 Portuguese (pt) ✅ Released
1.6.0 French (fr) ✅ Released
1.7.0 Italian (it) ✅ Released
2.0.0 Rules restructured into pluralio/rules/ subpackage, performance optimization ✅ Released
2.1.0 Esperanto (eo) — trivial -j plural ✅ Released
2.2.0 Utility functions: join(), ordinal(), template() ✅ Released
2.3.0 Catalan (ca) — Romance, natural fit 🔜 Planned
3.0.0 German (de) — umlauts + multiple plural patterns 🔜 Planned

Changelog

See CHANGELOG.md for the full history.

Recent releases

  • 2.2.0 — Utility functions: join(), ordinal(), template()
  • 2.1.2 — Bug fixes: restore() cache clearing, _match_case digit-only source, register_language redundant call
  • 2.1.1 — Bug fixes: Esperanto double-plural, register() cache, _match_case empty target
  • 2.1.0 — Esperanto (eo) support
  • 2.0.0 — Rules restructured into pluralio/rules/ subpackage, performance optimization

Contributing

Contributions are welcome! Whether it's a bug report, a new language, or a feature suggestion — please read our guides first:

Quick start for contributors

git clone https://github.com/MathiasPaulenko/pluralio.git
cd pluralio
pip install -e ".[dev]"

# Run checks
ruff check pluralio/ tests/
mypy pluralio/ tests/
pytest

# Build docs locally
pip install -e ".[docs]"
python -m sphinx -b html docs docs/_build/html

Security

If you discover a security vulnerability, please see our Security Policy for responsible disclosure instructions. Do not open a public issue.

License

MIT — Copyright (c) 2025 Mathias Paulenko

About

Pluralization and singularization for Python — ES/EN, zero deps, type-safe

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages