Skip to content

Make project compatible with Python 3.7+ #37

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 23 commits into from
Feb 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
os: [ "ubuntu-latest", "macos-latest", "windows-latest" ]
python-version: [ "3.9", "3.10", "3.11" ]
python-version: [ "3.7", "3.8", "3.9", "3.10", "3.11" ]
fail-fast: false
defaults:
run:
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
provides declarative *typed* argument parsing using `pydantic` models.

## Requirements
`pydantic-argparse` requires Python 3.9+
`pydantic-argparse` requires Python 3.7+

## Installation
Installation with `pip` is simple:
Expand Down
1,137 changes: 582 additions & 555 deletions poetry.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion pydantic_argparse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@
"""

# Local
from .argparse import ArgumentParser
from .argparse import ArgumentParser, BooleanOptionalAction
from .__metadata__ import __title__, __description__, __version__, __author__, __license__

# Public Re-Exports
__all__ = (
"ArgumentParser",
"BooleanOptionalAction",
"__title__",
"__description__",
"__version__",
Expand Down
7 changes: 6 additions & 1 deletion pydantic_argparse/__metadata__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@


# Standard
from importlib import metadata
import sys

if sys.version_info < (3, 8): # pragma: <3.8 cover
import importlib_metadata as metadata
else: # pragma: >=3.8 cover
from importlib import metadata


# Retrieve Metadata from Package
Expand Down
3 changes: 3 additions & 0 deletions pydantic_argparse/argparse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@
"""

# Local
import pydantic_argparse.argparse.patches
from .actions import BooleanOptionalAction
from .parser import ArgumentParser

# Public Re-Exports
__all__ = (
"ArgumentParser",
"BooleanOptionalAction"
)
91 changes: 89 additions & 2 deletions pydantic_argparse/argparse/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import argparse

# Typing
from typing import Any, Optional, Sequence, Union, cast
from typing import Any, Callable, Iterable, List, Optional, Sequence, Tuple, TypeVar, Union, cast


