Skip to main content

Strict-typed, dependency-free gettext-compatible i18n library (PO/MO parser, Plural-Forms evaluator, Catalog API).

Project description

pomo-i18n

English | 日本語

Tests coverage PyPI version Python License

pomo-i18n is a lightweight Python library for working with GNU gettext translation data, centered around a practical and explicit Catalog model.

Version 0.2.0 focuses on providing a gettext-compatible, in-memory translation catalog with a dict-like API, while keeping interoperability with traditional gettext workflows (.po, .mo, gettext(), ngettext()).


Overview

pomo-i18n is designed for developers who want to:

  • Work with gettext translation data without relying on global state
  • Treat translations as explicit Python objects
  • Use gettext-compatible behavior while staying idiomatic in Python

At the core of the library is the Catalog class -- an in-memory representation of a gettext message catalog.

A Catalog:

  • Stores messages keyed by msgid
  • Treats the header (msgid == "") as first-class data
  • Provides a dict-like interface compatible with gettext semantics
  • Supports plural rules, plural evaluation and caching
  • Can be loaded from .po / .mo files and written back to .mo

Unlike the standard gettext module, pomo-i18n does not hide state behind module-level globals. Instead, all translation data lives in explicit Catalog instances, making the behavior predictable, testable, and composable.


Scope and Non-goals

pomo-i18n focuses on explicit, in-memory gettext catalogs.

It intentionally does NOT provide:

  • Global translation state
  • Automatic language switching
  • Framework-specific integrations (Django, Flask, etc.)

These are expected to be built on top of Catalog.


gettext Compatibility

pomo-i18n aims to be behavior-compatible with GNU gettext where it matters:

  • Missing translations fall back to msgid
  • Header handling follows gettext conventions
  • gettext() and ngettext() APIs are provided
  • Plural rules are evaluated according to the gettext specification

At the same time, pomo-i18n exposes these concepts through a clear object model, allowing direct inspection and manipulation of translation data.


Installation

pip install pomo-i18n

Requirements:

  • Python 3.10+

GNU gettext dependency

pomo-i18n does not require GNU gettext at runtime. Only Python is required.

All runtime operations — loading .mo files, evaluating plural rules, and performing gettext() / ngettext() lookups — are implemented entirely in Python.

This makes pomo-i18n usable on platforms such as Windows without installing external GNU gettext tools.

.po files are currently supported as an import format. Full .po -> .mo compilation support is planned for a future release.


Quick Start

The central object in pomo-i18n is the Catalog.

A Catalog represents an in-memory gettext message catalog and can be used much like a Python dictionary.

Create a Catalog

from pypomo.catalog import Catalog

catalog = Catalog()

Dict-like usage

You can assign and retrieve translations using familiar dictionary syntax.

catalog["hello"] = "こんにちは"

print(catalog["hello"])
# -> "こんにちは"

If a translation is missing, the msgid itself is returned (gettext-compatible behavior):

print(catalog["missing"])
# -> "missing"

The gettext header (msgid == "") is treated as first-class data:

catalog[""] = (
    "Language: ja\n"
    "Plural-Forms: nplurals=2; plural=(n != 1);\n"
)

print(catalog[""])
# -> header string

Using get()

Catalog.get() behaves similarly to dict.get() with gettext-compatible fallback behavior:

catalog.get("hello")
# -> CatalogMessage

catalog.get("missing")
# -> "missing"

catalog.get("missing", "fallback")
# -> "fallback"

gettext / ngettext APIs

Catalog also provides familiar gettext-style APIs:

catalog.gettext("hello")
# -> "こんにちは"

catalog.ngettext("apple", "apples", 1)
# -> "apple"

catalog.ngettext("apple", "apples", 2)
# -> "apples"

Plural evaluation follows the Plural-Forms rule defined in the catalog header. If no rule is defined, a gettext-compatible default is used.


Loading and Writing .po / .mo

A Catalog can be created from .po (import) or .mo files and written back to .mo format:

from pypomo.mo.loader import load_mo
from pypomo.mo.writer import write_mo

catalog = load_mo("messages.mo")
write_mo("messages_out.mo", catalog)

(See the sections below for details.)


Design Philosophy: Why Catalog?

The design of pomo-i18n is driven by one core principle:

gettext compatibility without hidden global state

Problems with the standard gettext module

Python’s built-in gettext module is powerful, but it comes with trade-offs:

  • Translation state is stored in module-level globals
  • Active language and domain are often implicit
  • Testing requires patching or reconfiguring global state
  • Inspecting or modifying translation data is difficult

