Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
df13d7b
Implement append enum actions
timsavage Apr 27, 2022
973cc20
Bump version and update HISTORY
timsavage Apr 27, 2022
68bfbc2
Add test case for argument signatures
timsavage Apr 27, 2022
68b9b58
Merge branch 'development' into feature/enum-append-sequence
timsavage Jul 27, 2022
884215e
Make backwards compatible
timsavage Jul 27, 2022
db09fa0
Initial implementation of typed settings
timsavage Feb 19, 2023
0b2cd96
Typing tweaks
timsavage Feb 19, 2023
e15a633
Add support for typed settings, to provide a typed accessor object an…
timsavage Feb 20, 2023
b764fe5
Improve typing of decorators.
timsavage Feb 21, 2023
a57474e
Adding docs for typed-settings
timsavage Feb 27, 2023
9bb0f88
Tweak doc strings
timsavage Feb 27, 2023
c7c11b5
Relocked and bumped version to 4.12
timsavage Feb 27, 2023
d24c70c
Moved typed-settings docs under conf
timsavage Feb 27, 2023
b64cc7c
Support for literals
timsavage Feb 27, 2023
6685b96
Update history with literal info
timsavage Feb 27, 2023
8f81b4c
Migrate from pkg_resources to importlib.metadata
timsavage Mar 1, 2023
045f775
Fix typing to allow Type[argparse.Action]
timsavage Mar 1, 2023
131f9b0
Update HISTORY with pkg_resources to importlib.metadata
timsavage Mar 1, 2023
fdc6cf0
Better handle backported importlib
timsavage Mar 1, 2023
acb576b
Improved the event descriptor to handle the case where slots are defi…
timsavage Mar 7, 2023
55eb8ae
Update history
timsavage Mar 7, 2023
b951249
Relock dependencies
timsavage Mar 7, 2023
3cd2ef6
Merge branch 'development' into feature/enum-append-sequence
timsavage Mar 7, 2023
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
20 changes: 20 additions & 0 deletions HISTORY
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
4.12
====

Changes
-------

- Introduction of typed_settings, providing a cleaner way to access default settings
that supports auto-completion and type inference. And preparing the way for more
in-depth checks that ensure settings values match the expected types.

- Support ``Literal`` types in CLI. Maps str and int literals to choices.

- Support sequences of Enum values in the CLI. This is implemented via the
``AppendEnumValue`` and ``AppendEnumName`` actions.

- Migrate from pkg_resources to the standardised builtin importlib.metadata.

- Fix for handling events/callbacks on classes where the parent has __slots__ defined.


4.11.0
======

Expand Down
2 changes: 0 additions & 2 deletions docs/reference/conf/base_settings.rst

This file was deleted.

1 change: 1 addition & 0 deletions docs/reference/conf/typed-settings.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.. automodule:: pyapp.typed_settings
2 changes: 1 addition & 1 deletion docs/reference/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Contents:
app
checks
conf
conf/base_settings
conf/typed-settings
conf/helpers
events
injection
Expand Down
1,039 changes: 513 additions & 526 deletions poetry.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"

[tool.poetry]
name = "pyapp"
version = "4.11.0"
version = "4.12.0"
description = "A Python application framework - Let us handle the boring stuff!"
authors = ["Tim Savage <tim@savage.company>"]
license = "BSD-3-Clause"
Expand Down Expand Up @@ -37,6 +37,7 @@ python = "^3.8"
argcomplete = "*"
colorama = "*"
yarl = "*"
importlib_metadata = {version = "*", python = "<=3.9"}

pyyaml = {version = "*", optional = true }
toml = {version = "*", optional = true }
Expand Down
115 changes: 96 additions & 19 deletions src/pyapp/app/argument_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@

.. autoclass:: EnumName

.. autoclass:: AppendEnumValue

.. autoclass:: AppendEnumName


Date and Time types
~~~~~~~~~~~~~~~~~~~
Expand Down Expand Up @@ -46,6 +50,8 @@
"EnumValue",
"EnumName",
"EnumNameList",
"AppendEnumValue",
"AppendEnumName",
"DateAction",
"TimeAction",
"DateTimeAction",
Expand All @@ -59,15 +65,15 @@ class KeyValueAction(Action):

