diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd62446..4dc7ded 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/poetry.lock b/poetry.lock index ae50149..23e0af7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -215,6 +215,53 @@ files = [ {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] +[[package]] +name = "mypy" +version = "1.2.0" +description = "Optional static typing for Python" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mypy-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:701189408b460a2ff42b984e6bd45c3f41f0ac9f5f58b8873bbedc511900086d"}, + {file = "mypy-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fe91be1c51c90e2afe6827601ca14353bbf3953f343c2129fa1e247d55fd95ba"}, + {file = "mypy-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d26b513225ffd3eacece727f4387bdce6469192ef029ca9dd469940158bc89e"}, + {file = "mypy-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3a2d219775a120581a0ae8ca392b31f238d452729adbcb6892fa89688cb8306a"}, + {file = "mypy-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:2e93a8a553e0394b26c4ca683923b85a69f7ccdc0139e6acd1354cc884fe0128"}, + {file = "mypy-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3efde4af6f2d3ccf58ae825495dbb8d74abd6d176ee686ce2ab19bd025273f41"}, + {file = "mypy-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:695c45cea7e8abb6f088a34a6034b1d273122e5530aeebb9c09626cea6dca4cb"}, + {file = "mypy-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0e9464a0af6715852267bf29c9553e4555b61f5904a4fc538547a4d67617937"}, + {file = "mypy-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8293a216e902ac12779eb7a08f2bc39ec6c878d7c6025aa59464e0c4c16f7eb9"}, + {file = "mypy-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:f46af8d162f3d470d8ffc997aaf7a269996d205f9d746124a179d3abe05ac602"}, + {file = "mypy-1.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:031fc69c9a7e12bcc5660b74122ed84b3f1c505e762cc4296884096c6d8ee140"}, + {file = "mypy-1.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:390bc685ec209ada4e9d35068ac6988c60160b2b703072d2850457b62499e336"}, + {file = "mypy-1.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:4b41412df69ec06ab141808d12e0bf2823717b1c363bd77b4c0820feaa37249e"}, + {file = "mypy-1.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4e4a682b3f2489d218751981639cffc4e281d548f9d517addfd5a2917ac78119"}, + {file = "mypy-1.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a197ad3a774f8e74f21e428f0de7f60ad26a8d23437b69638aac2764d1e06a6a"}, + {file = "mypy-1.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c9a084bce1061e55cdc0493a2ad890375af359c766b8ac311ac8120d3a472950"}, + {file = "mypy-1.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaeaa0888b7f3ccb7bcd40b50497ca30923dba14f385bde4af78fac713d6d6f6"}, + {file = "mypy-1.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bea55fc25b96c53affab852ad94bf111a3083bc1d8b0c76a61dd101d8a388cf5"}, + {file = "mypy-1.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:4c8d8c6b80aa4a1689f2a179d31d86ae1367ea4a12855cc13aa3ba24bb36b2d8"}, + {file = "mypy-1.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:70894c5345bea98321a2fe84df35f43ee7bb0feec117a71420c60459fc3e1eed"}, + {file = "mypy-1.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4a99fe1768925e4a139aace8f3fb66db3576ee1c30b9c0f70f744ead7e329c9f"}, + {file = "mypy-1.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:023fe9e618182ca6317ae89833ba422c411469156b690fde6a315ad10695a521"}, + {file = "mypy-1.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4d19f1a239d59f10fdc31263d48b7937c585810288376671eaf75380b074f238"}, + {file = "mypy-1.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:2de7babe398cb7a85ac7f1fd5c42f396c215ab3eff731b4d761d68d0f6a80f48"}, + {file = "mypy-1.2.0-py3-none-any.whl", hash = "sha256:d8e9187bfcd5ffedbe87403195e1fc340189a68463903c39e2b63307c9fa0394"}, + {file = "mypy-1.2.0.tar.gz", hash = "sha256:f70a40410d774ae23fcb4afbbeca652905a04de7948eaf0b1789c8d1426b72d1"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=3.10" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +python2 = ["typed-ast (>=1.4.0,<2)"] +reports = ["lxml"] + [[package]] name = "mypy-extensions" version = "1.0.0" @@ -470,6 +517,30 @@ dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "pre-commit (>=2 doc = ["cairosvg (>=2.5.2,<3.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pillow (>=9.3.0,<10.0.0)"] test = ["black (>=22.3.0,<23.0.0)", "coverage (>=6.2,<7.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "pytest (>=4.4.0,<8.0.0)", "pytest-cov (>=2.10.0,<5.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=1.32.0,<4.0.0)", "rich (>=10.11.0,<14.0.0)", "shellingham (>=1.3.0,<2.0.0)"] +[[package]] +name = "types-pyyaml" +version = "6.0.12.9" +description = "Typing stubs for PyYAML" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "types-PyYAML-6.0.12.9.tar.gz", hash = "sha256:c51b1bd6d99ddf0aa2884a7a328810ebf70a4262c292195d3f4f9a0005f9eeb6"}, + {file = "types_PyYAML-6.0.12.9-py3-none-any.whl", hash = "sha256:5aed5aa66bd2d2e158f75dda22b059570ede988559f030cf294871d3b647e3e8"}, +] + +[[package]] +name = "types-toml" +version = "0.10.8.6" +description = "Typing stubs for toml" +category = "dev" +optional = false +python-versions = "*" +files = [ + {file = "types-toml-0.10.8.6.tar.gz", hash = "sha256:6d3ac79e36c9ee593c5d4fb33a50cca0e3adceb6ef5cff8b8e5aef67b4c4aaf2"}, + {file = "types_toml-0.10.8.6-py3-none-any.whl", hash = "sha256:de7b2bb1831d6f7a4b554671ffe5875e729753496961b3e9b202745e4955dafa"}, +] + [[package]] name = "typing-extensions" version = "4.5.0" @@ -575,4 +646,4 @@ yaml = ["pyyaml"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "cc5b1932ad4d3cfa069ac53f7ae9214ce46ab22688d670b5dc02acac84aa5f6e" +content-hash = "bbf3c242c142c63432fca328b3ced1f39dbb37db136b5f929d62557f86736e5d" diff --git a/pyproject.toml b/pyproject.toml index 45f949c..4f157f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/tests/test_example.py b/tests/test_example.py index e829d06..1d9c952 100644 --- a/tests/test_example.py +++ b/tests/test_example.py @@ -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}" diff --git a/typer_config/__init__.py b/typer_config/__init__.py index 69c0de3..b75094b 100644 --- a/typer_config/__init__.py +++ b/typer_config/__init__.py @@ -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) diff --git a/typer_config/loaders.py b/typer_config/loaders.py index 1e4cc55..e209f6e 100644 --- a/typer_config/loaders.py +++ b/typer_config/loaders.py @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/typer_config/types.py b/typer_config/types.py new file mode 100644 index 0000000..0e4a0f9 --- /dev/null +++ b/typer_config/types.py @@ -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 +]