Coverage for yuio / parse.py: 90%
1819 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-03 15:42 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-03 15:42 +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 return value.name
2551 else:
2552 return value.value
2554 def _get_docs(self) -> dict[E, str | None]:
2555 if self.__docs is not None:
2556 return self.__docs
2557 docs = _find_docs(self._inner)
2558 res = {}
2559 for e in self._inner:
2560 text = docs.get(e.name)
2561 if not text:
2562 continue
2563 if (index := text.find("\n\n")) != -1:
2564 res[e] = text[:index]
2565 else:
2566 res[e] = text
2567 return res
2569 def _get_desc(self) -> str:
2570 return self._inner.__name__
2572 def to_json_schema(
2573 self, ctx: yuio.json_schema.JsonSchemaContext, /
2574 ) -> yuio.json_schema.JsonSchemaType:
2575 schema = super().to_json_schema(ctx)
2577 if self._doc_inline:
2578 return schema
2579 else:
2580 return ctx.add_type(
2581 Enum._TyWrapper(self._inner, self._by_name, self._to_dash_case),
2582 _tx.type_repr(self._inner),
2583 lambda: yuio.json_schema.Meta(
2584 schema,
2585 title=self._inner.__name__,
2586 description=self._inner.__doc__,
2587 ),
2588 )
2590 def __repr__(self):
2591 if self._inner_raw is not None:
2592 return f"{self.__class__.__name__}({self._inner_raw!r})"
2593 else:
2594 return self.__class__.__name__
2596 @dataclasses.dataclass(unsafe_hash=True, match_args=False, slots=True)
2597 class _TyWrapper:
2598 inner: type
2599 by_name: bool
2600 to_dash_case: bool
2603class _LiteralType:
2604 def __init__(self, allowed_values: tuple[L, ...]) -> None:
2605 self._allowed_values = allowed_values
2607 def __instancecheck__(self, instance: _t.Any) -> bool:
2608 return instance in self._allowed_values
2611class Literal(_EnumBase[L, tuple[L, ...]], _t.Generic[L]):
2612 """
2613 Parser for literal values.
2615 This parser accepts a set of allowed values, and parses them using semantics of
2616 :class:`Enum` parser. It can be used with creating an enum for some value isn't
2617 practical, and semantics of :class:`OneOf` is limiting.
2619 Allowed values should be strings, ints, bools, or instances of :class:`enum.Enum`.
2621 If instances of :class:`enum.Enum` are passed, :class:`Literal` will rely on
2622 enum's :data:`__yuio_by_name__` and :data:`__yuio_to_dash_case__` attributes
2623 to parse these values.
2625 """
2627 if TYPE_CHECKING:
2629 def __new__(cls, *args: L) -> Literal[L]: ...
2631 def __init__(
2632 self,
2633 *literal_values: L,
2634 ):
2635 self._converted_values = {}
2637 for value in literal_values:
2638 orig_value = value
2640 if isinstance(value, enum.Enum):
2641 if getattr(type(value), "__yuio_by_name__", False):
2642 value = value.name
2643 else:
2644 value = value.value
2645 if getattr(type(value), "__yuio_to_dash_case__", False) and isinstance(
2646 value, str
2647 ):
2648 value = _to_dash_case(value)
2649 self._converted_values[orig_value] = value
2651 if not isinstance(value, (int, str, bool)):
2652 raise TypeError(
2653 f"literal parser doesn't support literals "
2654 f"of type {_t.type_repr(type(value))}: {orig_value!r}"
2655 )
2656 super().__init__(
2657 literal_values,
2658 _LiteralType(literal_values), # type: ignore
2659 )
2661 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
2662 with self._patch_stack_summary():
2663 raise TypeError(f"annotating a type with {self} is not supported")
2665 def _get_items(self) -> _t.Iterable[L]:
2666 return self._inner
2668 def _value_to_str(self, value: L) -> str:
2669 return str(self._converted_values.get(value, value))
2671 def _str_value_matches(self, value: L, given: str) -> bool:
2672 value = self._converted_values.get(value, value)
2673 if isinstance(value, str):
2674 return value == given
2675 elif isinstance(value, bool):
2676 try:
2677 given_parsed = Bool().parse(given)
2678 except ParsingError:
2679 return False
2680 else:
2681 return value == given_parsed
2682 elif isinstance(value, int):
2683 try:
2684 given_parsed = Int().parse(given)
2685 except ParsingError:
2686 return False
2687 else:
2688 return value == given_parsed
2689 else:
2690 return False
2692 def _str_value_matches_prefix(self, value: L, given: str) -> bool:
2693 value = self._converted_values.get(value, value)
2694 return isinstance(value, str) and value.casefold().startswith(given.casefold())
2696 def _config_value_matches(self, value: L, given: object) -> bool:
2697 value = self._converted_values.get(value, value)
2698 return value == given
2700 def _value_to_json(self, value: L) -> JsonValue:
2701 return value # type: ignore
2703 def __repr__(self):
2704 if self._inner_raw is not None:
2705 values = map(self._value_to_str, self._inner_raw)
2706 return f"{self.__class__.__name__}({yuio.string.JoinRepr(values)})"
2707 else:
2708 return self.__class__.__name__
2711class Decimal(ValueParser[decimal.Decimal]):
2712 """
2713 Parser for :class:`decimal.Decimal`.
2715 """
2717 def __init__(self):
2718 super().__init__(decimal.Decimal)
2720 def parse_with_ctx(self, ctx: StrParsingContext, /) -> decimal.Decimal:
2721 ctx = ctx.strip_if_non_space()
2722 try:
2723 return decimal.Decimal(ctx.value)
2724 except (ArithmeticError, ValueError, TypeError):
2725 raise ParsingError(
2726 "Can't parse `%r` as `decimal`",
2727 ctx.value,
2728 ctx=ctx,
2729 fallback_msg="Can't parse value as `decimal`",
2730 ) from None
2732 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> decimal.Decimal:
2733 value = ctx.value
2734 if not isinstance(value, (int, float, str, decimal.Decimal)):
2735 raise ParsingError.type_mismatch(value, int, float, str, ctx=ctx)
2736 try:
2737 return decimal.Decimal(value)
2738 except (ArithmeticError, ValueError, TypeError):
2739 raise ParsingError(
2740 "Can't parse `%r` as `decimal`",
2741 value,
2742 ctx=ctx,
2743 fallback_msg="Can't parse value as `decimal`",
2744 ) from None
2746 def to_json_schema(
2747 self, ctx: yuio.json_schema.JsonSchemaContext, /
2748 ) -> yuio.json_schema.JsonSchemaType:
2749 return ctx.add_type(
2750 decimal.Decimal,
2751 "Decimal",
2752 lambda: yuio.json_schema.Meta(
2753 yuio.json_schema.OneOf(
2754 [
2755 yuio.json_schema.Number(),
2756 yuio.json_schema.String(
2757 pattern=r"(?i)^[+-]?((\d+\.\d*|\.?\d+)(e[+-]?\d+)?|inf(inity)?|(nan|snan)\d*)$"
2758 ),
2759 ]
2760 ),
2761 title="Decimal",
2762 description="Decimal fixed-point and floating-point number.",
2763 ),
2764 )
2766 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2767 assert self.assert_type(value)
2768 return str(value)
2771class Fraction(ValueParser[fractions.Fraction]):
2772 """
2773 Parser for :class:`fractions.Fraction`.
2775 """
2777 def __init__(self):
2778 super().__init__(fractions.Fraction)
2780 def parse_with_ctx(self, ctx: StrParsingContext, /) -> fractions.Fraction:
2781 ctx = ctx.strip_if_non_space()
2782 try:
2783 return fractions.Fraction(ctx.value)
2784 except ValueError:
2785 raise ParsingError(
2786 "Can't parse `%r` as `fraction`",
2787 ctx.value,
2788 ctx=ctx,
2789 fallback_msg="Can't parse value as `fraction`",
2790 ) from None
2791 except ZeroDivisionError:
2792 raise ParsingError(
2793 "Can't parse `%r` as `fraction`, division by zero",
2794 ctx.value,
2795 ctx=ctx,
2796 fallback_msg="Can't parse value as `fraction`",
2797 ) from None
2799 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> fractions.Fraction:
2800 value = ctx.value
2801 if (
2802 isinstance(value, (list, tuple))
2803 and len(value) == 2
2804 and all(isinstance(v, (float, int)) for v in value)
2805 ):
2806 try:
2807 return fractions.Fraction(*value)
2808 except (ValueError, TypeError):
2809 raise ParsingError(
2810 "Can't parse `%s/%s` as `fraction`",
2811 value[0],
2812 value[1],
2813 ctx=ctx,
2814 fallback_msg="Can't parse value as `fraction`",
2815 ) from None
2816 except ZeroDivisionError:
2817 raise ParsingError(
2818 "Can't parse `%s/%s` as `fraction`, division by zero",
2819 value[0],
2820 value[1],
2821 ctx=ctx,
2822 fallback_msg="Can't parse value as `fraction`",
2823 ) from None
2824 if isinstance(value, (int, float, str, decimal.Decimal, fractions.Fraction)):
2825 try:
2826 return fractions.Fraction(value)
2827 except (ValueError, TypeError):
2828 raise ParsingError(
2829 "Can't parse `%r` as `fraction`",
2830 value,
2831 ctx=ctx,
2832 fallback_msg="Can't parse value as `fraction`",
2833 ) from None
2834 except ZeroDivisionError:
2835 raise ParsingError(
2836 "Can't parse `%r` as `fraction`, division by zero",
2837 value,
2838 ctx=ctx,
2839 fallback_msg="Can't parse value as `fraction`",
2840 ) from None
2841 raise ParsingError.type_mismatch(
2842 value, int, float, str, "a tuple of two ints", ctx=ctx
2843 )
2845 def to_json_schema(
2846 self, ctx: yuio.json_schema.JsonSchemaContext, /
2847 ) -> yuio.json_schema.JsonSchemaType:
2848 return ctx.add_type(
2849 fractions.Fraction,
2850 "Fraction",
2851 lambda: yuio.json_schema.Meta(
2852 yuio.json_schema.OneOf(
2853 [
2854 yuio.json_schema.Number(),
2855 yuio.json_schema.String(
2856 pattern=r"(?i)^[+-]?(\d+(\/\d+)?|(\d+\.\d*|\.?\d+)(e[+-]?\d+)?|inf(inity)?|nan)$"
2857 ),
2858 yuio.json_schema.Tuple(
2859 [yuio.json_schema.Number(), yuio.json_schema.Number()]
2860 ),
2861 ]
2862 ),
2863 title="Fraction",
2864 description="A rational number.",
2865 ),
2866 )
2868 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2869 assert self.assert_type(value)
2870 return str(value)
2873class Json(WrappingParser[T, Parser[T]], ValueParser[T], _t.Generic[T]):
2874 """Json(inner: Parser[T] | None = None, /)
2876 A parser that tries to parse value as JSON.
2878 This parser will load JSON strings into python objects.
2879 If `inner` parser is given, :class:`Json` will validate parsing results
2880 by calling :meth:`~Parser.parse_config_with_ctx` on the inner parser.
2882 :param inner:
2883 a parser used to convert and validate contents of json.
2885 """
2887 if TYPE_CHECKING:
2889 @_t.overload
2890 def __new__(cls, inner: Parser[T], /) -> Json[T]: ...
2892 @_t.overload
2893 def __new__(cls, /) -> Json[yuio.json_schema.JsonValue]: ...
2895 def __new__(cls, inner: Parser[T] | None = None, /) -> Json[_t.Any]: ...
2897 def __init__(
2898 self,
2899 inner: Parser[T] | None = None,
2900 /,
2901 ):
2902 super().__init__(inner, object)
2904 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
2905 result = _copy(self)
2906 result._inner = parser
2907 return result
2909 def parse_with_ctx(self, ctx: StrParsingContext, /) -> T:
2910 ctx = ctx.strip_if_non_space()
2911 try:
2912 config_value: JsonValue = json.loads(ctx.value)
2913 except json.JSONDecodeError as e:
2914 raise ParsingError(
2915 "Can't parse `%r` as `JsonValue`:\n%s",
2916 ctx.value,
2917 yuio.string.Indent(e),
2918 ctx=ctx,
2919 fallback_msg="Can't parse value as `JsonValue`",
2920 ) from None
2921 try:
2922 return self.parse_config_with_ctx(ConfigParsingContext(config_value))
2923 except ParsingError as e:
2924 raise ParsingError(
2925 "Error in parsed json value:\n%s",
2926 yuio.string.Indent(e),
2927 ctx=ctx,
2928 fallback_msg="Error in parsed json value",
2929 ) from None
2931 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> T:
2932 if self._inner_raw is not None:
2933 return self._inner_raw.parse_config_with_ctx(ctx)
2934 else:
2935 return _t.cast(T, ctx.value)
2937 def check_type(self, value: object, /) -> _t.TypeGuard[T]:
2938 return True
2940 def to_json_schema(
2941 self, ctx: yuio.json_schema.JsonSchemaContext, /
2942 ) -> yuio.json_schema.JsonSchemaType:
2943 if self._inner_raw is not None:
2944 return self._inner_raw.to_json_schema(ctx)
2945 else:
2946 return yuio.json_schema.Any()
2948 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2949 assert self.assert_type(value)
2950 if self._inner_raw is not None:
2951 return self._inner_raw.to_json_value(value)
2952 return value # type: ignore
2954 def __repr__(self):
2955 if self._inner_raw is not None:
2956 return f"{self.__class__.__name__}({self._inner_raw!r})"
2957 else:
2958 return super().__repr__()
2961class DateTime(ValueParser[datetime.datetime]):
2962 """
2963 Parse a datetime in ISO ('YYYY-MM-DD HH:MM:SS') format.
2965 """
2967 def __init__(self):
2968 super().__init__(datetime.datetime)
2970 def parse_with_ctx(self, ctx: StrParsingContext, /) -> datetime.datetime:
2971 ctx = ctx.strip_if_non_space()
2972 return self._parse(ctx.value, ctx)
2974 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> datetime.datetime:
2975 value = ctx.value
2976 if isinstance(value, datetime.datetime):
2977 return value
2978 elif isinstance(value, str):
2979 return self._parse(value, ctx)
2980 else:
2981 raise ParsingError.type_mismatch(value, str, ctx=ctx)
2983 @staticmethod
2984 def _parse(value: str, ctx: ConfigParsingContext | StrParsingContext):
2985 try:
2986 return datetime.datetime.fromisoformat(value)
2987 except ValueError:
2988 raise ParsingError(
2989 "Can't parse `%r` as `datetime`",
2990 value,
2991 ctx=ctx,
2992 fallback_msg="Can't parse value as `datetime`",
2993 ) from None
2995 def describe(self) -> str | None:
2996 return "YYYY-MM-DD[ HH:MM:SS]"
2998 def to_json_schema(
2999 self, ctx: yuio.json_schema.JsonSchemaContext, /
3000 ) -> yuio.json_schema.JsonSchemaType:
3001 return ctx.add_type(
3002 datetime.datetime,
3003 "DateTime",
3004 lambda: yuio.json_schema.Meta(
3005 yuio.json_schema.String(
3006 pattern=(
3007 r"^"
3008 r"("
3009 r"\d{4}-W\d{2}(-\d)?"
3010 r"|\d{4}-\d{2}-\d{2}"
3011 r"|\d{4}W\d{2}\d?"
3012 r"|\d{4}\d{2}\d{2}"
3013 r")"
3014 r"("
3015 r"[T ]"
3016 r"\d{2}(:\d{2}(:\d{2}(.\d{3}(\d{3})?)?)?)?"
3017 r"([+-]\d{2}(:\d{2}(:\d{2}(.\d{3}(\d{3})?)?)?)?|Z)?"
3018 r")?"
3019 r"$"
3020 )
3021 ),
3022 title="DateTime",
3023 description="ISO 8601 datetime.",
3024 ),
3025 )
3027 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3028 assert self.assert_type(value)
3029 return str(value)
3032class Date(ValueParser[datetime.date]):
3033 """
3034 Parse a date in ISO ('YYYY-MM-DD') format.
3036 """
3038 def __init__(self):
3039 super().__init__(datetime.date)
3041 def parse_with_ctx(self, ctx: StrParsingContext, /) -> datetime.date:
3042 ctx = ctx.strip_if_non_space()
3043 return self._parse(ctx.value, ctx)
3045 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> datetime.date:
3046 value = ctx.value
3047 if isinstance(value, datetime.datetime):
3048 return value.date()
3049 elif isinstance(value, datetime.date):
3050 return value
3051 elif isinstance(value, str):
3052 return self._parse(value, ctx)
3053 else:
3054 raise ParsingError.type_mismatch(value, str, ctx=ctx)
3056 @staticmethod
3057 def _parse(value: str, ctx: ConfigParsingContext | StrParsingContext):
3058 try:
3059 return datetime.date.fromisoformat(value)
3060 except ValueError:
3061 raise ParsingError(
3062 "Can't parse `%r` as `date`",
3063 value,
3064 ctx=ctx,
3065 fallback_msg="Can't parse value as `date`",
3066 ) from None
3068 def describe(self) -> str | None:
3069 return "YYYY-MM-DD"
3071 def to_json_schema(
3072 self, ctx: yuio.json_schema.JsonSchemaContext, /
3073 ) -> yuio.json_schema.JsonSchemaType:
3074 return ctx.add_type(
3075 datetime.date,
3076 "Date",
3077 lambda: yuio.json_schema.Meta(
3078 yuio.json_schema.String(
3079 pattern=(
3080 r"^"
3081 r"("
3082 r"\d{4}-W\d{2}(-\d)?"
3083 r"|\d{4}-\d{2}-\d{2}"
3084 r"|\d{4}W\d{2}\d?"
3085 r"|\d{4}\d{2}\d{2}"
3086 r")"
3087 r"$"
3088 )
3089 ),
3090 title="Date",
3091 description="ISO 8601 date.",
3092 ),
3093 )
3095 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3096 assert self.assert_type(value)
3097 return str(value)
3100class Time(ValueParser[datetime.time]):
3101 """
3102 Parse a time in ISO ('HH:MM:SS') format.
3104 """
3106 def __init__(self):
3107 super().__init__(datetime.time)
3109 def parse_with_ctx(self, ctx: StrParsingContext, /) -> datetime.time:
3110 ctx = ctx.strip_if_non_space()
3111 return self._parse(ctx.value, ctx)
3113 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> datetime.time:
3114 value = ctx.value
3115 if isinstance(value, datetime.datetime):
3116 return value.time()
3117 elif isinstance(value, datetime.time):
3118 return value
3119 elif isinstance(value, str):
3120 return self._parse(value, ctx)
3121 else:
3122 raise ParsingError.type_mismatch(value, str, ctx=ctx)
3124 @staticmethod
3125 def _parse(value: str, ctx: ConfigParsingContext | StrParsingContext):
3126 try:
3127 return datetime.time.fromisoformat(value)
3128 except ValueError:
3129 raise ParsingError(
3130 "Can't parse `%r` as `time`",
3131 value,
3132 ctx=ctx,
3133 fallback_msg="Can't parse value as `time`",
3134 ) from None
3136 def describe(self) -> str | None:
3137 return "HH:MM:SS"
3139 def to_json_schema(
3140 self, ctx: yuio.json_schema.JsonSchemaContext, /
3141 ) -> yuio.json_schema.JsonSchemaType:
3142 return ctx.add_type(
3143 datetime.time,
3144 "Time",
3145 lambda: yuio.json_schema.Meta(
3146 yuio.json_schema.String(
3147 pattern=(
3148 r"^"
3149 r"\d{2}(:\d{2}(:\d{2}(.\d{3}(\d{3})?)?)?)?"
3150 r"([+-]\d{2}(:\d{2}(:\d{2}(.\d{3}(\d{3})?)?)?)?|Z)?"
3151 r"$"
3152 )
3153 ),
3154 title="Time",
3155 description="ISO 8601 time.",
3156 ),
3157 )
3159 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3160 assert self.assert_type(value)
3161 return str(value)
3164_UNITS_MAP = (
3165 ("days", ("d", "day", "days")),
3166 ("seconds", ("s", "sec", "secs", "second", "seconds")),
3167 ("microseconds", ("us", "u", "micro", "micros", "microsecond", "microseconds")),
3168 ("milliseconds", ("ms", "l", "milli", "millis", "millisecond", "milliseconds")),
3169 ("minutes", ("m", "min", "mins", "minute", "minutes")),
3170 ("hours", ("h", "hr", "hrs", "hour", "hours")),
3171 ("weeks", ("w", "week", "weeks")),
3172)
3174_UNITS = {unit: name for name, units in _UNITS_MAP for unit in units}
3176_TIMEDELTA_RE = re.compile(
3177 r"""
3178 # General format: -1 day, -01:00:00.000000
3179 ^
3180 (?:([+-]?)\s*((?:\d+\s*[a-z]+\s*)+))?
3181 (?:,\s*)?
3182 (?:([+-]?)\s*(\d+):(\d?\d)(?::(\d?\d)(?:\.(?:(\d\d\d)(\d\d\d)?))?)?)?
3183 $
3184 """,
3185 re.VERBOSE | re.IGNORECASE,
3186)
3188_COMPONENT_RE = re.compile(r"(\d+)\s*([a-z]+)\s*")
3191class TimeDelta(ValueParser[datetime.timedelta]):
3192 """
3193 Parse a time delta.
3195 """
3197 def __init__(self):
3198 super().__init__(datetime.timedelta)
3200 def parse_with_ctx(self, ctx: StrParsingContext, /) -> datetime.timedelta:
3201 ctx = ctx.strip_if_non_space()
3202 return self._parse(ctx.value, ctx)
3204 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> datetime.timedelta:
3205 value = ctx.value
3206 if isinstance(value, datetime.timedelta):
3207 return value
3208 elif isinstance(value, str):
3209 return self._parse(value, ctx)
3210 else:
3211 raise ParsingError.type_mismatch(value, str, ctx=ctx)
3213 @staticmethod
3214 def _parse(value: str, ctx: ConfigParsingContext | StrParsingContext):
3215 value = value.strip()
3217 if not value:
3218 raise ParsingError("Got an empty `timedelta`", ctx=ctx)
3219 if value.endswith(","):
3220 raise ParsingError(
3221 "Can't parse `%r` as `timedelta`, trailing comma is not allowed",
3222 value,
3223 ctx=ctx,
3224 fallback_msg="Can't parse value as `timedelta`",
3225 )
3226 if value.startswith(","):
3227 raise ParsingError(
3228 "Can't parse `%r` as `timedelta`, leading comma is not allowed",
3229 value,
3230 ctx=ctx,
3231 fallback_msg="Can't parse value as `timedelta`",
3232 )
3234 if match := _TIMEDELTA_RE.match(value):
3235 (
3236 c_sign_s,
3237 components_s,
3238 t_sign_s,
3239 hour,
3240 minute,
3241 second,
3242 millisecond,
3243 microsecond,
3244 ) = match.groups()
3245 else:
3246 raise ParsingError(
3247 "Can't parse `%r` as `timedelta`",
3248 value,
3249 ctx=ctx,
3250 fallback_msg="Can't parse value as `timedelta`",
3251 )
3253 c_sign_s = -1 if c_sign_s == "-" else 1
3254 t_sign_s = -1 if t_sign_s == "-" else 1
3256 kwargs = {u: 0 for u, _ in _UNITS_MAP}
3258 if components_s:
3259 for num, unit in _COMPONENT_RE.findall(components_s):
3260 if unit_key := _UNITS.get(unit.lower()):
3261 kwargs[unit_key] += int(num)
3262 else:
3263 raise ParsingError(
3264 "Can't parse `%r` as `timedelta`, unknown unit `%r`",
3265 value,
3266 unit,
3267 ctx=ctx,
3268 fallback_msg="Can't parse value as `timedelta`",
3269 )
3271 timedelta = c_sign_s * datetime.timedelta(**kwargs)
3273 timedelta += t_sign_s * datetime.timedelta(
3274 hours=int(hour or "0"),
3275 minutes=int(minute or "0"),
3276 seconds=int(second or "0"),
3277 milliseconds=int(millisecond or "0"),
3278 microseconds=int(microsecond or "0"),
3279 )
3281 return timedelta
3283 def describe(self) -> str | None:
3284 return "[+|-]HH:MM:SS"
3286 def to_json_schema(
3287 self, ctx: yuio.json_schema.JsonSchemaContext, /
3288 ) -> yuio.json_schema.JsonSchemaType:
3289 return ctx.add_type(
3290 datetime.timedelta,
3291 "TimeDelta",
3292 lambda: yuio.json_schema.Meta(
3293 yuio.json_schema.String(
3294 # save yourself some trouble, paste this into https://regexper.com/
3295 pattern=(
3296 r"^(([+-]?\s*(\d+\s*(d|day|days|s|sec|secs|second|seconds"
3297 r"|us|u|micro|micros|microsecond|microseconds|ms|l|milli|"
3298 r"millis|millisecond|milliseconds|m|min|mins|minute|minutes"
3299 r"|h|hr|hrs|hour|hours|w|week|weeks)\s*)+)(,\s*)?"
3300 r"([+-]?\s*\d+:\d?\d(:\d?\d(\.\d\d\d(\d\d\d)?)?)?)"
3301 r"|([+-]?\s*\d+:\d?\d(:\d?\d(\.\d\d\d(\d\d\d)?)?)?)"
3302 r"|([+-]?\s*(\d+\s*(d|day|days|s|sec|secs|second|seconds"
3303 r"|us|u|micro|micros|microsecond|microseconds|ms|l|milli"
3304 r"|millis|millisecond|milliseconds|m|min|mins|minute|minutes"
3305 r"|h|hr|hrs|hour|hours|w|week|weeks)\s*)+))$"
3306 )
3307 ),
3308 title="Time delta. General format: '[+-] [M weeks] [N days] [+-]HH:MM:SS'",
3309 description=".",
3310 ),
3311 )
3313 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3314 assert self.assert_type(value)
3315 return str(value)
3318class Seconds(TimeDelta):
3319 """
3320 Parse a float and convert it to a time delta as a number of seconds.
3322 """
3324 @staticmethod
3325 def _parse(value: str, ctx: ConfigParsingContext | StrParsingContext):
3326 try:
3327 seconds = float(value)
3328 except ValueError:
3329 raise ParsingError(
3330 "Can't parse `%r` as `<seconds>`",
3331 ctx.value,
3332 ctx=ctx,
3333 fallback_msg="Can't parse value as `<seconds>`",
3334 ) from None
3335 return datetime.timedelta(seconds=seconds)
3337 def describe(self) -> str | None:
3338 return "<seconds>"
3340 def describe_or_def(self) -> str:
3341 return "<seconds>"
3343 def describe_many(self) -> str | tuple[str, ...]:
3344 return "<seconds>"
3346 def describe_value(self, value: object) -> str:
3347 assert self.assert_type(value)
3348 return str(value.total_seconds())
3350 def to_json_schema(
3351 self, ctx: yuio.json_schema.JsonSchemaContext, /
3352 ) -> yuio.json_schema.JsonSchemaType:
3353 return yuio.json_schema.Meta(yuio.json_schema.Number(), description="seconds")
3355 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3356 assert self.assert_type(value)
3357 return value.total_seconds()
3360class Path(ValueParser[pathlib.Path]):
3361 """
3362 Parse a file system path, return a :class:`pathlib.Path`.
3364 :param extensions:
3365 list of allowed file extensions, including preceding dots.
3367 """
3369 def __init__(
3370 self,
3371 /,
3372 *,
3373 extensions: str | _t.Collection[str] | None = None,
3374 ):
3375 self._extensions = [extensions] if isinstance(extensions, str) else extensions
3376 super().__init__(pathlib.Path)
3378 def parse_with_ctx(self, ctx: StrParsingContext, /) -> pathlib.Path:
3379 ctx = ctx.strip_if_non_space()
3380 return self._parse(ctx.value, ctx)
3382 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> pathlib.Path:
3383 value = ctx.value
3384 if not isinstance(value, str):
3385 raise ParsingError.type_mismatch(value, str, ctx=ctx)
3386 return self._parse(value, ctx)
3388 def _parse(self, value: str, ctx: ConfigParsingContext | StrParsingContext):
3389 res = pathlib.Path(value).expanduser().resolve().absolute()
3390 try:
3391 self._validate(res)
3392 except ParsingError as e:
3393 e.set_ctx(ctx)
3394 raise
3395 return res
3397 def describe(self) -> str | None:
3398 if self._extensions is not None:
3399 desc = "|".join(f"<*{e}>" for e in self._extensions)
3400 if len(self._extensions) > 1:
3401 desc = f"{{{desc}}}"
3402 return desc
3403 else:
3404 return super().describe()
3406 def _validate(self, value: pathlib.Path, /):
3407 if self._extensions is not None and not any(
3408 value.name.endswith(ext) for ext in self._extensions
3409 ):
3410 raise ParsingError(
3411 "<c path>%s</c> should have extension %s",
3412 value,
3413 yuio.string.Or(self._extensions),
3414 )
3416 def completer(self) -> yuio.complete.Completer | None:
3417 return yuio.complete.File(extensions=self._extensions)
3419 def to_json_schema(
3420 self, ctx: yuio.json_schema.JsonSchemaContext, /
3421 ) -> yuio.json_schema.JsonSchemaType:
3422 return yuio.json_schema.String()
3424 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3425 assert self.assert_type(value)
3426 return str(value)
3429class NonExistentPath(Path):
3430 """
3431 Parse a file system path and verify that it doesn't exist.
3433 :param extensions:
3434 list of allowed file extensions, including preceding dots.
3436 """
3438 def _validate(self, value: pathlib.Path, /):
3439 super()._validate(value)
3441 if value.exists():
3442 raise ParsingError("<c path>%s</c> already exists", value)
3445class ExistingPath(Path):
3446 """
3447 Parse a file system path and verify that it exists.
3449 :param extensions:
3450 list of allowed file extensions, including preceding dots.
3452 """
3454 def _validate(self, value: pathlib.Path, /):
3455 super()._validate(value)
3457 if not value.exists():
3458 raise ParsingError("<c path>%s</c> doesn't exist", value)
3461class File(ExistingPath):
3462 """
3463 Parse a file system path and verify that it points to a regular file.
3465 :param extensions:
3466 list of allowed file extensions, including preceding dots.
3468 """
3470 def _validate(self, value: pathlib.Path, /):
3471 super()._validate(value)
3473 if not value.is_file():
3474 raise ParsingError("<c path>%s</c> is not a file", value)
3477class Dir(ExistingPath):
3478 """
3479 Parse a file system path and verify that it points to a directory.
3481 """
3483 def __init__(self):
3484 # Disallow passing `extensions`.
3485 super().__init__()
3487 def _validate(self, value: pathlib.Path, /):
3488 super()._validate(value)
3490 if not value.is_dir():
3491 raise ParsingError("<c path>%s</c> is not a directory", value)
3493 def completer(self) -> yuio.complete.Completer | None:
3494 return yuio.complete.Dir()
3497class GitRepo(Dir):
3498 """
3499 Parse a file system path and verify that it points to a git repository.
3501 This parser just checks that the given directory has
3502 a subdirectory named ``.git``.
3504 """
3506 def _validate(self, value: pathlib.Path, /):
3507 super()._validate(value)
3509 if not value.joinpath(".git").is_dir():
3510 raise ParsingError("<c path>%s</c> is not a git repository root", value)
3513class Secret(Map[SecretValue[T], T], _t.Generic[T]):
3514 """Secret(inner: Parser[U], /)
3516 Wraps result of the inner parser into :class:`~yuio.secret.SecretValue`
3517 and ensures that :func:`yuio.io.ask` doesn't show value as user enters it.
3519 """
3521 if TYPE_CHECKING:
3523 @_t.overload
3524 def __new__(cls, inner: Parser[T], /) -> Secret[T]: ...
3526 @_t.overload
3527 def __new__(cls, /) -> PartialParser: ...
3529 def __new__(cls, *args, **kwargs) -> _t.Any: ...
3531 def __init__(self, inner: Parser[U] | None = None, /):
3532 super().__init__(inner, SecretValue, lambda x: x.data)
3534 def parse_with_ctx(self, ctx: StrParsingContext, /) -> SecretValue[T]:
3535 with self._replace_error():
3536 return super().parse_with_ctx(ctx)
3538 def parse_many_with_ctx(
3539 self, ctxs: _t.Sequence[StrParsingContext], /
3540 ) -> SecretValue[T]:
3541 with self._replace_error():
3542 return super().parse_many_with_ctx(ctxs)
3544 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> SecretValue[T]:
3545 with self._replace_error():
3546 return super().parse_config_with_ctx(ctx)
3548 @staticmethod
3549 @contextlib.contextmanager
3550 def _replace_error():
3551 try:
3552 yield
3553 except ParsingError as e:
3554 # Error messages can contain secret value, hide them.
3555 raise ParsingError(
3556 yuio.string.Printable(
3557 e.fallback_msg or "Error when parsing secret data"
3558 ),
3559 pos=e.pos,
3560 path=e.path,
3561 n_arg=e.n_arg,
3562 # Omit raw value.
3563 ) from None
3565 def describe_value(self, value: object, /) -> str:
3566 return "***"
3568 def completer(self) -> yuio.complete.Completer | None:
3569 return None
3571 def options(self) -> _t.Collection[yuio.widget.Option[SecretValue[T]]] | None:
3572 return None
3574 def widget(
3575 self,
3576 default: object | yuio.Missing,
3577 input_description: str | None,
3578 default_description: str | None,
3579 /,
3580 ) -> yuio.widget.Widget[SecretValue[T] | yuio.Missing]:
3581 return _secret_widget(self, default, input_description, default_description)
3583 def is_secret(self) -> bool:
3584 return True
3587class CollectionParser(
3588 WrappingParser[C, Parser[T]], ValueParser[C], PartialParser, _t.Generic[C, T]
3589):
3590 """CollectionParser(inner: Parser[T] | None, /, **kwargs)
3592 A base class for implementing collection parsing. It will split a string
3593 by the given delimiter, parse each item using a subparser, and then pass
3594 the result to the given constructor.
3596 :param inner:
3597 parser that will be used to parse collection items.
3598 :param ty:
3599 type of the collection that this parser returns.
3600 :param ctor:
3601 factory of instances of the collection that this parser returns.
3602 It should take an iterable of parsed items, and return a collection.
3603 :param iter:
3604 a function that is used to get an iterator from a collection.
3605 This defaults to :func:`iter`, but sometimes it may be different.
3606 For example, :class:`Dict` is implemented as a collection of pairs,
3607 and its `iter` is :meth:`dict.items`.
3608 :param config_type:
3609 type of a collection that we expect to find when parsing a config.
3610 This will usually be a list.
3611 :param config_type_iter:
3612 a function that is used to get an iterator from a config value.
3613 :param delimiter:
3614 delimiter that will be passed to :py:meth:`str.split`.
3616 The above parameters are exposed via protected attributes:
3617 ``self._inner``, ``self._ty``, etc.
3619 For example, let's implement a :class:`list` parser
3620 that repeats each element twice:
3622 .. code-block:: python
3624 from typing import Iterable, Generic
3627 class DoubleList(CollectionParser[list[T], T], Generic[T]):
3628 def __init__(self, inner: Parser[T], /, *, delimiter: str | None = None):
3629 super().__init__(inner, ty=list, ctor=self._ctor, delimiter=delimiter)
3631 @staticmethod
3632 def _ctor(values: Iterable[T]) -> list[T]:
3633 return [x for value in values for x in [value, value]]
3635 def to_json_schema(
3636 self, ctx: yuio.json_schema.JsonSchemaContext, /
3637 ) -> yuio.json_schema.JsonSchemaType:
3638 return {"type": "array", "items": self._inner.to_json_schema(ctx)}
3640 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3641 assert self.assert_type(value)
3642 return [self._inner.to_json_value(item) for item in value[::2]]
3644 ::
3646 >>> parser = DoubleList(Int())
3647 >>> parser.parse("1 2 3")
3648 [1, 1, 2, 2, 3, 3]
3649 >>> parser.to_json_value([1, 1, 2, 2, 3, 3])
3650 [1, 2, 3]
3652 """
3654 _allow_completing_duplicates: typing.ClassVar[bool] = True
3655 """
3656 If set to :data:`False`, autocompletion will not suggest item duplicates.
3658 """
3660 def __init__(
3661 self,
3662 inner: Parser[T] | None,
3663 /,
3664 *,
3665 ty: type[C],
3666 ctor: _t.Callable[[_t.Iterable[T]], C],
3667 iter: _t.Callable[[C], _t.Iterable[T]] = iter,
3668 config_type: type[C2] | tuple[type[C2], ...] = list,
3669 config_type_iter: _t.Callable[[C2], _t.Iterable[T]] = iter,
3670 delimiter: str | None = None,
3671 ):
3672 if delimiter == "":
3673 raise ValueError("empty delimiter")
3675 #: See class parameters for more details.
3676 self._ty = ty
3677 self._ctor = ctor
3678 self._iter = iter
3679 self._config_type = config_type
3680 self._config_type_iter = config_type_iter
3681 self._delimiter = delimiter
3683 super().__init__(inner, ty)
3685 def wrap(self: P, parser: Parser[_t.Any]) -> P:
3686 result = super().wrap(parser)
3687 result._inner = parser._inner # type: ignore
3688 return result
3690 def parse_with_ctx(self, ctx: StrParsingContext, /) -> C:
3691 return self._ctor(
3692 self._inner.parse_with_ctx(item) for item in ctx.split(self._delimiter)
3693 )
3695 def parse_many_with_ctx(self, ctxs: _t.Sequence[StrParsingContext], /) -> C:
3696 return self._ctor(self._inner.parse_with_ctx(item) for item in ctxs)
3698 def supports_parse_many(self) -> bool:
3699 return True
3701 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> C:
3702 value = ctx.value
3703 if not isinstance(value, self._config_type):
3704 expected = self._config_type
3705 if not isinstance(expected, tuple):
3706 expected = (expected,)
3707 raise ParsingError.type_mismatch(value, *expected, ctx=ctx)
3709 return self._ctor(
3710 self._inner.parse_config_with_ctx(ctx.descend(item, i))
3711 for i, item in enumerate(self._config_type_iter(value))
3712 )
3714 def get_nargs(self) -> _t.Literal["+", "*"] | int:
3715 return "*"
3717 def describe(self) -> str | None:
3718 delimiter = self._delimiter or " "
3719 value = self._inner.describe_or_def()
3721 return f"{value}[{delimiter}{value}[{delimiter}...]]"
3723 def describe_many(self) -> str | tuple[str, ...]:
3724 return self._inner.describe_or_def()
3726 def describe_value(self, value: object, /) -> str:
3727 assert self.assert_type(value)
3729 return (self._delimiter or " ").join(
3730 self._inner.describe_value(item) for item in self._iter(value)
3731 )
3733 def options(self) -> _t.Collection[yuio.widget.Option[C]] | None:
3734 return None
3736 def completer(self) -> yuio.complete.Completer | None:
3737 completer = self._inner.completer()
3738 return (
3739 yuio.complete.List(
3740 completer,
3741 delimiter=self._delimiter,
3742 allow_duplicates=self._allow_completing_duplicates,
3743 )
3744 if completer is not None
3745 else None
3746 )
3748 def widget(
3749 self,
3750 default: object | yuio.Missing,
3751 input_description: str | None,
3752 default_description: str | None,
3753 /,
3754 ) -> yuio.widget.Widget[C | yuio.Missing]:
3755 completer = self.completer()
3756 return _WidgetResultMapper(
3757 self,
3758 input_description,
3759 default,
3760 (
3761 yuio.widget.InputWithCompletion(
3762 completer,
3763 placeholder=default_description or "",
3764 )
3765 if completer is not None
3766 else yuio.widget.Input(
3767 placeholder=default_description or "",
3768 )
3769 ),
3770 )
3772 def is_secret(self) -> bool:
3773 return self._inner.is_secret()
3775 def __repr__(self):
3776 if self._inner_raw is not None:
3777 return f"{self.__class__.__name__}({self._inner_raw!r})"
3778 else:
3779 return self.__class__.__name__
3782class List(CollectionParser[list[T], T], _t.Generic[T]):
3783 """List(inner: Parser[T], /, *, delimiter: str | None = None)
3785 Parser for lists.
3787 Will split a string by the given delimiter, and parse each item
3788 using a subparser.
3790 :param inner:
3791 inner parser that will be used to parse list items.
3792 :param delimiter:
3793 delimiter that will be passed to :py:meth:`str.split`.
3795 """
3797 if TYPE_CHECKING:
3799 @_t.overload
3800 def __new__(
3801 cls, inner: Parser[T], /, *, delimiter: str | None = None
3802 ) -> List[T]: ...
3804 @_t.overload
3805 def __new__(cls, /, *, delimiter: str | None = None) -> PartialParser: ...
3807 def __new__(cls, *args, **kwargs) -> _t.Any: ...
3809 def __init__(
3810 self,
3811 inner: Parser[T] | None = None,
3812 /,
3813 *,
3814 delimiter: str | None = None,
3815 ):
3816 super().__init__(inner, ty=list, ctor=list, delimiter=delimiter)
3818 def to_json_schema(
3819 self, ctx: yuio.json_schema.JsonSchemaContext, /
3820 ) -> yuio.json_schema.JsonSchemaType:
3821 return yuio.json_schema.Array(self._inner.to_json_schema(ctx))
3823 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3824 assert self.assert_type(value)
3825 return [self._inner.to_json_value(item) for item in value]
3828class Set(CollectionParser[set[T], T], _t.Generic[T]):
3829 """Set(inner: Parser[T], /, *, delimiter: str | None = None)
3831 Parser for sets.
3833 Will split a string by the given delimiter, and parse each item
3834 using a subparser.
3836 :param inner:
3837 inner parser that will be used to parse set items.
3838 :param delimiter:
3839 delimiter that will be passed to :py:meth:`str.split`.
3841 """
3843 if TYPE_CHECKING:
3845 @_t.overload
3846 def __new__(
3847 cls, inner: Parser[T], /, *, delimiter: str | None = None
3848 ) -> Set[T]: ...
3850 @_t.overload
3851 def __new__(cls, /, *, delimiter: str | None = None) -> PartialParser: ...
3853 def __new__(cls, *args, **kwargs) -> _t.Any: ...
3855 _allow_completing_duplicates = False
3857 def __init__(
3858 self,
3859 inner: Parser[T] | None = None,
3860 /,
3861 *,
3862 delimiter: str | None = None,
3863 ):
3864 super().__init__(inner, ty=set, ctor=set, delimiter=delimiter)
3866 def widget(
3867 self,
3868 default: object | yuio.Missing,
3869 input_description: str | None,
3870 default_description: str | None,
3871 /,
3872 ) -> yuio.widget.Widget[set[T] | yuio.Missing]:
3873 options = self._inner.options()
3874 if options and len(options) <= 25:
3875 return yuio.widget.Map(yuio.widget.Multiselect(list(options)), set)
3876 else:
3877 return super().widget(default, input_description, default_description)
3879 def to_json_schema(
3880 self, ctx: yuio.json_schema.JsonSchemaContext, /
3881 ) -> yuio.json_schema.JsonSchemaType:
3882 return yuio.json_schema.Array(
3883 self._inner.to_json_schema(ctx), unique_items=True
3884 )
3886 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3887 assert self.assert_type(value)
3888 return [self._inner.to_json_value(item) for item in value]
3891class FrozenSet(CollectionParser[frozenset[T], T], _t.Generic[T]):
3892 """FrozenSet(inner: Parser[T], /, *, delimiter: str | None = None)
3894 Parser for frozen sets.
3896 Will split a string by the given delimiter, and parse each item
3897 using a subparser.
3899 :param inner:
3900 inner parser that will be used to parse set items.
3901 :param delimiter:
3902 delimiter that will be passed to :py:meth:`str.split`.
3904 """
3906 if TYPE_CHECKING:
3908 @_t.overload
3909 def __new__(
3910 cls, inner: Parser[T], /, *, delimiter: str | None = None
3911 ) -> FrozenSet[T]: ...
3913 @_t.overload
3914 def __new__(cls, /, *, delimiter: str | None = None) -> PartialParser: ...
3916 def __new__(cls, *args, **kwargs) -> _t.Any: ...
3918 _allow_completing_duplicates = False
3920 def __init__(
3921 self,
3922 inner: Parser[T] | None = None,
3923 /,
3924 *,
3925 delimiter: str | None = None,
3926 ):
3927 super().__init__(inner, ty=frozenset, ctor=frozenset, delimiter=delimiter)
3929 def to_json_schema(
3930 self, ctx: yuio.json_schema.JsonSchemaContext, /
3931 ) -> yuio.json_schema.JsonSchemaType:
3932 return yuio.json_schema.Array(
3933 self._inner.to_json_schema(ctx), unique_items=True
3934 )
3936 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3937 assert self.assert_type(value)
3938 return [self._inner.to_json_value(item) for item in value]
3941class Dict(CollectionParser[dict[K, V], tuple[K, V]], _t.Generic[K, V]):
3942 """Dict(key: Parser[K], value: Parser[V], /, *, delimiter: str | None = None, pair_delimiter: str = ":")
3944 Parser for dicts.
3946 Will split a string by the given delimiter, and parse each item
3947 using a :py:class:`Tuple` parser.
3949 :param key:
3950 inner parser that will be used to parse dict keys.
3951 :param value:
3952 inner parser that will be used to parse dict values.
3953 :param delimiter:
3954 delimiter that will be passed to :py:meth:`str.split`.
3955 :param pair_delimiter:
3956 delimiter that will be used to split key-value elements.
3958 """
3960 if TYPE_CHECKING:
3962 @_t.overload
3963 def __new__(
3964 cls,
3965 key: Parser[K],
3966 value: Parser[V],
3967 /,
3968 *,
3969 delimiter: str | None = None,
3970 pair_delimiter: str = ":",
3971 ) -> Dict[K, V]: ...
3973 @_t.overload
3974 def __new__(
3975 cls,
3976 /,
3977 *,
3978 delimiter: str | None = None,
3979 pair_delimiter: str = ":",
3980 ) -> PartialParser: ...
3982 def __new__(cls, *args, **kwargs) -> _t.Any: ...
3984 _allow_completing_duplicates = False
3986 def __init__(
3987 self,
3988 key: Parser[K] | None = None,
3989 value: Parser[V] | None = None,
3990 /,
3991 *,
3992 delimiter: str | None = None,
3993 pair_delimiter: str = ":",
3994 ):
3995 self._pair_delimiter = pair_delimiter
3996 super().__init__(
3997 (
3998 _DictElementParser(key, value, delimiter=pair_delimiter)
3999 if key and value
4000 else None
4001 ),
4002 ty=dict,
4003 ctor=dict,
4004 iter=dict.items,
4005 config_type=(dict, list),
4006 config_type_iter=self.__config_type_iter,
4007 delimiter=delimiter,
4008 )
4010 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
4011 result = super().wrap(parser)
4012 result._inner._delimiter = self._pair_delimiter # pyright: ignore[reportAttributeAccessIssue]
4013 return result
4015 @staticmethod
4016 def __config_type_iter(x) -> _t.Iterator[tuple[K, V]]:
4017 if isinstance(x, dict):
4018 return iter(x.items())
4019 else:
4020 return iter(x)
4022 def to_json_schema(
4023 self, ctx: yuio.json_schema.JsonSchemaContext, /
4024 ) -> yuio.json_schema.JsonSchemaType:
4025 key_schema = self._inner._inner[0].to_json_schema(ctx) # type: ignore
4026 value_schema = self._inner._inner[1].to_json_schema(ctx) # type: ignore
4027 return yuio.json_schema.Dict(key_schema, value_schema)
4029 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
4030 assert self.assert_type(value)
4031 items = _t.cast(
4032 list[tuple[yuio.json_schema.JsonValue, yuio.json_schema.JsonValue]],
4033 [self._inner.to_json_value(item) for item in value.items()],
4034 )
4036 if all(isinstance(k, str) for k, _ in items):
4037 return dict(_t.cast(list[tuple[str, yuio.json_schema.JsonValue]], items))
4038 else:
4039 return items
4042class Tuple(
4043 WrappingParser[TU, tuple[Parser[object], ...]],
4044 ValueParser[TU],
4045 PartialParser,
4046 _t.Generic[TU],
4047):
4048 """Tuple(*parsers: Parser[...], delimiter: str | None = None)
4050 Parser for tuples of fixed lengths.
4052 :param parsers:
4053 parsers for each tuple element.
4054 :param delimiter:
4055 delimiter that will be passed to :py:meth:`str.split`.
4057 """
4059 # See the links below for an explanation of shy this is so ugly:
4060 # https://github.com/python/typing/discussions/1450
4061 # https://github.com/python/typing/issues/1216
4062 if TYPE_CHECKING:
4063 T1 = _t.TypeVar("T1")
4064 T2 = _t.TypeVar("T2")
4065 T3 = _t.TypeVar("T3")
4066 T4 = _t.TypeVar("T4")
4067 T5 = _t.TypeVar("T5")
4068 T6 = _t.TypeVar("T6")
4069 T7 = _t.TypeVar("T7")
4070 T8 = _t.TypeVar("T8")
4071 T9 = _t.TypeVar("T9")
4072 T10 = _t.TypeVar("T10")
4074 @_t.overload
4075 def __new__(
4076 cls,
4077 /,
4078 *,
4079 delimiter: str | None = None,
4080 ) -> PartialParser: ...
4082 @_t.overload
4083 def __new__(
4084 cls,
4085 p1: Parser[T1],
4086 /,
4087 *,
4088 delimiter: str | None = None,
4089 ) -> Tuple[tuple[T1]]: ...
4091 @_t.overload
4092 def __new__(
4093 cls,
4094 p1: Parser[T1],
4095 p2: Parser[T2],
4096 /,
4097 *,
4098 delimiter: str | None = None,
4099 ) -> Tuple[tuple[T1, T2]]: ...
4101 @_t.overload
4102 def __new__(
4103 cls,
4104 p1: Parser[T1],
4105 p2: Parser[T2],
4106 p3: Parser[T3],
4107 /,
4108 *,
4109 delimiter: str | None = None,
4110 ) -> Tuple[tuple[T1, T2, T3]]: ...
4112 @_t.overload
4113 def __new__(
4114 cls,
4115 p1: Parser[T1],
4116 p2: Parser[T2],
4117 p3: Parser[T3],
4118 p4: Parser[T4],
4119 /,
4120 *,
4121 delimiter: str | None = None,
4122 ) -> Tuple[tuple[T1, T2, T3, T4]]: ...
4124 @_t.overload
4125 def __new__(
4126 cls,
4127 p1: Parser[T1],
4128 p2: Parser[T2],
4129 p3: Parser[T3],
4130 p4: Parser[T4],
4131 p5: Parser[T5],
4132 /,
4133 *,
4134 delimiter: str | None = None,
4135 ) -> Tuple[tuple[T1, T2, T3, T4, T5]]: ...
4137 @_t.overload
4138 def __new__(
4139 cls,
4140 p1: Parser[T1],
4141 p2: Parser[T2],
4142 p3: Parser[T3],
4143 p4: Parser[T4],
4144 p5: Parser[T5],
4145 p6: Parser[T6],
4146 /,
4147 *,
4148 delimiter: str | None = None,
4149 ) -> Tuple[tuple[T1, T2, T3, T4, T5, T6]]: ...
4151 @_t.overload
4152 def __new__(
4153 cls,
4154 p1: Parser[T1],
4155 p2: Parser[T2],
4156 p3: Parser[T3],
4157 p4: Parser[T4],
4158 p5: Parser[T5],
4159 p6: Parser[T6],
4160 p7: Parser[T7],
4161 /,
4162 *,
4163 delimiter: str | None = None,
4164 ) -> Tuple[tuple[T1, T2, T3, T4, T5, T6, T7]]: ...
4166 @_t.overload
4167 def __new__(
4168 cls,
4169 p1: Parser[T1],
4170 p2: Parser[T2],
4171 p3: Parser[T3],
4172 p4: Parser[T4],
4173 p5: Parser[T5],
4174 p6: Parser[T6],
4175 p7: Parser[T7],
4176 p8: Parser[T8],
4177 /,
4178 *,
4179 delimiter: str | None = None,
4180 ) -> Tuple[tuple[T1, T2, T3, T4, T5, T6, T7, T8]]: ...
4182 @_t.overload
4183 def __new__(
4184 cls,
4185 p1: Parser[T1],
4186 p2: Parser[T2],
4187 p3: Parser[T3],
4188 p4: Parser[T4],
4189 p5: Parser[T5],
4190 p6: Parser[T6],
4191 p7: Parser[T7],
4192 p8: Parser[T8],
4193 p9: Parser[T9],
4194 /,
4195 *,
4196 delimiter: str | None = None,
4197 ) -> Tuple[tuple[T1, T2, T3, T4, T5, T6, T7, T8, T9]]: ...
4199 @_t.overload
4200 def __new__(
4201 cls,
4202 p1: Parser[T1],
4203 p2: Parser[T2],
4204 p3: Parser[T3],
4205 p4: Parser[T4],
4206 p5: Parser[T5],
4207 p6: Parser[T6],
4208 p7: Parser[T7],
4209 p8: Parser[T8],
4210 p9: Parser[T9],
4211 p10: Parser[T10],
4212 /,
4213 *,
4214 delimiter: str | None = None,
4215 ) -> Tuple[tuple[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10]]: ...
4217 @_t.overload
4218 def __new__(
4219 cls,
4220 p1: Parser[T1],
4221 p2: Parser[T2],
4222 p3: Parser[T3],
4223 p4: Parser[T4],
4224 p5: Parser[T5],
4225 p6: Parser[T6],
4226 p7: Parser[T7],
4227 p8: Parser[T8],
4228 p9: Parser[T9],
4229 p10: Parser[T10],
4230 p11: Parser[object],
4231 *tail: Parser[object],
4232 delimiter: str | None = None,
4233 ) -> Tuple[tuple[_t.Any, ...]]: ...
4235 def __new__(cls, *args, **kwargs) -> _t.Any: ...
4237 def __init__(
4238 self,
4239 *parsers: Parser[_t.Any],
4240 delimiter: str | None = None,
4241 ):
4242 if delimiter == "":
4243 raise ValueError("empty delimiter")
4244 self._delimiter = delimiter
4245 super().__init__(parsers or None, tuple)
4247 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
4248 result = super().wrap(parser)
4249 result._inner = parser._inner # type: ignore
4250 return result
4252 def parse_with_ctx(self, ctx: StrParsingContext, /) -> TU:
4253 items = list(ctx.split(self._delimiter, maxsplit=len(self._inner) - 1))
4255 if len(items) != len(self._inner):
4256 raise ParsingError(
4257 "Expected %s element%s, got %s: `%r`",
4258 len(self._inner),
4259 "" if len(self._inner) == 1 else "s",
4260 len(items),
4261 ctx.value,
4262 ctx=ctx,
4263 )
4265 return _t.cast(
4266 TU,
4267 tuple(
4268 parser.parse_with_ctx(item) for parser, item in zip(self._inner, items)
4269 ),
4270 )
4272 def parse_many_with_ctx(self, ctxs: _t.Sequence[StrParsingContext], /) -> TU:
4273 if len(ctxs) != len(self._inner):
4274 raise ParsingError(
4275 "Expected %s element%s, got %s: `%r`",
4276 len(self._inner),
4277 "" if len(self._inner) == 1 else "s",
4278 len(ctxs),
4279 ctxs,
4280 )
4282 return _t.cast(
4283 TU,
4284 tuple(
4285 parser.parse_with_ctx(item) for parser, item in zip(self._inner, ctxs)
4286 ),
4287 )
4289 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> TU:
4290 value = ctx.value
4291 if not isinstance(value, (list, tuple)):
4292 raise ParsingError.type_mismatch(value, list, tuple, ctx=ctx)
4293 elif len(value) != len(self._inner):
4294 raise ParsingError(
4295 "Expected %s element%s, got %s: `%r`",
4296 len(self._inner),
4297 "" if len(self._inner) == 1 else "s",
4298 len(value),
4299 value,
4300 )
4302 return _t.cast(
4303 TU,
4304 tuple(
4305 parser.parse_config_with_ctx(ctx.descend(item, i))
4306 for i, (parser, item) in enumerate(zip(self._inner, value))
4307 ),
4308 )
4310 def supports_parse_many(self) -> bool:
4311 return True
4313 def get_nargs(self) -> _t.Literal["+", "*"] | int:
4314 return len(self._inner)
4316 def describe(self) -> str | None:
4317 delimiter = self._delimiter or " "
4318 desc = [parser.describe_or_def() for parser in self._inner]
4319 return delimiter.join(desc)
4321 def describe_many(self) -> str | tuple[str, ...]:
4322 return tuple(parser.describe_or_def() for parser in self._inner)
4324 def describe_value(self, value: object, /) -> str:
4325 assert self.assert_type(value)
4327 delimiter = self._delimiter or " "
4328 desc = [parser.describe_value(item) for parser, item in zip(self._inner, value)]
4330 return delimiter.join(desc)
4332 def options(self) -> _t.Collection[yuio.widget.Option[TU]] | None:
4333 return None
4335 def completer(self) -> yuio.complete.Completer | None:
4336 return yuio.complete.Tuple(
4337 *[parser.completer() or yuio.complete.Empty() for parser in self._inner],
4338 delimiter=self._delimiter,
4339 )
4341 def widget(
4342 self,
4343 default: object | yuio.Missing,
4344 input_description: str | None,
4345 default_description: str | None,
4346 /,
4347 ) -> yuio.widget.Widget[TU | yuio.Missing]:
4348 completer = self.completer()
4350 return _WidgetResultMapper(
4351 self,
4352 input_description,
4353 default,
4354 (
4355 yuio.widget.InputWithCompletion(
4356 completer,
4357 placeholder=default_description or "",
4358 )
4359 if completer is not None
4360 else yuio.widget.Input(
4361 placeholder=default_description or "",
4362 )
4363 ),
4364 )
4366 def to_json_schema(
4367 self, ctx: yuio.json_schema.JsonSchemaContext, /
4368 ) -> yuio.json_schema.JsonSchemaType:
4369 return yuio.json_schema.Tuple(
4370 [parser.to_json_schema(ctx) for parser in self._inner]
4371 )
4373 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
4374 assert self.assert_type(value)
4375 return [parser.to_json_value(item) for parser, item in zip(self._inner, value)]
4377 def is_secret(self) -> bool:
4378 return any(parser.is_secret() for parser in self._inner)
4380 def __repr__(self):
4381 if self._inner_raw is not None:
4382 return f"{self.__class__.__name__}{self._inner_raw!r}"
4383 else:
4384 return self.__class__.__name__
4387class _DictElementParser(Tuple[tuple[K, V]], _t.Generic[K, V]):
4388 def __init__(self, k: Parser[K], v: Parser[V], delimiter: str | None = None):
4389 super().__init__(k, v, delimiter=delimiter)
4391 # def parse_with_ctx(self, ctx: StrParsingContext, /) -> tuple[K, V]:
4392 # items = list(ctx.split(self._delimiter, maxsplit=len(self._inner) - 1))
4394 # if len(items) != len(self._inner):
4395 # raise ParsingError("Expected key-value pair, got `%r`", ctx.value)
4397 # return _t.cast(
4398 # tuple[K, V],
4399 # tuple(parser.parse_with_ctx(item) for parser, item in zip(self._inner, items)),
4400 # )
4402 # def parse_many_with_ctx(self, ctxs: _t.Sequence[StrParsingContext], /) -> tuple[K, V]:
4403 # if len(value) != len(self._inner):
4404 # with describe_context("element #%(key)r"):
4405 # raise ParsingError(
4406 # "Expected key-value pair, got `%r`",
4407 # value,
4408 # )
4410 # k = describe_context("key of element #%(key)r", self._inner[0].parse, value[0])
4411 # v = replace_context(k, self._inner[1].parse, value[1])
4413 # return _t.cast(tuple[K, V], (k, v))
4415 # def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> tuple[K, V]:
4416 # if not isinstance(value, (list, tuple)):
4417 # with describe_context("element #%(key)r"):
4418 # raise ParsingError.type_mismatch(value, list, tuple)
4419 # elif len(value) != len(self._inner):
4420 # with describe_context("element #%(key)r"):
4421 # raise ParsingError(
4422 # "Expected key-value pair, got `%r`",
4423 # value,
4424 # )
4426 # k = describe_context(
4427 # "key of element #%(key)r", self._inner[0].parse_config_with_ctx, value[0]
4428 # )
4429 # v = replace_context(k, self._inner[1].parse_config_with_ctx, value[1])
4431 # return _t.cast(tuple[K, V], (k, v))
4434class Optional(MappingParser[T | None, T], _t.Generic[T]):
4435 """Optional(inner: Parser[T], /)
4437 Parser for optional values.
4439 Allows handling :data:`None`\\ s when parsing config. Does not change how strings
4440 are parsed, though.
4442 :param inner:
4443 a parser used to extract and validate contents of an optional.
4445 """
4447 if TYPE_CHECKING:
4449 @_t.overload
4450 def __new__(cls, inner: Parser[T], /) -> Optional[T]: ...
4452 @_t.overload
4453 def __new__(cls, /) -> PartialParser: ...
4455 def __new__(cls, *args, **kwargs) -> _t.Any: ...
4457 def __init__(self, inner: Parser[T] | None = None, /):
4458 super().__init__(inner)
4460 def parse_with_ctx(self, ctx: StrParsingContext, /) -> T | None:
4461 return self._inner.parse_with_ctx(ctx)
4463 def parse_many_with_ctx(self, ctxs: _t.Sequence[StrParsingContext], /) -> T | None:
4464 return self._inner.parse_many_with_ctx(ctxs)
4466 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> T | None:
4467 if ctx.value is None:
4468 return None
4469 return self._inner.parse_config_with_ctx(ctx)
4471 def check_type(self, value: object, /) -> _t.TypeGuard[T | None]:
4472 return True
4474 def describe_value(self, value: object, /) -> str:
4475 if value is None:
4476 return "<none>"
4477 return self._inner.describe_value(value)
4479 def options(self) -> _t.Collection[yuio.widget.Option[T | None]] | None:
4480 return self._inner.options()
4482 def widget(
4483 self,
4484 default: object | yuio.Missing,
4485 input_description: str | None,
4486 default_description: str | None,
4487 /,
4488 ) -> yuio.widget.Widget[T | yuio.Missing]:
4489 return self._inner.widget(default, input_description, default_description)
4491 def to_json_schema(
4492 self, ctx: yuio.json_schema.JsonSchemaContext, /
4493 ) -> yuio.json_schema.JsonSchemaType:
4494 return yuio.json_schema.OneOf(
4495 [self._inner.to_json_schema(ctx), yuio.json_schema.Null()]
4496 )
4498 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
4499 if value is None:
4500 return None
4501 else:
4502 return self._inner.to_json_value(value)
4505class Union(WrappingParser[T, tuple[Parser[T], ...]], ValueParser[T], _t.Generic[T]):
4506 """Union(*parsers: Parser[T])
4508 Tries several parsers and returns the first successful result.
4510 .. warning::
4512 Order of parsers matters. Since parsers are tried in the same order as they're
4513 given, make sure to put parsers that are likely to succeed at the end.
4515 For example, this parser will always return a string because :class:`Str`
4516 can't fail::
4518 >>> parser = Union(Str(), Int()) # Always returns a string!
4519 >>> parser.parse("10")
4520 '10'
4522 To fix this, put :class:`Str` at the end so that :class:`Int` is tried first::
4524 >>> parser = Union(Int(), Str())
4525 >>> parser.parse("10")
4526 10
4527 >>> parser.parse("not an int")
4528 'not an int'
4530 """
4532 # See the links below for an explanation of shy this is so ugly:
4533 # https://github.com/python/typing/discussions/1450
4534 # https://github.com/python/typing/issues/1216
4535 if TYPE_CHECKING:
4536 T1 = _t.TypeVar("T1")
4537 T2 = _t.TypeVar("T2")
4538 T3 = _t.TypeVar("T3")
4539 T4 = _t.TypeVar("T4")
4540 T5 = _t.TypeVar("T5")
4541 T6 = _t.TypeVar("T6")
4542 T7 = _t.TypeVar("T7")
4543 T8 = _t.TypeVar("T8")
4544 T9 = _t.TypeVar("T9")
4545 T10 = _t.TypeVar("T10")
4547 @_t.overload
4548 def __new__(
4549 cls,
4550 /,
4551 ) -> PartialParser: ...
4553 @_t.overload
4554 def __new__(
4555 cls,
4556 p1: Parser[T1],
4557 /,
4558 ) -> Union[T1]: ...
4560 @_t.overload
4561 def __new__(
4562 cls,
4563 p1: Parser[T1],
4564 p2: Parser[T2],
4565 /,
4566 ) -> Union[T1 | T2]: ...
4568 @_t.overload
4569 def __new__(
4570 cls,
4571 p1: Parser[T1],
4572 p2: Parser[T2],
4573 p3: Parser[T3],
4574 /,
4575 ) -> Union[T1 | T2 | T3]: ...
4577 @_t.overload
4578 def __new__(
4579 cls,
4580 p1: Parser[T1],
4581 p2: Parser[T2],
4582 p3: Parser[T3],
4583 p4: Parser[T4],
4584 /,
4585 ) -> Union[T1 | T2 | T3 | T4]: ...
4587 @_t.overload
4588 def __new__(
4589 cls,
4590 p1: Parser[T1],
4591 p2: Parser[T2],
4592 p3: Parser[T3],
4593 p4: Parser[T4],
4594 p5: Parser[T5],
4595 /,
4596 ) -> Union[T1 | T2 | T3 | T4 | T5]: ...
4598 @_t.overload
4599 def __new__(
4600 cls,
4601 p1: Parser[T1],
4602 p2: Parser[T2],
4603 p3: Parser[T3],
4604 p4: Parser[T4],
4605 p5: Parser[T5],
4606 p6: Parser[T6],
4607 /,
4608 ) -> Union[T1 | T2 | T3 | T4 | T5 | T6]: ...
4610 @_t.overload
4611 def __new__(
4612 cls,
4613 p1: Parser[T1],
4614 p2: Parser[T2],
4615 p3: Parser[T3],
4616 p4: Parser[T4],
4617 p5: Parser[T5],
4618 p6: Parser[T6],
4619 p7: Parser[T7],
4620 /,
4621 ) -> Union[T1 | T2 | T3 | T4 | T5 | T6 | T7]: ...
4623 @_t.overload
4624 def __new__(
4625 cls,
4626 p1: Parser[T1],
4627 p2: Parser[T2],
4628 p3: Parser[T3],
4629 p4: Parser[T4],
4630 p5: Parser[T5],
4631 p6: Parser[T6],
4632 p7: Parser[T7],
4633 p8: Parser[T8],
4634 /,
4635 ) -> Union[T1 | T2 | T3 | T4 | T5 | T6 | T7 | T8]: ...
4637 @_t.overload
4638 def __new__(
4639 cls,
4640 p1: Parser[T1],
4641 p2: Parser[T2],
4642 p3: Parser[T3],
4643 p4: Parser[T4],
4644 p5: Parser[T5],
4645 p6: Parser[T6],
4646 p7: Parser[T7],
4647 p8: Parser[T8],
4648 p9: Parser[T9],
4649 /,
4650 ) -> Union[T1 | T2 | T3 | T4 | T5 | T6 | T7 | T8 | T9]: ...
4652 @_t.overload
4653 def __new__(
4654 cls,
4655 p1: Parser[T1],
4656 p2: Parser[T2],
4657 p3: Parser[T3],
4658 p4: Parser[T4],
4659 p5: Parser[T5],
4660 p6: Parser[T6],
4661 p7: Parser[T7],
4662 p8: Parser[T8],
4663 p9: Parser[T9],
4664 p10: Parser[T10],
4665 /,
4666 ) -> Union[T1 | T2 | T3 | T4 | T5 | T6 | T7 | T8 | T9 | T10]: ...
4668 @_t.overload
4669 def __new__(
4670 cls,
4671 p1: Parser[T1],
4672 p2: Parser[T2],
4673 p3: Parser[T3],
4674 p4: Parser[T4],
4675 p5: Parser[T5],
4676 p6: Parser[T6],
4677 p7: Parser[T7],
4678 p8: Parser[T8],
4679 p9: Parser[T9],
4680 p10: Parser[T10],
4681 p11: Parser[object],
4682 *parsers: Parser[object],
4683 ) -> Union[object]: ...
4685 def __new__(cls, *args, **kwargs) -> _t.Any: ...
4687 def __init__(self, *parsers: Parser[_t.Any]):
4688 super().__init__(parsers or None, object)
4690 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
4691 result = super().wrap(parser)
4692 result._inner = parser._inner # type: ignore
4693 return result
4695 def parse_with_ctx(self, ctx: StrParsingContext, /) -> T:
4696 errors: list[tuple[Parser[object], ParsingError]] = []
4697 for parser in self._inner:
4698 try:
4699 return parser.parse_with_ctx(ctx)
4700 except ParsingError as e:
4701 errors.append((parser, e))
4702 raise self._make_error(errors, ctx)
4704 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> T:
4705 errors: list[tuple[Parser[object], ParsingError]] = []
4706 for parser in self._inner:
4707 try:
4708 return parser.parse_config_with_ctx(ctx)
4709 except ParsingError as e:
4710 errors.append((parser, e))
4711 raise self._make_error(errors, ctx)
4713 def _make_error(
4714 self,
4715 errors: list[tuple[Parser[object], ParsingError]],
4716 ctx: StrParsingContext | ConfigParsingContext,
4717 ):
4718 msgs = []
4719 for parser, error in errors:
4720 error.raw = None
4721 error.pos = None
4722 msgs.append(
4723 yuio.string.Format(
4724 " Trying as `%s`:\n%s",
4725 parser.describe_or_def(),
4726 yuio.string.Indent(error, indent=4),
4727 )
4728 )
4729 return ParsingError(
4730 "Can't parse `%r`:\n%s", ctx.value, yuio.string.Stack(*msgs), ctx=ctx
4731 )
4733 def check_type(self, value: object, /) -> _t.TypeGuard[T]:
4734 return True
4736 def describe(self) -> str | None:
4737 if len(self._inner) > 1:
4739 def strip_curly_brackets(desc: str):
4740 if desc.startswith("{") and desc.endswith("}") and "|" in desc:
4741 s = desc[1:-1]
4742 if "{" not in s and "}" not in s:
4743 return s
4744 return desc
4746 desc = "|".join(
4747 strip_curly_brackets(parser.describe_or_def()) for parser in self._inner
4748 )
4749 desc = f"{{{desc}}}"
4750 else:
4751 desc = "|".join(parser.describe_or_def() for parser in self._inner)
4752 return desc
4754 def describe_value(self, value: object, /) -> str:
4755 for parser in self._inner:
4756 try:
4757 return parser.describe_value(value)
4758 except TypeError:
4759 pass
4761 raise TypeError(
4762 f"parser {self} can't handle value of type {_tx.type_repr(type(value))}"
4763 )
4765 def options(self) -> _t.Collection[yuio.widget.Option[T]] | None:
4766 result = []
4767 got_options = False
4768 for parser in self._inner:
4769 if options := parser.options():
4770 result.extend(options)
4771 got_options = True
4772 if got_options:
4773 return result
4774 else:
4775 return None
4777 def completer(self) -> yuio.complete.Completer | None:
4778 completers = []
4779 for parser in self._inner:
4780 if completer := parser.completer():
4781 completers.append((parser.describe(), completer))
4782 if not completers:
4783 return None
4784 elif len(completers) == 1:
4785 return completers[0][1]
4786 else:
4787 return yuio.complete.Alternative(completers)
4789 def widget(
4790 self,
4791 default: object | yuio.Missing,
4792 input_description: str | None,
4793 default_description: str | None,
4794 ) -> yuio.widget.Widget[T | yuio.Missing]:
4795 options = []
4796 for parser in self._inner:
4797 parser_options = parser.options()
4798 if parser_options is None:
4799 options = None
4800 break
4801 options.extend(parser_options)
4803 if not options:
4804 return super().widget(default, input_description, default_description)
4806 if default is yuio.MISSING:
4807 default_index = 0
4808 else:
4809 for i, option in enumerate(options):
4810 if option.value == default:
4811 default_index = i
4812 break
4813 else:
4814 options.insert(
4815 0,
4816 yuio.widget.Option(
4817 yuio.MISSING, default_description or str(default)
4818 ),
4819 )
4820 default_index = 0
4822 return yuio.widget.Choice(options, default_index=default_index)
4824 def to_json_schema(
4825 self, ctx: yuio.json_schema.JsonSchemaContext, /
4826 ) -> yuio.json_schema.JsonSchemaType:
4827 return yuio.json_schema.OneOf(
4828 [parser.to_json_schema(ctx) for parser in self._inner]
4829 )
4831 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
4832 for parser in self._inner:
4833 try:
4834 return parser.to_json_value(value)
4835 except TypeError:
4836 pass
4838 raise TypeError(
4839 f"parser {self} can't handle value of type {_tx.type_repr(type(value))}"
4840 )
4842 def is_secret(self) -> bool:
4843 return any(parser.is_secret() for parser in self._inner)
4845 def __repr__(self):
4846 return f"{self.__class__.__name__}{self._inner_raw!r}"
4849class _BoundImpl(ValidatingParser[T], _t.Generic[T, Cmp]):
4850 def __init__(
4851 self,
4852 inner: Parser[T] | None,
4853 /,
4854 *,
4855 lower: Cmp | None = None,
4856 lower_inclusive: Cmp | None = None,
4857 upper: Cmp | None = None,
4858 upper_inclusive: Cmp | None = None,
4859 mapper: _t.Callable[[T], Cmp],
4860 desc: str,
4861 ):
4862 super().__init__(inner)
4864 self._lower_bound: Cmp | None = None
4865 self._lower_bound_is_inclusive: bool = True
4866 self._upper_bound: Cmp | None = None
4867 self._upper_bound_is_inclusive: bool = True
4869 if lower is not None and lower_inclusive is not None:
4870 raise TypeError(
4871 "lower and lower_inclusive cannot be given at the same time"
4872 )
4873 elif lower is not None:
4874 self._lower_bound = lower
4875 self._lower_bound_is_inclusive = False
4876 elif lower_inclusive is not None:
4877 self._lower_bound = lower_inclusive
4878 self._lower_bound_is_inclusive = True
4880 if upper is not None and upper_inclusive is not None:
4881 raise TypeError(
4882 "upper and upper_inclusive cannot be given at the same time"
4883 )
4884 elif upper is not None:
4885 self._upper_bound = upper
4886 self._upper_bound_is_inclusive = False
4887 elif upper_inclusive is not None:
4888 self._upper_bound = upper_inclusive
4889 self._upper_bound_is_inclusive = True
4891 self.__mapper = mapper
4892 self.__desc = desc
4894 def _validate(self, value: T, /):
4895 mapped = self.__mapper(value)
4897 if self._lower_bound is not None:
4898 if self._lower_bound_is_inclusive and mapped < self._lower_bound:
4899 raise ParsingError(
4900 "%s should be greater than or equal to `%s`: `%r`",
4901 self.__desc,
4902 self._lower_bound,
4903 value,
4904 )
4905 elif not self._lower_bound_is_inclusive and not self._lower_bound < mapped:
4906 raise ParsingError(
4907 "%s should be greater than `%s`: `%r`",
4908 self.__desc,
4909 self._lower_bound,
4910 value,
4911 )
4913 if self._upper_bound is not None:
4914 if self._upper_bound_is_inclusive and self._upper_bound < mapped:
4915 raise ParsingError(
4916 "%s should be lesser than or equal to `%s`: `%r`",
4917 self.__desc,
4918 self._upper_bound,
4919 value,
4920 )
4921 elif not self._upper_bound_is_inclusive and not mapped < self._upper_bound:
4922 raise ParsingError(
4923 "%s should be lesser than `%s`: `%r`",
4924 self.__desc,
4925 self._upper_bound,
4926 value,
4927 )
4929 def __repr__(self):
4930 desc = ""
4931 if self._lower_bound is not None:
4932 desc += repr(self._lower_bound)
4933 desc += " <= " if self._lower_bound_is_inclusive else " < "
4934 mapper_name = getattr(self.__mapper, "__name__")
4935 if mapper_name and mapper_name != "<lambda>":
4936 desc += mapper_name
4937 else:
4938 desc += "x"
4939 if self._upper_bound is not None:
4940 desc += " <= " if self._upper_bound_is_inclusive else " < "
4941 desc += repr(self._upper_bound)
4942 return f"{self.__class__.__name__}({self.__wrapped_parser__!r}, {desc})"
4945class Bound(_BoundImpl[Cmp, Cmp], _t.Generic[Cmp]):
4946 """Bound(inner: Parser[Cmp], /, *, lower: Cmp | None = None, lower_inclusive: Cmp | None = None, upper: Cmp | None = None, upper_inclusive: Cmp | None = None)
4948 Check that value is upper- or lower-bound by some constraints.
4950 :param inner:
4951 parser whose result will be validated.
4952 :param lower:
4953 set lower bound for value, so we require that ``value > lower``.
4954 Can't be given if `lower_inclusive` is also given.
4955 :param lower_inclusive:
4956 set lower bound for value, so we require that ``value >= lower``.
4957 Can't be given if `lower` is also given.
4958 :param upper:
4959 set upper bound for value, so we require that ``value < upper``.
4960 Can't be given if `upper_inclusive` is also given.
4961 :param upper_inclusive:
4962 set upper bound for value, so we require that ``value <= upper``.
4963 Can't be given if `upper` is also given.
4964 :example:
4965 ::
4967 >>> # Int in range `0 < x <= 1`:
4968 >>> Bound(Int(), lower=0, upper_inclusive=1)
4969 Bound(Int, 0 < x <= 1)
4971 """
4973 if TYPE_CHECKING:
4975 @_t.overload
4976 def __new__(
4977 cls,
4978 inner: Parser[Cmp],
4979 /,
4980 *,
4981 lower: Cmp | None = None,
4982 lower_inclusive: Cmp | None = None,
4983 upper: Cmp | None = None,
4984 upper_inclusive: Cmp | None = None,
4985 ) -> Bound[Cmp]: ...
4987 @_t.overload
4988 def __new__(
4989 cls,
4990 *,
4991 lower: Cmp | None = None,
4992 lower_inclusive: Cmp | None = None,
4993 upper: Cmp | None = None,
4994 upper_inclusive: Cmp | None = None,
4995 ) -> PartialParser: ...
4997 def __new__(cls, *args, **kwargs) -> _t.Any: ...
4999 def __init__(
5000 self,
5001 inner: Parser[Cmp] | None = None,
5002 /,
5003 *,
5004 lower: Cmp | None = None,
5005 lower_inclusive: Cmp | None = None,
5006 upper: Cmp | None = None,
5007 upper_inclusive: Cmp | None = None,
5008 ):
5009 super().__init__(
5010 inner,
5011 lower=lower,
5012 lower_inclusive=lower_inclusive,
5013 upper=upper,
5014 upper_inclusive=upper_inclusive,
5015 mapper=lambda x: x,
5016 desc="Value",
5017 )
5019 def to_json_schema(
5020 self, ctx: yuio.json_schema.JsonSchemaContext, /
5021 ) -> yuio.json_schema.JsonSchemaType:
5022 bound = {}
5023 if isinstance(self._lower_bound, (int, float)):
5024 bound[
5025 "minimum" if self._lower_bound_is_inclusive else "exclusiveMinimum"
5026 ] = self._lower_bound
5027 if isinstance(self._upper_bound, (int, float)):
5028 bound[
5029 "maximum" if self._upper_bound_is_inclusive else "exclusiveMaximum"
5030 ] = self._upper_bound
5031 if bound:
5032 return yuio.json_schema.AllOf(
5033 [super().to_json_schema(ctx), yuio.json_schema.Opaque(bound)]
5034 )
5035 else:
5036 return super().to_json_schema(ctx)
5039@_t.overload
5040def Gt(inner: Parser[Cmp], bound: Cmp, /) -> Bound[Cmp]: ...
5041@_t.overload
5042def Gt(bound: _tx.SupportsLt[_t.Any], /) -> PartialParser: ...
5043def Gt(*args) -> _t.Any:
5044 """Gt(inner: Parser[Cmp], bound: Cmp, /)
5046 Alias for :class:`Bound`.
5048 :param inner:
5049 parser whose result will be validated.
5050 :param bound:
5051 lower bound for parsed values.
5053 """
5055 if len(args) == 1:
5056 return Bound(lower=args[0])
5057 elif len(args) == 2:
5058 return Bound(args[0], lower=args[1])
5059 else:
5060 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
5063@_t.overload
5064def Ge(inner: Parser[Cmp], bound: Cmp, /) -> Bound[Cmp]: ...
5065@_t.overload
5066def Ge(bound: _tx.SupportsLt[_t.Any], /) -> PartialParser: ...
5067def Ge(*args) -> _t.Any:
5068 """Ge(inner: Parser[Cmp], bound: Cmp, /)
5070 Alias for :class:`Bound`.
5072 :param inner:
5073 parser whose result will be validated.
5074 :param bound:
5075 lower inclusive bound for parsed values.
5077 """
5079 if len(args) == 1:
5080 return Bound(lower_inclusive=args[0])
5081 elif len(args) == 2:
5082 return Bound(args[0], lower_inclusive=args[1])
5083 else:
5084 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
5087@_t.overload
5088def Lt(inner: Parser[Cmp], bound: Cmp, /) -> Bound[Cmp]: ...
5089@_t.overload
5090def Lt(bound: _tx.SupportsLt[_t.Any], /) -> PartialParser: ...
5091def Lt(*args) -> _t.Any:
5092 """Lt(inner: Parser[Cmp], bound: Cmp, /)
5094 Alias for :class:`Bound`.
5096 :param inner:
5097 parser whose result will be validated.
5098 :param bound:
5099 upper bound for parsed values.
5101 """
5103 if len(args) == 1:
5104 return Bound(upper=args[0])
5105 elif len(args) == 2:
5106 return Bound(args[0], upper=args[1])
5107 else:
5108 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
5111@_t.overload
5112def Le(inner: Parser[Cmp], bound: Cmp, /) -> Bound[Cmp]: ...
5113@_t.overload
5114def Le(bound: _tx.SupportsLt[_t.Any], /) -> PartialParser: ...
5115def Le(*args) -> _t.Any:
5116 """Le(inner: Parser[Cmp], bound: Cmp, /)
5118 Alias for :class:`Bound`.
5120 :param inner:
5121 parser whose result will be validated.
5122 :param bound:
5123 upper inclusive bound for parsed values.
5125 """
5127 if len(args) == 1:
5128 return Bound(upper_inclusive=args[0])
5129 elif len(args) == 2:
5130 return Bound(args[0], upper_inclusive=args[1])
5131 else:
5132 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
5135class LenBound(_BoundImpl[Sz, int], _t.Generic[Sz]):
5136 """LenBound(inner: Parser[Sz], /, *, lower: int | None = None, lower_inclusive: int | None = None, upper: int | None = None, upper_inclusive: int | None = None)
5138 Check that length of a value is upper- or lower-bound by some constraints.
5140 The signature is the same as of the :class:`Bound` class.
5142 :param inner:
5143 parser whose result will be validated.
5144 :param lower:
5145 set lower bound for value's length, so we require that ``len(value) > lower``.
5146 Can't be given if `lower_inclusive` is also given.
5147 :param lower_inclusive:
5148 set lower bound for value's length, so we require that ``len(value) >= lower``.
5149 Can't be given if `lower` is also given.
5150 :param upper:
5151 set upper bound for value's length, so we require that ``len(value) < upper``.
5152 Can't be given if `upper_inclusive` is also given.
5153 :param upper_inclusive:
5154 set upper bound for value's length, so we require that ``len(value) <= upper``.
5155 Can't be given if `upper` is also given.
5156 :example:
5157 ::
5159 >>> # List of up to five ints:
5160 >>> LenBound(List(Int()), upper_inclusive=5)
5161 LenBound(List(Int), len <= 5)
5163 """
5165 if TYPE_CHECKING:
5167 @_t.overload
5168 def __new__(
5169 cls,
5170 inner: Parser[Sz],
5171 /,
5172 *,
5173 lower: int | None = None,
5174 lower_inclusive: int | None = None,
5175 upper: int | None = None,
5176 upper_inclusive: int | None = None,
5177 ) -> LenBound[Sz]: ...
5179 @_t.overload
5180 def __new__(
5181 cls,
5182 /,
5183 *,
5184 lower: int | None = None,
5185 lower_inclusive: int | None = None,
5186 upper: int | None = None,
5187 upper_inclusive: int | None = None,
5188 ) -> PartialParser: ...
5190 def __new__(cls, *args, **kwargs) -> _t.Any: ...
5192 def __init__(
5193 self,
5194 inner: Parser[Sz] | None = None,
5195 /,
5196 *,
5197 lower: int | None = None,
5198 lower_inclusive: int | None = None,
5199 upper: int | None = None,
5200 upper_inclusive: int | None = None,
5201 ):
5202 super().__init__(
5203 inner,
5204 lower=lower,
5205 lower_inclusive=lower_inclusive,
5206 upper=upper,
5207 upper_inclusive=upper_inclusive,
5208 mapper=len,
5209 desc="Length of value",
5210 )
5212 def get_nargs(self) -> _t.Literal["+", "*"] | int:
5213 if not self._inner.supports_parse_many():
5214 # somebody bound len of a string?
5215 return self._inner.get_nargs()
5217 lower = self._lower_bound
5218 if lower is not None and not self._lower_bound_is_inclusive:
5219 lower += 1
5220 upper = self._upper_bound
5221 if upper is not None and not self._upper_bound_is_inclusive:
5222 upper -= 1
5224 if lower == upper and lower is not None:
5225 return lower
5226 elif lower is not None and lower > 0:
5227 return "+"
5228 else:
5229 return "*"
5231 def to_json_schema(
5232 self, ctx: yuio.json_schema.JsonSchemaContext, /
5233 ) -> yuio.json_schema.JsonSchemaType:
5234 bound = {}
5235 min_bound = self._lower_bound
5236 if not self._lower_bound_is_inclusive and min_bound is not None:
5237 min_bound -= 1
5238 if min_bound is not None:
5239 bound["minLength"] = bound["minItems"] = bound["minProperties"] = min_bound
5240 max_bound = self._upper_bound
5241 if not self._upper_bound_is_inclusive and max_bound is not None:
5242 max_bound += 1
5243 if max_bound is not None:
5244 bound["maxLength"] = bound["maxItems"] = bound["maxProperties"] = max_bound
5245 if bound:
5246 return yuio.json_schema.AllOf(
5247 [super().to_json_schema(ctx), yuio.json_schema.Opaque(bound)]
5248 )
5249 else:
5250 return super().to_json_schema(ctx)
5253@_t.overload
5254def LenGt(inner: Parser[Sz], bound: int, /) -> LenBound[Sz]: ...
5255@_t.overload
5256def LenGt(bound: int, /) -> PartialParser: ...
5257def LenGt(*args) -> _t.Any:
5258 """LenGt(inner: Parser[Sz], bound: int, /)
5260 Alias for :class:`LenBound`.
5262 :param inner:
5263 parser whose result will be validated.
5264 :param bound:
5265 lower bound for parsed values's length.
5267 """
5269 if len(args) == 1:
5270 return LenBound(lower=args[0])
5271 elif len(args) == 2:
5272 return LenBound(args[0], lower=args[1])
5273 else:
5274 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
5277@_t.overload
5278def LenGe(inner: Parser[Sz], bound: int, /) -> LenBound[Sz]: ...
5279@_t.overload
5280def LenGe(bound: int, /) -> PartialParser: ...
5281def LenGe(*args) -> _t.Any:
5282 """LenGe(inner: Parser[Sz], bound: int, /)
5284 Alias for :class:`LenBound`.
5286 :param inner:
5287 parser whose result will be validated.
5288 :param bound:
5289 lower inclusive bound for parsed values's length.
5291 """
5293 if len(args) == 1:
5294 return LenBound(lower_inclusive=args[0])
5295 elif len(args) == 2:
5296 return LenBound(args[0], lower_inclusive=args[1])
5297 else:
5298 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
5301@_t.overload
5302def LenLt(inner: Parser[Sz], bound: int, /) -> LenBound[Sz]: ...
5303@_t.overload
5304def LenLt(bound: int, /) -> PartialParser: ...
5305def LenLt(*args) -> _t.Any:
5306 """LenLt(inner: Parser[Sz], bound: int, /)
5308 Alias for :class:`LenBound`.
5310 :param inner:
5311 parser whose result will be validated.
5312 :param bound:
5313 upper bound for parsed values's length.
5315 """
5317 if len(args) == 1:
5318 return LenBound(upper=args[0])
5319 elif len(args) == 2:
5320 return LenBound(args[0], upper=args[1])
5321 else:
5322 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
5325@_t.overload
5326def LenLe(inner: Parser[Sz], bound: int, /) -> LenBound[Sz]: ...
5327@_t.overload
5328def LenLe(bound: int, /) -> PartialParser: ...
5329def LenLe(*args) -> _t.Any:
5330 """LenLe(inner: Parser[Sz], bound: int, /)
5332 Alias for :class:`LenBound`.
5334 :param inner:
5335 parser whose result will be validated.
5336 :param bound:
5337 upper inclusive bound for parsed values's length.
5339 """
5341 if len(args) == 1:
5342 return LenBound(upper_inclusive=args[0])
5343 elif len(args) == 2:
5344 return LenBound(args[0], upper_inclusive=args[1])
5345 else:
5346 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
5349class OneOf(ValidatingParser[T], _t.Generic[T]):
5350 """OneOf(inner: Parser[T], values: typing.Collection[T], /)
5352 Check that the parsed value is one of the given set of values.
5354 .. note::
5356 This parser is meant to validate results of other parsers; if you're looking
5357 to parse enums or literal values, check out :class:`Enum` or :class:`Literal`.
5359 :param inner:
5360 parser whose result will be validated.
5361 :param values:
5362 collection of allowed values.
5363 :example:
5364 ::
5366 >>> # Accepts only strings 'A', 'B', or 'C':
5367 >>> OneOf(Str(), ['A', 'B', 'C'])
5368 OneOf(Str)
5370 """
5372 if TYPE_CHECKING:
5374 @_t.overload
5375 def __new__(cls, inner: Parser[T], values: _t.Collection[T], /) -> OneOf[T]: ...
5377 @_t.overload
5378 def __new__(cls, values: _t.Collection[T], /) -> PartialParser: ...
5380 def __new__(cls, *args) -> _t.Any: ...
5382 def __init__(self, *args):
5383 inner: Parser[T] | None
5384 values: _t.Collection[T]
5385 if len(args) == 1:
5386 inner, values = None, args[0]
5387 elif len(args) == 2:
5388 inner, values = args
5389 else:
5390 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
5392 super().__init__(inner)
5394 self._allowed_values = values
5396 def _validate(self, value: T, /):
5397 if value not in self._allowed_values:
5398 raise ParsingError(
5399 "Can't parse `%r`, should be %s",
5400 value,
5401 yuio.string.JoinRepr.or_(self._allowed_values),
5402 )
5404 def describe(self) -> str | None:
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()
5413 def describe_or_def(self) -> str:
5414 desc = "|".join(self.describe_value(e) for e in self._allowed_values)
5415 if len(desc) < 80:
5416 if len(self._allowed_values) > 1:
5417 desc = f"{{{desc}}}"
5418 return desc
5419 else:
5420 return super().describe_or_def()
5422 def options(self) -> _t.Collection[yuio.widget.Option[T]] | None:
5423 return [
5424 yuio.widget.Option(e, self.describe_value(e)) for e in self._allowed_values
5425 ]
5427 def completer(self) -> yuio.complete.Completer | None:
5428 return yuio.complete.Choice(
5429 [yuio.complete.Option(self.describe_value(e)) for e in self._allowed_values]
5430 )
5432 def widget(
5433 self,
5434 default: object | yuio.Missing,
5435 input_description: str | None,
5436 default_description: str | None,
5437 /,
5438 ) -> yuio.widget.Widget[T | yuio.Missing]:
5439 allowed_values = list(self._allowed_values)
5441 options = _t.cast(list[yuio.widget.Option[T | yuio.Missing]], self.options())
5443 if not options:
5444 return super().widget(default, input_description, default_description)
5446 if default is yuio.MISSING:
5447 default_index = 0
5448 elif default in allowed_values:
5449 default_index = list(allowed_values).index(default) # type: ignore
5450 else:
5451 options.insert(
5452 0, yuio.widget.Option(yuio.MISSING, default_description or str(default))
5453 )
5454 default_index = 0
5456 return yuio.widget.Choice(options, default_index=default_index)
5459class WithMeta(MappingParser[T, T], _t.Generic[T]):
5460 """WithMeta(inner: Parser[T], /, *, desc: str, completer: yuio.complete.Completer | None | ~yuio.MISSING = MISSING)
5462 Overrides inline help messages and other meta information of a wrapped parser.
5464 Inline help messages will show up as hints in autocompletion and widgets.
5466 :param inner:
5467 inner parser.
5468 :param desc:
5469 description override. This short string will be used in CLI, widgets, and
5470 completers to describe expected value.
5471 :param completer:
5472 completer override. Pass :data:`None` to disable completion.
5474 """
5476 if TYPE_CHECKING:
5478 @_t.overload
5479 def __new__(
5480 cls,
5481 inner: Parser[T],
5482 /,
5483 *,
5484 desc: str | None = None,
5485 completer: yuio.complete.Completer | yuio.Missing | None = yuio.MISSING,
5486 ) -> MappingParser[T, T]: ...
5488 @_t.overload
5489 def __new__(
5490 cls,
5491 /,
5492 *,
5493 desc: str | None = None,
5494 completer: yuio.complete.Completer | yuio.Missing | None = yuio.MISSING,
5495 ) -> PartialParser: ...
5497 def __new__(cls, *args, **kwargs) -> _t.Any: ...
5499 def __init__(
5500 self,
5501 *args,
5502 desc: str | None = None,
5503 completer: yuio.complete.Completer | yuio.Missing | None = yuio.MISSING,
5504 ):
5505 inner: Parser[T] | None
5506 if not args:
5507 inner = None
5508 elif len(args) == 1:
5509 inner = args[0]
5510 else:
5511 raise TypeError(f"expected at most 1 positional argument, got {len(args)}")
5513 self._desc = desc
5514 self._completer = completer
5515 super().__init__(inner)
5517 def check_type(self, value: object, /) -> _t.TypeGuard[T]:
5518 return True
5520 def describe(self) -> str | None:
5521 return self._desc or self._inner.describe()
5523 def describe_or_def(self) -> str:
5524 return self._desc or self._inner.describe_or_def()
5526 def describe_many(self) -> str | tuple[str, ...]:
5527 return self._desc or self._inner.describe_many()
5529 def describe_value(self, value: object, /) -> str:
5530 return self._inner.describe_value(value)
5532 def parse_with_ctx(self, ctx: StrParsingContext, /) -> T:
5533 return self._inner.parse_with_ctx(ctx)
5535 def parse_many_with_ctx(self, ctxs: _t.Sequence[StrParsingContext], /) -> T:
5536 return self._inner.parse_many_with_ctx(ctxs)
5538 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> T:
5539 return self._inner.parse_config_with_ctx(ctx)
5541 def options(self) -> _t.Collection[yuio.widget.Option[T]] | None:
5542 return self._inner.options()
5544 def completer(self) -> yuio.complete.Completer | None:
5545 if self._completer is not yuio.MISSING:
5546 return self._completer # type: ignore
5547 else:
5548 return self._inner.completer()
5550 def widget(
5551 self,
5552 default: object | yuio.Missing,
5553 input_description: str | None,
5554 default_description: str | None,
5555 /,
5556 ) -> yuio.widget.Widget[T | yuio.Missing]:
5557 return self._inner.widget(default, input_description, default_description)
5559 def to_json_value(self, value: object) -> yuio.json_schema.JsonValue:
5560 return self._inner.to_json_value(value)
5563class _WidgetResultMapper(yuio.widget.Map[T | yuio.Missing, str]):
5564 def __init__(
5565 self,
5566 parser: Parser[T],
5567 input_description: str | None,
5568 default: object | yuio.Missing,
5569 widget: yuio.widget.Widget[str],
5570 ):
5571 self._parser = parser
5572 self._input_description = input_description
5573 self._default = default
5574 super().__init__(widget, self.mapper)
5576 def mapper(self, s: str) -> T | yuio.Missing:
5577 if not s and self._default is not yuio.MISSING:
5578 return yuio.MISSING
5579 elif not s:
5580 raise ParsingError("Input is required")
5581 try:
5582 return self._parser.parse_with_ctx(StrParsingContext(s))
5583 except ParsingError as e:
5584 if (
5585 isinstance(
5586 self._inner, (yuio.widget.Input, yuio.widget.InputWithCompletion)
5587 )
5588 and e.pos
5589 and e.raw == self._inner.text
5590 ):
5591 if e.pos == (0, len(self._inner.text)):
5592 # Don't highlight the entire text, it's not useful and creates
5593 # visual noise.
5594 self._inner.err_region = None
5595 else:
5596 self._inner.err_region = e.pos
5597 e.raw = None
5598 e.pos = None
5599 raise
5601 @property
5602 def help_data(self):
5603 return super().help_data.with_action(
5604 group="Input Format",
5605 msg=self._input_description,
5606 prepend=True,
5607 prepend_group=True,
5608 )
5611def _secret_widget(
5612 parser: Parser[T],
5613 default: object | yuio.Missing,
5614 input_description: str | None,
5615 default_description: str | None,
5616 /,
5617) -> yuio.widget.Widget[T | yuio.Missing]:
5618 return _WidgetResultMapper(
5619 parser,
5620 input_description,
5621 default,
5622 (
5623 yuio.widget.SecretInput(
5624 placeholder=default_description or "",
5625 )
5626 ),
5627 )
5630class StrParsingContext:
5631 """StrParsingContext(content: str, /, *, n_arg: int | None = None)
5633 String parsing context tracks current position in the string.
5635 :param content:
5636 content to parse.
5637 :param n_arg:
5638 content index when using :meth:`~Parser.parse_many`.
5640 """
5642 def __init__(
5643 self,
5644 content: str,
5645 /,
5646 *,
5647 n_arg: int | None = None,
5648 _value: str | None = None,
5649 _start: int | None = None,
5650 _end: int | None = None,
5651 ):
5652 self.start: int = _start if _start is not None else 0
5653 """
5654 Start position of the value.
5656 """
5658 self.end: int = _end if _end is not None else self.start + len(content)
5659 """
5660 End position of the value.
5662 """
5664 self.content: str = content
5665 """
5666 Full content of the value that was passed to :meth:`Parser.parse`.
5668 """
5670 self.value: str = _value if _value is not None else content
5671 """
5672 Part of the :attr:`~StrParsingContext.content` that's currently being parsed.
5674 """
5676 self.n_arg: int | None = n_arg
5677 """
5678 For :meth:`~Parser.parse_many`, this attribute contains index of the value
5679 that is being parsed. For :meth:`~Parser.parse`, this is :data:`None`.
5681 """
5683 def split(
5684 self, delimiter: str | None = None, /, maxsplit: int = -1
5685 ) -> _t.Generator[StrParsingContext]:
5686 """
5687 Split current value by the given delimiter while keeping track of the current position.
5689 """
5691 if delimiter is None:
5692 yield from self._split_space(maxsplit=maxsplit)
5693 return
5695 dlen = len(delimiter)
5696 start = self.start
5697 for part in self.value.split(delimiter, maxsplit=maxsplit):
5698 yield StrParsingContext(
5699 self.content,
5700 _value=part,
5701 _start=start,
5702 _end=start + len(part),
5703 n_arg=self.n_arg,
5704 )
5705 start += len(part) + dlen
5707 def _split_space(self, maxsplit: int = -1) -> _t.Generator[StrParsingContext]:
5708 i = 0
5709 n_splits = 0
5710 is_space = True
5711 for part in re.split(r"(\s+)", self.value):
5712 is_space = not is_space
5713 if is_space:
5714 i += len(part)
5715 continue
5717 if not part:
5718 continue
5720 if maxsplit >= 0 and n_splits >= maxsplit:
5721 part = self.value[i:]
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 return
5730 else:
5731 yield StrParsingContext(
5732 self.content,
5733 _value=part,
5734 _start=i,
5735 _end=i + len(part),
5736 n_arg=self.n_arg,
5737 )
5738 i += len(part)
5739 n_splits += 1
5741 def strip(self, chars: str | None = None, /) -> StrParsingContext:
5742 """
5743 Strip current value while keeping track of the current position.
5745 """
5747 l_stripped = self.value.lstrip(chars)
5748 start = self.start + (len(self.value) - len(l_stripped))
5749 stripped = l_stripped.rstrip(chars)
5750 return StrParsingContext(
5751 self.content,
5752 _value=stripped,
5753 _start=start,
5754 _end=start + len(stripped),
5755 n_arg=self.n_arg,
5756 )
5758 def strip_if_non_space(self) -> StrParsingContext:
5759 """
5760 Strip current value unless it entirely consists of spaces.
5762 """
5764 if not self.value or self.value.isspace():
5765 return self
5766 else:
5767 return self.strip()
5769 # If you need more methods, feel free to open an issue or send a PR!
5770 # For now, `split` and `split` is enough.
5773class ConfigParsingContext:
5774 """
5775 Config parsing context tracks path in the config, similar to JSON path.
5777 """
5779 def __init__(
5780 self,
5781 value: object,
5782 /,
5783 *,
5784 parent: ConfigParsingContext | None = None,
5785 key: _t.Any = None,
5786 desc: str | None = None,
5787 ):
5788 self.value: object = value
5789 """
5790 Config value to be validated and parsed.
5792 """
5794 self.parent: ConfigParsingContext | None = parent
5795 """
5796 Parent context.
5798 """
5800 self.key: _t.Any = key
5801 """
5802 Key that was accessed when we've descended from parent context to this one.
5804 Root context has key :data:`None`.
5806 """
5808 self.desc: str | None = desc
5809 """
5810 Additional description of the key.
5812 """
5814 def descend(
5815 self,
5816 value: _t.Any,
5817 key: _t.Any,
5818 desc: str | None = None,
5819 ) -> ConfigParsingContext:
5820 """
5821 Create a new context that adds a new key to the path.
5823 :param value:
5824 inner value that was derived from the current value by accessing it with
5825 the given `key`.
5826 :param key:
5827 key that we use to descend into the current value.
5829 For example, let's say we're parsing a list. We iterate over it and pass
5830 its elements to a sub-parser. Before calling a sub-parser, we need to
5831 make a new context for it. In this situation, we'll pass current element
5832 as `value`, and is index as `key`.
5833 :param desc:
5834 human-readable description for the new context. Will be colorized
5835 and ``%``-formatted with a single named argument `key`.
5837 This is useful when parsing structures that need something more complex than
5838 JSON path. For example, when parsing a key in a dictionary, it is helpful
5839 to set description to something like ``"key of element #%(key)r"``.
5840 This way, parsing errors will have a more clear message:
5842 .. code-block:: text
5844 Parsing error:
5845 In key of element #2:
5846 Expected str, got int: 10
5848 """
5850 return ConfigParsingContext(value, parent=self, key=key, desc=desc)
5852 def make_path(self) -> list[tuple[_t.Any, str | None]]:
5853 """
5854 Capture current path.
5856 :returns:
5857 a list of tuples. First element of each tuple is a key, second is
5858 an additional description.
5860 """
5862 path = []
5864 root = self
5865 while True:
5866 if root.parent is None:
5867 break
5868 else:
5869 path.append((root.key, root.desc))
5870 root = root.parent
5872 path.reverse()
5874 return path
5877class _PathRenderer:
5878 def __init__(self, path: list[tuple[_t.Any, str | None]]):
5879 self._path = path
5881 def __colorized_str__(
5882 self, ctx: yuio.string.ReprContext
5883 ) -> yuio.string.ColorizedString:
5884 code_color = ctx.theme.get_color("msg/text:code/repr hl:repr")
5885 punct_color = ctx.theme.get_color("msg/text:code/repr hl/punct:repr")
5887 msg = yuio.string.ColorizedString(code_color)
5888 msg.start_no_wrap()
5890 for i, (key, desc) in enumerate(self._path):
5891 if desc:
5892 desc = (
5893 (yuio.string)
5894 .colorize(desc, ctx=ctx)
5895 .percent_format({"key": key}, ctx=ctx)
5896 )
5898 if i == len(self._path) - 1:
5899 # Last key.
5900 if msg:
5901 msg.append_color(punct_color)
5902 msg.append_str(", ")
5903 msg.append_colorized_str(desc)
5904 else:
5905 # Element in the middle.
5906 if not msg:
5907 msg.append_str("$")
5908 msg.append_color(punct_color)
5909 msg.append_str(".<")
5910 msg.append_colorized_str(desc)
5911 msg.append_str(">")
5912 elif isinstance(key, str) and re.match(r"^[a-zA-Z_][\w-]*$", key):
5913 # Key is identifier-like, use `x.key` notation.
5914 if not msg:
5915 msg.append_str("$")
5916 msg.append_color(punct_color)
5917 msg.append_str(".")
5918 msg.append_color(code_color)
5919 msg.append_str(key)
5920 else:
5921 # Key is not identifier-like, use `x[key]` notation.
5922 if not msg:
5923 msg.append_str("$")
5924 msg.append_color(punct_color)
5925 msg.append_str("[")
5926 msg.append_color(code_color)
5927 msg.append_str(repr(key))
5928 msg.append_color(punct_color)
5929 msg.append_str("]")
5931 msg.end_no_wrap()
5932 return msg
5935class _CodeRenderer:
5936 def __init__(self, code: str, pos: tuple[int, int], as_cli: bool = False):
5937 self._code = code
5938 self._pos = pos
5939 self._as_cli = as_cli
5941 def __colorized_str__(
5942 self, ctx: yuio.string.ReprContext
5943 ) -> yuio.string.ColorizedString:
5944 width = ctx.width - 2 # Account for indentation.
5946 if width < 10: # 6 symbols for ellipsis and at least 2 wide chars.
5947 return yuio.string.ColorizedString()
5949 start, end = self._pos
5950 if end == start:
5951 end += 1
5953 left = self._code[:start]
5954 center = self._code[start:end]
5955 right = self._code[end:]
5957 l_width = yuio.string.line_width(left)
5958 c_width = yuio.string.line_width(center)
5959 r_width = yuio.string.line_width(right)
5961 available_width = width - (3 if left else 0) - 3
5962 if c_width > available_width:
5963 # Center can't fit: remove left and right side,
5964 # and trim as much center as needed.
5966 left = "..." if l_width > 3 else left
5967 l_width = len(left)
5969 right = ""
5970 r_width = 0
5972 new_c = ""
5973 c_width = 0
5975 for c in center:
5976 cw = yuio.string.line_width(c)
5977 if c_width + cw <= available_width:
5978 new_c += c
5979 c_width += cw
5980 else:
5981 new_c += "..."
5982 c_width += 3
5983 break
5984 center = new_c
5986 if r_width > 3 and l_width + c_width + r_width > width:
5987 # Trim right side.
5988 new_r = ""
5989 r_width = 3
5990 for c in right:
5991 cw = yuio.string.line_width(c)
5992 if l_width + c_width + r_width + cw <= width:
5993 new_r += c
5994 r_width += cw
5995 else:
5996 new_r += "..."
5997 break
5998 right = new_r
6000 if l_width > 3 and l_width + c_width + r_width > width:
6001 # Trim left side.
6002 new_l = ""
6003 l_width = 3
6004 for c in left[::-1]:
6005 cw = yuio.string.line_width(c)
6006 if l_width + c_width + r_width + cw <= width:
6007 new_l += c
6008 l_width += cw
6009 else:
6010 new_l += "..."
6011 break
6012 left = new_l[::-1]
6014 if self._as_cli:
6015 punct_color = ctx.theme.get_color(
6016 "msg/text:code/sh-usage hl/punct:sh-usage"
6017 )
6018 else:
6019 punct_color = ctx.theme.get_color("msg/text:code/text hl/punct:text")
6021 res = yuio.string.ColorizedString()
6022 res.start_no_wrap()
6024 if self._as_cli:
6025 res.append_color(punct_color)
6026 res.append_str("$ ")
6027 res.append_colorized_str(
6028 ctx.str(
6029 yuio.string.Hl(
6030 left.replace("%", "%%") + "%s" + right.replace("%", "%%"), # pyright: ignore[reportArgumentType]
6031 yuio.string.WithBaseColor(
6032 center, base_color="hl/error:sh-usage"
6033 ),
6034 syntax="sh-usage",
6035 )
6036 )
6037 )
6038 else:
6039 text_color = ctx.theme.get_color("msg/text:code/text")
6040 res.append_color(punct_color)
6041 res.append_str("> ")
6042 res.append_color(text_color)
6043 res.append_str(left)
6044 res.append_color(text_color | ctx.theme.get_color("hl/error:text"))
6045 res.append_str(center)
6046 res.append_color(text_color)
6047 res.append_str(right)
6048 res.append_color(yuio.color.Color.NONE)
6049 res.append_str("\n")
6050 if self._as_cli:
6051 text_color = ctx.theme.get_color("msg/text:code/sh-usage")
6052 res.append_color(text_color | ctx.theme.get_color("hl/error:sh-usage"))
6053 else:
6054 text_color = ctx.theme.get_color("msg/text:code/text")
6055 res.append_color(text_color | ctx.theme.get_color("hl/error:text"))
6056 res.append_str(" ")
6057 res.append_str(" " * yuio.string.line_width(left))
6058 res.append_str("~" * yuio.string.line_width(center))
6060 res.end_no_wrap()
6062 return res
6065def _repr_and_adjust_pos(s: str, pos: tuple[int, int]):
6066 start, end = pos
6068 left = json.dumps(s[:start])[:-1]
6069 center = json.dumps(s[start:end])[1:-1]
6070 right = json.dumps(s[end:])[1:]
6072 return left + center + right, (len(left), len(left) + len(center))
6075_FromTypeHintCallback: _t.TypeAlias = _t.Callable[
6076 [type, type | None, tuple[object, ...]], Parser[object] | None
6077]
6080_FROM_TYPE_HINT_CALLBACKS: list[tuple[_FromTypeHintCallback, bool]] = []
6081_FROM_TYPE_HINT_DELIM_SUGGESTIONS: list[str | None] = [
6082 None,
6083 ",",
6084 "@",
6085 "/",
6086 "=",
6087]
6090class _FromTypeHintDepth(threading.local):
6091 def __init__(self):
6092 self.depth: int = 0
6093 self.uses_delim = False
6096_FROM_TYPE_HINT_DEPTH: _FromTypeHintDepth = _FromTypeHintDepth()
6099@_t.overload
6100def from_type_hint(ty: type[T], /) -> Parser[T]: ...
6101@_t.overload
6102def from_type_hint(ty: object, /) -> Parser[object]: ...
6103def from_type_hint(ty: _t.Any, /) -> Parser[object]:
6104 """from_type_hint(ty: type[T], /) -> Parser[T]
6106 Create parser from a type hint.
6108 :param ty:
6109 a type hint.
6111 This type hint should not contain strings or forward references. Make sure
6112 they're resolved before passing it to this function.
6113 :returns:
6114 a parser instance created from type hint.
6115 :raises:
6116 :class:`TypeError` if type hint contains forward references or types
6117 that don't have associated parsers.
6118 :example:
6119 ::
6121 >>> from_type_hint(list[int] | None)
6122 Optional(List(Int))
6124 """
6126 result = _from_type_hint(ty)
6127 setattr(result, "_Parser__typehint", ty)
6128 return result
6131def _from_type_hint(ty: _t.Any, /) -> Parser[object]:
6132 if isinstance(ty, (str, _t.ForwardRef)):
6133 raise TypeError(f"forward references are not supported here: {ty}")
6135 origin = _t.get_origin(ty)
6136 args = _t.get_args(ty)
6138 if origin is _t.Annotated:
6139 p = from_type_hint(args[0])
6140 for arg in args[1:]:
6141 if isinstance(arg, PartialParser):
6142 p = arg.wrap(p)
6143 return p
6145 for cb, uses_delim in _FROM_TYPE_HINT_CALLBACKS:
6146 prev_uses_delim = _FROM_TYPE_HINT_DEPTH.uses_delim
6147 _FROM_TYPE_HINT_DEPTH.uses_delim = uses_delim
6148 _FROM_TYPE_HINT_DEPTH.depth += uses_delim
6149 try:
6150 p = cb(ty, origin, args)
6151 if p is not None:
6152 return p
6153 finally:
6154 _FROM_TYPE_HINT_DEPTH.uses_delim = prev_uses_delim
6155 _FROM_TYPE_HINT_DEPTH.depth -= uses_delim
6157 if _tx.is_union(origin):
6158 if is_optional := (type(None) in args):
6159 args = list(args)
6160 args.remove(type(None))
6161 if len(args) == 1:
6162 p = from_type_hint(args[0])
6163 else:
6164 p = Union(*[from_type_hint(arg) for arg in args])
6165 if is_optional:
6166 p = Optional(p)
6167 return p
6168 else:
6169 raise TypeError(f"unsupported type {_tx.type_repr(ty)}")
6172@_t.overload
6173def register_type_hint_conversion(
6174 cb: _FromTypeHintCallback,
6175 /,
6176 *,
6177 uses_delim: bool = False,
6178) -> _FromTypeHintCallback: ...
6179@_t.overload
6180def register_type_hint_conversion(
6181 *,
6182 uses_delim: bool = False,
6183) -> _t.Callable[[_FromTypeHintCallback], _FromTypeHintCallback]: ...
6184def register_type_hint_conversion(
6185 cb: _FromTypeHintCallback | None = None,
6186 /,
6187 *,
6188 uses_delim: bool = False,
6189) -> (
6190 _FromTypeHintCallback | _t.Callable[[_FromTypeHintCallback], _FromTypeHintCallback]
6191):
6192 """
6193 Register a new converter from a type hint to a parser.
6195 This function takes a callback that accepts three positional arguments:
6197 - a type hint,
6198 - a type hint's origin (as defined by :func:`typing.get_origin`),
6199 - a type hint's args (as defined by :func:`typing.get_args`).
6201 The callback should return a parser if it can, or :data:`None` otherwise.
6203 All registered callbacks are tried in the same order
6204 as they were registered.
6206 If `uses_delim` is :data:`True`, callback can use
6207 :func:`suggest_delim_for_type_hint_conversion`.
6209 This function can be used as a decorator.
6211 :param cb:
6212 a function that should inspect a type hint and possibly return a parser.
6213 :param uses_delim:
6214 indicates that callback will use
6215 :func:`suggest_delim_for_type_hint_conversion`.
6216 :example:
6217 .. invisible-code-block: python
6219 class MyType: ...
6220 class MyTypeParser(ValueParser[MyType]):
6221 def __init__(self): super().__init__(MyType)
6222 def parse_with_ctx(self, ctx: StrParsingContext, /): ...
6223 def parse_config_with_ctx(self, value, /): ...
6224 def to_json_schema(self, ctx, /): ...
6225 def to_json_value(self, value, /): ...
6227 .. code-block:: python
6229 @register_type_hint_conversion
6230 def my_type_conversion(ty, origin, args):
6231 if ty is MyType:
6232 return MyTypeParser()
6233 else:
6234 return None
6236 ::
6238 >>> from_type_hint(MyType)
6239 MyTypeParser
6241 .. invisible-code-block: python
6243 del _FROM_TYPE_HINT_CALLBACKS[-1]
6245 """
6247 def registrar(cb: _FromTypeHintCallback):
6248 _FROM_TYPE_HINT_CALLBACKS.append((cb, uses_delim))
6249 return cb
6251 return registrar(cb) if cb is not None else registrar
6254def suggest_delim_for_type_hint_conversion() -> str | None:
6255 """
6256 Suggests a delimiter for use in type hint converters.
6258 When creating a parser for a collection of items based on a type hint,
6259 it is important to use different delimiters for nested collections.
6260 This function can suggest such a delimiter based on the current type hint's depth.
6262 .. invisible-code-block: python
6264 class MyCollection(list, _t.Generic[T]): ...
6265 class MyCollectionParser(CollectionParser[MyCollection[T], T], _t.Generic[T]):
6266 def __init__(self, inner: Parser[T], /, *, delimiter: _t.Optional[str] = None):
6267 super().__init__(inner, ty=MyCollection, ctor=MyCollection, delimiter=delimiter)
6268 def to_json_schema(self, ctx, /): ...
6269 def to_json_value(self, value, /): ...
6271 :raises:
6272 :class:`RuntimeError` if called from a type converter that
6273 didn't set `uses_delim` to :data:`True`.
6274 :example:
6275 .. code-block:: python
6277 @register_type_hint_conversion(uses_delim=True)
6278 def my_collection_conversion(ty, origin, args):
6279 if origin is MyCollection:
6280 return MyCollectionParser(
6281 from_type_hint(args[0]),
6282 delimiter=suggest_delim_for_type_hint_conversion(),
6283 )
6284 else:
6285 return None
6287 ::
6289 >>> parser = from_type_hint(MyCollection[MyCollection[str]])
6290 >>> parser
6291 MyCollectionParser(MyCollectionParser(Str))
6292 >>> # First delimiter is `None`, meaning split by whitespace:
6293 >>> parser._delimiter is None
6294 True
6295 >>> # Second delimiter is `","`:
6296 >>> parser._inner._delimiter == ","
6297 True
6299 ..
6300 >>> del _FROM_TYPE_HINT_CALLBACKS[-1]
6302 """
6304 if not _FROM_TYPE_HINT_DEPTH.uses_delim:
6305 raise RuntimeError(
6306 "looking up delimiters is not available in this callback; did you forget"
6307 " to pass `uses_delim=True` when registering this callback?"
6308 )
6310 depth = _FROM_TYPE_HINT_DEPTH.depth - 1
6311 if depth < len(_FROM_TYPE_HINT_DELIM_SUGGESTIONS):
6312 return _FROM_TYPE_HINT_DELIM_SUGGESTIONS[depth]
6313 else:
6314 return None
6317register_type_hint_conversion(lambda ty, origin, args: Str() if ty is str else None)
6318register_type_hint_conversion(lambda ty, origin, args: Int() if ty is int else None)
6319register_type_hint_conversion(lambda ty, origin, args: Float() if ty is float else None)
6320register_type_hint_conversion(lambda ty, origin, args: Bool() if ty is bool else None)
6321register_type_hint_conversion(
6322 lambda ty, origin, args: (
6323 Enum(ty) if isinstance(ty, type) and issubclass(ty, enum.Enum) else None
6324 )
6325)
6326register_type_hint_conversion(
6327 lambda ty, origin, args: Decimal() if ty is decimal.Decimal else None
6328)
6329register_type_hint_conversion(
6330 lambda ty, origin, args: Fraction() if ty is fractions.Fraction else None
6331)
6332register_type_hint_conversion(
6333 lambda ty, origin, args: (
6334 List(
6335 from_type_hint(args[0]), delimiter=suggest_delim_for_type_hint_conversion()
6336 )
6337 if origin is list
6338 else None
6339 ),
6340 uses_delim=True,
6341)
6342register_type_hint_conversion(
6343 lambda ty, origin, args: (
6344 Set(from_type_hint(args[0]), delimiter=suggest_delim_for_type_hint_conversion())
6345 if origin is set
6346 else None
6347 ),
6348 uses_delim=True,
6349)
6350register_type_hint_conversion(
6351 lambda ty, origin, args: (
6352 FrozenSet(
6353 from_type_hint(args[0]), delimiter=suggest_delim_for_type_hint_conversion()
6354 )
6355 if origin is frozenset
6356 else None
6357 ),
6358 uses_delim=True,
6359)
6360register_type_hint_conversion(
6361 lambda ty, origin, args: (
6362 Dict(
6363 from_type_hint(args[0]),
6364 from_type_hint(args[1]),
6365 delimiter=suggest_delim_for_type_hint_conversion(),
6366 )
6367 if origin is dict
6368 else None
6369 ),
6370 uses_delim=True,
6371)
6372register_type_hint_conversion(
6373 lambda ty, origin, args: (
6374 Tuple(
6375 *[from_type_hint(arg) for arg in args],
6376 delimiter=suggest_delim_for_type_hint_conversion(),
6377 )
6378 if origin is tuple and ... not in args
6379 else None
6380 ),
6381 uses_delim=True,
6382)
6383register_type_hint_conversion(
6384 lambda ty, origin, args: Path() if ty is pathlib.Path else None
6385)
6386register_type_hint_conversion(
6387 lambda ty, origin, args: Json() if ty is yuio.json_schema.JsonValue else None
6388)
6389register_type_hint_conversion(
6390 lambda ty, origin, args: DateTime() if ty is datetime.datetime else None
6391)
6392register_type_hint_conversion(
6393 lambda ty, origin, args: Date() if ty is datetime.date else None
6394)
6395register_type_hint_conversion(
6396 lambda ty, origin, args: Time() if ty is datetime.time else None
6397)
6398register_type_hint_conversion(
6399 lambda ty, origin, args: TimeDelta() if ty is datetime.timedelta else None
6400)
6401register_type_hint_conversion(
6402 lambda ty, origin, args: (
6403 Literal(*_t.cast(tuple[_t.Any, ...], args)) if origin is _t.Literal else None
6404 )
6405)
6408@register_type_hint_conversion
6409def __secret(ty, origin, args):
6410 if ty is SecretValue:
6411 raise TypeError("yuio.secret.SecretValue requires type arguments")
6412 if origin is SecretValue:
6413 if len(args) == 1:
6414 return Secret(from_type_hint(args[0]))
6415 else: # pragma: no cover
6416 raise TypeError(
6417 f"yuio.secret.SecretValue requires 1 type argument, got {len(args)}"
6418 )
6419 return None
6422def _is_optional_parser(parser: Parser[_t.Any] | None, /) -> bool:
6423 while parser is not None:
6424 if isinstance(parser, Optional):
6425 return True
6426 parser = parser.__wrapped_parser__
6427 return False
6430def _is_bool_parser(parser: Parser[_t.Any] | None, /) -> bool:
6431 while parser is not None:
6432 if isinstance(parser, Bool):
6433 return True
6434 parser = parser.__wrapped_parser__
6435 return False