Example of use::

@app.command
def my_command(options: Mapping[str, str]):
print(options)

@app.command
@argument("--option", action=KeyValueAction)
def my_command(args: Namespace):
print(args.option)

@app.command
def my_command(options: Mapping[str, str]):
print(options)

From CLI::

> my_app m_command --option a=foo --option b=bar
Expand Down Expand Up @@ -159,7 +165,7 @@ def to_enum(self, value):
class EnumValue(_EnumAction):
"""
Action to use an Enum as the type of an argument. In this mode the Enum is
reference by value.
referenced by value.

The choices are automatically generated for help.

Expand Down Expand Up @@ -194,7 +200,7 @@ def to_enum(self, value):
class EnumName(_EnumAction):
"""
Action to use an Enum as the type of an argument. In this mode the Enum is
reference by name.
referenced by name.

The choices are automatically generated for help.

Expand Down Expand Up @@ -226,10 +232,53 @@ def to_enum(self, value):
return self._enum[value]


class EnumNameList(EnumName):
def _copy_items(items):
"""
Action to use an Enum as the type of an argument. In this mode the Enum is
reference by name and appended to a list.
Extracted from argparse
"""
if items is None:
return []

# The copy module is used only in the 'append' and 'append_const'
# actions, and it is needed only when the default value isn't a list.
# Delay its import for speeding up the common case.
if isinstance(items, list):
return items[:]

import copy # pylint: disable=import-outside-toplevel

return copy.copy(items)


class _AppendEnumActionMixin(_EnumAction):
"""
Mixin to support appending enum items
"""

def __call__(self, parser, namespace, values, option_string=None):
items = getattr(namespace, self.dest, None)
items = _copy_items(items)
enum = self.to_enum(values)
items.append(enum)
setattr(namespace, self.dest, items)

def get_choices(self, choices: Union[Enum, Sequence[Enum]]):
"""
Get choices from the enum
"""
raise NotImplementedError # pragma: no cover

def to_enum(self, value):
"""
Get enum from the supplied value.
"""
raise NotImplementedError # pragma: no cover


class AppendEnumValue(EnumValue, _AppendEnumActionMixin):
"""
Action to use an Enum as the type of an argument and to accept multiple
enum values. In this mode the Enum is referenced by value.

The choices are automatically generated for help.

Expand All @@ -241,28 +290,56 @@ class Colour(Enum):
Blue = "blue"

@app.command
@argument("--colour", type=Colour, action=EnumNameList)
@argument("--colours", type=Colour, action=AppendEnumValue)
def my_command(args: Namespace):
print(args.colour)

# Or using typing definition

@app.command
def my_command(*, colour: Sequence[Colour]):
def my_command(*, colours: Sequence[Colour]):
print(colours)

From CLI::

> my_app m_command --colour red --colour blue
[Colour.Red, Colour.Blue]

.. versionadded:: 4.9

"""


class AppendEnumName(EnumName, _AppendEnumActionMixin):
"""
Action to use an Enum as the type of an argument and to accept multiple
enum values. In this mode the Enum is referenced by name.

The choices are automatically generated for help.

Example of use::

class Colour(Enum):
Red = "red"
Green = "green"
Blue = "blue"

@app.command
@argument("--colours", type=Colour, action=AppendEnumName)
def my_command(args: Namespace):
print(args.colour)

From CLI::

> my_app m_command --colour Red --colour Green
[Colour.Red, Colour.Green]
> my_app m_command --colour Red --colour Blue
[Colour.Red, Colour.Blue]

.. versionadded:: 4.8.2
.. versionadded:: 4.9

"""

def __call__(self, parser, namespace, values, option_string=None):
enum = self.to_enum(values)
items = getattr(namespace, self.dest, None) or []
items.append(enum)
setattr(namespace, self.dest, items)

EnumNameList = AppendEnumName


class _DateTimeAction(Action):
Expand Down
18 changes: 15 additions & 3 deletions src/pyapp/app/arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
from pyapp.compatability import async_run
from pyapp.utils import cached_property

