From 3ce38b3e4b0595c64bf686ef2745316c61084d34 Mon Sep 17 00:00:00 2001 From: Clif Bratcher Date: Sun, 5 May 2024 12:24:24 -0400 Subject: [PATCH] Shift optional and required to properties * Fold _parse_line into parse_or_prepend * Tests and more tests --- simplecli/simplecli.py | 70 ++++++++++++++++++++++---------------- tests/test_extract_args.py | 2 +- tests/test_param.py | 54 ++++++++++++++++++++++++++++- 3 files changed, 94 insertions(+), 32 deletions(-) diff --git a/simplecli/simplecli.py b/simplecli/simplecli.py index cc694bc..bdeed4b 100644 --- a/simplecli/simplecli.py +++ b/simplecli/simplecli.py @@ -1,3 +1,4 @@ +from __future__ import annotations import contextlib import inspect import io @@ -9,8 +10,6 @@ COMMENT, NAME, NL, - NUMBER, - STRING, TokenError, TokenInfo, generate_tokens, @@ -44,8 +43,8 @@ class Empty: class Param(inspect.Parameter): internal_only: bool # Do not pass to wrapped function - optional: bool # Value can be `None`, mirrors `Optional` type - required: bool # Exit if a value is not present + _required: bool # Exit if a value is not present + _optional: bool | None = None # Mirrors `Optional` type def __init__(self, *argv: Any, **kwargs: Any) -> None: kwargs["annotation"] = kwargs.pop("annotation", Empty) @@ -71,19 +70,19 @@ def __init__(self, *argv: Any, **kwargs: Any) -> None: param_line = str(kwargs.pop("line", "")) param_value = kwargs.pop("value", Empty) param_internal_only = bool(kwargs.pop("internal_only", False)) - param_optional = bool(kwargs.pop("optional", False)) + param_optional = kwargs.pop("optional", None) param_required = bool(kwargs.pop("required", True)) super().__init__(*argv, **kwargs) self._value = param_value self.description = param_description self.internal_only = param_internal_only - self.optional = param_optional - self.required = param_required + self._optional = ( + bool(param_optional) if param_optional is not None else None + ) + self._required = param_required # Overrides required as these values are generally unused - if self.internal_only: - self.required = False if not self.description: - self._parse_line(param_line) + self.parse_or_prepend(param_line) def __eq__(self, other: object) -> bool: if not isinstance(other, Param): @@ -110,6 +109,29 @@ def __str__(self) -> str: f"value={value} " ) + @property + def required(self) -> bool: + # Internal only params never requires a value + if self.internal_only: + return False + + # Optional implies no required value + if self.optional: + return False + + # Existence of a default value implies no required value + if self.default is not Empty: + return False + + # Fallback to set/default value + return self._required + + @property + def optional(self) -> bool: + if self._optional is not None: + return self._optional + return len(self.datatypes) == 2 and type(None) in self.datatypes + @property def help_name(self) -> str: return self.name.replace("_", "-") @@ -144,30 +166,18 @@ def parse_or_prepend( if not overwrite and self.description: return False - line_set = self._parse_line(line) + line_set = False + try: + for token in tokenize_string(line): + if token.exact_type is COMMENT: + self._set_description(token.string, force=True) + line_set = True + except TokenError: + line_set = False if comment: self._set_description(comment) return line_set - def _parse_line(self, line: str) -> bool: - self.description = "" - try: - tokens = list(tokenize_string(line)) - except TokenError: - return False - - for token in tokens: - if token.type not in (COMMENT, NAME, NUMBER, STRING): - continue - if token.exact_type is COMMENT: - self._set_description(token.string, force=True) - if token.string == "Optional": - self.required = False - self.optional = True - if self.default is not Empty: - self.required = False - return True - @property def datatypes(self) -> list[type]: args = get_args(self.annotation) diff --git a/tests/test_extract_args.py b/tests/test_extract_args.py index fec37ab..6adf3c8 100644 --- a/tests/test_extract_args.py +++ b/tests/test_extract_args.py @@ -28,7 +28,7 @@ def code(foo: int = 123): annotation=int, default=123, ) - expected_param._parse_line(" def code(foo: int = 123):\n") + expected_param.parse_or_prepend(" def code(foo: int = 123):\n") assert params == [expected_param] diff --git a/tests/test_param.py b/tests/test_param.py index 32eb626..1370124 100644 --- a/tests/test_param.py +++ b/tests/test_param.py @@ -1,7 +1,7 @@ import pytest import re from simplecli.simplecli import DefaultIfBool, Empty, Param -from typing import Union +from typing import Optional, Union def test_required_arguments(): @@ -106,3 +106,55 @@ def test_union_uniontype(): p2 = Param(name="testparam2", annotation=int | bool) assert p2.datatypes == [int, bool] assert p2.help_type == "[int, bool]" + + +def test_union_optional(): + p0 = Param(name="testparam1", annotation=Optional[float]) + assert p0.datatypes == [float, type(None)] + assert p0.help_type == "[float, NoneType]" + assert p0.optional is True + assert p0.required is False + + p1 = Param(name="testparam1", annotation=Union[None, float]) + assert p1.datatypes == [type(None), float] + assert p1.help_type == "[NoneType, float]" + assert p1.optional is True + assert p1.required is False + + p2 = Param(name="testparam2", annotation=None | bool) + assert p2.datatypes == [type(None), bool] + assert p2.help_type == "[NoneType, bool]" + + +def test_parse_or_prepend_description(): + p1 = Param(name="testparam1") + assert p1.description == "" + p1.parse_or_prepend(" testparam1, # stuff and things") + assert p1.description == "stuff and things" + + +def test_parse_or_prepend_optional(): + p1 = Param(name="testparam1", annotation=Optional[str]) + assert p1.optional is True + assert p1.required is False + p1.parse_or_prepend(" testparam1: Optional[str],") + assert p1.optional is True + assert p1.required is False + + +def test_parse_or_prepend_union_none(): + p1 = Param(name="testparam1", annotation=Union[None, str]) + assert p1.optional is True + assert p1.required is False + p1.parse_or_prepend(" testparam1: Union[None, str], # blarg") + assert p1.optional is True + assert p1.required is False + + +def test_parse_or_prepend_uniontype_none(): + p1 = Param(name="testparam1", annotation=None | str) + assert p1.optional is True + assert p1.required is False + p1.parse_or_prepend(" testparam1: UnionNone | str, # blarg") + assert p1.optional is True + assert p1.required is False