This makes gettext awkward in modern Python applications where:

  • Explicit dependencies are preferred
  • Multiple catalogs may coexist
  • Deterministic behavior and testability matter

Catalog as an explicit translation object

pomo-i18n replaces implicit global state with an explicit object model.

A Catalog is:

  • A concrete, inspectable Python object
  • An in-memory representation of a gettext message catalog
  • The single source of truth for:
    • messages
    • header metadata
    • plural rules
    • language information

Instead of asking “what is the current translation?”, you work with “this specific catalog instance”.

catalog = Catalog()
catalog["hello"] = "こんにちは"

Dict-like API with gettext semantics

A key design goal of Catalog is to feel natural to Python users while remaining faithful to gettext behavior.

Example:

  • Missing keys fall back to msgid
  • The header (msgid == "") is a first-class entry
  • No KeyError is raised for missing translations
  • Plural behavior follows Plural-Forms rules
catalog["missing"]
# -> "missing"

This mirrors how gettext behaves, but through a familiar dictionary-style interface.


Separation of concerns

pomo-i18n intentionally separates responsibilities:

  • Catalog

  • Owns message data, header state, and plural rules

  • CatalogMessage

  • Represents a single gettext entry (singular, plural, translations)

  • .po / .mo loaders and writers

    Handle file formats, not runtime behavior

This separation allows:

  • Clean internal APIs
  • Easier refactoring
  • Future extensions (e.g. merge strategies, plural dict access)

Designed for extensions, not hidden behavior

pomo-i18n is designed around explicit objects and predictable behavior, rather than hidden state or implicit side effects.

  • No implicit language switching
  • No hidden registries
  • No global translation state

Instead, it provides building blocks that can be composed to fit different application architectures.

This makes the library suitable for:

  • CLI tools
  • Web frameworks
  • Embedded systems
  • Testing environments
  • Custom i18n pipelines

Summary

In short,

  • Catalog replaces implicit gettext state with explicit objects
  • Dict-like APIs make translation access intuitive
  • gettext compatibility is preserved where it matters
  • The design favors clarity, predictability, and extensibility

This philosophy shapes all APIs in pomo-i18n starting with Catalog and expanding outward.


Dict-like API details (v0.2.0)

Catalog provides a dictionary-like interface that follows gettext-compatible semantics while remaining explicit and Pythonic.

This API is the primary interface of pomo-i18n v0.2.0.


catalog[key] -- lookup

catalog["hello"]

Behavior:

Condition Result
key exists translated singular string
key missing returns key itself
key == "" returns header msgstr
key is not str TypeError

Example:

catalog["hello"] = "こんにちは"

catalog["hello"]	# -> "こんにちは"
catalog["missing"]	# -> "missing"
catalog[""]			# -> `header string`

Notes:

  • Never raises KeyError
  • Fully compatible with gettext fallback behavior
  • Plural forms are not handled here (v0.2.0)

catalog[key] = value -- assignment

catalog["hello"] = "こんにちは"

Behavior:

  • key must be str
  • value must be str
  • Creates or replaces a singular message
  • key == "" updates the catalog header

Example:

catalog[""] = "Language: ja\nPlural-Forms: nplurals=2; plural=(n != 1);\n"

Notes:

  • Plural assignment is intentionally not supported in v0.2.0.
  • Header assignment updates plural rules if present

"key" in catalog -- membership test

"hello" in catalog

Behavior:

Condition Result
existing msgid True
missing msgid False
key == "" True if header exists
key not str False

Example:

"hello" in catalog
"" in catalog

Iteration and Views

for key in catalog:
	...

Equivalent to:

catalog.keys()

Available views:

catalog.keys()		# KeysView[str]
catalog.values()	# ValuesView[CatalogMessage]
catalog.items()		# ItemsView[str, CatalogMessage]
len(catalog)		# number of messages (including header)

catalog.get(key[, default])

catalog.get("hello")
catalog.get("hello", default)

Behavior (gettext-compatible):

Condition Result
key exists CatalogMessage
key missing, default provided default
key missing, no default returns key
key not str TypeError

Example:

catalog.get("hello")
# -> CatalogMessage

catalog.get("missing")
# -> "missing"

catalog.get("missing", "fallback")
# -> "fallback"

Design notes:

  • Unlike dict.get(), the no-default case returns key
  • This mirrors gettext's fallback semantics
  • The returned object is not a string when a message exists

Deletion is not supported

del catalog["hello"]

Always raises:

TypeError

Reason:

  • gettext catalogs are append/override-oriented
  • Explicit deletion is intentionally unsupported

Design rationale

  • No KeyError -- gettext-compatible behavior
  • Explicit message objects -- avoids hidden state
  • Header as first-class data -- not a special case
  • Singular-first API -- plural dict-like API planned for v0.3.0

gettext / ngettext APIs

In addition to its dict-like interface, Catalog provides gettext-compatible translation APIs.

These APIs are intended for drop-in familiarity with existing gettext-based code, while still operating on an explicit Catalog interface.


gettext()

catalog.gettext("hello")
# -> "こんにちは"

Behavior:

Situation Result
Translation exists Translated string
Translation not exists Original msgid
msgid == "" (header) Not accessed via gettext()

This matches standard GNU gettext behavior.


ngettext()

catalog.ngettext("apple", "apples", 1)
# -> "apple"

catalog.ngettext("apple", "apples", 2)
# -> "apples"

Behavior:

Step Condition Result
1 No translation exists singular if n == 1, else plural
2 Translation exists Evaluate plural index via Plural-Forms
3 msgstr[index] exists Return that form
4 Index out of range, msgstr[0] exists Fallback to msgstr[0]
5 No valid translation form found Fallback to translated singular
6 Last resort Fallback to original plural argument

Plural selection follows GNU gettext semantics.


Plural Rules

Plural rules are defined by the Plural-Forms header:

Plural-Forms: nplurals=2; plural=(n != 1);
Case Behavior
Header contains Plural-Forms Rule is parsed, compiled, and cached
Header missing gettext-compatible default is used
Default rule nplurals=2; plural=(n != 1)

Relationship to the Dict-like API

The dict-like API and gettext-style APIs are complementary

API Purpose
catalog["key"] Simple access (singular only)
catalog.get() Introspective access to message objects
gettext() gettext-compatible string lookup
ngettext() Plural-aware gettext-compatible lookup

In v0.2.0:

  • Dict-like access is singular-style
  • Plural handling is intentionally explicit via ngettext()

Design Notes

Design Choice Rationale
Instance-based API No hidden global state
Explicit Catalog object Deterministic, testable behavior
Separate plural API Avoid ambiguous dict-like plural semantics

Future Direction

Version Planned Improvement
v0.3.x Dict-like plural support
v0.3.x+ Closer integration with CatalogMessage

Until then, ngettext() remains the canonical plural API.


CatalogMessage Design

CatalogMessage is the internal message representation used by Catalog.

It corresponds closely to a single gettext entry (msgid, msgstr, msgid_plural, msgstr[n]), but is normalized into a Python-friendly, immutable-like structure.


Purpose

CatalogMessage exists to:

  • Represent a fully resolved translation unit

  • Separate parsing concerns (POEntry) from runtime lookup

  • Provide a stable object model for:

    • dict-like access
    • gettext / ngettext evaluation
    • .mo writing

Unlike POEntry, CatalogMessage is not a file format model -- it represents how translations behave at runtime.


Structure

@dataclass(slots=True)
class CatalogMessage:
	msgid: str
	singular: str
	plural: str | None
	translations: Dict[int, str]

Fields:

Field Meaning
msgid Logical key (original untranslated string)
singular Primary translation (never empty)
plural Plural msgid or None
translations {plural_index: translated_string}

Normalization rules

CatalogMessage enforces several invariants in __post_init__:

  • singular is never empty

    • Falls back to msgid
  • plural == "" is normalized to None

  • translations[0] always exists

    • Falls back to singular
  • Empty plural translations fall back conservatively:

    • index 0 -> singular
    • index n -> plural or singular

This guarantees that:

  • Lookup code never needs defensive checks
  • Missing plural forms degrade safely
  • .mo writing can assume consistent data

Construction helpers

Singular message

CatalogMessage.from_singular(
	msgid="hello",
	msgstr="こんにちは"
)

Used by:

  • catalog["key"] = value
  • Simple catalogs without plural support

Plural message

CatalogMessage.from_plural(
	msgid="apple",
	msgid_plural="apples",
	forms={
		0: "りんご",
		1: "りんごたち",
	},
)

Used internally when building from .po or mo data.

Missing forms are normalized automatically.


Access helpers

msg.as_plain()

Returns the singular translation (translations[0])

msg.get_plural(index)

Safely retrieves a plural form with fallback.

These helpers are primarily used by:

  • Catalog.__getitem__
  • Catalog.ngettext
  • .mo writer

Immutability model