class SubParsersAction(argparse._SubParsersAction):
Expand Down Expand Up @@ -86,7 +86,7 @@ def __call__(
# such, this function signature also accepts 'str' and 'None' types for
# the values argument. However, in reality, this should only ever be a
# list of strings here, so we just do a type cast.
values = cast(list[str], values)
values = cast(List[str], values)

# Get Parser Name and Remaining Argument Strings
parser_name, *arg_strings = values
Expand All @@ -113,3 +113,90 @@ def __call__(
if arg_strings:
vars(namespace).setdefault(argparse._UNRECOGNIZED_ARGS_ATTR, [])
getattr(namespace, argparse._UNRECOGNIZED_ARGS_ATTR).extend(arg_strings)


_T = TypeVar("_T")


class BooleanOptionalAction(argparse.Action): # pragma: no cover
"""Action for storing optional boolean arguments.

This backported action allows using GNU-style optional boolean arguments,
for example "--foo/--no-foo".

Source:
https://github.com/python/cpython/blob/72263f2a20002ceff443e3a231c713f2e14fe3fe/Lib/argparse.py#L878
"""
def __init__(
self,
option_strings: Sequence[str],
dest: str,
default: Optional[Union[_T, str]] = None,
type: Optional[Union[Callable[[str], _T], argparse.FileType]] = None, # noqa: A002
choices: Optional[Iterable[_T]] = None,
required: bool = False,
help: Optional[str] = None, # noqa: A002
metavar: Optional[Union[str, Tuple[str, ...]]] = None
) -> None:
"""Initializes the option.

This initializes a default option, but adds "--no-<OPT>" as an allowed
option string.

Args:
option_strings (Sequence[str]): Option strings.
dest (str): Variable to save the value to.
default (Optional[Union[_T, str]]): Default value of the option.
type (Optional[Union[Callable[[str], _T], argparse.FileType]]): Type to cast the option to.
choices (Optional[Iterable[_T]]): Allowed values for the option.
required (bool): Whether the option is required.
help (Optional[str]): Help string for the option.
metavar (Optional[Union[str, Tuple[str, ...]]]): Meta variable name for the option.
"""
_option_strings = []
for option_string in option_strings:
_option_strings.append(option_string)

if option_string.startswith('--'):
option_string = '--no-' + option_string[2:]
_option_strings.append(option_string)

super().__init__(
option_strings=_option_strings,
dest=dest,
nargs=0,
default=default,
type=type,
choices=choices,
required=required,
help=help,
metavar=metavar)

def __call__(
self,
parser: argparse.ArgumentParser,
namespace: argparse.Namespace,
values: Optional[Union[str, Sequence[Any]]],
option_string: Optional[str] = None
) -> None:
"""Parses arguments into a namespace, excluding "--no-opt".

This custom method parses arguments while omitting the options with
"--no".

Args:
parser (argparse.ArgumentParser): Parent argument parser object.
namespace (argparse.Namespace): Parent namespace being parsed to.
values (Optional[Union[str, Sequence[Any]]]): Arguments to parse.
option_string (Optional[str]): Optional option string.
"""
if option_string in self.option_strings:
setattr(namespace, self.dest, not option_string.startswith('--no-')) # type: ignore[union-attr]

def format_usage(self) -> str:
"""Formats the usage string.

Returns:
str: Usage string for the option.
"""
return ' | '.join(self.option_strings)
33 changes: 21 additions & 12 deletions pydantic_argparse/argparse/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from . import actions

# Typing
from typing import Any, Generic, NoReturn, Optional, TypeVar
from typing import Any, Generic, List, NoReturn, Optional, Type, TypeVar


# Constants
Expand Down Expand Up @@ -69,7 +69,7 @@ class ArgumentParser(argparse.ArgumentParser, Generic[PydanticModelT]):

def __init__(
self,
model: type[PydanticModelT],
model: Type[PydanticModelT],
prog: Optional[str] = None,
description: Optional[str] = None,
version: Optional[str] = None,
Expand All @@ -89,14 +89,23 @@ def __init__(
exit_on_error (bool): Whether to exit on error.
"""
# Initialise Super Class
super().__init__(
prog=prog,
description=description,
epilog=epilog,
exit_on_error=exit_on_error,
add_help=False, # Always disable the automatic help flag.
argument_default=argparse.SUPPRESS, # Allow `pydantic` to handle defaults.
)
if sys.version_info < (3, 9): # pragma: <3.9 cover
super().__init__(
prog=prog,
description=description,
epilog=epilog,
add_help=False, # Always disable the automatic help flag.
argument_default=argparse.SUPPRESS, # Allow `pydantic` to handle defaults.
)
else: # pragma: >=3.9 cover
super().__init__(
prog=prog,
description=description,
epilog=epilog,
exit_on_error=exit_on_error,
add_help=False, # Always disable the automatic help flag.
argument_default=argparse.SUPPRESS, # Allow `pydantic` to handle defaults.
)

# Set Model
self.model = model
Expand Down Expand Up @@ -126,7 +135,7 @@ def __init__(

def parse_typed_args(
self,
args: Optional[list[str]] = None,
args: Optional[List[str]] = None,
) -> PydanticModelT:
"""Parses command line arguments.

Expand Down Expand Up @@ -249,7 +258,7 @@ def _add_version_flag(self) -> None:
help="show program's version number and exit",
)

def _add_model(self, model: type[PydanticModelT]) -> None:
def _add_model(self, model: Type[PydanticModelT]) -> None:
"""Adds pydantic model to argument parser.

Args:
Expand Down
49 changes: 49 additions & 0 deletions pydantic_argparse/argparse/patches.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Monkey patches for ArgumentParser.

In order to support Python 3.7 and 3.8 while retaining the tests, we need to
backport the bugfix for [BPO-29298].

[BPO-29298]: https://bugs.python.org/issue29298
"""

# Standard
import argparse
import sys

# Typing
from typing import Optional


# In Python pre-3.9, argparse fails with required subparsers, unnamed dest, and
# empty argv. The bug BPO-29298 was fixed in 3.11 and backported to 3.10 and 3.9
# Here, we backport it to 3.7 and 3.8 as well, via monkey-patching
# https://github.com/python/cpython/blob/v3.11.1/Lib/argparse.py#L739-L751
if sys.version_info < (3, 9): # pragma: <3.9 cover
def _get_action_name(argument: Optional[argparse.Action]) -> Optional[str]: # pragma: no cover
"""Returns the action name for an argument action.

For arguments with option strings, it concatenates those with a slash.
For arguments with `metavar` or `dest`, it returns those. For arguments
with `choices`, it returns the list of those.

Args:
argument (Optional[argparse.Action]): Action generated by the argument.

Returns:
Optional[str]: Derived action name
"""
if argument is None:
return None
elif argument.option_strings:
return '/'.join(argument.option_strings)
elif argument.metavar not in (None, argparse.SUPPRESS):
return argument.metavar
elif argument.dest not in (None, argparse.SUPPRESS):
return argument.dest
elif argument.choices:
return '{' + ','.join(argument.choices) + '}'
else:
return None

# Monkey-Patch
argparse._get_action_name = _get_action_name
3 changes: 2 additions & 1 deletion pydantic_argparse/parsers/boolean.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import pydantic

# Local
from pydantic_argparse.argparse.actions import BooleanOptionalAction
from pydantic_argparse import utils


Expand Down Expand Up @@ -48,7 +49,7 @@ def parse_field(
# Add Required Boolean Field
parser.add_argument(
utils.argument_name(field.alias),
action=argparse.BooleanOptionalAction,
action=BooleanOptionalAction,
help=utils.argument_description(field.field_info.description),
dest=field.alias,
required=True,
Expand Down
8 changes: 4 additions & 4 deletions pydantic_argparse/parsers/enum.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from pydantic_argparse import utils

# Typing
from typing import TypeVar
from typing import Type, TypeVar


# Constants
Expand Down Expand Up @@ -49,7 +49,7 @@ def parse_field(
field (pydantic.fields.ModelField): Field to be added to parser.
"""
# Get Enum Type
enum_type: type[enum.Enum] = field.outer_type_
enum_type: Type[enum.Enum] = field.outer_type_

# Define Custom Type Caster
caster = utils.type_caster(field.alias, _arg_to_enum_member, enum_type=enum_type)
Expand Down Expand Up @@ -111,7 +111,7 @@ def parse_field(

def _arg_to_enum_member(
argument: str,
enum_type: type[EnumT],
enum_type: Type[EnumT],
) -> EnumT:
"""Attempts to convert string argument to a supplied enum member.

Expand All @@ -132,7 +132,7 @@ def _arg_to_enum_member(
raise ValueError from exc


def _enum_choices_metavar(enum_type: type[EnumT]) -> str:
def _enum_choices_metavar(enum_type: Type[EnumT]) -> str:
"""Generates a string metavar from enum choices.

Args:
Expand Down
16 changes: 12 additions & 4 deletions pydantic_argparse/parsers/literal.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@

# Standard
import argparse
import typing
import sys
if sys.version_info < (3, 8): # pragma: <3.8 cover
from typing_extensions import get_args
else: # pragma: >=3.8 cover
from typing import get_args

# Third-Party
import pydantic
Expand All @@ -18,8 +22,12 @@
from pydantic_argparse import utils

# Typing
from typing import Any, Iterable, Literal, TypeVar
from typing import Any, Iterable, List, TypeVar

if sys.version_info < (3, 8): # pragma: <3.8 cover
from typing_extensions import Literal
else: # pragma: >=3.8 cover
from typing import Literal

# Constants
T = TypeVar("T")
Expand Down Expand Up @@ -49,7 +57,7 @@ def parse_field(
field (pydantic.fields.ModelField): Field to be added to parser.
"""
# Get choices from literal
choices = list(typing.get_args(field.outer_type_))
choices = list(get_args(field.outer_type_))

# Define Custom Type Caster
caster = utils.type_caster(field.alias, _arg_to_choice, choices=choices)
Expand Down Expand Up @@ -111,7 +119,7 @@ def parse_field(

def _arg_to_choice(
argument: str,
choices: list[T],
choices: List[T],
) -> T:
"""Attempts to convert string argument to a supplied choice.

Expand Down
Loading