Parsing user input¶
Introduction to parsers and
yuio.parse.
Parsers control how Yuio interprets user input; they provide hints about which widgets to use, how to do autocompletion, and so on. Every time you get data from user, a parser is involved.
By default, Yuio constructs an appropriate parser from type hints. You can customize
this process by using typing.Annotated, or you can build a parser on your own.
Creating and using a parser¶
Parser classes are located in yuio.parse. Let’s make a simple parser
for positive integers:
import yuio.parse
parser = yuio.parse.Gt(yuio.parse.Int(), 0)
We can now use this parser on our own:
>>> parser.parse("10") # Parse text input
10
>>> data = 5 # Pretend this was loaded from JSON
>>> parser.parse_config(data) # Convert raw JSON data
5
Or pass it to other Yuio methods:
yuio.io.ask("Choose a number", parser=parser)
Creating a parser from type hints¶
You can also build a parser from type hint, should you need one:
>>> parser = yuio.parse.from_type_hint(dict[str, int])
>>> parser.parse("x:10 y:-5")
{'x': 10, 'y': -5}
Annotating type hints¶
Type hints offer a concise way to build a parser. However, they’re less expressive
when it comes to constraints or validation. You’ll have to use
typing.Annotated to inject parsers that don’t map directly to types:
from typing import Annotated
type_hint = dict[str, Annotated[int, yuio.parse.Gt(0)]]
Here, we’ve created a parser for dictionaries that map strings to positive ints.
Technically, Yuio will derive a parser from int, then it will apply
yuio.parse.Gt(0) on top of it.
Customizing parsers for CLI arguments and config fields¶
Now that we know how to use parsers, we can customize CLI arguments and config fields:
import yuio.app
import yuio.parse
@yuio.app.app
def main(
n_threads: int | None = yuio.app.field(
default=None,
parser=yuio.parse.Gt(yuio.parse.Int(), 0), # [1]_
)
):
...
Parser type must always match field type.
import yuio.app
import yuio.parse
@yuio.app.app
def main(
n_threads: Annotated[int, yuio.parse.Gt(0)] | None = None
):
...
Enum parser¶
A parser that you will use quite often is yuio.parse.Enum.
It parses enumerations derived from enum.Enum. We encourage use of enums
over plain strings because they provide enhanced widgets and autocompletion:
Enum parser has a few useful settings. It can load enumerators by name or by value, and it also can convert enumerator names to dash case:
class Beverage(enum.Enum):
COFFEE = 1
TEA = 2
SODA = 3
WATER = 4
yuio.io.ask(
"Which beverage would you like?",
parser=yuio.parse.Enum(Beverage, by_name=True, to_dash_case=True),
)
class Beverage(enum.Enum):
COFFEE = 1
TEA = 2
SODA = 3
WATER = 4
yuio.io.ask[
Annotated[
Beverage,
yuio.parse.Enum(by_name=True, to_dash_case=True),
]
]("Which beverage would you like?")
Literal parser¶
When making a separate enum is not practical, you can use yuio.parse.Literal:
yuio.io.ask(
"Which beverage would you like?",
parser=yuio.parse.Literal("coffee", "tea", "soda", "water"),
)
from typing import Literal
yuio.io.ask[Literal["coffee", "tea", "soda", "water"]](
"Which beverage would you like?"
)
JSON parser¶
While Yuio supports parsing collections, it doesn’t provide a fully capable context-free parser; instead, it relies on splitting string by delimiters, which can be limiting.
To enable parsing more complex structures, Yuio has yuio.parse.Json.
It can be used on its own:
>>> parser = yuio.parse.Json()
>>> parser.parse('{"key": "value"}')
{'key': 'value'}
>>> parser = yuio.parse.from_type_hint(yuio.parse.JsonValue)
>>> parser.parse('{"key": "value"}')
{'key': 'value'}
Or with a nested parser:
>>> parser = yuio.parse.Json(yuio.parse.List(yuio.parse.Int()))
>>> parser.parse("[1, 2, 3]")
[1, 2, 3]
>>> parser = yuio.parse.from_type_hint(Annotated[list[int], yuio.parse.Json()])
>>> parser.parse("[1, 2, 3]")
[1, 2, 3]
Validating parsers¶
Yuio provides a variety of parsers that validate
user input. If, however, you need a more complex validating procedure,
you can use yuio.parse.Apply with a custom function that throws
yuio.parse.ParsingError if validation fails.
For example, let’s make a parser that checks if the input is even:
def assert_is_even(value: int):
if value % 2 != 0:
raise yuio.parse.ParsingError(
"Expected an even value: `%r`", # [1]_
value,
)
parser = yuio.parse.Apply(yuio.parse.Int(), assert_is_even)
t-strings are also supported here.
def assert_is_even(value: int):
if value % 2 != 0:
raise yuio.parse.ParsingError(
"Expected an even value: `%r`", # [1]_
value,
)
parser = yuio.parse.from_type_hint(
Annotated[int, yuio.parse.Apply(assert_is_even)]
)
t-strings are also supported here.
The parser will apply our assert_is_even to all values that it returns:
>>> parser.parse("2")
2
>>> parser.parse("3")
Traceback (most recent call last):
...
yuio.parse.ParsingError: Expected an even value: 3
Mutating parsers¶
In addition to validation, you can mutate the input. For example:
>>> parser = yuio.parse.Lower(yuio.parse.Str())
>>> parser.parse("UPPER")
'upper'
>>> parser = yuio.parse.from_type_hint(
... Annotated[str, yuio.parse.Lower()]
... )
>>> parser.parse("UPPER")
'upper'
You can also use yuio.parse.Map to implement custom mutations.
Note that parsers need to convert parsed values back to their original form
when printing them, rendering examples for documentation, or converting to JSON.
For this reason, Map allows specifying a function
to undo the change:
>>> parser = yuio.parse.Map(
... yuio.parse.Int(),
... lambda x: 2 ** x,
... lambda x: int(math.log2(x)), # [1]_
... )
>>> parser.parse("10")
1024
>>> parser.describe_value(1024)
'10'
Reverse mapper is used when rendering documentation or converting values to JSON.
>>> parser = yuio.parse.from_type_hint(Annotated[
... int,
... yuio.parse.Map(
... lambda x: 2 ** x,
... lambda x: int(math.log2(x))), # [1]_
... ])
>>> parser.parse("10")
1024
>>> parser.describe_value(1024)
'10'
Reverse mapper is used when rendering documentation or converting values to JSON.
Union parsers¶
Yuio supports parsing unions of types:
>>> parser = yuio.parse.Union(yuio.parse.Int(), yuio.parse.Str())
>>> parser.parse("10")
10
>>> parser.parse("kitten")
'kitten'
>>> parser = yuio.parse.from_type_hint(int | str)
>>> parser.parse("10")
10
>>> parser.parse("kitten")
'kitten'
Warning
Order of parsers matters. Since parsers are tried in the same order as they’re given, make sure to put parsers that are likely to succeed at the end.
For example, this parser will always return a string because
Str can’t fail:
>>> parser = yuio.parse.Union(yuio.parse.Str(), yuio.parse.Int()) # Always returns a string!
>>> parser.parse("10")
'10'
To fix this, put Str at the end so that
Int is tried first:
>>> parser = yuio.parse.Union(yuio.parse.Int(), yuio.parse.Str())
>>> parser.parse("10")
10
>>> parser.parse("not an int")
'not an int'