CatalogMessage is immutable-like by design:

  • Uses @dataclass(slots=True)
  • No public mutation API
  • Treated as replace-on-write inside Catalog

This follows:

  • Safe reuse across catalogs
  • Predictable merge / update semantics
  • Efficient caching and plural evaluation

Relationship to other layers

Layer Role
POEntry File-format parsing model (.po)
CatalogMessage Runtime message representation
Catalog Lookup, plural evaluation, API surface

This separation is intentional:

  • Parsing (POEntry) may change in future versions
  • Runtime behavior (CatalogMessage) remains stable

Version notes

  • v0.2.0 supports singular-first usage
  • Plural dict-like assignment is planned for v0.3.0
  • CatalogMessage already supports full plural data

Loading and Writing Translation Files

pomo-i18n treats file formats (.po, .mo) as serialization layers around the central Catalog abstraction.

In v0.2.0, .mo files are the primary runtime format, while .po files are used as an import format.


Loading .po Files

Text-based .po files can be parsed using POParser and converted into a Catalog:

from pypomo.parser.po_parser import POParser
from pypomo.catalog import Catalog

parser = POParser()
entries = parser.parse("messages.po")

catalog = Catalog.from_po_entries(entries)

Notes:

  • .po parsing supports:

    • msgid
    • msgstr
    • msgid_plural
    • msgstr[n]
    • multiline strings
    • comments
  • Advanced features such as #, fuzzy, flags are collected as comments but not yet interpreted semantically

  • .po files are treated primarily as an import format in v0.2.0


Loading .mo Files

Binary .mo files can be loaded directly into a Catalog:

from pypomo.mo.loader import load_mo

catalog = load_mo("messages.mo")

This auto:

  • Loads all messages into memory
  • Parses the header (msgid == "")
  • Extracts and evaluates Plural-Forms
  • Enables immediate use via:
    • catalog["msgid"]
    • catalog.gettext()
    • catalog.ngettext()

Writing .mo Files

A Catalog can be written back to a gettext-compatible .mo file:

from pypomo.mo.writer import write_mo

write_mo("messages_out.mo", catalog)

Note:

  • Output is fully GNU gettext-compatible
  • Header and plural rules are preserved
  • Changes made via the dict-like API are reflected in the output
  • .mo is the recommended runtime/export format

Writing .po Files

Writing .po files is not supported v0.2.0.

Support for .po writing and richer metadata handling is planned for future releases.


Design Notes

  • Catalog is the single source of truth
  • .po / .mo files are serialization layers
  • No hidden global state
  • Explicit, testable, composable translation handling

Roadmap (Related)

Planned improvements in upcoming versions include:

  • Refactored .po module layout (pypomo.po.*)
  • Full support for flags (fuzzy, etc.)
  • .po writing support
  • Richer merge and update strategies

Roadmap

  • v0.3.x
    • Dict-like plural access
    • Refactored .po module layout
    • Improved merge strategies

License

MIT License © 2025 Kiminori Kato

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pomo_i18n-0.2.0.tar.gz (44.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pomo_i18n-0.2.0-py3-none-any.whl (8.8 kB view details)

Uploaded Python 3

File details

Details for the file pomo_i18n-0.2.0.tar.gz.

File metadata

  • Download URL: pomo_i18n-0.2.0.tar.gz
  • Upload date:
  • Size: 44.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for pomo_i18n-0.2.0.tar.gz
Algorithm Hash digest
SHA256 6c99d3e15d552a008b41fac091c52578ad8321f50839b39b58e5765630ca365f
MD5 2385dddd766c84deddd3929a007e1ba5
BLAKE2b-256 495d39bd9307e9830016408525e6bee23c40e3ccc6b0112190120f7c1b92400e

See more details on using hashes here.

Provenance

The following attestation bundles were made for pomo_i18n-0.2.0.tar.gz:

Publisher: release.yml on kimikato/pomo-i18n

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pomo_i18n-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: pomo_i18n-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 8.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for pomo_i18n-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d88fdaf22b607a5456bcb22822b5633c4bdcaf9ead7b0472f62d4c2ebbeba679
MD5 375a37a588bdc1c31bfcb7e4994c87e0
BLAKE2b-256 ac42fb3204043be41e1a5ab767bb35291e529d3625a457b7c3b5d1fb30e13d9f

See more details on using hashes here.

Provenance

The following attestation bundles were made for pomo_i18n-0.2.0-py3-none-any.whl:

Publisher: release.yml on kimikato/pomo-i18n

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page