Theme
Version
GitHub PyPI Discord
On this page

Parse localized input

Formatting and parsing solve opposite problems, but they share the same named profile. A profile says how a value is displayed and, when configured, which strict editing grammar the matching parser accepts.

Citry does not parse free-form requests such as "next Tuesday evening" or guess a currency or measurement unit from text.

Keep the user's edit separate from its value

A parse result preserves the exact input and reports its state:

result = self.i18n.parse.number(
    raw_amount,
    format="measurement",
)

if result.valid:
    save_amount(result.value)
else:
    show_edit_again(result.input, result.error)

Numeric results have three states:

  • valid: value contains a canonical Decimal;
  • incomplete: the input may become valid with more typing, such as a trailing decimal separator; and
  • invalid: the input breaks the profile's grammar.

Do not replace the edit field with value while the user is still typing. Keep the localized string in the control and use the canonical value for domain logic after the result becomes valid.

Parse numbers with locale digits and separators

Every NumberFormat includes a NumberInput policy:

from citry import FormatRegistry, NumberFormat, NumberInput

formats = FormatRegistry(
    number={
        "measurement": NumberFormat(),
        "scientific-measurement": NumberFormat(
            input=NumberInput(
                notation="decimal_or_scientific",
            ),
        ),
    },
)

The default accepts strict decimal notation. It checks the locale's digits, decimal separator, grouping separator, grouping sizes, and signs. It does not silently accept a digit or separator from another locale.

decimal_or_scientific also accepts ASCII e or E; the exponent digits still use the selected locale's digit set.

Parse percentages as ratios

PercentInput chooses whether the user edits the locale's percent affix:

from citry import PercentFormat, PercentInput

formats = FormatRegistry(
    percent={
        "completion": PercentFormat(
            input=PercentInput(affix="required"),
        ),
        "completion-field": PercentFormat(
            input=PercentInput(affix="omit"),
        ),
    },
)

Use required when the percent sign belongs inside the editable text. Use omit when the control renders the affix outside its input field.

Both modes return a ratio. Parsing localized 12.5 percent returns Decimal("0.125").

Choose strict text or segmented dates

A date profile may be display-only or may declare one input mode:

from citry import DateFormat, DateInput

formats = FormatRegistry(
    date={
        "invoice-date": DateFormat(
            length="short",
            input=DateInput(mode="strict_text"),
        ),
        "birthday-fields": DateFormat(
            length="long",
            input=DateInput(mode="segments"),
        ),
    },
)

strict_text accepts the locale-specific field order, separators, digits, and month names produced by that profile. It does not accept another locale's date shape.

segments is for a control that already owns separate fields. Pass the fields by meaning, regardless of their visual order:

from citry import DateSegments

result = self.i18n.parse.date_segments(
    DateSegments(
        year=year_edit,
        month=month_edit,
        day=day_edit,
    ),
    format="birthday-fields",
)

The valid result is a canonical Python date. Citry uses the selected locale's calendar when reading the fields and reports ambiguous or unsupported calendar input rather than guessing.

Make two-digit years explicit

By default, a year requires enough digits to identify it. If a product truly accepts two-digit years, define the first year of one explicit 100-year window:

DateInput(
    mode="strict_text",
    two_digit_year_start=1950,
)

The profile now maps two-digit years into 1950 through 2049. Another product can choose another window. Citry does not derive the window from the current date.

Parse wall-clock time without a zone

Time parsing follows the same two input modes:

from citry import TimeFormat, TimeInput, TimeSegments

formats = FormatRegistry(
    time={
        "appointment-time": TimeFormat(
            length="medium",
            input=TimeInput(mode="segments"),
        ),
    },
)

result = self.i18n.parse.time_segments(
    TimeSegments(
        hour="2",
        minute="30",
        second="00",
        day_period="PM",
    ),
    format="appointment-time",
)

A valid result is a zone-free Python time. Converting it to an instant needs a date and a time zone, so it belongs to datetime parsing instead.

Resolve local datetimes through an explicit zone

Datetime parsing combines date and time fields and requires a context with an IANA time zone:

from citry import (
    DateSegments,
    DateTimeFormat,
    DateTimeInput,
    DateTimeSegments,
    TimeSegments,
)

formats = FormatRegistry(
    datetime={
        "appointment": DateTimeFormat(
            length="medium",
            input=DateTimeInput(mode="segments"),
        ),
    },
)

context = i18n.make_context(
    locale="en-US",
    time_zone="Europe/Prague",
)
parser = i18n.for_context(context).parse

edit = DateTimeSegments(
    date=DateSegments(year="2026", month="10", day="25"),
    time=TimeSegments(hour="2", minute="30", second="00"),
)
result = parser.datetime_segments(edit, format="appointment")

A local time in a daylight-saving gap is invalid. A local time in a fold is ambiguous and contains both possible aware instants in alternatives. Resolve the user's choice explicitly:

result = parser.datetime_segments(
    edit,
    format="appointment",
    fold="earlier",  # or "later"
)

Citry reads time-zone transitions from its pinned tzdata package rather than the host machine's unversioned zone database.

Know the browser boundary

The browser service currently provides synchronous strict parsing for numbers and percentages:

const result = $i18n.parse.number(
  "12,345.50",
  { format: "measurement" },
);

It returns a frozen object with input, state, value, error, and valid. The canonical numeric value is a string so JavaScript does not lose decimal precision.

Date, time, and datetime parsing currently runs on the server. Those browser methods are absent because safe parity requires generated calendar-conversion and daylight-saving records; Citry does not reverse-engineer arbitrary Intl.DateTimeFormat output.

Unit controls parse their numeric field with parse.number() and keep the unit as separate domain data. Currency controls likewise keep the currency code explicit.