Coverage for yuio / parse.py: 90%
1830 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-29 19:55 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-29 19:55 +0000
1# Yuio project, MIT license.
2#
3# https://github.com/taminomara/yuio/
4#
5# You're free to copy this file to your project and edit it for your needs,
6# just keep this copyright line please :3
8"""
9Everything to do with parsing user input.
11Use provided classes to construct parsers and add validation::
13 >>> # Parses a string that matches the given regex.
14 >>> ident = Regex(Str(), r'^[a-zA-Z_][a-zA-Z0-9_]*$')
16 >>> # Parses a non-empty list of strings.
17 >>> idents = LenGe(List(ident), 1)
19Pass a parser to other yuio functions::
21 >>> yuio.io.ask('List of modules to reformat', parser=idents) # doctest: +SKIP
23Or parse strings yourself::
25 >>> idents.parse('sys os enum dataclasses')
26 ['sys', 'os', 'enum', 'dataclasses']
28Build a parser from type hints::
30 >>> from_type_hint(list[int] | None)
31 Optional(List(Int))
34Parser basics
35-------------
37All parsers are derived from the same base class :class:`Parser`,
38which describes parsing API.
40.. autoclass:: Parser
42 .. automethod:: parse
44 .. automethod:: parse_many
46 .. automethod:: supports_parse_many
48 .. automethod:: parse_config
50.. autoclass:: ParsingError
51 :members:
54Value parsers
55-------------
57.. autoclass:: Str
59.. autoclass:: Int
61.. autoclass:: Float
63.. autoclass:: Bool
65.. autoclass:: Enum
67.. autoclass:: Literal
69.. autoclass:: Decimal
71.. autoclass:: Fraction
73.. autoclass:: DateTime
75.. autoclass:: Date
77.. autoclass:: Time
79.. autoclass:: TimeDelta
81.. autoclass:: Seconds
83.. autoclass:: Json
85.. autoclass:: List
87.. autoclass:: Set
89.. autoclass:: FrozenSet
91.. autoclass:: Dict
93.. autoclass:: Tuple
95.. autoclass:: Optional
97.. autoclass:: Union
99.. autoclass:: Path
101.. autoclass:: NonExistentPath
103.. autoclass:: ExistingPath
105.. autoclass:: File
107.. autoclass:: Dir
109.. autoclass:: GitRepo
111.. autoclass:: Secret
114.. _validating-parsers:
116Validators
117----------
119.. autoclass:: Regex
121.. autoclass:: Bound
123.. autoclass:: Gt
125.. autoclass:: Ge
127.. autoclass:: Lt
129.. autoclass:: Le
131.. autoclass:: LenBound
133.. autoclass:: LenGt
135.. autoclass:: LenGe
137.. autoclass:: LenLt
139.. autoclass:: LenLe
141.. autoclass:: OneOf
144Auxiliary parsers
145-----------------
147.. autoclass:: Map
149.. autoclass:: Apply
151.. autoclass:: Lower
153.. autoclass:: Upper
155.. autoclass:: CaseFold
157.. autoclass:: Strip
159.. autoclass:: WithMeta
162Deriving parsers from type hints
163--------------------------------
165There is a way to automatically derive basic parsers from type hints
166(used by :mod:`yuio.config`):
168.. autofunction:: from_type_hint
171.. _partial parsers:
173Partial parsers
174---------------
176Sometimes it's not convenient to provide a parser for a complex type when
177all we need is to make a small adjustment to a part of the type. For example:
179.. invisible-code-block: python
181 from yuio.config import Config, field
183.. code-block:: python
185 class AppConfig(Config):
186 max_line_width: int | str = field(
187 default="default",
188 parser=Union(
189 Gt(Int(), 0),
190 OneOf(Str(), ["default", "unlimited", "keep"]),
191 ),
192 )
194.. invisible-code-block: python
196 AppConfig()
198Instead, we can use :obj:`typing.Annotated` to attach validating parsers directly
199to type hints:
201.. code-block:: python
203 from typing import Annotated
206 class AppConfig(Config):
207 max_line_width: (
208 Annotated[int, Gt(0)]
209 | Annotated[str, OneOf(["default", "unlimited", "keep"])]
210 ) = "default"
212.. invisible-code-block: python
214 AppConfig()
216Notice that we didn't specify inner parsers for :class:`Gt` and :class:`OneOf`.
217This is because their internal parsers are derived from type hint, so we only care
218about their settings.
220Parsers created in such a way are called "partial". You can't use a partial parser
221on its own because it doesn't have full information about the object's type.
222You can only use partial parsers in type hints::
224 >>> partial_parser = List(delimiter=",")
225 >>> partial_parser.parse_with_ctx("1,2,3") # doctest: +ELLIPSIS
226 Traceback (most recent call last):
227 ...
228 TypeError: List requires an inner parser
229 ...
232Other parser methods
233--------------------
235:class:`Parser` defines some more methods and attributes.
236They're rarely used because Yuio handles everything they do itself.
237However, you can still use them in case you need to.
239.. autoclass:: Parser
240 :noindex:
242 .. autoattribute:: __wrapped_parser__
244 .. automethod:: parse_with_ctx
246 .. automethod:: parse_many_with_ctx
248 .. automethod:: parse_config_with_ctx
250 .. automethod:: get_nargs
252 .. automethod:: check_type
254 .. automethod:: assert_type
256 .. automethod:: describe
258 .. automethod:: describe_or_def
260 .. automethod:: describe_many
262 .. automethod:: describe_value
264 .. automethod:: options
266 .. automethod:: completer
268 .. automethod:: widget
270 .. automethod:: to_json_schema
272 .. automethod:: to_json_value
274 .. automethod:: is_secret
277Building your own parser
278------------------------
280.. _parser hierarchy:
282Understanding parser hierarchy
283~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
285The topmost class in the parser hierarchy is :class:`PartialParser`. It provides
286abstract methods to deal with `partial parsers`_. The primary parser interface,
287:class:`Parser`, is derived from it. Below :class:`Parser`, there are several
288abstract classes that provide boilerplate implementations for common use cases.
290.. raw:: html
292 <p>
293 <pre class="mermaid">
294 ---
295 config:
296 class:
297 hideEmptyMembersBox: true
298 ---
299 classDiagram
301 class PartialParser
302 click PartialParser href "#yuio.parse.PartialParser" "yuio.parse.PartialParser"
304 class Parser
305 click Parser href "#yuio.parse.Parser" "yuio.parse.Parser"
306 PartialParser <|-- Parser
308 class ValueParser
309 click ValueParser href "#yuio.parse.ValueParser" "yuio.parse.ValueParser"
310 Parser <|-- ValueParser
312 class WrappingParser
313 click WrappingParser href "#yuio.parse.WrappingParser" "yuio.parse.WrappingParser"
314 Parser <|-- WrappingParser
316 class MappingParser
317 click MappingParser href "#yuio.parse.MappingParser" "yuio.parse.MappingParser"
318 WrappingParser <|-- MappingParser
320 class Map
321 click Map href "#yuio.parse.Map" "yuio.parse.Map"
322 MappingParser <|-- Map
324 class Apply
325 click Apply href "#yuio.parse.Apply" "yuio.parse.Apply"
326 MappingParser <|-- Apply
328 class ValidatingParser
329 click ValidatingParser href "#yuio.parse.ValidatingParser" "yuio.parse.ValidatingParser"
330 Apply <|-- ValidatingParser
332 class CollectionParser
333 click CollectionParser href "#yuio.parse.CollectionParser" "yuio.parse.CollectionParser"
334 ValueParser <|-- CollectionParser
335 WrappingParser <|-- CollectionParser
336 </pre>
337 </p>
339The reason for separation of :class:`PartialParser` and :class:`Parser`
340is better type checking. We want to prevent users from making a mistake of providing
341a partial parser to a function that expect a fully initialized parser. For example,
342consider this code:
344.. skip: next
346.. code-block:: python
348 yuio.io.ask("Enter some names", parser=List())
350This will fail because :class:`~List` needs an inner parser to function.
352To annotate this behavior, we provide type hints for ``__new__`` methods
353on each parser. When an inner parser is given, ``__new__`` is annotated as
354returning an instance of :class:`Parser`. When inner parser is omitted,
355``__new__`` is annotated as returning an instance of :class:`PartialParser`:
357.. skip: next
359.. code-block:: python
361 from typing import TYPE_CHECKING, Any, Generic, overload
363 class List(..., Generic[T]):
364 if TYPE_CHECKING:
365 @overload
366 def __new__(cls, delimiter: str | None = None) -> PartialParser:
367 ...
368 @overload
369 def __new__(cls, inner: Parser[T], delimiter: str | None = None) -> PartialParser:
370 ...
371 def __new__(cls, *args, **kwargs) -> Any:
372 ...
374With these type hints, our example will fail to type check: :func:`yuio.io.ask`
375expects a :class:`Parser`, but ``List.__new__`` returns a :class:`PartialParser`.
377Unfortunately, this means that all parsers derived from :class:`WrappingParser`
378must provide appropriate type hints for their ``__new__`` method.
380.. autoclass:: PartialParser
381 :members:
384Parsing contexts
385~~~~~~~~~~~~~~~~
387To track location of errors, parsers work with parsing context:
388:class:`StrParsingContext` for parsing raw strings, and :class:`ConfigParsingContext`
389for parsing configs.
391When raising a :class:`ParsingError`, pass context to it so that we can show error
392location to the user.
394.. autoclass:: StrParsingContext
395 :members:
397.. autoclass:: ConfigParsingContext
398 :members:
401Base classes
402~~~~~~~~~~~~
404.. autoclass:: ValueParser
406.. autoclass:: WrappingParser
408 .. autoattribute:: _inner
410 .. autoattribute:: _inner_raw
412.. autoclass:: MappingParser
414.. autoclass:: ValidatingParser
416 .. autoattribute:: __wrapped_parser__
417 :noindex:
419 .. automethod:: _validate
421.. autoclass:: CollectionParser
423 .. autoattribute:: _allow_completing_duplicates
426Adding type hint conversions
427~~~~~~~~~~~~~~~~~~~~~~~~~~~~
429You can register a converter so that :func:`from_type_hint` can derive custom
430parsers from type hints:
432.. autofunction:: register_type_hint_conversion(cb: Cb) -> Cb
434When implementing a callback, you might need to specify a delimiter
435for a collection parser. Use :func:`suggest_delim_for_type_hint_conversion`:
437.. autofunction:: suggest_delim_for_type_hint_conversion
440Re-imports
441----------
443.. type:: JsonValue
444 :no-index:
446 Alias of :obj:`yuio.json_schema.JsonValue`.
448.. type:: SecretString
449 :no-index:
451 Alias of :obj:`yuio.secret.SecretString`.
453.. type:: SecretValue
454 :no-index:
456 Alias of :obj:`yuio.secret.SecretValue`.
458"""
460from __future__ import annotations
462import abc
463import argparse
464import contextlib
465import dataclasses
466import datetime
467import decimal
468import enum
469import fractions
470import functools
471import json
472import pathlib
473import re
474import threading
475import traceback
476from copy import copy as _copy
478import yuio
479import yuio.color
480import yuio.complete
481import yuio.json_schema
482import yuio.string
483import yuio.widget
484from yuio.json_schema import JsonValue
485from yuio.secret import SecretString, SecretValue
486from yuio.util import find_docs as _find_docs
487from yuio.util import to_dash_case as _to_dash_case
489import typing
490import yuio._typing_ext as _tx
491from typing import TYPE_CHECKING
493if TYPE_CHECKING:
494 import typing_extensions as _t
495else:
496 from yuio import _typing as _t
498__all__ = [
499 "Apply",
500 "Bool",
501 "Bound",
502 "CaseFold",
503 "CollectionParser",
504 "ConfigParsingContext",
505 "Date",
506 "DateTime",
507 "Decimal",
508 "Dict",
509 "Dir",
510 "Enum",
511 "ExistingPath",
512 "File",
513 "Float",
514 "Fraction",
515 "FrozenSet",
516 "Ge",
517 "GitRepo",
518 "Gt",
519 "Int",
520 "Json",
521 "JsonValue",
522 "Le",
523 "LenBound",
524 "LenGe",
525 "LenGt",
526 "LenLe",
527 "LenLt",
528 "List",
529 "Literal",
530 "Lower",
531 "Lt",
532 "Map",
533 "MappingParser",
534 "NonExistentPath",
535 "OneOf",
536 "Optional",
537 "Parser",
538 "ParsingError",
539 "PartialParser",
540 "Path",
541 "Regex",
542 "Seconds",
543 "Secret",
544 "SecretString",
545 "SecretValue",
546 "Set",
547 "Str",
548 "StrParsingContext",
549 "Strip",
550 "Time",
551 "TimeDelta",
552 "Tuple",
553 "Union",
554 "Upper",
555 "ValidatingParser",
556 "ValueParser",
557 "WithMeta",
558 "WrappingParser",
559 "from_type_hint",
560 "register_type_hint_conversion",
561 "suggest_delim_for_type_hint_conversion",
562]
564T_co = _t.TypeVar("T_co", covariant=True)
565T = _t.TypeVar("T")
566U = _t.TypeVar("U")
567K = _t.TypeVar("K")
568V = _t.TypeVar("V")
569C = _t.TypeVar("C", bound=_t.Collection[object])
570C2 = _t.TypeVar("C2", bound=_t.Collection[object])
571Sz = _t.TypeVar("Sz", bound=_t.Sized)
572Cmp = _t.TypeVar("Cmp", bound=_tx.SupportsLt[_t.Any])
573E = _t.TypeVar("E", bound=enum.Enum)
574L = _t.TypeVar("L", bound=enum.Enum | int | str | bool | None)
575TU = _t.TypeVar("TU", bound=tuple[object, ...])
576P = _t.TypeVar("P", bound="Parser[_t.Any]")
577Params = _t.ParamSpec("Params")
580class ParsingError(yuio.PrettyException, ValueError, argparse.ArgumentTypeError):
581 """PrettyException(msg: typing.LiteralString, /, *args: typing.Any, ctx: ConfigParsingContext | StrParsingContext | None = None, fallback_msg: typing.LiteralString | None = None, **kwargs)
582 PrettyException(msg: str, /, *, ctx: ConfigParsingContext | StrParsingContext | None = None, fallback_msg: typing.LiteralString | None = None, **kwargs)
584 Raised when parsing or validation fails.
586 :param msg:
587 message to format. Can be a literal string or any other colorable object.
589 If it's given as a literal string, additional arguments for ``%``-formatting
590 may be given. Otherwise, giving additional arguments will cause
591 a :class:`TypeError`.
592 :param args:
593 arguments for ``%``-formatting the message.
594 :param fallback_msg:
595 fallback message that's guaranteed not to include representation of the faulty
596 value, will replace `msg` when parsing secret values.
598 .. warning::
600 This parameter must not include contents of the faulty value. It is typed
601 as :class:`~typing.LiteralString` as a deterrent; if you need string
602 interpolation, create an instance of :class:`ParsingError` and set
603 :attr:`~ParsingError.fallback_msg` directly.
604 :param ctx:
605 current error context that will be used to set :attr:`~ParsingError.raw`,
606 :attr:`~ParsingError.pos`, and other attributes.
607 :param kwargs:
608 other keyword arguments set :attr:`~ParsingError.raw`,
609 :attr:`~ParsingError.pos`, :attr:`~ParsingError.n_arg`,
610 :attr:`~ParsingError.path`.
612 """
614 @_t.overload
615 def __init__(
616 self,
617 msg: _t.LiteralString,
618 /,
619 *args,
620 fallback_msg: _t.LiteralString | None = None,
621 ctx: ConfigParsingContext | StrParsingContext | None = None,
622 raw: str | None = None,
623 pos: tuple[int, int] | None = None,
624 n_arg: int | None = None,
625 path: list[tuple[_t.Any, str | None]] | None = None,
626 ): ...
627 @_t.overload
628 def __init__(
629 self,
630 msg: yuio.string.ToColorable | None | yuio.Missing = yuio.MISSING,
631 /,
632 *,
633 fallback_msg: _t.LiteralString | None = None,
634 ctx: ConfigParsingContext | StrParsingContext | None = None,
635 raw: str | None = None,
636 pos: tuple[int, int] | None = None,
637 n_arg: int | None = None,
638 path: list[tuple[_t.Any, str | None]] | None = None,
639 ): ...
640 def __init__(
641 self,
642 *args,
643 fallback_msg: _t.LiteralString | None = None,
644 ctx: ConfigParsingContext | StrParsingContext | None = None,
645 raw: str | None = None,
646 pos: tuple[int, int] | None = None,
647 n_arg: int | None = None,
648 path: list[tuple[_t.Any, str | None]] | None = None,
649 ):
650 super().__init__(*args)
652 if ctx:
653 if isinstance(ctx, ConfigParsingContext):
654 path = path if path is not None else ctx.make_path()
655 else:
656 raw = raw if raw is not None else ctx.content
657 pos = pos if pos is not None else (ctx.start, ctx.end)
658 n_arg = n_arg if n_arg is not None else ctx.n_arg
660 self.fallback_msg: yuio.string.Colorable | None = fallback_msg
661 """
662 This message will be used if error occurred while parsing a secret value.
664 .. warning::
666 This colorable must not include contents of the faulty value.
668 """
670 self.raw: str | None = raw
671 """
672 For errors that happened when parsing a string, this attribute contains the
673 original string.
675 """
677 self.pos: tuple[int, int] | None = pos
678 """
679 For errors that happened when parsing a string, this attribute contains
680 position in the original string in which this error has occurred (start
681 and end indices).
683 """
685 self.n_arg: int | None = n_arg
686 """
687 For errors that happened in :meth:`~Parser.parse_many`, this attribute contains
688 index of the string in which this error has occurred.
690 """
692 self.path: list[tuple[_t.Any, str | None]] | None = path
693 """
694 For errors that happened in :meth:`~Parser.parse_config_with_ctx`, this attribute
695 contains path to the value in which this error has occurred.
697 """
699 @classmethod
700 def type_mismatch(
701 cls,
702 value: _t.Any,
703 /,
704 *expected: type | str,
705 ctx: ConfigParsingContext | StrParsingContext | None = None,
706 raw: str | None = None,
707 pos: tuple[int, int] | None = None,
708 n_arg: int | None = None,
709 path: list[tuple[_t.Any, str | None]] | None = None,
710 ):
711 """type_mismatch(value: _t.Any, /, *expected: type | str, **kwargs)
713 Make an error with a standard message "expected type X, got type Y".
715 :param value:
716 value of an unexpected type.
717 :param expected:
718 expected types. Each argument can be a type or a string that describes
719 a type.
720 :param kwargs:
721 keyword arguments will be passed to constructor.
722 :example:
723 ::
725 >>> raise ParsingError.type_mismatch(10, str)
726 Traceback (most recent call last):
727 ...
728 yuio.parse.ParsingError: Expected str, got int: 10
730 """
732 err = cls(
733 "Expected %s, got `%s`: `%r`",
734 yuio.string.Or(map(yuio.string.TypeRepr, expected)),
735 yuio.string.TypeRepr(type(value)),
736 value,
737 ctx=ctx,
738 raw=raw,
739 pos=pos,
740 n_arg=n_arg,
741 path=path,
742 )
743 err.fallback_msg = yuio.string.Format(
744 "Expected %s, got `%s`",
745 yuio.string.Or(map(yuio.string.TypeRepr, expected)),
746 yuio.string.TypeRepr(type(value)),
747 )
749 return err
751 def set_ctx(self, ctx: ConfigParsingContext | StrParsingContext):
752 if isinstance(ctx, ConfigParsingContext):
753 self.path = ctx.make_path()
754 else:
755 self.raw = ctx.content
756 self.pos = (ctx.start, ctx.end)
757 self.n_arg = ctx.n_arg
759 def to_colorable(self) -> yuio.string.Colorable:
760 colorable = super().to_colorable()
761 if self.path:
762 colorable = yuio.string.Format(
763 "In `%s`:\n%s",
764 _PathRenderer(self.path),
765 yuio.string.Indent(colorable),
766 )
767 if self.pos and self.raw and self.pos != (0, len(self.raw)):
768 raw, pos = _repr_and_adjust_pos(self.raw, self.pos)
769 colorable = yuio.string.Stack(
770 _CodeRenderer(raw, pos),
771 colorable,
772 )
773 return colorable
776class PartialParser(abc.ABC):
777 """
778 An interface of a partial parser.
780 """
782 def __init__(self):
783 self.__orig_traceback = traceback.extract_stack()
784 while self.__orig_traceback and self.__orig_traceback[-1].filename.endswith(
785 "yuio/parse.py"
786 ):
787 self.__orig_traceback.pop()
788 super().__init__()
790 def _get_orig_traceback(self) -> traceback.StackSummary:
791 """
792 Get stack summary for the place where this partial parser was created.
794 """
796 return self.__orig_traceback # pragma: no cover
798 @contextlib.contextmanager
799 def _patch_stack_summary(self):
800 """
801 Attach original traceback to any exception that's raised
802 within this context manager.
804 """
806 try:
807 yield
808 except Exception as e:
809 stack_summary_text = "Traceback (most recent call last):\n" + "".join(
810 self.__orig_traceback.format()
811 )
812 e.args = (
813 f"{e}\n\nThe above error happened because of "
814 f"this type hint:\n\n{stack_summary_text}",
815 )
816 setattr(e, "__yuio_stack_summary_text__", stack_summary_text)
817 raise e
819 @abc.abstractmethod
820 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
821 """
822 Apply this partial parser.
824 When Yuio checks type annotations, it derives a parser for the given type hint,
825 and the applies all partial parsers to it.
827 For example, given this type hint:
829 .. invisible-code-block: python
831 from typing import Annotated
833 .. code-block:: python
835 field: Annotated[str, Map(str.lower)]
837 Yuio will first infer parser for string (:class:`Str`), then it will pass
838 this parser to ``Map.wrap``.
840 :param parser:
841 a parser instance that was created by inspecting type hints
842 and previous annotations.
843 :returns:
844 a result of upgrading this parser from partial to full. This method
845 usually returns copy of `self`.
846 :raises:
847 :class:`TypeError` if this parser can't be wrapped. Specifically, this
848 method should raise a :class:`TypeError` for any non-partial parser.
850 """
852 return _copy(self) # pyright: ignore[reportReturnType]
855class Parser(PartialParser, _t.Generic[T_co]):
856 """
857 Base class for parsers.
859 """
861 # Original type hint from which this parser was derived.
862 __typehint: _t.Any = None
864 __wrapped_parser__: Parser[object] | None = None
865 """
866 An attribute for unwrapping parsers that validate or map results
867 of other parsers.
869 """
871 @_t.final
872 def parse(self, value: str, /) -> T_co:
873 """
874 Parse user input, raise :class:`ParsingError` on failure.
876 :param value:
877 value to parse.
878 :returns:
879 a parsed and processed value.
880 :raises:
881 :class:`ParsingError`.
883 """
885 return self.parse_with_ctx(StrParsingContext(value))
887 @abc.abstractmethod
888 def parse_with_ctx(self, ctx: StrParsingContext, /) -> T_co:
889 """
890 Actual implementation of :meth:`~Parser.parse`, receives parsing context instead
891 of a raw string.
893 :param ctx:
894 value to parse, wrapped into a parsing context.
895 :returns:
896 a parsed and processed value.
897 :raises:
898 :class:`ParsingError`.
900 """
902 raise NotImplementedError()
904 def parse_many(self, value: _t.Sequence[str], /) -> T_co:
905 """
906 For collection parsers, parse and validate collection
907 by parsing its items one-by-one.
909 :param value:
910 collection of values to parse.
911 :returns:
912 each value parsed and assembled into the target collection.
913 :raises:
914 :class:`ParsingError`. Also raises :class:`RuntimeError` if trying to call
915 this method on a parser that doesn't supports parsing collections
916 of objects.
917 :example:
918 ::
920 >>> # Let's say we're parsing a set of ints.
921 >>> parser = Set(Int())
923 >>> # And the user enters collection items one-by-one.
924 >>> user_input = ['1', '2', '3']
926 >>> # We can parse collection from its items:
927 >>> parser.parse_many(user_input)
928 {1, 2, 3}
930 """
932 return self.parse_many_with_ctx(
933 [StrParsingContext(item, n_arg=i) for i, item in enumerate(value)]
934 )
936 @abc.abstractmethod
937 def parse_many_with_ctx(self, ctxs: _t.Sequence[StrParsingContext], /) -> T_co:
938 """
939 Actual implementation of :meth:`~Parser.parse_many`, receives parsing contexts
940 instead of a raw strings.
942 :param ctxs:
943 values to parse, wrapped into a parsing contexts.
944 :returns:
945 a parsed and processed value.
946 :raises:
947 :class:`ParsingError`.
949 """
951 raise NotImplementedError()
953 @abc.abstractmethod
954 def supports_parse_many(self) -> bool:
955 """
956 Return :data:`True` if this parser returns a collection
957 and so supports :meth:`~Parser.parse_many`.
959 :returns:
960 :data:`True` if :meth:`~Parser.parse_many` is safe to call.
962 """
964 raise NotImplementedError()
966 @_t.final
967 def parse_config(self, value: object, /) -> T_co:
968 """
969 Parse value from a config, raise :class:`ParsingError` on failure.
971 This method accepts python values that would result from
972 parsing json, yaml, and similar formats.
974 :param value:
975 config value to parse.
976 :returns:
977 verified and processed config value.
978 :raises:
979 :class:`ParsingError`.
980 :example:
981 ::
983 >>> # Let's say we're parsing a set of ints.
984 >>> parser = Set(Int())
986 >>> # And we're loading it from json.
987 >>> import json
988 >>> user_config = json.loads('[1, 2, 3]')
990 >>> # We can process parsed json:
991 >>> parser.parse_config(user_config)
992 {1, 2, 3}
994 """
996 return self.parse_config_with_ctx(ConfigParsingContext(value))
998 @abc.abstractmethod
999 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> T_co:
1000 """
1001 Actual implementation of :meth:`~Parser.parse_config`, receives parsing context
1002 instead of a raw value.
1004 :param ctx:
1005 config value to parse, wrapped into a parsing contexts.
1006 :returns:
1007 verified and processed config value.
1008 :raises:
1009 :class:`ParsingError`.
1011 """
1013 raise NotImplementedError()
1015 @abc.abstractmethod
1016 def get_nargs(self) -> _t.Literal["+", "*"] | int:
1017 """
1018 Generate ``nargs`` for argparse.
1020 :returns:
1021 `nargs` as defined by argparse. If :meth:`~Parser.supports_parse_many`
1022 returns :data:`True`, value should be ``"+"`` or an integer. Otherwise,
1023 value should be ``1``.
1025 """
1027 raise NotImplementedError()
1029 @abc.abstractmethod
1030 def check_type(self, value: object, /) -> _t.TypeGuard[T_co]:
1031 """
1032 Check whether the parser can handle a particular value in its
1033 :meth:`~Parser.describe_value` and other methods.
1035 This function is used to raise :class:`TypeError`\\ s in function that accept
1036 unknown values. Parsers like :class:`Union` rely on :class:`TypeError`\\ s
1037 to dispatch values to correct sub-parsers.
1039 .. note::
1041 For performance reasons, this method should not inspect contents
1042 of containers, only their type (otherwise some methods turn from linear
1043 to quadratic).
1045 This also means that validating and mapping parsers
1046 can always return :data:`True`.
1048 :param value:
1049 value that needs a type check.
1050 :returns:
1051 :data:`True` if the value matches the type of this parser.
1053 """
1055 raise NotImplementedError()
1057 def assert_type(self, value: object, /) -> _t.TypeGuard[T_co]:
1058 """
1059 Call :meth:`~Parser.check_type` and raise a :class:`TypeError`
1060 if it returns :data:`False`.
1062 This method always returns :data:`True` or throws an error, but type checkers
1063 don't know this. Use ``assert parser.assert_type(value)`` so that they
1064 understand that type of the `value` has narrowed.
1066 :param value:
1067 value that needs a type check.
1068 :returns:
1069 always returns :data:`True`.
1070 :raises:
1071 :class:`TypeError`.
1073 """
1075 if not self.check_type(value):
1076 raise TypeError(
1077 f"parser {self} can't handle value of type {_tx.type_repr(type(value))}"
1078 )
1079 return True
1081 @abc.abstractmethod
1082 def describe(self) -> str | None:
1083 """
1084 Return a human-readable description of an expected input.
1086 Used to describe expected input in widgets.
1088 :returns:
1089 human-readable description of an expected input. Can return :data:`None`
1090 for simple values that don't need a special description.
1092 """
1094 raise NotImplementedError()
1096 @abc.abstractmethod
1097 def describe_or_def(self) -> str:
1098 """
1099 Like :py:meth:`~Parser.describe`, but guaranteed to return something.
1101 Used to describe expected input in CLI help.
1103 :returns:
1104 human-readable description of an expected input.
1106 """
1108 raise NotImplementedError()
1110 @abc.abstractmethod
1111 def describe_many(self) -> str | tuple[str, ...]:
1112 """
1113 Return a human-readable description of a container element.
1115 Used to describe expected input in CLI help.
1117 :returns:
1118 human-readable description of expected inputs. If the value is a string,
1119 then it describes an individual member of a collection. The the value
1120 is a tuple, then each of the tuple's element describes an expected value
1121 at the corresponding position.
1122 :raises:
1123 :class:`RuntimeError` if trying to call this method on a parser
1124 that doesn't supports parsing collections of objects.
1126 """
1128 raise NotImplementedError()
1130 @abc.abstractmethod
1131 def describe_value(self, value: object, /) -> str:
1132 """
1133 Return a human-readable description of the given value.
1135 Used in error messages, and to describe returned input in widgets.
1137 Note that, since parser's type parameter is covariant, this function is not
1138 guaranteed to receive a value of the same type that this parser produces.
1139 Call :meth:`~Parser.assert_type` to check for this case.
1141 :param value:
1142 value that needs a description.
1143 :returns:
1144 description of a value in the format that this parser would expect to see
1145 in a CLI argument or an environment variable.
1146 :raises:
1147 :class:`TypeError` if the given value is not of type
1148 that this parser produces.
1150 """
1152 raise NotImplementedError()
1154 @abc.abstractmethod
1155 def options(self) -> _t.Collection[yuio.widget.Option[T_co]] | None:
1156 """
1157 Return options for a :class:`~yuio.widget.Choice` or
1158 a :class:`~yuio.widget.Multiselect` widget.
1160 This function can be implemented for parsers that return a fixed set
1161 of pre-defined values, like :class:`Enum` or :class:`Literal`.
1162 Collection and union parsers may use this data to improve their widgets.
1163 For example, the :class:`Set` parser will use
1164 a :class:`~yuio.widget.Multiselect` widget.
1166 :returns:
1167 a full list of options that will be passed to a choice widget,
1168 or :data:`None` if the set of possible values is not known.
1170 Note that returning :data:`None` is not equivalent to returning an empty
1171 array: :data:`None` signals other parsers that they can't use choice
1172 widgets, while an empty array signals that there are simply no choices
1173 to add.
1175 """
1177 raise NotImplementedError()
1179 @abc.abstractmethod
1180 def completer(self) -> yuio.complete.Completer | None:
1181 """
1182 Return a completer for values of this parser.
1184 This function is used when assembling autocompletion functions for shells,
1185 and when reading values from user via :func:`yuio.io.ask`.
1187 :returns:
1188 a completer that will be used with CLI arguments or widgets.
1190 """
1192 raise NotImplementedError()
1194 @abc.abstractmethod
1195 def widget(
1196 self,
1197 default: object | yuio.Missing,
1198 input_description: str | None,
1199 default_description: str | None,
1200 /,
1201 ) -> yuio.widget.Widget[T_co | yuio.Missing]:
1202 """
1203 Return a widget for reading values of this parser.
1205 This function is used when reading values from user via :func:`yuio.io.ask`.
1207 The returned widget must produce values of type ``T``. If `default` is given,
1208 and the user input is empty, the widget must produce
1209 the :data:`~yuio.MISSING` constant (*not* the default constant).
1210 This is because the default value might be of any type
1211 (for example :data:`None`), and validating parsers should not check it.
1213 Validating parsers must wrap the widget they got from
1214 :attr:`__wrapped_parser__` into :class:`~yuio.widget.Map`
1215 or :class:`~yuio.widget.Apply` in order to validate widget's results.
1217 :param default:
1218 default value that will be used if widget returns :data:`~yuio.MISSING`.
1219 :param input_description:
1220 a string describing what input is expected.
1221 :param default_description:
1222 a string describing default value.
1223 :returns:
1224 a widget that will be used to ask user for values. The widget can choose
1225 to use :func:`~Parser.completer` or :func:`~Parser.options`, or implement
1226 some custom logic.
1228 """
1230 raise NotImplementedError()
1232 @abc.abstractmethod
1233 def to_json_schema(
1234 self, ctx: yuio.json_schema.JsonSchemaContext, /
1235 ) -> yuio.json_schema.JsonSchemaType:
1236 """
1237 Create a JSON schema object based on this parser.
1239 The purpose of this method is to make schemas for use in IDEs, i.e. to provide
1240 autocompletion or simple error checking. The returned schema is not guaranteed
1241 to reflect all constraints added to the parser. For example, :class:`OneOf`
1242 and :class:`Regex` parsers will not affect the generated schema.
1244 :param ctx:
1245 context for building a schema.
1246 :returns:
1247 a JSON schema that describes structure of values expected by this parser.
1249 """
1251 raise NotImplementedError()
1253 @abc.abstractmethod
1254 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
1255 """
1256 Convert given value to a representation suitable for JSON serialization.
1258 Note that, since parser's type parameter is covariant, this function is not
1259 guaranteed to receive a value of the same type that this parser produces.
1260 Call :meth:`~Parser.assert_type` to check for this case.
1262 :returns:
1263 a value converted to JSON-serializable representation.
1264 :raises:
1265 :class:`TypeError` if the given value is not of type
1266 that this parser produces.
1268 """
1270 raise NotImplementedError()
1272 @abc.abstractmethod
1273 def is_secret(self) -> bool:
1274 """
1275 Indicates that input functions should use secret input,
1276 i.e. :func:`~getpass.getpass` or :class:`yuio.widget.SecretInput`.
1278 """
1280 raise NotImplementedError()
1282 def __repr__(self):
1283 return self.__class__.__name__
1286class ValueParser(Parser[T], PartialParser, _t.Generic[T]):
1287 """
1288 Base implementation for a parser that returns a single value.
1290 Implements all method, except for :meth:`~Parser.parse_with_ctx`,
1291 :meth:`~Parser.parse_config_with_ctx`, :meth:`~Parser.to_json_schema`,
1292 and :meth:`~Parser.to_json_value`.
1294 :param ty:
1295 type of the produced value, used in :meth:`~Parser.check_type`.
1296 :example:
1297 .. invisible-code-block: python
1299 from dataclasses import dataclass
1300 @dataclass
1301 class MyType:
1302 data: str
1304 .. code-block:: python
1306 class MyTypeParser(ValueParser[MyType]):
1307 def __init__(self):
1308 super().__init__(MyType)
1310 def parse_with_ctx(self, ctx: StrParsingContext, /) -> MyType:
1311 return MyType(ctx.value)
1313 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> MyType:
1314 if not isinstance(ctx.value, str):
1315 raise ParsingError.type_mismatch(value, str, ctx=ctx)
1316 return MyType(ctx.value)
1318 def to_json_schema(
1319 self, ctx: yuio.json_schema.JsonSchemaContext, /
1320 ) -> yuio.json_schema.JsonSchemaType:
1321 return yuio.json_schema.String()
1323 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
1324 assert self.assert_type(value)
1325 return value.data
1327 ::
1329 >>> MyTypeParser().parse('pancake')
1330 MyType(data='pancake')
1332 """
1334 def __init__(self, ty: type[T], /, *args, **kwargs):
1335 super().__init__(*args, **kwargs)
1337 self._value_type = ty
1338 """
1339 Type of the produced value, used in :meth:`~Parser.check_type`.
1341 """
1343 def wrap(self: P, parser: Parser[_t.Any]) -> P:
1344 typehint = getattr(parser, "_Parser__typehint", None)
1345 if typehint is None:
1346 with self._patch_stack_summary():
1347 raise TypeError(
1348 f"annotating a type with {self} will override"
1349 " all previous annotations. Make sure that"
1350 f" {self} is the first annotation in"
1351 " your type hint.\n\n"
1352 "Example:\n"
1353 " Incorrect: Str() overrides effects of Map()\n"
1354 " field: typing.Annotated[str, Map(fn=str.lower), Str()]\n"
1355 " ^^^^^\n"
1356 " Correct: Str() is applied first, then Map()\n"
1357 " field: typing.Annotated[str, Str(), Map(fn=str.lower)]\n"
1358 " ^^^^^"
1359 )
1360 if not isinstance(self, parser.__class__):
1361 with self._patch_stack_summary():
1362 raise TypeError(
1363 f"annotating {_tx.type_repr(typehint)} with {self.__class__.__name__}"
1364 " conflicts with default parser for this type, which is"
1365 f" {parser.__class__.__name__}.\n\n"
1366 "Example:\n"
1367 " Incorrect: Path() can't be used to annotate `str`\n"
1368 " field: typing.Annotated[str, Path(extensions=[...])]\n"
1369 " ^^^^^^^^^^^^^^^^^^^^^^\n"
1370 " Correct: using Path() to annotate `pathlib.Path`\n"
1371 " field: typing.Annotated[pathlib.Path, Path(extensions=[...])]\n"
1372 " ^^^^^^^^^^^^^^^^^^^^^^"
1373 )
1374 return super().wrap(parser) # pyright: ignore[reportReturnType]
1376 def parse_many_with_ctx(self, ctxs: _t.Sequence[StrParsingContext], /) -> T:
1377 raise RuntimeError("unable to parse multiple values")
1379 def supports_parse_many(self) -> bool:
1380 return False
1382 def get_nargs(self) -> _t.Literal["+", "*"] | int:
1383 return 1
1385 def check_type(self, value: object, /) -> _t.TypeGuard[T]:
1386 return isinstance(value, self._value_type)
1388 def describe(self) -> str | None:
1389 return None
1391 def describe_or_def(self) -> str:
1392 return self.describe() or f"<{_to_dash_case(self.__class__.__name__)}>"
1394 def describe_many(self) -> str | tuple[str, ...]:
1395 return self.describe_or_def()
1397 def describe_value(self, value: object, /) -> str:
1398 assert self.assert_type(value)
1399 return str(value) or "<empty>"
1401 def options(self) -> _t.Collection[yuio.widget.Option[T]] | None:
1402 return None
1404 def completer(self) -> yuio.complete.Completer | None:
1405 return None
1407 def widget(
1408 self,
1409 default: object | yuio.Missing,
1410 input_description: str | None,
1411 default_description: str | None,
1412 /,
1413 ) -> yuio.widget.Widget[T | yuio.Missing]:
1414 completer = self.completer()
1415 return _WidgetResultMapper(
1416 self,
1417 input_description,
1418 default,
1419 (
1420 yuio.widget.InputWithCompletion(
1421 completer,
1422 placeholder=default_description or "",
1423 )
1424 if completer is not None
1425 else yuio.widget.Input(
1426 placeholder=default_description or "",
1427 )
1428 ),
1429 )
1431 def is_secret(self) -> bool:
1432 return False
1435class WrappingParser(Parser[T], _t.Generic[T, U]):
1436 """
1437 A base for a parser that wraps another parser and alters its output.
1439 This base simplifies dealing with partial parsers.
1441 The :attr:`~WrappingParser._inner` attribute is whatever internal state you need
1442 to store. When it is :data:`None`, the parser is considered partial. That is,
1443 you can't use such a parser to actually parse anything, but you can
1444 use it in a type annotation. When it is not :data:`None`, the parser is considered
1445 non partial. You can use it to parse things, but you can't use it
1446 in a type annotation.
1448 .. warning::
1450 All descendants of this class must include appropriate type hints
1451 for their ``__new__`` method, otherwise type annotations from this base
1452 will shadow implementation's ``__init__`` signature.
1454 See section on `parser hierarchy`_ for details.
1456 :param inner:
1457 inner data or :data:`None`.
1459 """
1461 if TYPE_CHECKING:
1463 @_t.overload
1464 def __new__(cls, inner: U, /) -> WrappingParser[T, U]: ...
1466 @_t.overload
1467 def __new__(cls, /) -> PartialParser: ...
1469 def __new__(cls, *args, **kwargs) -> _t.Any: ...
1471 def __init__(self, inner: U | None, /, *args, **kwargs):
1472 self.__inner = inner
1473 super().__init__(*args, **kwargs)
1475 @property
1476 def _inner(self) -> U:
1477 """
1478 Internal resource wrapped by this parser.
1480 :raises:
1481 Accessing it when the parser is in a partial state triggers an error
1482 and warns user that they didn't provide an inner parser.
1484 Setting a new value when the parser is not in a partial state triggers
1485 an error and warns user that they shouldn't provide an inner parser
1486 in type annotations.
1488 """
1490 if self.__inner is None:
1491 with self._patch_stack_summary():
1492 raise TypeError(f"{self.__class__.__name__} requires an inner parser")
1493 return self.__inner
1495 @_inner.setter
1496 def _inner(self, inner: U):
1497 if self.__inner is not None:
1498 with self._patch_stack_summary():
1499 raise TypeError(
1500 f"don't provide inner parser when using {self.__class__.__name__}"
1501 " with type annotations. The inner parser will be derived automatically"
1502 "from type hint.\n\n"
1503 "Example:\n"
1504 " Incorrect: List() has an inner parser\n"
1505 " field: typing.Annotated[list[str], List(Str(), delimiter=';')]\n"
1506 " ^^^^^^^^^^^^^^^^^^^^^^^^^^\n"
1507 " Correct: inner parser for List() derived from type hint\n"
1508 " field: typing.Annotated[list[str], List(delimiter=';')]\n"
1509 " ^^^^^^^^^^^^^^^^^^^"
1510 )
1511 self.__inner = inner
1513 @property
1514 def _inner_raw(self) -> U | None:
1515 """
1516 Unchecked access to the wrapped resource.
1518 """
1520 return self.__inner
1523class MappingParser(WrappingParser[T, Parser[U]], _t.Generic[T, U]):
1524 """
1525 This is base abstraction for :class:`Map` and :class:`Optional`.
1526 Forwards all calls to the inner parser, except for :meth:`~Parser.parse_with_ctx`,
1527 :meth:`~Parser.parse_many_with_ctx`, :meth:`~Parser.parse_config_with_ctx`,
1528 :meth:`~Parser.options`, :meth:`~Parser.check_type`,
1529 :meth:`~Parser.describe_value`, :meth:`~Parser.widget`,
1530 and :meth:`~Parser.to_json_value`.
1532 :param inner:
1533 mapped parser or :data:`None`.
1535 """
1537 if TYPE_CHECKING:
1539 @_t.overload
1540 def __new__(cls, inner: Parser[U], /) -> MappingParser[T, U]: ...
1542 @_t.overload
1543 def __new__(cls, /) -> PartialParser: ...
1545 def __new__(cls, *args, **kwargs) -> _t.Any: ...
1547 def __init__(self, inner: Parser[U] | None, /):
1548 super().__init__(inner)
1550 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
1551 result = super().wrap(parser)
1552 result._inner = parser # pyright: ignore[reportAttributeAccessIssue]
1553 return result
1555 def supports_parse_many(self) -> bool:
1556 return self._inner.supports_parse_many()
1558 def get_nargs(self) -> _t.Literal["+", "*"] | int:
1559 return self._inner.get_nargs()
1561 def describe(self) -> str | None:
1562 return self._inner.describe()
1564 def describe_or_def(self) -> str:
1565 return self._inner.describe_or_def()
1567 def describe_many(self) -> str | tuple[str, ...]:
1568 return self._inner.describe_many()
1570 def completer(self) -> yuio.complete.Completer | None:
1571 return self._inner.completer()
1573 def to_json_schema(
1574 self, ctx: yuio.json_schema.JsonSchemaContext, /
1575 ) -> yuio.json_schema.JsonSchemaType:
1576 return self._inner.to_json_schema(ctx)
1578 def is_secret(self) -> bool:
1579 return self._inner.is_secret()
1581 def __repr__(self):
1582 return f"{self.__class__.__name__}({self._inner_raw!r})"
1584 @property
1585 def __wrapped_parser__(self): # pyright: ignore[reportIncompatibleVariableOverride]
1586 return self._inner_raw
1589class Map(MappingParser[T, U], _t.Generic[T, U]):
1590 """Map(inner: Parser[U], fn: typing.Callable[[U], T], rev: typing.Callable[[T | object], U] | None = None, /)
1592 A wrapper that maps result of the given parser using the given function.
1594 :param inner:
1595 a parser whose result will be mapped.
1596 :param fn:
1597 a function to convert a result.
1598 :param rev:
1599 a function used to un-map a value.
1601 This function is used in :meth:`Parser.describe_value`
1602 and :meth:`Parser.to_json_value` to convert parsed value back
1603 to its original state.
1605 Note that, since parser's type parameter is covariant, this function is not
1606 guaranteed to receive a value of the same type that this parser produces.
1607 In this case, you should raise a :class:`TypeError`.
1608 :example:
1609 ..
1610 >>> import math
1612 ::
1614 >>> parser = yuio.parse.Map(
1615 ... yuio.parse.Int(),
1616 ... lambda x: 2 ** x,
1617 ... lambda x: int(math.log2(x)),
1618 ... )
1619 >>> parser.parse("10")
1620 1024
1621 >>> parser.describe_value(1024)
1622 '10'
1624 """
1626 if TYPE_CHECKING:
1628 @_t.overload
1629 def __new__(cls, inner: Parser[T], fn: _t.Callable[[T], T], /) -> Map[T, T]: ...
1631 @_t.overload
1632 def __new__(cls, fn: _t.Callable[[T], T], /) -> PartialParser: ...
1634 @_t.overload
1635 def __new__(
1636 cls,
1637 inner: Parser[U],
1638 fn: _t.Callable[[U], T],
1639 rev: _t.Callable[[T | object], U],
1640 /,
1641 ) -> Map[T, T]: ...
1643 @_t.overload
1644 def __new__(
1645 cls, fn: _t.Callable[[U], T], rev: _t.Callable[[T | object], U], /
1646 ) -> PartialParser: ...
1648 def __new__(cls, *args, **kwargs) -> _t.Any: ...
1650 def __init__(self, *args):
1651 inner: Parser[U] | None = None
1652 fn: _t.Callable[[U], T]
1653 rev: _t.Callable[[T | object], U] | None = None
1654 if len(args) == 1:
1655 (fn,) = args
1656 elif len(args) == 2 and isinstance(args[0], Parser):
1657 inner, fn = args
1658 elif len(args) == 2:
1659 fn, rev = args
1660 elif len(args) == 3:
1661 inner, fn, rev = args
1662 else:
1663 raise TypeError(
1664 f"expected between 1 and 2 positional arguments, got {len(args)}"
1665 )
1667 self._fn = fn
1668 self._rev = rev
1669 super().__init__(inner)
1671 def parse_with_ctx(self, ctx: StrParsingContext, /) -> T:
1672 res = self._inner.parse_with_ctx(ctx)
1673 try:
1674 return self._fn(res)
1675 except ParsingError as e:
1676 e.set_ctx(ctx)
1677 raise
1679 def parse_many_with_ctx(self, ctxs: _t.Sequence[StrParsingContext], /) -> T:
1680 return self._fn(self._inner.parse_many_with_ctx(ctxs))
1682 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> T:
1683 res = self._inner.parse_config_with_ctx(ctx)
1684 try:
1685 return self._fn(res)
1686 except ParsingError as e:
1687 e.set_ctx(ctx)
1688 raise
1690 def check_type(self, value: object, /) -> _t.TypeGuard[T]:
1691 return True
1693 def describe_value(self, value: object, /) -> str:
1694 if self._rev:
1695 value = self._rev(value)
1696 return self._inner.describe_value(value)
1698 def options(self) -> _t.Collection[yuio.widget.Option[T]] | None:
1699 options = self._inner.options()
1700 if options is not None:
1701 return [
1702 _t.cast(
1703 yuio.widget.Option[T],
1704 dataclasses.replace(option, value=self._fn(option.value)),
1705 )
1706 for option in options
1707 ]
1708 else:
1709 return None
1711 def widget(
1712 self,
1713 default: object | yuio.Missing,
1714 input_description: str | None,
1715 default_description: str | None,
1716 /,
1717 ) -> yuio.widget.Widget[T | yuio.Missing]:
1718 return yuio.widget.Map(
1719 self._inner.widget(default, input_description, default_description),
1720 lambda v: self._fn(v) if v is not yuio.MISSING else yuio.MISSING,
1721 )
1723 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
1724 if self._rev:
1725 value = self._rev(value)
1726 return self._inner.to_json_value(value)
1729@_t.overload
1730def Lower(inner: Parser[str], /) -> Parser[str]: ...
1731@_t.overload
1732def Lower() -> PartialParser: ...
1733def Lower(*args) -> _t.Any:
1734 """Lower(inner: Parser[str], /)
1736 Applies :meth:`str.lower` to the result of a string parser.
1738 :param inner:
1739 a parser whose result will be mapped.
1741 """
1743 return Map(*args, str.lower) # pyright: ignore[reportCallIssue]
1746@_t.overload
1747def Upper(inner: Parser[str], /) -> Parser[str]: ...
1748@_t.overload
1749def Upper() -> PartialParser: ...
1750def Upper(*args) -> _t.Any:
1751 """Upper(inner: Parser[str], /)
1753 Applies :meth:`str.upper` to the result of a string parser.
1755 :param inner:
1756 a parser whose result will be mapped.
1758 """
1760 return Map(*args, str.upper) # pyright: ignore[reportCallIssue]
1763@_t.overload
1764def CaseFold(inner: Parser[str], /) -> Parser[str]: ...
1765@_t.overload
1766def CaseFold() -> PartialParser: ...
1767def CaseFold(*args) -> _t.Any:
1768 """CaseFold(inner: Parser[str], /)
1770 Applies :meth:`str.casefold` to the result of a string parser.
1772 :param inner:
1773 a parser whose result will be mapped.
1775 """
1777 return Map(*args, str.casefold) # pyright: ignore[reportCallIssue]
1780@_t.overload
1781def Strip(inner: Parser[str], /) -> Parser[str]: ...
1782@_t.overload
1783def Strip() -> PartialParser: ...
1784def Strip(*args) -> _t.Any:
1785 """Strip(inner: Parser[str], /)
1787 Applies :meth:`str.strip` to the result of a string parser.
1789 :param inner:
1790 a parser whose result will be mapped.
1792 """
1794 return Map(*args, str.strip) # pyright: ignore[reportCallIssue]
1797@_t.overload
1798def Regex(
1799 inner: Parser[str],
1800 regex: str | _tx.StrRePattern,
1801 /,
1802 *,
1803 group: int | str = 0,
1804) -> Parser[str]: ...
1805@_t.overload
1806def Regex(
1807 regex: str | _tx.StrRePattern, /, *, group: int | str = 0
1808) -> PartialParser: ...
1809def Regex(*args, group: int | str = 0) -> _t.Any:
1810 """Regex(inner: Parser[str], regex: str | re.Pattern[str], /, *, group: int | str = 0)
1812 Matches the parsed string with the given regular expression.
1814 If regex has capturing groups, parser can return contents of a group.
1816 :param regex:
1817 regular expression for matching.
1818 :param group:
1819 name or index of a capturing group that should be used to get the final
1820 parsed value.
1822 """
1824 inner: Parser[str] | None
1825 regex: str | _tx.StrRePattern
1826 if len(args) == 1:
1827 inner, regex = None, args[0]
1828 elif len(args) == 2:
1829 inner, regex = args
1830 else:
1831 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
1833 if isinstance(regex, re.Pattern):
1834 compiled = regex
1835 else:
1836 compiled = re.compile(regex)
1838 def mapper(value: str) -> str:
1839 if (match := compiled.match(value)) is None:
1840 raise ParsingError(
1841 "Value doesn't match regex `%s`: `%r`",
1842 compiled.pattern,
1843 value,
1844 fallback_msg="Incorrect value format",
1845 )
1846 return match.group(group)
1848 return Map(inner, mapper) # type: ignore
1851class Apply(MappingParser[T, T], _t.Generic[T]):
1852 """Apply(inner: Parser[T], fn: typing.Callable[[T], None], /)
1854 A wrapper that applies the given function to the result of a wrapped parser.
1856 :param inner:
1857 a parser used to extract and validate a value.
1858 :param fn:
1859 a function that will be called after parsing a value.
1860 :example:
1861 ::
1863 >>> # Run `Int` parser, then print its output before returning.
1864 >>> print_output = Apply(Int(), lambda x: print(f"Value is {x}"))
1865 >>> result = print_output.parse("10")
1866 Value is 10
1867 >>> result
1868 10
1870 """
1872 if TYPE_CHECKING:
1874 @_t.overload
1875 def __new__(
1876 cls, inner: Parser[T], fn: _t.Callable[[T], None], /
1877 ) -> Apply[T]: ...
1879 @_t.overload
1880 def __new__(cls, fn: _t.Callable[[T], None], /) -> PartialParser: ...
1882 def __new__(cls, *args, **kwargs) -> _t.Any: ...
1884 def __init__(self, *args):
1885 inner: Parser[T] | None
1886 fn: _t.Callable[[T], None]
1887 if len(args) == 1:
1888 inner, fn = None, args[0]
1889 elif len(args) == 2:
1890 inner, fn = args
1891 else:
1892 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
1894 self._fn = fn
1895 super().__init__(inner)
1897 def parse_with_ctx(self, ctx: StrParsingContext, /) -> T:
1898 result = self._inner.parse_with_ctx(ctx)
1899 try:
1900 self._fn(result)
1901 except ParsingError as e:
1902 e.set_ctx(ctx)
1903 raise
1904 return result
1906 def parse_many_with_ctx(self, ctxs: _t.Sequence[StrParsingContext], /) -> T:
1907 result = self._inner.parse_many_with_ctx(ctxs)
1908 self._fn(result)
1909 return result
1911 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> T:
1912 result = self._inner.parse_config_with_ctx(ctx)
1913 try:
1914 self._fn(result)
1915 except ParsingError as e:
1916 e.set_ctx(ctx)
1917 raise
1918 return result
1920 def check_type(self, value: object, /) -> _t.TypeGuard[T]:
1921 return True
1923 def describe_value(self, value: object, /) -> str:
1924 return self._inner.describe_value(value)
1926 def options(self) -> _t.Collection[yuio.widget.Option[T]] | None:
1927 return self._inner.options()
1929 def completer(self) -> yuio.complete.Completer | None:
1930 return self._inner.completer()
1932 def widget(
1933 self,
1934 default: object | yuio.Missing,
1935 input_description: str | None,
1936 default_description: str | None,
1937 /,
1938 ) -> yuio.widget.Widget[T | yuio.Missing]:
1939 return yuio.widget.Apply(
1940 self._inner.widget(default, input_description, default_description),
1941 lambda v: self._fn(v) if v is not yuio.MISSING else None,
1942 )
1944 def to_json_schema(
1945 self, ctx: yuio.json_schema.JsonSchemaContext, /
1946 ) -> yuio.json_schema.JsonSchemaType:
1947 return self._inner.to_json_schema(ctx)
1949 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
1950 return self._inner.to_json_value(value)
1953class ValidatingParser(Apply[T], _t.Generic[T]):
1954 """
1955 Base implementation for a parser that validates result of another parser.
1957 This class wraps another parser and passes all method calls to it.
1958 All parsed values are additionally passed to :meth:`~ValidatingParser._validate`.
1960 :param inner:
1961 a parser which output will be validated.
1962 :example:
1963 .. code-block:: python
1965 class IsLower(ValidatingParser[str]):
1966 def _validate(self, value: str, /):
1967 if not value.islower():
1968 raise ParsingError(
1969 "Value should be lowercase: `%r`",
1970 value,
1971 fallback_msg="Value should be lowercase",
1972 )
1974 ::
1976 >>> IsLower(Str()).parse("Not lowercase!")
1977 Traceback (most recent call last):
1978 ...
1979 yuio.parse.ParsingError: Value should be lowercase: 'Not lowercase!'
1981 """
1983 if TYPE_CHECKING:
1985 @_t.overload
1986 def __new__(cls, inner: Parser[T], /) -> ValidatingParser[T]: ...
1988 @_t.overload
1989 def __new__(cls, /) -> PartialParser: ...
1991 def __new__(cls, *args, **kwargs) -> _t.Any: ...
1993 def __init__(self, inner: Parser[T] | None = None, /):
1994 super().__init__(inner, self._validate)
1996 @abc.abstractmethod
1997 def _validate(self, value: T, /):
1998 """
1999 Implementation of value validation.
2001 :param value:
2002 value which needs validating.
2003 :raises:
2004 should raise :class:`ParsingError` if validation fails.
2006 """
2008 raise NotImplementedError()
2011class Str(ValueParser[str]):
2012 """
2013 Parser for str values.
2015 """
2017 def __init__(self):
2018 super().__init__(str)
2020 def parse_with_ctx(self, ctx: StrParsingContext, /) -> str:
2021 return str(ctx.value)
2023 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> str:
2024 if not isinstance(ctx.value, str):
2025 raise ParsingError.type_mismatch(ctx.value, str, ctx=ctx)
2026 return str(ctx.value)
2028 def to_json_schema(
2029 self, ctx: yuio.json_schema.JsonSchemaContext, /
2030 ) -> yuio.json_schema.JsonSchemaType:
2031 return yuio.json_schema.String()
2033 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2034 assert self.assert_type(value)
2035 return value
2038class Int(ValueParser[int]):
2039 """
2040 Parser for int values.
2042 """
2044 def __init__(self):
2045 super().__init__(int)
2047 def parse_with_ctx(self, ctx: StrParsingContext, /) -> int:
2048 ctx = ctx.strip_if_non_space()
2049 try:
2050 value = ctx.value.casefold()
2051 if value.startswith("-"):
2052 neg = True
2053 value = value[1:].lstrip()
2054 else:
2055 neg = False
2056 if value.startswith("0x"):
2057 base = 16
2058 value = value[2:]
2059 elif value.startswith("0o"):
2060 base = 8
2061 value = value[2:]
2062 elif value.startswith("0b"):
2063 base = 2
2064 value = value[2:]
2065 else:
2066 base = 10
2067 if value[:1] in "-\n\t\r\v\b ":
2068 raise ValueError()
2069 res = int(value, base=base)
2070 if neg:
2071 res = -res
2072 return res
2073 except ValueError:
2074 raise ParsingError(
2075 "Can't parse `%r` as `int`",
2076 ctx.value,
2077 ctx=ctx,
2078 fallback_msg="Can't parse value as `int`",
2079 ) from None
2081 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> int:
2082 value = ctx.value
2083 if isinstance(value, float):
2084 if value != int(value): # pyright: ignore[reportUnnecessaryComparison]
2085 raise ParsingError.type_mismatch(value, int, ctx=ctx)
2086 value = int(value)
2087 if not isinstance(value, int):
2088 raise ParsingError.type_mismatch(value, int, ctx=ctx)
2089 return value
2091 def to_json_schema(
2092 self, ctx: yuio.json_schema.JsonSchemaContext, /
2093 ) -> yuio.json_schema.JsonSchemaType:
2094 return yuio.json_schema.Integer()
2096 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2097 assert self.assert_type(value)
2098 return value
2101class Float(ValueParser[float]):
2102 """
2103 Parser for float values.
2105 """
2107 def __init__(self):
2108 super().__init__(float)
2110 def parse_with_ctx(self, ctx: StrParsingContext, /) -> float:
2111 ctx = ctx.strip_if_non_space()
2112 try:
2113 return float(ctx.value)
2114 except ValueError:
2115 raise ParsingError(
2116 "Can't parse `%r` as `float`",
2117 ctx.value,
2118 ctx=ctx,
2119 fallback_msg="Can't parse value as `float`",
2120 ) from None
2122 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> float:
2123 value = ctx.value
2124 if not isinstance(value, (float, int)):
2125 raise ParsingError.type_mismatch(value, float, ctx=ctx)
2126 return value
2128 def to_json_schema(
2129 self, ctx: yuio.json_schema.JsonSchemaContext, /
2130 ) -> yuio.json_schema.JsonSchemaType:
2131 return yuio.json_schema.Number()
2133 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2134 assert self.assert_type(value)
2135 return value
2138class Bool(ValueParser[bool]):
2139 """
2140 Parser for bool values, such as ``"yes"`` or ``"no"``.
2142 """
2144 def __init__(self):
2145 super().__init__(bool)
2147 def parse_with_ctx(self, ctx: StrParsingContext, /) -> bool:
2148 ctx = ctx.strip_if_non_space()
2149 value = ctx.value.casefold()
2150 if value in ("y", "yes", "true", "1"):
2151 return True
2152 elif value in ("n", "no", "false", "0"):
2153 return False
2154 else:
2155 raise ParsingError(
2156 "Can't parse `%r` as `bool`, should be `yes`, `no`, `true`, or `false`",
2157 value,
2158 ctx=ctx,
2159 fallback_msg="Can't parse value as `bool`",
2160 )
2162 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> bool:
2163 value = ctx.value
2164 if not isinstance(value, bool):
2165 raise ParsingError.type_mismatch(value, bool, ctx=ctx)
2166 return value
2168 def describe(self) -> str | None:
2169 return "{yes|no}"
2171 def describe_value(self, value: object, /) -> str:
2172 assert self.assert_type(value)
2173 return "yes" if value else "no"
2175 def options(self) -> _t.Collection[yuio.widget.Option[bool]] | None:
2176 return [
2177 yuio.widget.Option(True, display_text="yes"),
2178 yuio.widget.Option(False, display_text="no"),
2179 ]
2181 def completer(self) -> yuio.complete.Completer | None:
2182 return yuio.complete.Choice(
2183 [
2184 yuio.complete.Option("true"),
2185 yuio.complete.Option("false"),
2186 ]
2187 )
2189 def widget(
2190 self,
2191 default: object | yuio.Missing,
2192 input_description: str | None,
2193 default_description: str | None,
2194 /,
2195 ) -> yuio.widget.Widget[bool | yuio.Missing]:
2196 options: list[yuio.widget.Option[bool | yuio.Missing]] = [
2197 yuio.widget.Option(False, "no"),
2198 yuio.widget.Option(True, "yes"),
2199 ]
2201 if default is yuio.MISSING:
2202 default_index = 0
2203 elif isinstance(default, bool):
2204 default_index = int(default)
2205 else:
2206 options.append(
2207 yuio.widget.Option(yuio.MISSING, default_description or str(default))
2208 )
2209 default_index = 2
2211 return yuio.widget.Choice(options, default_index=default_index)
2213 def to_json_schema(
2214 self, ctx: yuio.json_schema.JsonSchemaContext, /
2215 ) -> yuio.json_schema.JsonSchemaType:
2216 return yuio.json_schema.Boolean()
2218 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2219 assert self.assert_type(value)
2220 return value
2223class _EnumBase(WrappingParser[T, U], ValueParser[T], _t.Generic[T, U]):
2224 def __init__(self, inner: U | None = None, ty: type[T] | None = None, /):
2225 super().__init__(inner, ty)
2227 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
2228 result = super().wrap(parser)
2229 result._inner = parser._inner # type: ignore
2230 result._value_type = parser._value_type # type: ignore
2231 return result
2233 @abc.abstractmethod
2234 def _get_items(self) -> _t.Iterable[T]:
2235 raise NotImplementedError()
2237 @abc.abstractmethod
2238 def _value_to_str(self, value: T) -> str:
2239 raise NotImplementedError()
2241 @abc.abstractmethod
2242 def _str_value_matches(self, value: T, given: str) -> bool:
2243 raise NotImplementedError()
2245 @abc.abstractmethod
2246 def _str_value_matches_prefix(self, value: T, given: str) -> bool:
2247 raise NotImplementedError()
2249 @abc.abstractmethod
2250 def _config_value_matches(self, value: T, given: object) -> bool:
2251 raise NotImplementedError()
2253 @abc.abstractmethod
2254 def _value_to_json(self, value: T) -> JsonValue:
2255 raise NotImplementedError()
2257 def _get_docs(self) -> dict[T, str | None]:
2258 return {}
2260 def _get_desc(self) -> str:
2261 return repr(self)
2263 def parse_with_ctx(self, ctx: StrParsingContext, /) -> T:
2264 ctx = ctx.strip_if_non_space()
2266 candidates: list[T] = []
2267 for item in self._get_items():
2268 if self._str_value_matches(item, ctx.value):
2269 return item
2270 elif self._str_value_matches_prefix(item, ctx.value):
2271 candidates.append(item)
2273 if len(candidates) == 1:
2274 return candidates[0]
2275 elif len(candidates) > 1:
2276 enum_values = tuple(self._value_to_str(e) for e in candidates)
2277 raise ParsingError(
2278 "Can't parse `%r` as `%s`, possible candidates are %s",
2279 ctx.value,
2280 self._get_desc(),
2281 yuio.string.Or(enum_values),
2282 ctx=ctx,
2283 )
2284 else:
2285 enum_values = tuple(self._value_to_str(e) for e in self._get_items())
2286 raise ParsingError(
2287 "Can't parse `%r` as `%s`, should be %s",
2288 ctx.value,
2289 self._get_desc(),
2290 yuio.string.Or(enum_values),
2291 ctx=ctx,
2292 )
2294 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> T:
2295 value = ctx.value
2297 for item in self._get_items():
2298 if self._config_value_matches(item, value):
2299 return item
2301 enum_values = tuple(self._value_to_str(e) for e in self._get_items())
2302 raise ParsingError(
2303 "Can't parse `%r` as `%s`, should be %s",
2304 value,
2305 self._get_desc(),
2306 yuio.string.Or(enum_values),
2307 ctx=ctx,
2308 )
2310 def describe(self) -> str | None:
2311 enum_values = tuple(self._value_to_str(e) for e in self._get_items())
2312 desc = "|".join(enum_values)
2313 if len(enum_values) > 1:
2314 desc = f"{{{desc}}}"
2315 return desc
2317 def describe_many(self) -> str | tuple[str, ...]:
2318 return self.describe_or_def()
2320 def describe_value(self, value: object, /) -> str:
2321 assert self.assert_type(value)
2322 return self._value_to_str(value)
2324 def options(self) -> _t.Collection[yuio.widget.Option[T]]:
2325 docs = self._get_docs()
2326 options = []
2327 for value in self._get_items():
2328 comment = docs.get(value)
2329 if comment:
2330 lines = comment.splitlines()
2331 if not lines:
2332 comment = None
2333 elif len(lines) == 1:
2334 comment = str(lines[0])
2335 else:
2336 comment = str(lines[0]) + ("..." if lines[1] else "")
2337 options.append(
2338 yuio.widget.Option(
2339 value, display_text=self._value_to_str(value), comment=comment
2340 )
2341 )
2342 return options
2344 def completer(self) -> yuio.complete.Completer | None:
2345 return yuio.complete.Choice(
2346 [
2347 yuio.complete.Option(option.display_text, comment=option.comment)
2348 for option in self.options()
2349 ]
2350 )
2352 def widget(
2353 self,
2354 default: object | yuio.Missing,
2355 input_description: str | None,
2356 default_description: str | None,
2357 /,
2358 ) -> yuio.widget.Widget[T | yuio.Missing]:
2359 options: list[yuio.widget.Option[T | yuio.Missing]] = list(self.options())
2361 if not options:
2362 return super().widget(default, input_description, default_description)
2364 items = list(self._get_items())
2366 if default is yuio.MISSING:
2367 default_index = 0
2368 elif default in items:
2369 default_index = items.index(default) # type: ignore
2370 else:
2371 options.insert(
2372 0, yuio.widget.Option(yuio.MISSING, default_description or str(default))
2373 )
2374 default_index = 0
2376 return yuio.widget.Choice(options, default_index=default_index)
2378 def to_json_schema(
2379 self, ctx: yuio.json_schema.JsonSchemaContext, /
2380 ) -> yuio.json_schema.JsonSchemaType:
2381 items = [self._value_to_json((e)) for e in self._get_items()]
2382 docs = self._get_docs()
2384 descriptions = [docs.get(e) for e in self._get_items()]
2385 if not any(descriptions):
2386 descriptions = None
2388 return yuio.json_schema.Enum(items, descriptions) # type: ignore
2390 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2391 assert self.assert_type(value)
2392 return self._value_to_json(value)
2394 def __repr__(self):
2395 if self._inner_raw is not None:
2396 return f"{self.__class__.__name__}({self._inner_raw!r})"
2397 else:
2398 return self.__class__.__name__
2401class Enum(_EnumBase[E, type[E]], _t.Generic[E]):
2402 """Enum(enum_type: typing.Type[E], /, *, by_name: bool | None = None, to_dash_case: bool | None = None, doc_inline: bool = False)
2404 Parser for enums, as defined in the standard :mod:`enum` module.
2406 :param enum_type:
2407 enum class that will be used to parse and extract values.
2408 :param by_name:
2409 if :data:`True`, the parser will use enumerator names, instead of
2410 their values, to match the input.
2412 If not given, Yuio will search for :data:`__yuio_by_name__` attribute on the
2413 given enum class to infer value for this option.
2414 :param to_dash_case:
2415 convert enum names/values to dash case.
2417 If not given, Yuio will search for :data:`__yuio_to_dash_case__` attribute on the
2418 given enum class to infer value for this option.
2419 :param doc_inline:
2420 inline this enum in json schema and in documentation.
2422 Useful for small enums that don't warrant a separate section in documentation.
2424 If not given, Yuio will search for :data:`__yuio_doc_inline__` attribute on the
2425 given enum class to infer value for this option.
2427 """
2429 if TYPE_CHECKING:
2431 @_t.overload
2432 def __new__(
2433 cls,
2434 inner: type[E],
2435 /,
2436 *,
2437 by_name: bool | None = None,
2438 to_dash_case: bool | None = None,
2439 doc_inline: bool | None = None,
2440 ) -> Enum[E]: ...
2442 @_t.overload
2443 def __new__(
2444 cls,
2445 /,
2446 *,
2447 by_name: bool | None = None,
2448 to_dash_case: bool | None = None,
2449 doc_inline: bool | None = None,
2450 ) -> PartialParser: ...
2452 def __new__(cls, *args, **kwargs) -> _t.Any: ...
2454 def __init__(
2455 self,
2456 enum_type: type[E] | None = None,
2457 /,
2458 *,
2459 by_name: bool | None = None,
2460 to_dash_case: bool | None = None,
2461 doc_inline: bool | None = None,
2462 ):
2463 self.__by_name = by_name
2464 self.__to_dash_case = to_dash_case
2465 self.__doc_inline = doc_inline
2466 self.__docs = None
2467 super().__init__(enum_type, enum_type)
2469 @functools.cached_property
2470 def _by_name(self) -> bool:
2471 by_name = self.__by_name
2472 if by_name is None:
2473 by_name = getattr(self._inner, "__yuio_by_name__", False)
2474 return by_name
2476 @functools.cached_property
2477 def _to_dash_case(self) -> bool:
2478 to_dash_case = self.__to_dash_case
2479 if to_dash_case is None:
2480 to_dash_case = getattr(self._inner, "__yuio_to_dash_case__", False)
2481 return to_dash_case
2483 @functools.cached_property
2484 def _doc_inline(self) -> bool:
2485 doc_inline = self.__doc_inline
2486 if doc_inline is None:
2487 doc_inline = getattr(self._inner, "__yuio_doc_inline__", False)
2488 return doc_inline
2490 @functools.cached_property
2491 def _map_cache(self):
2492 items: dict[E, str] = {}
2493 for e in self._inner:
2494 if self._by_name:
2495 name = e.name
2496 else:
2497 name = str(e.value)
2498 if self._to_dash_case and isinstance(name, str):
2499 name = _to_dash_case(name)
2500 items[e] = name
2501 return items
2503 def _get_items(self) -> _t.Iterable[E]:
2504 return self._inner
2506 def _value_to_str(self, value: E) -> str:
2507 return self._map_cache[value]
2509 def _str_value_matches(self, value: E, given: str) -> bool:
2510 expected = self._map_cache[value]
2512 if isinstance(expected, str):
2513 return expected == given
2514 elif isinstance(expected, bool):
2515 try:
2516 given_parsed = Bool().parse(given)
2517 except ParsingError:
2518 return False
2519 else:
2520 return expected == given_parsed
2521 elif isinstance(expected, int):
2522 try:
2523 given_parsed = Int().parse(given)
2524 except ParsingError:
2525 return False
2526 else:
2527 return expected == given_parsed
2528 else:
2529 return False
2531 def _str_value_matches_prefix(self, value: E, given: str) -> bool:
2532 expected = self._map_cache[value]
2533 return isinstance(expected, str) and expected.casefold().startswith(
2534 given.casefold()
2535 )
2537 def _config_value_matches(self, value: E, given: object) -> bool:
2538 if given is value:
2539 return True
2541 if self._by_name:
2542 expected = self._map_cache[value]
2543 else:
2544 expected = value.value
2546 return expected == given
2548 def _value_to_json(self, value: E) -> JsonValue:
2549 if self._by_name:
2550 res = value.name
2551 else:
2552 res = value.value
2553 if self._to_dash_case and isinstance(res, str):
2554 res = _to_dash_case(res)
2555 return res
2557 def _get_docs(self) -> dict[E, str | None]:
2558 if self.__docs is not None:
2559 return self.__docs
2560 docs = _find_docs(self._inner)
2561 res = {}
2562 for e in self._inner:
2563 text = docs.get(e.name)
2564 if not text:
2565 continue
2566 if (index := text.find("\n\n")) != -1:
2567 res[e] = text[:index]
2568 else:
2569 res[e] = text
2570 return res
2572 def _get_desc(self) -> str:
2573 return self._inner.__name__
2575 def to_json_schema(
2576 self, ctx: yuio.json_schema.JsonSchemaContext, /
2577 ) -> yuio.json_schema.JsonSchemaType:
2578 schema = super().to_json_schema(ctx)
2580 if self._doc_inline:
2581 return schema
2582 else:
2583 return ctx.add_type(
2584 Enum._TyWrapper(self._inner, self._by_name, self._to_dash_case),
2585 _tx.type_repr(self._inner),
2586 lambda: yuio.json_schema.Meta(
2587 schema,
2588 title=self._inner.__name__,
2589 description=self._inner.__doc__,
2590 ),
2591 )
2593 def __repr__(self):
2594 if self._inner_raw is not None:
2595 return f"{self.__class__.__name__}({self._inner_raw!r})"
2596 else:
2597 return self.__class__.__name__
2599 @dataclasses.dataclass(unsafe_hash=True, match_args=False, slots=True)
2600 class _TyWrapper:
2601 inner: type
2602 by_name: bool
2603 to_dash_case: bool
2606class _LiteralType:
2607 def __init__(self, allowed_values: tuple[L, ...]) -> None:
2608 self._allowed_values = allowed_values
2610 def __instancecheck__(self, instance: _t.Any) -> bool:
2611 return instance in self._allowed_values
2614class Literal(_EnumBase[L, tuple[L, ...]], _t.Generic[L]):
2615 """
2616 Parser for literal values.
2618 This parser accepts a set of allowed values, and parses them using semantics of
2619 :class:`Enum` parser. It can be used with creating an enum for some value isn't
2620 practical, and semantics of :class:`OneOf` is limiting.
2622 Allowed values should be strings, ints, bools, or instances of :class:`enum.Enum`.
2624 If instances of :class:`enum.Enum` are passed, :class:`Literal` will rely on
2625 enum's :data:`__yuio_by_name__` and :data:`__yuio_to_dash_case__` attributes
2626 to parse these values.
2628 """
2630 if TYPE_CHECKING:
2632 def __new__(cls, *args: L) -> Literal[L]: ...
2634 def __init__(
2635 self,
2636 *literal_values: L,
2637 ):
2638 self._converted_values = {}
2640 for value in literal_values:
2641 orig_value = value
2643 if isinstance(value, enum.Enum):
2644 if getattr(type(value), "__yuio_by_name__", False):
2645 value = value.name
2646 else:
2647 value = value.value
2648 if getattr(type(value), "__yuio_to_dash_case__", False) and isinstance(
2649 value, str
2650 ):
2651 value = _to_dash_case(value)
2652 self._converted_values[orig_value] = value
2654 if not isinstance(value, (int, str, bool)):
2655 raise TypeError(
2656 f"literal parser doesn't support literals "
2657 f"of type {_t.type_repr(type(value))}: {orig_value!r}"
2658 )
2659 super().__init__(
2660 literal_values,
2661 _LiteralType(literal_values), # type: ignore
2662 )
2664 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
2665 with self._patch_stack_summary():
2666 raise TypeError(f"annotating a type with {self} is not supported")
2668 def _get_items(self) -> _t.Iterable[L]:
2669 return self._inner
2671 def _value_to_str(self, value: L) -> str:
2672 return str(self._converted_values.get(value, value))
2674 def _str_value_matches(self, value: L, given: str) -> bool:
2675 value = self._converted_values.get(value, value)
2676 if isinstance(value, str):
2677 return value == given
2678 elif isinstance(value, bool):
2679 try:
2680 given_parsed = Bool().parse(given)
2681 except ParsingError:
2682 return False
2683 else:
2684 return value == given_parsed
2685 elif isinstance(value, int):
2686 try:
2687 given_parsed = Int().parse(given)
2688 except ParsingError:
2689 return False
2690 else:
2691 return value == given_parsed
2692 else:
2693 return False
2695 def _str_value_matches_prefix(self, value: L, given: str) -> bool:
2696 value = self._converted_values.get(value, value)
2697 return isinstance(value, str) and value.casefold().startswith(given.casefold())
2699 def _config_value_matches(self, value: L, given: object) -> bool:
2700 value = self._converted_values.get(value, value)
2701 return value == given
2703 def _value_to_json(self, value: L) -> JsonValue:
2704 return value # type: ignore
2706 def __repr__(self):
2707 if self._inner_raw is not None:
2708 values = map(self._value_to_str, self._inner_raw)
2709 return f"{self.__class__.__name__}({yuio.string.JoinRepr(values)})"
2710 else:
2711 return self.__class__.__name__
2714class Decimal(ValueParser[decimal.Decimal]):
2715 """
2716 Parser for :class:`decimal.Decimal`.
2718 """
2720 def __init__(self):
2721 super().__init__(decimal.Decimal)
2723 def parse_with_ctx(self, ctx: StrParsingContext, /) -> decimal.Decimal:
2724 ctx = ctx.strip_if_non_space()
2725 try:
2726 return decimal.Decimal(ctx.value)
2727 except (ArithmeticError, ValueError, TypeError):
2728 raise ParsingError(
2729 "Can't parse `%r` as `decimal`",
2730 ctx.value,
2731 ctx=ctx,
2732 fallback_msg="Can't parse value as `decimal`",
2733 ) from None
2735 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> decimal.Decimal:
2736 value = ctx.value
2737 if not isinstance(value, (int, float, str, decimal.Decimal)):
2738 raise ParsingError.type_mismatch(value, int, float, str, ctx=ctx)
2739 try:
2740 return decimal.Decimal(value)
2741 except (ArithmeticError, ValueError, TypeError):
2742 raise ParsingError(
2743 "Can't parse `%r` as `decimal`",
2744 value,
2745 ctx=ctx,
2746 fallback_msg="Can't parse value as `decimal`",
2747 ) from None
2749 def to_json_schema(
2750 self, ctx: yuio.json_schema.JsonSchemaContext, /
2751 ) -> yuio.json_schema.JsonSchemaType:
2752 return ctx.add_type(
2753 decimal.Decimal,
2754 "Decimal",
2755 lambda: yuio.json_schema.Meta(
2756 yuio.json_schema.OneOf(
2757 [
2758 yuio.json_schema.Number(),
2759 yuio.json_schema.String(
2760 pattern=r"(?i)^[+-]?((\d+\.\d*|\.?\d+)(e[+-]?\d+)?|inf(inity)?|(nan|snan)\d*)$"
2761 ),
2762 ]
2763 ),
2764 title="Decimal",
2765 description="Decimal fixed-point and floating-point number.",
2766 ),
2767 )
2769 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2770 assert self.assert_type(value)
2771 return str(value)
2774class Fraction(ValueParser[fractions.Fraction]):
2775 """
2776 Parser for :class:`fractions.Fraction`.
2778 """
2780 def __init__(self):
2781 super().__init__(fractions.Fraction)
2783 def parse_with_ctx(self, ctx: StrParsingContext, /) -> fractions.Fraction:
2784 ctx = ctx.strip_if_non_space()
2785 try:
2786 return fractions.Fraction(ctx.value)
2787 except ValueError:
2788 raise ParsingError(
2789 "Can't parse `%r` as `fraction`",
2790 ctx.value,
2791 ctx=ctx,
2792 fallback_msg="Can't parse value as `fraction`",
2793 ) from None
2794 except ZeroDivisionError:
2795 raise ParsingError(
2796 "Can't parse `%r` as `fraction`, division by zero",
2797 ctx.value,
2798 ctx=ctx,
2799 fallback_msg="Can't parse value as `fraction`",
2800 ) from None
2802 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> fractions.Fraction:
2803 value = ctx.value
2804 if (
2805 isinstance(value, (list, tuple))
2806 and len(value) == 2
2807 and all(isinstance(v, (float, int)) for v in value)
2808 ):
2809 try:
2810 return fractions.Fraction(*value)
2811 except (ValueError, TypeError):
2812 raise ParsingError(
2813 "Can't parse `%s/%s` as `fraction`",
2814 value[0],
2815 value[1],
2816 ctx=ctx,
2817 fallback_msg="Can't parse value as `fraction`",
2818 ) from None
2819 except ZeroDivisionError:
2820 raise ParsingError(
2821 "Can't parse `%s/%s` as `fraction`, division by zero",
2822 value[0],
2823 value[1],
2824 ctx=ctx,
2825 fallback_msg="Can't parse value as `fraction`",
2826 ) from None
2827 if isinstance(value, (int, float, str, decimal.Decimal, fractions.Fraction)):
2828 try:
2829 return fractions.Fraction(value)
2830 except (ValueError, TypeError):
2831 raise ParsingError(
2832 "Can't parse `%r` as `fraction`",
2833 value,
2834 ctx=ctx,
2835 fallback_msg="Can't parse value as `fraction`",
2836 ) from None
2837 except ZeroDivisionError:
2838 raise ParsingError(
2839 "Can't parse `%r` as `fraction`, division by zero",
2840 value,
2841 ctx=ctx,
2842 fallback_msg="Can't parse value as `fraction`",
2843 ) from None
2844 raise ParsingError.type_mismatch(
2845 value, int, float, str, "a tuple of two ints", ctx=ctx
2846 )
2848 def to_json_schema(
2849 self, ctx: yuio.json_schema.JsonSchemaContext, /
2850 ) -> yuio.json_schema.JsonSchemaType:
2851 return ctx.add_type(
2852 fractions.Fraction,
2853 "Fraction",
2854 lambda: yuio.json_schema.Meta(
2855 yuio.json_schema.OneOf(
2856 [
2857 yuio.json_schema.Number(),
2858 yuio.json_schema.String(
2859 pattern=r"(?i)^[+-]?(\d+(\/\d+)?|(\d+\.\d*|\.?\d+)(e[+-]?\d+)?|inf(inity)?|nan)$"
2860 ),
2861 yuio.json_schema.Tuple(
2862 [yuio.json_schema.Number(), yuio.json_schema.Number()]
2863 ),
2864 ]
2865 ),
2866 title="Fraction",
2867 description="A rational number.",
2868 ),
2869 )
2871 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2872 assert self.assert_type(value)
2873 return str(value)
2876class Json(WrappingParser[T, Parser[T]], ValueParser[T], _t.Generic[T]):
2877 """Json(inner: Parser[T] | None = None, /)
2879 A parser that tries to parse value as JSON.
2881 This parser will load JSON strings into python objects.
2882 If `inner` parser is given, :class:`Json` will validate parsing results
2883 by calling :meth:`~Parser.parse_config_with_ctx` on the inner parser.
2885 :param inner:
2886 a parser used to convert and validate contents of json.
2888 """
2890 if TYPE_CHECKING:
2892 @_t.overload
2893 def __new__(cls, inner: Parser[T], /) -> Json[T]: ...
2895 @_t.overload
2896 def __new__(cls, /) -> Json[yuio.json_schema.JsonValue]: ...
2898 def __new__(cls, inner: Parser[T] | None = None, /) -> Json[_t.Any]: ...
2900 def __init__(
2901 self,
2902 inner: Parser[T] | None = None,
2903 /,
2904 ):
2905 super().__init__(inner, object)
2907 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
2908 result = _copy(self)
2909 result._inner = parser
2910 return result
2912 def parse_with_ctx(self, ctx: StrParsingContext, /) -> T:
2913 ctx = ctx.strip_if_non_space()
2914 try:
2915 config_value: JsonValue = json.loads(ctx.value)
2916 except json.JSONDecodeError as e:
2917 raise ParsingError(
2918 "Can't parse `%r` as `JsonValue`:\n%s",
2919 ctx.value,
2920 yuio.string.Indent(e),
2921 ctx=ctx,
2922 fallback_msg="Can't parse value as `JsonValue`",
2923 ) from None
2924 try:
2925 return self.parse_config_with_ctx(ConfigParsingContext(config_value))
2926 except ParsingError as e:
2927 raise ParsingError(
2928 "Error in parsed json value:\n%s",
2929 yuio.string.Indent(e),
2930 ctx=ctx,
2931 fallback_msg="Error in parsed json value",
2932 ) from None
2934 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> T:
2935 if self._inner_raw is not None:
2936 return self._inner_raw.parse_config_with_ctx(ctx)
2937 else:
2938 return _t.cast(T, ctx.value)
2940 def check_type(self, value: object, /) -> _t.TypeGuard[T]:
2941 return True
2943 def to_json_schema(
2944 self, ctx: yuio.json_schema.JsonSchemaContext, /
2945 ) -> yuio.json_schema.JsonSchemaType:
2946 if self._inner_raw is not None:
2947 return self._inner_raw.to_json_schema(ctx)
2948 else:
2949 return yuio.json_schema.Any()
2951 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2952 assert self.assert_type(value)
2953 if self._inner_raw is not None:
2954 return self._inner_raw.to_json_value(value)
2955 return value # type: ignore
2957 def __repr__(self):
2958 if self._inner_raw is not None:
2959 return f"{self.__class__.__name__}({self._inner_raw!r})"
2960 else:
2961 return super().__repr__()
2964class DateTime(ValueParser[datetime.datetime]):
2965 """
2966 Parse a datetime in ISO ('YYYY-MM-DD HH:MM:SS') format.
2968 """
2970 def __init__(self):
2971 super().__init__(datetime.datetime)
2973 def parse_with_ctx(self, ctx: StrParsingContext, /) -> datetime.datetime:
2974 ctx = ctx.strip_if_non_space()
2975 return self._parse(ctx.value, ctx)
2977 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> datetime.datetime:
2978 value = ctx.value
2979 if isinstance(value, datetime.datetime):
2980 return value
2981 elif isinstance(value, str):
2982 return self._parse(value, ctx)
2983 else:
2984 raise ParsingError.type_mismatch(value, str, ctx=ctx)
2986 @staticmethod
2987 def _parse(value: str, ctx: ConfigParsingContext | StrParsingContext):
2988 try:
2989 return datetime.datetime.fromisoformat(value)
2990 except ValueError:
2991 raise ParsingError(
2992 "Can't parse `%r` as `datetime`",
2993 value,
2994 ctx=ctx,
2995 fallback_msg="Can't parse value as `datetime`",
2996 ) from None
2998 def describe(self) -> str | None:
2999 return "YYYY-MM-DD[ HH:MM:SS]"
3001 def to_json_schema(
3002 self, ctx: yuio.json_schema.JsonSchemaContext, /
3003 ) -> yuio.json_schema.JsonSchemaType:
3004 return ctx.add_type(
3005 datetime.datetime,
3006 "DateTime",
3007 lambda: yuio.json_schema.Meta(
3008 yuio.json_schema.String(
3009 pattern=(
3010 r"^"
3011 r"("
3012 r"\d{4}-W\d{2}(-\d)?"
3013 r"|\d{4}-\d{2}-\d{2}"
3014 r"|\d{4}W\d{2}\d?"
3015 r"|\d{4}\d{2}\d{2}"
3016 r")"
3017 r"("
3018 r"[T ]"
3019 r"\d{2}(:\d{2}(:\d{2}(.\d{3}(\d{3})?)?)?)?"
3020 r"([+-]\d{2}(:\d{2}(:\d{2}(.\d{3}(\d{3})?)?)?)?|Z)?"
3021 r")?"
3022 r"$"
3023 )
3024 ),
3025 title="DateTime",
3026 description="ISO 8601 datetime.",
3027 ),
3028 )
3030 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3031 assert self.assert_type(value)
3032 return str(value)
3035class Date(ValueParser[datetime.date]):
3036 """
3037 Parse a date in ISO ('YYYY-MM-DD') format.
3039 """
3041 def __init__(self):
3042 super().__init__(datetime.date)
3044 def parse_with_ctx(self, ctx: StrParsingContext, /) -> datetime.date:
3045 ctx = ctx.strip_if_non_space()
3046 return self._parse(ctx.value, ctx)
3048 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> datetime.date:
3049 value = ctx.value
3050 if isinstance(value, datetime.datetime):
3051 return value.date()
3052 elif isinstance(value, datetime.date):
3053 return value
3054 elif isinstance(value, str):
3055 return self._parse(value, ctx)
3056 else:
3057 raise ParsingError.type_mismatch(value, str, ctx=ctx)
3059 @staticmethod
3060 def _parse(value: str, ctx: ConfigParsingContext | StrParsingContext):
3061 try:
3062 return datetime.date.fromisoformat(value)
3063 except ValueError:
3064 raise ParsingError(
3065 "Can't parse `%r` as `date`",
3066 value,
3067 ctx=ctx,
3068 fallback_msg="Can't parse value as `date`",
3069 ) from None
3071 def describe(self) -> str | None:
3072 return "YYYY-MM-DD"
3074 def to_json_schema(
3075 self, ctx: yuio.json_schema.JsonSchemaContext, /
3076 ) -> yuio.json_schema.JsonSchemaType:
3077 return ctx.add_type(
3078 datetime.date,
3079 "Date",
3080 lambda: yuio.json_schema.Meta(
3081 yuio.json_schema.String(
3082 pattern=(
3083 r"^"
3084 r"("
3085 r"\d{4}-W\d{2}(-\d)?"
3086 r"|\d{4}-\d{2}-\d{2}"
3087 r"|\d{4}W\d{2}\d?"
3088 r"|\d{4}\d{2}\d{2}"
3089 r")"
3090 r"$"
3091 )
3092 ),
3093 title="Date",
3094 description="ISO 8601 date.",
3095 ),
3096 )
3098 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3099 assert self.assert_type(value)
3100 return str(value)
3103class Time(ValueParser[datetime.time]):
3104 """
3105 Parse a time in ISO ('HH:MM:SS') format.
3107 """
3109 def __init__(self):
3110 super().__init__(datetime.time)
3112 def parse_with_ctx(self, ctx: StrParsingContext, /) -> datetime.time:
3113 ctx = ctx.strip_if_non_space()
3114 return self._parse(ctx.value, ctx)
3116 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> datetime.time:
3117 value = ctx.value
3118 if isinstance(value, datetime.datetime):
3119 return value.time()
3120 elif isinstance(value, datetime.time):
3121 return value
3122 elif isinstance(value, str):
3123 return self._parse(value, ctx)
3124 else:
3125 raise ParsingError.type_mismatch(value, str, ctx=ctx)
3127 @staticmethod
3128 def _parse(value: str, ctx: ConfigParsingContext | StrParsingContext):
3129 try:
3130 return datetime.time.fromisoformat(value)
3131 except ValueError:
3132 raise ParsingError(
3133 "Can't parse `%r` as `time`",
3134 value,
3135 ctx=ctx,
3136 fallback_msg="Can't parse value as `time`",
3137 ) from None
3139 def describe(self) -> str | None:
3140 return "HH:MM:SS"
3142 def to_json_schema(
3143 self, ctx: yuio.json_schema.JsonSchemaContext, /
3144 ) -> yuio.json_schema.JsonSchemaType:
3145 return ctx.add_type(
3146 datetime.time,
3147 "Time",
3148 lambda: yuio.json_schema.Meta(
3149 yuio.json_schema.String(
3150 pattern=(
3151 r"^"
3152 r"\d{2}(:\d{2}(:\d{2}(.\d{3}(\d{3})?)?)?)?"
3153 r"([+-]\d{2}(:\d{2}(:\d{2}(.\d{3}(\d{3})?)?)?)?|Z)?"
3154 r"$"
3155 )
3156 ),
3157 title="Time",
3158 description="ISO 8601 time.",
3159 ),
3160 )
3162 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3163 assert self.assert_type(value)
3164 return str(value)
3167_UNITS_MAP = (
3168 ("days", ("d", "day", "days")),
3169 ("seconds", ("s", "sec", "secs", "second", "seconds")),
3170 ("microseconds", ("us", "u", "micro", "micros", "microsecond", "microseconds")),
3171 ("milliseconds", ("ms", "l", "milli", "millis", "millisecond", "milliseconds")),
3172 ("minutes", ("m", "min", "mins", "minute", "minutes")),
3173 ("hours", ("h", "hr", "hrs", "hour", "hours")),
3174 ("weeks", ("w", "week", "weeks")),
3175)
3177_UNITS = {unit: name for name, units in _UNITS_MAP for unit in units}
3179_TIMEDELTA_RE = re.compile(
3180 r"""
3181 # General format: -1 day, -01:00:00.000000
3182 ^
3183 (?:([+-]?)\s*((?:\d+\s*[a-z]+\s*)+))?
3184 (?:,\s*)?
3185 (?:([+-]?)\s*(\d+):(\d?\d)(?::(\d?\d)(?:\.(?:(\d\d\d)(\d\d\d)?))?)?)?
3186 $
3187 """,
3188 re.VERBOSE | re.IGNORECASE,
3189)
3191_COMPONENT_RE = re.compile(r"(\d+)\s*([a-z]+)\s*")
3194class TimeDelta(ValueParser[datetime.timedelta]):
3195 """
3196 Parse a time delta.
3198 """
3200 def __init__(self):
3201 super().__init__(datetime.timedelta)
3203 def parse_with_ctx(self, ctx: StrParsingContext, /) -> datetime.timedelta:
3204 ctx = ctx.strip_if_non_space()
3205 return self._parse(ctx.value, ctx)
3207 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> datetime.timedelta:
3208 value = ctx.value
3209 if isinstance(value, datetime.timedelta):
3210 return value
3211 elif isinstance(value, str):
3212 return self._parse(value, ctx)
3213 else:
3214 raise ParsingError.type_mismatch(value, str, ctx=ctx)
3216 @staticmethod
3217 def _parse(value: str, ctx: ConfigParsingContext | StrParsingContext):
3218 value = value.strip()
3220 if not value:
3221 raise ParsingError("Got an empty `timedelta`", ctx=ctx)
3222 if value.endswith(","):
3223 raise ParsingError(
3224 "Can't parse `%r` as `timedelta`, trailing comma is not allowed",
3225 value,
3226 ctx=ctx,
3227 fallback_msg="Can't parse value as `timedelta`",
3228 )
3229 if value.startswith(","):
3230 raise ParsingError(
3231 "Can't parse `%r` as `timedelta`, leading comma is not allowed",
3232 value,
3233 ctx=ctx,
3234 fallback_msg="Can't parse value as `timedelta`",
3235 )
3237 if match := _TIMEDELTA_RE.match(value):
3238 (
3239 c_sign_s,
3240 components_s,
3241 t_sign_s,
3242 hour,
3243 minute,
3244 second,
3245 millisecond,
3246 microsecond,
3247 ) = match.groups()
3248 else:
3249 raise ParsingError(
3250 "Can't parse `%r` as `timedelta`",
3251 value,
3252 ctx=ctx,
3253 fallback_msg="Can't parse value as `timedelta`",
3254 )
3256 c_sign_s = -1 if c_sign_s == "-" else 1
3257 t_sign_s = -1 if t_sign_s == "-" else 1
3259 kwargs = {u: 0 for u, _ in _UNITS_MAP}
3261 if components_s:
3262 for num, unit in _COMPONENT_RE.findall(components_s):
3263 if unit_key := _UNITS.get(unit.lower()):
3264 kwargs[unit_key] += int(num)
3265 else:
3266 raise ParsingError(
3267 "Can't parse `%r` as `timedelta`, unknown unit `%r`",
3268 value,
3269 unit,
3270 ctx=ctx,
3271 fallback_msg="Can't parse value as `timedelta`",
3272 )
3274 timedelta = c_sign_s * datetime.timedelta(**kwargs)
3276 timedelta += t_sign_s * datetime.timedelta(
3277 hours=int(hour or "0"),
3278 minutes=int(minute or "0"),
3279 seconds=int(second or "0"),
3280 milliseconds=int(millisecond or "0"),
3281 microseconds=int(microsecond or "0"),
3282 )
3284 return timedelta
3286 def describe(self) -> str | None:
3287 return "[+|-]HH:MM:SS"
3289 def to_json_schema(
3290 self, ctx: yuio.json_schema.JsonSchemaContext, /
3291 ) -> yuio.json_schema.JsonSchemaType:
3292 return ctx.add_type(
3293 datetime.timedelta,
3294 "TimeDelta",
3295 lambda: yuio.json_schema.Meta(
3296 yuio.json_schema.String(
3297 # save yourself some trouble, paste this into https://regexper.com/
3298 pattern=(
3299 r"^(([+-]?\s*(\d+\s*(d|day|days|s|sec|secs|second|seconds"
3300 r"|us|u|micro|micros|microsecond|microseconds|ms|l|milli|"
3301 r"millis|millisecond|milliseconds|m|min|mins|minute|minutes"
3302 r"|h|hr|hrs|hour|hours|w|week|weeks)\s*)+)(,\s*)?"
3303 r"([+-]?\s*\d+:\d?\d(:\d?\d(\.\d\d\d(\d\d\d)?)?)?)"
3304 r"|([+-]?\s*\d+:\d?\d(:\d?\d(\.\d\d\d(\d\d\d)?)?)?)"
3305 r"|([+-]?\s*(\d+\s*(d|day|days|s|sec|secs|second|seconds"
3306 r"|us|u|micro|micros|microsecond|microseconds|ms|l|milli"
3307 r"|millis|millisecond|milliseconds|m|min|mins|minute|minutes"
3308 r"|h|hr|hrs|hour|hours|w|week|weeks)\s*)+))$"
3309 )
3310 ),
3311 title="Time delta. General format: '[+-] [M weeks] [N days] [+-]HH:MM:SS'",
3312 description=".",
3313 ),
3314 )
3316 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3317 assert self.assert_type(value)
3318 return str(value)
3321class Seconds(TimeDelta):
3322 """
3323 Parse a float and convert it to a time delta as a number of seconds.
3325 """
3327 @staticmethod
3328 def _parse(value: str, ctx: ConfigParsingContext | StrParsingContext):
3329 try:
3330 seconds = float(value)
3331 except ValueError:
3332 raise ParsingError(
3333 "Can't parse `%r` as `<seconds>`",
3334 ctx.value,
3335 ctx=ctx,
3336 fallback_msg="Can't parse value as `<seconds>`",
3337 ) from None
3338 return datetime.timedelta(seconds=seconds)
3340 def describe(self) -> str | None:
3341 return "<seconds>"
3343 def describe_or_def(self) -> str:
3344 return "<seconds>"
3346 def describe_many(self) -> str | tuple[str, ...]:
3347 return "<seconds>"
3349 def describe_value(self, value: object) -> str:
3350 assert self.assert_type(value)
3351 return str(value.total_seconds())
3353 def to_json_schema(
3354 self, ctx: yuio.json_schema.JsonSchemaContext, /
3355 ) -> yuio.json_schema.JsonSchemaType:
3356 return yuio.json_schema.Meta(yuio.json_schema.Number(), description="seconds")
3358 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3359 assert self.assert_type(value)
3360 return value.total_seconds()
3363class Path(ValueParser[pathlib.Path]):
3364 """
3365 Parse a file system path, return a :class:`pathlib.Path`.
3367 :param extensions:
3368 list of allowed file extensions, including preceding dots.
3370 """
3372 def __init__(
3373 self,
3374 /,
3375 *,
3376 extensions: str | _t.Collection[str] | None = None,
3377 ):
3378 self._extensions = [extensions] if isinstance(extensions, str) else extensions
3379 super().__init__(pathlib.Path)
3381 def parse_with_ctx(self, ctx: StrParsingContext, /) -> pathlib.Path:
3382 ctx = ctx.strip_if_non_space()
3383 return self._parse(ctx.value, ctx)
3385 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> pathlib.Path:
3386 value = ctx.value
3387 if not isinstance(value, str):
3388 raise ParsingError.type_mismatch(value, str, ctx=ctx)
3389 return self._parse(value, ctx)
3391 def _parse(self, value: str, ctx: ConfigParsingContext | StrParsingContext):
3392 res = pathlib.Path(value).expanduser().resolve().absolute()
3393 try:
3394 self._validate(res)
3395 except ParsingError as e:
3396 e.set_ctx(ctx)
3397 raise
3398 return res
3400 def describe(self) -> str | None:
3401 if self._extensions is not None:
3402 desc = "|".join(f"<*{e}>" for e in self._extensions)
3403 if len(self._extensions) > 1:
3404 desc = f"{{{desc}}}"
3405 return desc
3406 else:
3407 return super().describe()
3409 def _validate(self, value: pathlib.Path, /):
3410 if self._extensions is not None and not any(
3411 value.name.endswith(ext) for ext in self._extensions
3412 ):
3413 raise ParsingError(
3414 "<c path>%s</c> should have extension %s",
3415 value,
3416 yuio.string.Or(self._extensions),
3417 )
3419 def completer(self) -> yuio.complete.Completer | None:
3420 return yuio.complete.File(extensions=self._extensions)
3422 def to_json_schema(
3423 self, ctx: yuio.json_schema.JsonSchemaContext, /
3424 ) -> yuio.json_schema.JsonSchemaType:
3425 return yuio.json_schema.String()
3427 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3428 assert self.assert_type(value)
3429 return str(value)
3432class NonExistentPath(Path):
3433 """
3434 Parse a file system path and verify that it doesn't exist.
3436 :param extensions:
3437 list of allowed file extensions, including preceding dots.
3439 """
3441 def _validate(self, value: pathlib.Path, /):
3442 super()._validate(value)
3444 if value.exists():
3445 raise ParsingError("<c path>%s</c> already exists", value)
3448class ExistingPath(Path):
3449 """
3450 Parse a file system path and verify that it exists.
3452 :param extensions:
3453 list of allowed file extensions, including preceding dots.
3455 """
3457 def _validate(self, value: pathlib.Path, /):
3458 super()._validate(value)
3460 if not value.exists():
3461 raise ParsingError("<c path>%s</c> doesn't exist", value)
3464class File(ExistingPath):
3465 """
3466 Parse a file system path and verify that it points to a regular file.
3468 :param extensions:
3469 list of allowed file extensions, including preceding dots.
3471 """
3473 def _validate(self, value: pathlib.Path, /):
3474 super()._validate(value)
3476 if not value.is_file():
3477 raise ParsingError("<c path>%s</c> is not a file", value)
3480class Dir(ExistingPath):
3481 """
3482 Parse a file system path and verify that it points to a directory.
3484 """
3486 def __init__(self):
3487 # Disallow passing `extensions`.
3488 super().__init__()
3490 def _validate(self, value: pathlib.Path, /):
3491 super()._validate(value)
3493 if not value.is_dir():
3494 raise ParsingError("<c path>%s</c> is not a directory", value)
3496 def completer(self) -> yuio.complete.Completer | None:
3497 return yuio.complete.Dir()
3500class GitRepo(Dir):
3501 """
3502 Parse a file system path and verify that it points to a git repository.
3504 This parser just checks that the given directory has
3505 a subdirectory named ``.git``.
3507 """
3509 def _validate(self, value: pathlib.Path, /):
3510 super()._validate(value)
3512 if not value.joinpath(".git").is_dir():
3513 raise ParsingError("<c path>%s</c> is not a git repository root", value)
3516class Secret(Map[SecretValue[T], T], _t.Generic[T]):
3517 """Secret(inner: Parser[U], /)
3519 Wraps result of the inner parser into :class:`~yuio.secret.SecretValue`
3520 and ensures that :func:`yuio.io.ask` doesn't show value as user enters it.
3522 """
3524 if TYPE_CHECKING:
3526 @_t.overload
3527 def __new__(cls, inner: Parser[T], /) -> Secret[T]: ...
3529 @_t.overload
3530 def __new__(cls, /) -> PartialParser: ...
3532 def __new__(cls, *args, **kwargs) -> _t.Any: ...
3534 def __init__(self, inner: Parser[U] | None = None, /):
3535 super().__init__(inner, SecretValue, lambda x: x.data)
3537 def parse_with_ctx(self, ctx: StrParsingContext, /) -> SecretValue[T]:
3538 with self._replace_error():
3539 return super().parse_with_ctx(ctx)
3541 def parse_many_with_ctx(
3542 self, ctxs: _t.Sequence[StrParsingContext], /
3543 ) -> SecretValue[T]:
3544 with self._replace_error():
3545 return super().parse_many_with_ctx(ctxs)
3547 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> SecretValue[T]:
3548 with self._replace_error():
3549 return super().parse_config_with_ctx(ctx)
3551 @staticmethod
3552 @contextlib.contextmanager
3553 def _replace_error():
3554 try:
3555 yield
3556 except ParsingError as e:
3557 # Error messages can contain secret value, hide them.
3558 raise ParsingError(
3559 yuio.string.Printable(
3560 e.fallback_msg or "Error when parsing secret data"
3561 ),
3562 pos=e.pos,
3563 path=e.path,
3564 n_arg=e.n_arg,
3565 # Omit raw value.
3566 ) from None
3568 def describe_value(self, value: object, /) -> str:
3569 return "***"
3571 def completer(self) -> yuio.complete.Completer | None:
3572 return None
3574 def options(self) -> _t.Collection[yuio.widget.Option[SecretValue[T]]] | None:
3575 return None
3577 def widget(
3578 self,
3579 default: object | yuio.Missing,
3580 input_description: str | None,
3581 default_description: str | None,
3582 /,
3583 ) -> yuio.widget.Widget[SecretValue[T] | yuio.Missing]:
3584 return _secret_widget(self, default, input_description, default_description)
3586 def is_secret(self) -> bool:
3587 return True
3590class CollectionParser(
3591 WrappingParser[C, Parser[T]], ValueParser[C], PartialParser, _t.Generic[C, T]
3592):
3593 """CollectionParser(inner: Parser[T] | None, /, **kwargs)
3595 A base class for implementing collection parsing. It will split a string
3596 by the given delimiter, parse each item using a subparser, and then pass
3597 the result to the given constructor.
3599 :param inner:
3600 parser that will be used to parse collection items.
3601 :param ty:
3602 type of the collection that this parser returns.
3603 :param ctor:
3604 factory of instances of the collection that this parser returns.
3605 It should take an iterable of parsed items, and return a collection.
3606 :param iter:
3607 a function that is used to get an iterator from a collection.
3608 This defaults to :func:`iter`, but sometimes it may be different.
3609 For example, :class:`Dict` is implemented as a collection of pairs,
3610 and its `iter` is :meth:`dict.items`.
3611 :param config_type:
3612 type of a collection that we expect to find when parsing a config.
3613 This will usually be a list.
3614 :param config_type_iter:
3615 a function that is used to get an iterator from a config value.
3616 :param delimiter:
3617 delimiter that will be passed to :py:meth:`str.split`.
3619 The above parameters are exposed via protected attributes:
3620 ``self._inner``, ``self._ty``, etc.
3622 For example, let's implement a :class:`list` parser
3623 that repeats each element twice:
3625 .. code-block:: python
3627 from typing import Iterable, Generic
3630 class DoubleList(CollectionParser[list[T], T], Generic[T]):
3631 def __init__(self, inner: Parser[T], /, *, delimiter: str | None = None):
3632 super().__init__(inner, ty=list, ctor=self._ctor, delimiter=delimiter)
3634 @staticmethod
3635 def _ctor(values: Iterable[T]) -> list[T]:
3636 return [x for value in values for x in [value, value]]
3638 def to_json_schema(
3639 self, ctx: yuio.json_schema.JsonSchemaContext, /
3640 ) -> yuio.json_schema.JsonSchemaType:
3641 return {"type": "array", "items": self._inner.to_json_schema(ctx)}
3643 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3644 assert self.assert_type(value)
3645 return [self._inner.to_json_value(item) for item in value[::2]]
3647 ::
3649 >>> parser = DoubleList(Int())
3650 >>> parser.parse("1 2 3")
3651 [1, 1, 2, 2, 3, 3]
3652 >>> parser.to_json_value([1, 1, 2, 2, 3, 3])
3653 [1, 2, 3]
3655 """
3657 _allow_completing_duplicates: typing.ClassVar[bool] = True
3658 """
3659 If set to :data:`False`, autocompletion will not suggest item duplicates.
3661 """
3663 def __init__(
3664 self,
3665 inner: Parser[T] | None,
3666 /,
3667 *,
3668 ty: type[C],
3669 ctor: _t.Callable[[_t.Iterable[T]], C],
3670 iter: _t.Callable[[C], _t.Iterable[T]] = iter,
3671 config_type: type[C2] | tuple[type[C2], ...] = list,
3672 config_type_iter: _t.Callable[[C2], _t.Iterable[T]] = iter,
3673 delimiter: str | None = None,
3674 ):
3675 if delimiter == "":
3676 raise ValueError("empty delimiter")
3678 #: See class parameters for more details.
3679 self._ty = ty
3680 self._ctor = ctor
3681 self._iter = iter
3682 self._config_type = config_type
3683 self._config_type_iter = config_type_iter
3684 self._delimiter = delimiter
3686 super().__init__(inner, ty)
3688 def wrap(self: P, parser: Parser[_t.Any]) -> P:
3689 result = super().wrap(parser)
3690 result._inner = parser._inner # type: ignore
3691 return result
3693 def parse_with_ctx(self, ctx: StrParsingContext, /) -> C:
3694 return self._ctor(
3695 self._inner.parse_with_ctx(item) for item in ctx.split(self._delimiter)
3696 )
3698 def parse_many_with_ctx(self, ctxs: _t.Sequence[StrParsingContext], /) -> C:
3699 return self._ctor(self._inner.parse_with_ctx(item) for item in ctxs)
3701 def supports_parse_many(self) -> bool:
3702 return True
3704 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> C:
3705 value = ctx.value
3706 if not isinstance(value, self._config_type):
3707 expected = self._config_type
3708 if not isinstance(expected, tuple):
3709 expected = (expected,)
3710 raise ParsingError.type_mismatch(value, *expected, ctx=ctx)
3712 return self._ctor(
3713 self._inner.parse_config_with_ctx(ctx.descend(item, i))
3714 for i, item in enumerate(self._config_type_iter(value))
3715 )
3717 def get_nargs(self) -> _t.Literal["+", "*"] | int:
3718 return "*"
3720 def describe(self) -> str | None:
3721 delimiter = self._delimiter or " "
3722 value = self._inner.describe_or_def()
3724 return f"{value}[{delimiter}{value}[{delimiter}...]]"
3726 def describe_many(self) -> str | tuple[str, ...]:
3727 return self._inner.describe_or_def()
3729 def describe_value(self, value: object, /) -> str:
3730 assert self.assert_type(value)
3732 return (self._delimiter or " ").join(
3733 self._inner.describe_value(item) for item in self._iter(value)
3734 )
3736 def options(self) -> _t.Collection[yuio.widget.Option[C]] | None:
3737 return None
3739 def completer(self) -> yuio.complete.Completer | None:
3740 completer = self._inner.completer()
3741 return (
3742 yuio.complete.List(
3743 completer,
3744 delimiter=self._delimiter,
3745 allow_duplicates=self._allow_completing_duplicates,
3746 )
3747 if completer is not None
3748 else None
3749 )
3751 def widget(
3752 self,
3753 default: object | yuio.Missing,
3754 input_description: str | None,
3755 default_description: str | None,
3756 /,
3757 ) -> yuio.widget.Widget[C | yuio.Missing]:
3758 completer = self.completer()
3759 return _WidgetResultMapper(
3760 self,
3761 input_description,
3762 default,
3763 (
3764 yuio.widget.InputWithCompletion(
3765 completer,
3766 placeholder=default_description or "",
3767 )
3768 if completer is not None
3769 else yuio.widget.Input(
3770 placeholder=default_description or "",
3771 )
3772 ),
3773 )
3775 def is_secret(self) -> bool:
3776 return self._inner.is_secret()
3778 def __repr__(self):
3779 if self._inner_raw is not None:
3780 return f"{self.__class__.__name__}({self._inner_raw!r})"
3781 else:
3782 return self.__class__.__name__
3785class List(CollectionParser[list[T], T], _t.Generic[T]):
3786 """List(inner: Parser[T], /, *, delimiter: str | None = None)
3788 Parser for lists.
3790 Will split a string by the given delimiter, and parse each item
3791 using a subparser.
3793 :param inner:
3794 inner parser that will be used to parse list items.
3795 :param delimiter:
3796 delimiter that will be passed to :py:meth:`str.split`.
3798 """
3800 if TYPE_CHECKING:
3802 @_t.overload
3803 def __new__(
3804 cls, inner: Parser[T], /, *, delimiter: str | None = None
3805 ) -> List[T]: ...
3807 @_t.overload
3808 def __new__(cls, /, *, delimiter: str | None = None) -> PartialParser: ...
3810 def __new__(cls, *args, **kwargs) -> _t.Any: ...
3812 def __init__(
3813 self,
3814 inner: Parser[T] | None = None,
3815 /,
3816 *,
3817 delimiter: str | None = None,
3818 ):
3819 super().__init__(inner, ty=list, ctor=list, delimiter=delimiter)
3821 def to_json_schema(
3822 self, ctx: yuio.json_schema.JsonSchemaContext, /
3823 ) -> yuio.json_schema.JsonSchemaType:
3824 return yuio.json_schema.Array(self._inner.to_json_schema(ctx))
3826 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3827 assert self.assert_type(value)
3828 return [self._inner.to_json_value(item) for item in value]
3831class Set(CollectionParser[set[T], T], _t.Generic[T]):
3832 """Set(inner: Parser[T], /, *, delimiter: str | None = None)
3834 Parser for sets.
3836 Will split a string by the given delimiter, and parse each item
3837 using a subparser.
3839 :param inner:
3840 inner parser that will be used to parse set items.
3841 :param delimiter:
3842 delimiter that will be passed to :py:meth:`str.split`.
3844 """
3846 if TYPE_CHECKING:
3848 @_t.overload
3849 def __new__(
3850 cls, inner: Parser[T], /, *, delimiter: str | None = None
3851 ) -> Set[T]: ...
3853 @_t.overload
3854 def __new__(cls, /, *, delimiter: str | None = None) -> PartialParser: ...
3856 def __new__(cls, *args, **kwargs) -> _t.Any: ...
3858 _allow_completing_duplicates = False
3860 def __init__(
3861 self,
3862 inner: Parser[T] | None = None,
3863 /,
3864 *,
3865 delimiter: str | None = None,
3866 ):
3867 super().__init__(inner, ty=set, ctor=set, delimiter=delimiter)
3869 def widget(
3870 self,
3871 default: object | yuio.Missing,
3872 input_description: str | None,
3873 default_description: str | None,
3874 /,
3875 ) -> yuio.widget.Widget[set[T] | yuio.Missing]:
3876 options = self._inner.options()
3877 if options and len(options) <= 25:
3878 return yuio.widget.Map(yuio.widget.Multiselect(list(options)), set)
3879 else:
3880 return super().widget(default, input_description, default_description)
3882 def to_json_schema(
3883 self, ctx: yuio.json_schema.JsonSchemaContext, /
3884 ) -> yuio.json_schema.JsonSchemaType:
3885 return yuio.json_schema.Array(
3886 self._inner.to_json_schema(ctx), unique_items=True
3887 )
3889 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3890 assert self.assert_type(value)
3891 return [self._inner.to_json_value(item) for item in value]
3894class FrozenSet(CollectionParser[frozenset[T], T], _t.Generic[T]):
3895 """FrozenSet(inner: Parser[T], /, *, delimiter: str | None = None)
3897 Parser for frozen sets.
3899 Will split a string by the given delimiter, and parse each item
3900 using a subparser.
3902 :param inner:
3903 inner parser that will be used to parse set items.
3904 :param delimiter:
3905 delimiter that will be passed to :py:meth:`str.split`.
3907 """
3909 if TYPE_CHECKING:
3911 @_t.overload
3912 def __new__(
3913 cls, inner: Parser[T], /, *, delimiter: str | None = None
3914 ) -> FrozenSet[T]: ...
3916 @_t.overload
3917 def __new__(cls, /, *, delimiter: str | None = None) -> PartialParser: ...
3919 def __new__(cls, *args, **kwargs) -> _t.Any: ...
3921 _allow_completing_duplicates = False
3923 def __init__(
3924 self,
3925 inner: Parser[T] | None = None,
3926 /,
3927 *,
3928 delimiter: str | None = None,
3929 ):
3930 super().__init__(inner, ty=frozenset, ctor=frozenset, delimiter=delimiter)
3932 def to_json_schema(
3933 self, ctx: yuio.json_schema.JsonSchemaContext, /
3934 ) -> yuio.json_schema.JsonSchemaType:
3935 return yuio.json_schema.Array(
3936 self._inner.to_json_schema(ctx), unique_items=True
3937 )
3939 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3940 assert self.assert_type(value)
3941 return [self._inner.to_json_value(item) for item in value]
3944class Dict(CollectionParser[dict[K, V], tuple[K, V]], _t.Generic[K, V]):
3945 """Dict(key: Parser[K], value: Parser[V], /, *, delimiter: str | None = None, pair_delimiter: str = ":")
3947 Parser for dicts.
3949 Will split a string by the given delimiter, and parse each item
3950 using a :py:class:`Tuple` parser.
3952 :param key:
3953 inner parser that will be used to parse dict keys.
3954 :param value:
3955 inner parser that will be used to parse dict values.
3956 :param delimiter:
3957 delimiter that will be passed to :py:meth:`str.split`.
3958 :param pair_delimiter:
3959 delimiter that will be used to split key-value elements.
3961 """
3963 if TYPE_CHECKING:
3965 @_t.overload
3966 def __new__(
3967 cls,
3968 key: Parser[K],
3969 value: Parser[V],
3970 /,
3971 *,
3972 delimiter: str | None = None,
3973 pair_delimiter: str = ":",
3974 ) -> Dict[K, V]: ...
3976 @_t.overload
3977 def __new__(
3978 cls,
3979 /,
3980 *,
3981 delimiter: str | None = None,
3982 pair_delimiter: str = ":",
3983 ) -> PartialParser: ...
3985 def __new__(cls, *args, **kwargs) -> _t.Any: ...
3987 _allow_completing_duplicates = False
3989 def __init__(
3990 self,
3991 key: Parser[K] | None = None,
3992 value: Parser[V] | None = None,
3993 /,
3994 *,
3995 delimiter: str | None = None,
3996 pair_delimiter: str = ":",
3997 ):
3998 self._pair_delimiter = pair_delimiter
3999 super().__init__(
4000 (
4001 _DictElementParser(key, value, delimiter=pair_delimiter)
4002 if key and value
4003 else None
4004 ),
4005 ty=dict,
4006 ctor=dict,
4007 iter=dict.items,
4008 config_type=(dict, list),
4009 config_type_iter=self.__config_type_iter,
4010 delimiter=delimiter,
4011 )
4013 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
4014 result = super().wrap(parser)
4015 result._inner._delimiter = self._pair_delimiter # pyright: ignore[reportAttributeAccessIssue]
4016 return result
4018 @staticmethod
4019 def __config_type_iter(x) -> _t.Iterator[tuple[K, V]]:
4020 if isinstance(x, dict):
4021 return iter(x.items())
4022 else:
4023 return iter(x)
4025 def to_json_schema(
4026 self, ctx: yuio.json_schema.JsonSchemaContext, /
4027 ) -> yuio.json_schema.JsonSchemaType:
4028 key_schema = self._inner._inner[0].to_json_schema(ctx) # type: ignore
4029 value_schema = self._inner._inner[1].to_json_schema(ctx) # type: ignore
4030 return yuio.json_schema.Dict(key_schema, value_schema)
4032 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
4033 assert self.assert_type(value)
4034 items = _t.cast(
4035 list[tuple[yuio.json_schema.JsonValue, yuio.json_schema.JsonValue]],
4036 [self._inner.to_json_value(item) for item in value.items()],
4037 )
4039 if all(isinstance(k, str) for k, _ in items):
4040 return dict(_t.cast(list[tuple[str, yuio.json_schema.JsonValue]], items))
4041 else:
4042 return items
4045class Tuple(
4046 WrappingParser[TU, tuple[Parser[object], ...]],
4047 ValueParser[TU],
4048 PartialParser,
4049 _t.Generic[TU],
4050):
4051 """Tuple(*parsers: Parser[...], delimiter: str | None = None)
4053 Parser for tuples of fixed lengths.
4055 :param parsers:
4056 parsers for each tuple element.
4057 :param delimiter:
4058 delimiter that will be passed to :py:meth:`str.split`.
4060 """
4062 # See the links below for an explanation of shy this is so ugly:
4063 # https://github.com/python/typing/discussions/1450
4064 # https://github.com/python/typing/issues/1216
4065 if TYPE_CHECKING:
4066 T1 = _t.TypeVar("T1")
4067 T2 = _t.TypeVar("T2")
4068 T3 = _t.TypeVar("T3")
4069 T4 = _t.TypeVar("T4")
4070 T5 = _t.TypeVar("T5")
4071 T6 = _t.TypeVar("T6")
4072 T7 = _t.TypeVar("T7")
4073 T8 = _t.TypeVar("T8")
4074 T9 = _t.TypeVar("T9")
4075 T10 = _t.TypeVar("T10")
4077 @_t.overload
4078 def __new__(
4079 cls,
4080 /,
4081 *,
4082 delimiter: str | None = None,
4083 ) -> PartialParser: ...
4085 @_t.overload
4086 def __new__(
4087 cls,
4088 p1: Parser[T1],
4089 /,
4090 *,
4091 delimiter: str | None = None,
4092 ) -> Tuple[tuple[T1]]: ...
4094 @_t.overload
4095 def __new__(
4096 cls,
4097 p1: Parser[T1],
4098 p2: Parser[T2],
4099 /,
4100 *,
4101 delimiter: str | None = None,
4102 ) -> Tuple[tuple[T1, T2]]: ...
4104 @_t.overload
4105 def __new__(
4106 cls,
4107 p1: Parser[T1],
4108 p2: Parser[T2],
4109 p3: Parser[T3],
4110 /,
4111 *,
4112 delimiter: str | None = None,
4113 ) -> Tuple[tuple[T1, T2, T3]]: ...
4115 @_t.overload
4116 def __new__(
4117 cls,
4118 p1: Parser[T1],
4119 p2: Parser[T2],
4120 p3: Parser[T3],
4121 p4: Parser[T4],
4122 /,
4123 *,
4124 delimiter: str | None = None,
4125 ) -> Tuple[tuple[T1, T2, T3, T4]]: ...
4127 @_t.overload
4128 def __new__(
4129 cls,
4130 p1: Parser[T1],
4131 p2: Parser[T2],
4132 p3: Parser[T3],
4133 p4: Parser[T4],
4134 p5: Parser[T5],
4135 /,
4136 *,
4137 delimiter: str | None = None,
4138 ) -> Tuple[tuple[T1, T2, T3, T4, T5]]: ...
4140 @_t.overload
4141 def __new__(
4142 cls,
4143 p1: Parser[T1],
4144 p2: Parser[T2],
4145 p3: Parser[T3],
4146 p4: Parser[T4],
4147 p5: Parser[T5],
4148 p6: Parser[T6],
4149 /,
4150 *,
4151 delimiter: str | None = None,
4152 ) -> Tuple[tuple[T1, T2, T3, T4, T5, T6]]: ...
4154 @_t.overload
4155 def __new__(
4156 cls,
4157 p1: Parser[T1],
4158 p2: Parser[T2],
4159 p3: Parser[T3],
4160 p4: Parser[T4],
4161 p5: Parser[T5],
4162 p6: Parser[T6],
4163 p7: Parser[T7],
4164 /,
4165 *,
4166 delimiter: str | None = None,
4167 ) -> Tuple[tuple[T1, T2, T3, T4, T5, T6, T7]]: ...
4169 @_t.overload
4170 def __new__(
4171 cls,
4172 p1: Parser[T1],
4173 p2: Parser[T2],
4174 p3: Parser[T3],
4175 p4: Parser[T4],
4176 p5: Parser[T5],
4177 p6: Parser[T6],
4178 p7: Parser[T7],
4179 p8: Parser[T8],
4180 /,
4181 *,
4182 delimiter: str | None = None,
4183 ) -> Tuple[tuple[T1, T2, T3, T4, T5, T6, T7, T8]]: ...
4185 @_t.overload
4186 def __new__(
4187 cls,
4188 p1: Parser[T1],
4189 p2: Parser[T2],
4190 p3: Parser[T3],
4191 p4: Parser[T4],
4192 p5: Parser[T5],
4193 p6: Parser[T6],
4194 p7: Parser[T7],
4195 p8: Parser[T8],
4196 p9: Parser[T9],
4197 /,
4198 *,
4199 delimiter: str | None = None,
4200 ) -> Tuple[tuple[T1, T2, T3, T4, T5, T6, T7, T8, T9]]: ...
4202 @_t.overload
4203 def __new__(
4204 cls,
4205 p1: Parser[T1],
4206 p2: Parser[T2],
4207 p3: Parser[T3],
4208 p4: Parser[T4],
4209 p5: Parser[T5],
4210 p6: Parser[T6],
4211 p7: Parser[T7],
4212 p8: Parser[T8],
4213 p9: Parser[T9],
4214 p10: Parser[T10],
4215 /,
4216 *,
4217 delimiter: str | None = None,
4218 ) -> Tuple[tuple[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10]]: ...
4220 @_t.overload
4221 def __new__(
4222 cls,
4223 p1: Parser[T1],
4224 p2: Parser[T2],
4225 p3: Parser[T3],
4226 p4: Parser[T4],
4227 p5: Parser[T5],
4228 p6: Parser[T6],
4229 p7: Parser[T7],
4230 p8: Parser[T8],
4231 p9: Parser[T9],
4232 p10: Parser[T10],
4233 p11: Parser[object],
4234 *tail: Parser[object],
4235 delimiter: str | None = None,
4236 ) -> Tuple[tuple[_t.Any, ...]]: ...
4238 def __new__(cls, *args, **kwargs) -> _t.Any: ...
4240 def __init__(
4241 self,
4242 *parsers: Parser[_t.Any],
4243 delimiter: str | None = None,
4244 ):
4245 if delimiter == "":
4246 raise ValueError("empty delimiter")
4247 self._delimiter = delimiter
4248 super().__init__(parsers or None, tuple)
4250 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
4251 result = super().wrap(parser)
4252 result._inner = parser._inner # type: ignore
4253 return result
4255 def parse_with_ctx(self, ctx: StrParsingContext, /) -> TU:
4256 items = list(ctx.split(self._delimiter, maxsplit=len(self._inner) - 1))
4258 if len(items) != len(self._inner):
4259 raise ParsingError(
4260 "Expected %s element%s, got %s: `%r`",
4261 len(self._inner),
4262 "" if len(self._inner) == 1 else "s",
4263 len(items),
4264 ctx.value,
4265 ctx=ctx,
4266 )
4268 return _t.cast(
4269 TU,
4270 tuple(
4271 parser.parse_with_ctx(item) for parser, item in zip(self._inner, items)
4272 ),
4273 )
4275 def parse_many_with_ctx(self, ctxs: _t.Sequence[StrParsingContext], /) -> TU:
4276 if len(ctxs) != len(self._inner):
4277 raise ParsingError(
4278 "Expected %s element%s, got %s: `%r`",
4279 len(self._inner),
4280 "" if len(self._inner) == 1 else "s",
4281 len(ctxs),
4282 ctxs,
4283 )
4285 return _t.cast(
4286 TU,
4287 tuple(
4288 parser.parse_with_ctx(item) for parser, item in zip(self._inner, ctxs)
4289 ),
4290 )
4292 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> TU:
4293 value = ctx.value
4294 if not isinstance(value, (list, tuple)):
4295 raise ParsingError.type_mismatch(value, list, tuple, ctx=ctx)
4296 elif len(value) != len(self._inner):
4297 raise ParsingError(
4298 "Expected %s element%s, got %s: `%r`",
4299 len(self._inner),
4300 "" if len(self._inner) == 1 else "s",
4301 len(value),
4302 value,
4303 ctx=ctx,
4304 )
4306 return _t.cast(
4307 TU,
4308 tuple(
4309 parser.parse_config_with_ctx(ctx.descend(item, i))
4310 for i, (parser, item) in enumerate(zip(self._inner, value))
4311 ),
4312 )
4314 def supports_parse_many(self) -> bool:
4315 return True
4317 def get_nargs(self) -> _t.Literal["+", "*"] | int:
4318 return len(self._inner)
4320 def describe(self) -> str | None:
4321 delimiter = self._delimiter or " "
4322 desc = [parser.describe_or_def() for parser in self._inner]
4323 return delimiter.join(desc)
4325 def describe_many(self) -> str | tuple[str, ...]:
4326 return tuple(parser.describe_or_def() for parser in self._inner)
4328 def describe_value(self, value: object, /) -> str:
4329 assert self.assert_type(value)
4331 delimiter = self._delimiter or " "
4332 desc = [parser.describe_value(item) for parser, item in zip(self._inner, value)]
4334 return delimiter.join(desc)
4336 def options(self) -> _t.Collection[yuio.widget.Option[TU]] | None:
4337 return None
4339 def completer(self) -> yuio.complete.Completer | None:
4340 return yuio.complete.Tuple(
4341 *[parser.completer() or yuio.complete.Empty() for parser in self._inner],
4342 delimiter=self._delimiter,
4343 )
4345 def widget(
4346 self,
4347 default: object | yuio.Missing,
4348 input_description: str | None,
4349 default_description: str | None,
4350 /,
4351 ) -> yuio.widget.Widget[TU | yuio.Missing]:
4352 completer = self.completer()
4354 return _WidgetResultMapper(
4355 self,
4356 input_description,
4357 default,
4358 (
4359 yuio.widget.InputWithCompletion(
4360 completer,
4361 placeholder=default_description or "",
4362 )
4363 if completer is not None
4364 else yuio.widget.Input(
4365 placeholder=default_description or "",
4366 )
4367 ),
4368 )
4370 def to_json_schema(
4371 self, ctx: yuio.json_schema.JsonSchemaContext, /
4372 ) -> yuio.json_schema.JsonSchemaType:
4373 return yuio.json_schema.Tuple(
4374 [parser.to_json_schema(ctx) for parser in self._inner]
4375 )
4377 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
4378 assert self.assert_type(value)
4379 return [parser.to_json_value(item) for parser, item in zip(self._inner, value)]
4381 def is_secret(self) -> bool:
4382 return any(parser.is_secret() for parser in self._inner)
4384 def __repr__(self):
4385 if self._inner_raw is not None:
4386 return f"{self.__class__.__name__}{self._inner_raw!r}"
4387 else:
4388 return self.__class__.__name__
4391class _DictElementParser(Tuple[tuple[K, V]], _t.Generic[K, V]):
4392 def __init__(self, k: Parser[K], v: Parser[V], delimiter: str | None = None):
4393 super().__init__(k, v, delimiter=delimiter)
4395 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> tuple[K, V]:
4396 if not isinstance(ctx.value, (list, tuple)):
4397 raise ParsingError.type_mismatch(ctx.value, list, tuple, ctx=ctx)
4398 elif len(ctx.value) != 2:
4399 raise ParsingError(
4400 "Expected 2 element, got %s: `%r`",
4401 len(ctx.value),
4402 ctx.value,
4403 ctx=ctx,
4404 )
4406 key = self._inner[0].parse_config_with_ctx(
4407 ConfigParsingContext(
4408 ctx.value[0],
4409 parent=ctx.parent,
4410 key=ctx.key,
4411 desc="key of element #%(key)r",
4412 )
4413 )
4414 value = self._inner[1].parse_config_with_ctx(
4415 ConfigParsingContext(
4416 ctx.value[1],
4417 parent=ctx.parent,
4418 key=key,
4419 )
4420 )
4422 return _t.cast(tuple[K, V], (key, value))
4425class Optional(MappingParser[T | None, T], _t.Generic[T]):
4426 """Optional(inner: Parser[T], /)
4428 Parser for optional values.
4430 Allows handling :data:`None`\\ s when parsing config. Does not change how strings
4431 are parsed, though.
4433 :param inner:
4434 a parser used to extract and validate contents of an optional.
4436 """
4438 if TYPE_CHECKING:
4440 @_t.overload
4441 def __new__(cls, inner: Parser[T], /) -> Optional[T]: ...
4443 @_t.overload
4444 def __new__(cls, /) -> PartialParser: ...
4446 def __new__(cls, *args, **kwargs) -> _t.Any: ...
4448 def __init__(self, inner: Parser[T] | None = None, /):
4449 super().__init__(inner)
4451 def parse_with_ctx(self, ctx: StrParsingContext, /) -> T | None:
4452 return self._inner.parse_with_ctx(ctx)
4454 def parse_many_with_ctx(self, ctxs: _t.Sequence[StrParsingContext], /) -> T | None:
4455 return self._inner.parse_many_with_ctx(ctxs)
4457 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> T | None:
4458 if ctx.value is None:
4459 return None
4460 return self._inner.parse_config_with_ctx(ctx)
4462 def check_type(self, value: object, /) -> _t.TypeGuard[T | None]:
4463 return True
4465 def describe_value(self, value: object, /) -> str:
4466 if value is None:
4467 return "<none>"
4468 return self._inner.describe_value(value)
4470 def options(self) -> _t.Collection[yuio.widget.Option[T | None]] | None:
4471 return self._inner.options()
4473 def widget(
4474 self,
4475 default: object | yuio.Missing,
4476 input_description: str | None,
4477 default_description: str | None,
4478 /,
4479 ) -> yuio.widget.Widget[T | yuio.Missing]:
4480 return self._inner.widget(default, input_description, default_description)
4482 def to_json_schema(
4483 self, ctx: yuio.json_schema.JsonSchemaContext, /
4484 ) -> yuio.json_schema.JsonSchemaType:
4485 return yuio.json_schema.OneOf(
4486 [self._inner.to_json_schema(ctx), yuio.json_schema.Null()]
4487 )
4489 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
4490 if value is None:
4491 return None
4492 else:
4493 return self._inner.to_json_value(value)
4496class Union(WrappingParser[T, tuple[Parser[T], ...]], ValueParser[T], _t.Generic[T]):
4497 """Union(*parsers: Parser[T])
4499 Tries several parsers and returns the first successful result.
4501 .. warning::
4503 Order of parsers matters. Since parsers are tried in the same order as they're
4504 given, make sure to put parsers that are likely to succeed at the end.
4506 For example, this parser will always return a string because :class:`Str`
4507 can't fail::
4509 >>> parser = Union(Str(), Int()) # Always returns a string!
4510 >>> parser.parse("10")
4511 '10'
4513 To fix this, put :class:`Str` at the end so that :class:`Int` is tried first::
4515 >>> parser = Union(Int(), Str())
4516 >>> parser.parse("10")
4517 10
4518 >>> parser.parse("not an int")
4519 'not an int'
4521 """
4523 # See the links below for an explanation of shy this is so ugly:
4524 # https://github.com/python/typing/discussions/1450
4525 # https://github.com/python/typing/issues/1216
4526 if TYPE_CHECKING:
4527 T1 = _t.TypeVar("T1")
4528 T2 = _t.TypeVar("T2")
4529 T3 = _t.TypeVar("T3")
4530 T4 = _t.TypeVar("T4")
4531 T5 = _t.TypeVar("T5")
4532 T6 = _t.TypeVar("T6")
4533 T7 = _t.TypeVar("T7")
4534 T8 = _t.TypeVar("T8")
4535 T9 = _t.TypeVar("T9")
4536 T10 = _t.TypeVar("T10")
4538 @_t.overload
4539 def __new__(
4540 cls,
4541 /,
4542 ) -> PartialParser: ...
4544 @_t.overload
4545 def __new__(
4546 cls,
4547 p1: Parser[T1],
4548 /,
4549 ) -> Union[T1]: ...
4551 @_t.overload
4552 def __new__(
4553 cls,
4554 p1: Parser[T1],
4555 p2: Parser[T2],
4556 /,
4557 ) -> Union[T1 | T2]: ...
4559 @_t.overload
4560 def __new__(
4561 cls,
4562 p1: Parser[T1],
4563 p2: Parser[T2],
4564 p3: Parser[T3],
4565 /,
4566 ) -> Union[T1 | T2 | T3]: ...
4568 @_t.overload
4569 def __new__(
4570 cls,
4571 p1: Parser[T1],
4572 p2: Parser[T2],
4573 p3: Parser[T3],
4574 p4: Parser[T4],
4575 /,
4576 ) -> Union[T1 | T2 | T3 | T4]: ...
4578 @_t.overload
4579 def __new__(
4580 cls,
4581 p1: Parser[T1],
4582 p2: Parser[T2],
4583 p3: Parser[T3],
4584 p4: Parser[T4],
4585 p5: Parser[T5],
4586 /,
4587 ) -> Union[T1 | T2 | T3 | T4 | T5]: ...
4589 @_t.overload
4590 def __new__(
4591 cls,
4592 p1: Parser[T1],
4593 p2: Parser[T2],
4594 p3: Parser[T3],
4595 p4: Parser[T4],
4596 p5: Parser[T5],
4597 p6: Parser[T6],
4598 /,
4599 ) -> Union[T1 | T2 | T3 | T4 | T5 | T6]: ...
4601 @_t.overload
4602 def __new__(
4603 cls,
4604 p1: Parser[T1],
4605 p2: Parser[T2],
4606 p3: Parser[T3],
4607 p4: Parser[T4],
4608 p5: Parser[T5],
4609 p6: Parser[T6],
4610 p7: Parser[T7],
4611 /,
4612 ) -> Union[T1 | T2 | T3 | T4 | T5 | T6 | T7]: ...
4614 @_t.overload
4615 def __new__(
4616 cls,
4617 p1: Parser[T1],
4618 p2: Parser[T2],
4619 p3: Parser[T3],
4620 p4: Parser[T4],
4621 p5: Parser[T5],
4622 p6: Parser[T6],
4623 p7: Parser[T7],
4624 p8: Parser[T8],
4625 /,
4626 ) -> Union[T1 | T2 | T3 | T4 | T5 | T6 | T7 | T8]: ...
4628 @_t.overload
4629 def __new__(
4630 cls,
4631 p1: Parser[T1],
4632 p2: Parser[T2],
4633 p3: Parser[T3],
4634 p4: Parser[T4],
4635 p5: Parser[T5],
4636 p6: Parser[T6],
4637 p7: Parser[T7],
4638 p8: Parser[T8],
4639 p9: Parser[T9],
4640 /,
4641 ) -> Union[T1 | T2 | T3 | T4 | T5 | T6 | T7 | T8 | T9]: ...
4643 @_t.overload
4644 def __new__(
4645 cls,
4646 p1: Parser[T1],
4647 p2: Parser[T2],
4648 p3: Parser[T3],
4649 p4: Parser[T4],
4650 p5: Parser[T5],
4651 p6: Parser[T6],
4652 p7: Parser[T7],
4653 p8: Parser[T8],
4654 p9: Parser[T9],
4655 p10: Parser[T10],
4656 /,
4657 ) -> Union[T1 | T2 | T3 | T4 | T5 | T6 | T7 | T8 | T9 | T10]: ...
4659 @_t.overload
4660 def __new__(
4661 cls,
4662 p1: Parser[T1],
4663 p2: Parser[T2],
4664 p3: Parser[T3],
4665 p4: Parser[T4],
4666 p5: Parser[T5],
4667 p6: Parser[T6],
4668 p7: Parser[T7],
4669 p8: Parser[T8],
4670 p9: Parser[T9],
4671 p10: Parser[T10],
4672 p11: Parser[object],
4673 *parsers: Parser[object],
4674 ) -> Union[object]: ...
4676 def __new__(cls, *args, **kwargs) -> _t.Any: ...
4678 def __init__(self, *parsers: Parser[_t.Any]):
4679 super().__init__(parsers or None, object)
4681 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
4682 result = super().wrap(parser)
4683 result._inner = parser._inner # type: ignore
4684 return result
4686 def parse_with_ctx(self, ctx: StrParsingContext, /) -> T:
4687 errors: list[tuple[Parser[object], ParsingError]] = []
4688 for parser in self._inner:
4689 try:
4690 return parser.parse_with_ctx(ctx)
4691 except ParsingError as e:
4692 errors.append((parser, e))
4693 raise self._make_error(errors, ctx)
4695 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> T:
4696 errors: list[tuple[Parser[object], ParsingError]] = []
4697 for parser in self._inner:
4698 try:
4699 return parser.parse_config_with_ctx(ctx)
4700 except ParsingError as e:
4701 errors.append((parser, e))
4702 raise self._make_error(errors, ctx)
4704 def _make_error(
4705 self,
4706 errors: list[tuple[Parser[object], ParsingError]],
4707 ctx: StrParsingContext | ConfigParsingContext,
4708 ):
4709 msgs = []
4710 for parser, error in errors:
4711 error.raw = None
4712 error.pos = None
4713 msgs.append(
4714 yuio.string.Format(
4715 " Trying as `%s`:\n%s",
4716 parser.describe_or_def(),
4717 yuio.string.Indent(error, indent=4),
4718 )
4719 )
4720 return ParsingError(
4721 "Can't parse `%r`:\n%s", ctx.value, yuio.string.Stack(*msgs), ctx=ctx
4722 )
4724 def check_type(self, value: object, /) -> _t.TypeGuard[T]:
4725 return True
4727 def describe(self) -> str | None:
4728 if len(self._inner) > 1:
4730 def strip_curly_brackets(desc: str):
4731 if desc.startswith("{") and desc.endswith("}") and "|" in desc:
4732 s = desc[1:-1]
4733 if "{" not in s and "}" not in s:
4734 return s
4735 return desc
4737 desc = "|".join(
4738 strip_curly_brackets(parser.describe_or_def()) for parser in self._inner
4739 )
4740 desc = f"{{{desc}}}"
4741 else:
4742 desc = "|".join(parser.describe_or_def() for parser in self._inner)
4743 return desc
4745 def describe_value(self, value: object, /) -> str:
4746 for parser in self._inner:
4747 try:
4748 return parser.describe_value(value)
4749 except TypeError:
4750 pass
4752 raise TypeError(
4753 f"parser {self} can't handle value of type {_tx.type_repr(type(value))}"
4754 )
4756 def options(self) -> _t.Collection[yuio.widget.Option[T]] | None:
4757 result = []
4758 got_options = False
4759 for parser in self._inner:
4760 if options := parser.options():
4761 result.extend(options)
4762 got_options = True
4763 if got_options:
4764 return result
4765 else:
4766 return None
4768 def completer(self) -> yuio.complete.Completer | None:
4769 completers = []
4770 for parser in self._inner:
4771 if completer := parser.completer():
4772 completers.append((parser.describe(), completer))
4773 if not completers:
4774 return None
4775 elif len(completers) == 1:
4776 return completers[0][1]
4777 else:
4778 return yuio.complete.Alternative(completers)
4780 def widget(
4781 self,
4782 default: object | yuio.Missing,
4783 input_description: str | None,
4784 default_description: str | None,
4785 ) -> yuio.widget.Widget[T | yuio.Missing]:
4786 options = []
4787 for parser in self._inner:
4788 parser_options = parser.options()
4789 if parser_options is None:
4790 options = None
4791 break
4792 options.extend(parser_options)
4794 if not options:
4795 return super().widget(default, input_description, default_description)
4797 if default is yuio.MISSING:
4798 default_index = 0
4799 else:
4800 for i, option in enumerate(options):
4801 if option.value == default:
4802 default_index = i
4803 break
4804 else:
4805 options.insert(
4806 0,
4807 yuio.widget.Option(
4808 yuio.MISSING, default_description or str(default)
4809 ),
4810 )
4811 default_index = 0
4813 return yuio.widget.Choice(options, default_index=default_index)
4815 def to_json_schema(
4816 self, ctx: yuio.json_schema.JsonSchemaContext, /
4817 ) -> yuio.json_schema.JsonSchemaType:
4818 return yuio.json_schema.OneOf(
4819 [parser.to_json_schema(ctx) for parser in self._inner]
4820 )
4822 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
4823 for parser in self._inner:
4824 try:
4825 return parser.to_json_value(value)
4826 except TypeError:
4827 pass
4829 raise TypeError(
4830 f"parser {self} can't handle value of type {_tx.type_repr(type(value))}"
4831 )
4833 def is_secret(self) -> bool:
4834 return any(parser.is_secret() for parser in self._inner)
4836 def __repr__(self):
4837 return f"{self.__class__.__name__}{self._inner_raw!r}"
4840class _BoundImpl(ValidatingParser[T], _t.Generic[T, Cmp]):
4841 def __init__(
4842 self,
4843 inner: Parser[T] | None,
4844 /,
4845 *,
4846 lower: Cmp | None = None,
4847 lower_inclusive: Cmp | None = None,
4848 upper: Cmp | None = None,
4849 upper_inclusive: Cmp | None = None,
4850 mapper: _t.Callable[[T], Cmp],
4851 desc: str,
4852 ):
4853 super().__init__(inner)
4855 self._lower_bound: Cmp | None = None
4856 self._lower_bound_is_inclusive: bool = True
4857 self._upper_bound: Cmp | None = None
4858 self._upper_bound_is_inclusive: bool = True
4860 if lower is not None and lower_inclusive is not None:
4861 raise TypeError(
4862 "lower and lower_inclusive cannot be given at the same time"
4863 )
4864 elif lower is not None:
4865 self._lower_bound = lower
4866 self._lower_bound_is_inclusive = False
4867 elif lower_inclusive is not None:
4868 self._lower_bound = lower_inclusive
4869 self._lower_bound_is_inclusive = True
4871 if upper is not None and upper_inclusive is not None:
4872 raise TypeError(
4873 "upper and upper_inclusive cannot be given at the same time"
4874 )
4875 elif upper is not None:
4876 self._upper_bound = upper
4877 self._upper_bound_is_inclusive = False
4878 elif upper_inclusive is not None:
4879 self._upper_bound = upper_inclusive
4880 self._upper_bound_is_inclusive = True
4882 self.__mapper = mapper
4883 self.__desc = desc
4885 def _validate(self, value: T, /):
4886 mapped = self.__mapper(value)
4888 if self._lower_bound is not None:
4889 if self._lower_bound_is_inclusive and mapped < self._lower_bound:
4890 raise ParsingError(
4891 "%s should be greater than or equal to `%s`: `%r`",
4892 self.__desc,
4893 self._lower_bound,
4894 value,
4895 )
4896 elif not self._lower_bound_is_inclusive and not self._lower_bound < mapped:
4897 raise ParsingError(
4898 "%s should be greater than `%s`: `%r`",
4899 self.__desc,
4900 self._lower_bound,
4901 value,
4902 )
4904 if self._upper_bound is not None:
4905 if self._upper_bound_is_inclusive and self._upper_bound < mapped:
4906 raise ParsingError(
4907 "%s should be lesser than or equal to `%s`: `%r`",
4908 self.__desc,
4909 self._upper_bound,
4910 value,
4911 )
4912 elif not self._upper_bound_is_inclusive and not mapped < self._upper_bound:
4913 raise ParsingError(
4914 "%s should be lesser than `%s`: `%r`",
4915 self.__desc,
4916 self._upper_bound,
4917 value,
4918 )
4920 def __repr__(self):
4921 desc = ""
4922 if self._lower_bound is not None:
4923 desc += repr(self._lower_bound)
4924 desc += " <= " if self._lower_bound_is_inclusive else " < "
4925 mapper_name = getattr(self.__mapper, "__name__")
4926 if mapper_name and mapper_name != "<lambda>":
4927 desc += mapper_name
4928 else:
4929 desc += "x"
4930 if self._upper_bound is not None:
4931 desc += " <= " if self._upper_bound_is_inclusive else " < "
4932 desc += repr(self._upper_bound)
4933 return f"{self.__class__.__name__}({self.__wrapped_parser__!r}, {desc})"
4936class Bound(_BoundImpl[Cmp, Cmp], _t.Generic[Cmp]):
4937 """Bound(inner: Parser[Cmp], /, *, lower: Cmp | None = None, lower_inclusive: Cmp | None = None, upper: Cmp | None = None, upper_inclusive: Cmp | None = None)
4939 Check that value is upper- or lower-bound by some constraints.
4941 :param inner:
4942 parser whose result will be validated.
4943 :param lower:
4944 set lower bound for value, so we require that ``value > lower``.
4945 Can't be given if `lower_inclusive` is also given.
4946 :param lower_inclusive:
4947 set lower bound for value, so we require that ``value >= lower``.
4948 Can't be given if `lower` is also given.
4949 :param upper:
4950 set upper bound for value, so we require that ``value < upper``.
4951 Can't be given if `upper_inclusive` is also given.
4952 :param upper_inclusive:
4953 set upper bound for value, so we require that ``value <= upper``.
4954 Can't be given if `upper` is also given.
4955 :example:
4956 ::
4958 >>> # Int in range `0 < x <= 1`:
4959 >>> Bound(Int(), lower=0, upper_inclusive=1)
4960 Bound(Int, 0 < x <= 1)
4962 """
4964 if TYPE_CHECKING:
4966 @_t.overload
4967 def __new__(
4968 cls,
4969 inner: Parser[Cmp],
4970 /,
4971 *,
4972 lower: Cmp | None = None,
4973 lower_inclusive: Cmp | None = None,
4974 upper: Cmp | None = None,
4975 upper_inclusive: Cmp | None = None,
4976 ) -> Bound[Cmp]: ...
4978 @_t.overload
4979 def __new__(
4980 cls,
4981 *,
4982 lower: Cmp | None = None,
4983 lower_inclusive: Cmp | None = None,
4984 upper: Cmp | None = None,
4985 upper_inclusive: Cmp | None = None,
4986 ) -> PartialParser: ...
4988 def __new__(cls, *args, **kwargs) -> _t.Any: ...
4990 def __init__(
4991 self,
4992 inner: Parser[Cmp] | None = None,
4993 /,
4994 *,
4995 lower: Cmp | None = None,
4996 lower_inclusive: Cmp | None = None,
4997 upper: Cmp | None = None,
4998 upper_inclusive: Cmp | None = None,
4999 ):
5000 super().__init__(
5001 inner,
5002 lower=lower,
5003 lower_inclusive=lower_inclusive,
5004 upper=upper,
5005 upper_inclusive=upper_inclusive,
5006 mapper=lambda x: x,
5007 desc="Value",
5008 )
5010 def to_json_schema(
5011 self, ctx: yuio.json_schema.JsonSchemaContext, /
5012 ) -> yuio.json_schema.JsonSchemaType:
5013 bound = {}
5014 if isinstance(self._lower_bound, (int, float)):
5015 bound[
5016 "minimum" if self._lower_bound_is_inclusive else "exclusiveMinimum"
5017 ] = self._lower_bound
5018 if isinstance(self._upper_bound, (int, float)):
5019 bound[
5020 "maximum" if self._upper_bound_is_inclusive else "exclusiveMaximum"
5021 ] = self._upper_bound
5022 if bound:
5023 return yuio.json_schema.AllOf(
5024 [super().to_json_schema(ctx), yuio.json_schema.Opaque(bound)]
5025 )
5026 else:
5027 return super().to_json_schema(ctx)
5030@_t.overload
5031def Gt(inner: Parser[Cmp], bound: Cmp, /) -> Bound[Cmp]: ...
5032@_t.overload
5033def Gt(bound: _tx.SupportsLt[_t.Any], /) -> PartialParser: ...
5034def Gt(*args) -> _t.Any:
5035 """Gt(inner: Parser[Cmp], bound: Cmp, /)
5037 Alias for :class:`Bound`.
5039 :param inner:
5040 parser whose result will be validated.
5041 :param bound:
5042 lower bound for parsed values.
5044 """
5046 if len(args) == 1:
5047 return Bound(lower=args[0])
5048 elif len(args) == 2:
5049 return Bound(args[0], lower=args[1])
5050 else:
5051 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
5054@_t.overload
5055def Ge(inner: Parser[Cmp], bound: Cmp, /) -> Bound[Cmp]: ...
5056@_t.overload
5057def Ge(bound: _tx.SupportsLt[_t.Any], /) -> PartialParser: ...
5058def Ge(*args) -> _t.Any:
5059 """Ge(inner: Parser[Cmp], bound: Cmp, /)
5061 Alias for :class:`Bound`.
5063 :param inner:
5064 parser whose result will be validated.
5065 :param bound:
5066 lower inclusive bound for parsed values.
5068 """
5070 if len(args) == 1:
5071 return Bound(lower_inclusive=args[0])
5072 elif len(args) == 2:
5073 return Bound(args[0], lower_inclusive=args[1])
5074 else:
5075 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
5078@_t.overload
5079def Lt(inner: Parser[Cmp], bound: Cmp, /) -> Bound[Cmp]: ...
5080@_t.overload
5081def Lt(bound: _tx.SupportsLt[_t.Any], /) -> PartialParser: ...
5082def Lt(*args) -> _t.Any:
5083 """Lt(inner: Parser[Cmp], bound: Cmp, /)
5085 Alias for :class:`Bound`.
5087 :param inner:
5088 parser whose result will be validated.
5089 :param bound:
5090 upper bound for parsed values.
5092 """
5094 if len(args) == 1:
5095 return Bound(upper=args[0])
5096 elif len(args) == 2:
5097 return Bound(args[0], upper=args[1])
5098 else:
5099 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
5102@_t.overload
5103def Le(inner: Parser[Cmp], bound: Cmp, /) -> Bound[Cmp]: ...
5104@_t.overload
5105def Le(bound: _tx.SupportsLt[_t.Any], /) -> PartialParser: ...
5106def Le(*args) -> _t.Any:
5107 """Le(inner: Parser[Cmp], bound: Cmp, /)
5109 Alias for :class:`Bound`.
5111 :param inner:
5112 parser whose result will be validated.
5113 :param bound:
5114 upper inclusive bound for parsed values.
5116 """
5118 if len(args) == 1:
5119 return Bound(upper_inclusive=args[0])
5120 elif len(args) == 2:
5121 return Bound(args[0], upper_inclusive=args[1])
5122 else:
5123 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
5126class LenBound(_BoundImpl[Sz, int], _t.Generic[Sz]):
5127 """LenBound(inner: Parser[Sz], /, *, lower: int | None = None, lower_inclusive: int | None = None, upper: int | None = None, upper_inclusive: int | None = None)
5129 Check that length of a value is upper- or lower-bound by some constraints.
5131 The signature is the same as of the :class:`Bound` class.
5133 :param inner:
5134 parser whose result will be validated.
5135 :param lower:
5136 set lower bound for value's length, so we require that ``len(value) > lower``.
5137 Can't be given if `lower_inclusive` is also given.
5138 :param lower_inclusive:
5139 set lower bound for value's length, so we require that ``len(value) >= lower``.
5140 Can't be given if `lower` is also given.
5141 :param upper:
5142 set upper bound for value's length, so we require that ``len(value) < upper``.
5143 Can't be given if `upper_inclusive` is also given.
5144 :param upper_inclusive:
5145 set upper bound for value's length, so we require that ``len(value) <= upper``.
5146 Can't be given if `upper` is also given.
5147 :example:
5148 ::
5150 >>> # List of up to five ints:
5151 >>> LenBound(List(Int()), upper_inclusive=5)
5152 LenBound(List(Int), len <= 5)
5154 """
5156 if TYPE_CHECKING:
5158 @_t.overload
5159 def __new__(
5160 cls,
5161 inner: Parser[Sz],
5162 /,
5163 *,
5164 lower: int | None = None,
5165 lower_inclusive: int | None = None,
5166 upper: int | None = None,
5167 upper_inclusive: int | None = None,
5168 ) -> LenBound[Sz]: ...
5170 @_t.overload
5171 def __new__(
5172 cls,
5173 /,
5174 *,
5175 lower: int | None = None,
5176 lower_inclusive: int | None = None,
5177 upper: int | None = None,
5178 upper_inclusive: int | None = None,
5179 ) -> PartialParser: ...
5181 def __new__(cls, *args, **kwargs) -> _t.Any: ...
5183 def __init__(
5184 self,
5185 inner: Parser[Sz] | None = None,
5186 /,
5187 *,
5188 lower: int | None = None,
5189 lower_inclusive: int | None = None,
5190 upper: int | None = None,
5191 upper_inclusive: int | None = None,
5192 ):
5193 super().__init__(
5194 inner,
5195 lower=lower,
5196 lower_inclusive=lower_inclusive,
5197 upper=upper,
5198 upper_inclusive=upper_inclusive,
5199 mapper=len,
5200 desc="Length of value",
5201 )
5203 def get_nargs(self) -> _t.Literal["+", "*"] | int:
5204 if not self._inner.supports_parse_many():
5205 # somebody bound len of a string?
5206 return self._inner.get_nargs()
5208 lower = self._lower_bound
5209 if lower is not None and not self._lower_bound_is_inclusive:
5210 lower += 1
5211 upper = self._upper_bound
5212 if upper is not None and not self._upper_bound_is_inclusive:
5213 upper -= 1
5215 if lower == upper and lower is not None:
5216 return lower
5217 elif lower is not None and lower > 0:
5218 return "+"
5219 else:
5220 return "*"
5222 def to_json_schema(
5223 self, ctx: yuio.json_schema.JsonSchemaContext, /
5224 ) -> yuio.json_schema.JsonSchemaType:
5225 bound = {}
5226 min_bound = self._lower_bound
5227 if not self._lower_bound_is_inclusive and min_bound is not None:
5228 min_bound += 1
5229 if min_bound is not None:
5230 bound["minLength"] = bound["minItems"] = bound["minProperties"] = min_bound
5231 max_bound = self._upper_bound
5232 if not self._upper_bound_is_inclusive and max_bound is not None:
5233 max_bound -= 1
5234 if max_bound is not None:
5235 bound["maxLength"] = bound["maxItems"] = bound["maxProperties"] = max_bound
5236 if bound:
5237 return yuio.json_schema.AllOf(
5238 [super().to_json_schema(ctx), yuio.json_schema.Opaque(bound)]
5239 )
5240 else:
5241 return super().to_json_schema(ctx)
5244@_t.overload
5245def LenGt(inner: Parser[Sz], bound: int, /) -> LenBound[Sz]: ...
5246@_t.overload
5247def LenGt(bound: int, /) -> PartialParser: ...
5248def LenGt(*args) -> _t.Any:
5249 """LenGt(inner: Parser[Sz], bound: int, /)
5251 Alias for :class:`LenBound`.
5253 :param inner:
5254 parser whose result will be validated.
5255 :param bound:
5256 lower bound for parsed values's length.
5258 """
5260 if len(args) == 1:
5261 return LenBound(lower=args[0])
5262 elif len(args) == 2:
5263 return LenBound(args[0], lower=args[1])
5264 else:
5265 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
5268@_t.overload
5269def LenGe(inner: Parser[Sz], bound: int, /) -> LenBound[Sz]: ...
5270@_t.overload
5271def LenGe(bound: int, /) -> PartialParser: ...
5272def LenGe(*args) -> _t.Any:
5273 """LenGe(inner: Parser[Sz], bound: int, /)
5275 Alias for :class:`LenBound`.
5277 :param inner:
5278 parser whose result will be validated.
5279 :param bound:
5280 lower inclusive bound for parsed values's length.
5282 """
5284 if len(args) == 1:
5285 return LenBound(lower_inclusive=args[0])
5286 elif len(args) == 2:
5287 return LenBound(args[0], lower_inclusive=args[1])
5288 else:
5289 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
5292@_t.overload
5293def LenLt(inner: Parser[Sz], bound: int, /) -> LenBound[Sz]: ...
5294@_t.overload
5295def LenLt(bound: int, /) -> PartialParser: ...
5296def LenLt(*args) -> _t.Any:
5297 """LenLt(inner: Parser[Sz], bound: int, /)
5299 Alias for :class:`LenBound`.
5301 :param inner:
5302 parser whose result will be validated.
5303 :param bound:
5304 upper bound for parsed values's length.
5306 """
5308 if len(args) == 1:
5309 return LenBound(upper=args[0])
5310 elif len(args) == 2:
5311 return LenBound(args[0], upper=args[1])
5312 else:
5313 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
5316@_t.overload
5317def LenLe(inner: Parser[Sz], bound: int, /) -> LenBound[Sz]: ...
5318@_t.overload
5319def LenLe(bound: int, /) -> PartialParser: ...
5320def LenLe(*args) -> _t.Any:
5321 """LenLe(inner: Parser[Sz], bound: int, /)
5323 Alias for :class:`LenBound`.
5325 :param inner:
5326 parser whose result will be validated.
5327 :param bound:
5328 upper inclusive bound for parsed values's length.
5330 """
5332 if len(args) == 1:
5333 return LenBound(upper_inclusive=args[0])
5334 elif len(args) == 2:
5335 return LenBound(args[0], upper_inclusive=args[1])
5336 else:
5337 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
5340class OneOf(ValidatingParser[T], _t.Generic[T]):
5341 """OneOf(inner: Parser[T], values: typing.Collection[T], /)
5343 Check that the parsed value is one of the given set of values.
5345 .. note::
5347 This parser is meant to validate results of other parsers; if you're looking
5348 to parse enums or literal values, check out :class:`Enum` or :class:`Literal`.
5350 :param inner:
5351 parser whose result will be validated.
5352 :param values:
5353 collection of allowed values.
5354 :example:
5355 ::
5357 >>> # Accepts only strings 'A', 'B', or 'C':
5358 >>> OneOf(Str(), ['A', 'B', 'C'])
5359 OneOf(Str)
5361 """
5363 if TYPE_CHECKING:
5365 @_t.overload
5366 def __new__(cls, inner: Parser[T], values: _t.Collection[T], /) -> OneOf[T]: ...
5368 @_t.overload
5369 def __new__(cls, values: _t.Collection[T], /) -> PartialParser: ...
5371 def __new__(cls, *args) -> _t.Any: ...
5373 def __init__(self, *args):
5374 inner: Parser[T] | None
5375 values: _t.Collection[T]
5376 if len(args) == 1:
5377 inner, values = None, args[0]
5378 elif len(args) == 2:
5379 inner, values = args
5380 else:
5381 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
5383 super().__init__(inner)
5385 self._allowed_values = values
5387 def _validate(self, value: T, /):
5388 if value not in self._allowed_values:
5389 raise ParsingError(
5390 "Can't parse `%r`, should be %s",
5391 value,
5392 yuio.string.JoinRepr.or_(self._allowed_values),
5393 )
5395 def describe(self) -> str | None:
5396 desc = "|".join(self.describe_value(e) for e in self._allowed_values)
5397 if len(desc) < 80:
5398 if len(self._allowed_values) > 1:
5399 desc = f"{{{desc}}}"
5400 return desc
5401 else:
5402 return super().describe()
5404 def describe_or_def(self) -> str:
5405 desc = "|".join(self.describe_value(e) for e in self._allowed_values)
5406 if len(desc) < 80:
5407 if len(self._allowed_values) > 1:
5408 desc = f"{{{desc}}}"
5409 return desc
5410 else:
5411 return super().describe_or_def()
5413 def options(self) -> _t.Collection[yuio.widget.Option[T]] | None:
5414 return [
5415 yuio.widget.Option(e, self.describe_value(e)) for e in self._allowed_values
5416 ]
5418 def completer(self) -> yuio.complete.Completer | None:
5419 return yuio.complete.Choice(
5420 [yuio.complete.Option(self.describe_value(e)) for e in self._allowed_values]
5421 )
5423 def widget(
5424 self,
5425 default: object | yuio.Missing,
5426 input_description: str | None,
5427 default_description: str | None,
5428 /,
5429 ) -> yuio.widget.Widget[T | yuio.Missing]:
5430 allowed_values = list(self._allowed_values)
5432 options = _t.cast(list[yuio.widget.Option[T | yuio.Missing]], self.options())
5434 if not options:
5435 return super().widget(default, input_description, default_description)
5437 if default is yuio.MISSING:
5438 default_index = 0
5439 elif default in allowed_values:
5440 default_index = list(allowed_values).index(default) # type: ignore
5441 else:
5442 options.insert(
5443 0, yuio.widget.Option(yuio.MISSING, default_description or str(default))
5444 )
5445 default_index = 0
5447 return yuio.widget.Choice(options, default_index=default_index)
5450class WithMeta(MappingParser[T, T], _t.Generic[T]):
5451 """WithMeta(inner: Parser[T], /, *, desc: str, completer: yuio.complete.Completer | None | ~yuio.MISSING = MISSING)
5453 Overrides inline help messages and other meta information of a wrapped parser.
5455 Inline help messages will show up as hints in autocompletion and widgets.
5457 :param inner:
5458 inner parser.
5459 :param desc:
5460 description override. This short string will be used in CLI, widgets, and
5461 completers to describe expected value.
5462 :param completer:
5463 completer override. Pass :data:`None` to disable completion.
5465 """
5467 if TYPE_CHECKING:
5469 @_t.overload
5470 def __new__(
5471 cls,
5472 inner: Parser[T],
5473 /,
5474 *,
5475 desc: str | None = None,
5476 completer: yuio.complete.Completer | yuio.Missing | None = yuio.MISSING,
5477 ) -> MappingParser[T, T]: ...
5479 @_t.overload
5480 def __new__(
5481 cls,
5482 /,
5483 *,
5484 desc: str | None = None,
5485 completer: yuio.complete.Completer | yuio.Missing | None = yuio.MISSING,
5486 ) -> PartialParser: ...
5488 def __new__(cls, *args, **kwargs) -> _t.Any: ...
5490 def __init__(
5491 self,
5492 *args,
5493 desc: str | None = None,
5494 completer: yuio.complete.Completer | yuio.Missing | None = yuio.MISSING,
5495 ):
5496 inner: Parser[T] | None
5497 if not args:
5498 inner = None
5499 elif len(args) == 1:
5500 inner = args[0]
5501 else:
5502 raise TypeError(f"expected at most 1 positional argument, got {len(args)}")
5504 self._desc = desc
5505 self._completer = completer
5506 super().__init__(inner)
5508 def check_type(self, value: object, /) -> _t.TypeGuard[T]:
5509 return True
5511 def describe(self) -> str | None:
5512 return self._desc or self._inner.describe()
5514 def describe_or_def(self) -> str:
5515 return self._desc or self._inner.describe_or_def()
5517 def describe_many(self) -> str | tuple[str, ...]:
5518 return self._desc or self._inner.describe_many()
5520 def describe_value(self, value: object, /) -> str:
5521 return self._inner.describe_value(value)
5523 def parse_with_ctx(self, ctx: StrParsingContext, /) -> T:
5524 return self._inner.parse_with_ctx(ctx)
5526 def parse_many_with_ctx(self, ctxs: _t.Sequence[StrParsingContext], /) -> T:
5527 return self._inner.parse_many_with_ctx(ctxs)
5529 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> T:
5530 return self._inner.parse_config_with_ctx(ctx)
5532 def options(self) -> _t.Collection[yuio.widget.Option[T]] | None:
5533 return self._inner.options()
5535 def completer(self) -> yuio.complete.Completer | None:
5536 if self._completer is not yuio.MISSING:
5537 return self._completer # type: ignore
5538 else:
5539 return self._inner.completer()
5541 def widget(
5542 self,
5543 default: object | yuio.Missing,
5544 input_description: str | None,
5545 default_description: str | None,
5546 /,
5547 ) -> yuio.widget.Widget[T | yuio.Missing]:
5548 return self._inner.widget(default, input_description, default_description)
5550 def to_json_value(self, value: object) -> yuio.json_schema.JsonValue:
5551 return self._inner.to_json_value(value)
5554class _WidgetResultMapper(yuio.widget.Map[T | yuio.Missing, str]):
5555 def __init__(
5556 self,
5557 parser: Parser[T],
5558 input_description: str | None,
5559 default: object | yuio.Missing,
5560 widget: yuio.widget.Widget[str],
5561 ):
5562 self._parser = parser
5563 self._input_description = input_description
5564 self._default = default
5565 super().__init__(widget, self.mapper)
5567 def mapper(self, s: str) -> T | yuio.Missing:
5568 if not s and self._default is not yuio.MISSING:
5569 return yuio.MISSING
5570 elif not s:
5571 raise ParsingError("Input is required")
5572 try:
5573 return self._parser.parse_with_ctx(StrParsingContext(s))
5574 except ParsingError as e:
5575 if (
5576 isinstance(
5577 self._inner, (yuio.widget.Input, yuio.widget.InputWithCompletion)
5578 )
5579 and e.pos
5580 and e.raw == self._inner.text
5581 ):
5582 if e.pos == (0, len(self._inner.text)):
5583 # Don't highlight the entire text, it's not useful and creates
5584 # visual noise.
5585 self._inner.err_region = None
5586 else:
5587 self._inner.err_region = e.pos
5588 e.raw = None
5589 e.pos = None
5590 raise
5592 @property
5593 def help_data(self):
5594 return super().help_data.with_action(
5595 group="Input Format",
5596 msg=self._input_description,
5597 prepend=True,
5598 prepend_group=True,
5599 )
5602def _secret_widget(
5603 parser: Parser[T],
5604 default: object | yuio.Missing,
5605 input_description: str | None,
5606 default_description: str | None,
5607 /,
5608) -> yuio.widget.Widget[T | yuio.Missing]:
5609 return _WidgetResultMapper(
5610 parser,
5611 input_description,
5612 default,
5613 (
5614 yuio.widget.SecretInput(
5615 placeholder=default_description or "",
5616 )
5617 ),
5618 )
5621class StrParsingContext:
5622 """StrParsingContext(content: str, /, *, n_arg: int | None = None)
5624 String parsing context tracks current position in the string.
5626 :param content:
5627 content to parse.
5628 :param n_arg:
5629 content index when using :meth:`~Parser.parse_many`.
5631 """
5633 def __init__(
5634 self,
5635 content: str,
5636 /,
5637 *,
5638 n_arg: int | None = None,
5639 _value: str | None = None,
5640 _start: int | None = None,
5641 _end: int | None = None,
5642 ):
5643 self.start: int = _start if _start is not None else 0
5644 """
5645 Start position of the value.
5647 """
5649 self.end: int = _end if _end is not None else self.start + len(content)
5650 """
5651 End position of the value.
5653 """
5655 self.content: str = content
5656 """
5657 Full content of the value that was passed to :meth:`Parser.parse`.
5659 """
5661 self.value: str = _value if _value is not None else content
5662 """
5663 Part of the :attr:`~StrParsingContext.content` that's currently being parsed.
5665 """
5667 self.n_arg: int | None = n_arg
5668 """
5669 For :meth:`~Parser.parse_many`, this attribute contains index of the value
5670 that is being parsed. For :meth:`~Parser.parse`, this is :data:`None`.
5672 """
5674 def split(
5675 self, delimiter: str | None = None, /, maxsplit: int = -1
5676 ) -> _t.Generator[StrParsingContext]:
5677 """
5678 Split current value by the given delimiter while keeping track of the current position.
5680 """
5682 if delimiter is None:
5683 yield from self._split_space(maxsplit=maxsplit)
5684 return
5686 dlen = len(delimiter)
5687 start = self.start
5688 for part in self.value.split(delimiter, maxsplit=maxsplit):
5689 yield StrParsingContext(
5690 self.content,
5691 _value=part,
5692 _start=start,
5693 _end=start + len(part),
5694 n_arg=self.n_arg,
5695 )
5696 start += len(part) + dlen
5698 def _split_space(self, maxsplit: int = -1) -> _t.Generator[StrParsingContext]:
5699 i = 0
5700 n_splits = 0
5701 is_space = True
5702 for part in re.split(r"(\s+)", self.value):
5703 is_space = not is_space
5704 if is_space:
5705 i += len(part)
5706 continue
5708 if not part:
5709 continue
5711 if maxsplit >= 0 and n_splits >= maxsplit:
5712 part = self.value[i:]
5713 yield StrParsingContext(
5714 self.content,
5715 _value=part,
5716 _start=i,
5717 _end=i + len(part),
5718 n_arg=self.n_arg,
5719 )
5720 return
5721 else:
5722 yield StrParsingContext(
5723 self.content,
5724 _value=part,
5725 _start=i,
5726 _end=i + len(part),
5727 n_arg=self.n_arg,
5728 )
5729 i += len(part)
5730 n_splits += 1
5732 def strip(self, chars: str | None = None, /) -> StrParsingContext:
5733 """
5734 Strip current value while keeping track of the current position.
5736 """
5738 l_stripped = self.value.lstrip(chars)
5739 start = self.start + (len(self.value) - len(l_stripped))
5740 stripped = l_stripped.rstrip(chars)
5741 return StrParsingContext(
5742 self.content,
5743 _value=stripped,
5744 _start=start,
5745 _end=start + len(stripped),
5746 n_arg=self.n_arg,
5747 )
5749 def strip_if_non_space(self) -> StrParsingContext:
5750 """
5751 Strip current value unless it entirely consists of spaces.
5753 """
5755 if not self.value or self.value.isspace():
5756 return self
5757 else:
5758 return self.strip()
5760 # If you need more methods, feel free to open an issue or send a PR!
5761 # For now, `split` and `strip` is enough.
5764class ConfigParsingContext:
5765 """
5766 Config parsing context tracks path in the config, similar to JSON path.
5768 """
5770 def __init__(
5771 self,
5772 value: object,
5773 /,
5774 *,
5775 parent: ConfigParsingContext | None = None,
5776 key: _t.Any = None,
5777 desc: str | None = None,
5778 ):
5779 self.value: object = value
5780 """
5781 Config value to be validated and parsed.
5783 """
5785 self.parent: ConfigParsingContext | None = parent
5786 """
5787 Parent context.
5789 """
5791 self.key: _t.Any = key
5792 """
5793 Key that was accessed when we've descended from parent context to this one.
5795 Root context has key :data:`None`.
5797 """
5799 self.desc: str | None = desc
5800 """
5801 Additional description of the key.
5803 """
5805 def descend(
5806 self,
5807 value: _t.Any,
5808 key: _t.Any,
5809 desc: str | None = None,
5810 ) -> ConfigParsingContext:
5811 """
5812 Create a new context that adds a new key to the path.
5814 :param value:
5815 inner value that was derived from the current value by accessing it with
5816 the given `key`.
5817 :param key:
5818 key that we use to descend into the current value.
5820 For example, let's say we're parsing a list. We iterate over it and pass
5821 its elements to a sub-parser. Before calling a sub-parser, we need to
5822 make a new context for it. In this situation, we'll pass current element
5823 as `value`, and is index as `key`.
5824 :param desc:
5825 human-readable description for the new context. Will be colorized
5826 and ``%``-formatted with a single named argument `key`.
5828 This is useful when parsing structures that need something more complex than
5829 JSON path. For example, when parsing a key in a dictionary, it is helpful
5830 to set description to something like ``"key of element #%(key)r"``.
5831 This way, parsing errors will have a more clear message:
5833 .. code-block:: text
5835 Parsing error:
5836 In key of element #2:
5837 Expected str, got int: 10
5839 """
5841 return ConfigParsingContext(value, parent=self, key=key, desc=desc)
5843 def make_path(self) -> list[tuple[_t.Any, str | None]]:
5844 """
5845 Capture current path.
5847 :returns:
5848 a list of tuples. First element of each tuple is a key, second is
5849 an additional description.
5851 """
5853 path = []
5855 root = self
5856 while True:
5857 if root.parent is None:
5858 break
5859 else:
5860 path.append((root.key, root.desc))
5861 root = root.parent
5863 path.reverse()
5865 return path
5868class _PathRenderer:
5869 def __init__(self, path: list[tuple[_t.Any, str | None]]):
5870 self._path = path
5872 def __colorized_str__(
5873 self, ctx: yuio.string.ReprContext
5874 ) -> yuio.string.ColorizedString:
5875 code_color = ctx.theme.get_color("msg/text:code/repr hl:repr")
5876 punct_color = ctx.theme.get_color("msg/text:code/repr hl/punct:repr")
5878 msg = yuio.string.ColorizedString(code_color)
5879 msg.start_no_wrap()
5881 for i, (key, desc) in enumerate(self._path):
5882 if desc:
5883 desc = (
5884 (yuio.string)
5885 .colorize(desc, ctx=ctx)
5886 .percent_format({"key": key}, ctx=ctx)
5887 )
5889 if i == len(self._path) - 1:
5890 # Last key.
5891 if msg:
5892 msg.append_color(punct_color)
5893 msg.append_str(", ")
5894 msg.append_colorized_str(desc)
5895 else:
5896 # Element in the middle.
5897 if not msg:
5898 msg.append_str("$")
5899 msg.append_color(punct_color)
5900 msg.append_str(".<")
5901 msg.append_colorized_str(desc)
5902 msg.append_str(">")
5903 elif isinstance(key, str) and re.match(r"^[a-zA-Z_][\w-]*$", key):
5904 # Key is identifier-like, use `x.key` notation.
5905 if not msg:
5906 msg.append_str("$")
5907 msg.append_color(punct_color)
5908 msg.append_str(".")
5909 msg.append_color(code_color)
5910 msg.append_str(key)
5911 else:
5912 # Key is not identifier-like, use `x[key]` notation.
5913 if not msg:
5914 msg.append_str("$")
5915 msg.append_color(punct_color)
5916 msg.append_str("[")
5917 msg.append_color(code_color)
5918 msg.append_str(repr(key))
5919 msg.append_color(punct_color)
5920 msg.append_str("]")
5922 msg.end_no_wrap()
5923 return msg
5926class _CodeRenderer:
5927 def __init__(self, code: str, pos: tuple[int, int], as_cli: bool = False):
5928 self._code = code
5929 self._pos = pos
5930 self._as_cli = as_cli
5932 def __colorized_str__(
5933 self, ctx: yuio.string.ReprContext
5934 ) -> yuio.string.ColorizedString:
5935 width = ctx.width - 2 # Account for indentation.
5937 if width < 10: # 6 symbols for ellipsis and at least 2 wide chars.
5938 return yuio.string.ColorizedString()
5940 start, end = self._pos
5941 if end == start:
5942 end += 1
5944 left = self._code[:start]
5945 center = self._code[start:end]
5946 right = self._code[end:]
5948 l_width = yuio.string.line_width(left)
5949 c_width = yuio.string.line_width(center)
5950 r_width = yuio.string.line_width(right)
5952 available_width = width - (3 if left else 0) - 3
5953 if c_width > available_width:
5954 # Center can't fit: remove left and right side,
5955 # and trim as much center as needed.
5957 left = "..." if l_width > 3 else left
5958 l_width = len(left)
5960 right = ""
5961 r_width = 0
5963 new_c = ""
5964 c_width = 0
5966 for c in center:
5967 cw = yuio.string.line_width(c)
5968 if c_width + cw <= available_width:
5969 new_c += c
5970 c_width += cw
5971 else:
5972 new_c += "..."
5973 c_width += 3
5974 break
5975 center = new_c
5977 if r_width > 3 and l_width + c_width + r_width > width:
5978 # Trim right side.
5979 new_r = ""
5980 r_width = 3
5981 for c in right:
5982 cw = yuio.string.line_width(c)
5983 if l_width + c_width + r_width + cw <= width:
5984 new_r += c
5985 r_width += cw
5986 else:
5987 new_r += "..."
5988 break
5989 right = new_r
5991 if l_width > 3 and l_width + c_width + r_width > width:
5992 # Trim left side.
5993 new_l = ""
5994 l_width = 3
5995 for c in left[::-1]:
5996 cw = yuio.string.line_width(c)
5997 if l_width + c_width + r_width + cw <= width:
5998 new_l += c
5999 l_width += cw
6000 else:
6001 new_l += "..."
6002 break
6003 left = new_l[::-1]
6005 if self._as_cli:
6006 punct_color = ctx.theme.get_color(
6007 "msg/text:code/sh-usage hl/punct:sh-usage"
6008 )
6009 else:
6010 punct_color = ctx.theme.get_color("msg/text:code/text hl/punct:text")
6012 res = yuio.string.ColorizedString()
6013 res.start_no_wrap()
6015 if self._as_cli:
6016 res.append_color(punct_color)
6017 res.append_str("$ ")
6018 res.append_colorized_str(
6019 ctx.str(
6020 yuio.string.Hl(
6021 left.replace("%", "%%") + "%s" + right.replace("%", "%%"), # pyright: ignore[reportArgumentType]
6022 yuio.string.WithBaseColor(
6023 center, base_color="hl/error:sh-usage"
6024 ),
6025 syntax="sh-usage",
6026 )
6027 )
6028 )
6029 else:
6030 text_color = ctx.theme.get_color("msg/text:code/text")
6031 res.append_color(punct_color)
6032 res.append_str("> ")
6033 res.append_color(text_color)
6034 res.append_str(left)
6035 res.append_color(text_color | ctx.theme.get_color("hl/error:text"))
6036 res.append_str(center)
6037 res.append_color(text_color)
6038 res.append_str(right)
6039 res.append_color(yuio.color.Color.NONE)
6040 res.append_str("\n")
6041 if self._as_cli:
6042 text_color = ctx.theme.get_color("msg/text:code/sh-usage")
6043 res.append_color(text_color | ctx.theme.get_color("hl/error:sh-usage"))
6044 else:
6045 text_color = ctx.theme.get_color("msg/text:code/text")
6046 res.append_color(text_color | ctx.theme.get_color("hl/error:text"))
6047 res.append_str(" ")
6048 res.append_str(" " * yuio.string.line_width(left))
6049 res.append_str("~" * yuio.string.line_width(center))
6051 res.end_no_wrap()
6053 return res
6056def _repr_and_adjust_pos(s: str, pos: tuple[int, int]):
6057 start, end = pos
6059 left = json.dumps(s[:start])[:-1]
6060 center = json.dumps(s[start:end])[1:-1]
6061 right = json.dumps(s[end:])[1:]
6063 return left + center + right, (len(left), len(left) + len(center))
6066_FromTypeHintCallback: _t.TypeAlias = _t.Callable[
6067 [type, type | None, tuple[object, ...]], Parser[object] | None
6068]
6071_FROM_TYPE_HINT_CALLBACKS: list[tuple[_FromTypeHintCallback, bool]] = []
6072_FROM_TYPE_HINT_DELIM_SUGGESTIONS: list[str | None] = [
6073 None,
6074 ",",
6075 "@",
6076 "/",
6077 "=",
6078]
6081class _FromTypeHintDepth(threading.local):
6082 def __init__(self):
6083 self.depth: int = 0
6084 self.uses_delim = False
6087_FROM_TYPE_HINT_DEPTH: _FromTypeHintDepth = _FromTypeHintDepth()
6090@_t.overload
6091def from_type_hint(ty: type[T], /) -> Parser[T]: ...
6092@_t.overload
6093def from_type_hint(ty: object, /) -> Parser[object]: ...
6094def from_type_hint(ty: _t.Any, /) -> Parser[object]:
6095 """from_type_hint(ty: type[T], /) -> Parser[T]
6097 Create parser from a type hint.
6099 :param ty:
6100 a type hint.
6102 This type hint should not contain strings or forward references. Make sure
6103 they're resolved before passing it to this function.
6104 :returns:
6105 a parser instance created from type hint.
6106 :raises:
6107 :class:`TypeError` if type hint contains forward references or types
6108 that don't have associated parsers.
6109 :example:
6110 ::
6112 >>> from_type_hint(list[int] | None)
6113 Optional(List(Int))
6115 """
6117 result = _from_type_hint(ty)
6118 setattr(result, "_Parser__typehint", ty)
6119 return result
6122def _from_type_hint(ty: _t.Any, /) -> Parser[object]:
6123 if isinstance(ty, (str, _t.ForwardRef)):
6124 raise TypeError(f"forward references are not supported here: {ty}")
6126 origin = _t.get_origin(ty)
6127 args = _t.get_args(ty)
6129 if origin is _t.Annotated:
6130 p = from_type_hint(args[0])
6131 for arg in args[1:]:
6132 if isinstance(arg, PartialParser):
6133 p = arg.wrap(p)
6134 return p
6136 for cb, uses_delim in _FROM_TYPE_HINT_CALLBACKS:
6137 prev_uses_delim = _FROM_TYPE_HINT_DEPTH.uses_delim
6138 _FROM_TYPE_HINT_DEPTH.uses_delim = uses_delim
6139 _FROM_TYPE_HINT_DEPTH.depth += uses_delim
6140 try:
6141 p = cb(ty, origin, args)
6142 if p is not None:
6143 return p
6144 finally:
6145 _FROM_TYPE_HINT_DEPTH.uses_delim = prev_uses_delim
6146 _FROM_TYPE_HINT_DEPTH.depth -= uses_delim
6148 if _tx.is_union(origin):
6149 if is_optional := (type(None) in args):
6150 args = list(args)
6151 args.remove(type(None))
6152 if len(args) == 1:
6153 p = from_type_hint(args[0])
6154 else:
6155 p = Union(*[from_type_hint(arg) for arg in args])
6156 if is_optional:
6157 p = Optional(p)
6158 return p
6159 else:
6160 raise TypeError(f"unsupported type {_tx.type_repr(ty)}")
6163@_t.overload
6164def register_type_hint_conversion(
6165 cb: _FromTypeHintCallback,
6166 /,
6167 *,
6168 uses_delim: bool = False,
6169) -> _FromTypeHintCallback: ...
6170@_t.overload
6171def register_type_hint_conversion(
6172 *,
6173 uses_delim: bool = False,
6174) -> _t.Callable[[_FromTypeHintCallback], _FromTypeHintCallback]: ...
6175def register_type_hint_conversion(
6176 cb: _FromTypeHintCallback | None = None,
6177 /,
6178 *,
6179 uses_delim: bool = False,
6180) -> (
6181 _FromTypeHintCallback | _t.Callable[[_FromTypeHintCallback], _FromTypeHintCallback]
6182):
6183 """
6184 Register a new converter from a type hint to a parser.
6186 This function takes a callback that accepts three positional arguments:
6188 - a type hint,
6189 - a type hint's origin (as defined by :func:`typing.get_origin`),
6190 - a type hint's args (as defined by :func:`typing.get_args`).
6192 The callback should return a parser if it can, or :data:`None` otherwise.
6194 All registered callbacks are tried in the same order
6195 as they were registered.
6197 If `uses_delim` is :data:`True`, callback can use
6198 :func:`suggest_delim_for_type_hint_conversion`.
6200 This function can be used as a decorator.
6202 :param cb:
6203 a function that should inspect a type hint and possibly return a parser.
6204 :param uses_delim:
6205 indicates that callback will use
6206 :func:`suggest_delim_for_type_hint_conversion`.
6207 :example:
6208 .. invisible-code-block: python
6210 class MyType: ...
6211 class MyTypeParser(ValueParser[MyType]):
6212 def __init__(self): super().__init__(MyType)
6213 def parse_with_ctx(self, ctx: StrParsingContext, /): ...
6214 def parse_config_with_ctx(self, value, /): ...
6215 def to_json_schema(self, ctx, /): ...
6216 def to_json_value(self, value, /): ...
6218 .. code-block:: python
6220 @register_type_hint_conversion
6221 def my_type_conversion(ty, origin, args):
6222 if ty is MyType:
6223 return MyTypeParser()
6224 else:
6225 return None
6227 ::
6229 >>> from_type_hint(MyType)
6230 MyTypeParser
6232 .. invisible-code-block: python
6234 del _FROM_TYPE_HINT_CALLBACKS[-1]
6236 """
6238 def registrar(cb: _FromTypeHintCallback):
6239 _FROM_TYPE_HINT_CALLBACKS.append((cb, uses_delim))
6240 return cb
6242 return registrar(cb) if cb is not None else registrar
6245def suggest_delim_for_type_hint_conversion() -> str | None:
6246 """
6247 Suggests a delimiter for use in type hint converters.
6249 When creating a parser for a collection of items based on a type hint,
6250 it is important to use different delimiters for nested collections.
6251 This function can suggest such a delimiter based on the current type hint's depth.
6253 .. invisible-code-block: python
6255 class MyCollection(list, _t.Generic[T]): ...
6256 class MyCollectionParser(CollectionParser[MyCollection[T], T], _t.Generic[T]):
6257 def __init__(self, inner: Parser[T], /, *, delimiter: _t.Optional[str] = None):
6258 super().__init__(inner, ty=MyCollection, ctor=MyCollection, delimiter=delimiter)
6259 def to_json_schema(self, ctx, /): ...
6260 def to_json_value(self, value, /): ...
6262 :raises:
6263 :class:`RuntimeError` if called from a type converter that
6264 didn't set `uses_delim` to :data:`True`.
6265 :example:
6266 .. code-block:: python
6268 @register_type_hint_conversion(uses_delim=True)
6269 def my_collection_conversion(ty, origin, args):
6270 if origin is MyCollection:
6271 return MyCollectionParser(
6272 from_type_hint(args[0]),
6273 delimiter=suggest_delim_for_type_hint_conversion(),
6274 )
6275 else:
6276 return None
6278 ::
6280 >>> parser = from_type_hint(MyCollection[MyCollection[str]])
6281 >>> parser
6282 MyCollectionParser(MyCollectionParser(Str))
6283 >>> # First delimiter is `None`, meaning split by whitespace:
6284 >>> parser._delimiter is None
6285 True
6286 >>> # Second delimiter is `","`:
6287 >>> parser._inner._delimiter == ","
6288 True
6290 ..
6291 >>> del _FROM_TYPE_HINT_CALLBACKS[-1]
6293 """
6295 if not _FROM_TYPE_HINT_DEPTH.uses_delim:
6296 raise RuntimeError(
6297 "looking up delimiters is not available in this callback; did you forget"
6298 " to pass `uses_delim=True` when registering this callback?"
6299 )
6301 depth = _FROM_TYPE_HINT_DEPTH.depth - 1
6302 if depth < len(_FROM_TYPE_HINT_DELIM_SUGGESTIONS):
6303 return _FROM_TYPE_HINT_DELIM_SUGGESTIONS[depth]
6304 else:
6305 return None
6308register_type_hint_conversion(lambda ty, origin, args: Str() if ty is str else None)
6309register_type_hint_conversion(lambda ty, origin, args: Int() if ty is int else None)
6310register_type_hint_conversion(lambda ty, origin, args: Float() if ty is float else None)
6311register_type_hint_conversion(lambda ty, origin, args: Bool() if ty is bool else None)
6312register_type_hint_conversion(
6313 lambda ty, origin, args: (
6314 Enum(ty) if isinstance(ty, type) and issubclass(ty, enum.Enum) else None
6315 )
6316)
6317register_type_hint_conversion(
6318 lambda ty, origin, args: Decimal() if ty is decimal.Decimal else None
6319)
6320register_type_hint_conversion(
6321 lambda ty, origin, args: Fraction() if ty is fractions.Fraction else None
6322)
6323register_type_hint_conversion(
6324 lambda ty, origin, args: (
6325 List(
6326 from_type_hint(args[0]), delimiter=suggest_delim_for_type_hint_conversion()
6327 )
6328 if origin is list
6329 else None
6330 ),
6331 uses_delim=True,
6332)
6333register_type_hint_conversion(
6334 lambda ty, origin, args: (
6335 Set(from_type_hint(args[0]), delimiter=suggest_delim_for_type_hint_conversion())
6336 if origin is set
6337 else None
6338 ),
6339 uses_delim=True,
6340)
6341register_type_hint_conversion(
6342 lambda ty, origin, args: (
6343 FrozenSet(
6344 from_type_hint(args[0]), delimiter=suggest_delim_for_type_hint_conversion()
6345 )
6346 if origin is frozenset
6347 else None
6348 ),
6349 uses_delim=True,
6350)
6351register_type_hint_conversion(
6352 lambda ty, origin, args: (
6353 Dict(
6354 from_type_hint(args[0]),
6355 from_type_hint(args[1]),
6356 delimiter=suggest_delim_for_type_hint_conversion(),
6357 )
6358 if origin is dict
6359 else None
6360 ),
6361 uses_delim=True,
6362)
6363register_type_hint_conversion(
6364 lambda ty, origin, args: (
6365 Tuple(
6366 *[from_type_hint(arg) for arg in args],
6367 delimiter=suggest_delim_for_type_hint_conversion(),
6368 )
6369 if origin is tuple and ... not in args
6370 else None
6371 ),
6372 uses_delim=True,
6373)
6374register_type_hint_conversion(
6375 lambda ty, origin, args: Path() if ty is pathlib.Path else None
6376)
6377register_type_hint_conversion(
6378 lambda ty, origin, args: Json() if ty is yuio.json_schema.JsonValue else None
6379)
6380register_type_hint_conversion(
6381 lambda ty, origin, args: DateTime() if ty is datetime.datetime else None
6382)
6383register_type_hint_conversion(
6384 lambda ty, origin, args: Date() if ty is datetime.date else None
6385)
6386register_type_hint_conversion(
6387 lambda ty, origin, args: Time() if ty is datetime.time else None
6388)
6389register_type_hint_conversion(
6390 lambda ty, origin, args: TimeDelta() if ty is datetime.timedelta else None
6391)
6392register_type_hint_conversion(
6393 lambda ty, origin, args: (
6394 Literal(*_t.cast(tuple[_t.Any, ...], args)) if origin is _t.Literal else None
6395 )
6396)
6399@register_type_hint_conversion
6400def __secret(ty, origin, args):
6401 if ty is SecretValue:
6402 raise TypeError("yuio.secret.SecretValue requires type arguments")
6403 if origin is SecretValue:
6404 if len(args) == 1:
6405 return Secret(from_type_hint(args[0]))
6406 else: # pragma: no cover
6407 raise TypeError(
6408 f"yuio.secret.SecretValue requires 1 type argument, got {len(args)}"
6409 )
6410 return None
6413def _is_optional_parser(parser: Parser[_t.Any] | None, /) -> bool:
6414 while parser is not None:
6415 if isinstance(parser, Optional):
6416 return True
6417 parser = parser.__wrapped_parser__
6418 return False
6421def _is_bool_parser(parser: Parser[_t.Any] | None, /) -> bool:
6422 while parser is not None:
6423 if isinstance(parser, Bool):
6424 return True
6425 parser = parser.__wrapped_parser__
6426 return False