Skip to content

Commit

Permalink
Feat: type checking (#4)
Browse files Browse the repository at this point in the history
* feat: type checking with mypy

* fix: TypeAlias for old versions of python

* fix: workflows syntax
  • Loading branch information
maxb2 authored May 2, 2023
1 parent b7a10c3 commit d4481fb
Show file tree
Hide file tree
Showing 7 changed files with 164 additions and 40 deletions.
13 changes: 10 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,23 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3

- uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}

- name: Setup Poetry
uses: abatilo/actions-poetry@v2
with:
poetry-version: ${{ matrix.poetry-version }}

- name: Install Dependencies
run: poetry install --extras all
- name: Linting
run: poetry run pylint typer_config/
run: poetry install --all-extras

- name: Linting and Type checking
run: |
poetry run pylint typer_config/
poetry run mypy typer_config/
- name: Testing
run: poetry run pytest
73 changes: 72 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ pylint = "^2.17.3"
black = "^23.3.0"
isort = "^5.12.0"
pytest = "^7.3.1"
mypy = "^1.2.0"
types-toml = "^0.10.8.6"
types-pyyaml = "^6.0.12.9"

[build-system]
requires = ["poetry-core"]
Expand Down
6 changes: 3 additions & 3 deletions tests/test_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,21 +48,21 @@ def test_simple_example(simple_app):

result = RUNNER.invoke(_app, ["--config", conf])

assert result.exit_code == 0, f"Loading failed for {conf}"
assert result.exit_code == 0, f"Loading failed for {conf}\n\n{result.stdout}"
assert (
result.stdout.strip() == "things nothing stuff"
), f"Unexpected output for {conf}"

result = RUNNER.invoke(_app, ["--config", conf, "others"])

assert result.exit_code == 0, f"Loading failed for {conf}"
assert result.exit_code == 0, f"Loading failed for {conf}\n\n{result.stdout}"
assert (
result.stdout.strip() == "things nothing others"
), f"Unexpected output for {conf}"

result = RUNNER.invoke(_app, ["--config", conf, "--opt1", "people"])

