Coverage for yuio / parse.py: 92%
1634 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-05 11:41 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-05 11:41 +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:: Decimal
69.. autoclass:: Fraction
71.. autoclass:: Json
73.. autoclass:: List
75.. autoclass:: Set
77.. autoclass:: FrozenSet
79.. autoclass:: Dict
81.. autoclass:: Tuple
83.. autoclass:: Optional
85.. autoclass:: Union
87.. autoclass:: Path
89.. autoclass:: NonExistentPath
91.. autoclass:: ExistingPath
93.. autoclass:: File
95.. autoclass:: Dir
97.. autoclass:: GitRepo
99.. autoclass:: Secret
102.. _validating-parsers:
104Validators
105----------
107.. autoclass:: Regex
109.. autoclass:: Bound
111.. autoclass:: Gt
113.. autoclass:: Ge
115.. autoclass:: Lt
117.. autoclass:: Le
119.. autoclass:: LenBound
121.. autoclass:: LenGt
123.. autoclass:: LenGe
125.. autoclass:: LenLt
127.. autoclass:: LenLe
129.. autoclass:: OneOf
132Auxiliary parsers
133-----------------
135.. autoclass:: Map
137.. autoclass:: Apply
139.. autoclass:: Lower
141.. autoclass:: Upper
143.. autoclass:: CaseFold
145.. autoclass:: Strip
147.. autoclass:: WithMeta
150Deriving parsers from type hints
151--------------------------------
153There is a way to automatically derive basic parsers from type hints
154(used by :mod:`yuio.config`):
156.. autofunction:: from_type_hint
159.. _partial parsers:
161Partial parsers
162---------------
164Sometimes it's not convenient to provide a parser for a complex type when
165all we need is to make a small adjustment to a part of the type. For example:
167.. invisible-code-block: python
169 from yuio.config import Config, field
171.. code-block:: python
173 class AppConfig(Config):
174 max_line_width: int | str = field(
175 default="default",
176 parser=Union(
177 Gt(Int(), 0),
178 OneOf(Str(), ["default", "unlimited", "keep"]),
179 ),
180 )
182.. invisible-code-block: python
184 AppConfig()
186Instead, we can use :obj:`typing.Annotated` to attach validating parsers directly
187to type hints:
189.. code-block:: python
191 from typing import Annotated
194 class AppConfig(Config):
195 max_line_width: (
196 Annotated[int, Gt(0)]
197 | Annotated[str, OneOf(["default", "unlimited", "keep"])]
198 ) = "default"
200.. invisible-code-block: python
202 AppConfig()
204Notice that we didn't specify inner parsers for :class:`Gt` and :class:`OneOf`.
205This is because their internal parsers are derived from type hint, so we only care
206about their settings.
208Parsers created in such a way are called "partial". You can't use a partial parser
209on its own because it doesn't have full information about the object's type.
210You can only use partial parsers in type hints::
212 >>> partial_parser = List(delimiter=",")
213 >>> partial_parser.parse_with_ctx("1,2,3") # doctest: +ELLIPSIS
214 Traceback (most recent call last):
215 ...
216 TypeError: List requires an inner parser
217 ...
220Other parser methods
221--------------------
223:class:`Parser` defines some more methods and attributes.
224They're rarely used because Yuio handles everything they do itself.
225However, you can still use them in case you need to.
227.. autoclass:: Parser
228 :noindex:
230 .. autoattribute:: __wrapped_parser__
232 .. automethod:: parse_with_ctx
234 .. automethod:: parse_many_with_ctx
236 .. automethod:: parse_config_with_ctx
238 .. automethod:: get_nargs
240 .. automethod:: check_type
242 .. automethod:: assert_type
244 .. automethod:: describe
246 .. automethod:: describe_or_def
248 .. automethod:: describe_many
250 .. automethod:: describe_value
252 .. automethod:: options
254 .. automethod:: completer
256 .. automethod:: widget
258 .. automethod:: to_json_schema
260 .. automethod:: to_json_value
262 .. automethod:: is_secret
265Building your own parser
266------------------------
268.. _parser hierarchy:
270Understanding parser hierarchy
271~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
273The topmost class in the parser hierarchy is :class:`PartialParser`. It provides
274abstract methods to deal with `partial parsers`_. The primary parser interface,
275:class:`Parser`, is derived from it. Below :class:`Parser`, there are several
276abstract classes that provide boilerplate implementations for common use cases.
278.. raw:: html
280 <p>
281 <pre class="mermaid">
282 ---
283 config:
284 class:
285 hideEmptyMembersBox: true
286 ---
287 classDiagram
289 class PartialParser
290 click PartialParser href "#yuio.parse.PartialParser" "yuio.parse.PartialParser"
292 class Parser
293 click Parser href "#yuio.parse.Parser" "yuio.parse.Parser"
294 PartialParser <|-- Parser
296 class ValueParser
297 click ValueParser href "#yuio.parse.ValueParser" "yuio.parse.ValueParser"
298 Parser <|-- ValueParser
300 class WrappingParser
301 click WrappingParser href "#yuio.parse.WrappingParser" "yuio.parse.WrappingParser"
302 Parser <|-- WrappingParser
304 class MappingParser
305 click MappingParser href "#yuio.parse.MappingParser" "yuio.parse.MappingParser"
306 WrappingParser <|-- MappingParser
308 class Map
309 click Map href "#yuio.parse.Map" "yuio.parse.Map"
310 MappingParser <|-- Map
312 class Apply
313 click Apply href "#yuio.parse.Apply" "yuio.parse.Apply"
314 MappingParser <|-- Apply
316 class ValidatingParser
317 click ValidatingParser href "#yuio.parse.ValidatingParser" "yuio.parse.ValidatingParser"
318 Apply <|-- ValidatingParser
320 class CollectionParser
321 click CollectionParser href "#yuio.parse.CollectionParser" "yuio.parse.CollectionParser"
322 ValueParser <|-- CollectionParser
323 WrappingParser <|-- CollectionParser
324 </pre>
325 </p>
327The reason for separation of :class:`PartialParser` and :class:`Parser`
328is better type checking. We want to prevent users from making a mistake of providing
329a partial parser to a function that expect a fully initialized parser. For example,
330consider this code:
332.. skip: next
334.. code-block:: python
336 yuio.io.ask("Enter some names", parser=List())
338This will fail because :class:`~List` needs an inner parser to function.
340To annotate this behavior, we provide type hints for ``__new__`` methods
341on each parser. When an inner parser is given, ``__new__`` is annotated as
342returning an instance of :class:`Parser`. When inner parser is omitted,
343``__new__`` is annotated as returning an instance of :class:`PartialParser`:
345.. skip: next
347.. code-block:: python
349 from typing import TYPE_CHECKING, Any, Generic, overload
351 class List(..., Generic[T]):
352 if TYPE_CHECKING:
353 @overload
354 def __new__(cls, delimiter: str | None = None) -> PartialParser:
355 ...
356 @overload
357 def __new__(cls, inner: Parser[T], delimiter: str | None = None) -> PartialParser:
358 ...
359 def __new__(cls, *args, **kwargs) -> Any:
360 ...
362With these type hints, our example will fail to type check: :func:`yuio.io.ask`
363expects a :class:`Parser`, but ``List.__new__`` returns a :class:`PartialParser`.
365Unfortunately, this means that all parsers derived from :class:`WrappingParser`
366must provide appropriate type hints for their ``__new__`` method.
368.. autoclass:: PartialParser
369 :members:
372Parsing contexts
373~~~~~~~~~~~~~~~~
375To track location of errors, parsers work with parsing context:
376:class:`StrParsingContext` for parsing raw strings, and :class:`ConfigParsingContext`
377for parsing configs.
379When raising a :class:`ParsingError`, pass context to it so that we can show error
380location to the user.
382.. autoclass:: StrParsingContext
383 :members:
385.. autoclass:: ConfigParsingContext
386 :members:
389Base classes
390~~~~~~~~~~~~
392.. autoclass:: ValueParser
394.. autoclass:: WrappingParser
396 .. autoattribute:: _inner
398 .. autoattribute:: _inner_raw
400.. autoclass:: MappingParser
402.. autoclass:: ValidatingParser
404 .. autoattribute:: __wrapped_parser__
405 :noindex:
407 .. automethod:: _validate
409.. autoclass:: CollectionParser
411 .. autoattribute:: _allow_completing_duplicates
414Adding type hint conversions
415~~~~~~~~~~~~~~~~~~~~~~~~~~~~
417You can register a converter so that :func:`from_type_hint` can derive custom
418parsers from type hints:
420.. autofunction:: register_type_hint_conversion(cb: Cb) -> Cb
422When implementing a callback, you might need to specify a delimiter
423for a collection parser. Use :func:`suggest_delim_for_type_hint_conversion`:
425.. autofunction:: suggest_delim_for_type_hint_conversion
428Re-imports
429----------
431.. type:: JsonValue
432 :no-index:
434 Alias of :obj:`yuio.json_schema.JsonValue`.
436.. type:: SecretString
437 :no-index:
439 Alias of :obj:`yuio.secret.SecretString`.
441.. type:: SecretValue
442 :no-index:
444 Alias of :obj:`yuio.secret.SecretValue`.
446"""
448from __future__ import annotations
450import abc
451import argparse
452import contextlib
453import dataclasses
454import datetime
455import decimal
456import enum
457import fractions
458import functools
459import json
460import pathlib
461import re
462import threading
463import traceback
464import types
466import yuio
467import yuio.color
468import yuio.complete
469import yuio.json_schema
470import yuio.string
471import yuio.widget
472from yuio.json_schema import JsonValue
473from yuio.secret import SecretString, SecretValue
474from yuio.util import _find_docs
475from yuio.util import to_dash_case as _to_dash_case
477import typing
478import yuio._typing_ext as _tx
479from typing import TYPE_CHECKING
481if TYPE_CHECKING:
482 import typing_extensions as _t
483else:
484 from yuio import _typing as _t
486__all__ = [
487 "Apply",
488 "Bool",
489 "Bound",
490 "CaseFold",
491 "CollectionParser",
492 "ConfigParsingContext",
493 "Date",
494 "DateTime",
495 "Decimal",
496 "Dict",
497 "Dir",
498 "Enum",
499 "ExistingPath",
500 "File",
501 "Float",
502 "Fraction",
503 "FrozenSet",
504 "Ge",
505 "GitRepo",
506 "Gt",
507 "Int",
508 "Json",
509 "JsonValue",
510 "Le",
511 "LenBound",
512 "LenGe",
513 "LenGt",
514 "LenLe",
515 "LenLt",
516 "List",
517 "Lower",
518 "Lt",
519 "Map",
520 "MappingParser",
521 "NonExistentPath",
522 "OneOf",
523 "Optional",
524 "Parser",
525 "ParsingError",
526 "PartialParser",
527 "Path",
528 "Regex",
529 "Secret",
530 "SecretString",
531 "SecretValue",
532 "Set",
533 "Str",
534 "StrParsingContext",
535 "Strip",
536 "Time",
537 "TimeDelta",
538 "Tuple",
539 "Union",
540 "Upper",
541 "ValidatingParser",
542 "ValueParser",
543 "WithMeta",
544 "WrappingParser",
545 "from_type_hint",
546 "register_type_hint_conversion",
547 "suggest_delim_for_type_hint_conversion",
548]
550T_co = _t.TypeVar("T_co", covariant=True)
551T = _t.TypeVar("T")
552U = _t.TypeVar("U")
553K = _t.TypeVar("K")
554V = _t.TypeVar("V")
555C = _t.TypeVar("C", bound=_t.Collection[object])
556C2 = _t.TypeVar("C2", bound=_t.Collection[object])
557Sz = _t.TypeVar("Sz", bound=_t.Sized)
558Cmp = _t.TypeVar("Cmp", bound=_tx.SupportsLt[_t.Any])
559E = _t.TypeVar("E", bound=enum.Enum)
560TU = _t.TypeVar("TU", bound=tuple[object, ...])
561P = _t.TypeVar("P", bound="Parser[_t.Any]")
562Params = _t.ParamSpec("Params")
565class ParsingError(yuio.PrettyException, ValueError, argparse.ArgumentTypeError):
566 """PrettyException(msg: typing.LiteralString, /, *args: typing.Any, ctx: ConfigParsingContext | StrParsingContext | None = None, fallback_msg: typing.LiteralString | None = None, **kwargs)
567 PrettyException(msg: str, /, *, ctx: ConfigParsingContext | StrParsingContext | None = None, fallback_msg: typing.LiteralString | None = None, **kwargs)
569 Raised when parsing or validation fails.
571 :param msg:
572 message to format. Can be a literal string or any other colorable object.
574 If it's given as a literal string, additional arguments for ``%``-formatting
575 may be given. Otherwise, giving additional arguments will cause
576 a :class:`TypeError`.
577 :param args:
578 arguments for ``%``-formatting the message.
579 :param fallback_msg:
580 fallback message that's guaranteed not to include representation of the faulty
581 value, will replace `msg` when parsing secret values.
583 .. warning::
585 This parameter must not include contents of the faulty value. It is typed
586 as :class:`~typing.LiteralString` as a deterrent; if you need string
587 interpolation, create an instance of :class:`ParsingError` and set
588 :attr:`~ParsingError.fallback_msg` directly.
589 :param ctx:
590 current error context that will be used to set :attr:`~ParsingError.raw`,
591 :attr:`~ParsingError.pos`, and other attributes.
592 :param kwargs:
593 other keyword arguments set :attr:`~ParsingError.raw`,
594 :attr:`~ParsingError.pos`, :attr:`~ParsingError.n_arg`,
595 :attr:`~ParsingError.path`.
597 """
599 @_t.overload
600 def __init__(
601 self,
602 msg: _t.LiteralString,
603 /,
604 *args,
605 fallback_msg: _t.LiteralString | None = None,
606 ctx: ConfigParsingContext | StrParsingContext | None = None,
607 raw: str | None = None,
608 pos: tuple[int, int] | None = None,
609 n_arg: int | None = None,
610 path: list[tuple[_t.Any, str | None]] | None = None,
611 ): ...
612 @_t.overload
613 def __init__(
614 self,
615 msg: yuio.string.ToColorable | None | yuio.Missing = yuio.MISSING,
616 /,
617 *,
618 fallback_msg: _t.LiteralString | None = None,
619 ctx: ConfigParsingContext | StrParsingContext | None = None,
620 raw: str | None = None,
621 pos: tuple[int, int] | None = None,
622 n_arg: int | None = None,
623 path: list[tuple[_t.Any, str | None]] | None = None,
624 ): ...
625 def __init__(
626 self,
627 *args,
628 fallback_msg: _t.LiteralString | None = None,
629 ctx: ConfigParsingContext | StrParsingContext | None = None,
630 raw: str | None = None,
631 pos: tuple[int, int] | None = None,
632 n_arg: int | None = None,
633 path: list[tuple[_t.Any, str | None]] | None = None,
634 ):
635 super().__init__(*args)
637 if ctx:
638 if isinstance(ctx, ConfigParsingContext):
639 path = path if path is not None else ctx.make_path()
640 else:
641 raw = raw if raw is not None else ctx.content
642 pos = pos if pos is not None else (ctx.start, ctx.end)
643 n_arg = n_arg if n_arg is not None else ctx.n_arg
645 self.fallback_msg: yuio.string.Colorable | None = fallback_msg
646 """
647 This message will be used if error occurred while parsing a secret value.
649 .. warning::
651 This colorable must not include contents of the faulty value.
653 """
655 self.raw: str | None = raw
656 """
657 For errors that happened when parsing a string, this attribute contains the
658 original string.
660 """
662 self.pos: tuple[int, int] | None = pos
663 """
664 For errors that happened when parsing a string, this attribute contains
665 position in the original string in which this error has occurred (start
666 and end indices).
668 """
670 self.n_arg: int | None = n_arg
671 """
672 For errors that happened in :meth:`~Parser.parse_many`, this attribute contains
673 index of the string in which this error has occurred.
675 """
677 self.path: list[tuple[_t.Any, str | None]] | None = path
678 """
679 For errors that happened in :meth:`~Parser.parse_config_with_ctx`, this attribute
680 contains path to the value in which this error has occurred.
682 """
684 @classmethod
685 def type_mismatch(
686 cls,
687 value: _t.Any,
688 /,
689 *expected: type | str,
690 ctx: ConfigParsingContext | StrParsingContext | None = None,
691 raw: str | None = None,
692 pos: tuple[int, int] | None = None,
693 n_arg: int | None = None,
694 path: list[tuple[_t.Any, str | None]] | None = None,
695 ):
696 """type_mismatch(value: _t.Any, /, *expected: type | str, **kwargs)
698 Make an error with a standard message "expected type X, got type Y".
700 :param value:
701 value of an unexpected type.
702 :param expected:
703 expected types. Each argument can be a type or a string that describes
704 a type.
705 :param kwargs:
706 keyword arguments will be passed to constructor.
707 :example:
708 ::
710 >>> raise ParsingError.type_mismatch(10, str)
711 Traceback (most recent call last):
712 ...
713 yuio.parse.ParsingError: Expected str, got int: 10
715 """
717 err = cls(
718 "Expected %s, got `%s`: `%r`",
719 yuio.string.Or(map(yuio.string.TypeRepr, expected)),
720 yuio.string.TypeRepr(type(value)),
721 value,
722 ctx=ctx,
723 raw=raw,
724 pos=pos,
725 n_arg=n_arg,
726 path=path,
727 )
728 err.fallback_msg = yuio.string.Format(
729 "Expected %s, got `%s`",
730 yuio.string.Or(map(yuio.string.TypeRepr, expected)),
731 yuio.string.TypeRepr(type(value)),
732 )
734 return err
736 def set_ctx(self, ctx: ConfigParsingContext | StrParsingContext):
737 if isinstance(ctx, ConfigParsingContext):
738 self.path = ctx.make_path()
739 else:
740 self.raw = ctx.content
741 self.pos = (ctx.start, ctx.end)
742 self.n_arg = ctx.n_arg
744 def to_colorable(self) -> yuio.string.Colorable:
745 colorable = super().to_colorable()
746 if self.path:
747 colorable = yuio.string.Format(
748 "In `%s`:\n%s",
749 _PathRenderer(self.path),
750 yuio.string.Indent(colorable),
751 )
752 if self.pos and self.raw and self.pos != (0, len(self.raw)):
753 raw, pos = _repr_and_adjust_pos(self.raw, self.pos)
754 colorable = yuio.string.Stack(
755 _CodeRenderer(raw, pos),
756 colorable,
757 )
758 return colorable
761class PartialParser(abc.ABC):
762 """
763 An interface of a partial parser.
765 """
767 def __init__(self):
768 self.__orig_traceback = traceback.extract_stack()
769 while self.__orig_traceback and self.__orig_traceback[-1].filename.endswith(
770 "yuio/parse.py"
771 ):
772 self.__orig_traceback.pop()
773 super().__init__()
775 def _get_orig_traceback(self) -> traceback.StackSummary:
776 """
777 Get stack summary for the place where this partial parser was created.
779 """
781 return self.__orig_traceback # pragma: no cover
783 @contextlib.contextmanager
784 def _patch_stack_summary(self):
785 """
786 Attach original traceback to any exception that's raised
787 within this context manager.
789 """
791 try:
792 yield
793 except Exception as e:
794 stack_summary_text = "Traceback (most recent call last):\n" + "".join(
795 self.__orig_traceback.format()
796 )
797 e.args = (
798 f"{e}\n\nThe above error happened because of "
799 f"this type hint:\n\n{stack_summary_text}",
800 )
801 setattr(e, "__yuio_stack_summary_text__", stack_summary_text)
802 raise e
804 @abc.abstractmethod
805 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
806 """
807 Apply this partial parser.
809 When Yuio checks type annotations, it derives a parser for the given type hint,
810 and the applies all partial parsers to it.
812 For example, given this type hint:
814 .. invisible-code-block: python
816 from typing import Annotated
818 .. code-block:: python
820 field: Annotated[str, Map(str.lower)]
822 Yuio will first infer parser for string (:class:`Str`), then it will pass
823 this parser to ``Map.wrap``.
825 :param parser:
826 a parser instance that was created by inspecting type hints
827 and previous annotations.
828 :returns:
829 a result of upgrading this parser from partial to full. This method
830 usually returns `self`.
831 :raises:
832 :class:`TypeError` if this parser can't be wrapped. Specifically, this
833 method should raise a :class:`TypeError` for any non-partial parser.
835 """
837 raise NotImplementedError()
840class Parser(PartialParser, _t.Generic[T_co]):
841 """
842 Base class for parsers.
844 """
846 # Original type hint from which this parser was derived.
847 __typehint: _t.Any = None
849 __wrapped_parser__: Parser[object] | None = None
850 """
851 An attribute for unwrapping parsers that validate or map results
852 of other parsers.
854 """
856 @_t.final
857 def parse(self, value: str, /) -> T_co:
858 """
859 Parse user input, raise :class:`ParsingError` on failure.
861 :param value:
862 value to parse.
863 :returns:
864 a parsed and processed value.
865 :raises:
866 :class:`ParsingError`.
868 """
870 return self.parse_with_ctx(StrParsingContext(value))
872 @abc.abstractmethod
873 def parse_with_ctx(self, ctx: StrParsingContext, /) -> T_co:
874 """
875 Actual implementation of :meth:`~Parser.parse`, receives parsing context instead
876 of a raw string.
878 :param ctx:
879 value to parse, wrapped into a parsing context.
880 :returns:
881 a parsed and processed value.
882 :raises:
883 :class:`ParsingError`.
885 """
887 raise NotImplementedError()
889 def parse_many(self, value: _t.Sequence[str], /) -> T_co:
890 """
891 For collection parsers, parse and validate collection
892 by parsing its items one-by-one.
894 :param value:
895 collection of values to parse.
896 :returns:
897 each value parsed and assembled into the target collection.
898 :raises:
899 :class:`ParsingError`. Also raises :class:`RuntimeError` if trying to call
900 this method on a parser that doesn't supports parsing collections
901 of objects.
902 :example:
903 ::
905 >>> # Let's say we're parsing a set of ints.
906 >>> parser = Set(Int())
908 >>> # And the user enters collection items one-by-one.
909 >>> user_input = ['1', '2', '3']
911 >>> # We can parse collection from its items:
912 >>> parser.parse_many(user_input)
913 {1, 2, 3}
915 """
917 return self.parse_many_with_ctx(
918 [StrParsingContext(item, n_arg=i) for i, item in enumerate(value)]
919 )
921 @abc.abstractmethod
922 def parse_many_with_ctx(self, ctxs: _t.Sequence[StrParsingContext], /) -> T_co:
923 """
924 Actual implementation of :meth:`~Parser.parse_many`, receives parsing contexts
925 instead of a raw strings.
927 :param ctxs:
928 values to parse, wrapped into a parsing contexts.
929 :returns:
930 a parsed and processed value.
931 :raises:
932 :class:`ParsingError`.
934 """
936 raise NotImplementedError()
938 @abc.abstractmethod
939 def supports_parse_many(self) -> bool:
940 """
941 Return :data:`True` if this parser returns a collection
942 and so supports :meth:`~Parser.parse_many`.
944 :returns:
945 :data:`True` if :meth:`~Parser.parse_many` is safe to call.
947 """
949 raise NotImplementedError()
951 @_t.final
952 def parse_config(self, value: object, /) -> T_co:
953 """
954 Parse value from a config, raise :class:`ParsingError` on failure.
956 This method accepts python values that would result from
957 parsing json, yaml, and similar formats.
959 :param value:
960 config value to parse.
961 :returns:
962 verified and processed config value.
963 :raises:
964 :class:`ParsingError`.
965 :example:
966 ::
968 >>> # Let's say we're parsing a set of ints.
969 >>> parser = Set(Int())
971 >>> # And we're loading it from json.
972 >>> import json
973 >>> user_config = json.loads('[1, 2, 3]')
975 >>> # We can process parsed json:
976 >>> parser.parse_config(user_config)
977 {1, 2, 3}
979 """
981 return self.parse_config_with_ctx(ConfigParsingContext(value))
983 @abc.abstractmethod
984 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> T_co:
985 """
986 Actual implementation of :meth:`~Parser.parse_config`, receives parsing context
987 instead of a raw value.
989 :param ctx:
990 config value to parse, wrapped into a parsing contexts.
991 :returns:
992 verified and processed config value.
993 :raises:
994 :class:`ParsingError`.
996 """
998 raise NotImplementedError()
1000 @abc.abstractmethod
1001 def get_nargs(self) -> _t.Literal["+", "*"] | int:
1002 """
1003 Generate ``nargs`` for argparse.
1005 :returns:
1006 `nargs` as defined by argparse. If :meth:`~Parser.supports_parse_many`
1007 returns :data:`True`, value should be ``"+"`` or an integer. Otherwise,
1008 value should be ``1``.
1010 """
1012 raise NotImplementedError()
1014 @abc.abstractmethod
1015 def check_type(self, value: object, /) -> _t.TypeGuard[T_co]:
1016 """
1017 Check whether the parser can handle a particular value in its
1018 :meth:`~Parser.describe_value` and other methods.
1020 This function is used to raise :class:`TypeError`\\ s in function that accept
1021 unknown values. Parsers like :class:`Union` rely on :class:`TypeError`\\ s
1022 to dispatch values to correct sub-parsers.
1024 .. note::
1026 For performance reasons, this method should not inspect contents
1027 of containers, only their type (otherwise some methods turn from linear
1028 to quadratic).
1030 This also means that validating and mapping parsers
1031 can always return :data:`True`.
1033 :param value:
1034 value that needs a type check.
1035 :returns:
1036 :data:`True` if the value matches the type of this parser.
1038 """
1040 raise NotImplementedError()
1042 def assert_type(self, value: object, /) -> _t.TypeGuard[T_co]:
1043 """
1044 Call :meth:`~Parser.check_type` and raise a :class:`TypeError`
1045 if it returns :data:`False`.
1047 This method always returns :data:`True` or throws an error, but type checkers
1048 don't know this. Use ``assert parser.assert_type(value)`` so that they
1049 understand that type of the `value` has narrowed.
1051 :param value:
1052 value that needs a type check.
1053 :returns:
1054 always returns :data:`True`.
1055 :raises:
1056 :class:`TypeError`.
1058 """
1060 if not self.check_type(value):
1061 raise TypeError(
1062 f"parser {self} can't handle value of type {_tx.type_repr(type(value))}"
1063 )
1064 return True
1066 @abc.abstractmethod
1067 def describe(self) -> str | None:
1068 """
1069 Return a human-readable description of an expected input.
1071 Used to describe expected input in widgets.
1073 :returns:
1074 human-readable description of an expected input. Can return :data:`None`
1075 for simple values that don't need a special description.
1077 """
1079 raise NotImplementedError()
1081 @abc.abstractmethod
1082 def describe_or_def(self) -> str:
1083 """
1084 Like :py:meth:`~Parser.describe`, but guaranteed to return something.
1086 Used to describe expected input in CLI help.
1088 :returns:
1089 human-readable description of an expected input.
1091 """
1093 raise NotImplementedError()
1095 @abc.abstractmethod
1096 def describe_many(self) -> str | tuple[str, ...]:
1097 """
1098 Return a human-readable description of a container element.
1100 Used to describe expected input in CLI help.
1102 :returns:
1103 human-readable description of expected inputs. If the value is a string,
1104 then it describes an individual member of a collection. The the value
1105 is a tuple, then each of the tuple's element describes an expected value
1106 at the corresponding position.
1107 :raises:
1108 :class:`RuntimeError` if trying to call this method on a parser
1109 that doesn't supports parsing collections of objects.
1111 """
1113 raise NotImplementedError()
1115 @abc.abstractmethod
1116 def describe_value(self, value: object, /) -> str:
1117 """
1118 Return a human-readable description of the given value.
1120 Used in error messages, and to describe returned input in widgets.
1122 Note that, since parser's type parameter is covariant, this function is not
1123 guaranteed to receive a value of the same type that this parser produces.
1124 Call :meth:`~Parser.assert_type` to check for this case.
1126 :param value:
1127 value that needs a description.
1128 :returns:
1129 description of a value in the format that this parser would expect to see
1130 in a CLI argument or an environment variable.
1131 :raises:
1132 :class:`TypeError` if the given value is not of type
1133 that this parser produces.
1135 """
1137 raise NotImplementedError()
1139 @abc.abstractmethod
1140 def options(self) -> _t.Collection[yuio.widget.Option[T_co]] | None:
1141 """
1142 Return options for a :class:`~yuio.widget.Multiselect` widget.
1144 This function can be implemented for parsers that return a fixed set
1145 of pre-defined values, like :class:`Enum` or :class:`OneOf`.
1146 Collection parsers may use this data to improve their widgets.
1147 For example, the :class:`Set` parser will use
1148 a :class:`~yuio.widget.Multiselect` widget.
1150 :returns:
1151 a full list of options that will be passed to
1152 a :class:`~yuio.widget.Multiselect` widget, or :data:`None`
1153 if the set of possible values is not known.
1155 """
1157 raise NotImplementedError()
1159 @abc.abstractmethod
1160 def completer(self) -> yuio.complete.Completer | None:
1161 """
1162 Return a completer for values of this parser.
1164 This function is used when assembling autocompletion functions for shells,
1165 and when reading values from user via :func:`yuio.io.ask`.
1167 :returns:
1168 a completer that will be used with CLI arguments or widgets.
1170 """
1172 raise NotImplementedError()
1174 @abc.abstractmethod
1175 def widget(
1176 self,
1177 default: object | yuio.Missing,
1178 input_description: str | None,
1179 default_description: str | None,
1180 /,
1181 ) -> yuio.widget.Widget[T_co | yuio.Missing]:
1182 """
1183 Return a widget for reading values of this parser.
1185 This function is used when reading values from user via :func:`yuio.io.ask`.
1187 The returned widget must produce values of type ``T``. If `default` is given,
1188 and the user input is empty, the widget must produce
1189 the :data:`~yuio.MISSING` constant (*not* the default constant).
1190 This is because the default value might be of any type
1191 (for example :data:`None`), and validating parsers should not check it.
1193 Validating parsers must wrap the widget they got from
1194 :attr:`__wrapped_parser__` into :class:`~yuio.widget.Map`
1195 or :class:`~yuio.widget.Apply` in order to validate widget's results.
1197 :param default:
1198 default value that will be used if widget returns :data:`~yuio.MISSING`.
1199 :param input_description:
1200 a string describing what input is expected.
1201 :param default_description:
1202 a string describing default value.
1203 :returns:
1204 a widget that will be used to ask user for values. The widget can choose
1205 to use :func:`~Parser.completer` or :func:`~Parser.options`, or implement
1206 some custom logic.
1208 """
1210 raise NotImplementedError()
1212 @abc.abstractmethod
1213 def to_json_schema(
1214 self, ctx: yuio.json_schema.JsonSchemaContext, /
1215 ) -> yuio.json_schema.JsonSchemaType:
1216 """
1217 Create a JSON schema object based on this parser.
1219 The purpose of this method is to make schemas for use in IDEs, i.e. to provide
1220 autocompletion or simple error checking. The returned schema is not guaranteed
1221 to reflect all constraints added to the parser. For example, :class:`OneOf`
1222 and :class:`Regex` parsers will not affect the generated schema.
1224 :param ctx:
1225 context for building a schema.
1226 :returns:
1227 a JSON schema that describes structure of values expected by this parser.
1229 """
1231 raise NotImplementedError()
1233 @abc.abstractmethod
1234 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
1235 """
1236 Convert given value to a representation suitable for JSON serialization.
1238 Note that, since parser's type parameter is covariant, this function is not
1239 guaranteed to receive a value of the same type that this parser produces.
1240 Call :meth:`~Parser.assert_type` to check for this case.
1242 :returns:
1243 a value converted to JSON-serializable representation.
1244 :raises:
1245 :class:`TypeError` if the given value is not of type
1246 that this parser produces.
1248 """
1250 raise NotImplementedError()
1252 @abc.abstractmethod
1253 def is_secret(self) -> bool:
1254 """
1255 Indicates that input functions should use secret input,
1256 i.e. :func:`~getpass.getpass` or :class:`yuio.widget.SecretInput`.
1258 """
1260 raise NotImplementedError()
1262 def __repr__(self):
1263 return self.__class__.__name__
1266class ValueParser(Parser[T], PartialParser, _t.Generic[T]):
1267 """
1268 Base implementation for a parser that returns a single value.
1270 Implements all method, except for :meth:`~Parser.parse_with_ctx`,
1271 :meth:`~Parser.parse_config_with_ctx`, :meth:`~Parser.to_json_schema`,
1272 and :meth:`~Parser.to_json_value`.
1274 :param ty:
1275 type of the produced value, used in :meth:`~Parser.check_type`.
1276 :example:
1277 .. invisible-code-block: python
1279 from dataclasses import dataclass
1280 @dataclass
1281 class MyType:
1282 data: str
1284 .. code-block:: python
1286 class MyTypeParser(ValueParser[MyType]):
1287 def __init__(self):
1288 super().__init__(MyType)
1290 def parse_with_ctx(self, ctx: StrParsingContext, /) -> MyType:
1291 return MyType(ctx.value)
1293 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> MyType:
1294 if not isinstance(ctx.value, str):
1295 raise ParsingError.type_mismatch(value, str, ctx=ctx)
1296 return MyType(ctx.value)
1298 def to_json_schema(
1299 self, ctx: yuio.json_schema.JsonSchemaContext, /
1300 ) -> yuio.json_schema.JsonSchemaType:
1301 return yuio.json_schema.String()
1303 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
1304 assert self.assert_type(value)
1305 return value.data
1307 ::
1309 >>> MyTypeParser().parse('pancake')
1310 MyType(data='pancake')
1312 """
1314 def __init__(self, ty: type[T], /, *args, **kwargs) -> types.NoneType:
1315 super().__init__(*args, **kwargs)
1317 self._value_type = ty
1318 """
1319 Type of the produced value, used in :meth:`~Parser.check_type`.
1321 """
1323 def wrap(self: P, parser: Parser[_t.Any]) -> P:
1324 typehint = getattr(parser, "_Parser__typehint", None)
1325 if typehint is None:
1326 with self._patch_stack_summary():
1327 raise TypeError(
1328 f"annotating a type with {self} will override"
1329 " all previous annotations. Make sure that"
1330 f" {self} is the first annotation in"
1331 " your type hint.\n\n"
1332 "Example:\n"
1333 " Incorrect: Str() overrides effects of Map()\n"
1334 " field: typing.Annotated[str, Map(fn=str.lower), Str()]\n"
1335 " ^^^^^\n"
1336 " Correct: Str() is applied first, then Map()\n"
1337 " field: typing.Annotated[str, Str(), Map(fn=str.lower)]\n"
1338 " ^^^^^"
1339 )
1340 if not isinstance(self, parser.__class__):
1341 with self._patch_stack_summary():
1342 raise TypeError(
1343 f"annotating {_tx.type_repr(typehint)} with {self.__class__.__name__}"
1344 " conflicts with default parser for this type, which is"
1345 f" {parser.__class__.__name__}.\n\n"
1346 "Example:\n"
1347 " Incorrect: Path() can't be used to annotate `str`\n"
1348 " field: typing.Annotated[str, Path(extensions=[...])]\n"
1349 " ^^^^^^^^^^^^^^^^^^^^^^\n"
1350 " Correct: using Path() to annotate `pathlib.Path`\n"
1351 " field: typing.Annotated[pathlib.Path, Path(extensions=[...])]\n"
1352 " ^^^^^^^^^^^^^^^^^^^^^^"
1353 )
1354 return self
1356 def parse_many_with_ctx(self, ctxs: _t.Sequence[StrParsingContext], /) -> T:
1357 raise RuntimeError("unable to parse multiple values")
1359 def supports_parse_many(self) -> bool:
1360 return False
1362 def get_nargs(self) -> _t.Literal["+", "*"] | int:
1363 return 1
1365 def check_type(self, value: object, /) -> _t.TypeGuard[T]:
1366 return isinstance(value, self._value_type)
1368 def describe(self) -> str | None:
1369 return None
1371 def describe_or_def(self) -> str:
1372 return self.describe() or f"<{_to_dash_case(self.__class__.__name__)}>"
1374 def describe_many(self) -> str | tuple[str, ...]:
1375 return self.describe_or_def()
1377 def describe_value(self, value: object, /) -> str:
1378 assert self.assert_type(value)
1379 return str(value) or "<empty>"
1381 def options(self) -> _t.Collection[yuio.widget.Option[T]] | None:
1382 return None
1384 def completer(self) -> yuio.complete.Completer | None:
1385 return None
1387 def widget(
1388 self,
1389 default: object | yuio.Missing,
1390 input_description: str | None,
1391 default_description: str | None,
1392 /,
1393 ) -> yuio.widget.Widget[T | yuio.Missing]:
1394 completer = self.completer()
1395 return _WidgetResultMapper(
1396 self,
1397 input_description,
1398 default,
1399 (
1400 yuio.widget.InputWithCompletion(
1401 completer,
1402 placeholder=default_description or "",
1403 )
1404 if completer is not None
1405 else yuio.widget.Input(
1406 placeholder=default_description or "",
1407 )
1408 ),
1409 )
1411 def is_secret(self) -> bool:
1412 return False
1415class WrappingParser(Parser[T], _t.Generic[T, U]):
1416 """
1417 A base for a parser that wraps another parser and alters its output.
1419 This base simplifies dealing with partial parsers.
1421 The :attr:`~WrappingParser._inner` attribute is whatever internal state you need
1422 to store. When it is :data:`None`, the parser is considered partial. That is,
1423 you can't use such a parser to actually parse anything, but you can
1424 use it in a type annotation. When it is not :data:`None`, the parser is considered
1425 non partial. You can use it to parse things, but you can't use it
1426 in a type annotation.
1428 .. warning::
1430 All descendants of this class must include appropriate type hints
1431 for their ``__new__`` method, otherwise type annotations from this base
1432 will shadow implementation's ``__init__`` signature.
1434 See section on `parser hierarchy`_ for details.
1436 :param inner:
1437 inner data or :data:`None`.
1439 """
1441 if TYPE_CHECKING:
1443 @_t.overload
1444 def __new__(cls, inner: U, /) -> WrappingParser[T, U]: ...
1446 @_t.overload
1447 def __new__(cls, /) -> PartialParser: ...
1449 def __new__(cls, *args, **kwargs) -> _t.Any: ...
1451 def __init__(self, inner: U | None, /, *args, **kwargs):
1452 self.__inner = inner
1453 super().__init__(*args, **kwargs)
1455 @property
1456 def _inner(self) -> U:
1457 """
1458 Internal resource wrapped by this parser.
1460 :raises:
1461 Accessing it when the parser is in a partial state triggers an error
1462 and warns user that they didn't provide an inner parser.
1464 Setting a new value when the parser is not in a partial state triggers
1465 an error and warns user that they shouldn't provide an inner parser
1466 in type annotations.
1468 """
1470 if self.__inner is None:
1471 with self._patch_stack_summary():
1472 raise TypeError(f"{self.__class__.__name__} requires an inner parser")
1473 return self.__inner
1475 @_inner.setter
1476 def _inner(self, inner: U):
1477 if self.__inner is not None:
1478 with self._patch_stack_summary():
1479 raise TypeError(
1480 f"don't provide inner parser when using {self.__class__.__name__}"
1481 " with type annotations. The inner parser will be derived automatically"
1482 "from type hint.\n\n"
1483 "Example:\n"
1484 " Incorrect: List() has an inner parser\n"
1485 " field: typing.Annotated[list[str], List(Str(), delimiter=';')]\n"
1486 " ^^^^^^^^^^^^^^^^^^^^^^^^^^\n"
1487 " Correct: inner parser for List() derived from type hint\n"
1488 " field: typing.Annotated[list[str], List(delimiter=';')]\n"
1489 " ^^^^^^^^^^^^^^^^^^^"
1490 )
1491 self.__inner = inner
1493 @property
1494 def _inner_raw(self) -> U | None:
1495 """
1496 Unchecked access to the wrapped resource.
1498 """
1500 return self.__inner
1503class MappingParser(WrappingParser[T, Parser[U]], _t.Generic[T, U]):
1504 """
1505 This is base abstraction for :class:`Map` and :class:`Optional`.
1506 Forwards all calls to the inner parser, except for :meth:`~Parser.parse_with_ctx`,
1507 :meth:`~Parser.parse_many_with_ctx`, :meth:`~Parser.parse_config_with_ctx`,
1508 :meth:`~Parser.options`, :meth:`~Parser.check_type`,
1509 :meth:`~Parser.describe_value`, :meth:`~Parser.widget`,
1510 and :meth:`~Parser.to_json_value`.
1512 :param inner:
1513 mapped parser or :data:`None`.
1515 """
1517 if TYPE_CHECKING:
1519 @_t.overload
1520 def __new__(cls, inner: Parser[U], /) -> MappingParser[T, U]: ...
1522 @_t.overload
1523 def __new__(cls, /) -> PartialParser: ...
1525 def __new__(cls, *args, **kwargs) -> _t.Any: ...
1527 def __init__(self, inner: Parser[U] | None, /):
1528 super().__init__(inner)
1530 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
1531 self._inner = parser
1532 return self
1534 def supports_parse_many(self) -> bool:
1535 return self._inner.supports_parse_many()
1537 def get_nargs(self) -> _t.Literal["+", "*"] | int:
1538 return self._inner.get_nargs()
1540 def describe(self) -> str | None:
1541 return self._inner.describe()
1543 def describe_or_def(self) -> str:
1544 return self._inner.describe_or_def()
1546 def describe_many(self) -> str | tuple[str, ...]:
1547 return self._inner.describe_many()
1549 def completer(self) -> yuio.complete.Completer | None:
1550 return self._inner.completer()
1552 def to_json_schema(
1553 self, ctx: yuio.json_schema.JsonSchemaContext, /
1554 ) -> yuio.json_schema.JsonSchemaType:
1555 return self._inner.to_json_schema(ctx)
1557 def is_secret(self) -> bool:
1558 return self._inner.is_secret()
1560 def __repr__(self):
1561 return f"{self.__class__.__name__}({self._inner_raw!r})"
1563 @property
1564 def __wrapped_parser__(self): # pyright: ignore[reportIncompatibleVariableOverride]
1565 return self._inner_raw
1568class Map(MappingParser[T, U], _t.Generic[T, U]):
1569 """Map(inner: Parser[U], fn: typing.Callable[[U], T], rev: typing.Callable[[T | object], U] | None = None, /)
1571 A wrapper that maps result of the given parser using the given function.
1573 :param inner:
1574 a parser whose result will be mapped.
1575 :param fn:
1576 a function to convert a result.
1577 :param rev:
1578 a function used to un-map a value.
1580 This function is used in :meth:`Parser.describe_value`
1581 and :meth:`Parser.to_json_value` to convert parsed value back
1582 to its original state.
1584 Note that, since parser's type parameter is covariant, this function is not
1585 guaranteed to receive a value of the same type that this parser produces.
1586 In this case, you should raise a :class:`TypeError`.
1587 :example:
1588 ..
1589 >>> import math
1591 ::
1593 >>> parser = yuio.parse.Map(
1594 ... yuio.parse.Int(),
1595 ... lambda x: 2 ** x,
1596 ... lambda x: int(math.log2(x)),
1597 ... )
1598 >>> parser.parse("10")
1599 1024
1600 >>> parser.describe_value(1024)
1601 '10'
1603 """
1605 if TYPE_CHECKING:
1607 @_t.overload
1608 def __new__(cls, inner: Parser[T], fn: _t.Callable[[T], T], /) -> Map[T, T]: ...
1610 @_t.overload
1611 def __new__(cls, fn: _t.Callable[[T], T], /) -> PartialParser: ...
1613 @_t.overload
1614 def __new__(
1615 cls,
1616 inner: Parser[U],
1617 fn: _t.Callable[[U], T],
1618 rev: _t.Callable[[T | object], U],
1619 /,
1620 ) -> Map[T, T]: ...
1622 @_t.overload
1623 def __new__(
1624 cls, fn: _t.Callable[[U], T], rev: _t.Callable[[T | object], U], /
1625 ) -> PartialParser: ...
1627 def __new__(cls, *args, **kwargs) -> _t.Any: ...
1629 def __init__(self, *args):
1630 inner: Parser[U] | None = None
1631 fn: _t.Callable[[U], T]
1632 rev: _t.Callable[[T | object], U] | None = None
1633 if len(args) == 1:
1634 (fn,) = args
1635 elif len(args) == 2 and isinstance(args[0], Parser):
1636 inner, fn = args
1637 elif len(args) == 2:
1638 fn, rev = args
1639 elif len(args) == 3:
1640 inner, fn, rev = args
1641 else:
1642 raise TypeError(
1643 f"expected between 1 and 2 positional arguments, got {len(args)}"
1644 )
1646 self._fn = fn
1647 self._rev = rev
1648 super().__init__(inner)
1650 def parse_with_ctx(self, ctx: StrParsingContext, /) -> T:
1651 res = self._inner.parse_with_ctx(ctx)
1652 try:
1653 return self._fn(res)
1654 except ParsingError as e:
1655 e.set_ctx(ctx)
1656 raise
1658 def parse_many_with_ctx(self, ctxs: _t.Sequence[StrParsingContext], /) -> T:
1659 return self._fn(self._inner.parse_many_with_ctx(ctxs))
1661 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> T:
1662 res = self._inner.parse_config_with_ctx(ctx)
1663 try:
1664 return self._fn(res)
1665 except ParsingError as e:
1666 e.set_ctx(ctx)
1667 raise
1669 def check_type(self, value: object, /) -> _t.TypeGuard[T]:
1670 return True
1672 def describe_value(self, value: object, /) -> str:
1673 if self._rev:
1674 value = self._rev(value)
1675 return self._inner.describe_value(value)
1677 def options(self) -> _t.Collection[yuio.widget.Option[T]] | None:
1678 options = self._inner.options()
1679 if options is not None:
1680 return [
1681 _t.cast(
1682 yuio.widget.Option[T],
1683 dataclasses.replace(option, value=self._fn(option.value)),
1684 )
1685 for option in options
1686 ]
1687 else:
1688 return None
1690 def widget(
1691 self,
1692 default: object | yuio.Missing,
1693 input_description: str | None,
1694 default_description: str | None,
1695 /,
1696 ) -> yuio.widget.Widget[T | yuio.Missing]:
1697 return yuio.widget.Map(
1698 self._inner.widget(default, input_description, default_description),
1699 lambda v: self._fn(v) if v is not yuio.MISSING else yuio.MISSING,
1700 )
1702 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
1703 if self._rev:
1704 value = self._rev(value)
1705 return self._inner.to_json_value(value)
1708@_t.overload
1709def Lower(inner: Parser[str], /) -> Parser[str]: ...
1710@_t.overload
1711def Lower() -> PartialParser: ...
1712def Lower(*args) -> _t.Any:
1713 """Lower(inner: Parser[str], /)
1715 Applies :meth:`str.lower` to the result of a string parser.
1717 :param inner:
1718 a parser whose result will be mapped.
1720 """
1722 return Map(*args, str.lower) # pyright: ignore[reportCallIssue]
1725@_t.overload
1726def Upper(inner: Parser[str], /) -> Parser[str]: ...
1727@_t.overload
1728def Upper() -> PartialParser: ...
1729def Upper(*args) -> _t.Any:
1730 """Upper(inner: Parser[str], /)
1732 Applies :meth:`str.upper` to the result of a string parser.
1734 :param inner:
1735 a parser whose result will be mapped.
1737 """
1739 return Map(*args, str.upper) # pyright: ignore[reportCallIssue]
1742@_t.overload
1743def CaseFold(inner: Parser[str], /) -> Parser[str]: ...
1744@_t.overload
1745def CaseFold() -> PartialParser: ...
1746def CaseFold(*args) -> _t.Any:
1747 """CaseFold(inner: Parser[str], /)
1749 Applies :meth:`str.casefold` to the result of a string parser.
1751 :param inner:
1752 a parser whose result will be mapped.
1754 """
1756 return Map(*args, str.casefold) # pyright: ignore[reportCallIssue]
1759@_t.overload
1760def Strip(inner: Parser[str], /) -> Parser[str]: ...
1761@_t.overload
1762def Strip() -> PartialParser: ...
1763def Strip(*args) -> _t.Any:
1764 """Strip(inner: Parser[str], /)
1766 Applies :meth:`str.strip` to the result of a string parser.
1768 :param inner:
1769 a parser whose result will be mapped.
1771 """
1773 return Map(*args, str.strip) # pyright: ignore[reportCallIssue]
1776@_t.overload
1777def Regex(
1778 inner: Parser[str],
1779 regex: str | _tx.StrRePattern,
1780 /,
1781 *,
1782 group: int | str = 0,
1783) -> Parser[str]: ...
1784@_t.overload
1785def Regex(
1786 regex: str | _tx.StrRePattern, /, *, group: int | str = 0
1787) -> PartialParser: ...
1788def Regex(*args, group: int | str = 0) -> _t.Any:
1789 """Regex(inner: Parser[str], regex: str | re.Pattern[str], /, *, group: int | str = 0)
1791 Matches the parsed string with the given regular expression.
1793 If regex has capturing groups, parser can return contents of a group.
1795 :param regex:
1796 regular expression for matching.
1797 :param group:
1798 name or index of a capturing group that should be used to get the final
1799 parsed value.
1801 """
1803 inner: Parser[str] | None
1804 regex: str | _tx.StrRePattern
1805 if len(args) == 1:
1806 inner, regex = None, args[0]
1807 elif len(args) == 2:
1808 inner, regex = args
1809 else:
1810 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
1812 if isinstance(regex, re.Pattern):
1813 compiled = regex
1814 else:
1815 compiled = re.compile(regex)
1817 def mapper(value: str) -> str:
1818 if (match := compiled.match(value)) is None:
1819 raise ParsingError(
1820 "Value doesn't match regex `%s`: `%r`",
1821 compiled.pattern,
1822 value,
1823 fallback_msg="Incorrect value format",
1824 )
1825 return match.group(group)
1827 return Map(inner, mapper) # type: ignore
1830class Apply(MappingParser[T, T], _t.Generic[T]):
1831 """Apply(inner: Parser[T], fn: typing.Callable[[T], None], /)
1833 A wrapper that applies the given function to the result of a wrapped parser.
1835 :param inner:
1836 a parser used to extract and validate a value.
1837 :param fn:
1838 a function that will be called after parsing a value.
1839 :example:
1840 ::
1842 >>> # Run `Int` parser, then print its output before returning.
1843 >>> print_output = Apply(Int(), lambda x: print(f"Value is {x}"))
1844 >>> result = print_output.parse("10")
1845 Value is 10
1846 >>> result
1847 10
1849 """
1851 if TYPE_CHECKING:
1853 @_t.overload
1854 def __new__(
1855 cls, inner: Parser[T], fn: _t.Callable[[T], None], /
1856 ) -> Apply[T]: ...
1858 @_t.overload
1859 def __new__(cls, fn: _t.Callable[[T], None], /) -> PartialParser: ...
1861 def __new__(cls, *args, **kwargs) -> _t.Any: ...
1863 def __init__(self, *args):
1864 inner: Parser[T] | None
1865 fn: _t.Callable[[T], None]
1866 if len(args) == 1:
1867 inner, fn = None, args[0]
1868 elif len(args) == 2:
1869 inner, fn = args
1870 else:
1871 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
1873 self._fn = fn
1874 super().__init__(inner)
1876 def parse_with_ctx(self, ctx: StrParsingContext, /) -> T:
1877 result = self._inner.parse_with_ctx(ctx)
1878 try:
1879 self._fn(result)
1880 except ParsingError as e:
1881 e.set_ctx(ctx)
1882 raise
1883 return result
1885 def parse_many_with_ctx(self, ctxs: _t.Sequence[StrParsingContext], /) -> T:
1886 result = self._inner.parse_many_with_ctx(ctxs)
1887 self._fn(result)
1888 return result
1890 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> T:
1891 result = self._inner.parse_config_with_ctx(ctx)
1892 try:
1893 self._fn(result)
1894 except ParsingError as e:
1895 e.set_ctx(ctx)
1896 raise
1897 return result
1899 def check_type(self, value: object, /) -> _t.TypeGuard[T]:
1900 return True
1902 def describe_value(self, value: object, /) -> str:
1903 return self._inner.describe_value(value)
1905 def options(self) -> _t.Collection[yuio.widget.Option[T]] | None:
1906 return self._inner.options()
1908 def completer(self) -> yuio.complete.Completer | None:
1909 return self._inner.completer()
1911 def widget(
1912 self,
1913 default: object | yuio.Missing,
1914 input_description: str | None,
1915 default_description: str | None,
1916 /,
1917 ) -> yuio.widget.Widget[T | yuio.Missing]:
1918 return yuio.widget.Apply(
1919 self._inner.widget(default, input_description, default_description),
1920 lambda v: self._fn(v) if v is not yuio.MISSING else None,
1921 )
1923 def to_json_schema(
1924 self, ctx: yuio.json_schema.JsonSchemaContext, /
1925 ) -> yuio.json_schema.JsonSchemaType:
1926 return self._inner.to_json_schema(ctx)
1928 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
1929 return self._inner.to_json_value(value)
1932class ValidatingParser(Apply[T], _t.Generic[T]):
1933 """
1934 Base implementation for a parser that validates result of another parser.
1936 This class wraps another parser and passes all method calls to it.
1937 All parsed values are additionally passed to :meth:`~ValidatingParser._validate`.
1939 :param inner:
1940 a parser which output will be validated.
1941 :example:
1942 .. code-block:: python
1944 class IsLower(ValidatingParser[str]):
1945 def _validate(self, value: str, /):
1946 if not value.islower():
1947 raise ParsingError(
1948 "Value should be lowercase: `%r`",
1949 value,
1950 fallback_msg="Value should be lowercase",
1951 )
1953 ::
1955 >>> IsLower(Str()).parse("Not lowercase!")
1956 Traceback (most recent call last):
1957 ...
1958 yuio.parse.ParsingError: Value should be lowercase: 'Not lowercase!'
1960 """
1962 if TYPE_CHECKING:
1964 @_t.overload
1965 def __new__(cls, inner: Parser[T], /) -> ValidatingParser[T]: ...
1967 @_t.overload
1968 def __new__(cls, /) -> PartialParser: ...
1970 def __new__(cls, *args, **kwargs) -> _t.Any: ...
1972 def __init__(self, inner: Parser[T] | None = None, /):
1973 super().__init__(inner, self._validate)
1975 @abc.abstractmethod
1976 def _validate(self, value: T, /):
1977 """
1978 Implementation of value validation.
1980 :param value:
1981 value which needs validating.
1982 :raises:
1983 should raise :class:`ParsingError` if validation fails.
1985 """
1987 raise NotImplementedError()
1990class Str(ValueParser[str]):
1991 """
1992 Parser for str values.
1994 """
1996 def __init__(self):
1997 super().__init__(str)
1999 def parse_with_ctx(self, ctx: StrParsingContext, /) -> str:
2000 return str(ctx.value)
2002 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> str:
2003 if not isinstance(ctx.value, str):
2004 raise ParsingError.type_mismatch(ctx.value, str, ctx=ctx)
2005 return str(ctx.value)
2007 def to_json_schema(
2008 self, ctx: yuio.json_schema.JsonSchemaContext, /
2009 ) -> yuio.json_schema.JsonSchemaType:
2010 return yuio.json_schema.String()
2012 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2013 assert self.assert_type(value)
2014 return value
2017class Int(ValueParser[int]):
2018 """
2019 Parser for int values.
2021 """
2023 def __init__(self):
2024 super().__init__(int)
2026 def parse_with_ctx(self, ctx: StrParsingContext, /) -> int:
2027 ctx = ctx.strip_if_non_space()
2028 try:
2029 value = ctx.value.casefold()
2030 if value.startswith("-"):
2031 neg = True
2032 value = value[1:].lstrip()
2033 else:
2034 neg = False
2035 if value.startswith("0x"):
2036 base = 16
2037 value = value[2:]
2038 elif value.startswith("0o"):
2039 base = 8
2040 value = value[2:]
2041 elif value.startswith("0b"):
2042 base = 2
2043 value = value[2:]
2044 else:
2045 base = 10
2046 if value[:1] in "-\n\t\r\v\b ":
2047 raise ValueError()
2048 res = int(value, base=base)
2049 if neg:
2050 res = -res
2051 return res
2052 except ValueError:
2053 raise ParsingError(
2054 "Can't parse `%r` as `int`",
2055 ctx.value,
2056 ctx=ctx,
2057 fallback_msg="Can't parse value as `int`",
2058 ) from None
2060 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> int:
2061 value = ctx.value
2062 if isinstance(value, float):
2063 if value != int(value): # pyright: ignore[reportUnnecessaryComparison]
2064 raise ParsingError.type_mismatch(value, int, ctx=ctx)
2065 value = int(value)
2066 if not isinstance(value, int):
2067 raise ParsingError.type_mismatch(value, int, ctx=ctx)
2068 return value
2070 def to_json_schema(
2071 self, ctx: yuio.json_schema.JsonSchemaContext, /
2072 ) -> yuio.json_schema.JsonSchemaType:
2073 return yuio.json_schema.Integer()
2075 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2076 assert self.assert_type(value)
2077 return value
2080class Float(ValueParser[float]):
2081 """
2082 Parser for float values.
2084 """
2086 def __init__(self):
2087 super().__init__(float)
2089 def parse_with_ctx(self, ctx: StrParsingContext, /) -> float:
2090 ctx = ctx.strip_if_non_space()
2091 try:
2092 return float(ctx.value)
2093 except ValueError:
2094 raise ParsingError(
2095 "Can't parse `%r` as `float`",
2096 ctx.value,
2097 ctx=ctx,
2098 fallback_msg="Can't parse value as `float`",
2099 ) from None
2101 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> float:
2102 value = ctx.value
2103 if not isinstance(value, (float, int)):
2104 raise ParsingError.type_mismatch(value, float, ctx=ctx)
2105 return value
2107 def to_json_schema(
2108 self, ctx: yuio.json_schema.JsonSchemaContext, /
2109 ) -> yuio.json_schema.JsonSchemaType:
2110 return yuio.json_schema.Number()
2112 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2113 assert self.assert_type(value)
2114 return value
2117class Bool(ValueParser[bool]):
2118 """
2119 Parser for bool values, such as ``"yes"`` or ``"no"``.
2121 """
2123 def __init__(self):
2124 super().__init__(bool)
2126 def parse_with_ctx(self, ctx: StrParsingContext, /) -> bool:
2127 ctx = ctx.strip_if_non_space()
2128 value = ctx.value.casefold()
2129 if value in ("y", "yes", "true", "1"):
2130 return True
2131 elif value in ("n", "no", "false", "0"):
2132 return False
2133 else:
2134 raise ParsingError(
2135 "Can't parse `%r` as `bool`, should be `yes`, `no`, `true`, or `false`",
2136 value,
2137 ctx=ctx,
2138 fallback_msg="Can't parse value as `bool`",
2139 )
2141 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> bool:
2142 value = ctx.value
2143 if not isinstance(value, bool):
2144 raise ParsingError.type_mismatch(value, bool, ctx=ctx)
2145 return value
2147 def describe(self) -> str | None:
2148 return "{yes|no}"
2150 def describe_value(self, value: object, /) -> str:
2151 assert self.assert_type(value)
2152 return "yes" if value else "no"
2154 def completer(self) -> yuio.complete.Completer | None:
2155 return yuio.complete.Choice(
2156 [
2157 yuio.complete.Option("no"),
2158 yuio.complete.Option("yes"),
2159 yuio.complete.Option("true"),
2160 yuio.complete.Option("false"),
2161 ]
2162 )
2164 def widget(
2165 self,
2166 default: object | yuio.Missing,
2167 input_description: str | None,
2168 default_description: str | None,
2169 /,
2170 ) -> yuio.widget.Widget[bool | yuio.Missing]:
2171 options: list[yuio.widget.Option[bool | yuio.Missing]] = [
2172 yuio.widget.Option(False, "no"),
2173 yuio.widget.Option(True, "yes"),
2174 ]
2176 if default is yuio.MISSING:
2177 default_index = 0
2178 elif isinstance(default, bool):
2179 default_index = int(default)
2180 else:
2181 options.append(
2182 yuio.widget.Option(yuio.MISSING, default_description or str(default))
2183 )
2184 default_index = 2
2186 return yuio.widget.Choice(options, default_index=default_index)
2188 def to_json_schema(
2189 self, ctx: yuio.json_schema.JsonSchemaContext, /
2190 ) -> yuio.json_schema.JsonSchemaType:
2191 return yuio.json_schema.Boolean()
2193 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2194 assert self.assert_type(value)
2195 return value
2198class Enum(WrappingParser[E, type[E]], ValueParser[E], _t.Generic[E]):
2199 """Enum(enum_type: typing.Type[E], /, *, by_name: bool = False, to_dash_case: bool = False, doc_inline: bool = False)
2201 Parser for enums, as defined in the standard :mod:`enum` module.
2203 :param enum_type:
2204 enum class that will be used to parse and extract values.
2205 :param by_name:
2206 if :data:`True`, the parser will use enumerator names, instead of
2207 their values, to match the input.
2208 :param to_dash_case:
2209 convert enum names/values to dash case.
2210 :param doc_inline:
2211 inline this enum in json schema and in documentation.
2213 """
2215 if TYPE_CHECKING:
2217 @_t.overload
2218 def __new__(
2219 cls,
2220 inner: type[E],
2221 /,
2222 *,
2223 by_name: bool = False,
2224 to_dash_case: bool = False,
2225 doc_inline: bool = False,
2226 ) -> Enum[E]: ...
2228 @_t.overload
2229 def __new__(
2230 cls,
2231 /,
2232 *,
2233 by_name: bool = False,
2234 to_dash_case: bool = False,
2235 doc_inline: bool = False,
2236 ) -> PartialParser: ...
2238 def __new__(cls, *args, **kwargs) -> _t.Any: ...
2240 def __init__(
2241 self,
2242 enum_type: type[E] | None = None,
2243 /,
2244 *,
2245 by_name: bool = False,
2246 to_dash_case: bool = False,
2247 doc_inline: bool = False,
2248 ):
2249 self._by_name = by_name
2250 self._to_dash_case = to_dash_case
2251 self._doc_inline = doc_inline
2252 super().__init__(enum_type, enum_type)
2254 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
2255 result = super().wrap(parser)
2256 result._inner = parser._inner # type: ignore
2257 result._value_type = parser._inner # type: ignore
2258 return result
2260 @functools.cached_property
2261 def _getter(self) -> _t.Callable[[E], str]:
2262 items = {}
2263 for e in self._inner:
2264 if self._by_name:
2265 name = e.name
2266 else:
2267 name = str(e.value)
2268 if self._to_dash_case:
2269 name = _to_dash_case(name)
2270 items[e] = name
2271 return lambda e: items[e]
2273 @functools.cached_property
2274 def _docs(self) -> dict[str, str]:
2275 return _find_docs(self._inner)
2277 def parse_with_ctx(self, ctx: StrParsingContext, /) -> E:
2278 ctx = ctx.strip_if_non_space()
2279 return self._parse(ctx.value, ctx)
2281 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> E:
2282 value = ctx.value
2284 if isinstance(value, self._inner):
2285 return value
2287 if not isinstance(value, str):
2288 raise ParsingError.type_mismatch(value, str, ctx=ctx)
2290 result = self._parse(value, ctx)
2292 if self._getter(result) != value:
2293 raise ParsingError(
2294 "Can't parse `%r` as `%s`, did you mean `%s`?",
2295 value,
2296 self._inner.__name__,
2297 self._getter(result),
2298 ctx=ctx,
2299 )
2301 return result
2303 def _parse(self, value: str, ctx: ConfigParsingContext | StrParsingContext):
2304 cf_value = value.strip().casefold()
2306 candidates: list[E] = []
2307 for item in self._inner:
2308 if self._getter(item) == value:
2309 return item
2310 elif (self._getter(item)).casefold().startswith(cf_value):
2311 candidates.append(item)
2313 if len(candidates) == 1:
2314 return candidates[0]
2315 elif len(candidates) > 1:
2316 enum_values = tuple(self._getter(e) for e in candidates)
2317 raise ParsingError(
2318 "Can't parse `%r` as `%s`, possible candidates are %s",
2319 value,
2320 self._inner.__name__,
2321 yuio.string.Or(enum_values),
2322 ctx=ctx,
2323 )
2324 else:
2325 enum_values = tuple(self._getter(e) for e in self._inner)
2326 raise ParsingError(
2327 "Can't parse `%r` as `%s`, should be %s",
2328 value,
2329 self._inner.__name__,
2330 yuio.string.Or(enum_values),
2331 ctx=ctx,
2332 )
2334 def describe(self) -> str | None:
2335 desc = "|".join(self._getter(e) for e in self._inner)
2336 if len(self._inner) > 1:
2337 desc = f"{{{desc}}}"
2338 return desc
2340 def describe_many(self) -> str | tuple[str, ...]:
2341 return self.describe_or_def()
2343 def describe_value(self, value: object, /) -> str:
2344 assert self.assert_type(value)
2345 return str(self._getter(value))
2347 def options(self) -> _t.Collection[yuio.widget.Option[E]]:
2348 docs = self._docs
2349 return [
2350 yuio.widget.Option(
2351 e, display_text=self._getter(e), comment=docs.get(e.name)
2352 )
2353 for e in self._inner
2354 ]
2356 def completer(self) -> yuio.complete.Completer | None:
2357 docs = self._docs
2358 return yuio.complete.Choice(
2359 [
2360 yuio.complete.Option(self._getter(e), comment=docs.get(e.name))
2361 for e in self._inner
2362 ]
2363 )
2365 def widget(
2366 self,
2367 default: object | yuio.Missing,
2368 input_description: str | None,
2369 default_description: str | None,
2370 /,
2371 ) -> yuio.widget.Widget[E | yuio.Missing]:
2372 options: list[yuio.widget.Option[E | yuio.Missing]] = list(self.options())
2374 if default is yuio.MISSING:
2375 default_index = 0
2376 elif isinstance(default, self._inner):
2377 default_index = list(self._inner).index(default)
2378 else:
2379 options.insert(
2380 0, yuio.widget.Option(yuio.MISSING, default_description or str(default))
2381 )
2382 default_index = 0
2384 return yuio.widget.Choice(options, default_index=default_index)
2386 def to_json_schema(
2387 self, ctx: yuio.json_schema.JsonSchemaContext, /
2388 ) -> yuio.json_schema.JsonSchemaType:
2389 items = [self._getter(e) for e in self._inner]
2390 docs = self._docs
2391 descriptions = [docs.get(e.name) for e in self._inner]
2392 if not any(descriptions):
2393 descriptions = None
2394 if self._doc_inline:
2395 return yuio.json_schema.Enum(items, descriptions)
2396 else:
2397 return ctx.add_type(
2398 Enum._TyWrapper(self._inner, self._by_name, self._to_dash_case),
2399 _tx.type_repr(self._inner),
2400 lambda: yuio.json_schema.Meta(
2401 yuio.json_schema.Enum(items, descriptions),
2402 title=self._inner.__name__,
2403 description=self._inner.__doc__,
2404 ),
2405 )
2407 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2408 assert self.assert_type(value)
2409 return self._getter(value)
2411 def __repr__(self):
2412 if self._inner_raw is not None:
2413 return f"{self.__class__.__name__}({self._inner_raw!r})"
2414 else:
2415 return self.__class__.__name__
2417 @dataclasses.dataclass(unsafe_hash=True, match_args=False, slots=True)
2418 class _TyWrapper:
2419 inner: type
2420 by_name: bool
2421 to_dash_case: bool
2424class Decimal(ValueParser[decimal.Decimal]):
2425 """
2426 Parser for :class:`decimal.Decimal`.
2428 """
2430 def __init__(self):
2431 super().__init__(decimal.Decimal)
2433 def parse_with_ctx(self, ctx: StrParsingContext, /) -> decimal.Decimal:
2434 ctx = ctx.strip_if_non_space()
2435 try:
2436 return decimal.Decimal(ctx.value)
2437 except (ArithmeticError, ValueError, TypeError):
2438 raise ParsingError(
2439 "Can't parse `%r` as `decimal`",
2440 ctx.value,
2441 ctx=ctx,
2442 fallback_msg="Can't parse value as `decimal`",
2443 ) from None
2445 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> decimal.Decimal:
2446 value = ctx.value
2447 if not isinstance(value, (int, float, str, decimal.Decimal)):
2448 raise ParsingError.type_mismatch(value, int, float, str, ctx=ctx)
2449 try:
2450 return decimal.Decimal(value)
2451 except (ArithmeticError, ValueError, TypeError):
2452 raise ParsingError(
2453 "Can't parse `%r` as `decimal`",
2454 value,
2455 ctx=ctx,
2456 fallback_msg="Can't parse value as `decimal`",
2457 ) from None
2459 def to_json_schema(
2460 self, ctx: yuio.json_schema.JsonSchemaContext, /
2461 ) -> yuio.json_schema.JsonSchemaType:
2462 return ctx.add_type(
2463 decimal.Decimal,
2464 "Decimal",
2465 lambda: yuio.json_schema.Meta(
2466 yuio.json_schema.OneOf(
2467 [
2468 yuio.json_schema.Number(),
2469 yuio.json_schema.String(
2470 pattern=r"(?i)^[+-]?((\d+\.\d*|\.?\d+)(e[+-]?\d+)?|inf(inity)?|(nan|snan)\d*)$"
2471 ),
2472 ]
2473 ),
2474 title="Decimal",
2475 description="Decimal fixed-point and floating-point number.",
2476 ),
2477 )
2479 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2480 assert self.assert_type(value)
2481 return str(value)
2484class Fraction(ValueParser[fractions.Fraction]):
2485 """
2486 Parser for :class:`fractions.Fraction`.
2488 """
2490 def __init__(self):
2491 super().__init__(fractions.Fraction)
2493 def parse_with_ctx(self, ctx: StrParsingContext, /) -> fractions.Fraction:
2494 ctx = ctx.strip_if_non_space()
2495 try:
2496 return fractions.Fraction(ctx.value)
2497 except ValueError:
2498 raise ParsingError(
2499 "Can't parse `%r` as `fraction`",
2500 ctx.value,
2501 ctx=ctx,
2502 fallback_msg="Can't parse value as `fraction`",
2503 ) from None
2504 except ZeroDivisionError:
2505 raise ParsingError(
2506 "Can't parse `%r` as `fraction`, division by zero",
2507 ctx.value,
2508 ctx=ctx,
2509 fallback_msg="Can't parse value as `fraction`",
2510 ) from None
2512 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> fractions.Fraction:
2513 value = ctx.value
2514 if (
2515 isinstance(value, (list, tuple))
2516 and len(value) == 2
2517 and all(isinstance(v, (float, int)) for v in value)
2518 ):
2519 try:
2520 return fractions.Fraction(*value)
2521 except (ValueError, TypeError):
2522 raise ParsingError(
2523 "Can't parse `%s/%s` as `fraction`",
2524 value[0],
2525 value[1],
2526 ctx=ctx,
2527 fallback_msg="Can't parse value as `fraction`",
2528 ) from None
2529 except ZeroDivisionError:
2530 raise ParsingError(
2531 "Can't parse `%s/%s` as `fraction`, division by zero",
2532 value[0],
2533 value[1],
2534 ctx=ctx,
2535 fallback_msg="Can't parse value as `fraction`",
2536 ) from None
2537 if isinstance(value, (int, float, str, decimal.Decimal, fractions.Fraction)):
2538 try:
2539 return fractions.Fraction(value)
2540 except (ValueError, TypeError):
2541 raise ParsingError(
2542 "Can't parse `%r` as `fraction`",
2543 value,
2544 ctx=ctx,
2545 fallback_msg="Can't parse value as `fraction`",
2546 ) from None
2547 except ZeroDivisionError:
2548 raise ParsingError(
2549 "Can't parse `%r` as `fraction`, division by zero",
2550 value,
2551 ctx=ctx,
2552 fallback_msg="Can't parse value as `fraction`",
2553 ) from None
2554 raise ParsingError.type_mismatch(
2555 value, int, float, str, "a tuple of two ints", ctx=ctx
2556 )
2558 def to_json_schema(
2559 self, ctx: yuio.json_schema.JsonSchemaContext, /
2560 ) -> yuio.json_schema.JsonSchemaType:
2561 return ctx.add_type(
2562 fractions.Fraction,
2563 "Fraction",
2564 lambda: yuio.json_schema.Meta(
2565 yuio.json_schema.OneOf(
2566 [
2567 yuio.json_schema.Number(),
2568 yuio.json_schema.String(
2569 pattern=r"(?i)^[+-]?(\d+(\/\d+)?|(\d+\.\d*|\.?\d+)(e[+-]?\d+)?|inf(inity)?|nan)$"
2570 ),
2571 yuio.json_schema.Tuple(
2572 [yuio.json_schema.Number(), yuio.json_schema.Number()]
2573 ),
2574 ]
2575 ),
2576 title="Fraction",
2577 description="A rational number.",
2578 ),
2579 )
2581 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2582 assert self.assert_type(value)
2583 return str(value)
2586class Json(WrappingParser[T, Parser[T]], ValueParser[T], _t.Generic[T]):
2587 """Json(inner: Parser[T] | None = None, /)
2589 A parser that tries to parse value as JSON.
2591 This parser will load JSON strings into python objects.
2592 If `inner` parser is given, :class:`Json` will validate parsing results
2593 by calling :meth:`~Parser.parse_config_with_ctx` on the inner parser.
2595 :param inner:
2596 a parser used to convert and validate contents of json.
2598 """
2600 if TYPE_CHECKING:
2602 @_t.overload
2603 def __new__(cls, inner: Parser[T], /) -> Json[T]: ...
2605 @_t.overload
2606 def __new__(cls, /) -> Json[yuio.json_schema.JsonValue]: ...
2608 def __new__(cls, inner: Parser[T] | None = None, /) -> Json[_t.Any]: ...
2610 def __init__(
2611 self,
2612 inner: Parser[T] | None = None,
2613 /,
2614 ):
2615 super().__init__(inner, object)
2617 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
2618 self._inner = parser
2619 return self
2621 def parse_with_ctx(self, ctx: StrParsingContext, /) -> T:
2622 ctx = ctx.strip_if_non_space()
2623 try:
2624 config_value: JsonValue = json.loads(ctx.value)
2625 except json.JSONDecodeError as e:
2626 raise ParsingError(
2627 "Can't parse `%r` as `JsonValue`:\n%s",
2628 ctx.value,
2629 yuio.string.Indent(e),
2630 ctx=ctx,
2631 fallback_msg="Can't parse value as `JsonValue`",
2632 ) from None
2633 try:
2634 return self.parse_config_with_ctx(ConfigParsingContext(config_value))
2635 except ParsingError as e:
2636 raise ParsingError(
2637 "Error in parsed json value:\n%s",
2638 yuio.string.Indent(e),
2639 ctx=ctx,
2640 fallback_msg="Error in parsed json value",
2641 ) from None
2643 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> T:
2644 if self._inner_raw is not None:
2645 return self._inner_raw.parse_config_with_ctx(ctx)
2646 else:
2647 return _t.cast(T, ctx.value)
2649 def check_type(self, value: object, /) -> _t.TypeGuard[T]:
2650 return True
2652 def to_json_schema(
2653 self, ctx: yuio.json_schema.JsonSchemaContext, /
2654 ) -> yuio.json_schema.JsonSchemaType:
2655 if self._inner_raw is not None:
2656 return self._inner_raw.to_json_schema(ctx)
2657 else:
2658 return yuio.json_schema.Any()
2660 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2661 assert self.assert_type(value)
2662 if self._inner_raw is not None:
2663 return self._inner_raw.to_json_value(value)
2664 return value # type: ignore
2666 def __repr__(self):
2667 if self._inner_raw is not None:
2668 return f"{self.__class__.__name__}({self._inner_raw!r})"
2669 else:
2670 return super().__repr__()
2673class DateTime(ValueParser[datetime.datetime]):
2674 """
2675 Parse a datetime in ISO ('YYYY-MM-DD HH:MM:SS') format.
2677 """
2679 def __init__(self):
2680 super().__init__(datetime.datetime)
2682 def parse_with_ctx(self, ctx: StrParsingContext, /) -> datetime.datetime:
2683 ctx = ctx.strip_if_non_space()
2684 return self._parse(ctx.value, ctx)
2686 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> datetime.datetime:
2687 value = ctx.value
2688 if isinstance(value, datetime.datetime):
2689 return value
2690 elif isinstance(value, str):
2691 return self._parse(value, ctx)
2692 else:
2693 raise ParsingError.type_mismatch(value, str, ctx=ctx)
2695 @staticmethod
2696 def _parse(value: str, ctx: ConfigParsingContext | StrParsingContext):
2697 try:
2698 return datetime.datetime.fromisoformat(value)
2699 except ValueError:
2700 raise ParsingError(
2701 "Can't parse `%r` as `datetime`",
2702 value,
2703 ctx=ctx,
2704 fallback_msg="Can't parse value as `datetime`",
2705 ) from None
2707 def describe(self) -> str | None:
2708 return "YYYY-MM-DD[ HH:MM:SS]"
2710 def to_json_schema(
2711 self, ctx: yuio.json_schema.JsonSchemaContext, /
2712 ) -> yuio.json_schema.JsonSchemaType:
2713 return ctx.add_type(
2714 datetime.datetime,
2715 "DateTime",
2716 lambda: yuio.json_schema.Meta(
2717 yuio.json_schema.String(
2718 pattern=(
2719 r"^"
2720 r"("
2721 r"\d{4}-W\d{2}(-\d)?"
2722 r"|\d{4}-\d{2}-\d{2}"
2723 r"|\d{4}W\d{2}\d?"
2724 r"|\d{4}\d{2}\d{2}"
2725 r")"
2726 r"("
2727 r"[T ]"
2728 r"\d{2}(:\d{2}(:\d{2}(.\d{3}(\d{3})?)?)?)?"
2729 r"([+-]\d{2}(:\d{2}(:\d{2}(.\d{3}(\d{3})?)?)?)?|Z)?"
2730 r")?"
2731 r"$"
2732 )
2733 ),
2734 title="DateTime",
2735 description="ISO 8601 datetime.",
2736 ),
2737 )
2739 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2740 assert self.assert_type(value)
2741 return str(value)
2744class Date(ValueParser[datetime.date]):
2745 """
2746 Parse a date in ISO ('YYYY-MM-DD') format.
2748 """
2750 def __init__(self):
2751 super().__init__(datetime.date)
2753 def parse_with_ctx(self, ctx: StrParsingContext, /) -> datetime.date:
2754 ctx = ctx.strip_if_non_space()
2755 return self._parse(ctx.value, ctx)
2757 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> datetime.date:
2758 value = ctx.value
2759 if isinstance(value, datetime.datetime):
2760 return value.date()
2761 elif isinstance(value, datetime.date):
2762 return value
2763 elif isinstance(value, str):
2764 return self._parse(value, ctx)
2765 else:
2766 raise ParsingError.type_mismatch(value, str, ctx=ctx)
2768 @staticmethod
2769 def _parse(value: str, ctx: ConfigParsingContext | StrParsingContext):
2770 try:
2771 return datetime.date.fromisoformat(value)
2772 except ValueError:
2773 raise ParsingError(
2774 "Can't parse `%r` as `date`",
2775 value,
2776 ctx=ctx,
2777 fallback_msg="Can't parse value as `date`",
2778 ) from None
2780 def describe(self) -> str | None:
2781 return "YYYY-MM-DD"
2783 def to_json_schema(
2784 self, ctx: yuio.json_schema.JsonSchemaContext, /
2785 ) -> yuio.json_schema.JsonSchemaType:
2786 return ctx.add_type(
2787 datetime.date,
2788 "Date",
2789 lambda: yuio.json_schema.Meta(
2790 yuio.json_schema.String(
2791 pattern=(
2792 r"^"
2793 r"("
2794 r"\d{4}-W\d{2}(-\d)?"
2795 r"|\d{4}-\d{2}-\d{2}"
2796 r"|\d{4}W\d{2}\d?"
2797 r"|\d{4}\d{2}\d{2}"
2798 r")"
2799 r"$"
2800 )
2801 ),
2802 title="Date",
2803 description="ISO 8601 date.",
2804 ),
2805 )
2807 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2808 assert self.assert_type(value)
2809 return str(value)
2812class Time(ValueParser[datetime.time]):
2813 """
2814 Parse a time in ISO ('HH:MM:SS') format.
2816 """
2818 def __init__(self):
2819 super().__init__(datetime.time)
2821 def parse_with_ctx(self, ctx: StrParsingContext, /) -> datetime.time:
2822 ctx = ctx.strip_if_non_space()
2823 return self._parse(ctx.value, ctx)
2825 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> datetime.time:
2826 value = ctx.value
2827 if isinstance(value, datetime.datetime):
2828 return value.time()
2829 elif isinstance(value, datetime.time):
2830 return value
2831 elif isinstance(value, str):
2832 return self._parse(value, ctx)
2833 else:
2834 raise ParsingError.type_mismatch(value, str, ctx=ctx)
2836 @staticmethod
2837 def _parse(value: str, ctx: ConfigParsingContext | StrParsingContext):
2838 try:
2839 return datetime.time.fromisoformat(value)
2840 except ValueError:
2841 raise ParsingError(
2842 "Can't parse `%r` as `time`",
2843 value,
2844 ctx=ctx,
2845 fallback_msg="Can't parse value as `time`",
2846 ) from None
2848 def describe(self) -> str | None:
2849 return "HH:MM:SS"
2851 def to_json_schema(
2852 self, ctx: yuio.json_schema.JsonSchemaContext, /
2853 ) -> yuio.json_schema.JsonSchemaType:
2854 return ctx.add_type(
2855 datetime.time,
2856 "Time",
2857 lambda: yuio.json_schema.Meta(
2858 yuio.json_schema.String(
2859 pattern=(
2860 r"^"
2861 r"\d{2}(:\d{2}(:\d{2}(.\d{3}(\d{3})?)?)?)?"
2862 r"([+-]\d{2}(:\d{2}(:\d{2}(.\d{3}(\d{3})?)?)?)?|Z)?"
2863 r"$"
2864 )
2865 ),
2866 title="Time",
2867 description="ISO 8601 time.",
2868 ),
2869 )
2871 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2872 assert self.assert_type(value)
2873 return str(value)
2876_UNITS_MAP = (
2877 ("days", ("d", "day", "days")),
2878 ("seconds", ("s", "sec", "secs", "second", "seconds")),
2879 ("microseconds", ("us", "u", "micro", "micros", "microsecond", "microseconds")),
2880 ("milliseconds", ("ms", "l", "milli", "millis", "millisecond", "milliseconds")),
2881 ("minutes", ("m", "min", "mins", "minute", "minutes")),
2882 ("hours", ("h", "hr", "hrs", "hour", "hours")),
2883 ("weeks", ("w", "week", "weeks")),
2884)
2886_UNITS = {unit: name for name, units in _UNITS_MAP for unit in units}
2888_TIMEDELTA_RE = re.compile(
2889 r"""
2890 # General format: -1 day, -01:00:00.000000
2891 ^
2892 (?:([+-]?)\s*((?:\d+\s*[a-z]+\s*)+))?
2893 (?:,\s*)?
2894 (?:([+-]?)\s*(\d+):(\d?\d)(?::(\d?\d)(?:\.(?:(\d\d\d)(\d\d\d)?))?)?)?
2895 $
2896 """,
2897 re.VERBOSE | re.IGNORECASE,
2898)
2900_COMPONENT_RE = re.compile(r"(\d+)\s*([a-z]+)\s*")
2903class TimeDelta(ValueParser[datetime.timedelta]):
2904 """
2905 Parse a time delta.
2907 """
2909 def __init__(self):
2910 super().__init__(datetime.timedelta)
2912 def parse_with_ctx(self, ctx: StrParsingContext, /) -> datetime.timedelta:
2913 ctx = ctx.strip_if_non_space()
2914 return self._parse(ctx.value, ctx)
2916 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> datetime.timedelta:
2917 value = ctx.value
2918 if isinstance(value, datetime.timedelta):
2919 return value
2920 elif isinstance(value, str):
2921 return self._parse(value, ctx)
2922 else:
2923 raise ParsingError.type_mismatch(value, str, ctx=ctx)
2925 @staticmethod
2926 def _parse(value: str, ctx: ConfigParsingContext | StrParsingContext):
2927 value = value.strip()
2929 if not value:
2930 raise ParsingError("Got an empty `timedelta`", ctx=ctx)
2931 if value.endswith(","):
2932 raise ParsingError(
2933 "Can't parse `%r` as `timedelta`, trailing coma is not allowed",
2934 value,
2935 ctx=ctx,
2936 fallback_msg="Can't parse value as `timedelta`",
2937 )
2938 if value.startswith(","):
2939 raise ParsingError(
2940 "Can't parse `%r` as `timedelta`, leading coma is not allowed",
2941 value,
2942 ctx=ctx,
2943 fallback_msg="Can't parse value as `timedelta`",
2944 )
2946 if match := _TIMEDELTA_RE.match(value):
2947 (
2948 c_sign_s,
2949 components_s,
2950 t_sign_s,
2951 hour,
2952 minute,
2953 second,
2954 millisecond,
2955 microsecond,
2956 ) = match.groups()
2957 else:
2958 raise ParsingError(
2959 "Can't parse `%r` as `timedelta`",
2960 value,
2961 ctx=ctx,
2962 fallback_msg="Can't parse value as `timedelta`",
2963 )
2965 c_sign_s = -1 if c_sign_s == "-" else 1
2966 t_sign_s = -1 if t_sign_s == "-" else 1
2968 kwargs = {u: 0 for u, _ in _UNITS_MAP}
2970 if components_s:
2971 for num, unit in _COMPONENT_RE.findall(components_s):
2972 if unit_key := _UNITS.get(unit.lower()):
2973 kwargs[unit_key] += int(num)
2974 else:
2975 raise ParsingError(
2976 "Can't parse `%r` as `timedelta`, unknown unit `%r`",
2977 value,
2978 unit,
2979 ctx=ctx,
2980 fallback_msg="Can't parse value as `timedelta`",
2981 )
2983 timedelta = c_sign_s * datetime.timedelta(**kwargs)
2985 timedelta += t_sign_s * datetime.timedelta(
2986 hours=int(hour or "0"),
2987 minutes=int(minute or "0"),
2988 seconds=int(second or "0"),
2989 milliseconds=int(millisecond or "0"),
2990 microseconds=int(microsecond or "0"),
2991 )
2993 return timedelta
2995 def describe(self) -> str | None:
2996 return "[+|-]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.date,
3003 "TimeDelta",
3004 lambda: yuio.json_schema.Meta(
3005 yuio.json_schema.String(
3006 # save yourself some trouble, paste this into https://regexper.com/
3007 pattern=(
3008 r"^(([+-]?\s*(\d+\s*(d|day|days|s|sec|secs|second|seconds"
3009 r"|us|u|micro|micros|microsecond|microseconds|ms|l|milli|"
3010 r"millis|millisecond|milliseconds|m|min|mins|minute|minutes"
3011 r"|h|hr|hrs|hour|hours|w|week|weeks)\s*)+)(,\s*)?"
3012 r"([+-]?\s*\d+:\d?\d(:\d?\d(\.\d\d\d(\d\d\d)?)?)?)"
3013 r"|([+-]?\s*\d+:\d?\d(:\d?\d(\.\d\d\d(\d\d\d)?)?)?)"
3014 r"|([+-]?\s*(\d+\s*(d|day|days|s|sec|secs|second|seconds"
3015 r"|us|u|micro|micros|microsecond|microseconds|ms|l|milli"
3016 r"|millis|millisecond|milliseconds|m|min|mins|minute|minutes"
3017 r"|h|hr|hrs|hour|hours|w|week|weeks)\s*)+))$"
3018 )
3019 ),
3020 title="Time delta. General format: '[+-] [M weeks] [N days] [+-]HH:MM:SS'",
3021 description=".",
3022 ),
3023 )
3025 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3026 assert self.assert_type(value)
3027 return str(value)
3030class Path(ValueParser[pathlib.Path]):
3031 """
3032 Parse a file system path, return a :class:`pathlib.Path`.
3034 :param extensions:
3035 list of allowed file extensions, including preceding dots.
3037 """
3039 def __init__(
3040 self,
3041 /,
3042 *,
3043 extensions: str | _t.Collection[str] | None = None,
3044 ):
3045 self._extensions = [extensions] if isinstance(extensions, str) else extensions
3046 super().__init__(pathlib.Path)
3048 def parse_with_ctx(self, ctx: StrParsingContext, /) -> pathlib.Path:
3049 ctx = ctx.strip_if_non_space()
3050 return self._parse(ctx.value, ctx)
3052 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> pathlib.Path:
3053 value = ctx.value
3054 if not isinstance(value, str):
3055 raise ParsingError.type_mismatch(value, str, ctx=ctx)
3056 return self._parse(value, ctx)
3058 def _parse(self, value: str, ctx: ConfigParsingContext | StrParsingContext):
3059 res = pathlib.Path(value).expanduser().resolve().absolute()
3060 try:
3061 self._validate(res)
3062 except ParsingError as e:
3063 e.set_ctx(ctx)
3064 raise
3065 return res
3067 def describe(self) -> str | None:
3068 if self._extensions is not None:
3069 desc = "|".join(f"<*{e}>" for e in self._extensions)
3070 if len(self._extensions) > 1:
3071 desc = f"{{{desc}}}"
3072 return desc
3073 else:
3074 return super().describe()
3076 def _validate(self, value: pathlib.Path, /):
3077 if self._extensions is not None and not any(
3078 value.name.endswith(ext) for ext in self._extensions
3079 ):
3080 raise ParsingError(
3081 "<c path>%s</c> should have extension %s",
3082 value,
3083 yuio.string.Or(self._extensions),
3084 )
3086 def completer(self) -> yuio.complete.Completer | None:
3087 return yuio.complete.File(extensions=self._extensions)
3089 def to_json_schema(
3090 self, ctx: yuio.json_schema.JsonSchemaContext, /
3091 ) -> yuio.json_schema.JsonSchemaType:
3092 return yuio.json_schema.String()
3094 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3095 assert self.assert_type(value)
3096 return str(value)
3099class NonExistentPath(Path):
3100 """
3101 Parse a file system path and verify that it doesn't exist.
3103 :param extensions:
3104 list of allowed file extensions, including preceding dots.
3106 """
3108 def _validate(self, value: pathlib.Path, /):
3109 super()._validate(value)
3111 if value.exists():
3112 raise ParsingError("<c path>%s</c> already exists", value)
3115class ExistingPath(Path):
3116 """
3117 Parse a file system path and verify that it exists.
3119 :param extensions:
3120 list of allowed file extensions, including preceding dots.
3122 """
3124 def _validate(self, value: pathlib.Path, /):
3125 super()._validate(value)
3127 if not value.exists():
3128 raise ParsingError("<c path>%s</c> doesn't exist", value)
3131class File(ExistingPath):
3132 """
3133 Parse a file system path and verify that it points to a regular file.
3135 :param extensions:
3136 list of allowed file extensions, including preceding dots.
3138 """
3140 def _validate(self, value: pathlib.Path, /):
3141 super()._validate(value)
3143 if not value.is_file():
3144 raise ParsingError("<c path>%s</c> is not a file", value)
3147class Dir(ExistingPath):
3148 """
3149 Parse a file system path and verify that it points to a directory.
3151 """
3153 def __init__(self):
3154 # Disallow passing `extensions`.
3155 super().__init__()
3157 def _validate(self, value: pathlib.Path, /):
3158 super()._validate(value)
3160 if not value.is_dir():
3161 raise ParsingError("<c path>%s</c> is not a directory", value)
3163 def completer(self) -> yuio.complete.Completer | None:
3164 return yuio.complete.Dir()
3167class GitRepo(Dir):
3168 """
3169 Parse a file system path and verify that it points to a git repository.
3171 This parser just checks that the given directory has
3172 a subdirectory named ``.git``.
3174 """
3176 def _validate(self, value: pathlib.Path, /):
3177 super()._validate(value)
3179 if not value.joinpath(".git").is_dir():
3180 raise ParsingError("<c path>%s</c> is not a git repository root", value)
3183class Secret(Map[SecretValue[T], T], _t.Generic[T]):
3184 """Secret(inner: Parser[U], /)
3186 Wraps result of the inner parser into :class:`~yuio.secret.SecretValue`
3187 and ensures that :func:`yuio.io.ask` doesn't show value as user enters it.
3189 """
3191 if TYPE_CHECKING:
3193 @_t.overload
3194 def __new__(cls, inner: Parser[T], /) -> Secret[T]: ...
3196 @_t.overload
3197 def __new__(cls, /) -> PartialParser: ...
3199 def __new__(cls, *args, **kwargs) -> _t.Any: ...
3201 def __init__(self, inner: Parser[U] | None = None, /):
3202 super().__init__(inner, SecretValue, lambda x: x.data)
3204 def parse_with_ctx(self, ctx: StrParsingContext, /) -> SecretValue[T]:
3205 with self._replace_error():
3206 return super().parse_with_ctx(ctx)
3208 def parse_many_with_ctx(
3209 self, ctxs: _t.Sequence[StrParsingContext], /
3210 ) -> SecretValue[T]:
3211 with self._replace_error():
3212 return super().parse_many_with_ctx(ctxs)
3214 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> SecretValue[T]:
3215 with self._replace_error():
3216 return super().parse_config_with_ctx(ctx)
3218 @staticmethod
3219 @contextlib.contextmanager
3220 def _replace_error():
3221 try:
3222 yield
3223 except ParsingError as e:
3224 # Error messages can contain secret value, hide them.
3225 raise ParsingError(
3226 yuio.string.Printable(
3227 e.fallback_msg or "Error when parsing secret data"
3228 ),
3229 pos=e.pos,
3230 path=e.path,
3231 n_arg=e.n_arg,
3232 # Omit raw value.
3233 ) from None
3235 def describe_value(self, value: object, /) -> str:
3236 return "***"
3238 def completer(self) -> yuio.complete.Completer | None:
3239 return None
3241 def options(self) -> _t.Collection[yuio.widget.Option[SecretValue[T]]] | None:
3242 return None
3244 def widget(
3245 self,
3246 default: object | yuio.Missing,
3247 input_description: str | None,
3248 default_description: str | None,
3249 /,
3250 ) -> yuio.widget.Widget[SecretValue[T] | yuio.Missing]:
3251 return _secret_widget(self, default, input_description, default_description)
3253 def is_secret(self) -> bool:
3254 return True
3257class CollectionParser(
3258 WrappingParser[C, Parser[T]], ValueParser[C], PartialParser, _t.Generic[C, T]
3259):
3260 """CollectionParser(inner: Parser[T] | None, /, **kwargs)
3262 A base class for implementing collection parsing. It will split a string
3263 by the given delimiter, parse each item using a subparser, and then pass
3264 the result to the given constructor.
3266 :param inner:
3267 parser that will be used to parse collection items.
3268 :param ty:
3269 type of the collection that this parser returns.
3270 :param ctor:
3271 factory of instances of the collection that this parser returns.
3272 It should take an iterable of parsed items, and return a collection.
3273 :param iter:
3274 a function that is used to get an iterator from a collection.
3275 This defaults to :func:`iter`, but sometimes it may be different.
3276 For example, :class:`Dict` is implemented as a collection of pairs,
3277 and its `iter` is :meth:`dict.items`.
3278 :param config_type:
3279 type of a collection that we expect to find when parsing a config.
3280 This will usually be a list.
3281 :param config_type_iter:
3282 a function that is used to get an iterator from a config value.
3283 :param delimiter:
3284 delimiter that will be passed to :py:meth:`str.split`.
3286 The above parameters are exposed via protected attributes:
3287 ``self._inner``, ``self._ty``, etc.
3289 For example, let's implement a :class:`list` parser
3290 that repeats each element twice:
3292 .. code-block:: python
3294 from typing import Iterable, Generic
3297 class DoubleList(CollectionParser[list[T], T], Generic[T]):
3298 def __init__(self, inner: Parser[T], /, *, delimiter: str | None = None):
3299 super().__init__(inner, ty=list, ctor=self._ctor, delimiter=delimiter)
3301 @staticmethod
3302 def _ctor(values: Iterable[T]) -> list[T]:
3303 return [x for value in values for x in [value, value]]
3305 def to_json_schema(
3306 self, ctx: yuio.json_schema.JsonSchemaContext, /
3307 ) -> yuio.json_schema.JsonSchemaType:
3308 return {"type": "array", "items": self._inner.to_json_schema(ctx)}
3310 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3311 assert self.assert_type(value)
3312 return [self._inner.to_json_value(item) for item in value[::2]]
3314 ::
3316 >>> parser = DoubleList(Int())
3317 >>> parser.parse("1 2 3")
3318 [1, 1, 2, 2, 3, 3]
3319 >>> parser.to_json_value([1, 1, 2, 2, 3, 3])
3320 [1, 2, 3]
3322 """
3324 _allow_completing_duplicates: typing.ClassVar[bool] = True
3325 """
3326 If set to :data:`False`, autocompletion will not suggest item duplicates.
3328 """
3330 def __init__(
3331 self,
3332 inner: Parser[T] | None,
3333 /,
3334 *,
3335 ty: type[C],
3336 ctor: _t.Callable[[_t.Iterable[T]], C],
3337 iter: _t.Callable[[C], _t.Iterable[T]] = iter,
3338 config_type: type[C2] | tuple[type[C2], ...] = list,
3339 config_type_iter: _t.Callable[[C2], _t.Iterable[T]] = iter,
3340 delimiter: str | None = None,
3341 ):
3342 if delimiter == "":
3343 raise ValueError("empty delimiter")
3345 #: See class parameters for more details.
3346 self._ty = ty
3347 self._ctor = ctor
3348 self._iter = iter
3349 self._config_type = config_type
3350 self._config_type_iter = config_type_iter
3351 self._delimiter = delimiter
3353 super().__init__(inner, ty)
3355 def wrap(self: P, parser: Parser[_t.Any]) -> P:
3356 result = super().wrap(parser)
3357 result._inner = parser._inner # type: ignore
3358 return result
3360 def parse_with_ctx(self, ctx: StrParsingContext, /) -> C:
3361 return self._ctor(
3362 self._inner.parse_with_ctx(item) for item in ctx.split(self._delimiter)
3363 )
3365 def parse_many_with_ctx(self, ctxs: _t.Sequence[StrParsingContext], /) -> C:
3366 return self._ctor(self._inner.parse_with_ctx(item) for item in ctxs)
3368 def supports_parse_many(self) -> bool:
3369 return True
3371 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> C:
3372 value = ctx.value
3373 if not isinstance(value, self._config_type):
3374 expected = self._config_type
3375 if not isinstance(expected, tuple):
3376 expected = (expected,)
3377 raise ParsingError.type_mismatch(value, *expected, ctx=ctx)
3379 return self._ctor(
3380 self._inner.parse_config_with_ctx(ctx.descend(item, i))
3381 for i, item in enumerate(self._config_type_iter(value))
3382 )
3384 def get_nargs(self) -> _t.Literal["+", "*"] | int:
3385 return "*"
3387 def describe(self) -> str | None:
3388 delimiter = self._delimiter or " "
3389 value = self._inner.describe_or_def()
3391 return f"{value}[{delimiter}{value}[{delimiter}...]]"
3393 def describe_many(self) -> str | tuple[str, ...]:
3394 return self._inner.describe_or_def()
3396 def describe_value(self, value: object, /) -> str:
3397 assert self.assert_type(value)
3399 return (self._delimiter or " ").join(
3400 self._inner.describe_value(item) for item in self._iter(value)
3401 )
3403 def options(self) -> _t.Collection[yuio.widget.Option[C]] | None:
3404 return None
3406 def completer(self) -> yuio.complete.Completer | None:
3407 completer = self._inner.completer()
3408 return (
3409 yuio.complete.List(
3410 completer,
3411 delimiter=self._delimiter,
3412 allow_duplicates=self._allow_completing_duplicates,
3413 )
3414 if completer is not None
3415 else None
3416 )
3418 def widget(
3419 self,
3420 default: object | yuio.Missing,
3421 input_description: str | None,
3422 default_description: str | None,
3423 /,
3424 ) -> yuio.widget.Widget[C | yuio.Missing]:
3425 completer = self.completer()
3426 return _WidgetResultMapper(
3427 self,
3428 input_description,
3429 default,
3430 (
3431 yuio.widget.InputWithCompletion(
3432 completer,
3433 placeholder=default_description or "",
3434 )
3435 if completer is not None
3436 else yuio.widget.Input(
3437 placeholder=default_description or "",
3438 )
3439 ),
3440 )
3442 def is_secret(self) -> bool:
3443 return self._inner.is_secret()
3445 def __repr__(self):
3446 if self._inner_raw is not None:
3447 return f"{self.__class__.__name__}({self._inner_raw!r})"
3448 else:
3449 return self.__class__.__name__
3452class List(CollectionParser[list[T], T], _t.Generic[T]):
3453 """List(inner: Parser[T], /, *, delimiter: str | None = None)
3455 Parser for lists.
3457 Will split a string by the given delimiter, and parse each item
3458 using a subparser.
3460 :param inner:
3461 inner parser that will be used to parse list items.
3462 :param delimiter:
3463 delimiter that will be passed to :py:meth:`str.split`.
3465 """
3467 if TYPE_CHECKING:
3469 @_t.overload
3470 def __new__(
3471 cls, inner: Parser[T], /, *, delimiter: str | None = None
3472 ) -> List[T]: ...
3474 @_t.overload
3475 def __new__(cls, /, *, delimiter: str | None = None) -> PartialParser: ...
3477 def __new__(cls, *args, **kwargs) -> _t.Any: ...
3479 def __init__(
3480 self,
3481 inner: Parser[T] | None = None,
3482 /,
3483 *,
3484 delimiter: str | None = None,
3485 ):
3486 super().__init__(inner, ty=list, ctor=list, delimiter=delimiter)
3488 def to_json_schema(
3489 self, ctx: yuio.json_schema.JsonSchemaContext, /
3490 ) -> yuio.json_schema.JsonSchemaType:
3491 return yuio.json_schema.Array(self._inner.to_json_schema(ctx))
3493 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3494 assert self.assert_type(value)
3495 return [self._inner.to_json_value(item) for item in value]
3498class Set(CollectionParser[set[T], T], _t.Generic[T]):
3499 """Set(inner: Parser[T], /, *, delimiter: str | None = None)
3501 Parser for sets.
3503 Will split a string by the given delimiter, and parse each item
3504 using a subparser.
3506 :param inner:
3507 inner parser that will be used to parse set items.
3508 :param delimiter:
3509 delimiter that will be passed to :py:meth:`str.split`.
3511 """
3513 if TYPE_CHECKING:
3515 @_t.overload
3516 def __new__(
3517 cls, inner: Parser[T], /, *, delimiter: str | None = None
3518 ) -> Set[T]: ...
3520 @_t.overload
3521 def __new__(cls, /, *, delimiter: str | None = None) -> PartialParser: ...
3523 def __new__(cls, *args, **kwargs) -> _t.Any: ...
3525 _allow_completing_duplicates = False
3527 def __init__(
3528 self,
3529 inner: Parser[T] | None = None,
3530 /,
3531 *,
3532 delimiter: str | None = None,
3533 ):
3534 super().__init__(inner, ty=set, ctor=set, delimiter=delimiter)
3536 def widget(
3537 self,
3538 default: object | yuio.Missing,
3539 input_description: str | None,
3540 default_description: str | None,
3541 /,
3542 ) -> yuio.widget.Widget[set[T] | yuio.Missing]:
3543 options = self._inner.options()
3544 if options is not None and len(options) <= 25:
3545 return yuio.widget.Map(yuio.widget.Multiselect(list(options)), set)
3546 else:
3547 return super().widget(default, input_description, default_description)
3549 def to_json_schema(
3550 self, ctx: yuio.json_schema.JsonSchemaContext, /
3551 ) -> yuio.json_schema.JsonSchemaType:
3552 return yuio.json_schema.Array(
3553 self._inner.to_json_schema(ctx), unique_items=True
3554 )
3556 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3557 assert self.assert_type(value)
3558 return [self._inner.to_json_value(item) for item in value]
3561class FrozenSet(CollectionParser[frozenset[T], T], _t.Generic[T]):
3562 """FrozenSet(inner: Parser[T], /, *, delimiter: str | None = None)
3564 Parser for frozen sets.
3566 Will split a string by the given delimiter, and parse each item
3567 using a subparser.
3569 :param inner:
3570 inner parser that will be used to parse set items.
3571 :param delimiter:
3572 delimiter that will be passed to :py:meth:`str.split`.
3574 """
3576 if TYPE_CHECKING:
3578 @_t.overload
3579 def __new__(
3580 cls, inner: Parser[T], /, *, delimiter: str | None = None
3581 ) -> FrozenSet[T]: ...
3583 @_t.overload
3584 def __new__(cls, /, *, delimiter: str | None = None) -> PartialParser: ...
3586 def __new__(cls, *args, **kwargs) -> _t.Any: ...
3588 _allow_completing_duplicates = False
3590 def __init__(
3591 self,
3592 inner: Parser[T] | None = None,
3593 /,
3594 *,
3595 delimiter: str | None = None,
3596 ):
3597 super().__init__(inner, ty=frozenset, ctor=frozenset, delimiter=delimiter)
3599 def to_json_schema(
3600 self, ctx: yuio.json_schema.JsonSchemaContext, /
3601 ) -> yuio.json_schema.JsonSchemaType:
3602 return yuio.json_schema.Array(
3603 self._inner.to_json_schema(ctx), unique_items=True
3604 )
3606 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3607 assert self.assert_type(value)
3608 return [self._inner.to_json_value(item) for item in value]
3611class Dict(CollectionParser[dict[K, V], tuple[K, V]], _t.Generic[K, V]):
3612 """Dict(key: Parser[K], value: Parser[V], /, *, delimiter: str | None = None, pair_delimiter: str = ":")
3614 Parser for dicts.
3616 Will split a string by the given delimiter, and parse each item
3617 using a :py:class:`Tuple` parser.
3619 :param key:
3620 inner parser that will be used to parse dict keys.
3621 :param value:
3622 inner parser that will be used to parse dict values.
3623 :param delimiter:
3624 delimiter that will be passed to :py:meth:`str.split`.
3625 :param pair_delimiter:
3626 delimiter that will be used to split key-value elements.
3628 """
3630 if TYPE_CHECKING:
3632 @_t.overload
3633 def __new__(
3634 cls,
3635 key: Parser[K],
3636 value: Parser[V],
3637 /,
3638 *,
3639 delimiter: str | None = None,
3640 pair_delimiter: str = ":",
3641 ) -> Dict[K, V]: ...
3643 @_t.overload
3644 def __new__(
3645 cls,
3646 /,
3647 *,
3648 delimiter: str | None = None,
3649 pair_delimiter: str = ":",
3650 ) -> PartialParser: ...
3652 def __new__(cls, *args, **kwargs) -> _t.Any: ...
3654 _allow_completing_duplicates = False
3656 def __init__(
3657 self,
3658 key: Parser[K] | None = None,
3659 value: Parser[V] | None = None,
3660 /,
3661 *,
3662 delimiter: str | None = None,
3663 pair_delimiter: str = ":",
3664 ):
3665 self._pair_delimiter = pair_delimiter
3666 super().__init__(
3667 (
3668 _DictElementParser(key, value, delimiter=pair_delimiter)
3669 if key and value
3670 else None
3671 ),
3672 ty=dict,
3673 ctor=dict,
3674 iter=dict.items,
3675 config_type=(dict, list),
3676 config_type_iter=self.__config_type_iter,
3677 delimiter=delimiter,
3678 )
3680 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
3681 result = super().wrap(parser)
3682 setattr(result._inner, "_delimiter", self._pair_delimiter)
3683 return result
3685 @staticmethod
3686 def __config_type_iter(x) -> _t.Iterator[tuple[K, V]]:
3687 if isinstance(x, dict):
3688 return iter(x.items())
3689 else:
3690 return iter(x)
3692 def to_json_schema(
3693 self, ctx: yuio.json_schema.JsonSchemaContext, /
3694 ) -> yuio.json_schema.JsonSchemaType:
3695 key_schema = self._inner._inner[0].to_json_schema(ctx) # type: ignore
3696 value_schema = self._inner._inner[1].to_json_schema(ctx) # type: ignore
3697 return yuio.json_schema.Dict(key_schema, value_schema)
3699 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3700 assert self.assert_type(value)
3701 items = _t.cast(
3702 list[tuple[yuio.json_schema.JsonValue, yuio.json_schema.JsonValue]],
3703 [self._inner.to_json_value(item) for item in value.items()],
3704 )
3706 if all(isinstance(k, str) for k, _ in items):
3707 return dict(_t.cast(list[tuple[str, yuio.json_schema.JsonValue]], items))
3708 else:
3709 return items
3712class Tuple(
3713 WrappingParser[TU, tuple[Parser[object], ...]],
3714 ValueParser[TU],
3715 PartialParser,
3716 _t.Generic[TU],
3717):
3718 """Tuple(*parsers: Parser[...], delimiter: str | None = None)
3720 Parser for tuples of fixed lengths.
3722 :param parsers:
3723 parsers for each tuple element.
3724 :param delimiter:
3725 delimiter that will be passed to :py:meth:`str.split`.
3727 """
3729 # See the links below for an explanation of shy this is so ugly:
3730 # https://github.com/python/typing/discussions/1450
3731 # https://github.com/python/typing/issues/1216
3732 if TYPE_CHECKING:
3733 T1 = _t.TypeVar("T1")
3734 T2 = _t.TypeVar("T2")
3735 T3 = _t.TypeVar("T3")
3736 T4 = _t.TypeVar("T4")
3737 T5 = _t.TypeVar("T5")
3738 T6 = _t.TypeVar("T6")
3739 T7 = _t.TypeVar("T7")
3740 T8 = _t.TypeVar("T8")
3741 T9 = _t.TypeVar("T9")
3742 T10 = _t.TypeVar("T10")
3744 @_t.overload
3745 def __new__(
3746 cls,
3747 /,
3748 *,
3749 delimiter: str | None = None,
3750 ) -> PartialParser: ...
3752 @_t.overload
3753 def __new__(
3754 cls,
3755 p1: Parser[T1],
3756 /,
3757 *,
3758 delimiter: str | None = None,
3759 ) -> Tuple[tuple[T1]]: ...
3761 @_t.overload
3762 def __new__(
3763 cls,
3764 p1: Parser[T1],
3765 p2: Parser[T2],
3766 /,
3767 *,
3768 delimiter: str | None = None,
3769 ) -> Tuple[tuple[T1, T2]]: ...
3771 @_t.overload
3772 def __new__(
3773 cls,
3774 p1: Parser[T1],
3775 p2: Parser[T2],
3776 p3: Parser[T3],
3777 /,
3778 *,
3779 delimiter: str | None = None,
3780 ) -> Tuple[tuple[T1, T2, T3]]: ...
3782 @_t.overload
3783 def __new__(
3784 cls,
3785 p1: Parser[T1],
3786 p2: Parser[T2],
3787 p3: Parser[T3],
3788 p4: Parser[T4],
3789 /,
3790 *,
3791 delimiter: str | None = None,
3792 ) -> Tuple[tuple[T1, T2, T3, T4]]: ...
3794 @_t.overload
3795 def __new__(
3796 cls,
3797 p1: Parser[T1],
3798 p2: Parser[T2],
3799 p3: Parser[T3],
3800 p4: Parser[T4],
3801 p5: Parser[T5],
3802 /,
3803 *,
3804 delimiter: str | None = None,
3805 ) -> Tuple[tuple[T1, T2, T3, T4, T5]]: ...
3807 @_t.overload
3808 def __new__(
3809 cls,
3810 p1: Parser[T1],
3811 p2: Parser[T2],
3812 p3: Parser[T3],
3813 p4: Parser[T4],
3814 p5: Parser[T5],
3815 p6: Parser[T6],
3816 /,
3817 *,
3818 delimiter: str | None = None,
3819 ) -> Tuple[tuple[T1, T2, T3, T4, T5, T6]]: ...
3821 @_t.overload
3822 def __new__(
3823 cls,
3824 p1: Parser[T1],
3825 p2: Parser[T2],
3826 p3: Parser[T3],
3827 p4: Parser[T4],
3828 p5: Parser[T5],
3829 p6: Parser[T6],
3830 p7: Parser[T7],
3831 /,
3832 *,
3833 delimiter: str | None = None,
3834 ) -> Tuple[tuple[T1, T2, T3, T4, T5, T6, T7]]: ...
3836 @_t.overload
3837 def __new__(
3838 cls,
3839 p1: Parser[T1],
3840 p2: Parser[T2],
3841 p3: Parser[T3],
3842 p4: Parser[T4],
3843 p5: Parser[T5],
3844 p6: Parser[T6],
3845 p7: Parser[T7],
3846 p8: Parser[T8],
3847 /,
3848 *,
3849 delimiter: str | None = None,
3850 ) -> Tuple[tuple[T1, T2, T3, T4, T5, T6, T7, T8]]: ...
3852 @_t.overload
3853 def __new__(
3854 cls,
3855 p1: Parser[T1],
3856 p2: Parser[T2],
3857 p3: Parser[T3],
3858 p4: Parser[T4],
3859 p5: Parser[T5],
3860 p6: Parser[T6],
3861 p7: Parser[T7],
3862 p8: Parser[T8],
3863 p9: Parser[T9],
3864 /,
3865 *,
3866 delimiter: str | None = None,
3867 ) -> Tuple[tuple[T1, T2, T3, T4, T5, T6, T7, T8, T9]]: ...
3869 @_t.overload
3870 def __new__(
3871 cls,
3872 p1: Parser[T1],
3873 p2: Parser[T2],
3874 p3: Parser[T3],
3875 p4: Parser[T4],
3876 p5: Parser[T5],
3877 p6: Parser[T6],
3878 p7: Parser[T7],
3879 p8: Parser[T8],
3880 p9: Parser[T9],
3881 p10: Parser[T10],
3882 /,
3883 *,
3884 delimiter: str | None = None,
3885 ) -> Tuple[tuple[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10]]: ...
3887 @_t.overload
3888 def __new__(
3889 cls,
3890 p1: Parser[T1],
3891 p2: Parser[T2],
3892 p3: Parser[T3],
3893 p4: Parser[T4],
3894 p5: Parser[T5],
3895 p6: Parser[T6],
3896 p7: Parser[T7],
3897 p8: Parser[T8],
3898 p9: Parser[T9],
3899 p10: Parser[T10],
3900 p11: Parser[object],
3901 *tail: Parser[object],
3902 delimiter: str | None = None,
3903 ) -> Tuple[tuple[_t.Any, ...]]: ...
3905 def __new__(cls, *args, **kwargs) -> _t.Any: ...
3907 def __init__(
3908 self,
3909 *parsers: Parser[_t.Any],
3910 delimiter: str | None = None,
3911 ):
3912 if delimiter == "":
3913 raise ValueError("empty delimiter")
3914 self._delimiter = delimiter
3915 super().__init__(parsers or None, tuple)
3917 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
3918 result = super().wrap(parser)
3919 result._inner = parser._inner # type: ignore
3920 return result
3922 def parse_with_ctx(self, ctx: StrParsingContext, /) -> TU:
3923 items = list(ctx.split(self._delimiter, maxsplit=len(self._inner) - 1))
3925 if len(items) != len(self._inner):
3926 raise ParsingError(
3927 "Expected %s element%s, got %s: `%r`",
3928 len(self._inner),
3929 "" if len(self._inner) == 1 else "s",
3930 len(items),
3931 ctx.value,
3932 ctx=ctx,
3933 )
3935 return _t.cast(
3936 TU,
3937 tuple(
3938 parser.parse_with_ctx(item) for parser, item in zip(self._inner, items)
3939 ),
3940 )
3942 def parse_many_with_ctx(self, ctxs: _t.Sequence[StrParsingContext], /) -> TU:
3943 if len(ctxs) != len(self._inner):
3944 raise ParsingError(
3945 "Expected %s element%s, got %s: `%r`",
3946 len(self._inner),
3947 "" if len(self._inner) == 1 else "s",
3948 len(ctxs),
3949 ctxs,
3950 )
3952 return _t.cast(
3953 TU,
3954 tuple(
3955 parser.parse_with_ctx(item) for parser, item in zip(self._inner, ctxs)
3956 ),
3957 )
3959 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> TU:
3960 value = ctx.value
3961 if not isinstance(value, (list, tuple)):
3962 raise ParsingError.type_mismatch(value, list, tuple, ctx=ctx)
3963 elif len(value) != len(self._inner):
3964 raise ParsingError(
3965 "Expected %s element%s, got %s: `%r`",
3966 len(self._inner),
3967 "" if len(self._inner) == 1 else "s",
3968 len(value),
3969 value,
3970 )
3972 return _t.cast(
3973 TU,
3974 tuple(
3975 parser.parse_config_with_ctx(ctx.descend(item, i))
3976 for i, (parser, item) in enumerate(zip(self._inner, value))
3977 ),
3978 )
3980 def supports_parse_many(self) -> bool:
3981 return True
3983 def get_nargs(self) -> _t.Literal["+", "*"] | int:
3984 return len(self._inner)
3986 def describe(self) -> str | None:
3987 delimiter = self._delimiter or " "
3988 desc = [parser.describe_or_def() for parser in self._inner]
3989 return delimiter.join(desc)
3991 def describe_many(self) -> str | tuple[str, ...]:
3992 return tuple(parser.describe_or_def() for parser in self._inner)
3994 def describe_value(self, value: object, /) -> str:
3995 assert self.assert_type(value)
3997 delimiter = self._delimiter or " "
3998 desc = [parser.describe_value(item) for parser, item in zip(self._inner, value)]
4000 return delimiter.join(desc)
4002 def options(self) -> _t.Collection[yuio.widget.Option[TU]] | None:
4003 return None
4005 def completer(self) -> yuio.complete.Completer | None:
4006 return yuio.complete.Tuple(
4007 *[parser.completer() or yuio.complete.Empty() for parser in self._inner],
4008 delimiter=self._delimiter,
4009 )
4011 def widget(
4012 self,
4013 default: object | yuio.Missing,
4014 input_description: str | None,
4015 default_description: str | None,
4016 /,
4017 ) -> yuio.widget.Widget[TU | yuio.Missing]:
4018 completer = self.completer()
4020 return _WidgetResultMapper(
4021 self,
4022 input_description,
4023 default,
4024 (
4025 yuio.widget.InputWithCompletion(
4026 completer,
4027 placeholder=default_description or "",
4028 )
4029 if completer is not None
4030 else yuio.widget.Input(
4031 placeholder=default_description or "",
4032 )
4033 ),
4034 )
4036 def to_json_schema(
4037 self, ctx: yuio.json_schema.JsonSchemaContext, /
4038 ) -> yuio.json_schema.JsonSchemaType:
4039 return yuio.json_schema.Tuple(
4040 [parser.to_json_schema(ctx) for parser in self._inner]
4041 )
4043 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
4044 assert self.assert_type(value)
4045 return [parser.to_json_value(item) for parser, item in zip(self._inner, value)]
4047 def is_secret(self) -> bool:
4048 return any(parser.is_secret() for parser in self._inner)
4050 def __repr__(self):
4051 if self._inner_raw is not None:
4052 return f"{self.__class__.__name__}{self._inner_raw!r}"
4053 else:
4054 return self.__class__.__name__
4057class _DictElementParser(Tuple[tuple[K, V]], _t.Generic[K, V]):
4058 def __init__(self, k: Parser[K], v: Parser[V], delimiter: str | None = None):
4059 super().__init__(k, v, delimiter=delimiter)
4061 # def parse_with_ctx(self, ctx: StrParsingContext, /) -> tuple[K, V]:
4062 # items = list(ctx.split(self._delimiter, maxsplit=len(self._inner) - 1))
4064 # if len(items) != len(self._inner):
4065 # raise ParsingError("Expected key-value pair, got `%r`", ctx.value)
4067 # return _t.cast(
4068 # tuple[K, V],
4069 # tuple(parser.parse_with_ctx(item) for parser, item in zip(self._inner, items)),
4070 # )
4072 # def parse_many_with_ctx(self, ctxs: _t.Sequence[StrParsingContext], /) -> tuple[K, V]:
4073 # if len(value) != len(self._inner):
4074 # with describe_context("element #%(key)r"):
4075 # raise ParsingError(
4076 # "Expected key-value pair, got `%r`",
4077 # value,
4078 # )
4080 # k = describe_context("key of element #%(key)r", self._inner[0].parse, value[0])
4081 # v = replace_context(k, self._inner[1].parse, value[1])
4083 # return _t.cast(tuple[K, V], (k, v))
4085 # def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> tuple[K, V]:
4086 # if not isinstance(value, (list, tuple)):
4087 # with describe_context("element #%(key)r"):
4088 # raise ParsingError.type_mismatch(value, list, tuple)
4089 # elif len(value) != len(self._inner):
4090 # with describe_context("element #%(key)r"):
4091 # raise ParsingError(
4092 # "Expected key-value pair, got `%r`",
4093 # value,
4094 # )
4096 # k = describe_context(
4097 # "key of element #%(key)r", self._inner[0].parse_config_with_ctx, value[0]
4098 # )
4099 # v = replace_context(k, self._inner[1].parse_config_with_ctx, value[1])
4101 # return _t.cast(tuple[K, V], (k, v))
4104class Optional(MappingParser[T | None, T], _t.Generic[T]):
4105 """Optional(inner: Parser[T], /)
4107 Parser for optional values.
4109 Allows handling :data:`None`\\ s when parsing config. Does not change how strings
4110 are parsed, though.
4112 :param inner:
4113 a parser used to extract and validate contents of an optional.
4115 """
4117 if TYPE_CHECKING:
4119 @_t.overload
4120 def __new__(cls, inner: Parser[T], /) -> Optional[T]: ...
4122 @_t.overload
4123 def __new__(cls, /) -> PartialParser: ...
4125 def __new__(cls, *args, **kwargs) -> _t.Any: ...
4127 def __init__(self, inner: Parser[T] | None = None, /):
4128 super().__init__(inner)
4130 def parse_with_ctx(self, ctx: StrParsingContext, /) -> T | None:
4131 return self._inner.parse_with_ctx(ctx)
4133 def parse_many_with_ctx(self, ctxs: _t.Sequence[StrParsingContext], /) -> T | None:
4134 return self._inner.parse_many_with_ctx(ctxs)
4136 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> T | None:
4137 if ctx.value is None:
4138 return None
4139 return self._inner.parse_config_with_ctx(ctx)
4141 def check_type(self, value: object, /) -> _t.TypeGuard[T | None]:
4142 return True
4144 def describe_value(self, value: object, /) -> str:
4145 if value is None:
4146 return "<none>"
4147 return self._inner.describe_value(value)
4149 def options(self) -> _t.Collection[yuio.widget.Option[T | None]] | None:
4150 return self._inner.options()
4152 def widget(
4153 self,
4154 default: object | yuio.Missing,
4155 input_description: str | None,
4156 default_description: str | None,
4157 /,
4158 ) -> yuio.widget.Widget[T | yuio.Missing]:
4159 return self._inner.widget(default, input_description, default_description)
4161 def to_json_schema(
4162 self, ctx: yuio.json_schema.JsonSchemaContext, /
4163 ) -> yuio.json_schema.JsonSchemaType:
4164 return yuio.json_schema.OneOf(
4165 [self._inner.to_json_schema(ctx), yuio.json_schema.Null()]
4166 )
4168 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
4169 if value is None:
4170 return None
4171 else:
4172 return self._inner.to_json_value(value)
4175class Union(WrappingParser[T, tuple[Parser[T], ...]], ValueParser[T], _t.Generic[T]):
4176 """Union(*parsers: Parser[T])
4178 Tries several parsers and returns the first successful result.
4180 .. warning::
4182 Order of parsers matters. Since parsers are tried in the same order as they're
4183 given, make sure to put parsers that are likely to succeed at the end.
4185 For example, this parser will always return a string because :class:`Str`
4186 can't fail::
4188 >>> parser = Union(Str(), Int()) # Always returns a string!
4189 >>> parser.parse("10")
4190 '10'
4192 To fix this, put :class:`Str` at the end so that :class:`Int` is tried first::
4194 >>> parser = Union(Int(), Str())
4195 >>> parser.parse("10")
4196 10
4197 >>> parser.parse("not an int")
4198 'not an int'
4200 """
4202 # See the links below for an explanation of shy this is so ugly:
4203 # https://github.com/python/typing/discussions/1450
4204 # https://github.com/python/typing/issues/1216
4205 if TYPE_CHECKING:
4206 T1 = _t.TypeVar("T1")
4207 T2 = _t.TypeVar("T2")
4208 T3 = _t.TypeVar("T3")
4209 T4 = _t.TypeVar("T4")
4210 T5 = _t.TypeVar("T5")
4211 T6 = _t.TypeVar("T6")
4212 T7 = _t.TypeVar("T7")
4213 T8 = _t.TypeVar("T8")
4214 T9 = _t.TypeVar("T9")
4215 T10 = _t.TypeVar("T10")
4217 @_t.overload
4218 def __new__(
4219 cls,
4220 /,
4221 ) -> PartialParser: ...
4223 @_t.overload
4224 def __new__(
4225 cls,
4226 p1: Parser[T1],
4227 /,
4228 ) -> Union[T1]: ...
4230 @_t.overload
4231 def __new__(
4232 cls,
4233 p1: Parser[T1],
4234 p2: Parser[T2],
4235 /,
4236 ) -> Union[T1 | T2]: ...
4238 @_t.overload
4239 def __new__(
4240 cls,
4241 p1: Parser[T1],
4242 p2: Parser[T2],
4243 p3: Parser[T3],
4244 /,
4245 ) -> Union[T1 | T2 | T3]: ...
4247 @_t.overload
4248 def __new__(
4249 cls,
4250 p1: Parser[T1],
4251 p2: Parser[T2],
4252 p3: Parser[T3],
4253 p4: Parser[T4],
4254 /,
4255 ) -> Union[T1 | T2 | T3 | T4]: ...
4257 @_t.overload
4258 def __new__(
4259 cls,
4260 p1: Parser[T1],
4261 p2: Parser[T2],
4262 p3: Parser[T3],
4263 p4: Parser[T4],
4264 p5: Parser[T5],
4265 /,
4266 ) -> Union[T1 | T2 | T3 | T4 | T5]: ...
4268 @_t.overload
4269 def __new__(
4270 cls,
4271 p1: Parser[T1],
4272 p2: Parser[T2],
4273 p3: Parser[T3],
4274 p4: Parser[T4],
4275 p5: Parser[T5],
4276 p6: Parser[T6],
4277 /,
4278 ) -> Union[T1 | T2 | T3 | T4 | T5 | T6]: ...
4280 @_t.overload
4281 def __new__(
4282 cls,
4283 p1: Parser[T1],
4284 p2: Parser[T2],
4285 p3: Parser[T3],
4286 p4: Parser[T4],
4287 p5: Parser[T5],
4288 p6: Parser[T6],
4289 p7: Parser[T7],
4290 /,
4291 ) -> Union[T1 | T2 | T3 | T4 | T5 | T6 | T7]: ...
4293 @_t.overload
4294 def __new__(
4295 cls,
4296 p1: Parser[T1],
4297 p2: Parser[T2],
4298 p3: Parser[T3],
4299 p4: Parser[T4],
4300 p5: Parser[T5],
4301 p6: Parser[T6],
4302 p7: Parser[T7],
4303 p8: Parser[T8],
4304 /,
4305 ) -> Union[T1 | T2 | T3 | T4 | T5 | T6 | T7 | T8]: ...
4307 @_t.overload
4308 def __new__(
4309 cls,
4310 p1: Parser[T1],
4311 p2: Parser[T2],
4312 p3: Parser[T3],
4313 p4: Parser[T4],
4314 p5: Parser[T5],
4315 p6: Parser[T6],
4316 p7: Parser[T7],
4317 p8: Parser[T8],
4318 p9: Parser[T9],
4319 /,
4320 ) -> Union[T1 | T2 | T3 | T4 | T5 | T6 | T7 | T8 | T9]: ...
4322 @_t.overload
4323 def __new__(
4324 cls,
4325 p1: Parser[T1],
4326 p2: Parser[T2],
4327 p3: Parser[T3],
4328 p4: Parser[T4],
4329 p5: Parser[T5],
4330 p6: Parser[T6],
4331 p7: Parser[T7],
4332 p8: Parser[T8],
4333 p9: Parser[T9],
4334 p10: Parser[T10],
4335 /,
4336 ) -> Union[T1 | T2 | T3 | T4 | T5 | T6 | T7 | T8 | T9 | T10]: ...
4338 @_t.overload
4339 def __new__(
4340 cls,
4341 p1: Parser[T1],
4342 p2: Parser[T2],
4343 p3: Parser[T3],
4344 p4: Parser[T4],
4345 p5: Parser[T5],
4346 p6: Parser[T6],
4347 p7: Parser[T7],
4348 p8: Parser[T8],
4349 p9: Parser[T9],
4350 p10: Parser[T10],
4351 p11: Parser[object],
4352 *parsers: Parser[object],
4353 ) -> Union[object]: ...
4355 def __new__(cls, *args, **kwargs) -> _t.Any: ...
4357 def __init__(self, *parsers: Parser[_t.Any]):
4358 super().__init__(parsers or None, object)
4360 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
4361 result = super().wrap(parser)
4362 result._inner = parser._inner # type: ignore
4363 return result
4365 def parse_with_ctx(self, ctx: StrParsingContext, /) -> T:
4366 errors: list[tuple[Parser[object], ParsingError]] = []
4367 for parser in self._inner:
4368 try:
4369 return parser.parse_with_ctx(ctx)
4370 except ParsingError as e:
4371 errors.append((parser, e))
4372 raise self._make_error(errors, ctx)
4374 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> T:
4375 errors: list[tuple[Parser[object], ParsingError]] = []
4376 for parser in self._inner:
4377 try:
4378 return parser.parse_config_with_ctx(ctx)
4379 except ParsingError as e:
4380 errors.append((parser, e))
4381 raise self._make_error(errors, ctx)
4383 def _make_error(
4384 self,
4385 errors: list[tuple[Parser[object], ParsingError]],
4386 ctx: StrParsingContext | ConfigParsingContext,
4387 ):
4388 msgs = []
4389 for parser, error in errors:
4390 error.raw = None
4391 error.pos = None
4392 msgs.append(
4393 yuio.string.Format(
4394 " Trying as `%s`:\n%s",
4395 parser.describe_or_def(),
4396 yuio.string.Indent(error, indent=4),
4397 )
4398 )
4399 return ParsingError(
4400 "Can't parse `%r`:\n%s", ctx.value, yuio.string.Stack(*msgs), ctx=ctx
4401 )
4403 def check_type(self, value: object, /) -> _t.TypeGuard[T]:
4404 return True
4406 def describe(self) -> str | None:
4407 if len(self._inner) > 1:
4409 def strip_curly_brackets(desc: str):
4410 if desc.startswith("{") and desc.endswith("}") and "|" in desc:
4411 s = desc[1:-1]
4412 if "{" not in s and "}" not in s:
4413 return s
4414 return desc
4416 desc = "|".join(
4417 strip_curly_brackets(parser.describe_or_def()) for parser in self._inner
4418 )
4419 desc = f"{{{desc}}}"
4420 else:
4421 desc = "|".join(parser.describe_or_def() for parser in self._inner)
4422 return desc
4424 def describe_value(self, value: object, /) -> str:
4425 for parser in self._inner:
4426 try:
4427 return parser.describe_value(value)
4428 except TypeError:
4429 pass
4431 raise TypeError(
4432 f"parser {self} can't handle value of type {_tx.type_repr(type(value))}"
4433 )
4435 def options(self) -> _t.Collection[yuio.widget.Option[T]] | None:
4436 result = []
4437 got_options = False
4438 for parser in self._inner:
4439 if options := parser.options():
4440 result.extend(options)
4441 got_options = True
4442 if got_options:
4443 return result
4444 else:
4445 return None
4447 def completer(self) -> yuio.complete.Completer | None:
4448 completers = []
4449 for parser in self._inner:
4450 if completer := parser.completer():
4451 completers.append((parser.describe(), completer))
4452 if not completers:
4453 return None
4454 elif len(completers) == 1:
4455 return completers[0][1]
4456 else:
4457 return yuio.complete.Alternative(completers)
4459 def to_json_schema(
4460 self, ctx: yuio.json_schema.JsonSchemaContext, /
4461 ) -> yuio.json_schema.JsonSchemaType:
4462 return yuio.json_schema.OneOf(
4463 [parser.to_json_schema(ctx) for parser in self._inner]
4464 )
4466 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
4467 for parser in self._inner:
4468 try:
4469 return parser.to_json_value(value)
4470 except TypeError:
4471 pass
4473 raise TypeError(
4474 f"parser {self} can't handle value of type {_tx.type_repr(type(value))}"
4475 )
4477 def is_secret(self) -> bool:
4478 return any(parser.is_secret() for parser in self._inner)
4480 def __repr__(self):
4481 return f"{self.__class__.__name__}{self._inner_raw!r}"
4484class _BoundImpl(ValidatingParser[T], _t.Generic[T, Cmp]):
4485 def __init__(
4486 self,
4487 inner: Parser[T] | None,
4488 /,
4489 *,
4490 lower: Cmp | None = None,
4491 lower_inclusive: Cmp | None = None,
4492 upper: Cmp | None = None,
4493 upper_inclusive: Cmp | None = None,
4494 mapper: _t.Callable[[T], Cmp],
4495 desc: str,
4496 ):
4497 super().__init__(inner)
4499 self._lower_bound: Cmp | None = None
4500 self._lower_bound_is_inclusive: bool = True
4501 self._upper_bound: Cmp | None = None
4502 self._upper_bound_is_inclusive: bool = True
4504 if lower is not None and lower_inclusive is not None:
4505 raise TypeError(
4506 "lower and lower_inclusive cannot be given at the same time"
4507 )
4508 elif lower is not None:
4509 self._lower_bound = lower
4510 self._lower_bound_is_inclusive = False
4511 elif lower_inclusive is not None:
4512 self._lower_bound = lower_inclusive
4513 self._lower_bound_is_inclusive = True
4515 if upper is not None and upper_inclusive is not None:
4516 raise TypeError(
4517 "upper and upper_inclusive cannot be given at the same time"
4518 )
4519 elif upper is not None:
4520 self._upper_bound = upper
4521 self._upper_bound_is_inclusive = False
4522 elif upper_inclusive is not None:
4523 self._upper_bound = upper_inclusive
4524 self._upper_bound_is_inclusive = True
4526 self.__mapper = mapper
4527 self.__desc = desc
4529 def _validate(self, value: T, /):
4530 mapped = self.__mapper(value)
4532 if self._lower_bound is not None:
4533 if self._lower_bound_is_inclusive and mapped < self._lower_bound:
4534 raise ParsingError(
4535 "%s should be greater than or equal to `%s`: `%r`",
4536 self.__desc,
4537 self._lower_bound,
4538 value,
4539 )
4540 elif not self._lower_bound_is_inclusive and not self._lower_bound < mapped:
4541 raise ParsingError(
4542 "%s should be greater than `%s`: `%r`",
4543 self.__desc,
4544 self._lower_bound,
4545 value,
4546 )
4548 if self._upper_bound is not None:
4549 if self._upper_bound_is_inclusive and self._upper_bound < mapped:
4550 raise ParsingError(
4551 "%s should be lesser than or equal to `%s`: `%r`",
4552 self.__desc,
4553 self._upper_bound,
4554 value,
4555 )
4556 elif not self._upper_bound_is_inclusive and not mapped < self._upper_bound:
4557 raise ParsingError(
4558 "%s should be lesser than `%s`: `%r`",
4559 self.__desc,
4560 self._upper_bound,
4561 value,
4562 )
4564 def __repr__(self):
4565 desc = ""
4566 if self._lower_bound is not None:
4567 desc += repr(self._lower_bound)
4568 desc += " <= " if self._lower_bound_is_inclusive else " < "
4569 mapper_name = getattr(self.__mapper, "__name__")
4570 if mapper_name and mapper_name != "<lambda>":
4571 desc += mapper_name
4572 else:
4573 desc += "x"
4574 if self._upper_bound is not None:
4575 desc += " <= " if self._upper_bound_is_inclusive else " < "
4576 desc += repr(self._upper_bound)
4577 return f"{self.__class__.__name__}({self.__wrapped_parser__!r}, {desc})"
4580class Bound(_BoundImpl[Cmp, Cmp], _t.Generic[Cmp]):
4581 """Bound(inner: Parser[Cmp], /, *, lower: Cmp | None = None, lower_inclusive: Cmp | None = None, upper: Cmp | None = None, upper_inclusive: Cmp | None = None)
4583 Check that value is upper- or lower-bound by some constraints.
4585 :param inner:
4586 parser whose result will be validated.
4587 :param lower:
4588 set lower bound for value, so we require that ``value > lower``.
4589 Can't be given if `lower_inclusive` is also given.
4590 :param lower_inclusive:
4591 set lower bound for value, so we require that ``value >= lower``.
4592 Can't be given if `lower` is also given.
4593 :param upper:
4594 set upper bound for value, so we require that ``value < upper``.
4595 Can't be given if `upper_inclusive` is also given.
4596 :param upper_inclusive:
4597 set upper bound for value, so we require that ``value <= upper``.
4598 Can't be given if `upper` is also given.
4599 :example:
4600 ::
4602 >>> # Int in range `0 < x <= 1`:
4603 >>> Bound(Int(), lower=0, upper_inclusive=1)
4604 Bound(Int, 0 < x <= 1)
4606 """
4608 if TYPE_CHECKING:
4610 @_t.overload
4611 def __new__(
4612 cls,
4613 inner: Parser[Cmp],
4614 /,
4615 *,
4616 lower: Cmp | None = None,
4617 lower_inclusive: Cmp | None = None,
4618 upper: Cmp | None = None,
4619 upper_inclusive: Cmp | None = None,
4620 ) -> Bound[Cmp]: ...
4622 @_t.overload
4623 def __new__(
4624 cls,
4625 *,
4626 lower: Cmp | None = None,
4627 lower_inclusive: Cmp | None = None,
4628 upper: Cmp | None = None,
4629 upper_inclusive: Cmp | None = None,
4630 ) -> PartialParser: ...
4632 def __new__(cls, *args, **kwargs) -> _t.Any: ...
4634 def __init__(
4635 self,
4636 inner: Parser[Cmp] | None = None,
4637 /,
4638 *,
4639 lower: Cmp | None = None,
4640 lower_inclusive: Cmp | None = None,
4641 upper: Cmp | None = None,
4642 upper_inclusive: Cmp | None = None,
4643 ):
4644 super().__init__(
4645 inner,
4646 lower=lower,
4647 lower_inclusive=lower_inclusive,
4648 upper=upper,
4649 upper_inclusive=upper_inclusive,
4650 mapper=lambda x: x,
4651 desc="Value",
4652 )
4654 def to_json_schema(
4655 self, ctx: yuio.json_schema.JsonSchemaContext, /
4656 ) -> yuio.json_schema.JsonSchemaType:
4657 bound = {}
4658 if isinstance(self._lower_bound, (int, float)):
4659 bound[
4660 "minimum" if self._lower_bound_is_inclusive else "exclusiveMinimum"
4661 ] = self._lower_bound
4662 if isinstance(self._upper_bound, (int, float)):
4663 bound[
4664 "maximum" if self._upper_bound_is_inclusive else "exclusiveMaximum"
4665 ] = self._upper_bound
4666 if bound:
4667 return yuio.json_schema.AllOf(
4668 [super().to_json_schema(ctx), yuio.json_schema.Opaque(bound)]
4669 )
4670 else:
4671 return super().to_json_schema(ctx)
4674@_t.overload
4675def Gt(inner: Parser[Cmp], bound: Cmp, /) -> Bound[Cmp]: ...
4676@_t.overload
4677def Gt(bound: _tx.SupportsLt[_t.Any], /) -> PartialParser: ...
4678def Gt(*args) -> _t.Any:
4679 """Gt(inner: Parser[Cmp], bound: Cmp, /)
4681 Alias for :class:`Bound`.
4683 :param inner:
4684 parser whose result will be validated.
4685 :param bound:
4686 lower bound for parsed values.
4688 """
4690 if len(args) == 1:
4691 return Bound(lower=args[0])
4692 elif len(args) == 2:
4693 return Bound(args[0], lower=args[1])
4694 else:
4695 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
4698@_t.overload
4699def Ge(inner: Parser[Cmp], bound: Cmp, /) -> Bound[Cmp]: ...
4700@_t.overload
4701def Ge(bound: _tx.SupportsLt[_t.Any], /) -> PartialParser: ...
4702def Ge(*args) -> _t.Any:
4703 """Ge(inner: Parser[Cmp], bound: Cmp, /)
4705 Alias for :class:`Bound`.
4707 :param inner:
4708 parser whose result will be validated.
4709 :param bound:
4710 lower inclusive bound for parsed values.
4712 """
4714 if len(args) == 1:
4715 return Bound(lower_inclusive=args[0])
4716 elif len(args) == 2:
4717 return Bound(args[0], lower_inclusive=args[1])
4718 else:
4719 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
4722@_t.overload
4723def Lt(inner: Parser[Cmp], bound: Cmp, /) -> Bound[Cmp]: ...
4724@_t.overload
4725def Lt(bound: _tx.SupportsLt[_t.Any], /) -> PartialParser: ...
4726def Lt(*args) -> _t.Any:
4727 """Lt(inner: Parser[Cmp], bound: Cmp, /)
4729 Alias for :class:`Bound`.
4731 :param inner:
4732 parser whose result will be validated.
4733 :param bound:
4734 upper bound for parsed values.
4736 """
4738 if len(args) == 1:
4739 return Bound(upper=args[0])
4740 elif len(args) == 2:
4741 return Bound(args[0], upper=args[1])
4742 else:
4743 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
4746@_t.overload
4747def Le(inner: Parser[Cmp], bound: Cmp, /) -> Bound[Cmp]: ...
4748@_t.overload
4749def Le(bound: _tx.SupportsLt[_t.Any], /) -> PartialParser: ...
4750def Le(*args) -> _t.Any:
4751 """Le(inner: Parser[Cmp], bound: Cmp, /)
4753 Alias for :class:`Bound`.
4755 :param inner:
4756 parser whose result will be validated.
4757 :param bound:
4758 upper inclusive bound for parsed values.
4760 """
4762 if len(args) == 1:
4763 return Bound(upper_inclusive=args[0])
4764 elif len(args) == 2:
4765 return Bound(args[0], upper_inclusive=args[1])
4766 else:
4767 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
4770class LenBound(_BoundImpl[Sz, int], _t.Generic[Sz]):
4771 """LenBound(inner: Parser[Sz], /, *, lower: int | None = None, lower_inclusive: int | None = None, upper: int | None = None, upper_inclusive: int | None = None)
4773 Check that length of a value is upper- or lower-bound by some constraints.
4775 The signature is the same as of the :class:`Bound` class.
4777 :param inner:
4778 parser whose result will be validated.
4779 :param lower:
4780 set lower bound for value's length, so we require that ``len(value) > lower``.
4781 Can't be given if `lower_inclusive` is also given.
4782 :param lower_inclusive:
4783 set lower bound for value's length, so we require that ``len(value) >= lower``.
4784 Can't be given if `lower` is also given.
4785 :param upper:
4786 set upper bound for value's length, so we require that ``len(value) < upper``.
4787 Can't be given if `upper_inclusive` is also given.
4788 :param upper_inclusive:
4789 set upper bound for value's length, so we require that ``len(value) <= upper``.
4790 Can't be given if `upper` is also given.
4791 :example:
4792 ::
4794 >>> # List of up to five ints:
4795 >>> LenBound(List(Int()), upper_inclusive=5)
4796 LenBound(List(Int), len <= 5)
4798 """
4800 if TYPE_CHECKING:
4802 @_t.overload
4803 def __new__(
4804 cls,
4805 inner: Parser[Sz],
4806 /,
4807 *,
4808 lower: int | None = None,
4809 lower_inclusive: int | None = None,
4810 upper: int | None = None,
4811 upper_inclusive: int | None = None,
4812 ) -> LenBound[Sz]: ...
4814 @_t.overload
4815 def __new__(
4816 cls,
4817 /,
4818 *,
4819 lower: int | None = None,
4820 lower_inclusive: int | None = None,
4821 upper: int | None = None,
4822 upper_inclusive: int | None = None,
4823 ) -> PartialParser: ...
4825 def __new__(cls, *args, **kwargs) -> _t.Any: ...
4827 def __init__(
4828 self,
4829 inner: Parser[Sz] | None = None,
4830 /,
4831 *,
4832 lower: int | None = None,
4833 lower_inclusive: int | None = None,
4834 upper: int | None = None,
4835 upper_inclusive: int | None = None,
4836 ):
4837 super().__init__(
4838 inner,
4839 lower=lower,
4840 lower_inclusive=lower_inclusive,
4841 upper=upper,
4842 upper_inclusive=upper_inclusive,
4843 mapper=len,
4844 desc="Length of value",
4845 )
4847 def get_nargs(self) -> _t.Literal["+", "*"] | int:
4848 if not self._inner.supports_parse_many():
4849 # somebody bound len of a string?
4850 return self._inner.get_nargs()
4852 lower = self._lower_bound
4853 if lower is not None and not self._lower_bound_is_inclusive:
4854 lower += 1
4855 upper = self._upper_bound
4856 if upper is not None and not self._upper_bound_is_inclusive:
4857 upper -= 1
4859 if lower == upper and lower is not None:
4860 return lower
4861 elif lower is not None and lower > 0:
4862 return "+"
4863 else:
4864 return "*"
4866 def to_json_schema(
4867 self, ctx: yuio.json_schema.JsonSchemaContext, /
4868 ) -> yuio.json_schema.JsonSchemaType:
4869 bound = {}
4870 min_bound = self._lower_bound
4871 if not self._lower_bound_is_inclusive and min_bound is not None:
4872 min_bound -= 1
4873 if min_bound is not None:
4874 bound["minLength"] = bound["minItems"] = bound["minProperties"] = min_bound
4875 max_bound = self._upper_bound
4876 if not self._upper_bound_is_inclusive and max_bound is not None:
4877 max_bound += 1
4878 if max_bound is not None:
4879 bound["maxLength"] = bound["maxItems"] = bound["maxProperties"] = max_bound
4880 if bound:
4881 return yuio.json_schema.AllOf(
4882 [super().to_json_schema(ctx), yuio.json_schema.Opaque(bound)]
4883 )
4884 else:
4885 return super().to_json_schema(ctx)
4888@_t.overload
4889def LenGt(inner: Parser[Sz], bound: int, /) -> LenBound[Sz]: ...
4890@_t.overload
4891def LenGt(bound: int, /) -> PartialParser: ...
4892def LenGt(*args) -> _t.Any:
4893 """LenGt(inner: Parser[Sz], bound: int, /)
4895 Alias for :class:`LenBound`.
4897 :param inner:
4898 parser whose result will be validated.
4899 :param bound:
4900 lower bound for parsed values's length.
4902 """
4904 if len(args) == 1:
4905 return LenBound(lower=args[0])
4906 elif len(args) == 2:
4907 return LenBound(args[0], lower=args[1])
4908 else:
4909 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
4912@_t.overload
4913def LenGe(inner: Parser[Sz], bound: int, /) -> LenBound[Sz]: ...
4914@_t.overload
4915def LenGe(bound: int, /) -> PartialParser: ...
4916def LenGe(*args) -> _t.Any:
4917 """LenGe(inner: Parser[Sz], bound: int, /)
4919 Alias for :class:`LenBound`.
4921 :param inner:
4922 parser whose result will be validated.
4923 :param bound:
4924 lower inclusive bound for parsed values's length.
4926 """
4928 if len(args) == 1:
4929 return LenBound(lower_inclusive=args[0])
4930 elif len(args) == 2:
4931 return LenBound(args[0], lower_inclusive=args[1])
4932 else:
4933 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
4936@_t.overload
4937def LenLt(inner: Parser[Sz], bound: int, /) -> LenBound[Sz]: ...
4938@_t.overload
4939def LenLt(bound: int, /) -> PartialParser: ...
4940def LenLt(*args) -> _t.Any:
4941 """LenLt(inner: Parser[Sz], bound: int, /)
4943 Alias for :class:`LenBound`.
4945 :param inner:
4946 parser whose result will be validated.
4947 :param bound:
4948 upper bound for parsed values's length.
4950 """
4952 if len(args) == 1:
4953 return LenBound(upper=args[0])
4954 elif len(args) == 2:
4955 return LenBound(args[0], upper=args[1])
4956 else:
4957 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
4960@_t.overload
4961def LenLe(inner: Parser[Sz], bound: int, /) -> LenBound[Sz]: ...
4962@_t.overload
4963def LenLe(bound: int, /) -> PartialParser: ...
4964def LenLe(*args) -> _t.Any:
4965 """LenLe(inner: Parser[Sz], bound: int, /)
4967 Alias for :class:`LenBound`.
4969 :param inner:
4970 parser whose result will be validated.
4971 :param bound:
4972 upper inclusive bound for parsed values's length.
4974 """
4976 if len(args) == 1:
4977 return LenBound(upper_inclusive=args[0])
4978 elif len(args) == 2:
4979 return LenBound(args[0], upper_inclusive=args[1])
4980 else:
4981 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
4984class OneOf(ValidatingParser[T], _t.Generic[T]):
4985 """OneOf(inner: Parser[T], values: typing.Collection[T], /)
4987 Check that the parsed value is one of the given set of values.
4989 :param inner:
4990 parser whose result will be validated.
4991 :param values:
4992 collection of allowed values.
4993 :example:
4994 ::
4996 >>> # Accepts only strings 'A', 'B', or 'C':
4997 >>> OneOf(Str(), ['A', 'B', 'C'])
4998 OneOf(Str)
5000 """
5002 if TYPE_CHECKING:
5004 @_t.overload
5005 def __new__(cls, inner: Parser[T], values: _t.Collection[T], /) -> OneOf[T]: ...
5007 @_t.overload
5008 def __new__(cls, values: _t.Collection[T], /) -> PartialParser: ...
5010 def __new__(cls, *args) -> _t.Any: ...
5012 def __init__(self, *args):
5013 inner: Parser[T] | None
5014 values: _t.Collection[T]
5015 if len(args) == 1:
5016 inner, values = None, args[0]
5017 elif len(args) == 2:
5018 inner, values = args
5019 else:
5020 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
5022 super().__init__(inner)
5024 self._allowed_values = values
5026 def _validate(self, value: T, /):
5027 if value not in self._allowed_values:
5028 raise ParsingError(
5029 "Can't parse `%r`, should be %s",
5030 value,
5031 yuio.string.JoinRepr.or_(self._allowed_values),
5032 )
5034 def describe(self) -> str | None:
5035 desc = "|".join(self.describe_value(e) for e in self._allowed_values)
5036 if len(desc) < 80:
5037 if len(self._allowed_values) > 1:
5038 desc = f"{{{desc}}}"
5039 return desc
5040 else:
5041 return super().describe()
5043 def describe_or_def(self) -> str:
5044 desc = "|".join(self.describe_value(e) for e in self._allowed_values)
5045 if len(desc) < 80:
5046 if len(self._allowed_values) > 1:
5047 desc = f"{{{desc}}}"
5048 return desc
5049 else:
5050 return super().describe_or_def()
5052 def options(self) -> _t.Collection[yuio.widget.Option[T]] | None:
5053 return [
5054 yuio.widget.Option(e, self.describe_value(e)) for e in self._allowed_values
5055 ]
5057 def completer(self) -> yuio.complete.Completer | None:
5058 return yuio.complete.Choice(
5059 [yuio.complete.Option(self.describe_value(e)) for e in self._allowed_values]
5060 )
5062 def widget(
5063 self,
5064 default: object | yuio.Missing,
5065 input_description: str | None,
5066 default_description: str | None,
5067 /,
5068 ) -> yuio.widget.Widget[T | yuio.Missing]:
5069 allowed_values = list(self._allowed_values)
5071 options = _t.cast(list[yuio.widget.Option[T | yuio.Missing]], self.options())
5073 if default is yuio.MISSING:
5074 default_index = 0
5075 elif default in allowed_values:
5076 default_index = list(allowed_values).index(default) # type: ignore
5077 else:
5078 options.insert(
5079 0, yuio.widget.Option(yuio.MISSING, default_description or str(default))
5080 )
5081 default_index = 0
5083 return yuio.widget.Choice(options, default_index=default_index)
5086class WithMeta(MappingParser[T, T], _t.Generic[T]):
5087 """WithMeta(inner: Parser[T], /, *, desc: str, completer: yuio.complete.Completer | None | ~yuio.MISSING = MISSING)
5089 Overrides inline help messages and other meta information of a wrapped parser.
5091 Inline help messages will show up as hints in autocompletion and widgets.
5093 :param inner:
5094 inner parser.
5095 :param desc:
5096 description override. This short string will be used in CLI, widgets, and
5097 completers to describe expected value.
5098 :param completer:
5099 completer override. Pass :data:`None` to disable completion.
5101 """
5103 if TYPE_CHECKING:
5105 @_t.overload
5106 def __new__(
5107 cls,
5108 inner: Parser[T],
5109 /,
5110 *,
5111 desc: str | None = None,
5112 completer: yuio.complete.Completer | yuio.Missing | None = yuio.MISSING,
5113 ) -> MappingParser[T, T]: ...
5115 @_t.overload
5116 def __new__(
5117 cls,
5118 /,
5119 *,
5120 desc: str | None = None,
5121 completer: yuio.complete.Completer | yuio.Missing | None = yuio.MISSING,
5122 ) -> PartialParser: ...
5124 def __new__(cls, *args, **kwargs) -> _t.Any: ...
5126 def __init__(
5127 self,
5128 *args,
5129 desc: str | None = None,
5130 completer: yuio.complete.Completer | yuio.Missing | None = yuio.MISSING,
5131 ):
5132 inner: Parser[T] | None
5133 if not args:
5134 inner = None
5135 elif len(args) == 1:
5136 inner = args[0]
5137 else:
5138 raise TypeError(f"expected at most 1 positional argument, got {len(args)}")
5140 self._desc = desc
5141 self._completer = completer
5142 super().__init__(inner)
5144 def check_type(self, value: object, /) -> _t.TypeGuard[T]:
5145 return True
5147 def describe(self) -> str | None:
5148 return self._desc or self._inner.describe()
5150 def describe_or_def(self) -> str:
5151 return self._desc or self._inner.describe_or_def()
5153 def describe_many(self) -> str | tuple[str, ...]:
5154 return self._desc or self._inner.describe_many()
5156 def describe_value(self, value: object, /) -> str:
5157 return self._inner.describe_value(value)
5159 def parse_with_ctx(self, ctx: StrParsingContext, /) -> T:
5160 return self._inner.parse_with_ctx(ctx)
5162 def parse_many_with_ctx(self, ctxs: _t.Sequence[StrParsingContext], /) -> T:
5163 return self._inner.parse_many_with_ctx(ctxs)
5165 def parse_config_with_ctx(self, ctx: ConfigParsingContext, /) -> T:
5166 return self._inner.parse_config_with_ctx(ctx)
5168 def options(self) -> _t.Collection[yuio.widget.Option[T]] | None:
5169 return self._inner.options()
5171 def completer(self) -> yuio.complete.Completer | None:
5172 if self._completer is not yuio.MISSING:
5173 return self._completer # type: ignore
5174 else:
5175 return self._inner.completer()
5177 def widget(
5178 self,
5179 default: object | yuio.Missing,
5180 input_description: str | None,
5181 default_description: str | None,
5182 /,
5183 ) -> yuio.widget.Widget[T | yuio.Missing]:
5184 return self._inner.widget(default, input_description, default_description)
5186 def to_json_value(self, value: object) -> yuio.json_schema.JsonValue:
5187 return self._inner.to_json_value(value)
5190class _WidgetResultMapper(yuio.widget.Map[T | yuio.Missing, str]):
5191 def __init__(
5192 self,
5193 parser: Parser[T],
5194 input_description: str | None,
5195 default: object | yuio.Missing,
5196 widget: yuio.widget.Widget[str],
5197 ):
5198 self._parser = parser
5199 self._input_description = input_description
5200 self._default = default
5201 super().__init__(widget, self.mapper)
5203 def mapper(self, s: str) -> T | yuio.Missing:
5204 if not s and self._default is not yuio.MISSING:
5205 return yuio.MISSING
5206 elif not s:
5207 raise ParsingError("Input is required")
5208 try:
5209 return self._parser.parse_with_ctx(StrParsingContext(s))
5210 except ParsingError as e:
5211 if (
5212 isinstance(
5213 self._inner, (yuio.widget.Input, yuio.widget.InputWithCompletion)
5214 )
5215 and e.pos
5216 and e.raw == self._inner.text
5217 ):
5218 if e.pos == (0, len(self._inner.text)):
5219 # Don't highlight the entire text, it's not useful and creates
5220 # visual noise.
5221 self._inner.err_region = None
5222 else:
5223 self._inner.err_region = e.pos
5224 e.raw = None
5225 e.pos = None
5226 raise
5228 @property
5229 def help_data(self):
5230 return super().help_data.with_action(
5231 group="Input Format",
5232 msg=self._input_description,
5233 prepend=True,
5234 prepend_group=True,
5235 )
5238def _secret_widget(
5239 parser: Parser[T],
5240 default: object | yuio.Missing,
5241 input_description: str | None,
5242 default_description: str | None,
5243 /,
5244) -> yuio.widget.Widget[T | yuio.Missing]:
5245 return _WidgetResultMapper(
5246 parser,
5247 input_description,
5248 default,
5249 (
5250 yuio.widget.SecretInput(
5251 placeholder=default_description or "",
5252 )
5253 ),
5254 )
5257class StrParsingContext:
5258 """StrParsingContext(content: str, /, *, n_arg: int | None = None)
5260 String parsing context tracks current position in the string.
5262 :param content:
5263 content to parse.
5264 :param n_arg:
5265 content index when using :meth:`~Parser.parse_many`.
5267 """
5269 def __init__(
5270 self,
5271 content: str,
5272 /,
5273 *,
5274 n_arg: int | None = None,
5275 _value: str | None = None,
5276 _start: int | None = None,
5277 _end: int | None = None,
5278 ):
5279 self.start: int = _start if _start is not None else 0
5280 """
5281 Start position of the value.
5283 """
5285 self.end: int = _end if _end is not None else self.start + len(content)
5286 """
5287 End position of the value.
5289 """
5291 self.content: str = content
5292 """
5293 Full content of the value that was passed to :meth:`Parser.parse`.
5295 """
5297 self.value: str = _value if _value is not None else content
5298 """
5299 Part of the :attr:`~StrParsingContext.content` that's currently being parsed.
5301 """
5303 self.n_arg: int | None = n_arg
5304 """
5305 For :meth:`~Parser.parse_many`, this attribute contains index of the value
5306 that is being parsed. For :meth:`~Parser.parse`, this is :data:`None`.
5308 """
5310 def split(
5311 self, delimiter: str | None = None, /, maxsplit: int = -1
5312 ) -> _t.Generator[StrParsingContext]:
5313 """
5314 Split current value by the given delimiter while keeping track of the current position.
5316 """
5318 if delimiter is None:
5319 yield from self._split_space(maxsplit=maxsplit)
5320 return
5322 dlen = len(delimiter)
5323 start = self.start
5324 for part in self.value.split(delimiter, maxsplit=maxsplit):
5325 yield StrParsingContext(
5326 self.content,
5327 _value=part,
5328 _start=start,
5329 _end=start + len(part),
5330 n_arg=self.n_arg,
5331 )
5332 start += len(part) + dlen
5334 def _split_space(self, maxsplit: int = -1) -> _t.Generator[StrParsingContext]:
5335 i = 0
5336 n_splits = 0
5337 is_space = True
5338 for part in re.split(r"(\s+)", self.value):
5339 is_space = not is_space
5340 if is_space:
5341 i += len(part)
5342 continue
5344 if not part:
5345 continue
5347 if maxsplit >= 0 and n_splits >= maxsplit:
5348 part = self.value[i:]
5349 yield StrParsingContext(
5350 self.content,
5351 _value=part,
5352 _start=i,
5353 _end=i + len(part),
5354 n_arg=self.n_arg,
5355 )
5356 return
5357 else:
5358 yield StrParsingContext(
5359 self.content,
5360 _value=part,
5361 _start=i,
5362 _end=i + len(part),
5363 n_arg=self.n_arg,
5364 )
5365 i += len(part)
5366 n_splits += 1
5368 def strip(self, chars: str | None = None, /) -> StrParsingContext:
5369 """
5370 Strip current value while keeping track of the current position.
5372 """
5374 l_stripped = self.value.lstrip(chars)
5375 start = self.start + (len(self.value) - len(l_stripped))
5376 stripped = l_stripped.rstrip(chars)
5377 return StrParsingContext(
5378 self.content,
5379 _value=stripped,
5380 _start=start,
5381 _end=start + len(stripped),
5382 n_arg=self.n_arg,
5383 )
5385 def strip_if_non_space(self) -> StrParsingContext:
5386 """
5387 Strip current value unless it entirely consists of spaces.
5389 """
5391 if not self.value or self.value.isspace():
5392 return self
5393 else:
5394 return self.strip()
5396 # If you need more methods, feel free to open an issue or send a PR!
5397 # For now, `split` and `split` is enough.
5400class ConfigParsingContext:
5401 """
5402 Config parsing context tracks path in the config, similar to JSON path.
5404 """
5406 def __init__(
5407 self,
5408 value: object,
5409 /,
5410 *,
5411 parent: ConfigParsingContext | None = None,
5412 key: _t.Any = None,
5413 desc: str | None = None,
5414 ):
5415 self.value: object = value
5416 """
5417 Config value to be validated and parsed.
5419 """
5421 self.parent: ConfigParsingContext | None = parent
5422 """
5423 Parent context.
5425 """
5427 self.key: _t.Any = key
5428 """
5429 Key that was accessed when we've descended from parent context to this one.
5431 Root context has key :data:`None`.
5433 """
5435 self.desc: str | None = desc
5436 """
5437 Additional description of the key.
5439 """
5441 def descend(
5442 self,
5443 value: _t.Any,
5444 key: _t.Any,
5445 desc: str | None = None,
5446 ) -> ConfigParsingContext:
5447 """
5448 Create a new context that adds a new key to the path.
5450 :param value:
5451 inner value that was derived from the current value by accessing it with
5452 the given `key`.
5453 :param key:
5454 key that we use to descend into the current value.
5456 For example, let's say we're parsing a list. We iterate over it and pass
5457 its elements to a sub-parser. Before calling a sub-parser, we need to
5458 make a new context for it. In this situation, we'll pass current element
5459 as `value`, and is index as `key`.
5460 :param desc:
5461 human-readable description for the new context. Will be colorized
5462 and ``%``-formatted with a single named argument `key`.
5464 This is useful when parsing structures that need something more complex than
5465 JSON path. For example, when parsing a key in a dictionary, it is helpful
5466 to set description to something like ``"key of element #%(key)r"``.
5467 This way, parsing errors will have a more clear message:
5469 .. code-block:: text
5471 Parsing error:
5472 In key of element #2:
5473 Expected str, got int: 10
5475 """
5477 return ConfigParsingContext(value, parent=self, key=key, desc=desc)
5479 def make_path(self) -> list[tuple[_t.Any, str | None]]:
5480 """
5481 Capture current path.
5483 :returns:
5484 a list of tuples. First element of each tuple is a key, second is
5485 an additional description.
5487 """
5489 path = []
5491 root = self
5492 while True:
5493 if root.parent is None:
5494 break
5495 else:
5496 path.append((root.key, root.desc))
5497 root = root.parent
5499 path.reverse()
5501 return path
5504class _PathRenderer:
5505 def __init__(self, path: list[tuple[_t.Any, str | None]]):
5506 self._path = path
5508 def __colorized_str__(
5509 self, ctx: yuio.string.ReprContext
5510 ) -> yuio.string.ColorizedString:
5511 code_color = ctx.theme.get_color("msg/text:code/repr hl:repr")
5512 punct_color = ctx.theme.get_color("msg/text:code/repr hl/punct:repr")
5514 msg = yuio.string.ColorizedString(code_color)
5515 msg.start_no_wrap()
5517 for i, (key, desc) in enumerate(self._path):
5518 if desc:
5519 desc = (
5520 (yuio.string)
5521 .colorize(desc, ctx=ctx)
5522 .percent_format({"key": key}, ctx=ctx)
5523 )
5525 if i == len(self._path) - 1:
5526 # Last key.
5527 if msg:
5528 msg.append_color(punct_color)
5529 msg.append_str(", ")
5530 msg.append_colorized_str(desc)
5531 else:
5532 # Element in the middle.
5533 if not msg:
5534 msg.append_str("$")
5535 msg.append_color(punct_color)
5536 msg.append_str(".<")
5537 msg.append_colorized_str(desc)
5538 msg.append_str(">")
5539 elif isinstance(key, str) and re.match(r"^[a-zA-Z_][\w-]*$", key):
5540 # Key is identifier-like, use `x.key` notation.
5541 if not msg:
5542 msg.append_str("$")
5543 msg.append_color(punct_color)
5544 msg.append_str(".")
5545 msg.append_color(code_color)
5546 msg.append_str(key)
5547 else:
5548 # Key is not identifier-like, use `x[key]` notation.
5549 if not msg:
5550 msg.append_str("$")
5551 msg.append_color(punct_color)
5552 msg.append_str("[")
5553 msg.append_color(code_color)
5554 msg.append_str(repr(key))
5555 msg.append_color(punct_color)
5556 msg.append_str("]")
5558 msg.end_no_wrap()
5559 return msg
5562class _CodeRenderer:
5563 def __init__(self, code: str, pos: tuple[int, int], as_cli: bool = False):
5564 self._code = code
5565 self._pos = pos
5566 self._as_cli = as_cli
5568 def __colorized_str__(
5569 self, ctx: yuio.string.ReprContext
5570 ) -> yuio.string.ColorizedString:
5571 width = ctx.width - 2 # Account for indentation.
5573 if width < 10: # 6 symbols for ellipsis and at least 2 wide chars.
5574 return yuio.string.ColorizedString()
5576 start, end = self._pos
5577 if end == start:
5578 end += 1
5580 left = self._code[:start]
5581 center = self._code[start:end]
5582 right = self._code[end:]
5584 l_width = yuio.string.line_width(left)
5585 c_width = yuio.string.line_width(center)
5586 r_width = yuio.string.line_width(right)
5588 available_width = width - (3 if left else 0) - 3
5589 if c_width > available_width:
5590 # Center can't fit: remove left and right side,
5591 # and trim as much center as needed.
5593 left = "..." if l_width > 3 else left
5594 l_width = len(left)
5596 right = ""
5597 r_width = 0
5599 new_c = ""
5600 c_width = 0
5602 for c in center:
5603 cw = yuio.string.line_width(c)
5604 if c_width + cw <= available_width:
5605 new_c += c
5606 c_width += cw
5607 else:
5608 new_c += "..."
5609 c_width += 3
5610 break
5611 center = new_c
5613 if r_width > 3 and l_width + c_width + r_width > width:
5614 # Trim right side.
5615 new_r = ""
5616 r_width = 3
5617 for c in right:
5618 cw = yuio.string.line_width(c)
5619 if l_width + c_width + r_width + cw <= width:
5620 new_r += c
5621 r_width += cw
5622 else:
5623 new_r += "..."
5624 break
5625 right = new_r
5627 if l_width > 3 and l_width + c_width + r_width > width:
5628 # Trim left side.
5629 new_l = ""
5630 l_width = 3
5631 for c in left[::-1]:
5632 cw = yuio.string.line_width(c)
5633 if l_width + c_width + r_width + cw <= width:
5634 new_l += c
5635 l_width += cw
5636 else:
5637 new_l += "..."
5638 break
5639 left = new_l[::-1]
5641 if self._as_cli:
5642 punct_color = ctx.theme.get_color(
5643 "msg/text:code/sh-usage hl/punct:sh-usage"
5644 )
5645 else:
5646 punct_color = ctx.theme.get_color("msg/text:code/text hl/punct:text")
5648 res = yuio.string.ColorizedString()
5649 res.start_no_wrap()
5651 if self._as_cli:
5652 res.append_color(punct_color)
5653 res.append_str("$ ")
5654 res.append_colorized_str(
5655 ctx.str(
5656 yuio.string.Hl(
5657 left.replace("%", "%%") + "%s" + right.replace("%", "%%"), # pyright: ignore[reportArgumentType]
5658 yuio.string.WithBaseColor(
5659 center, base_color="hl/error:sh-usage"
5660 ),
5661 syntax="sh-usage",
5662 )
5663 )
5664 )
5665 else:
5666 text_color = ctx.theme.get_color("msg/text:code/text")
5667 res.append_color(punct_color)
5668 res.append_str("> ")
5669 res.append_color(text_color)
5670 res.append_str(left)
5671 res.append_color(text_color | ctx.theme.get_color("hl/error:text"))
5672 res.append_str(center)
5673 res.append_color(text_color)
5674 res.append_str(right)
5675 res.append_color(yuio.color.Color.NONE)
5676 res.append_str("\n")
5677 if self._as_cli:
5678 text_color = ctx.theme.get_color("msg/text:code/sh-usage")
5679 res.append_color(text_color | ctx.theme.get_color("hl/error:sh-usage"))
5680 else:
5681 text_color = ctx.theme.get_color("msg/text:code/text")
5682 res.append_color(text_color | ctx.theme.get_color("hl/error:text"))
5683 res.append_str(" ")
5684 res.append_str(" " * yuio.string.line_width(left))
5685 res.append_str("~" * yuio.string.line_width(center))
5687 res.end_no_wrap()
5689 return res
5692def _repr_and_adjust_pos(s: str, pos: tuple[int, int]):
5693 start, end = pos
5695 left = json.dumps(s[:start])[:-1]
5696 center = json.dumps(s[start:end])[1:-1]
5697 right = json.dumps(s[end:])[1:]
5699 return left + center + right, (len(left), len(left) + len(center))
5702_FromTypeHintCallback: _t.TypeAlias = _t.Callable[
5703 [type, type | None, tuple[object, ...]], Parser[object] | None
5704]
5707_FROM_TYPE_HINT_CALLBACKS: list[tuple[_FromTypeHintCallback, bool]] = []
5708_FROM_TYPE_HINT_DELIM_SUGGESTIONS: list[str | None] = [
5709 None,
5710 ",",
5711 "@",
5712 "/",
5713 "=",
5714]
5717class _FromTypeHintDepth(threading.local):
5718 def __init__(self):
5719 self.depth: int = 0
5720 self.uses_delim = False
5723_FROM_TYPE_HINT_DEPTH: _FromTypeHintDepth = _FromTypeHintDepth()
5726@_t.overload
5727def from_type_hint(ty: type[T], /) -> Parser[T]: ...
5728@_t.overload
5729def from_type_hint(ty: object, /) -> Parser[object]: ...
5730def from_type_hint(ty: _t.Any, /) -> Parser[object]:
5731 """from_type_hint(ty: type[T], /) -> Parser[T]
5733 Create parser from a type hint.
5735 :param ty:
5736 a type hint.
5738 This type hint should not contain strings or forward references. Make sure
5739 they're resolved before passing it to this function.
5740 :returns:
5741 a parser instance created from type hint.
5742 :raises:
5743 :class:`TypeError` if type hint contains forward references or types
5744 that don't have associated parsers.
5745 :example:
5746 ::
5748 >>> from_type_hint(list[int] | None)
5749 Optional(List(Int))
5751 """
5753 result = _from_type_hint(ty)
5754 setattr(result, "_Parser__typehint", ty)
5755 return result
5758def _from_type_hint(ty: _t.Any, /) -> Parser[object]:
5759 if isinstance(ty, (str, _t.ForwardRef)):
5760 raise TypeError(f"forward references are not supported here: {ty}")
5762 origin = _t.get_origin(ty)
5763 args = _t.get_args(ty)
5765 if origin is _t.Annotated:
5766 p = from_type_hint(args[0])
5767 for arg in args[1:]:
5768 if isinstance(arg, PartialParser):
5769 p = arg.wrap(p)
5770 return p
5772 for cb, uses_delim in _FROM_TYPE_HINT_CALLBACKS:
5773 prev_uses_delim = _FROM_TYPE_HINT_DEPTH.uses_delim
5774 _FROM_TYPE_HINT_DEPTH.uses_delim = uses_delim
5775 _FROM_TYPE_HINT_DEPTH.depth += uses_delim
5776 try:
5777 p = cb(ty, origin, args)
5778 if p is not None:
5779 return p
5780 finally:
5781 _FROM_TYPE_HINT_DEPTH.uses_delim = prev_uses_delim
5782 _FROM_TYPE_HINT_DEPTH.depth -= uses_delim
5784 if _tx.is_union(origin):
5785 if is_optional := (type(None) in args):
5786 args = list(args)
5787 args.remove(type(None))
5788 if len(args) == 1:
5789 p = from_type_hint(args[0])
5790 else:
5791 p = Union(*[from_type_hint(arg) for arg in args])
5792 if is_optional:
5793 p = Optional(p)
5794 return p
5795 else:
5796 raise TypeError(f"unsupported type {_tx.type_repr(ty)}")
5799@_t.overload
5800def register_type_hint_conversion(
5801 cb: _FromTypeHintCallback,
5802 /,
5803 *,
5804 uses_delim: bool = False,
5805) -> _FromTypeHintCallback: ...
5806@_t.overload
5807def register_type_hint_conversion(
5808 *,
5809 uses_delim: bool = False,
5810) -> _t.Callable[[_FromTypeHintCallback], _FromTypeHintCallback]: ...
5811def register_type_hint_conversion(
5812 cb: _FromTypeHintCallback | None = None,
5813 /,
5814 *,
5815 uses_delim: bool = False,
5816) -> (
5817 _FromTypeHintCallback | _t.Callable[[_FromTypeHintCallback], _FromTypeHintCallback]
5818):
5819 """
5820 Register a new converter from a type hint to a parser.
5822 This function takes a callback that accepts three positional arguments:
5824 - a type hint,
5825 - a type hint's origin (as defined by :func:`typing.get_origin`),
5826 - a type hint's args (as defined by :func:`typing.get_args`).
5828 The callback should return a parser if it can, or :data:`None` otherwise.
5830 All registered callbacks are tried in the same order
5831 as they were registered.
5833 If `uses_delim` is :data:`True`, callback can use
5834 :func:`suggest_delim_for_type_hint_conversion`.
5836 This function can be used as a decorator.
5838 :param cb:
5839 a function that should inspect a type hint and possibly return a parser.
5840 :param uses_delim:
5841 indicates that callback will use
5842 :func:`suggest_delim_for_type_hint_conversion`.
5843 :example:
5844 .. invisible-code-block: python
5846 class MyType: ...
5847 class MyTypeParser(ValueParser[MyType]):
5848 def __init__(self): super().__init__(MyType)
5849 def parse_with_ctx(self, ctx: StrParsingContext, /): ...
5850 def parse_config_with_ctx(self, value, /): ...
5851 def to_json_schema(self, ctx, /): ...
5852 def to_json_value(self, value, /): ...
5854 .. code-block:: python
5856 @register_type_hint_conversion
5857 def my_type_conversion(ty, origin, args):
5858 if ty is MyType:
5859 return MyTypeParser()
5860 else:
5861 return None
5863 ::
5865 >>> from_type_hint(MyType)
5866 MyTypeParser
5868 .. invisible-code-block: python
5870 del _FROM_TYPE_HINT_CALLBACKS[-1]
5872 """
5874 def registrar(cb: _FromTypeHintCallback):
5875 _FROM_TYPE_HINT_CALLBACKS.append((cb, uses_delim))
5876 return cb
5878 return registrar(cb) if cb is not None else registrar
5881def suggest_delim_for_type_hint_conversion() -> str | None:
5882 """
5883 Suggests a delimiter for use in type hint converters.
5885 When creating a parser for a collection of items based on a type hint,
5886 it is important to use different delimiters for nested collections.
5887 This function can suggest such a delimiter based on the current type hint's depth.
5889 .. invisible-code-block: python
5891 class MyCollection(list, _t.Generic[T]): ...
5892 class MyCollectionParser(CollectionParser[MyCollection[T], T], _t.Generic[T]):
5893 def __init__(self, inner: Parser[T], /, *, delimiter: _t.Optional[str] = None):
5894 super().__init__(inner, ty=MyCollection, ctor=MyCollection, delimiter=delimiter)
5895 def to_json_schema(self, ctx, /): ...
5896 def to_json_value(self, value, /): ...
5898 :raises:
5899 :class:`RuntimeError` if called from a type converter that
5900 didn't set `uses_delim` to :data:`True`.
5901 :example:
5902 .. code-block:: python
5904 @register_type_hint_conversion(uses_delim=True)
5905 def my_collection_conversion(ty, origin, args):
5906 if origin is MyCollection:
5907 return MyCollectionParser(
5908 from_type_hint(args[0]),
5909 delimiter=suggest_delim_for_type_hint_conversion(),
5910 )
5911 else:
5912 return None
5914 ::
5916 >>> parser = from_type_hint(MyCollection[MyCollection[str]])
5917 >>> parser
5918 MyCollectionParser(MyCollectionParser(Str))
5919 >>> # First delimiter is `None`, meaning split by whitespace:
5920 >>> parser._delimiter is None
5921 True
5922 >>> # Second delimiter is `","`:
5923 >>> parser._inner._delimiter == ","
5924 True
5926 ..
5927 >>> del _FROM_TYPE_HINT_CALLBACKS[-1]
5929 """
5931 if not _FROM_TYPE_HINT_DEPTH.uses_delim:
5932 raise RuntimeError(
5933 "looking up delimiters is not available in this callback; did you forget"
5934 " to pass `uses_delim=True` when registering this callback?"
5935 )
5937 depth = _FROM_TYPE_HINT_DEPTH.depth - 1
5938 if depth < len(_FROM_TYPE_HINT_DELIM_SUGGESTIONS):
5939 return _FROM_TYPE_HINT_DELIM_SUGGESTIONS[depth]
5940 else:
5941 return None
5944register_type_hint_conversion(lambda ty, origin, args: Str() if ty is str else None)
5945register_type_hint_conversion(lambda ty, origin, args: Int() if ty is int else None)
5946register_type_hint_conversion(lambda ty, origin, args: Float() if ty is float else None)
5947register_type_hint_conversion(lambda ty, origin, args: Bool() if ty is bool else None)
5948register_type_hint_conversion(
5949 lambda ty, origin, args: (
5950 Enum(ty) if isinstance(ty, type) and issubclass(ty, enum.Enum) else None
5951 )
5952)
5953register_type_hint_conversion(
5954 lambda ty, origin, args: Decimal() if ty is decimal.Decimal else None
5955)
5956register_type_hint_conversion(
5957 lambda ty, origin, args: Fraction() if ty is fractions.Fraction else None
5958)
5959register_type_hint_conversion(
5960 lambda ty, origin, args: (
5961 List(
5962 from_type_hint(args[0]), delimiter=suggest_delim_for_type_hint_conversion()
5963 )
5964 if origin is list
5965 else None
5966 ),
5967 uses_delim=True,
5968)
5969register_type_hint_conversion(
5970 lambda ty, origin, args: (
5971 Set(from_type_hint(args[0]), delimiter=suggest_delim_for_type_hint_conversion())
5972 if origin is set
5973 else None
5974 ),
5975 uses_delim=True,
5976)
5977register_type_hint_conversion(
5978 lambda ty, origin, args: (
5979 FrozenSet(
5980 from_type_hint(args[0]), delimiter=suggest_delim_for_type_hint_conversion()
5981 )
5982 if origin is frozenset
5983 else None
5984 ),
5985 uses_delim=True,
5986)
5987register_type_hint_conversion(
5988 lambda ty, origin, args: (
5989 Dict(
5990 from_type_hint(args[0]),
5991 from_type_hint(args[1]),
5992 delimiter=suggest_delim_for_type_hint_conversion(),
5993 )
5994 if origin is dict
5995 else None
5996 ),
5997 uses_delim=True,
5998)
5999register_type_hint_conversion(
6000 lambda ty, origin, args: (
6001 Tuple(
6002 *[from_type_hint(arg) for arg in args],
6003 delimiter=suggest_delim_for_type_hint_conversion(),
6004 )
6005 if origin is tuple and ... not in args
6006 else None
6007 ),
6008 uses_delim=True,
6009)
6010register_type_hint_conversion(
6011 lambda ty, origin, args: Path() if ty is pathlib.Path else None
6012)
6013register_type_hint_conversion(
6014 lambda ty, origin, args: Json() if ty is yuio.json_schema.JsonValue else None
6015)
6016register_type_hint_conversion(
6017 lambda ty, origin, args: DateTime() if ty is datetime.datetime else None
6018)
6019register_type_hint_conversion(
6020 lambda ty, origin, args: Date() if ty is datetime.date else None
6021)
6022register_type_hint_conversion(
6023 lambda ty, origin, args: Time() if ty is datetime.time else None
6024)
6025register_type_hint_conversion(
6026 lambda ty, origin, args: TimeDelta() if ty is datetime.timedelta else None
6027)
6030@register_type_hint_conversion
6031def __secret(ty, origin, args):
6032 if ty is SecretValue:
6033 raise TypeError("yuio.secret.SecretValue requires type arguments")
6034 if origin is SecretValue:
6035 if len(args) == 1:
6036 return Secret(from_type_hint(args[0]))
6037 else: # pragma: no cover
6038 raise TypeError(
6039 f"yuio.secret.SecretValue requires 1 type argument, got {len(args)}"
6040 )
6041 return None
6044def _is_optional_parser(parser: Parser[_t.Any] | None, /) -> bool:
6045 while parser is not None:
6046 if isinstance(parser, Optional):
6047 return True
6048 parser = parser.__wrapped_parser__
6049 return False
6052def _is_bool_parser(parser: Parser[_t.Any] | None, /) -> bool:
6053 while parser is not None:
6054 if isinstance(parser, Bool):
6055 return True
6056 parser = parser.__wrapped_parser__
6057 return False