Coverage for yuio / parse.py: 88%
1278 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-04 10:05 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-04 10:05 +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
53Value parsers
54-------------
56.. autoclass:: Str
58.. autoclass:: Int
60.. autoclass:: Float
62.. autoclass:: Bool
64.. autoclass:: Enum
66.. autoclass:: Decimal
68.. autoclass:: Fraction
70.. autoclass:: Json
72.. autoclass:: List
74.. autoclass:: Set
76.. autoclass:: FrozenSet
78.. autoclass:: Dict
80.. autoclass:: Tuple
82.. autoclass:: Optional
84.. autoclass:: Union
86.. autoclass:: Path
88.. autoclass:: NonExistentPath
90.. autoclass:: ExistingPath
92.. autoclass:: File
94.. autoclass:: Dir
96.. autoclass:: GitRepo
99.. _validating-parsers:
101Validators
102----------
104.. autoclass:: Regex
106.. autoclass:: Bound
108.. autoclass:: Gt
110.. autoclass:: Ge
112.. autoclass:: Lt
114.. autoclass:: Le
116.. autoclass:: LenBound
118.. autoclass:: LenGt
120.. autoclass:: LenGe
122.. autoclass:: LenLt
124.. autoclass:: LenLe
126.. autoclass:: OneOf
129Auxiliary parsers
130-----------------
132.. autoclass:: Map
134.. autoclass:: Apply
136.. autoclass:: Lower
138.. autoclass:: Upper
140.. autoclass:: CaseFold
142.. autoclass:: Strip
144.. autoclass:: WithMeta
146.. autoclass:: Secret
149Deriving parsers from type hints
150--------------------------------
152There is a way to automatically derive basic parsers from type hints
153(used by :mod:`yuio.config`):
155.. autofunction:: from_type_hint
158.. _partial parsers:
160Partial parsers
161---------------
163Sometimes it's not convenient to provide a parser for a complex type when
164all we need is to make a small adjustment to a part of the type. For example:
166.. invisible-code-block: python
168 from yuio.config import Config, field
170.. code-block:: python
172 class AppConfig(Config):
173 max_line_width: int | str = field(
174 default="default",
175 parser=Union(
176 Gt(Int(), 0),
177 OneOf(Str(), ["default", "unlimited", "keep"]),
178 ),
179 )
181.. invisible-code-block: python
183 AppConfig()
185Instead, we can use :class:`typing.Annotated` to attach validating parsers directly
186to type hints:
188.. code-block:: python
190 from typing import Annotated
193 class AppConfig(Config):
194 max_line_width: (
195 Annotated[int, Gt(0)]
196 | Annotated[str, OneOf(["default", "unlimited", "keep"])]
197 ) = "default"
199.. invisible-code-block: python
201 AppConfig()
203Notice that we didn't specify inner parsers for :class:`Gt` and :class:`OneOf`.
204This is because their internal parsers are derived from type hint, so we only care
205about their settings.
207Parsers created in such a way are called "partial". You can't use a partial parser
208on its own because it doesn't have full information about the object's type.
209You can only use partial parsers in type hints::
211 >>> partial_parser = List(delimiter=",")
212 >>> partial_parser.parse("1,2,3") # doctest: +ELLIPSIS
213 Traceback (most recent call last):
214 ...
215 TypeError: List requires an inner parser
216 ...
219Other parser methods
220--------------------
222:class:`Parser` defines some more methods and attributes.
223They're rarely used because Yuio handles everything they do itself.
224However, you can still use them in case you need to.
226.. autoclass:: Parser
227 :noindex:
229 .. autoattribute:: __wrapped_parser__
231 .. automethod:: get_nargs
233 .. automethod:: check_type
235 .. automethod:: assert_type
237 .. automethod:: describe
239 .. automethod:: describe_or_def
241 .. automethod:: describe_many
243 .. automethod:: describe_value
245 .. automethod:: options
247 .. automethod:: completer
249 .. automethod:: widget
251 .. automethod:: to_json_schema
253 .. automethod:: to_json_value
255 .. automethod:: is_secret
258Building your own parser
259------------------------
261.. _parser hierarchy:
263Understanding parser hierarchy
264~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
266The topmost class in the parser hierarchy is :class:`PartialParser`. It provides
267abstract methods to deal with `partial parsers`_. The primary parser interface,
268:class:`Parser`, is derived from it. Below :class:`Parser`, there are several
269abstract classes that provide boilerplate implementations for common use cases.
271.. raw:: html
273 <p>
274 <pre class="mermaid">
275 ---
276 config:
277 class:
278 hideEmptyMembersBox: true
279 ---
280 classDiagram
282 class PartialParser
283 click PartialParser href "#yuio.parse.PartialParser" "yuio.parse.PartialParser"
285 class Parser
286 click Parser href "#yuio.parse.Parser" "yuio.parse.Parser"
287 PartialParser <|-- Parser
289 class ValueParser
290 click ValueParser href "#yuio.parse.ValueParser" "yuio.parse.ValueParser"
291 Parser <|-- ValueParser
293 class WrappingParser
294 click WrappingParser href "#yuio.parse.WrappingParser" "yuio.parse.WrappingParser"
295 Parser <|-- WrappingParser
297 class MappingParser
298 click MappingParser href "#yuio.parse.MappingParser" "yuio.parse.MappingParser"
299 WrappingParser <|-- MappingParser
301 class Map
302 click Map href "#yuio.parse.Map" "yuio.parse.Map"
303 MappingParser <|-- Map
305 class Apply
306 click Apply href "#yuio.parse.Apply" "yuio.parse.Apply"
307 MappingParser <|-- Apply
309 class ValidatingParser
310 click ValidatingParser href "#yuio.parse.ValidatingParser" "yuio.parse.ValidatingParser"
311 Apply <|-- ValidatingParser
313 class CollectionParser
314 click CollectionParser href "#yuio.parse.CollectionParser" "yuio.parse.CollectionParser"
315 ValueParser <|-- CollectionParser
316 WrappingParser <|-- CollectionParser
317 </pre>
318 </p>
320The reason for separation of :class:`PartialParser` and :class:`Parser`
321is better type checking. We want to prevent users from making a mistake of providing
322a partial parser to a function that expect a fully initialized parser. For example,
323consider this code:
325.. skip: next
327.. code-block:: python
329 yuio.io.ask("Enter some names", parser=List())
331This will fail because ``List`` needs an inner parser to function.
333To annotate this behavior, we provide type hints for ``__new__`` methods
334on each parser. When an inner parser is given, ``__new__`` is annotated as
335returning an instance of :class:`Parser`. When inner parser is omitted,
336``__new__`` is annotated as returning an instance of :class:`PartialParser`:
338.. skip: next
340.. code-block:: python
342 from typing import TYPE_CHECKING, Any, Generic, overload
344 class List(..., Generic[T]):
345 if TYPE_CHECKING:
346 @overload
347 def __new__(cls, delimiter: str | None = None) -> PartialParser:
348 ...
349 @overload
350 def __new__(cls, inner: Parser[T], delimiter: str | None = None) -> PartialParser:
351 ...
352 def __new__(cls, *args, **kwargs) -> Any:
353 ...
355With these type hints, our example will fail to type check: :func:`yuio.io.ask`
356expects a :class:`Parser`, but ``List.__new__`` returns a :class:`PartialParser`.
358Unfortunately, this means that all parsers derived from :class:`WrappingParser`
359must provide appropriate type hints for their ``__new__`` method.
361.. autoclass:: PartialParser
362 :members:
365Base classes
366~~~~~~~~~~~~
368.. autoclass:: ValueParser
370.. autoclass:: WrappingParser
372 .. autoattribute:: _inner
374 .. autoattribute:: _inner_raw
376.. autoclass:: MappingParser
378.. autoclass:: ValidatingParser
380 .. autoattribute:: __wrapped_parser__
381 :noindex:
383 .. automethod:: _validate
385.. autoclass:: CollectionParser
387 .. autoattribute:: _allow_completing_duplicates
390Adding type hint conversions
391~~~~~~~~~~~~~~~~~~~~~~~~~~~~
393You can register a converter so that :func:`from_type_hint` can derive custom
394parsers from type hints:
396.. autofunction:: register_type_hint_conversion(cb: Cb) -> Cb
398When implementing a callback, you might need to specify a delimiter
399for a collection parser. Use :func:`suggest_delim_for_type_hint_conversion`:
401.. autofunction:: suggest_delim_for_type_hint_conversion
403"""
405from __future__ import annotations
407import abc
408import argparse
409import contextlib
410import dataclasses
411import datetime
412import decimal
413import enum
414import fractions
415import functools
416import json
417import pathlib
418import re
419import threading
420import traceback
421import types
422import typing
424import yuio
425import yuio.complete
426import yuio.json_schema
427import yuio.string
428import yuio.widget
429from yuio import _typing as _t
430from yuio.json_schema import JsonValue
431from yuio.secret import SecretString, SecretValue
432from yuio.util import _find_docs
433from yuio.util import to_dash_case as _to_dash_case
435__all__ = [
436 "Apply",
437 "Bool",
438 "Bound",
439 "CaseFold",
440 "CollectionParser",
441 "Date",
442 "DateTime",
443 "Decimal",
444 "Dict",
445 "Dir",
446 "Enum",
447 "ExistingPath",
448 "File",
449 "Float",
450 "Fraction",
451 "FrozenSet",
452 "Ge",
453 "GitRepo",
454 "Gt",
455 "Int",
456 "Json",
457 "JsonValue",
458 "Le",
459 "LenBound",
460 "LenGe",
461 "LenGt",
462 "LenLe",
463 "LenLt",
464 "List",
465 "Lower",
466 "Lt",
467 "Map",
468 "MappingParser",
469 "NonExistentPath",
470 "OneOf",
471 "Optional",
472 "Parser",
473 "ParsingError",
474 "PartialParser",
475 "Path",
476 "Regex",
477 "Secret",
478 "SecretString",
479 "SecretValue",
480 "Set",
481 "Str",
482 "Strip",
483 "Time",
484 "TimeDelta",
485 "Tuple",
486 "Union",
487 "Upper",
488 "ValidatingParser",
489 "ValueParser",
490 "WithMeta",
491 "WrappingParser",
492 "from_type_hint",
493 "register_type_hint_conversion",
494 "suggest_delim_for_type_hint_conversion",
495]
497T_co = _t.TypeVar("T_co", covariant=True)
498T = _t.TypeVar("T")
499U = _t.TypeVar("U")
500K = _t.TypeVar("K")
501V = _t.TypeVar("V")
502C = _t.TypeVar("C", bound=_t.Collection[object])
503C2 = _t.TypeVar("C2", bound=_t.Collection[object])
504Sz = _t.TypeVar("Sz", bound=_t.Sized)
505Cmp = _t.TypeVar("Cmp", bound=_t.SupportsLt[_t.Any])
506E = _t.TypeVar("E", bound=enum.Enum)
507TU = _t.TypeVar("TU", bound=tuple[object, ...])
508P = _t.TypeVar("P", bound="Parser[_t.Any]")
511class ParsingError(yuio.PrettyException, ValueError, argparse.ArgumentTypeError):
512 """
513 Raised when parsing or validation fails.
515 """
517 @classmethod
518 def type_mismatch(cls, value: _t.Any, /, *expected: type | str):
519 """
520 Make an error with a standard message "expected type X, got type Y".
522 :param value:
523 value of an unexpected type.
524 :param expected:
525 expected types. Each argument can be a type or a string that describes
526 a type.
527 :example:
528 ::
530 >>> raise ParsingError.type_mismatch(10, str)
531 Traceback (most recent call last):
532 ...
533 yuio.parse.ParsingError: Expected str, got int: 10
535 """
537 return cls(
538 "Expected %s, got `%s`: `%r`",
539 yuio.string.Or(map(yuio.string.TypeRepr, expected)),
540 yuio.string.TypeRepr(type(value)),
541 value,
542 )
545class PartialParser(abc.ABC):
546 """
547 An interface of a partial parser.
549 """
551 def __init__(self):
552 self.__orig_traceback = traceback.extract_stack()
553 while self.__orig_traceback and self.__orig_traceback[-1].filename.endswith(
554 "yuio/parse.py"
555 ):
556 self.__orig_traceback.pop()
557 super().__init__()
559 def _get_orig_traceback(self) -> traceback.StackSummary:
560 """
561 Get stack summary for the place where this partial parser was created.
563 """
565 return self.__orig_traceback
567 @contextlib.contextmanager
568 def _patch_stack_summary(self):
569 """
570 Attach original traceback to any exception that's raised
571 within this context manager.
573 """
575 try:
576 yield
577 except Exception as e:
578 stack_summary_text = "Traceback (most recent call last):\n" + "".join(
579 self.__orig_traceback.format()
580 )
581 e.args = (
582 f"{e}\n\nThe above error happened because of "
583 f"this type hint:\n\n{stack_summary_text}",
584 )
585 setattr(e, "__yuio_stack_summary_text__", stack_summary_text)
586 raise e
588 @abc.abstractmethod
589 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
590 """
591 Apply this partial parser.
593 When Yuio checks type annotations, it derives a parser for the given type hint,
594 and the applies all partial parsers to it.
596 For example, given this type hint:
598 .. invisible-code-block: python
600 from typing import Annotated
602 .. code-block:: python
604 field: Annotated[str, Map(str.lower)]
606 Yuio will first infer parser for string (:class:`Str`), then it will pass
607 this parser to ``Map.wrap``.
609 :param parser:
610 a parser instance that was created by inspecting type hints
611 and previous annotations.
612 :returns:
613 a result of upgrading this parser from partial to full. This method
614 usually returns ``self``.
615 :raises:
616 :class:`TypeError` if this parser can't be wrapped. Specifically, this
617 method should raise a :class:`TypeError` for any non-partial parser.
619 """
621 raise NotImplementedError()
624class Parser(PartialParser, _t.Generic[T_co]):
625 """
626 Base class for parsers.
628 """
630 # Original type hint from which this parser was derived.
631 __typehint: _t.Any = None
633 __wrapped_parser__: Parser[object] | None = None
634 """
635 An attribute for unwrapping parsers that validate or map results
636 of other parsers.
638 """
640 @abc.abstractmethod
641 def parse(self, value: str, /) -> T_co:
642 """
643 Parse user input, raise :class:`ParsingError` on failure.
645 :param value:
646 value to parse.
647 :returns:
648 a parsed and processed value.
649 :raises:
650 :class:`ParsingError`.
652 """
654 raise NotImplementedError()
656 @abc.abstractmethod
657 def parse_many(self, value: _t.Sequence[str], /) -> T_co:
658 """
659 For collection parsers, parse and validate collection
660 by parsing its items one-by-one.
662 :param value:
663 collection of values to parse.
664 :returns:
665 each value parsed and assembled into the target collection.
666 :raises:
667 :class:`ParsingError`. Also raises :class:`RuntimeError` if trying to call
668 this method on a parser that doesn't supports parsing collections
669 of objects.
670 :example:
671 ::
673 >>> # Let's say we're parsing a set of ints.
674 >>> parser = Set(Int())
676 >>> # And the user enters collection items one-by-one.
677 >>> user_input = ['1', '2', '3']
679 >>> # We can parse collection from its items:
680 >>> parser.parse_many(user_input)
681 {1, 2, 3}
683 """
685 raise NotImplementedError()
687 @abc.abstractmethod
688 def supports_parse_many(self) -> bool:
689 """
690 Return :data:`True` if this parser returns a collection
691 and so supports :meth:`~Parser.parse_many`.
693 :returns:
694 :data:`True` if :meth:`~Parser.parse_many` is safe to call.
696 """
698 raise NotImplementedError()
700 @abc.abstractmethod
701 def parse_config(self, value: object, /) -> T_co:
702 """
703 Parse value from a config, raise :class:`ParsingError` on failure.
705 This method accepts python values that would result from
706 parsing json, yaml, and similar formats.
708 :param value:
709 config value to parse.
710 :returns:
711 verified and processed config value.
712 :raises:
713 :class:`ParsingError`.
714 :example:
715 ::
717 >>> # Let's say we're parsing a set of ints.
718 >>> parser = Set(Int())
720 >>> # And we're loading it from json.
721 >>> import json
722 >>> user_config = json.loads('[1, 2, 3]')
724 >>> # We can process parsed json:
725 >>> parser.parse_config(user_config)
726 {1, 2, 3}
728 """
730 raise NotImplementedError()
732 @abc.abstractmethod
733 def get_nargs(self) -> _t.Literal["+", "*", "?"] | int | None:
734 """
735 Generate ``nargs`` for argparse.
737 :returns:
738 ``nargs`` as defined by argparse. If :meth:`~Parser.supports_parse_many`
739 returns :data:`True`, value should be ``"+"``, ``"*"``, ``"?"``,
740 or an integer. Otherwise, value should be :data:`None`.
742 """
744 raise NotImplementedError()
746 @abc.abstractmethod
747 def check_type(self, value: object, /) -> _t.TypeGuard[T_co]:
748 """
749 Check whether the parser can handle a particular value in its
750 :meth:`~Parser.describe_value` and other methods.
752 This function is used in :class:`Union` to dispatch values to correct parsers.
754 :param value:
755 value that needs a type check.
756 :returns:
757 :data:`True` if the value matches the type of this parser.
759 """
761 raise NotImplementedError()
763 def assert_type(self, value: object, /) -> _t.TypeGuard[T_co]:
764 """
765 Call :meth:`~Parser.check_type` and raise a :class:`TypeError`
766 if it returns :data:`False`.
768 This method always returns :data:`True` or throws an error, but type checkers
769 don't know this. Use ``assert parser.assert_type(value)`` so that they
770 understand that type of the ``value`` has narrowed.
772 :param value:
773 value that needs a type check.
774 :returns:
775 always returns :data:`True`.
776 :raises:
777 :class:`TypeError`.
779 """
781 if not self.check_type(value):
782 raise TypeError(
783 f"parser {self} can't handle value of type {_t.type_repr(type(value))}"
784 )
785 return True
787 @abc.abstractmethod
788 def describe(self) -> str | None:
789 """
790 Return a human-readable description of an expected input.
792 Used to describe expected input in widgets.
794 :returns:
795 human-readable description of an expected input. Can return :data:`None`
796 for simple values that don't need a special description.
798 """
800 raise NotImplementedError()
802 @abc.abstractmethod
803 def describe_or_def(self) -> str:
804 """
805 Like :py:meth:`~Parser.describe`, but guaranteed to return something.
807 Used to describe expected input in CLI help.
809 :returns:
810 human-readable description of an expected input.
812 """
814 raise NotImplementedError()
816 @abc.abstractmethod
817 def describe_many(self) -> str | tuple[str, ...]:
818 """
819 Return a human-readable description of a container element.
821 Used to describe expected input in CLI help.
823 :returns:
824 human-readable description of expected inputs. If the value is a string,
825 then it describes an individual member of a collection. The the value
826 is a tuple, then each of the tuple's element describes an expected value
827 at the corresponding position.
828 :raises:
829 :class:`RuntimeError` if trying to call this method on a parser
830 that doesn't supports parsing collections of objects.
832 """
834 raise NotImplementedError()
836 @abc.abstractmethod
837 def describe_value(self, value: object, /) -> str:
838 """
839 Return a human-readable description of the given value.
841 Used in error messages, and to describe returned input in widgets.
843 Note that, since parser's type parameter is covariant, this function is not
844 guaranteed to receive a value of the same type that this parser produces.
845 Call :meth:`~Parser.assert_type` to check for this case.
847 :param value:
848 value that needs a description.
849 :returns:
850 description of a value in the format that this parser would expect to see
851 in a CLI argument or an environment variable.
852 :raises:
853 :class:`TypeError` if the given value is not of type
854 that this parser produces.
856 """
858 raise NotImplementedError()
860 @abc.abstractmethod
861 def options(self) -> _t.Collection[yuio.widget.Option[T_co]] | None:
862 """
863 Return options for a :class:`~yuio.widget.Multiselect` widget.
865 This function can be implemented for parsers that return a fixed set
866 of pre-defined values, like :class:`Enum` or :class:`OneOf`.
867 Collection parsers may use this data to improve their widgets.
868 For example, the :class:`Set` parser will use
869 a :class:`~yuio.widget.Multiselect` widget.
871 :returns:
872 a full list of options that will be passed to
873 a :class:`~yuio.widget.Multiselect` widget, or :data:`None`
874 if the set of possible values is not known.
876 """
878 raise NotImplementedError()
880 @abc.abstractmethod
881 def completer(self) -> yuio.complete.Completer | None:
882 """
883 Return a completer for values of this parser.
885 This function is used when assembling autocompletion functions for shells,
886 and when reading values from user via :func:`yuio.io.ask`.
888 :returns:
889 a completer that will be used with CLI arguments or widgets.
891 """
893 raise NotImplementedError()
895 @abc.abstractmethod
896 def widget(
897 self,
898 default: object | yuio.Missing,
899 input_description: str | None,
900 default_description: str | None,
901 /,
902 ) -> yuio.widget.Widget[T_co | yuio.Missing]:
903 """
904 Return a widget for reading values of this parser.
906 This function is used when reading values from user via :func:`yuio.io.ask`.
908 The returned widget must produce values of type ``T``. If ``default`` is given,
909 and the user input is empty, the widget must produce
910 the :data:`~yuio.MISSING` constant (*not* the default constant).
911 This is because the default value might be of any type
912 (for example :data:`None`), and validating parsers should not check it.
914 Validating parsers must wrap the widget they got from
915 :attr:`__wrapped_parser__` into :class:`~yuio.widget.Map`
916 or :class:`~yuio.widget.Apply` in order to validate widget's results.
918 :param default:
919 default value that will be used if widget returns :data:`~yuio.MISSING`.
920 :param input_description:
921 a string describing what input is expected.
922 :param default_description:
923 a string describing default value.
924 :returns:
925 a widget that will be used to ask user for values. The widget can choose
926 to use :func:`~Parser.completer` or :func:`~Parser.options`, or implement
927 some custom logic.
929 """
931 raise NotImplementedError()
933 @abc.abstractmethod
934 def to_json_schema(
935 self, ctx: yuio.json_schema.JsonSchemaContext, /
936 ) -> yuio.json_schema.JsonSchemaType:
937 """
938 Create a JSON schema object based on this parser.
940 The purpose of this method is to make schemas for use in IDEs, i.e. to provide
941 autocompletion or simple error checking. The returned schema is not guaranteed
942 to reflect all constraints added to the parser. For example, :class:`OneOf`
943 and :class:`Regex` parsers will not affect the generated schema.
945 :param ctx:
946 context for building a schema.
947 :returns:
948 a JSON schema that describes structure of values expected by this parser.
950 """
952 raise NotImplementedError()
954 @abc.abstractmethod
955 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
956 """
957 Convert given value to a representation suitable for JSON serialization.
959 Note that, since parser's type parameter is covariant, this function is not
960 guaranteed to receive a value of the same type that this parser produces.
961 Call :meth:`~Parser.assert_type` to check for this case.
963 :returns:
964 a value converted to JSON-serializable representation.
965 :raises:
966 :class:`TypeError` if the given value is not of type
967 that this parser produces.
969 """
971 raise NotImplementedError()
973 @abc.abstractmethod
974 def is_secret(self) -> bool:
975 """
976 Indicates that input functions should use secret input,
977 i.e. :func:`~getpass.getpass` or :class:`yuio.widget.SecretInput`.
979 """
981 raise NotImplementedError()
983 def __repr__(self):
984 return self.__class__.__name__
987class ValueParser(Parser[T], PartialParser, _t.Generic[T]):
988 """
989 Base implementation for a parser that returns a single value.
991 Implements all method, except for :meth:`~Parser.parse`,
992 :meth:`~Parser.parse_config`, :meth:`~Parser.to_json_schema`,
993 and :meth:`~Parser.to_json_value`.
995 :param ty:
996 type of the produced value, used in :meth:`~Parser.check_type`.
997 :example:
998 .. invisible-code-block: python
1000 from dataclasses import dataclass
1001 @dataclass
1002 class MyType:
1003 data: str
1005 .. code-block:: python
1007 class MyTypeParser(ValueParser[MyType]):
1008 def __init__(self):
1009 super().__init__(MyType)
1011 def parse(self, value: str, /) -> MyType:
1012 return self.parse_config(value)
1014 def parse_config(self, value: object, /) -> MyType:
1015 if not isinstance(value, str):
1016 raise ParsingError.type_mismatch(value, str)
1017 return MyType(value)
1019 def to_json_schema(
1020 self, ctx: yuio.json_schema.JsonSchemaContext, /
1021 ) -> yuio.json_schema.JsonSchemaType:
1022 return yuio.json_schema.String()
1024 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
1025 assert self.assert_type(value)
1026 return value.data
1028 ::
1030 >>> MyTypeParser().parse('pancake')
1031 MyType(data='pancake')
1033 """
1035 def __init__(self, ty: type[T], /, *args, **kwargs) -> types.NoneType:
1036 super().__init__(*args, **kwargs)
1038 self._value_type = ty
1039 """
1040 Type of the produced value, used in :meth:`~Parser.check_type`.
1042 """
1044 def wrap(self: P, parser: Parser[_t.Any]) -> P:
1045 typehint = getattr(parser, "_Parser__typehint", None)
1046 if typehint is None:
1047 with self._patch_stack_summary():
1048 raise TypeError(
1049 f"annotating a type with {self} will override"
1050 " all previous annotations. Make sure that"
1051 f" {self} is the first annotation in"
1052 " your type hint.\n\n"
1053 "Example:\n"
1054 " Incorrect: Str() overrides effects of Map()\n"
1055 " field: typing.Annotated[str, Map(fn=str.lower), Str()]\n"
1056 " ^^^^^\n"
1057 " Correct: Str() is applied first, then Map()\n"
1058 " field: typing.Annotated[str, Str(), Map(fn=str.lower)]\n"
1059 " ^^^^^"
1060 )
1061 if not isinstance(self, parser.__class__):
1062 with self._patch_stack_summary():
1063 raise TypeError(
1064 f"annotating {_t.type_repr(typehint)} with {self.__class__.__name__}"
1065 " conflicts with default parser for this type, which is"
1066 f" {parser.__class__.__name__}.\n\n"
1067 "Example:\n"
1068 " Incorrect: Path() can't be used to annotate `str`\n"
1069 " field: typing.Annotated[str, Path(extensions=[...])]\n"
1070 " ^^^^^^^^^^^^^^^^^^^^^^\n"
1071 " Correct: using Path() to annotate `pathlib.Path`\n"
1072 " field: typing.Annotated[pathlib.Path, Path(extensions=[...])]\n"
1073 " ^^^^^^^^^^^^^^^^^^^^^^"
1074 )
1075 return self
1077 def parse_many(self, value: _t.Sequence[str], /) -> T:
1078 raise RuntimeError("unable to parse multiple values")
1080 def supports_parse_many(self) -> bool:
1081 return False
1083 def get_nargs(self) -> _t.Literal["+", "*", "?"] | int | None:
1084 return None
1086 def check_type(self, value: object, /) -> _t.TypeGuard[T]:
1087 return isinstance(value, self._value_type)
1089 def describe(self) -> str | None:
1090 return None
1092 def describe_or_def(self) -> str:
1093 return self.describe() or f"<{_to_dash_case(self.__class__.__name__)}>"
1095 def describe_many(self) -> str | tuple[str, ...]:
1096 return self.describe_or_def()
1098 def describe_value(self, value: object, /) -> str:
1099 assert self.assert_type(value)
1100 return str(value) or "<empty>"
1102 def options(self) -> _t.Collection[yuio.widget.Option[T]] | None:
1103 return None
1105 def completer(self) -> yuio.complete.Completer | None:
1106 return None
1108 def widget(
1109 self,
1110 default: object | yuio.Missing,
1111 input_description: str | None,
1112 default_description: str | None,
1113 /,
1114 ) -> yuio.widget.Widget[T | yuio.Missing]:
1115 completer = self.completer()
1116 return _WidgetResultMapper(
1117 self,
1118 input_description,
1119 default,
1120 (
1121 yuio.widget.InputWithCompletion(
1122 completer,
1123 placeholder=default_description or "",
1124 )
1125 if completer is not None
1126 else yuio.widget.Input(
1127 placeholder=default_description or "",
1128 )
1129 ),
1130 )
1132 def is_secret(self) -> bool:
1133 return False
1136class WrappingParser(Parser[T], _t.Generic[T, U]):
1137 """
1138 A base for a parser that wraps another parser and alters its output.
1140 This base simplifies dealing with partial parsers.
1142 The :attr:`~WrappingParser._inner` attribute is whatever internal state you need
1143 to store. When it is :data:`None`, the parser is considered partial. That is,
1144 you can't use such a parser to actually parse anything, but you can
1145 use it in a type annotation. When it is not :data:`None`, the parser is considered
1146 non partial. You can use it to parse things, but you can't use it
1147 in a type annotation.
1149 .. warning::
1151 All descendants of this class must include appropriate type hints
1152 for their ``__new__`` method, otherwise type annotations from this base
1153 will shadow implementation's ``__init__`` signature.
1155 See section on `parser hierarchy`_ for details.
1157 :param inner:
1158 inner data or :data:`None`.
1160 """
1162 if _t.TYPE_CHECKING:
1164 @_t.overload
1165 def __new__(cls, inner: U, /) -> WrappingParser[T, U]: ...
1167 @_t.overload
1168 def __new__(cls, /) -> PartialParser: ...
1170 def __new__(cls, *args, **kwargs) -> _t.Any: ...
1172 def __init__(self, inner: U | None, /, *args, **kwargs):
1173 self.__inner = inner
1174 super().__init__(*args, **kwargs)
1176 @property
1177 def _inner(self) -> U:
1178 """
1179 Internal resource wrapped by this parser.
1181 :raises:
1182 Accessing it when the parser is in a partial state triggers an error
1183 and warns user that they didn't provide an inner parser.
1185 Setting a new value when the parser is not in a partial state triggers
1186 an error and warns user that they shouldn't provide an inner parser
1187 in type annotations.
1189 """
1191 if self.__inner is None:
1192 with self._patch_stack_summary():
1193 raise TypeError(f"{self.__class__.__name__} requires an inner parser")
1194 return self.__inner
1196 @_inner.setter
1197 def _inner(self, inner: U):
1198 if self.__inner is not None:
1199 with self._patch_stack_summary():
1200 raise TypeError(
1201 f"don't provide inner parser when using {self.__class__.__name__}"
1202 " with type annotations. The inner parser will be derived automatically"
1203 "from type hint.\n\n"
1204 "Example:\n"
1205 " Incorrect: List() has an inner parser\n"
1206 " field: typing.Annotated[list[str], List(Str(), delimiter=';')]\n"
1207 " ^^^^^^^^^^^^^^^^^^^^^^^^^^\n"
1208 " Correct: inner parser for List() derived from type hint\n"
1209 " field: typing.Annotated[list[str], List(delimiter=';')]\n"
1210 " ^^^^^^^^^^^^^^^^^^^"
1211 )
1212 self.__inner = inner
1214 @property
1215 def _inner_raw(self) -> U | None:
1216 """
1217 Unchecked access to the wrapped resource.
1219 """
1221 return self.__inner
1224class MappingParser(WrappingParser[T, Parser[U]], _t.Generic[T, U]):
1225 """
1226 This is a base abstraction for :class:`Map` and :class:`Optional`.
1227 Forwards all calls to the inner parser, except for :meth:`~Parser.parse`,
1228 :meth:`~Parser.parse_many`, :meth:`~Parser.parse_config`,
1229 :meth:`~Parser.options`, :meth:`~Parser.check_type`,
1230 :meth:`~Parser.describe_value`, :meth:`~Parser.describe_value`,
1231 :meth:`~Parser.widget`, and :meth:`~Parser.to_json_value`.
1233 :param inner:
1234 mapped parser or :data:`None`.
1236 """
1238 if _t.TYPE_CHECKING:
1240 @_t.overload
1241 def __new__(cls, inner: Parser[U], /) -> MappingParser[T, U]: ...
1243 @_t.overload
1244 def __new__(cls, /) -> PartialParser: ...
1246 def __new__(cls, *args, **kwargs) -> _t.Any: ...
1248 def __init__(self, inner: Parser[U] | None, /):
1249 super().__init__(inner)
1251 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
1252 self._inner = parser
1253 return self
1255 def supports_parse_many(self) -> bool:
1256 return self._inner.supports_parse_many()
1258 def get_nargs(self) -> _t.Literal["+", "*", "?"] | int | None:
1259 return self._inner.get_nargs()
1261 def describe(self) -> str | None:
1262 return self._inner.describe()
1264 def describe_or_def(self) -> str:
1265 return self._inner.describe_or_def()
1267 def describe_many(self) -> str | tuple[str, ...]:
1268 return self._inner.describe_many()
1270 def completer(self) -> yuio.complete.Completer | None:
1271 return self._inner.completer()
1273 def to_json_schema(
1274 self, ctx: yuio.json_schema.JsonSchemaContext, /
1275 ) -> yuio.json_schema.JsonSchemaType:
1276 return self._inner.to_json_schema(ctx)
1278 def is_secret(self) -> bool:
1279 return self._inner.is_secret()
1281 def __repr__(self):
1282 return f"{self.__class__.__name__}({self._inner_raw!r})"
1284 @property
1285 def __wrapped_parser__(self): # pyright: ignore[reportIncompatibleVariableOverride]
1286 return self._inner_raw
1289class Map(MappingParser[T, U], _t.Generic[T, U]):
1290 """Map(inner: Parser[U], fn: typing.Callable[[U], T], rev: typing.Callable[[T | object], U] | None = None, /)
1292 A wrapper that maps result of the given parser using the given function.
1294 :param inner:
1295 a parser whose result will be mapped.
1296 :param fn:
1297 a function to convert a result.
1298 :param rev:
1299 a function used to un-map a value.
1301 This function is used in :meth:`Parser.describe_value`
1302 and :meth:`Parser.to_json_value` to convert parsed value back
1303 to its original state.
1305 Note that, since parser's type parameter is covariant, this function is not
1306 guaranteed to receive a value of the same type that this parser produces.
1307 In this case, you should raise a :class:`TypeError`.
1308 :example:
1309 ..
1310 >>> import math
1312 ::
1314 >>> parser = yuio.parse.Map(
1315 ... yuio.parse.Int(),
1316 ... lambda x: 2 ** x,
1317 ... lambda x: int(math.log2(x)),
1318 ... )
1319 >>> parser.parse("10")
1320 1024
1321 >>> parser.describe_value(1024)
1322 '10'
1324 """
1326 if _t.TYPE_CHECKING:
1328 @_t.overload
1329 def __new__(cls, inner: Parser[T], fn: _t.Callable[[T], T], /) -> Map[T, T]: ...
1331 @_t.overload
1332 def __new__(cls, fn: _t.Callable[[T], T], /) -> PartialParser: ...
1334 @_t.overload
1335 def __new__(
1336 cls,
1337 inner: Parser[U],
1338 fn: _t.Callable[[U], T],
1339 rev: _t.Callable[[T | object], U],
1340 /,
1341 ) -> Map[T, T]: ...
1343 @_t.overload
1344 def __new__(
1345 cls, fn: _t.Callable[[U], T], rev: _t.Callable[[T | object], U], /
1346 ) -> PartialParser: ...
1348 def __new__(cls, *args, **kwargs) -> _t.Any: ...
1350 def __init__(self, *args):
1351 inner: Parser[U] | None = None
1352 fn: _t.Callable[[U], T]
1353 rev: _t.Callable[[T | object], U] | None = None
1354 if len(args) == 1:
1355 (fn,) = args
1356 elif len(args) == 2 and isinstance(args[0], Parser):
1357 inner, fn = args
1358 elif len(args) == 2:
1359 fn, rev = args
1360 elif len(args) == 3:
1361 inner, fn, rev = args
1362 else:
1363 raise TypeError(
1364 f"expected between 1 and 2 positional arguments, got {len(args)}"
1365 )
1367 self.__fn = fn
1368 self.__rev = rev
1369 super().__init__(inner)
1371 def parse(self, value: str, /) -> T:
1372 return self.__fn(self._inner.parse(value))
1374 def parse_many(self, value: _t.Sequence[str], /) -> T:
1375 return self.__fn(self._inner.parse_many(value))
1377 def parse_config(self, value: object, /) -> T:
1378 return self.__fn(self._inner.parse_config(value))
1380 def check_type(self, value: object, /) -> _t.TypeGuard[T]:
1381 if self.__rev:
1382 value = self.__rev(value)
1383 return self._inner.check_type(value)
1385 def describe_value(self, value: object, /) -> str:
1386 if self.__rev:
1387 value = self.__rev(value)
1388 return self._inner.describe_value(value)
1390 def options(self) -> _t.Collection[yuio.widget.Option[T]] | None:
1391 options = self._inner.options()
1392 if options is not None:
1393 return [
1394 _t.cast(
1395 yuio.widget.Option[T],
1396 dataclasses.replace(option, value=self.__fn(option.value)),
1397 )
1398 for option in options
1399 ]
1400 else:
1401 return None
1403 def widget(
1404 self,
1405 default: object | yuio.Missing,
1406 input_description: str | None,
1407 default_description: str | None,
1408 /,
1409 ) -> yuio.widget.Widget[T | yuio.Missing]:
1410 return yuio.widget.Map(
1411 self._inner.widget(default, input_description, default_description),
1412 lambda v: self.__fn(v) if v is not yuio.MISSING else yuio.MISSING,
1413 )
1415 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
1416 if self.__rev:
1417 value = self.__rev(value)
1418 return self._inner.to_json_value(value)
1421@_t.overload
1422def Lower(inner: Parser[str], /) -> Parser[str]: ...
1423@_t.overload
1424def Lower() -> PartialParser: ...
1425def Lower(*args) -> _t.Any:
1426 """Lower(inner: Parser[str], /)
1428 Applies :meth:`str.lower` to the result of a string parser.
1430 :param inner:
1431 a parser whose result will be mapped.
1433 """
1435 return Map(*args, str.lower) # pyright: ignore[reportCallIssue]
1438@_t.overload
1439def Upper(inner: Parser[str], /) -> Parser[str]: ...
1440@_t.overload
1441def Upper() -> PartialParser: ...
1442def Upper(*args) -> _t.Any:
1443 """Upper(inner: Parser[str], /)
1445 Applies :meth:`str.upper` to the result of a string parser.
1447 :param inner:
1448 a parser whose result will be mapped.
1450 """
1452 return Map(*args, str.upper) # pyright: ignore[reportCallIssue]
1455@_t.overload
1456def CaseFold(inner: Parser[str], /) -> Parser[str]: ...
1457@_t.overload
1458def CaseFold() -> PartialParser: ...
1459def CaseFold(*args) -> _t.Any:
1460 """CaseFold(inner: Parser[str], /)
1462 Applies :meth:`str.casefold` to the result of a string parser.
1464 :param inner:
1465 a parser whose result will be mapped.
1467 """
1469 return Map(*args, str.casefold) # pyright: ignore[reportCallIssue]
1472@_t.overload
1473def Strip(inner: Parser[str], /) -> Parser[str]: ...
1474@_t.overload
1475def Strip() -> PartialParser: ...
1476def Strip(*args) -> _t.Any:
1477 """Strip(inner: Parser[str], /)
1479 Applies :meth:`str.strip` to the result of a string parser.
1481 :param inner:
1482 a parser whose result will be mapped.
1484 """
1486 return Map(*args, str.strip) # pyright: ignore[reportCallIssue]
1489@_t.overload
1490def Regex(
1491 inner: Parser[str],
1492 regex: str | _t.StrRePattern,
1493 /,
1494 *,
1495 group: int | str = 0,
1496) -> Parser[str]: ...
1497@_t.overload
1498def Regex(
1499 regex: str | _t.StrRePattern, /, *, group: int | str = 0
1500) -> PartialParser: ...
1501def Regex(*args, group: int | str = 0) -> _t.Any:
1502 """Regex(inner: Parser[str], regex: str | re.Pattern[str], /, *, group: int | str = 0)
1504 Matches the parsed string with the given regular expression.
1506 If regex has capturing groups, parser can return contents of a group.
1508 :param regex:
1509 regular expression for matching.
1510 :param group:
1511 name or index of a capturing group that should be used to get the final
1512 parsed value.
1514 """
1516 inner: Parser[str] | None
1517 regex: str | _t.StrRePattern
1518 if len(args) == 1:
1519 inner, regex = None, args[0]
1520 elif len(args) == 2:
1521 inner, regex = args
1522 else:
1523 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
1525 if isinstance(regex, re.Pattern):
1526 compiled = regex
1527 else:
1528 compiled = re.compile(regex)
1530 def mapper(value: str) -> str:
1531 if (match := compiled.match(value)) is None:
1532 raise ParsingError(
1533 "value doesn't match regex `%s`: `%r`", compiled.pattern, value
1534 )
1535 return match.group(group)
1537 return Map(inner, mapper) # type: ignore
1540class Apply(MappingParser[T, T], _t.Generic[T]):
1541 """Apply(inner: Parser[T], fn: typing.Callable[[T], None], /)
1543 A wrapper that applies the given function to the result of a wrapped parser.
1545 :param inner:
1546 a parser used to extract and validate a value.
1547 :param fn:
1548 a function that will be called after parsing a value.
1549 :example:
1550 ::
1552 >>> # Run `Int` parser, then print its output before returning.
1553 >>> print_output = Apply(Int(), lambda x: print(f"Value is {x}"))
1554 >>> result = print_output.parse("10")
1555 Value is 10
1556 >>> result
1557 10
1559 """
1561 if _t.TYPE_CHECKING:
1563 @_t.overload
1564 def __new__(
1565 cls, inner: Parser[T], fn: _t.Callable[[T], None], /
1566 ) -> Apply[T]: ...
1568 @_t.overload
1569 def __new__(cls, fn: _t.Callable[[T], None], /) -> PartialParser: ...
1571 def __new__(cls, *args, **kwargs) -> _t.Any: ...
1573 def __init__(self, *args):
1574 inner: Parser[T] | None
1575 fn: _t.Callable[[T], None]
1576 if len(args) == 1:
1577 inner, fn = None, args[0]
1578 elif len(args) == 2:
1579 inner, fn = args
1580 else:
1581 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
1583 self.__fn = fn
1584 super().__init__(inner)
1586 def parse(self, value: str, /) -> T:
1587 result = self._inner.parse(value)
1588 self.__fn(result)
1589 return result
1591 def parse_many(self, value: _t.Sequence[str], /) -> T:
1592 result = self._inner.parse_many(value)
1593 self.__fn(result)
1594 return result
1596 def parse_config(self, value: object, /) -> T:
1597 result = self._inner.parse_config(value)
1598 self.__fn(result)
1599 return result
1601 def check_type(self, value: object, /) -> _t.TypeGuard[T]:
1602 return self._inner.check_type(value)
1604 def describe_value(self, value: object, /) -> str:
1605 return self._inner.describe_value(value)
1607 def options(self) -> _t.Collection[yuio.widget.Option[T]] | None:
1608 return self._inner.options()
1610 def completer(self) -> yuio.complete.Completer | None:
1611 return self._inner.completer()
1613 def widget(
1614 self,
1615 default: object | yuio.Missing,
1616 input_description: str | None,
1617 default_description: str | None,
1618 /,
1619 ) -> yuio.widget.Widget[T | yuio.Missing]:
1620 return yuio.widget.Apply(
1621 self._inner.widget(default, input_description, default_description),
1622 lambda v: self.__fn(v) if v is not yuio.MISSING else None,
1623 )
1625 def to_json_schema(
1626 self, ctx: yuio.json_schema.JsonSchemaContext, /
1627 ) -> yuio.json_schema.JsonSchemaType:
1628 return self._inner.to_json_schema(ctx)
1630 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
1631 return self._inner.to_json_value(value)
1634class ValidatingParser(Apply[T], _t.Generic[T]):
1635 """
1636 Base implementation for a parser that validates result of another parser.
1638 This class wraps another parser and passes all method calls to it.
1639 All parsed values are additionally passed to :meth:`~ValidatingParser._validate`.
1641 :param inner:
1642 a parser which output will be validated.
1643 :example:
1644 .. code-block:: python
1646 class IsLower(ValidatingParser[str]):
1647 def _validate(self, value: str, /):
1648 if not value.islower():
1649 raise ParsingError("value should be lowercase: `%r`", value)
1651 ::
1653 >>> IsLower(Str()).parse("Not lowercase!")
1654 Traceback (most recent call last):
1655 ...
1656 yuio.parse.ParsingError: value should be lowercase: 'Not lowercase!'
1658 """
1660 if _t.TYPE_CHECKING:
1662 @_t.overload
1663 def __new__(cls, inner: Parser[T], /) -> ValidatingParser[T]: ...
1665 @_t.overload
1666 def __new__(cls, /) -> PartialParser: ...
1668 def __new__(cls, *args, **kwargs) -> _t.Any: ...
1670 def __init__(self, inner: Parser[T] | None = None, /):
1671 super().__init__(inner, self._validate)
1673 @abc.abstractmethod
1674 def _validate(self, value: T, /):
1675 """
1676 Implementation of value validation.
1678 :param value:
1679 value which needs validating.
1680 :raises:
1681 should raise :class:`ParsingError` if validation fails.
1683 """
1685 raise NotImplementedError()
1688class Str(ValueParser[str]):
1689 """
1690 Parser for str values.
1692 """
1694 def __init__(self):
1695 super().__init__(str)
1697 def parse(self, value: str, /) -> str:
1698 return value
1700 def parse_config(self, value: object, /) -> str:
1701 if not isinstance(value, str):
1702 raise ParsingError.type_mismatch(value, str)
1703 return value
1705 def to_json_schema(
1706 self, ctx: yuio.json_schema.JsonSchemaContext, /
1707 ) -> yuio.json_schema.JsonSchemaType:
1708 return yuio.json_schema.String()
1710 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
1711 assert self.assert_type(value)
1712 return value
1715class Int(ValueParser[int]):
1716 """
1717 Parser for int values.
1719 """
1721 def __init__(self):
1722 super().__init__(int)
1724 def parse(self, value: str, /) -> int:
1725 try:
1726 return int(value.strip())
1727 except ValueError:
1728 raise ParsingError("Can't parse `%r` as `int`", value) from None
1730 def parse_config(self, value: object, /) -> int:
1731 if isinstance(value, float):
1732 if value != int(value): # pyright: ignore[reportUnnecessaryComparison]
1733 raise ParsingError.type_mismatch(value, int)
1734 value = int(value)
1735 if not isinstance(value, int):
1736 raise ParsingError.type_mismatch(value, int)
1737 return value
1739 def to_json_schema(
1740 self, ctx: yuio.json_schema.JsonSchemaContext, /
1741 ) -> yuio.json_schema.JsonSchemaType:
1742 return yuio.json_schema.Integer()
1744 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
1745 assert self.assert_type(value)
1746 return value
1749class Float(ValueParser[float]):
1750 """
1751 Parser for float values.
1753 """
1755 def __init__(self):
1756 super().__init__(float)
1758 def parse(self, value: str, /) -> float:
1759 try:
1760 return float(value.strip())
1761 except ValueError:
1762 raise ParsingError("Can't parse `%r` as `float`", value) from None
1764 def parse_config(self, value: object, /) -> float:
1765 if not isinstance(value, (float, int)):
1766 raise ParsingError.type_mismatch(value, float)
1767 return value
1769 def to_json_schema(
1770 self, ctx: yuio.json_schema.JsonSchemaContext, /
1771 ) -> yuio.json_schema.JsonSchemaType:
1772 return yuio.json_schema.Number()
1774 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
1775 assert self.assert_type(value)
1776 return value
1779class Bool(ValueParser[bool]):
1780 """
1781 Parser for bool values, such as ``"yes"`` or ``"no"``.
1783 """
1785 def __init__(self):
1786 super().__init__(bool)
1788 def parse(self, value: str, /) -> bool:
1789 value = value.strip().lower()
1791 if value in ("y", "yes", "true", "1"):
1792 return True
1793 elif value in ("n", "no", "false", "0"):
1794 return False
1795 else:
1796 raise ParsingError(
1797 "Can't parse `%r` as `bool`, should be one of `'yes'`, `'no'`", value
1798 )
1800 def parse_config(self, value: object, /) -> bool:
1801 if not isinstance(value, bool):
1802 raise ParsingError.type_mismatch(value, bool)
1803 return value
1805 def describe(self) -> str | None:
1806 return "{yes|no}"
1808 def describe_value(self, value: object, /) -> str:
1809 assert self.assert_type(value)
1810 return "yes" if value else "no"
1812 def completer(self) -> yuio.complete.Completer | None:
1813 return yuio.complete.Choice(
1814 [
1815 yuio.complete.Option("no"),
1816 yuio.complete.Option("yes"),
1817 ]
1818 )
1820 def widget(
1821 self,
1822 default: object | yuio.Missing,
1823 input_description: str | None,
1824 default_description: str | None,
1825 /,
1826 ) -> yuio.widget.Widget[bool | yuio.Missing]:
1827 options: list[yuio.widget.Option[bool | yuio.Missing]] = [
1828 yuio.widget.Option(False, "no"),
1829 yuio.widget.Option(True, "yes"),
1830 ]
1832 if default is yuio.MISSING:
1833 default_index = 0
1834 elif isinstance(default, bool):
1835 default_index = int(default)
1836 else:
1837 options.append(
1838 yuio.widget.Option(yuio.MISSING, default_description or str(default))
1839 )
1840 default_index = 2
1842 return yuio.widget.Choice(options, default_index=default_index)
1844 def to_json_schema(
1845 self, ctx: yuio.json_schema.JsonSchemaContext, /
1846 ) -> yuio.json_schema.JsonSchemaType:
1847 return yuio.json_schema.Boolean()
1849 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
1850 assert self.assert_type(value)
1851 return value
1854class Enum(WrappingParser[E, type[E]], ValueParser[E], _t.Generic[E]):
1855 """Enum(enum_type: typing.Type[E], /, *, by_name: bool = False, to_dash_case: bool = False, doc_inline: bool = False)
1857 Parser for enums, as defined in the standard :mod:`enum` module.
1859 :param enum_type:
1860 enum class that will be used to parse and extract values.
1861 :param by_name:
1862 if :data:`True`, the parser will use enumerator names, instead of
1863 their values, to match the input.
1864 :param to_dash_case:
1865 convert enum names/values to dash case.
1866 :param doc_inline:
1867 inline this enum in json schema and in documentation.
1869 """
1871 if _t.TYPE_CHECKING:
1873 @_t.overload
1874 def __new__(
1875 cls,
1876 inner: type[E],
1877 /,
1878 *,
1879 by_name: bool = False,
1880 to_dash_case: bool = False,
1881 doc_inline: bool = False,
1882 ) -> Enum[E]: ...
1884 @_t.overload
1885 def __new__(
1886 cls,
1887 /,
1888 *,
1889 by_name: bool = False,
1890 to_dash_case: bool = False,
1891 doc_inline: bool = False,
1892 ) -> PartialParser: ...
1894 def __new__(cls, *args, **kwargs) -> _t.Any: ...
1896 def __init__(
1897 self,
1898 enum_type: type[E] | None = None,
1899 /,
1900 *,
1901 by_name: bool = False,
1902 to_dash_case: bool = False,
1903 doc_inline: bool = False,
1904 ):
1905 self.__by_name = by_name
1906 self.__to_dash_case = to_dash_case
1907 self.__doc_inline = doc_inline
1908 super().__init__(enum_type, enum_type)
1910 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
1911 result = super().wrap(parser)
1912 result._inner = parser._inner # type: ignore
1913 result._value_type = parser._inner # type: ignore
1914 return result
1916 @functools.cached_property
1917 def __getter(self) -> _t.Callable[[E], str]:
1918 items = {}
1919 for e in self._inner:
1920 if self.__by_name:
1921 name = e.name
1922 else:
1923 name = str(e.value)
1924 if self.__to_dash_case:
1925 name = _to_dash_case(name)
1926 items[e] = name
1927 return lambda e: items[e]
1929 @functools.cached_property
1930 def __docs(self) -> dict[str, str]:
1931 return _find_docs(self._inner)
1933 def parse(self, value: str, /) -> E:
1934 cf_value = value.strip().casefold()
1936 candidates: list[E] = []
1937 for item in self._inner:
1938 if self.__getter(item) == value:
1939 return item
1940 elif (self.__getter(item)).casefold().startswith(cf_value):
1941 candidates.append(item)
1943 if len(candidates) == 1:
1944 return candidates[0]
1945 elif len(candidates) > 1:
1946 enum_values = tuple(self.__getter(e) for e in candidates)
1947 raise ParsingError(
1948 "Can't parse `%r` as `%s`, possible candidates are %s",
1949 value,
1950 self._inner.__name__,
1951 yuio.string.Or(enum_values),
1952 )
1953 else:
1954 enum_values = tuple(self.__getter(e) for e in self._inner)
1955 raise ParsingError(
1956 "Can't parse `%r` as `%s`, should be %s",
1957 value,
1958 self._inner.__name__,
1959 yuio.string.Or(enum_values),
1960 )
1962 def parse_config(self, value: object, /) -> E:
1963 if not isinstance(value, str):
1964 raise ParsingError.type_mismatch(value, str)
1966 result = self.parse(value)
1968 if self.__getter(result) != value:
1969 raise ParsingError(
1970 "Can't parse `%r` as `%s`, did you mean `%s`?",
1971 value,
1972 self._inner.__name__,
1973 self.__getter(result),
1974 )
1976 return result
1978 def describe(self) -> str | None:
1979 desc = "|".join(self.__getter(e) for e in self._inner)
1980 if len(self._inner) > 1:
1981 desc = f"{{{desc}}}"
1982 return desc
1984 def describe_many(self) -> str | tuple[str, ...]:
1985 return self.describe_or_def()
1987 def describe_value(self, value: object, /) -> str:
1988 assert self.assert_type(value)
1989 return str(self.__getter(value))
1991 def options(self) -> _t.Collection[yuio.widget.Option[E]]:
1992 docs = self.__docs
1993 return [
1994 yuio.widget.Option(
1995 e, display_text=self.__getter(e), comment=docs.get(e.name)
1996 )
1997 for e in self._inner
1998 ]
2000 def completer(self) -> yuio.complete.Completer | None:
2001 docs = self.__docs
2002 return yuio.complete.Choice(
2003 [
2004 yuio.complete.Option(self.__getter(e), comment=docs.get(e.name))
2005 for e in self._inner
2006 ]
2007 )
2009 def widget(
2010 self,
2011 default: object | yuio.Missing,
2012 input_description: str | None,
2013 default_description: str | None,
2014 /,
2015 ) -> yuio.widget.Widget[E | yuio.Missing]:
2016 options: list[yuio.widget.Option[E | yuio.Missing]] = list(self.options())
2018 if default is yuio.MISSING:
2019 default_index = 0
2020 elif isinstance(default, self._inner):
2021 default_index = list(self._inner).index(default)
2022 else:
2023 options.insert(
2024 0, yuio.widget.Option(yuio.MISSING, default_description or str(default))
2025 )
2026 default_index = 0
2028 return yuio.widget.Choice(options, default_index=default_index)
2030 def to_json_schema(
2031 self, ctx: yuio.json_schema.JsonSchemaContext, /
2032 ) -> yuio.json_schema.JsonSchemaType:
2033 items = [self.__getter(e) for e in self._inner]
2034 docs = self.__docs
2035 descriptions = [docs.get(e.name) for e in self._inner]
2036 if not any(descriptions):
2037 descriptions = None
2038 if self.__doc_inline:
2039 return yuio.json_schema.Enum(items, descriptions)
2040 else:
2041 return ctx.add_type(
2042 Enum._TyWrapper(self._inner, self.__by_name, self.__to_dash_case),
2043 _t.type_repr(self._inner),
2044 lambda: yuio.json_schema.Meta(
2045 yuio.json_schema.Enum(items, descriptions),
2046 title=self._inner.__name__,
2047 description=self._inner.__doc__,
2048 ),
2049 )
2051 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2052 assert self.assert_type(value)
2053 return self.__getter(value)
2055 def __repr__(self):
2056 if self._inner_raw is not None:
2057 return f"{self.__class__.__name__}({self._inner_raw!r})"
2058 else:
2059 return self.__class__.__name__
2061 @dataclasses.dataclass(unsafe_hash=True, match_args=False, slots=True)
2062 class _TyWrapper:
2063 inner: type
2064 by_name: bool
2065 to_dash_case: bool
2068class Decimal(ValueParser[decimal.Decimal]):
2069 """
2070 Parser for :class:`decimal.Decimal`.
2072 """
2074 def __init__(self):
2075 super().__init__(decimal.Decimal)
2077 def parse(self, value: str, /) -> decimal.Decimal:
2078 return self.parse_config(value)
2080 def parse_config(self, value: object, /) -> decimal.Decimal:
2081 if not isinstance(value, (int, float, str, decimal.Decimal)):
2082 raise ParsingError.type_mismatch(value, int, float, str)
2083 try:
2084 return decimal.Decimal(value)
2085 except (ArithmeticError, ValueError, TypeError):
2086 raise ParsingError("Can't parse `%r` as `decimal`", value) from None
2088 def to_json_schema(
2089 self, ctx: yuio.json_schema.JsonSchemaContext, /
2090 ) -> yuio.json_schema.JsonSchemaType:
2091 return ctx.add_type(
2092 decimal.Decimal,
2093 "Decimal",
2094 lambda: yuio.json_schema.Meta(
2095 yuio.json_schema.OneOf(
2096 [
2097 yuio.json_schema.Number(),
2098 yuio.json_schema.String(
2099 pattern=r"(?i)^[+-]?((\d+\.\d*|\.?\d+)(e[+-]?\d+)?|inf(inity)?|(nan|snan)\d*)$"
2100 ),
2101 ]
2102 ),
2103 title="Decimal",
2104 description="Decimal fixed-point and floating-point number.",
2105 ),
2106 )
2108 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2109 assert self.assert_type(value)
2110 return str(value)
2113class Fraction(ValueParser[fractions.Fraction]):
2114 """
2115 Parser for :class:`fractions.Fraction`.
2117 """
2119 def __init__(self):
2120 super().__init__(fractions.Fraction)
2122 def parse(self, value: str, /) -> fractions.Fraction:
2123 return self.parse_config(value)
2125 def parse_config(self, value: object, /) -> fractions.Fraction:
2126 if (
2127 isinstance(value, (list, tuple))
2128 and len(value) == 2
2129 and all(isinstance(v, (float, int)) for v in value)
2130 ):
2131 try:
2132 return fractions.Fraction(*value)
2133 except ValueError:
2134 raise ParsingError(
2135 "Can't parse `%s/%s` as `fraction`", value[0], value[1]
2136 ) from None
2137 except ZeroDivisionError:
2138 raise ParsingError(
2139 "Can't parse `%s/%s` as `fraction`, division by zero",
2140 value[0],
2141 value[1],
2142 ) from None
2143 if isinstance(value, (int, float, str, decimal.Decimal, fractions.Fraction)):
2144 try:
2145 return fractions.Fraction(value)
2146 except ValueError:
2147 raise ParsingError(
2148 "Can't parse `%r` as `fraction`",
2149 value,
2150 ) from None
2151 except ZeroDivisionError:
2152 raise ParsingError(
2153 "Can't parse `%r` as `fraction`, division by zero",
2154 value,
2155 ) from None
2156 raise ParsingError.type_mismatch(value, int, float, str, "a tuple of two ints")
2158 def to_json_schema(
2159 self, ctx: yuio.json_schema.JsonSchemaContext, /
2160 ) -> yuio.json_schema.JsonSchemaType:
2161 return ctx.add_type(
2162 fractions.Fraction,
2163 "Fraction",
2164 lambda: yuio.json_schema.Meta(
2165 yuio.json_schema.OneOf(
2166 [
2167 yuio.json_schema.Number(),
2168 yuio.json_schema.String(
2169 pattern=r"(?i)^[+-]?(\d+(\/\d+)?|(\d+\.\d*|\.?\d+)(e[+-]?\d+)?|inf(inity)?|nan)$"
2170 ),
2171 yuio.json_schema.Tuple(
2172 [yuio.json_schema.Number(), yuio.json_schema.Number()]
2173 ),
2174 ]
2175 ),
2176 title="Fraction",
2177 description="A rational number.",
2178 ),
2179 )
2181 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2182 assert self.assert_type(value)
2183 return str(value)
2186class Json(WrappingParser[T, Parser[T]], ValueParser[T], _t.Generic[T]):
2187 """Json(inner: Parser[T] | None = None, /)
2189 A parser that tries to parse value as JSON.
2191 This parser will load JSON strings into python objects.
2192 If ``inner`` parser is given, :class:`Json` will validate parsing results
2193 by calling :meth:`~Parser.parse_config` on the inner parser.
2195 :param inner:
2196 a parser used to convert and validate contents of json.
2198 """
2200 if _t.TYPE_CHECKING:
2202 @_t.overload
2203 def __new__(cls, inner: Parser[T], /) -> Json[T]: ...
2205 @_t.overload
2206 def __new__(cls, /) -> Json[yuio.json_schema.JsonValue]: ...
2208 def __new__(cls, inner: Parser[T] | None = None, /) -> Json[_t.Any]: ...
2210 def __init__(
2211 self,
2212 inner: Parser[T] | None = None,
2213 /,
2214 ):
2215 super().__init__(inner, object)
2217 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
2218 self._inner = parser
2219 return self
2221 def parse(self, value: str) -> T:
2222 try:
2223 config_value = json.loads(value)
2224 except json.JSONDecodeError as e:
2225 raise ParsingError(
2226 "Can't parse `%r` as `JsonValue`: %s", value, e
2227 ) from None
2228 return self.parse_config(config_value)
2230 def parse_config(self, value: object) -> T:
2231 if self._inner_raw is not None:
2232 return self._inner_raw.parse_config(value)
2233 else:
2234 return _t.cast(T, value)
2236 def check_type(self, value: object, /) -> _t.TypeGuard[T]:
2237 if self._inner_raw is not None:
2238 return self._inner_raw.check_type(value)
2239 else:
2240 return True # xxx: make a better check
2242 def to_json_schema(
2243 self, ctx: yuio.json_schema.JsonSchemaContext, /
2244 ) -> yuio.json_schema.JsonSchemaType:
2245 if self._inner_raw is not None:
2246 return self._inner_raw.to_json_schema(ctx)
2247 else:
2248 return yuio.json_schema.Any()
2250 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2251 assert self.assert_type(value)
2252 if self._inner_raw is not None:
2253 return self._inner_raw.to_json_value(value)
2254 return value
2256 def __repr__(self):
2257 if self._inner_raw is not None:
2258 return f"{self.__class__.__name__}({self._inner_raw!r})"
2259 else:
2260 return super().__repr__()
2263class DateTime(ValueParser[datetime.datetime]):
2264 """
2265 Parse a datetime in ISO ('YYYY-MM-DD HH:MM:SS') format.
2267 """
2269 def __init__(self):
2270 super().__init__(datetime.datetime)
2272 def parse(self, value: str, /) -> datetime.datetime:
2273 try:
2274 return datetime.datetime.fromisoformat(value)
2275 except ValueError:
2276 raise ParsingError("Can't parse `%r` as `datetime`", value) from None
2278 def parse_config(self, value: object, /) -> datetime.datetime:
2279 if isinstance(value, datetime.datetime):
2280 return value
2281 elif isinstance(value, str):
2282 return self.parse(value)
2283 else:
2284 raise ParsingError.type_mismatch(value, str)
2286 def describe(self) -> str | None:
2287 return "YYYY-MM-DD[ HH:MM:SS]"
2289 def to_json_schema(
2290 self, ctx: yuio.json_schema.JsonSchemaContext, /
2291 ) -> yuio.json_schema.JsonSchemaType:
2292 return ctx.add_type(
2293 datetime.datetime,
2294 "DateTime",
2295 lambda: yuio.json_schema.Meta(
2296 yuio.json_schema.String(
2297 pattern=(
2298 r"^"
2299 r"("
2300 r"\d{4}-W\d{2}(-\d)?"
2301 r"|\d{4}-\d{2}-\d{2}"
2302 r"|\d{4}W\d{2}\d?"
2303 r"|\d{4}\d{2}\d{2}"
2304 r")"
2305 r"("
2306 r"[T ]"
2307 r"\d{2}(:\d{2}(:\d{2}(.\d{3}(\d{3})?)?)?)?"
2308 r"([+-]\d{2}(:\d{2}(:\d{2}(.\d{3}(\d{3})?)?)?)?|Z)?"
2309 r")?"
2310 r"$"
2311 )
2312 ),
2313 title="DateTime",
2314 description="ISO 8601 datetime.",
2315 ),
2316 )
2318 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2319 assert self.assert_type(value)
2320 return str(value)
2323class Date(ValueParser[datetime.date]):
2324 """
2325 Parse a date in ISO ('YYYY-MM-DD') format.
2327 """
2329 def __init__(self):
2330 super().__init__(datetime.date)
2332 def parse(self, value: str, /) -> datetime.date:
2333 try:
2334 return datetime.date.fromisoformat(value)
2335 except ValueError:
2336 raise ParsingError("Can't parse `%r` as `date`", value) from None
2338 def parse_config(self, value: object, /) -> datetime.date:
2339 if isinstance(value, datetime.datetime):
2340 return value.date()
2341 elif isinstance(value, datetime.date):
2342 return value
2343 elif isinstance(value, str):
2344 return self.parse(value)
2345 else:
2346 raise ParsingError.type_mismatch(value, str)
2348 def describe(self) -> str | None:
2349 return "YYYY-MM-DD"
2351 def to_json_schema(
2352 self, ctx: yuio.json_schema.JsonSchemaContext, /
2353 ) -> yuio.json_schema.JsonSchemaType:
2354 return ctx.add_type(
2355 datetime.date,
2356 "Date",
2357 lambda: yuio.json_schema.Meta(
2358 yuio.json_schema.String(
2359 pattern=(
2360 r"^"
2361 r"("
2362 r"\d{4}-W\d{2}(-\d)?"
2363 r"|\d{4}-\d{2}-\d{2}"
2364 r"|\d{4}W\d{2}\d?"
2365 r"|\d{4}\d{2}\d{2}"
2366 r")"
2367 r"$"
2368 )
2369 ),
2370 title="Date",
2371 description="ISO 8601 date.",
2372 ),
2373 )
2375 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2376 assert self.assert_type(value)
2377 return str(value)
2380class Time(ValueParser[datetime.time]):
2381 """
2382 Parse a time in ISO ('HH:MM:SS') format.
2384 """
2386 def __init__(self):
2387 super().__init__(datetime.time)
2389 def parse(self, value: str, /) -> datetime.time:
2390 try:
2391 return datetime.time.fromisoformat(value)
2392 except ValueError:
2393 raise ParsingError("Can't parse `%r` as `time`", value) from None
2395 def parse_config(self, value: object, /) -> datetime.time:
2396 if isinstance(value, datetime.datetime):
2397 return value.time()
2398 elif isinstance(value, datetime.time):
2399 return value
2400 elif isinstance(value, str):
2401 return self.parse(value)
2402 else:
2403 raise ParsingError.type_mismatch(value, str)
2405 def describe(self) -> str | None:
2406 return "HH:MM:SS"
2408 def to_json_schema(
2409 self, ctx: yuio.json_schema.JsonSchemaContext, /
2410 ) -> yuio.json_schema.JsonSchemaType:
2411 return ctx.add_type(
2412 datetime.time,
2413 "Time",
2414 lambda: yuio.json_schema.Meta(
2415 yuio.json_schema.String(
2416 pattern=(
2417 r"^"
2418 r"\d{2}(:\d{2}(:\d{2}(.\d{3}(\d{3})?)?)?)?"
2419 r"([+-]\d{2}(:\d{2}(:\d{2}(.\d{3}(\d{3})?)?)?)?|Z)?"
2420 r"$"
2421 )
2422 ),
2423 title="Time",
2424 description="ISO 8601 time.",
2425 ),
2426 )
2428 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2429 assert self.assert_type(value)
2430 return str(value)
2433_UNITS_MAP = (
2434 ("days", ("d", "day", "days")),
2435 ("seconds", ("s", "sec", "secs", "second", "seconds")),
2436 ("microseconds", ("us", "u", "micro", "micros", "microsecond", "microseconds")),
2437 ("milliseconds", ("ms", "l", "milli", "millis", "millisecond", "milliseconds")),
2438 ("minutes", ("m", "min", "mins", "minute", "minutes")),
2439 ("hours", ("h", "hr", "hrs", "hour", "hours")),
2440 ("weeks", ("w", "week", "weeks")),
2441)
2443_UNITS = {unit: name for name, units in _UNITS_MAP for unit in units}
2445_TIMEDELTA_RE = re.compile(
2446 r"""
2447 # General format: -1 day, -01:00:00.000000
2448 ^
2449 (?:([+-]?)\s*((?:\d+\s*[a-z]+\s*)+))?
2450 (?:,\s*)?
2451 (?:([+-]?)\s*(\d+):(\d?\d)(?::(\d?\d)(?:\.(?:(\d\d\d)(\d\d\d)?))?)?)?
2452 $
2453 """,
2454 re.VERBOSE | re.IGNORECASE,
2455)
2457_COMPONENT_RE = re.compile(r"(\d+)\s*([a-z]+)\s*")
2460class TimeDelta(ValueParser[datetime.timedelta]):
2461 """
2462 Parse a time delta.
2464 """
2466 def __init__(self):
2467 super().__init__(datetime.timedelta)
2469 def parse(self, value: str, /) -> datetime.timedelta:
2470 value = value.strip()
2472 if not value:
2473 raise ParsingError("Got an empty `timedelta`")
2474 if value.endswith(","):
2475 raise ParsingError(
2476 "Can't parse `%r` as `timedelta`, trailing coma is not allowed", value
2477 )
2478 if value.startswith(","):
2479 raise ParsingError(
2480 "Can't parse `%r` as `timedelta`, leading coma is not allowed", value
2481 )
2483 if match := _TIMEDELTA_RE.match(value):
2484 (
2485 c_sign_s,
2486 components_s,
2487 t_sign_s,
2488 hour,
2489 minute,
2490 second,
2491 millisecond,
2492 microsecond,
2493 ) = match.groups()
2494 else:
2495 raise ParsingError("Can't parse `%r` as `timedelta`", value)
2497 c_sign_s = -1 if c_sign_s == "-" else 1
2498 t_sign_s = -1 if t_sign_s == "-" else 1
2500 kwargs = {u: 0 for u, _ in _UNITS_MAP}
2502 if components_s:
2503 for num, unit in _COMPONENT_RE.findall(components_s):
2504 if unit_key := _UNITS.get(unit.lower()):
2505 kwargs[unit_key] += int(num)
2506 else:
2507 raise ParsingError(
2508 "Can't parse `%r` as `timedelta`, unknown unit `%r`",
2509 value,
2510 unit,
2511 )
2513 timedelta = c_sign_s * datetime.timedelta(**kwargs)
2515 timedelta += t_sign_s * datetime.timedelta(
2516 hours=int(hour or "0"),
2517 minutes=int(minute or "0"),
2518 seconds=int(second or "0"),
2519 milliseconds=int(millisecond or "0"),
2520 microseconds=int(microsecond or "0"),
2521 )
2523 return timedelta
2525 def parse_config(self, value: object, /) -> datetime.timedelta:
2526 if isinstance(value, datetime.timedelta):
2527 return value
2528 elif isinstance(value, str):
2529 return self.parse(value)
2530 else:
2531 raise ParsingError.type_mismatch(value, str)
2533 def describe(self) -> str | None:
2534 return "[+|-]HH:MM:SS"
2536 def to_json_schema(
2537 self, ctx: yuio.json_schema.JsonSchemaContext, /
2538 ) -> yuio.json_schema.JsonSchemaType:
2539 return ctx.add_type(
2540 datetime.date,
2541 "TimeDelta",
2542 lambda: yuio.json_schema.Meta(
2543 yuio.json_schema.String(
2544 # save yourself some trouble, paste this into https://regexper.com/
2545 pattern=(
2546 r"^(([+-]?\s*(\d+\s*(d|day|days|s|sec|secs|second|seconds"
2547 r"|us|u|micro|micros|microsecond|microseconds|ms|l|milli|"
2548 r"millis|millisecond|milliseconds|m|min|mins|minute|minutes"
2549 r"|h|hr|hrs|hour|hours|w|week|weeks)\s*)+)(,\s*)?"
2550 r"([+-]?\s*\d+:\d?\d(:\d?\d(\.\d\d\d(\d\d\d)?)?)?)"
2551 r"|([+-]?\s*\d+:\d?\d(:\d?\d(\.\d\d\d(\d\d\d)?)?)?)"
2552 r"|([+-]?\s*(\d+\s*(d|day|days|s|sec|secs|second|seconds"
2553 r"|us|u|micro|micros|microsecond|microseconds|ms|l|milli"
2554 r"|millis|millisecond|milliseconds|m|min|mins|minute|minutes"
2555 r"|h|hr|hrs|hour|hours|w|week|weeks)\s*)+))$"
2556 )
2557 ),
2558 title="Time delta. General format: '[+-] [M weeks] [N days] [+-]HH:MM:SS'",
2559 description=".",
2560 ),
2561 )
2563 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2564 assert self.assert_type(value)
2565 return str(value)
2568class Path(ValueParser[pathlib.Path]):
2569 """
2570 Parse a file system path, return a :class:`pathlib.Path`.
2572 :param extensions:
2573 list of allowed file extensions, including preceding dots.
2575 """
2577 def __init__(
2578 self,
2579 /,
2580 *,
2581 extensions: str | _t.Collection[str] | None = None,
2582 ):
2583 self.__extensions = [extensions] if isinstance(extensions, str) else extensions
2584 super().__init__(pathlib.Path)
2586 def parse(self, value: str, /) -> pathlib.Path:
2587 path = pathlib.Path(value).expanduser().resolve().absolute()
2588 self._validate(path)
2589 return path
2591 def parse_config(self, value: object, /) -> pathlib.Path:
2592 if not isinstance(value, str):
2593 raise ParsingError.type_mismatch(value, str)
2594 return self.parse(value)
2596 def describe(self) -> str | None:
2597 if self.__extensions is not None:
2598 desc = "|".join(f"<*{e}>" for e in self.__extensions)
2599 if len(self.__extensions) > 1:
2600 desc = f"{{{desc}}}"
2601 return desc
2602 else:
2603 return super().describe()
2605 def _validate(self, value: pathlib.Path, /):
2606 if self.__extensions is not None and not any(
2607 value.name.endswith(ext) for ext in self.__extensions
2608 ):
2609 raise ParsingError(
2610 "<c path>%s</c> should have extension %s",
2611 value,
2612 yuio.string.Or(self.__extensions),
2613 )
2615 def completer(self) -> yuio.complete.Completer | None:
2616 return yuio.complete.File(extensions=self.__extensions)
2618 def to_json_schema(
2619 self, ctx: yuio.json_schema.JsonSchemaContext, /
2620 ) -> yuio.json_schema.JsonSchemaType:
2621 return yuio.json_schema.String()
2623 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2624 assert self.assert_type(value)
2625 return str(value)
2628class NonExistentPath(Path):
2629 """
2630 Parse a file system path and verify that it doesn't exist.
2632 :param extensions:
2633 list of allowed file extensions, including preceding dots.
2635 """
2637 def _validate(self, value: pathlib.Path, /):
2638 super()._validate(value)
2640 if value.exists():
2641 raise ParsingError("<c path>%s</c> already exists", value)
2644class ExistingPath(Path):
2645 """
2646 Parse a file system path and verify that it exists.
2648 :param extensions:
2649 list of allowed file extensions, including preceding dots.
2651 """
2653 def _validate(self, value: pathlib.Path, /):
2654 super()._validate(value)
2656 if not value.exists():
2657 raise ParsingError("<c path>%s</c> doesn't exist", value)
2660class File(ExistingPath):
2661 """
2662 Parse a file system path and verify that it points to a regular file.
2664 :param extensions:
2665 list of allowed file extensions, including preceding dots.
2667 """
2669 def _validate(self, value: pathlib.Path, /):
2670 super()._validate(value)
2672 if not value.is_file():
2673 raise ParsingError("<c path>%s</c> is not a file", value)
2676class Dir(ExistingPath):
2677 """
2678 Parse a file system path and verify that it points to a directory.
2680 """
2682 def __init__(self):
2683 # Disallow passing `extensions`.
2684 super().__init__()
2686 def _validate(self, value: pathlib.Path, /):
2687 super()._validate(value)
2689 if not value.is_dir():
2690 raise ParsingError("<c path>%s</c> is not a directory", value)
2692 def completer(self) -> yuio.complete.Completer | None:
2693 return yuio.complete.Dir()
2696class GitRepo(Dir):
2697 """
2698 Parse a file system path and verify that it points to a git repository.
2700 This parser just checks that the given directory has
2701 a subdirectory named ``.git``.
2703 """
2705 def _validate(self, value: pathlib.Path, /):
2706 super()._validate(value)
2708 if not value.joinpath(".git").is_dir():
2709 raise ParsingError("<c path>%s</c> is not a git repository root", value)
2712class Secret(Map[SecretValue[T], T], _t.Generic[T]):
2713 """
2714 Wraps result of the inner parser into :class:`~yuio.secret.SecretValue`
2715 and ensures that :func:`yuio.io.ask` doesn't show value as user enters it.
2717 """
2719 if _t.TYPE_CHECKING:
2721 @_t.overload
2722 def __new__(cls, inner: Parser[T], /) -> Secret[T]: ...
2724 @_t.overload
2725 def __new__(cls, /) -> PartialParser: ...
2727 def __new__(cls, *args, **kwargs) -> _t.Any: ...
2729 def __init__(self, inner: Parser[U] | None = None, /):
2730 super().__init__(inner, SecretValue, lambda x: x.data)
2732 def parse(self, value: str) -> SecretValue[T]:
2733 try:
2734 return super().parse(value)
2735 except ParsingError:
2736 # Error messages can contain secret value, hide them.
2737 raise ParsingError("Error when parsing secret data")
2739 def parse_many(self, value: _t.Sequence[str]) -> SecretValue[T]:
2740 try:
2741 return super().parse_many(value)
2742 except ParsingError:
2743 # Error messages can contain secret value, hide them.
2744 raise ParsingError("Error when parsing secret data")
2746 def parse_config(self, value: object) -> SecretValue[T]:
2747 try:
2748 return super().parse_config(value)
2749 except ParsingError:
2750 # Error messages can contain secret value, hide them.
2751 raise ParsingError("Error when parsing secret data")
2753 def describe_value(self, value: object, /) -> str:
2754 return "***"
2756 def widget(
2757 self,
2758 default: object | yuio.Missing,
2759 input_description: str | None,
2760 default_description: str | None,
2761 /,
2762 ) -> yuio.widget.Widget[SecretValue[T] | yuio.Missing]:
2763 return _secret_widget(self, default, input_description, default_description)
2765 def is_secret(self) -> bool:
2766 return True
2769class CollectionParser(
2770 WrappingParser[C, Parser[T]], ValueParser[C], PartialParser, _t.Generic[C, T]
2771):
2772 """CollectionParser(inner: Parser[T] | None, /, *, ty: type[C], ctor: typing.Callable[[typing.Iterable[T]], C], iter: typing.Callable[[C], typing.Iterable[T]] = iter, config_type: type[C2] | tuple[type[C2], ...] = list, config_type_iter: typing.Callable[[C2], typing.Iterable[T]] = iter, delimiter: str | None = None)
2774 A base class for implementing collection parsing. It will split a string
2775 by the given delimiter, parse each item using a subparser, and then pass
2776 the result to the given constructor.
2778 :param inner:
2779 parser that will be used to parse collection items.
2780 :param ty:
2781 type of the collection that this parser returns.
2782 :param ctor:
2783 factory of instances of the collection that this parser returns.
2784 It should take an iterable of parsed items, and return a collection.
2785 :param iter:
2786 a function that is used to get an iterator from a collection.
2787 This defaults to :func:`iter`, but sometimes it may be different.
2788 For example, :class:`Dict` is implemented as a collection of pairs,
2789 and its ``iter`` is :meth:`dict.items`.
2790 :param config_type:
2791 type of a collection that we expect to find when parsing a config.
2792 This will usually be a list.
2793 :param config_type_iter:
2794 a function that is used to get an iterator from a config value.
2795 :param delimiter:
2796 delimiter that will be passed to :py:meth:`str.split`.
2798 The above parameters are exposed via protected attributes:
2799 ``self._inner``, ``self._ty``, etc.
2801 For example, let's implement a :class:`list` parser
2802 that repeats each element twice:
2804 .. code-block::
2806 from typing import Iterable, Generic
2808 class DoubleList(CollectionParser[list[T], T], Generic[T]):
2809 def __init__(self, inner: Parser[T], /, *, delimiter: str | None = None):
2810 super().__init__(inner, ty=list, ctor=self._ctor, delimiter=delimiter)
2812 @staticmethod
2813 def _ctor(values: Iterable[T]) -> list[T]:
2814 return [x for value in values for x in [value, value]]
2816 def to_json_schema(self, ctx: yuio.json_schema.JsonSchemaContext, /) -> yuio.json_schema.JsonSchemaType:
2817 return {"type": "array", "items": self._inner.to_json_schema(ctx)}
2819 """
2821 _allow_completing_duplicates: typing.ClassVar[bool] = True
2822 """
2823 If set to :data:`False`, autocompletion will not suggest item duplicates.
2825 """
2827 def __init__(
2828 self,
2829 inner: Parser[T] | None,
2830 /,
2831 *,
2832 ty: type[C],
2833 ctor: _t.Callable[[_t.Iterable[T]], C],
2834 iter: _t.Callable[[C], _t.Iterable[T]] = iter,
2835 config_type: type[C2] | tuple[type[C2], ...] = list,
2836 config_type_iter: _t.Callable[[C2], _t.Iterable[T]] = iter,
2837 delimiter: str | None = None,
2838 ):
2839 if delimiter == "":
2840 raise ValueError("empty delimiter")
2842 #: See class parameters for more details.
2843 self._ty = ty
2844 #: See class parameters for more details.
2845 self._ctor = ctor
2846 #: See class parameters for more details.
2847 self._iter = iter
2848 #: See class parameters for more details.
2849 self._config_type = config_type
2850 #: See class parameters for more details.
2851 self._config_type_iter = config_type_iter
2852 #: See class parameters for more details.
2853 self._delimiter = delimiter
2855 super().__init__(inner, ty)
2857 def wrap(self: P, parser: Parser[_t.Any]) -> P:
2858 result = super().wrap(parser)
2859 result._inner = parser._inner # type: ignore
2860 return result
2862 def parse(self, value: str, /) -> C:
2863 return self.parse_many(value.split(self._delimiter))
2865 def parse_many(self, value: _t.Sequence[str], /) -> C:
2866 return self._ctor(self._inner.parse(item) for item in value)
2868 def supports_parse_many(self) -> bool:
2869 return True
2871 def parse_config(self, value: object, /) -> C:
2872 if not isinstance(value, self._config_type):
2873 expected = self._config_type
2874 if not isinstance(expected, tuple):
2875 expected = (expected,)
2876 raise ParsingError.type_mismatch(value, *expected)
2878 return self._ctor(
2879 self._inner.parse_config(item) for item in self._config_type_iter(value)
2880 )
2882 def get_nargs(self) -> _t.Literal["+", "*", "?"] | int | None:
2883 return "*"
2885 def describe(self) -> str | None:
2886 delimiter = self._delimiter or " "
2887 value = self._inner.describe_or_def()
2889 return f"{value}[{delimiter}{value}[{delimiter}...]]"
2891 def describe_many(self) -> str | tuple[str, ...]:
2892 return self._inner.describe_or_def()
2894 def describe_value(self, value: object, /) -> str:
2895 assert self.assert_type(value)
2897 return (self._delimiter or " ").join(
2898 self._inner.describe_value(item) for item in self._iter(value)
2899 )
2901 def options(self) -> _t.Collection[yuio.widget.Option[C]] | None:
2902 return None
2904 def completer(self) -> yuio.complete.Completer | None:
2905 completer = self._inner.completer()
2906 return (
2907 yuio.complete.List(
2908 completer,
2909 delimiter=self._delimiter,
2910 allow_duplicates=self._allow_completing_duplicates,
2911 )
2912 if completer is not None
2913 else None
2914 )
2916 def widget(
2917 self,
2918 default: object | yuio.Missing,
2919 input_description: str | None,
2920 default_description: str | None,
2921 /,
2922 ) -> yuio.widget.Widget[C | yuio.Missing]:
2923 completer = self.completer()
2924 return _WidgetResultMapper(
2925 self,
2926 input_description,
2927 default,
2928 (
2929 yuio.widget.InputWithCompletion(
2930 completer,
2931 placeholder=default_description or "",
2932 )
2933 if completer is not None
2934 else yuio.widget.Input(
2935 placeholder=default_description or "",
2936 )
2937 ),
2938 )
2940 def is_secret(self) -> bool:
2941 return self._inner.is_secret()
2943 def __repr__(self):
2944 if self._inner_raw is not None:
2945 return f"{self.__class__.__name__}({self._inner_raw!r})"
2946 else:
2947 return self.__class__.__name__
2950class List(CollectionParser[list[T], T], _t.Generic[T]):
2951 """List(inner: Parser[T], /, *, delimiter: str | None = None)
2953 Parser for lists.
2955 Will split a string by the given delimiter, and parse each item
2956 using a subparser.
2958 :param inner:
2959 inner parser that will be used to parse list items.
2960 :param delimiter:
2961 delimiter that will be passed to :py:meth:`str.split`.
2963 """
2965 if _t.TYPE_CHECKING:
2967 @_t.overload
2968 def __new__(
2969 cls, inner: Parser[T], /, *, delimiter: str | None = None
2970 ) -> List[T]: ...
2972 @_t.overload
2973 def __new__(cls, /, *, delimiter: str | None = None) -> PartialParser: ...
2975 def __new__(cls, *args, **kwargs) -> _t.Any: ...
2977 def __init__(
2978 self,
2979 inner: Parser[T] | None = None,
2980 /,
2981 *,
2982 delimiter: str | None = None,
2983 ):
2984 super().__init__(inner, ty=list, ctor=list, delimiter=delimiter)
2986 def to_json_schema(
2987 self, ctx: yuio.json_schema.JsonSchemaContext, /
2988 ) -> yuio.json_schema.JsonSchemaType:
2989 return yuio.json_schema.Array(self._inner.to_json_schema(ctx))
2991 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
2992 assert self.assert_type(value)
2993 return [self._inner.to_json_value(item) for item in value]
2996class Set(CollectionParser[set[T], T], _t.Generic[T]):
2997 """Set(inner: Parser[T], /, *, delimiter: str | None = None)
2999 Parser for sets.
3001 Will split a string by the given delimiter, and parse each item
3002 using a subparser.
3004 :param inner:
3005 inner parser that will be used to parse set items.
3006 :param delimiter:
3007 delimiter that will be passed to :py:meth:`str.split`.
3009 """
3011 if _t.TYPE_CHECKING:
3013 @_t.overload
3014 def __new__(
3015 cls, inner: Parser[T], /, *, delimiter: str | None = None
3016 ) -> Set[T]: ...
3018 @_t.overload
3019 def __new__(cls, /, *, delimiter: str | None = None) -> PartialParser: ...
3021 def __new__(cls, *args, **kwargs) -> _t.Any: ...
3023 _allow_completing_duplicates = False
3025 def __init__(
3026 self,
3027 inner: Parser[T] | None = None,
3028 /,
3029 *,
3030 delimiter: str | None = None,
3031 ):
3032 super().__init__(inner, ty=set, ctor=set, delimiter=delimiter)
3034 def widget(
3035 self,
3036 default: object | yuio.Missing,
3037 input_description: str | None,
3038 default_description: str | None,
3039 /,
3040 ) -> yuio.widget.Widget[set[T] | yuio.Missing]:
3041 options = self._inner.options()
3042 if options is not None and len(options) <= 25:
3043 return yuio.widget.Map(yuio.widget.Multiselect(list(options)), set)
3044 else:
3045 return super().widget(default, input_description, default_description)
3047 def to_json_schema(
3048 self, ctx: yuio.json_schema.JsonSchemaContext, /
3049 ) -> yuio.json_schema.JsonSchemaType:
3050 return yuio.json_schema.Array(
3051 self._inner.to_json_schema(ctx), unique_items=True
3052 )
3054 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3055 assert self.assert_type(value)
3056 return [self._inner.to_json_value(item) for item in value]
3059class FrozenSet(CollectionParser[frozenset[T], T], _t.Generic[T]):
3060 """FrozenSet(inner: Parser[T], /, *, delimiter: str | None = None)
3062 Parser for frozen sets.
3064 Will split a string by the given delimiter, and parse each item
3065 using a subparser.
3067 :param inner:
3068 inner parser that will be used to parse set items.
3069 :param delimiter:
3070 delimiter that will be passed to :py:meth:`str.split`.
3072 """
3074 if _t.TYPE_CHECKING:
3076 @_t.overload
3077 def __new__(
3078 cls, inner: Parser[T], /, *, delimiter: str | None = None
3079 ) -> FrozenSet[T]: ...
3081 @_t.overload
3082 def __new__(cls, /, *, delimiter: str | None = None) -> PartialParser: ...
3084 def __new__(cls, *args, **kwargs) -> _t.Any: ...
3086 _allow_completing_duplicates = False
3088 def __init__(
3089 self,
3090 inner: Parser[T] | None = None,
3091 /,
3092 *,
3093 delimiter: str | None = None,
3094 ):
3095 super().__init__(inner, ty=frozenset, ctor=frozenset, delimiter=delimiter)
3097 def to_json_schema(
3098 self, ctx: yuio.json_schema.JsonSchemaContext, /
3099 ) -> yuio.json_schema.JsonSchemaType:
3100 return yuio.json_schema.Array(
3101 self._inner.to_json_schema(ctx), unique_items=True
3102 )
3104 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3105 assert self.assert_type(value)
3106 return [self._inner.to_json_value(item) for item in value]
3109class Dict(CollectionParser[dict[K, V], tuple[K, V]], _t.Generic[K, V]):
3110 """Dict(key: Parser[K], value: Parser[V], /, *, delimiter: str | None = None, pair_delimiter: str = ":")
3112 Parser for dicts.
3114 Will split a string by the given delimiter, and parse each item
3115 using a :py:class:`Tuple` parser.
3117 :param key:
3118 inner parser that will be used to parse dict keys.
3119 :param value:
3120 inner parser that will be used to parse dict values.
3121 :param delimiter:
3122 delimiter that will be passed to :py:meth:`str.split`.
3123 :param pair_delimiter:
3124 delimiter that will be used to split key-value elements.
3126 """
3128 if _t.TYPE_CHECKING:
3130 @_t.overload
3131 def __new__(
3132 cls,
3133 key: Parser[K],
3134 value: Parser[V],
3135 /,
3136 *,
3137 delimiter: str | None = None,
3138 pair_delimiter: str = ":",
3139 ) -> Dict[K, V]: ...
3141 @_t.overload
3142 def __new__(
3143 cls,
3144 /,
3145 *,
3146 delimiter: str | None = None,
3147 pair_delimiter: str = ":",
3148 ) -> PartialParser: ...
3150 def __new__(cls, *args, **kwargs) -> _t.Any: ...
3152 _allow_completing_duplicates = False
3154 def __init__(
3155 self,
3156 key: Parser[K] | None = None,
3157 value: Parser[V] | None = None,
3158 /,
3159 *,
3160 delimiter: str | None = None,
3161 pair_delimiter: str = ":",
3162 ):
3163 self.__pair_delimiter = pair_delimiter
3164 super().__init__(
3165 Tuple(key, value, delimiter=pair_delimiter) if key and value else None,
3166 ty=dict,
3167 ctor=dict,
3168 iter=dict.items,
3169 config_type=(dict, list),
3170 config_type_iter=self.__config_type_iter,
3171 delimiter=delimiter,
3172 )
3174 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
3175 result = super().wrap(parser)
3176 setattr(result._inner, "_Tuple__delimiter", self.__pair_delimiter)
3177 return result
3179 @staticmethod
3180 def __config_type_iter(x) -> _t.Iterator[tuple[K, V]]:
3181 if isinstance(x, dict):
3182 return iter(x.items())
3183 else:
3184 return iter(x)
3186 def to_json_schema(
3187 self, ctx: yuio.json_schema.JsonSchemaContext, /
3188 ) -> yuio.json_schema.JsonSchemaType:
3189 key_schema = self._inner._inner[0].to_json_schema(ctx) # type: ignore
3190 value_schema = self._inner._inner[1].to_json_schema(ctx) # type: ignore
3191 return yuio.json_schema.Dict(key_schema, value_schema)
3193 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3194 assert self.assert_type(value)
3195 items = _t.cast(
3196 list[tuple[yuio.json_schema.JsonValue, yuio.json_schema.JsonValue]],
3197 [self._inner.to_json_value(item) for item in value.items()],
3198 )
3200 if all(isinstance(k, str) for k, _ in items):
3201 return dict(_t.cast(list[tuple[str, yuio.json_schema.JsonValue]], items))
3202 else:
3203 return items
3206class Tuple(
3207 WrappingParser[TU, tuple[Parser[object], ...]],
3208 ValueParser[TU],
3209 PartialParser,
3210 _t.Generic[TU],
3211):
3212 """Tuple(*parsers: Parser[T], delimiter: str | None = None)
3214 Parser for tuples of fixed lengths.
3216 :param parsers:
3217 parsers for each tuple element.
3218 :param delimiter:
3219 delimiter that will be passed to :py:meth:`str.split`.
3221 """
3223 # See the links below for an explanation of shy this is so ugly:
3224 # https://github.com/python/typing/discussions/1450
3225 # https://github.com/python/typing/issues/1216
3226 if _t.TYPE_CHECKING:
3227 T1 = _t.TypeVar("T1")
3228 T2 = _t.TypeVar("T2")
3229 T3 = _t.TypeVar("T3")
3230 T4 = _t.TypeVar("T4")
3231 T5 = _t.TypeVar("T5")
3232 T6 = _t.TypeVar("T6")
3233 T7 = _t.TypeVar("T7")
3234 T8 = _t.TypeVar("T8")
3235 T9 = _t.TypeVar("T9")
3236 T10 = _t.TypeVar("T10")
3238 @_t.overload
3239 def __new__(
3240 cls,
3241 /,
3242 *,
3243 delimiter: str | None = None,
3244 ) -> PartialParser: ...
3246 @_t.overload
3247 def __new__(
3248 cls,
3249 p1: Parser[T1],
3250 /,
3251 *,
3252 delimiter: str | None = None,
3253 ) -> Tuple[tuple[T1]]: ...
3255 @_t.overload
3256 def __new__(
3257 cls,
3258 p1: Parser[T1],
3259 p2: Parser[T2],
3260 /,
3261 *,
3262 delimiter: str | None = None,
3263 ) -> Tuple[tuple[T1, T2]]: ...
3265 @_t.overload
3266 def __new__(
3267 cls,
3268 p1: Parser[T1],
3269 p2: Parser[T2],
3270 p3: Parser[T3],
3271 /,
3272 *,
3273 delimiter: str | None = None,
3274 ) -> Tuple[tuple[T1, T2, T3]]: ...
3276 @_t.overload
3277 def __new__(
3278 cls,
3279 p1: Parser[T1],
3280 p2: Parser[T2],
3281 p3: Parser[T3],
3282 p4: Parser[T4],
3283 /,
3284 *,
3285 delimiter: str | None = None,
3286 ) -> Tuple[tuple[T1, T2, T3, T4]]: ...
3288 @_t.overload
3289 def __new__(
3290 cls,
3291 p1: Parser[T1],
3292 p2: Parser[T2],
3293 p3: Parser[T3],
3294 p4: Parser[T4],
3295 p5: Parser[T5],
3296 /,
3297 *,
3298 delimiter: str | None = None,
3299 ) -> Tuple[tuple[T1, T2, T3, T4, T5]]: ...
3301 @_t.overload
3302 def __new__(
3303 cls,
3304 p1: Parser[T1],
3305 p2: Parser[T2],
3306 p3: Parser[T3],
3307 p4: Parser[T4],
3308 p5: Parser[T5],
3309 p6: Parser[T6],
3310 /,
3311 *,
3312 delimiter: str | None = None,
3313 ) -> Tuple[tuple[T1, T2, T3, T4, T5, T6]]: ...
3315 @_t.overload
3316 def __new__(
3317 cls,
3318 p1: Parser[T1],
3319 p2: Parser[T2],
3320 p3: Parser[T3],
3321 p4: Parser[T4],
3322 p5: Parser[T5],
3323 p6: Parser[T6],
3324 p7: Parser[T7],
3325 /,
3326 *,
3327 delimiter: str | None = None,
3328 ) -> Tuple[tuple[T1, T2, T3, T4, T5, T6, T7]]: ...
3330 @_t.overload
3331 def __new__(
3332 cls,
3333 p1: Parser[T1],
3334 p2: Parser[T2],
3335 p3: Parser[T3],
3336 p4: Parser[T4],
3337 p5: Parser[T5],
3338 p6: Parser[T6],
3339 p7: Parser[T7],
3340 p8: Parser[T8],
3341 /,
3342 *,
3343 delimiter: str | None = None,
3344 ) -> Tuple[tuple[T1, T2, T3, T4, T5, T6, T7, T8]]: ...
3346 @_t.overload
3347 def __new__(
3348 cls,
3349 p1: Parser[T1],
3350 p2: Parser[T2],
3351 p3: Parser[T3],
3352 p4: Parser[T4],
3353 p5: Parser[T5],
3354 p6: Parser[T6],
3355 p7: Parser[T7],
3356 p8: Parser[T8],
3357 p9: Parser[T9],
3358 /,
3359 *,
3360 delimiter: str | None = None,
3361 ) -> Tuple[tuple[T1, T2, T3, T4, T5, T6, T7, T8, T9]]: ...
3363 @_t.overload
3364 def __new__(
3365 cls,
3366 p1: Parser[T1],
3367 p2: Parser[T2],
3368 p3: Parser[T3],
3369 p4: Parser[T4],
3370 p5: Parser[T5],
3371 p6: Parser[T6],
3372 p7: Parser[T7],
3373 p8: Parser[T8],
3374 p9: Parser[T9],
3375 p10: Parser[T10],
3376 /,
3377 *,
3378 delimiter: str | None = None,
3379 ) -> Tuple[tuple[T1, T2, T3, T4, T5, T6, T7, T8, T9, T10]]: ...
3381 @_t.overload
3382 def __new__(
3383 cls,
3384 p1: Parser[T1],
3385 p2: Parser[T2],
3386 p3: Parser[T3],
3387 p4: Parser[T4],
3388 p5: Parser[T5],
3389 p6: Parser[T6],
3390 p7: Parser[T7],
3391 p8: Parser[T8],
3392 p9: Parser[T9],
3393 p10: Parser[T10],
3394 p11: Parser[object],
3395 *tail: Parser[object],
3396 delimiter: str | None = None,
3397 ) -> Tuple[tuple[_t.Any, ...]]: ...
3399 def __new__(cls, *args, **kwargs) -> _t.Any: ...
3401 def __init__(
3402 self,
3403 *parsers: Parser[_t.Any],
3404 delimiter: str | None = None,
3405 ):
3406 if delimiter == "":
3407 raise ValueError("empty delimiter")
3408 self.__delimiter = delimiter
3409 super().__init__(parsers or None, tuple)
3411 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
3412 result = super().wrap(parser)
3413 result._inner = parser._inner # type: ignore
3414 return result
3416 def parse(self, value: str, /) -> TU:
3417 items = value.split(self.__delimiter, maxsplit=len(self._inner) - 1)
3418 return self.parse_many(items)
3420 def parse_many(self, value: _t.Sequence[str], /) -> TU:
3421 if len(value) != len(self._inner):
3422 raise ParsingError(
3423 "Expected %s element%s, got %s: `%r`",
3424 len(self._inner),
3425 "" if len(self._inner) == 1 else "s",
3426 len(value),
3427 value,
3428 )
3430 return _t.cast(
3431 TU,
3432 tuple(parser.parse(item) for parser, item in zip(self._inner, value)),
3433 )
3435 def parse_config(self, value: object, /) -> TU:
3436 if not isinstance(value, (list, tuple)):
3437 raise ParsingError.type_mismatch(value, list, tuple)
3438 elif len(value) != len(self._inner):
3439 raise ParsingError(
3440 "Expected %s element%s, got %s: `%r`",
3441 len(self._inner),
3442 "" if len(self._inner) == 1 else "s",
3443 len(value),
3444 value,
3445 )
3447 return _t.cast(
3448 TU,
3449 tuple(
3450 parser.parse_config(item) for parser, item in zip(self._inner, value)
3451 ),
3452 )
3454 def supports_parse_many(self) -> bool:
3455 return True
3457 def get_nargs(self) -> _t.Literal["+", "*", "?"] | int | None:
3458 return len(self._inner)
3460 def describe(self) -> str | None:
3461 delimiter = self.__delimiter or " "
3462 desc = [parser.describe_or_def() for parser in self._inner]
3463 return delimiter.join(desc)
3465 def describe_many(self) -> str | tuple[str, ...]:
3466 return tuple(parser.describe_or_def() for parser in self._inner)
3468 def describe_value(self, value: object, /) -> str:
3469 assert self.assert_type(value)
3471 delimiter = self.__delimiter or " "
3472 desc = [parser.describe_value(item) for parser, item in zip(self._inner, value)]
3474 return delimiter.join(desc)
3476 def options(self) -> _t.Collection[yuio.widget.Option[TU]] | None:
3477 return None
3479 def completer(self) -> yuio.complete.Completer | None:
3480 return yuio.complete.Tuple(
3481 *[parser.completer() or yuio.complete.Empty() for parser in self._inner],
3482 delimiter=self.__delimiter,
3483 )
3485 def widget(
3486 self,
3487 default: object | yuio.Missing,
3488 input_description: str | None,
3489 default_description: str | None,
3490 /,
3491 ) -> yuio.widget.Widget[TU | yuio.Missing]:
3492 completer = self.completer()
3494 return _WidgetResultMapper(
3495 self,
3496 input_description,
3497 default,
3498 (
3499 yuio.widget.InputWithCompletion(
3500 completer,
3501 placeholder=default_description or "",
3502 )
3503 if completer is not None
3504 else yuio.widget.Input(
3505 placeholder=default_description or "",
3506 )
3507 ),
3508 )
3510 def to_json_schema(
3511 self, ctx: yuio.json_schema.JsonSchemaContext, /
3512 ) -> yuio.json_schema.JsonSchemaType:
3513 return yuio.json_schema.Tuple(
3514 [parser.to_json_schema(ctx) for parser in self._inner]
3515 )
3517 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3518 assert self.assert_type(value)
3519 return [parser.to_json_value(item) for parser, item in zip(self._inner, value)]
3521 def is_secret(self) -> bool:
3522 return any(parser.is_secret() for parser in self._inner)
3524 def __repr__(self):
3525 if self._inner_raw is not None:
3526 return f"{self.__class__.__name__}{self._inner_raw!r}"
3527 else:
3528 return self.__class__.__name__
3531class Optional(MappingParser[T | None, T], _t.Generic[T]):
3532 """Optional(inner: Parser[T], /)
3534 Parser for optional values.
3536 Allows handling :data:`None`\\ s when parsing config. Does not change how strings
3537 are parsed, though.
3539 :param inner:
3540 a parser used to extract and validate contents of an optional.
3542 """
3544 if _t.TYPE_CHECKING:
3546 @_t.overload
3547 def __new__(cls, inner: Parser[T], /) -> Optional[T]: ...
3549 @_t.overload
3550 def __new__(cls, /) -> PartialParser: ...
3552 def __new__(cls, *args, **kwargs) -> _t.Any: ...
3554 def __init__(self, inner: Parser[T] | None = None, /):
3555 super().__init__(inner)
3557 def parse(self, value: str, /) -> T | None:
3558 return self._inner.parse(value)
3560 def parse_many(self, value: _t.Sequence[str], /) -> T | None:
3561 return self._inner.parse_many(value)
3563 def parse_config(self, value: object, /) -> T | None:
3564 if value is None:
3565 return None
3566 return self._inner.parse_config(value)
3568 def check_type(self, value: object, /) -> _t.TypeGuard[T | None]:
3569 if value is None:
3570 return True
3571 return self._inner.check_type(value)
3573 def describe_value(self, value: object, /) -> str:
3574 if value is None:
3575 return "<none>"
3576 return self._inner.describe_value(value)
3578 def options(self) -> _t.Collection[yuio.widget.Option[T | None]] | None:
3579 return self._inner.options()
3581 def widget(
3582 self,
3583 default: object | yuio.Missing,
3584 input_description: str | None,
3585 default_description: str | None,
3586 /,
3587 ) -> yuio.widget.Widget[T | yuio.Missing]:
3588 return self._inner.widget(default, input_description, default_description)
3590 def to_json_schema(
3591 self, ctx: yuio.json_schema.JsonSchemaContext, /
3592 ) -> yuio.json_schema.JsonSchemaType:
3593 return yuio.json_schema.OneOf(
3594 [self._inner.to_json_schema(ctx), yuio.json_schema.Null()]
3595 )
3597 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3598 if value is None:
3599 return None
3600 else:
3601 return self._inner.to_json_value(value)
3604class Union(WrappingParser[T, tuple[Parser[T], ...]], ValueParser[T], _t.Generic[T]):
3605 """Union(*parsers: Parser[T])
3607 Tries several parsers and returns the first successful result.
3609 .. warning::
3611 Order of parsers matters. Since parsers are tried in the same order as they're
3612 given, make sure to put parsers that are likely to succeed at the end.
3614 For example, this parser will always return a string because :class:`Str`
3615 can't fail::
3617 >>> parser = Union(Str(), Int()) # Always returns a string!
3618 >>> parser.parse("10")
3619 '10'
3621 To fix this, put :class:`Str` at the end so that :class:`Int` is tried first::
3623 >>> parser = Union(Int(), Str())
3624 >>> parser.parse("10")
3625 10
3626 >>> parser.parse("not an int")
3627 'not an int'
3629 """
3631 # See the links below for an explanation of shy this is so ugly:
3632 # https://github.com/python/typing/discussions/1450
3633 # https://github.com/python/typing/issues/1216
3634 if _t.TYPE_CHECKING:
3635 T1 = _t.TypeVar("T1")
3636 T2 = _t.TypeVar("T2")
3637 T3 = _t.TypeVar("T3")
3638 T4 = _t.TypeVar("T4")
3639 T5 = _t.TypeVar("T5")
3640 T6 = _t.TypeVar("T6")
3641 T7 = _t.TypeVar("T7")
3642 T8 = _t.TypeVar("T8")
3643 T9 = _t.TypeVar("T9")
3644 T10 = _t.TypeVar("T10")
3646 @_t.overload
3647 def __new__(
3648 cls,
3649 /,
3650 ) -> PartialParser: ...
3652 @_t.overload
3653 def __new__(
3654 cls,
3655 p1: Parser[T1],
3656 /,
3657 ) -> Union[T1]: ...
3659 @_t.overload
3660 def __new__(
3661 cls,
3662 p1: Parser[T1],
3663 p2: Parser[T2],
3664 /,
3665 ) -> Union[T1 | T2]: ...
3667 @_t.overload
3668 def __new__(
3669 cls,
3670 p1: Parser[T1],
3671 p2: Parser[T2],
3672 p3: Parser[T3],
3673 /,
3674 ) -> Union[T1 | T2 | T3]: ...
3676 @_t.overload
3677 def __new__(
3678 cls,
3679 p1: Parser[T1],
3680 p2: Parser[T2],
3681 p3: Parser[T3],
3682 p4: Parser[T4],
3683 /,
3684 ) -> Union[T1 | T2 | T3 | T4]: ...
3686 @_t.overload
3687 def __new__(
3688 cls,
3689 p1: Parser[T1],
3690 p2: Parser[T2],
3691 p3: Parser[T3],
3692 p4: Parser[T4],
3693 p5: Parser[T5],
3694 /,
3695 ) -> Union[T1 | T2 | T3 | T4 | T5]: ...
3697 @_t.overload
3698 def __new__(
3699 cls,
3700 p1: Parser[T1],
3701 p2: Parser[T2],
3702 p3: Parser[T3],
3703 p4: Parser[T4],
3704 p5: Parser[T5],
3705 p6: Parser[T6],
3706 /,
3707 ) -> Union[T1 | T2 | T3 | T4 | T5 | T6]: ...
3709 @_t.overload
3710 def __new__(
3711 cls,
3712 p1: Parser[T1],
3713 p2: Parser[T2],
3714 p3: Parser[T3],
3715 p4: Parser[T4],
3716 p5: Parser[T5],
3717 p6: Parser[T6],
3718 p7: Parser[T7],
3719 /,
3720 ) -> Union[T1 | T2 | T3 | T4 | T5 | T6 | T7]: ...
3722 @_t.overload
3723 def __new__(
3724 cls,
3725 p1: Parser[T1],
3726 p2: Parser[T2],
3727 p3: Parser[T3],
3728 p4: Parser[T4],
3729 p5: Parser[T5],
3730 p6: Parser[T6],
3731 p7: Parser[T7],
3732 p8: Parser[T8],
3733 /,
3734 ) -> Union[T1 | T2 | T3 | T4 | T5 | T6 | T7 | T8]: ...
3736 @_t.overload
3737 def __new__(
3738 cls,
3739 p1: Parser[T1],
3740 p2: Parser[T2],
3741 p3: Parser[T3],
3742 p4: Parser[T4],
3743 p5: Parser[T5],
3744 p6: Parser[T6],
3745 p7: Parser[T7],
3746 p8: Parser[T8],
3747 p9: Parser[T9],
3748 /,
3749 ) -> Union[T1 | T2 | T3 | T4 | T5 | T6 | T7 | T8 | T9]: ...
3751 @_t.overload
3752 def __new__(
3753 cls,
3754 p1: Parser[T1],
3755 p2: Parser[T2],
3756 p3: Parser[T3],
3757 p4: Parser[T4],
3758 p5: Parser[T5],
3759 p6: Parser[T6],
3760 p7: Parser[T7],
3761 p8: Parser[T8],
3762 p9: Parser[T9],
3763 p10: Parser[T10],
3764 /,
3765 ) -> Union[T1 | T2 | T3 | T4 | T5 | T6 | T7 | T8 | T9 | T10]: ...
3767 @_t.overload
3768 def __new__(
3769 cls,
3770 p1: Parser[T1],
3771 p2: Parser[T2],
3772 p3: Parser[T3],
3773 p4: Parser[T4],
3774 p5: Parser[T5],
3775 p6: Parser[T6],
3776 p7: Parser[T7],
3777 p8: Parser[T8],
3778 p9: Parser[T9],
3779 p10: Parser[T10],
3780 p11: Parser[object],
3781 *parsers: Parser[object],
3782 ) -> Union[object]: ...
3784 def __new__(cls, *args, **kwargs) -> _t.Any: ...
3786 def __init__(self, *parsers: Parser[_t.Any]):
3787 super().__init__(parsers or None, object)
3789 def wrap(self, parser: Parser[_t.Any]) -> Parser[_t.Any]:
3790 result = super().wrap(parser)
3791 result._inner = parser._inner # type: ignore
3792 return result
3794 def parse(self, value: str, /) -> T:
3795 errors: list[tuple[Parser[object], ParsingError]] = []
3796 for parser in self._inner:
3797 try:
3798 return parser.parse(value)
3799 except ParsingError as e:
3800 errors.append((parser, e))
3802 msgs = []
3803 for parser, error in errors:
3804 msgs.append(
3805 yuio.string.Format(
3806 " Trying as `%s`:\n%s",
3807 parser.describe_or_def(),
3808 yuio.string.Indent(error, indent=4),
3809 )
3810 )
3811 raise ParsingError(
3812 "Can't parse `%r`:\n%s", value, yuio.string.JoinStr(msgs, sep="\n")
3813 )
3815 def parse_config(self, value: object, /) -> T:
3816 errors: list[tuple[Parser[object], ParsingError]] = []
3817 for parser in self._inner:
3818 try:
3819 return parser.parse_config(value)
3820 except ParsingError as e:
3821 errors.append((parser, e))
3823 msgs = []
3824 for parser, error in errors:
3825 msgs.append(
3826 yuio.string.Format(
3827 " Trying as `%s`:\n%s",
3828 parser.describe(),
3829 yuio.string.Indent(error, indent=4),
3830 )
3831 )
3832 raise ParsingError(
3833 "Can't parse `%r`:\n%s", value, yuio.string.JoinStr(msgs, sep="\n")
3834 )
3836 def check_type(self, value: object, /) -> _t.TypeGuard[T]:
3837 return any(parser.check_type(value) for parser in self._inner)
3839 def describe(self) -> str | None:
3840 if len(self._inner) > 1:
3842 def strip_curly_brackets(desc: str):
3843 if desc.startswith("{") and desc.endswith("}") and "|" in desc:
3844 s = desc[1:-1]
3845 if "{" not in s and "}" not in s:
3846 return s
3847 return desc
3849 desc = "|".join(
3850 strip_curly_brackets(parser.describe_or_def()) for parser in self._inner
3851 )
3852 desc = f"{{{desc}}}"
3853 else:
3854 desc = "|".join(parser.describe_or_def() for parser in self._inner)
3855 return desc
3857 def describe_value(self, value: object, /) -> str:
3858 for parser in self._inner:
3859 try:
3860 return parser.describe_value(value)
3861 except TypeError:
3862 pass
3864 raise TypeError(
3865 f"parser {self} can't handle value of type {_t.type_repr(type(value))}"
3866 )
3868 def options(self) -> _t.Collection[yuio.widget.Option[T]] | None:
3869 result = []
3870 got_options = False
3871 for parser in self._inner:
3872 if options := parser.options():
3873 result.extend(options)
3874 got_options = True
3875 if got_options:
3876 return result
3877 else:
3878 return None
3880 def completer(self) -> yuio.complete.Completer | None:
3881 completers = []
3882 for parser in self._inner:
3883 if completer := parser.completer():
3884 completers.append((parser.describe(), completer))
3885 if not completers:
3886 return None
3887 elif len(completers) == 1:
3888 return completers[0][1]
3889 else:
3890 return yuio.complete.Alternative(completers)
3892 def to_json_schema(
3893 self, ctx: yuio.json_schema.JsonSchemaContext, /
3894 ) -> yuio.json_schema.JsonSchemaType:
3895 return yuio.json_schema.OneOf(
3896 [parser.to_json_schema(ctx) for parser in self._inner]
3897 )
3899 def to_json_value(self, value: object, /) -> yuio.json_schema.JsonValue:
3900 for parser in self._inner:
3901 try:
3902 return parser.to_json_value(value)
3903 except TypeError:
3904 pass
3906 raise TypeError(
3907 f"parser {self} can't handle value of type {_t.type_repr(type(value))}"
3908 )
3910 def is_secret(self) -> bool:
3911 return any(parser.is_secret() for parser in self._inner)
3913 def __repr__(self):
3914 return f"{self.__class__.__name__}{self._inner_raw!r}"
3917class _BoundImpl(ValidatingParser[T], _t.Generic[T, Cmp]):
3918 def __init__(
3919 self,
3920 inner: Parser[T] | None,
3921 /,
3922 *,
3923 lower: Cmp | None = None,
3924 lower_inclusive: Cmp | None = None,
3925 upper: Cmp | None = None,
3926 upper_inclusive: Cmp | None = None,
3927 mapper: _t.Callable[[T], Cmp],
3928 desc: str,
3929 ):
3930 super().__init__(inner)
3932 self._lower_bound: Cmp | None = None
3933 self._lower_bound_is_inclusive: bool = True
3934 self._upper_bound: Cmp | None = None
3935 self._upper_bound_is_inclusive: bool = True
3937 if lower is not None and lower_inclusive is not None:
3938 raise TypeError(
3939 "lower and lower_inclusive cannot be given at the same time"
3940 )
3941 elif lower is not None:
3942 self._lower_bound = lower
3943 self._lower_bound_is_inclusive = False
3944 elif lower_inclusive is not None:
3945 self._lower_bound = lower_inclusive
3946 self._lower_bound_is_inclusive = True
3948 if upper is not None and upper_inclusive is not None:
3949 raise TypeError(
3950 "upper and upper_inclusive cannot be given at the same time"
3951 )
3952 elif upper is not None:
3953 self._upper_bound = upper
3954 self._upper_bound_is_inclusive = False
3955 elif upper_inclusive is not None:
3956 self._upper_bound = upper_inclusive
3957 self._upper_bound_is_inclusive = True
3959 self.__mapper = mapper
3960 self.__desc = desc
3962 def _validate(self, value: T, /):
3963 mapped = self.__mapper(value)
3965 if self._lower_bound is not None:
3966 if self._lower_bound_is_inclusive and mapped < self._lower_bound:
3967 raise ParsingError(
3968 "%s should be greater than or equal to `%s`: `%r`",
3969 self.__desc,
3970 self._lower_bound,
3971 value,
3972 )
3973 elif not self._lower_bound_is_inclusive and not self._lower_bound < mapped:
3974 raise ParsingError(
3975 "%s should be greater than `%s`: `%r`",
3976 self.__desc,
3977 self._lower_bound,
3978 value,
3979 )
3981 if self._upper_bound is not None:
3982 if self._upper_bound_is_inclusive and self._upper_bound < mapped:
3983 raise ParsingError(
3984 "%s should be lesser than or equal to `%s`: `%r`",
3985 self.__desc,
3986 self._upper_bound,
3987 value,
3988 )
3989 elif not self._upper_bound_is_inclusive and not mapped < self._upper_bound:
3990 raise ParsingError(
3991 "%s should be lesser than `%s`: `%r`",
3992 self.__desc,
3993 self._upper_bound,
3994 value,
3995 )
3997 def __repr__(self):
3998 desc = ""
3999 if self._lower_bound is not None:
4000 desc += repr(self._lower_bound)
4001 desc += " <= " if self._lower_bound_is_inclusive else " < "
4002 mapper_name = getattr(self.__mapper, "__name__")
4003 if mapper_name and mapper_name != "<lambda>":
4004 desc += mapper_name
4005 else:
4006 desc += "x"
4007 if self._upper_bound is not None:
4008 desc += " <= " if self._upper_bound_is_inclusive else " < "
4009 desc += repr(self._upper_bound)
4010 return f"{self.__class__.__name__}({self.__wrapped_parser__!r}, {desc})"
4013class Bound(_BoundImpl[Cmp, Cmp], _t.Generic[Cmp]):
4014 """Bound(inner: Parser[Cmp], /, *, lower: Cmp | None = None, lower_inclusive: Cmp | None = None, upper: Cmp | None = None, upper_inclusive: Cmp | None = None)
4016 Check that value is upper- or lower-bound by some constraints.
4018 :param inner:
4019 parser whose result will be validated.
4020 :param lower:
4021 set lower bound for value, so we require that ``value > lower``.
4022 Can't be given if ``lower_inclusive`` is also given.
4023 :param lower_inclusive:
4024 set lower bound for value, so we require that ``value >= lower``.
4025 Can't be given if ``lower`` is also given.
4026 :param upper:
4027 set upper bound for value, so we require that ``value < upper``.
4028 Can't be given if ``upper_inclusive`` is also given.
4029 :param upper_inclusive:
4030 set upper bound for value, so we require that ``value <= upper``.
4031 Can't be given if ``upper`` is also given.
4032 :example:
4033 ::
4035 >>> # Int in range `0 < x <= 1`:
4036 >>> Bound(Int(), lower=0, upper_inclusive=1)
4037 Bound(Int, 0 < x <= 1)
4039 """
4041 if _t.TYPE_CHECKING:
4043 @_t.overload
4044 def __new__(
4045 cls,
4046 inner: Parser[Cmp],
4047 /,
4048 *,
4049 lower: Cmp | None = None,
4050 lower_inclusive: Cmp | None = None,
4051 upper: Cmp | None = None,
4052 upper_inclusive: Cmp | None = None,
4053 ) -> Bound[Cmp]: ...
4055 @_t.overload
4056 def __new__(
4057 cls,
4058 *,
4059 lower: Cmp | None = None,
4060 lower_inclusive: Cmp | None = None,
4061 upper: Cmp | None = None,
4062 upper_inclusive: Cmp | None = None,
4063 ) -> PartialParser: ...
4065 def __new__(cls, *args, **kwargs) -> _t.Any: ...
4067 def __init__(
4068 self,
4069 inner: Parser[Cmp] | None = None,
4070 /,
4071 *,
4072 lower: Cmp | None = None,
4073 lower_inclusive: Cmp | None = None,
4074 upper: Cmp | None = None,
4075 upper_inclusive: Cmp | None = None,
4076 ):
4077 super().__init__(
4078 inner,
4079 lower=lower,
4080 lower_inclusive=lower_inclusive,
4081 upper=upper,
4082 upper_inclusive=upper_inclusive,
4083 mapper=lambda x: x,
4084 desc="Value",
4085 )
4087 def to_json_schema(
4088 self, ctx: yuio.json_schema.JsonSchemaContext, /
4089 ) -> yuio.json_schema.JsonSchemaType:
4090 bound = {}
4091 if isinstance(self._lower_bound, (int, float)):
4092 bound[
4093 "minimum" if self._lower_bound_is_inclusive else "exclusiveMinimum"
4094 ] = self._lower_bound
4095 if isinstance(self._upper_bound, (int, float)):
4096 bound[
4097 "maximum" if self._upper_bound_is_inclusive else "exclusiveMaximum"
4098 ] = self._upper_bound
4099 if bound:
4100 return yuio.json_schema.AllOf(
4101 [super().to_json_schema(ctx), yuio.json_schema.Opaque(bound)]
4102 )
4103 else:
4104 return super().to_json_schema(ctx)
4107@_t.overload
4108def Gt(inner: Parser[Cmp], bound: Cmp, /) -> Bound[Cmp]: ...
4109@_t.overload
4110def Gt(bound: _t.SupportsLt[_t.Any], /) -> PartialParser: ...
4111def Gt(*args) -> _t.Any:
4112 """Gt(inner: Parser[Cmp], bound: Cmp, /)
4114 Alias for :class:`Bound`.
4116 :param inner:
4117 parser whose result will be validated.
4118 :param bound:
4119 lower bound for parsed values.
4121 """
4123 if len(args) == 1:
4124 return Bound(lower=args[0])
4125 elif len(args) == 2:
4126 return Bound(args[0], lower=args[1])
4127 else:
4128 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
4131@_t.overload
4132def Ge(inner: Parser[Cmp], bound: Cmp, /) -> Bound[Cmp]: ...
4133@_t.overload
4134def Ge(bound: _t.SupportsLt[_t.Any], /) -> PartialParser: ...
4135def Ge(*args) -> _t.Any:
4136 """Ge(inner: Parser[Cmp], bound: Cmp, /)
4138 Alias for :class:`Bound`.
4140 :param inner:
4141 parser whose result will be validated.
4142 :param bound:
4143 lower inclusive bound for parsed values.
4145 """
4147 if len(args) == 1:
4148 return Bound(lower_inclusive=args[0])
4149 elif len(args) == 2:
4150 return Bound(args[0], lower_inclusive=args[1])
4151 else:
4152 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
4155@_t.overload
4156def Lt(inner: Parser[Cmp], bound: Cmp, /) -> Bound[Cmp]: ...
4157@_t.overload
4158def Lt(bound: _t.SupportsLt[_t.Any], /) -> PartialParser: ...
4159def Lt(*args) -> _t.Any:
4160 """Lt(inner: Parser[Cmp], bound: Cmp, /)
4162 Alias for :class:`Bound`.
4164 :param inner:
4165 parser whose result will be validated.
4166 :param bound:
4167 upper bound for parsed values.
4169 """
4171 if len(args) == 1:
4172 return Bound(upper=args[0])
4173 elif len(args) == 2:
4174 return Bound(args[0], upper=args[1])
4175 else:
4176 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
4179@_t.overload
4180def Le(inner: Parser[Cmp], bound: Cmp, /) -> Bound[Cmp]: ...
4181@_t.overload
4182def Le(bound: _t.SupportsLt[_t.Any], /) -> PartialParser: ...
4183def Le(*args) -> _t.Any:
4184 """Le(inner: Parser[Cmp], bound: Cmp, /)
4186 Alias for :class:`Bound`.
4188 :param inner:
4189 parser whose result will be validated.
4190 :param bound:
4191 upper inclusive bound for parsed values.
4193 """
4195 if len(args) == 1:
4196 return Bound(upper_inclusive=args[0])
4197 elif len(args) == 2:
4198 return Bound(args[0], upper_inclusive=args[1])
4199 else:
4200 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
4203class LenBound(_BoundImpl[Sz, int], _t.Generic[Sz]):
4204 """LenBound(inner: Parser[Sz], /, *, lower: int | None = None, lower_inclusive: int | None = None, upper: int | None = None, upper_inclusive: int | None = None)
4206 Check that length of a value is upper- or lower-bound by some constraints.
4208 The signature is the same as of the :class:`Bound` class.
4210 :param inner:
4211 parser whose result will be validated.
4212 :param lower:
4213 set lower bound for value's length, so we require that ``len(value) > lower``.
4214 Can't be given if ``lower_inclusive`` is also given.
4215 :param lower_inclusive:
4216 set lower bound for value's length, so we require that ``len(value) >= lower``.
4217 Can't be given if ``lower`` is also given.
4218 :param upper:
4219 set upper bound for value's length, so we require that ``len(value) < upper``.
4220 Can't be given if ``upper_inclusive`` is also given.
4221 :param upper_inclusive:
4222 set upper bound for value's length, so we require that ``len(value) <= upper``.
4223 Can't be given if ``upper`` is also given.
4224 :example:
4225 ::
4227 >>> # List of up to five ints:
4228 >>> LenBound(List(Int()), upper_inclusive=5)
4229 LenBound(List(Int), len <= 5)
4231 """
4233 if _t.TYPE_CHECKING:
4235 @_t.overload
4236 def __new__(
4237 cls,
4238 inner: Parser[Sz],
4239 /,
4240 *,
4241 lower: int | None = None,
4242 lower_inclusive: int | None = None,
4243 upper: int | None = None,
4244 upper_inclusive: int | None = None,
4245 ) -> LenBound[Sz]: ...
4247 @_t.overload
4248 def __new__(
4249 cls,
4250 /,
4251 *,
4252 lower: int | None = None,
4253 lower_inclusive: int | None = None,
4254 upper: int | None = None,
4255 upper_inclusive: int | None = None,
4256 ) -> PartialParser: ...
4258 def __new__(cls, *args, **kwargs) -> _t.Any: ...
4260 def __init__(
4261 self,
4262 inner: Parser[Sz] | None = None,
4263 /,
4264 *,
4265 lower: int | None = None,
4266 lower_inclusive: int | None = None,
4267 upper: int | None = None,
4268 upper_inclusive: int | None = None,
4269 ):
4270 super().__init__(
4271 inner,
4272 lower=lower,
4273 lower_inclusive=lower_inclusive,
4274 upper=upper,
4275 upper_inclusive=upper_inclusive,
4276 mapper=len,
4277 desc="Length of value",
4278 )
4280 def get_nargs(self) -> _t.Literal["+", "*", "?"] | int | None:
4281 if not self._inner.supports_parse_many():
4282 # somebody bound len of a string?
4283 return self._inner.get_nargs()
4285 lower = self._lower_bound
4286 if lower is not None and not self._lower_bound_is_inclusive:
4287 lower += 1
4288 upper = self._upper_bound
4289 if upper is not None and not self._upper_bound_is_inclusive:
4290 upper -= 1
4292 if lower == upper and lower is not None:
4293 return lower
4294 elif lower is not None and lower > 0:
4295 return "+"
4296 else:
4297 return "*"
4299 def to_json_schema(
4300 self, ctx: yuio.json_schema.JsonSchemaContext, /
4301 ) -> yuio.json_schema.JsonSchemaType:
4302 bound = {}
4303 min_bound = self._lower_bound
4304 if not self._lower_bound_is_inclusive and min_bound is not None:
4305 min_bound -= 1
4306 if min_bound is not None:
4307 bound["minLength"] = bound["minItems"] = bound["minProperties"] = min_bound
4308 max_bound = self._upper_bound
4309 if not self._upper_bound_is_inclusive and max_bound is not None:
4310 max_bound += 1
4311 if max_bound is not None:
4312 bound["maxLength"] = bound["maxItems"] = bound["maxProperties"] = max_bound
4313 if bound:
4314 return yuio.json_schema.AllOf(
4315 [super().to_json_schema(ctx), yuio.json_schema.Opaque(bound)]
4316 )
4317 else:
4318 return super().to_json_schema(ctx)
4321@_t.overload
4322def LenGt(inner: Parser[Sz], bound: int, /) -> LenBound[Sz]: ...
4323@_t.overload
4324def LenGt(bound: int, /) -> PartialParser: ...
4325def LenGt(*args) -> _t.Any:
4326 """LenGt(inner: Parser[Sz], bound: int, /)
4328 Alias for :class:`LenBound`.
4330 :param inner:
4331 parser whose result will be validated.
4332 :param bound:
4333 lower bound for parsed values's length.
4335 """
4337 if len(args) == 1:
4338 return LenBound(lower=args[0])
4339 elif len(args) == 2:
4340 return LenBound(args[0], lower=args[1])
4341 else:
4342 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
4345@_t.overload
4346def LenGe(inner: Parser[Sz], bound: int, /) -> LenBound[Sz]: ...
4347@_t.overload
4348def LenGe(bound: int, /) -> PartialParser: ...
4349def LenGe(*args) -> _t.Any:
4350 """LenGe(inner: Parser[Sz], bound: int, /)
4352 Alias for :class:`LenBound`.
4354 :param inner:
4355 parser whose result will be validated.
4356 :param bound:
4357 lower inclusive bound for parsed values's length.
4359 """
4361 if len(args) == 1:
4362 return LenBound(lower_inclusive=args[0])
4363 elif len(args) == 2:
4364 return LenBound(args[0], lower_inclusive=args[1])
4365 else:
4366 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
4369@_t.overload
4370def LenLt(inner: Parser[Sz], bound: int, /) -> LenBound[Sz]: ...
4371@_t.overload
4372def LenLt(bound: int, /) -> PartialParser: ...
4373def LenLt(*args) -> _t.Any:
4374 """LenLt(inner: Parser[Sz], bound: int, /)
4376 Alias for :class:`LenBound`.
4378 :param inner:
4379 parser whose result will be validated.
4380 :param bound:
4381 upper bound for parsed values's length.
4383 """
4385 if len(args) == 1:
4386 return LenBound(upper=args[0])
4387 elif len(args) == 2:
4388 return LenBound(args[0], upper=args[1])
4389 else:
4390 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
4393@_t.overload
4394def LenLe(inner: Parser[Sz], bound: int, /) -> LenBound[Sz]: ...
4395@_t.overload
4396def LenLe(bound: int, /) -> PartialParser: ...
4397def LenLe(*args) -> _t.Any:
4398 """LenLe(inner: Parser[Sz], bound: int, /)
4400 Alias for :class:`LenBound`.
4402 :param inner:
4403 parser whose result will be validated.
4404 :param bound:
4405 upper inclusive bound for parsed values's length.
4407 """
4409 if len(args) == 1:
4410 return LenBound(upper_inclusive=args[0])
4411 elif len(args) == 2:
4412 return LenBound(args[0], upper_inclusive=args[1])
4413 else:
4414 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
4417class OneOf(ValidatingParser[T], _t.Generic[T]):
4418 """OneOf(inner: Parser[T], values: typing.Collection[T], /)
4420 Check that the parsed value is one of the given set of values.
4422 :param inner:
4423 parser whose result will be validated.
4424 :param values:
4425 collection of allowed values.
4426 :example:
4427 ::
4429 >>> # Accepts only strings 'A', 'B', or 'C':
4430 >>> OneOf(Str(), ['A', 'B', 'C'])
4431 OneOf(Str)
4433 """
4435 if _t.TYPE_CHECKING:
4437 @_t.overload
4438 def __new__(cls, inner: Parser[T], values: _t.Collection[T], /) -> OneOf[T]: ...
4440 @_t.overload
4441 def __new__(cls, values: _t.Collection[T], /) -> PartialParser: ...
4443 def __new__(cls, *args) -> _t.Any: ...
4445 def __init__(self, *args):
4446 inner: Parser[T] | None
4447 values: _t.Collection[T]
4448 if len(args) == 1:
4449 inner, values = None, args[0]
4450 elif len(args) == 2:
4451 inner, values = args
4452 else:
4453 raise TypeError(f"expected 1 or 2 positional arguments, got {len(args)}")
4455 super().__init__(inner)
4457 self.__allowed_values = values
4459 def _validate(self, value: T, /):
4460 if value not in self.__allowed_values:
4461 raise ParsingError(
4462 "Can't parse `%r`, should be %s",
4463 value,
4464 yuio.string.JoinRepr.or_(self.__allowed_values),
4465 )
4467 def describe(self) -> str | None:
4468 desc = "|".join(self.describe_value(e) for e in self.__allowed_values)
4469 if len(desc) < 80:
4470 if len(self.__allowed_values) > 1:
4471 desc = f"{{{desc}}}"
4472 return desc
4473 else:
4474 return super().describe()
4476 def describe_or_def(self) -> str:
4477 desc = "|".join(self.describe_value(e) for e in self.__allowed_values)
4478 if len(desc) < 80:
4479 if len(self.__allowed_values) > 1:
4480 desc = f"{{{desc}}}"
4481 return desc
4482 else:
4483 return super().describe_or_def()
4485 def options(self) -> _t.Collection[yuio.widget.Option[T]] | None:
4486 return [
4487 yuio.widget.Option(e, self.describe_value(e)) for e in self.__allowed_values
4488 ]
4490 def completer(self) -> yuio.complete.Completer | None:
4491 return yuio.complete.Choice(
4492 [
4493 yuio.complete.Option(self.describe_value(e))
4494 for e in self.__allowed_values
4495 ]
4496 )
4498 def widget(
4499 self,
4500 default: object | yuio.Missing,
4501 input_description: str | None,
4502 default_description: str | None,
4503 /,
4504 ) -> yuio.widget.Widget[T | yuio.Missing]:
4505 allowed_values = list(self.__allowed_values)
4507 options = _t.cast(list[yuio.widget.Option[T | yuio.Missing]], self.options())
4509 if default is yuio.MISSING:
4510 default_index = 0
4511 elif default in allowed_values:
4512 default_index = list(allowed_values).index(default) # type: ignore
4513 else:
4514 options.insert(
4515 0, yuio.widget.Option(yuio.MISSING, default_description or str(default))
4516 )
4517 default_index = 0
4519 return yuio.widget.Choice(options, default_index=default_index)
4522class WithMeta(MappingParser[T, T], _t.Generic[T]):
4523 """WithMeta(inner: Parser[T], /, *, desc: str, completer: yuio.complete.Completer | None | ~yuio.MISSING = MISSING)
4525 Overrides inline help messages and other meta information of a wrapped parser.
4527 Inline help messages will show up as hints in autocompletion and widgets.
4529 :param inner:
4530 inner parser.
4531 :param desc:
4532 description override. This short string will be used in CLI, widgets, and
4533 completers to describe expected value.
4534 :param completer:
4535 completer override. Pass :data:`None` to disable completion.
4537 """
4539 if _t.TYPE_CHECKING:
4541 @_t.overload
4542 def __new__(
4543 cls,
4544 inner: Parser[T],
4545 /,
4546 *,
4547 desc: str | None = None,
4548 completer: yuio.complete.Completer | yuio.Missing | None = yuio.MISSING,
4549 ) -> MappingParser[T, T]: ...
4551 @_t.overload
4552 def __new__(
4553 cls,
4554 /,
4555 *,
4556 desc: str | None = None,
4557 completer: yuio.complete.Completer | yuio.Missing | None = yuio.MISSING,
4558 ) -> PartialParser: ...
4560 def __new__(cls, *args, **kwargs) -> _t.Any: ...
4562 def __init__(
4563 self,
4564 *args,
4565 desc: str | None = None,
4566 completer: yuio.complete.Completer | yuio.Missing | None = yuio.MISSING,
4567 ):
4568 inner: Parser[T] | None
4569 if not args:
4570 inner = None
4571 elif len(args) == 1:
4572 inner = args[0]
4573 else:
4574 raise TypeError(f"expected at most 1 positional argument, got {len(args)}")
4576 self.__desc = desc
4577 self.__completer = completer
4578 super().__init__(inner)
4580 def check_type(self, value: object, /) -> _t.TypeGuard[T]:
4581 return self._inner.check_type(value)
4583 def describe(self) -> str | None:
4584 return self.__desc or self._inner.describe()
4586 def describe_or_def(self) -> str:
4587 return self.__desc or self._inner.describe_or_def()
4589 def describe_many(self) -> str | tuple[str, ...]:
4590 return self.__desc or self._inner.describe_many()
4592 def describe_value(self, value: object, /) -> str:
4593 return self._inner.describe_value(value)
4595 def parse(self, value: str, /) -> T:
4596 return self._inner.parse(value)
4598 def parse_many(self, value: _t.Sequence[str], /) -> T:
4599 return self._inner.parse_many(value)
4601 def parse_config(self, value: object, /) -> T:
4602 return self._inner.parse_config(value)
4604 def options(self) -> _t.Collection[yuio.widget.Option[T]] | None:
4605 return self._inner.options()
4607 def completer(self) -> yuio.complete.Completer | None:
4608 if self.__completer is not yuio.MISSING:
4609 return self.__completer # type: ignore
4610 else:
4611 return self._inner.completer()
4613 def widget(
4614 self,
4615 default: object | yuio.Missing,
4616 input_description: str | None,
4617 default_description: str | None,
4618 /,
4619 ) -> yuio.widget.Widget[T | yuio.Missing]:
4620 return self._inner.widget(default, input_description, default_description)
4622 def to_json_value(self, value: object) -> yuio.json_schema.JsonValue:
4623 return self._inner.to_json_value(value)
4626class _WidgetResultMapper(yuio.widget.Map[T | yuio.Missing, str]):
4627 def __init__(
4628 self,
4629 parser: Parser[T],
4630 input_description: str | None,
4631 default: object | yuio.Missing,
4632 widget: yuio.widget.Widget[str],
4633 ):
4634 self._parser = parser
4635 self._input_description = input_description
4636 self._default = default
4637 super().__init__(widget, self.mapper)
4639 def mapper(self, s: str) -> T | yuio.Missing:
4640 if not s and self._default is not yuio.MISSING:
4641 return yuio.MISSING
4642 elif not s:
4643 raise ParsingError("Input is required")
4644 else:
4645 return self._parser.parse(s)
4647 @property
4648 def help_data(self):
4649 return super().help_data.with_action(
4650 group="Input Format",
4651 msg=self._input_description,
4652 prepend=True,
4653 prepend_group=True,
4654 )
4657def _secret_widget(
4658 parser: Parser[T],
4659 default: object | yuio.Missing,
4660 input_description: str | None,
4661 default_description: str | None,
4662 /,
4663) -> yuio.widget.Widget[T | yuio.Missing]:
4664 return _WidgetResultMapper(
4665 parser,
4666 input_description,
4667 default,
4668 (
4669 yuio.widget.SecretInput(
4670 placeholder=default_description or "",
4671 )
4672 ),
4673 )
4676_FromTypeHintCallback: _t.TypeAlias = _t.Callable[
4677 [type, type | None, tuple[object, ...]], Parser[object] | None
4678]
4681_FROM_TYPE_HINT_CALLBACKS: list[tuple[_FromTypeHintCallback, bool]] = []
4682_FROM_TYPE_HINT_DELIM_SUGGESTIONS: list[str | None] = [
4683 None,
4684 ",",
4685 "@",
4686 "/",
4687 "=",
4688]
4691class _FromTypeHintDepth(threading.local):
4692 def __init__(self):
4693 self.depth: int = 0
4694 self.uses_delim = False
4697_FROM_TYPE_HINT_DEPTH: _FromTypeHintDepth = _FromTypeHintDepth()
4700@_t.overload
4701def from_type_hint(ty: type[T], /) -> Parser[T]: ...
4702@_t.overload
4703def from_type_hint(ty: object, /) -> Parser[object]: ...
4704def from_type_hint(ty: _t.Any, /) -> Parser[object]:
4705 """from_type_hint(ty: type[T], /) -> Parser[T]
4707 Create parser from a type hint.
4709 :param ty:
4710 a type hint.
4712 This type hint should not contain strings or forward references. Make sure
4713 they're resolved before passing it to this function.
4714 :returns:
4715 a parser instance created from type hint.
4716 :raises:
4717 :class:`TypeError` if type hint contains forward references or types
4718 that don't have associated parsers.
4719 :example:
4720 ::
4722 >>> from_type_hint(list[int] | None)
4723 Optional(List(Int))
4725 """
4727 result = _from_type_hint(ty)
4728 setattr(result, "_Parser__typehint", ty)
4729 return result
4732def _from_type_hint(ty: _t.Any, /) -> Parser[object]:
4733 if isinstance(ty, (str, _t.ForwardRef)):
4734 raise TypeError(f"forward references are not supported here: {ty}")
4736 origin = _t.get_origin(ty)
4737 args = _t.get_args(ty)
4739 if origin is _t.Annotated:
4740 p = from_type_hint(args[0])
4741 for arg in args[1:]:
4742 if isinstance(arg, PartialParser):
4743 p = arg.wrap(p)
4744 return p
4746 for cb, uses_delim in _FROM_TYPE_HINT_CALLBACKS:
4747 prev_uses_delim = _FROM_TYPE_HINT_DEPTH.uses_delim
4748 _FROM_TYPE_HINT_DEPTH.uses_delim = uses_delim
4749 _FROM_TYPE_HINT_DEPTH.depth += uses_delim
4750 try:
4751 p = cb(ty, origin, args)
4752 if p is not None:
4753 return p
4754 finally:
4755 _FROM_TYPE_HINT_DEPTH.uses_delim = prev_uses_delim
4756 _FROM_TYPE_HINT_DEPTH.depth -= uses_delim
4758 if _t.is_union(origin):
4759 if is_optional := (type(None) in args):
4760 args = list(args)
4761 args.remove(type(None))
4762 if len(args) == 1:
4763 p = from_type_hint(args[0])
4764 else:
4765 p = Union(*[from_type_hint(arg) for arg in args])
4766 if is_optional:
4767 p = Optional(p)
4768 return p
4769 else:
4770 raise TypeError(f"unsupported type {_t.type_repr(ty)}")
4773@_t.overload
4774def register_type_hint_conversion(
4775 cb: _FromTypeHintCallback,
4776 /,
4777 *,
4778 uses_delim: bool = False,
4779) -> _FromTypeHintCallback: ...
4780@_t.overload
4781def register_type_hint_conversion(
4782 *,
4783 uses_delim: bool = False,
4784) -> _t.Callable[[_FromTypeHintCallback], _FromTypeHintCallback]: ...
4785def register_type_hint_conversion(
4786 cb: _FromTypeHintCallback | None = None,
4787 /,
4788 *,
4789 uses_delim: bool = False,
4790) -> (
4791 _FromTypeHintCallback | _t.Callable[[_FromTypeHintCallback], _FromTypeHintCallback]
4792):
4793 """
4794 Register a new converter from a type hint to a parser.
4796 This function takes a callback that accepts three positional arguments:
4798 - a type hint,
4799 - a type hint's origin (as defined by :func:`typing.get_origin`),
4800 - a type hint's args (as defined by :func:`typing.get_args`).
4802 The callback should return a parser if it can, or :data:`None` otherwise.
4804 All registered callbacks are tried in the same order
4805 as they were registered.
4807 If ``uses_delim`` is :data:`True`, callback can use
4808 :func:`suggest_delim_for_type_hint_conversion`.
4810 This function can be used as a decorator.
4812 :param cb:
4813 a function that should inspect a type hint and possibly return a parser.
4814 :param uses_delim:
4815 indicates that callback will use
4816 :func:`suggest_delim_for_type_hint_conversion`.
4817 :example:
4818 .. invisible-code-block: python
4820 class MyType: ...
4821 class MyTypeParser(ValueParser[MyType]):
4822 def __init__(self): super().__init__(MyType)
4823 def parse(self, value: str, /): ...
4824 def parse_config(self, value, /): ...
4825 def to_json_schema(self, ctx, /): ...
4826 def to_json_value(self, value, /): ...
4828 .. code-block:: python
4830 @register_type_hint_conversion
4831 def my_type_conversion(ty, origin, args):
4832 if ty is MyType:
4833 return MyTypeParser()
4834 else:
4835 return None
4837 ::
4839 >>> from_type_hint(MyType)
4840 MyTypeParser
4842 .. invisible-code-block: python
4844 del _FROM_TYPE_HINT_CALLBACKS[-1]
4846 """
4848 def registrar(cb: _FromTypeHintCallback):
4849 _FROM_TYPE_HINT_CALLBACKS.append((cb, uses_delim))
4850 return cb
4852 return registrar(cb) if cb is not None else registrar
4855def suggest_delim_for_type_hint_conversion() -> str | None:
4856 """
4857 Suggests a delimiter for use in type hint converters.
4859 When creating a parser for a collection of items based on a type hint,
4860 it is important to use different delimiters for nested collections.
4861 This function can suggest such a delimiter based on the current type hint's depth.
4863 .. invisible-code-block: python
4865 class MyCollection(list, _t.Generic[T]): ...
4866 class MyCollectionParser(CollectionParser[MyCollection[T], T], _t.Generic[T]):
4867 def __init__(self, inner: Parser[T], /, *, delimiter: _t.Optional[str] = None):
4868 super().__init__(inner, ty=MyCollection, ctor=MyCollection, delimiter=delimiter)
4869 def to_json_schema(self, ctx, /): ...
4870 def to_json_value(self, value, /): ...
4872 :raises:
4873 :class:`RuntimeError` if called from a type converter that
4874 didn't set ``uses_delim`` to :data:`True`.
4875 :example:
4876 .. code-block:: python
4878 @register_type_hint_conversion(uses_delim=True)
4879 def my_collection_conversion(ty, origin, args):
4880 if origin is MyCollection:
4881 return MyCollectionParser(
4882 from_type_hint(args[0]),
4883 delimiter=suggest_delim_for_type_hint_conversion(),
4884 )
4885 else:
4886 return None
4888 ::
4890 >>> parser = from_type_hint(MyCollection[MyCollection[str]])
4891 >>> parser
4892 MyCollectionParser(MyCollectionParser(Str))
4893 >>> parser._delimiter is None
4894 True
4895 >>> parser._inner._delimiter == ","
4896 True
4898 ..
4899 >>> del _FROM_TYPE_HINT_CALLBACKS[-1]
4901 """
4903 if not _FROM_TYPE_HINT_DEPTH.uses_delim:
4904 raise RuntimeError(
4905 "looking up delimiters is not available in this callback; did you forget"
4906 " to pass `uses_delim=True` when registering this callback?"
4907 )
4909 depth = _FROM_TYPE_HINT_DEPTH.depth - 1
4910 if depth < len(_FROM_TYPE_HINT_DELIM_SUGGESTIONS):
4911 return _FROM_TYPE_HINT_DELIM_SUGGESTIONS[depth]
4912 else:
4913 return None
4916register_type_hint_conversion(lambda ty, origin, args: Str() if ty is str else None)
4917register_type_hint_conversion(lambda ty, origin, args: Int() if ty is int else None)
4918register_type_hint_conversion(lambda ty, origin, args: Float() if ty is float else None)
4919register_type_hint_conversion(lambda ty, origin, args: Bool() if ty is bool else None)
4920register_type_hint_conversion(
4921 lambda ty, origin, args: (
4922 Enum(ty) if isinstance(ty, type) and issubclass(ty, enum.Enum) else None
4923 )
4924)
4925register_type_hint_conversion(
4926 lambda ty, origin, args: Decimal() if ty is decimal.Decimal else None
4927)
4928register_type_hint_conversion(
4929 lambda ty, origin, args: Fraction() if ty is fractions.Fraction else None
4930)
4931register_type_hint_conversion(
4932 lambda ty, origin, args: (
4933 List(
4934 from_type_hint(args[0]), delimiter=suggest_delim_for_type_hint_conversion()
4935 )
4936 if origin is list
4937 else None
4938 ),
4939 uses_delim=True,
4940)
4941register_type_hint_conversion(
4942 lambda ty, origin, args: (
4943 Set(from_type_hint(args[0]), delimiter=suggest_delim_for_type_hint_conversion())
4944 if origin is set
4945 else None
4946 ),
4947 uses_delim=True,
4948)
4949register_type_hint_conversion(
4950 lambda ty, origin, args: (
4951 FrozenSet(
4952 from_type_hint(args[0]), delimiter=suggest_delim_for_type_hint_conversion()
4953 )
4954 if origin is frozenset
4955 else None
4956 ),
4957 uses_delim=True,
4958)
4959register_type_hint_conversion(
4960 lambda ty, origin, args: (
4961 Dict(
4962 from_type_hint(args[0]),
4963 from_type_hint(args[1]),
4964 delimiter=suggest_delim_for_type_hint_conversion(),
4965 )
4966 if origin is dict
4967 else None
4968 ),
4969 uses_delim=True,
4970)
4971register_type_hint_conversion(
4972 lambda ty, origin, args: (
4973 Tuple(
4974 *[from_type_hint(arg) for arg in args],
4975 delimiter=suggest_delim_for_type_hint_conversion(),
4976 )
4977 if origin is tuple and ... not in args
4978 else None
4979 ),
4980 uses_delim=True,
4981)
4982register_type_hint_conversion(
4983 lambda ty, origin, args: Path() if ty is pathlib.Path else None
4984)
4985register_type_hint_conversion(
4986 lambda ty, origin, args: Json() if ty is yuio.json_schema.JsonValue else None
4987)
4988register_type_hint_conversion(
4989 lambda ty, origin, args: DateTime() if ty is datetime.datetime else None
4990)
4991register_type_hint_conversion(
4992 lambda ty, origin, args: Date() if ty is datetime.date else None
4993)
4994register_type_hint_conversion(
4995 lambda ty, origin, args: Time() if ty is datetime.time else None
4996)
4997register_type_hint_conversion(
4998 lambda ty, origin, args: TimeDelta() if ty is datetime.timedelta else None
4999)
5002@register_type_hint_conversion
5003def __secret(ty, origin, args):
5004 if ty is SecretValue:
5005 raise TypeError("yuio.secret.SecretValue requires type arguments")
5006 if origin is SecretValue:
5007 if len(args) == 1:
5008 return Secret(from_type_hint(args[0]))
5009 else:
5010 raise TypeError(
5011 f"yuio.secret.SecretValue requires 1 type argument, got {len(args)}"
5012 )
5013 return None
5016def _is_optional_parser(parser: Parser[_t.Any] | None, /) -> bool:
5017 while parser is not None:
5018 if isinstance(parser, Optional):
5019 return True
5020 parser = parser.__wrapped_parser__
5021 return False
5024def _is_bool_parser(parser: Parser[_t.Any] | None, /) -> bool:
5025 while parser is not None:
5026 if isinstance(parser, Bool):
5027 return True
5028 parser = parser.__wrapped_parser__
5029 return False