from .argument_actions import AppendEnumName
from .argument_actions import EnumName
from .argument_actions import EnumNameList
from .argument_actions import KeyValueAction
from .argument_actions import TYPE_ACTIONS

Expand Down Expand Up @@ -233,13 +233,25 @@ def _handle_generics( # pylint: disable=too-many-branches
"Only Optional[TYPE] or Union[TYPE, None] are supported"
)

elif name == "typing.Literal":
choices = type_.__args__
choice_type = type(choices[0])
if choice_type not in (str, int):
raise TypeError("Only str and int Literal types are supported")
# Ensure only a single type is supplied
if not all(isinstance(choice, choice_type) for choice in choices):
raise TypeError("All literal values must be the same type")

kwargs["choices"] = type_.__args__
return choice_type

elif issubclass(origin, Tuple):
kwargs["nargs"] = len(type_.__args__)

elif issubclass(origin, Sequence):
args = type_.__args__
if len(args) == 1 and issubclass(args[0], Enum):
kwargs["action"] = EnumNameList
kwargs["action"] = AppendEnumName
elif positional:
kwargs["nargs"] = "+"
else:
Expand Down Expand Up @@ -339,7 +351,7 @@ def from_parameter(cls, name: str, parameter: inspect.Parameter) -> "Argument":
def __init__(
self,
*name_or_flags,
action: str = None,
action: Union[str, Type[argparse.Action]] = None,
nargs: Union[int, str] = None,
const: Any = None,
default: Any = EMPTY,
Expand Down
10 changes: 7 additions & 3 deletions src/pyapp/checks/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from typing import List
from typing import NamedTuple
from typing import Sequence
from typing import TypeVar
from typing import Union

from pyapp import extensions
Expand All @@ -29,6 +30,7 @@ class Tags:


Check = Callable[[Settings], Union[CheckMessage, Sequence[CheckMessage]]]
_C = TypeVar("_C", bound=Callable[[Check], Check])


class CheckResult(NamedTuple):
Expand All @@ -47,9 +49,11 @@ class CheckRegistry(List[Check]):
Registry list for checks.
"""

def register(
self, check: Check = None, *tags
): # pylint: disable=keyword-arg-before-vararg
def register( # pylint: disable=keyword-arg-before-vararg
self,
check: Union[Check, str] = None,
*tags: str,
) -> Union[_C, Callable[[_C], _C]]:
"""
Can be used as a function or a decorator. Register given function
`func` labeled with given `tags`. The function should receive **kwargs
Expand Down
6 changes: 6 additions & 0 deletions src/pyapp/conf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@

.. autoclass:: HttpLoader

Default settings
================

.. automodule:: pyapp.conf.base_settings
:members:

"""
import logging
import os
Expand Down
7 changes: 1 addition & 6 deletions src/pyapp/conf/base_settings.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
"""
Default Settings
~~~~~~~~~~~~~~~~

.. auto
"""
"""Base settings used to initialise settings object."""
from typing import Any
from typing import Dict
from typing import Sequence
Expand Down
13 changes: 12 additions & 1 deletion src/pyapp/conf/loaders/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,20 @@
from pyapp.conf.loaders.file_loader import FileLoader
from pyapp.conf.loaders.http_loader import HttpLoader
from pyapp.exceptions import InvalidConfiguration
from pyapp.typed_settings import SettingsDefType
from yarl import URL


def _settings_iterator(obj):
"""Iterate settings from an object"""
for key in dir(obj):
value = getattr(obj, key)
if isinstance(value, SettingsDefType):
yield from getattr(value, "_settings", ())
elif key.isupper():
yield key, value


class ModuleLoader(Loader):
"""
Load configuration from an importable module.
Expand Down Expand Up @@ -57,7 +68,7 @@ def __iter__(self) -> Iterator[Tuple[str, Any]]:
except ImportError as ex:
raise InvalidConfiguration(f"Unable to load module: {self}\n{ex}") from ex

return ((k, getattr(mod, k)) for k in dir(mod) if k.isupper())
return _settings_iterator(mod)

def __str__(self):
return f"{self.scheme}:{self.module}" # pylint: disable=no-member
Expand Down
Loading