assert result.exit_code == 0, f"Loading failed for {conf}"
assert result.exit_code == 0, f"Loading failed for {conf}\n\n{result.stdout}"
assert (
result.stdout.strip() == "people nothing stuff"
), f"Unexpected output for {conf}"
23 changes: 11 additions & 12 deletions typer_config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,42 +2,41 @@
Typer Configuration Utilities
"""

from typing import Any, Callable, Dict

import typer

from .loaders import json_loader, toml_loader, yaml_loader
from .types import ConfigParameterCallback, Loader, ParameterValue


def conf_callback_factory(
loader: Callable[[Any], Dict[str, Any]]
) -> Callable[[typer.Context, typer.CallbackParam, Any], Any]:
def conf_callback_factory(loader: Loader) -> ConfigParameterCallback:
"""Configuration callback factory
Parameters
----------
loader : Callable[[Any], Dict[str, Any]]
loader : Loader
Loader function that takes the value passed to the typer CLI and
returns a dictionary that is applied to the click context's default map.
Returns
-------
Callable[[typer.Context, typer.CallbackParam, Any], Any]
ConfigParameterCallback
Configuration callback function.
"""

def _callback(ctx: typer.Context, param: typer.CallbackParam, value: Any) -> Any:
def _callback(
ctx: typer.Context, param: typer.CallbackParam, value: ParameterValue
) -> ParameterValue:
try:
conf = loader(value) # Load config file
ctx.default_map = ctx.default_map or {} # Initialize the default map
ctx.default_map.update(conf) # Merge the config Dict into default_map
except Exception as ex:
raise typer.BadParameter(value, ctx=ctx, param=param) from ex
raise typer.BadParameter(str(ex), ctx=ctx, param=param) from ex
return value

return _callback


yaml_conf_callback = conf_callback_factory(yaml_loader)
json_conf_callback = conf_callback_factory(json_loader)
toml_conf_callback = conf_callback_factory(toml_loader)
yaml_conf_callback: ConfigParameterCallback = conf_callback_factory(yaml_loader)
json_conf_callback: ConfigParameterCallback = conf_callback_factory(json_loader)
toml_conf_callback: ConfigParameterCallback = conf_callback_factory(toml_loader)
61 changes: 40 additions & 21 deletions typer_config/loaders.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,45 @@
"""
Configuration File Loaders.
These loaders must follow the signature: Callable[[Any], Dict[str, Any]]
These loaders must implement the interface:
typer_config.types.Loader = Callable[[Any], Dict[str, Any]]
"""

import json
from typing import Any, Dict
import sys

from .types import ConfDict

USING_TOMLLIB = False
TOML_MISSING = True
YAML_MISSING = True

try:
# Only available for python>=3.11
import tomllib as toml

if sys.version_info >= (3, 11):
import tomllib # type: ignore

TOML_MISSING = False
USING_TOMLLIB = True
except ImportError:
else:
try:
# Third-party toml parsing library
import toml

TOML_MISSING = False

except ImportError:
toml = None
pass


try:
import yaml

YAML_MISSING = False
except ImportError:
yaml = None
pass


# pylint: disable-next=unused-argument
def dummy_loader(path: str) -> Dict[str, Any]:
def dummy_loader(path: str) -> ConfDict:
"""Dummy loader to show the required interface.
Parameters
Expand All @@ -38,13 +49,13 @@ def dummy_loader(path: str) -> Dict[str, Any]:
Returns
-------
Dict
ConfDict
dictionary loaded from file
"""
return {}


def yaml_loader(path: str) -> Dict[str, Any]:
def yaml_loader(path: str) -> ConfDict:
"""YAML file loader
Parameters
Expand All @@ -54,17 +65,20 @@ def yaml_loader(path: str) -> Dict[str, Any]:
Returns
-------
Dict
ConfDict
dictionary loaded from file
"""

if YAML_MISSING:
raise ModuleNotFoundError("Please install the pyyaml library.")

with open(path, "r", encoding="utf-8") as _file:
conf = yaml.safe_load(_file)
conf: ConfDict = yaml.safe_load(_file)

return conf


def json_loader(path: str) -> Dict[str, Any]:
def json_loader(path: str) -> ConfDict:
"""JSON file loader
Parameters
Expand All @@ -74,17 +88,17 @@ def json_loader(path: str) -> Dict[str, Any]:
Returns
-------
Dict
ConfDict
dictionary loaded from file
"""

with open(path, "r", encoding="utf-8") as _file:
conf = json.load(_file)
conf: ConfDict = json.load(_file)

return conf


def toml_loader(path: str) -> Dict[str, Any]:
def toml_loader(path: str) -> ConfDict:
"""TOML file loader
Parameters
Expand All @@ -94,15 +108,20 @@ def toml_loader(path: str) -> Dict[str, Any]:
Returns
-------
Dict
ConfDict
dictionary loaded from file
"""

if TOML_MISSING:
raise ModuleNotFoundError("Please install the toml library.")

conf: ConfDict = {}

if USING_TOMLLIB:
with open(path, "rb") as _file:
conf = toml.load(_file)
conf = tomllib.load(_file) # type: ignore
else:
with open(path, "r", encoding="utf-8") as _file:
conf = toml.load(_file)
conf = toml.load(_file) # type: ignore

return conf
25 changes: 25 additions & 0 deletions typer_config/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""
Data and Function types.
"""

import sys
from typing import Any, Callable, Dict

from typer import CallbackParam as typer_CallbackParam
from typer import Context as typer_Context

# Handle TypeAlias based on python version
if sys.version_info < (3, 10):
from typing_extensions import TypeAlias
else:
from typing import TypeAlias

# Data types
ConfDict: TypeAlias = Dict[str, Any]
ParameterValue: TypeAlias = Any

# Function types
Loader: TypeAlias = Callable[[Any], ConfDict]
ConfigParameterCallback: TypeAlias = Callable[
[typer_Context, typer_CallbackParam, ParameterValue], ParameterValue
]

0 comments on commit d4481fb

Please sign in to comment.