From 34075555b4905fb3e8663add462e8731dfb5f3eb Mon Sep 17 00:00:00 2001 From: Hayden Richards <62866982+SupImDos@users.noreply.github.com> Date: Sun, 23 Jan 2022 18:18:16 +0800 Subject: [PATCH] Feature: Subcommand Parsing (#9) * First method of recursive subcommand parsing. * Fixed non-recursive mistake. * Renamed `parent_command` to `command`. * Renamed `commands` to `subcommands` * Fixed malformed docstring arg. * Fixed future imports in unit tests * Unified handling of combining command strings for namespacing. * Fixed mangled metavars. * Fixed pylint no-self-use. * Added check for dest in kwargs before augmenting. * Remove 'parent command' method. * Updated dependencies. * Added custom SubParserAction to easily parse commands. * Rearranged functions * Improved SubParsersAction docstring. * Added full propagation of unrecognized args to top level namespace. * Guarded unrecognized args propagation with check. * Refactored __version__ and unit test structure. * Restructured unit tests again. * Linting and styleguide changes. * Updated pyproject.toml * Bumped back to 0.3.1 after merge. * Added unit tests for 'utils' module. * Completed full unit test coverage of commands. --- poetry.lock | 187 ++++++++-------- pydantic_argparse/__init__.py | 3 +- pydantic_argparse/__version__.py | 17 +- pydantic_argparse/argparse/__init__.py | 1 - pydantic_argparse/argparse/actions.py | 105 +++++++++ pydantic_argparse/argparse/parser.py | 69 +++--- pydantic_argparse/parsers/__init__.py | 2 +- pydantic_argparse/parsers/boolean.py | 24 +-- pydantic_argparse/parsers/command.py | 32 +++ pydantic_argparse/parsers/container.py | 21 +- pydantic_argparse/parsers/enum.py | 37 ++-- pydantic_argparse/parsers/json.py | 25 ++- pydantic_argparse/parsers/literal.py | 35 +-- pydantic_argparse/parsers/standard.py | 21 +- pydantic_argparse/utils/__init__.py | 3 +- pydantic_argparse/utils/utils.py | 53 +++-- pyproject.toml | 2 - tests/argparse/__init__.py | 7 + tests/argparse/test_actions.py | 104 +++++++++ tests/{ => argparse}/test_parser.py | 287 ++++++++++++++----------- tests/conftest.py | 205 ++++++++++-------- tests/parsers/__init__.py | 7 + tests/test_version.py | 72 ------- tests/utils/__init__.py | 7 + tests/utils/test_utils.py | 130 +++++++++++ 25 files changed, 924 insertions(+), 532 deletions(-) create mode 100644 pydantic_argparse/argparse/actions.py create mode 100644 pydantic_argparse/parsers/command.py create mode 100644 tests/argparse/__init__.py create mode 100644 tests/argparse/test_actions.py rename tests/{ => argparse}/test_parser.py (56%) create mode 100644 tests/parsers/__init__.py delete mode 100644 tests/test_version.py create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/test_utils.py diff --git a/poetry.lock b/poetry.lock index c9f7ca2..b6e54c2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,10 +1,10 @@ [[package]] name = "astroid" -version = "2.9.0" +version = "2.9.3" description = "An abstract syntax tree for Python with inference support." category = "dev" optional = false -python-versions = "~=3.6" +python-versions = ">=3.6.2" [package.dependencies] lazy-object-proxy = ">=1.4.0" @@ -21,17 +21,17 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "attrs" -version = "21.2.0" +version = "21.4.0" description = "Classes Without Boilerplate" category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] [[package]] name = "colorama" @@ -79,11 +79,11 @@ plugins = ["setuptools"] [[package]] name = "lazy-object-proxy" -version = "1.7.0" +version = "1.7.1" description = "A fast and thorough lazy object proxy." category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +python-versions = ">=3.6" [[package]] name = "mccabe" @@ -139,11 +139,11 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "platformdirs" -version = "2.4.0" +version = "2.4.1" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.extras] docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] @@ -183,7 +183,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "pydantic" -version = "1.8.2" +version = "1.9.0" description = "Data validation and settings management using python 3.6 type hinting" category = "main" optional = false @@ -292,19 +292,11 @@ python-versions = ">=3.7" [[package]] name = "tomlkit" -version = "0.7.2" +version = "0.8.0" description = "Style preserving TOML library" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - -[[package]] -name = "types-toml" -version = "0.10.1" -description = "Typing stubs for toml" -category = "dev" -optional = false -python-versions = "*" +python-versions = ">=3.6,<4.0" [[package]] name = "typing-extensions" @@ -337,20 +329,20 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "fbf682708c309f692c15c1dd99df80a551fe016a995b087fbd9ce81720e51ac4" +content-hash = "7b5b865308ed3a5eb57ccdbb5db1aa302d8ee609c5d9c871870f096b3e555bc3" [metadata.files] astroid = [ - {file = "astroid-2.9.0-py3-none-any.whl", hash = "sha256:776ca0b748b4ad69c00bfe0fff38fa2d21c338e12c84aa9715ee0d473c422778"}, - {file = "astroid-2.9.0.tar.gz", hash = "sha256:5939cf55de24b92bda00345d4d0659d01b3c7dafb5055165c330bc7c568ba273"}, + {file = "astroid-2.9.3-py3-none-any.whl", hash = "sha256:506daabe5edffb7e696ad82483ad0228245a9742ed7d2d8c9cdb31537decf9f6"}, + {file = "astroid-2.9.3.tar.gz", hash = "sha256:1efdf4e867d4d8ba4a9f6cf9ce07cd182c4c41de77f23814feb27ca93ca9d877"}, ] atomicwrites = [ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, ] attrs = [ - {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, - {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, + {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, + {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, ] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, @@ -414,43 +406,43 @@ isort = [ {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, ] lazy-object-proxy = [ - {file = "lazy-object-proxy-1.7.0.tar.gz", hash = "sha256:2185392631e9d1733749d06ee5210438908d46cc04666a0eba5679d885754894"}, - {file = "lazy_object_proxy-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2ce8da459d711302f4c95942c243c3b219a5b8ce9a7e3cbb6fe2deadddb62111"}, - {file = "lazy_object_proxy-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3087cb819773e37354d5e95ca6bdb5f73d831179971b2437320ec3de901abee"}, - {file = "lazy_object_proxy-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f5fd85b271c8bd29b565a776e3a803ac488de9df2b68c53caa3dc442e8d8754"}, - {file = "lazy_object_proxy-1.7.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a6b22b6075e14537eebbc8652fcafe1076be7fde4d4a31cd9fadcf0eb1af27ca"}, - {file = "lazy_object_proxy-1.7.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1b19e68114ef8ba3f6cc6686d38ff4342fff82cb9918e2cd94ffaa65255fc833"}, - {file = "lazy_object_proxy-1.7.0-cp310-cp310-win32.whl", hash = "sha256:6ba8151eab9d39fa0f1d3f9b74064669c89680f08db206ec434400bde717cbdb"}, - {file = "lazy_object_proxy-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:70a67e4e12d18243a3710dd085eb8913a2555b416467e85d5269e05008042224"}, - {file = "lazy_object_proxy-1.7.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:1b4b228d58fdf4f57db967929ca5cb8df5f0d1ea06f5155fa65c1c66ba95339c"}, - {file = "lazy_object_proxy-1.7.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc0518f5d4004845cda9241f07d911d9bc9395379cd77bf8dd3ad62a1915f09"}, - {file = "lazy_object_proxy-1.7.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:682a83f5134c3f267bc07fefed8d50450aec6a8dc339c17b1beb07422a137c8c"}, - {file = "lazy_object_proxy-1.7.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:1aa3097b421e932670a4c0e826b5b9877ad5f0dff736bb5347cc28f1dacd9ad5"}, - {file = "lazy_object_proxy-1.7.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:3c9362f6cf03d150c9282298a2ed316a14992383c67abecd111fe4a8f5fea875"}, - {file = "lazy_object_proxy-1.7.0-cp36-cp36m-win32.whl", hash = "sha256:64bf2c4ae8fb4f2a3c8c1649f76a07741e59c2d8841ffe2e686c9c1e4aa27e08"}, - {file = "lazy_object_proxy-1.7.0-cp36-cp36m-win_amd64.whl", hash = "sha256:896a0f2e4fdfeab980f42cb326de1c8e59cfb8914f4dbdf2af1be45a20300a9e"}, - {file = "lazy_object_proxy-1.7.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5b05d300d90a626f6e12d4a462a6a1a19054fea70878d9e7797d49404e54411a"}, - {file = "lazy_object_proxy-1.7.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af144174b8b70e4b09f9b72ba31fcafd936308aa6a6a1c63d22f723d388f4165"}, - {file = "lazy_object_proxy-1.7.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:838913a85d675901b22f01d236e6ab560af5ef31f28159a0e241271b75b300dd"}, - {file = "lazy_object_proxy-1.7.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b8793cddf912a48242440a63300db2b051f5fb7a8b01572d1d89e14749bce783"}, - {file = "lazy_object_proxy-1.7.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2a732deb47c46b0f6422e7f250dd09de2e8cca612d10ea8472e20e2a6d327696"}, - {file = "lazy_object_proxy-1.7.0-cp37-cp37m-win32.whl", hash = "sha256:32d5ce9c61b77e38b70fbee565fa7351d1fa6b97a2e7aa391cea58764c94dd77"}, - {file = "lazy_object_proxy-1.7.0-cp37-cp37m-win_amd64.whl", hash = "sha256:db9a0dfd7e85814eee0b3ab3985b163eefb60e2c8f62537f9adc83a38585e507"}, - {file = "lazy_object_proxy-1.7.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a1e4b163a6e6a7ef643d012bffe0357369c5c47e4b6a510777877353c443518f"}, - {file = "lazy_object_proxy-1.7.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f059ab2df150aa74c456072a7b9e144531bed0c35e73be4a087f3e7ce2bf63"}, - {file = "lazy_object_proxy-1.7.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b324126db57574166d3e96a9066d45461db7ff8e7baa3846dbb869b37057e2e"}, - {file = "lazy_object_proxy-1.7.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d55dffc9a96bc3d68779557b8cbce37e9335e5b972aa83874c6269e30f69fff2"}, - {file = "lazy_object_proxy-1.7.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:3a01b05b4591fbb63e1498dfd894ed44686873788ab437f86fcca8186f914f5b"}, - {file = "lazy_object_proxy-1.7.0-cp38-cp38-win32.whl", hash = "sha256:e5263f216b9d905f93a381242d9e11364dd5c38699822a48c949d4a9f22f6e44"}, - {file = "lazy_object_proxy-1.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:a77eec36a3bc90808f6a6d605337e904abafab20fd3b1ad7b9d4194e4ab38a4c"}, - {file = "lazy_object_proxy-1.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8768b7d3e606b4900d444449fe2f6463ca11f9c3e37e723c37a1cf42fc748e26"}, - {file = "lazy_object_proxy-1.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26124051d32e0f40a2864d0af4e9be751fa9a0ef65164c496163a99c3fd4c295"}, - {file = "lazy_object_proxy-1.7.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a87f307527473d8fe32c28c0f27208150d8f72e98ad19b929ddecdad1e824c6b"}, - {file = "lazy_object_proxy-1.7.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7912e5887e548ab33b34ad1a2370c09f39b30de410198befd2e18d1f96f7f914"}, - {file = "lazy_object_proxy-1.7.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:f2694aac2bdabc36de10e51619552e6c7e76d28a5910986e7d633ee8f6d86024"}, - {file = "lazy_object_proxy-1.7.0-cp39-cp39-win32.whl", hash = "sha256:6b5b5ed5b2052d204b0791b9cdee289141eeb1f3e00a113916fe7e34b5f15751"}, - {file = "lazy_object_proxy-1.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:d202e73eb141c56b3dd8b021282c8de3dc048e8e936a8dd0ac4ff40458ef876f"}, - {file = "lazy_object_proxy-1.7.0-pp37.pp38-none-any.whl", hash = "sha256:90fca6db4b472872df8790fddab854183762903f7e3fa976ab744d89b7100422"}, + {file = "lazy-object-proxy-1.7.1.tar.gz", hash = "sha256:d609c75b986def706743cdebe5e47553f4a5a1da9c5ff66d76013ef396b5a8a4"}, + {file = "lazy_object_proxy-1.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bb8c5fd1684d60a9902c60ebe276da1f2281a318ca16c1d0a96db28f62e9166b"}, + {file = "lazy_object_proxy-1.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a57d51ed2997e97f3b8e3500c984db50a554bb5db56c50b5dab1b41339b37e36"}, + {file = "lazy_object_proxy-1.7.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd45683c3caddf83abbb1249b653a266e7069a09f486daa8863fb0e7496a9fdb"}, + {file = "lazy_object_proxy-1.7.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8561da8b3dd22d696244d6d0d5330618c993a215070f473b699e00cf1f3f6443"}, + {file = "lazy_object_proxy-1.7.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fccdf7c2c5821a8cbd0a9440a456f5050492f2270bd54e94360cac663398739b"}, + {file = "lazy_object_proxy-1.7.1-cp310-cp310-win32.whl", hash = "sha256:898322f8d078f2654d275124a8dd19b079080ae977033b713f677afcfc88e2b9"}, + {file = "lazy_object_proxy-1.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:85b232e791f2229a4f55840ed54706110c80c0a210d076eee093f2b2e33e1bfd"}, + {file = "lazy_object_proxy-1.7.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:46ff647e76f106bb444b4533bb4153c7370cdf52efc62ccfc1a28bdb3cc95442"}, + {file = "lazy_object_proxy-1.7.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12f3bb77efe1367b2515f8cb4790a11cffae889148ad33adad07b9b55e0ab22c"}, + {file = "lazy_object_proxy-1.7.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c19814163728941bb871240d45c4c30d33b8a2e85972c44d4e63dd7107faba44"}, + {file = "lazy_object_proxy-1.7.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:e40f2013d96d30217a51eeb1db28c9ac41e9d0ee915ef9d00da639c5b63f01a1"}, + {file = "lazy_object_proxy-1.7.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:2052837718516a94940867e16b1bb10edb069ab475c3ad84fd1e1a6dd2c0fcfc"}, + {file = "lazy_object_proxy-1.7.1-cp36-cp36m-win32.whl", hash = "sha256:6a24357267aa976abab660b1d47a34aaf07259a0c3859a34e536f1ee6e76b5bb"}, + {file = "lazy_object_proxy-1.7.1-cp36-cp36m-win_amd64.whl", hash = "sha256:6aff3fe5de0831867092e017cf67e2750c6a1c7d88d84d2481bd84a2e019ec35"}, + {file = "lazy_object_proxy-1.7.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6a6e94c7b02641d1311228a102607ecd576f70734dc3d5e22610111aeacba8a0"}, + {file = "lazy_object_proxy-1.7.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4ce15276a1a14549d7e81c243b887293904ad2d94ad767f42df91e75fd7b5b6"}, + {file = "lazy_object_proxy-1.7.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e368b7f7eac182a59ff1f81d5f3802161932a41dc1b1cc45c1f757dc876b5d2c"}, + {file = "lazy_object_proxy-1.7.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6ecbb350991d6434e1388bee761ece3260e5228952b1f0c46ffc800eb313ff42"}, + {file = "lazy_object_proxy-1.7.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:553b0f0d8dbf21890dd66edd771f9b1b5f51bd912fa5f26de4449bfc5af5e029"}, + {file = "lazy_object_proxy-1.7.1-cp37-cp37m-win32.whl", hash = "sha256:c7a683c37a8a24f6428c28c561c80d5f4fd316ddcf0c7cab999b15ab3f5c5c69"}, + {file = "lazy_object_proxy-1.7.1-cp37-cp37m-win_amd64.whl", hash = "sha256:df2631f9d67259dc9620d831384ed7732a198eb434eadf69aea95ad18c587a28"}, + {file = "lazy_object_proxy-1.7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:07fa44286cda977bd4803b656ffc1c9b7e3bc7dff7d34263446aec8f8c96f88a"}, + {file = "lazy_object_proxy-1.7.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dca6244e4121c74cc20542c2ca39e5c4a5027c81d112bfb893cf0790f96f57e"}, + {file = "lazy_object_proxy-1.7.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91ba172fc5b03978764d1df5144b4ba4ab13290d7bab7a50f12d8117f8630c38"}, + {file = "lazy_object_proxy-1.7.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:043651b6cb706eee4f91854da4a089816a6606c1428fd391573ef8cb642ae4f7"}, + {file = "lazy_object_proxy-1.7.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b9e89b87c707dd769c4ea91f7a31538888aad05c116a59820f28d59b3ebfe25a"}, + {file = "lazy_object_proxy-1.7.1-cp38-cp38-win32.whl", hash = "sha256:9d166602b525bf54ac994cf833c385bfcc341b364e3ee71e3bf5a1336e677b55"}, + {file = "lazy_object_proxy-1.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:8f3953eb575b45480db6568306893f0bd9d8dfeeebd46812aa09ca9579595148"}, + {file = "lazy_object_proxy-1.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dd7ed7429dbb6c494aa9bc4e09d94b778a3579be699f9d67da7e6804c422d3de"}, + {file = "lazy_object_proxy-1.7.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70ed0c2b380eb6248abdef3cd425fc52f0abd92d2b07ce26359fcbc399f636ad"}, + {file = "lazy_object_proxy-1.7.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7096a5e0c1115ec82641afbdd70451a144558ea5cf564a896294e346eb611be1"}, + {file = "lazy_object_proxy-1.7.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f769457a639403073968d118bc70110e7dce294688009f5c24ab78800ae56dc8"}, + {file = "lazy_object_proxy-1.7.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:39b0e26725c5023757fc1ab2a89ef9d7ab23b84f9251e28f9cc114d5b59c1b09"}, + {file = "lazy_object_proxy-1.7.1-cp39-cp39-win32.whl", hash = "sha256:2130db8ed69a48a3440103d4a520b89d8a9405f1b06e2cc81640509e8bf6548f"}, + {file = "lazy_object_proxy-1.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:677ea950bef409b47e51e733283544ac3d660b709cfce7b187f5ace137960d61"}, + {file = "lazy_object_proxy-1.7.1-pp37.pp38-none-any.whl", hash = "sha256:d66906d5785da8e0be7360912e99c9188b70f52c422f9fc18223347235691a84"}, ] mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, @@ -494,8 +486,8 @@ pastel = [ {file = "pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d"}, ] platformdirs = [ - {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"}, - {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"}, + {file = "platformdirs-2.4.1-py3-none-any.whl", hash = "sha256:1d7385c7db91728b83efd0ca99a5afb296cab9d0ed8313a45ed8ba17967ecfca"}, + {file = "platformdirs-2.4.1.tar.gz", hash = "sha256:440633ddfebcc36264232365d7840a970e75e1018d15b4327d11f91909045fda"}, ] pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, @@ -510,28 +502,41 @@ py = [ {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, ] pydantic = [ - {file = "pydantic-1.8.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739"}, - {file = "pydantic-1.8.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4"}, - {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:589eb6cd6361e8ac341db97602eb7f354551482368a37f4fd086c0733548308e"}, - {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:10e5622224245941efc193ad1d159887872776df7a8fd592ed746aa25d071840"}, - {file = "pydantic-1.8.2-cp36-cp36m-win_amd64.whl", hash = "sha256:99a9fc39470010c45c161a1dc584997f1feb13f689ecf645f59bb4ba623e586b"}, - {file = "pydantic-1.8.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a83db7205f60c6a86f2c44a61791d993dff4b73135df1973ecd9eed5ea0bda20"}, - {file = "pydantic-1.8.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:41b542c0b3c42dc17da70554bc6f38cbc30d7066d2c2815a94499b5684582ecb"}, - {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:ea5cb40a3b23b3265f6325727ddfc45141b08ed665458be8c6285e7b85bd73a1"}, - {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:18b5ea242dd3e62dbf89b2b0ec9ba6c7b5abaf6af85b95a97b00279f65845a23"}, - {file = "pydantic-1.8.2-cp37-cp37m-win_amd64.whl", hash = "sha256:234a6c19f1c14e25e362cb05c68afb7f183eb931dd3cd4605eafff055ebbf287"}, - {file = "pydantic-1.8.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd"}, - {file = "pydantic-1.8.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e710876437bc07bd414ff453ac8ec63d219e7690128d925c6e82889d674bb505"}, - {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:ac8eed4ca3bd3aadc58a13c2aa93cd8a884bcf21cb019f8cfecaae3b6ce3746e"}, - {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:4a03cbbe743e9c7247ceae6f0d8898f7a64bb65800a45cbdc52d65e370570820"}, - {file = "pydantic-1.8.2-cp38-cp38-win_amd64.whl", hash = "sha256:8621559dcf5afacf0069ed194278f35c255dc1a1385c28b32dd6c110fd6531b3"}, - {file = "pydantic-1.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8b223557f9510cf0bfd8b01316bf6dd281cf41826607eada99662f5e4963f316"}, - {file = "pydantic-1.8.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:244ad78eeb388a43b0c927e74d3af78008e944074b7d0f4f696ddd5b2af43c62"}, - {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:05ef5246a7ffd2ce12a619cbb29f3307b7c4509307b1b49f456657b43529dc6f"}, - {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:54cd5121383f4a461ff7644c7ca20c0419d58052db70d8791eacbbe31528916b"}, - {file = "pydantic-1.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:4be75bebf676a5f0f87937c6ddb061fa39cbea067240d98e298508c1bda6f3f3"}, - {file = "pydantic-1.8.2-py3-none-any.whl", hash = "sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833"}, - {file = "pydantic-1.8.2.tar.gz", hash = "sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b"}, + {file = "pydantic-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cb23bcc093697cdea2708baae4f9ba0e972960a835af22560f6ae4e7e47d33f5"}, + {file = "pydantic-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1d5278bd9f0eee04a44c712982343103bba63507480bfd2fc2790fa70cd64cf4"}, + {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab624700dc145aa809e6f3ec93fb8e7d0f99d9023b713f6a953637429b437d37"}, + {file = "pydantic-1.9.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8d7da6f1c1049eefb718d43d99ad73100c958a5367d30b9321b092771e96c25"}, + {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3c3b035103bd4e2e4a28da9da7ef2fa47b00ee4a9cf4f1a735214c1bcd05e0f6"}, + {file = "pydantic-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3011b975c973819883842c5ab925a4e4298dffccf7782c55ec3580ed17dc464c"}, + {file = "pydantic-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:086254884d10d3ba16da0588604ffdc5aab3f7f09557b998373e885c690dd398"}, + {file = "pydantic-1.9.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0fe476769acaa7fcddd17cadd172b156b53546ec3614a4d880e5d29ea5fbce65"}, + {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8e9dcf1ac499679aceedac7e7ca6d8641f0193c591a2d090282aaf8e9445a46"}, + {file = "pydantic-1.9.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1e4c28f30e767fd07f2ddc6f74f41f034d1dd6bc526cd59e63a82fe8bb9ef4c"}, + {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:c86229333cabaaa8c51cf971496f10318c4734cf7b641f08af0a6fbf17ca3054"}, + {file = "pydantic-1.9.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:c0727bda6e38144d464daec31dff936a82917f431d9c39c39c60a26567eae3ed"}, + {file = "pydantic-1.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:dee5ef83a76ac31ab0c78c10bd7d5437bfdb6358c95b91f1ba7ff7b76f9996a1"}, + {file = "pydantic-1.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d9c9bdb3af48e242838f9f6e6127de9be7063aad17b32215ccc36a09c5cf1070"}, + {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ee7e3209db1e468341ef41fe263eb655f67f5c5a76c924044314e139a1103a2"}, + {file = "pydantic-1.9.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b6037175234850ffd094ca77bf60fb54b08b5b22bc85865331dd3bda7a02fa1"}, + {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b2571db88c636d862b35090ccf92bf24004393f85c8870a37f42d9f23d13e032"}, + {file = "pydantic-1.9.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8b5ac0f1c83d31b324e57a273da59197c83d1bb18171e512908fe5dc7278a1d6"}, + {file = "pydantic-1.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:bbbc94d0c94dd80b3340fc4f04fd4d701f4b038ebad72c39693c794fd3bc2d9d"}, + {file = "pydantic-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e0896200b6a40197405af18828da49f067c2fa1f821491bc8f5bde241ef3f7d7"}, + {file = "pydantic-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bdfdadb5994b44bd5579cfa7c9b0e1b0e540c952d56f627eb227851cda9db77"}, + {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:574936363cd4b9eed8acdd6b80d0143162f2eb654d96cb3a8ee91d3e64bf4cf9"}, + {file = "pydantic-1.9.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c556695b699f648c58373b542534308922c46a1cda06ea47bc9ca45ef5b39ae6"}, + {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:f947352c3434e8b937e3aa8f96f47bdfe6d92779e44bb3f41e4c213ba6a32145"}, + {file = "pydantic-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5e48ef4a8b8c066c4a31409d91d7ca372a774d0212da2787c0d32f8045b1e034"}, + {file = "pydantic-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:96f240bce182ca7fe045c76bcebfa0b0534a1bf402ed05914a6f1dadff91877f"}, + {file = "pydantic-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:815ddebb2792efd4bba5488bc8fde09c29e8ca3227d27cf1c6990fc830fd292b"}, + {file = "pydantic-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6c5b77947b9e85a54848343928b597b4f74fc364b70926b3c4441ff52620640c"}, + {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c68c3bc88dbda2a6805e9a142ce84782d3930f8fdd9655430d8576315ad97ce"}, + {file = "pydantic-1.9.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a79330f8571faf71bf93667d3ee054609816f10a259a109a0738dac983b23c3"}, + {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f5a64b64ddf4c99fe201ac2724daada8595ada0d102ab96d019c1555c2d6441d"}, + {file = "pydantic-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a733965f1a2b4090a5238d40d983dcd78f3ecea221c7af1497b845a9709c1721"}, + {file = "pydantic-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cc6a4cb8a118ffec2ca5fcb47afbacb4f16d0ab8b7350ddea5e8ef7bcc53a16"}, + {file = "pydantic-1.9.0-py3-none-any.whl", hash = "sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3"}, + {file = "pydantic-1.9.0.tar.gz", hash = "sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a"}, ] pylint = [ {file = "pylint-2.12.2-py3-none-any.whl", hash = "sha256:daabda3f7ed9d1c60f52d563b1b854632fd90035bcf01443e234d3dc794e3b74"}, @@ -562,12 +567,8 @@ tomli = [ {file = "tomli-2.0.0.tar.gz", hash = "sha256:c292c34f58502a1eb2bbb9f5bbc9a5ebc37bee10ffb8c2d6bbdfa8eb13cc14e1"}, ] tomlkit = [ - {file = "tomlkit-0.7.2-py2.py3-none-any.whl", hash = "sha256:173ad840fa5d2aac140528ca1933c29791b79a374a0861a80347f42ec9328117"}, - {file = "tomlkit-0.7.2.tar.gz", hash = "sha256:d7a454f319a7e9bd2e249f239168729327e4dd2d27b17dc68be264ad1ce36754"}, -] -types-toml = [ - {file = "types-toml-0.10.1.tar.gz", hash = "sha256:5c1f8f8d57692397c8f902bf6b4d913a0952235db7db17d2908cc110e70610cb"}, - {file = "types_toml-0.10.1-py3-none-any.whl", hash = "sha256:8cdfd2b7c89bed703158b042dd5cf04255dae77096db66f4a12ca0a93ccb07a5"}, + {file = "tomlkit-0.8.0-py3-none-any.whl", hash = "sha256:b824e3466f1d475b2b5f1c392954c6cb7ea04d64354ff7300dc7c14257dc85db"}, + {file = "tomlkit-0.8.0.tar.gz", hash = "sha256:29e84a855712dfe0e88a48f6d05c21118dbafb283bb2eed614d46f80deb8e9a1"}, ] typing-extensions = [ {file = "typing_extensions-4.0.1-py3-none-any.whl", hash = "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"}, diff --git a/pydantic_argparse/__init__.py b/pydantic_argparse/__init__.py index 4081aa4..fce25cc 100644 --- a/pydantic_argparse/__init__.py +++ b/pydantic_argparse/__init__.py @@ -5,7 +5,6 @@ @author Hayden Richards """ - # Local -from .__version__ import __app__, __description__, __version__, __authors__ +from .__version__ import __title__, __description__, __version__, __author__, __license__ from .argparse import ArgumentParser diff --git a/pydantic_argparse/__version__.py b/pydantic_argparse/__version__.py index 30f88b7..822ef44 100644 --- a/pydantic_argparse/__version__.py +++ b/pydantic_argparse/__version__.py @@ -1,13 +1,18 @@ """__version__.py -Exports the name, version, description and authors of the package +Exports the title, description, version, author and license of the package @author Hayden Richards """ -# Duplicated from `pyproject.toml` -__app__ = "pydantic-argparse" -__description__ = "Typed Argument Parsing with Pydantic" -__version__ = "0.3.1" -__authors__ = ["Hayden Richards "] +# Standard +from importlib import metadata + + +# Retrieve Metadata from Package +__title__ = metadata.metadata(__package__)["name"] +__description__ = metadata.metadata(__package__)["summary"] +__version__ = metadata.metadata(__package__)["version"] +__author__ = metadata.metadata(__package__)["author"] +__license__ = metadata.metadata(__package__)["license"] diff --git a/pydantic_argparse/argparse/__init__.py b/pydantic_argparse/argparse/__init__.py index ccd52cd..f9014db 100644 --- a/pydantic_argparse/argparse/__init__.py +++ b/pydantic_argparse/argparse/__init__.py @@ -5,6 +5,5 @@ @author Hayden Richards """ - # Local from .parser import ArgumentParser diff --git a/pydantic_argparse/argparse/actions.py b/pydantic_argparse/argparse/actions.py new file mode 100644 index 0000000..826246c --- /dev/null +++ b/pydantic_argparse/argparse/actions.py @@ -0,0 +1,105 @@ +"""actions.py + +Provides custom Actions classes. + +@author Hayden Richards +""" + + +# Standard +import argparse + +# Typing +from typing import Any, Optional, Sequence, Union, cast + + +class SubParsersAction(argparse._SubParsersAction): # pylint: disable=protected-access + """Custom SubParsersAction.""" + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: Union[str, Sequence[Any], None], + option_string: Optional[str]=None, + ) -> None: + """Parses arguments with the specified subparser, then embeds the + resultant sub-namespace into the supplied parent namespace. + + This subclass differs in functionality from the existing standard + argparse SubParsersAction because it nests the resultant sub-namespace + directly into the parent namespace, rather than iterating through and + updating the parent namespace object with each argument individually. + + Example: + # Create Argument Parser + parser = argparse.ArgumentParser() + + # Add Example Global Argument + parser.add_argument("--time") + + # Add SubParsersAction + subparsers = parser.add_subparsers() + + # Add Example 'walk' Command with Arguments + walk = subparsers.add_parser("walk") + walk.add_argument("--speed") + walk.add_argument("--distance") + + # Add Example 'talk' Command with Arguments + talk = subparsers.add_parser("talk") + talk.add_argument("--volume") + talk.add_argument("--topic") + + Parsing the arguments: + * --time 3 walk --speed 7 --distance 42 + + Resultant namespaces: + * Original: Namespace(time=3, speed=7, distance=42) + * Custom: Namespace(time=3, walk=Namespace(speed=7, distance=42)) + + This behaviour results in a final namespace structure which is much + easier to parse, where subcommands are easily identified and nested + into their own namespace recursively. + + Args: + parser (argparse.ArgumentParser): Parent argument parser object. + namespace (argparse.Namespace): Parent namespace being parsed to. + values (Union[str, Sequence[Any], None]): Arguments to parse. + option_string (Optional[str]): Optional option string (not used). + + Raises: + argparse.ArgumentError: Raised if subparser name does not exist. + """ + # Check values object is a sequence + # In order to not violate the Liskov Substitution Principle (LSP), the + # function signature for __call__ must match the base Action class. As + # 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) + + # Get Parser Name and Remaining Argument Strings + parser_name, *arg_strings = values + + # Try select the parser + try: + # Select the parser + parser = self._name_parser_map[parser_name] + + except KeyError as exc: + # Parser doesn't exist, raise an exception + raise argparse.ArgumentError( + self, + f"unknown parser {parser_name} (choices: {', '.join(self._name_parser_map)})" + ) from exc + + # Parse all the remaining options into a sub-namespace, then embed this + # sub-namespace into the parent namespace + subnamespace, arg_strings = parser.parse_known_args(arg_strings) + setattr(namespace, parser_name, subnamespace) + + # Store any unrecognized options on the parent namespace, so that the + # top level parser can decide what to do with them + if arg_strings: + vars(namespace).setdefault(argparse._UNRECOGNIZED_ARGS_ATTR, []) + getattr(namespace, argparse._UNRECOGNIZED_ARGS_ATTR).extend(arg_strings) diff --git a/pydantic_argparse/argparse/parser.py b/pydantic_argparse/argparse/parser.py index 787849c..3b57b0e 100644 --- a/pydantic_argparse/argparse/parser.py +++ b/pydantic_argparse/argparse/parser.py @@ -6,9 +6,6 @@ """ -from __future__ import annotations - - # Standard import argparse import collections @@ -20,14 +17,9 @@ import typing_inspect # Local -from ..parsers import ( - parse_boolean_field, - parse_container_field, - parse_enum_field, - parse_json_field, - parse_literal_field, - parse_standard_field, -) +from pydantic_argparse import parsers +from pydantic_argparse import utils +from . import actions # Typing from typing import Any, Generic, Literal, NoReturn, Optional, TypeVar # pylint: disable=wrong-import-order @@ -40,6 +32,7 @@ class ArgumentParser(argparse.ArgumentParser, Generic[PydanticModelT]): """Custom Typed Argument Parser.""" # Argument Group Names + COMMANDS = "commands" REQUIRED = "required arguments" OPTIONAL = "optional arguments" HELP = "help" @@ -80,6 +73,9 @@ def __init__( add_help=False, # Always disable the automatic help flag. ) + # Set Model + self.model = model + # Set Add Help and Exit on Error Flag self.add_help = add_help self.exit_on_error = exit_on_error @@ -89,6 +85,7 @@ def __init__( self.model = model # Add Arguments Groups + self._subcommands: Optional[argparse._SubParsersAction] = None self._required_group = self.add_argument_group(ArgumentParser.REQUIRED) self._optional_group = self.add_argument_group(ArgumentParser.OPTIONAL) self._help_group = self.add_argument_group(ArgumentParser.HELP) @@ -115,10 +112,10 @@ def parse_typed_args( PydanticModelT: Typed arguments. """ # Call Super Class Method - namespace = self.parse_args(args, None) + namespace = self.parse_args(args) - # Restructure Namespace - arguments = self._restructure_namespace(namespace) + # Convert Namespace to Dictionary + arguments = utils.namespace_to_dict(namespace) # Handle Possible Validation Errors try: @@ -223,42 +220,40 @@ def _add_field(self, field: pydantic.fields.ModelField) -> None: # Switch on Field Type if field_type is bool: # Add Boolean Field - parse_boolean_field(self, field) + parsers.parse_boolean_field(self, field) elif field_origin in (list, tuple, set, frozenset, collections.deque): # Add Container Field - parse_container_field(self, field) + parsers.parse_container_field(self, field) elif field_origin is dict: # Add Dictionary (JSON) Field - parse_json_field(self, field) + parsers.parse_json_field(self, field) elif field_origin is Literal: # Add Literal Field - parse_literal_field(self, field) + parsers.parse_literal_field(self, field) elif isinstance(field_type, enum.EnumMeta): # Add Enum Field - parse_enum_field(self, field) - - else: - # Add Other Standard Field - parse_standard_field(self, field) + parsers.parse_enum_field(self, field) - def _restructure_namespace( # pylint: disable=no-self-use - self, - namespace: argparse.Namespace, - ) -> dict[str, Any]: - """Restructures namespace to a nested dictionary. + elif isinstance(field_type, pydantic.main.ModelMetaclass): + # Check for Sub-Commands Group + if not self._subcommands: + # Add Sub-Commands Group + self._subcommands = self.add_subparsers( + title=ArgumentParser.COMMANDS, + action=actions.SubParsersAction, + required=True, + ) - Args: - namespace (argparse.Namespace): Namespace to restructure. + # Shuffle it to the top + self._action_groups.insert(0, self._action_groups.pop()) - Returns: - dict[str, Any]: Nested dictionary constructed from namespace. - """ - # Get Arguments from Vars - arguments = vars(namespace) + # Add Command + parsers.parse_command_field(self._subcommands, field) - # Return - return arguments + else: + # Add Other Standard Field + parsers.parse_standard_field(self, field) diff --git a/pydantic_argparse/parsers/__init__.py b/pydantic_argparse/parsers/__init__.py index 0ff7b75..61cd35b 100644 --- a/pydantic_argparse/parsers/__init__.py +++ b/pydantic_argparse/parsers/__init__.py @@ -5,9 +5,9 @@ @author Hayden Richards """ - # Local from .boolean import parse_boolean_field +from .command import parse_command_field from .container import parse_container_field from .enum import parse_enum_field from .json import parse_json_field diff --git a/pydantic_argparse/parsers/boolean.py b/pydantic_argparse/parsers/boolean.py index ec42f33..de178d5 100644 --- a/pydantic_argparse/parsers/boolean.py +++ b/pydantic_argparse/parsers/boolean.py @@ -6,9 +6,6 @@ """ -from __future__ import annotations - - # Standard import argparse @@ -16,8 +13,7 @@ import pydantic # Local -from ..utils import argument_description, argument_name - +from pydantic_argparse import utils def parse_boolean_field( @@ -27,7 +23,7 @@ def parse_boolean_field( """Adds boolean pydantic field to argument parser. Args: - parser: (argparse.ArgumentParser): Argument parser to add to. + parser (argparse.ArgumentParser): Argument parser to add to. field (pydantic.fields.ModelField): Field to be added to parser. """ # Booleans can be treated as required or optional flags @@ -47,14 +43,14 @@ def _parse_boolean_field_required( """Adds required boolean pydantic field to argument parser. Args: - parser: (argparse.ArgumentParser): Argument parser to add to. + parser (argparse.ArgumentParser): Argument parser to add to. field (pydantic.fields.ModelField): Field to be added to parser. """ # Add Required Boolean Field parser.add_argument( - argument_name(field.name), + utils.argument_name(field.name), action=argparse.BooleanOptionalAction, - help=argument_description(field.field_info.description), + help=utils.argument_description(field.field_info.description), dest=field.name, required=True, ) @@ -67,7 +63,7 @@ def _parse_boolean_field_optional( """Adds optional boolean pydantic field to argument parser. Args: - parser: (argparse.ArgumentParser): Argument parser to add to. + parser (argparse.ArgumentParser): Argument parser to add to. field (pydantic.fields.ModelField): Field to be added to parser. """ # Get Default @@ -77,10 +73,10 @@ def _parse_boolean_field_optional( if default: # Optional (Default True) parser.add_argument( - argument_name("no-" + field.name), + utils.argument_name("no-" + field.name), action=argparse._StoreFalseAction, # pylint: disable=protected-access default=default, - help=argument_description(field.field_info.description, default), + help=utils.argument_description(field.field_info.description, default), dest=field.name, required=False, ) @@ -88,10 +84,10 @@ def _parse_boolean_field_optional( else: # Optional (Default False) parser.add_argument( - argument_name(field.name), + utils.argument_name(field.name), action=argparse._StoreTrueAction, # pylint: disable=protected-access default=default, - help=argument_description(field.field_info.description, default), + help=utils.argument_description(field.field_info.description, default), dest=field.name, required=False, ) diff --git a/pydantic_argparse/parsers/command.py b/pydantic_argparse/parsers/command.py new file mode 100644 index 0000000..dd438b9 --- /dev/null +++ b/pydantic_argparse/parsers/command.py @@ -0,0 +1,32 @@ +"""command.py + +Provides functions to parse command fields. + +@author Hayden Richards +""" + + +# Standard +import argparse + +# Third-Party +import pydantic + + +def parse_command_field( + subparser: argparse._SubParsersAction, + field: pydantic.fields.ModelField, + ) -> None: + """Adds command pydantic field to argument parser. + + Args: + subparser (argparse._SubParsersAction): Sub-parser to add to. + field (pydantic.fields.ModelField): Field to be added to parser. + """ + # Add Command + subparser.add_parser( + field.name, + help=field.field_info.description, + model=field.outer_type_, + exit_on_error=False, # Allow top level parser to handle exiting + ) diff --git a/pydantic_argparse/parsers/container.py b/pydantic_argparse/parsers/container.py index 8d66407..c670dd2 100644 --- a/pydantic_argparse/parsers/container.py +++ b/pydantic_argparse/parsers/container.py @@ -6,9 +6,6 @@ """ -from __future__ import annotations - - # Standard import argparse @@ -16,7 +13,7 @@ import pydantic # Local -from ..utils import argument_description, argument_name +from pydantic_argparse import utils def parse_container_field( @@ -26,7 +23,7 @@ def parse_container_field( """Adds standard pydantic field to argument parser. Args: - parser: (argparse.ArgumentParser): Argument parser to add to. + parser (argparse.ArgumentParser): Argument parser to add to. field (pydantic.fields.ModelField): Field to be added to parser. """ # List, Tuple, Set, FrozenSet, Deque @@ -46,16 +43,17 @@ def _parse_container_field_required( """Adds required container pydantic field to argument parser. Args: - parser: (argparse.ArgumentParser): Argument parser to add to. + parser (argparse.ArgumentParser): Argument parser to add to. field (pydantic.fields.ModelField): Field to be added to parser. """ # Add Required Container Field parser.add_argument( - argument_name(field.name), + utils.argument_name(field.name), action=argparse._StoreAction, # pylint: disable=protected-access nargs=argparse.ONE_OR_MORE, - help=argument_description(field.field_info.description), + help=utils.argument_description(field.field_info.description), dest=field.name, + metavar=field.name.upper(), required=True, ) @@ -67,7 +65,7 @@ def _parse_container_field_optional( """Adds optional container pydantic field to argument parser. Args: - parser: (argparse.ArgumentParser): Argument parser to add to. + parser (argparse.ArgumentParser): Argument parser to add to. field (pydantic.fields.ModelField): Field to be added to parser. """ # Get Default @@ -75,11 +73,12 @@ def _parse_container_field_optional( # Add Optional Container Field parser.add_argument( - argument_name(field.name), + utils.argument_name(field.name), action=argparse._StoreAction, # pylint: disable=protected-access nargs=argparse.ONE_OR_MORE, default=default, - help=argument_description(field.field_info.description, default), + help=utils.argument_description(field.field_info.description, default), dest=field.name, + metavar=field.name.upper(), required=False, ) diff --git a/pydantic_argparse/parsers/enum.py b/pydantic_argparse/parsers/enum.py index 5fed697..e87e3e3 100644 --- a/pydantic_argparse/parsers/enum.py +++ b/pydantic_argparse/parsers/enum.py @@ -6,9 +6,6 @@ """ -from __future__ import annotations - - # Standard import argparse import enum @@ -17,13 +14,13 @@ import pydantic # Local -from ..utils import argument_description, argument_name, type_caster +from pydantic_argparse import utils # Typing from typing import TypeVar # pylint: disable=wrong-import-order -# Typing +# Constants EnumT = TypeVar("EnumT", bound=enum.Enum) @@ -34,7 +31,7 @@ def parse_enum_field( """Adds enum pydantic field to argument parser. Args: - parser: (argparse.ArgumentParser): Argument parser to add to. + parser (argparse.ArgumentParser): Argument parser to add to. field (pydantic.fields.ModelField): Field to be added to parser. """ # Enums are treated as choices @@ -54,23 +51,24 @@ def _parse_enum_field_required( """Adds required enum pydantic field to argument parser. Args: - parser: (argparse.ArgumentParser): Argument parser to add to. + parser (argparse.ArgumentParser): Argument parser to add to. field (pydantic.fields.ModelField): Field to be added to parser. """ # Get Enum Type enum_type: type[enum.Enum] = field.outer_type_ # Define Custom Type Caster - caster = type_caster(field.name, _arg_to_enum_member, enum_type=enum_type) + caster = utils.type_caster(field.name, _arg_to_enum_member, enum_type=enum_type) # Add Required Enum Field parser.add_argument( - argument_name(field.name), + utils.argument_name(field.name), action=argparse._StoreAction, # pylint: disable=protected-access type=caster, choices=enum_type, - help=argument_description(field.field_info.description), + help=utils.argument_description(field.field_info.description), dest=field.name, + metavar=field.name.upper(), required=True, ) @@ -82,14 +80,14 @@ def _parse_enum_field_optional( """Adds optional enum pydantic field to argument parser. Args: - parser: (argparse.ArgumentParser): Argument parser to add to. + parser (argparse.ArgumentParser): Argument parser to add to. field (pydantic.fields.ModelField): Field to be added to parser. """ # Get Enum Type enum_type: type[enum.Enum] = field.outer_type_ # Define Custom Type Caster - caster = type_caster(field.name, _arg_to_enum_member, enum_type=enum_type) + caster = utils.type_caster(field.name, _arg_to_enum_member, enum_type=enum_type) # Get Default default = field.get_default() @@ -100,37 +98,40 @@ def _parse_enum_field_optional( if default is not None and field.allow_none: # Optional Flag (Default Not None) parser.add_argument( - argument_name("no-" + field.name), + utils.argument_name("no-" + field.name), action=argparse._StoreConstAction, # pylint: disable=protected-access const=None, default=default, - help=argument_description(field.field_info.description, default), + help=utils.argument_description(field.field_info.description, default), dest=field.name, + metavar=field.name.upper(), required=False, ) else: # Optional Flag (Default None) parser.add_argument( - argument_name(field.name), + utils.argument_name(field.name), action=argparse._StoreConstAction, # pylint: disable=protected-access const=list(enum_type)[0], default=default, - help=argument_description(field.field_info.description, default), + help=utils.argument_description(field.field_info.description, default), dest=field.name, + metavar=field.name.upper(), required=False, ) else: # Optional Choice parser.add_argument( - argument_name(field.name), + utils.argument_name(field.name), action=argparse._StoreAction, # pylint: disable=protected-access type=caster, choices=enum_type, default=default, - help=argument_description(field.field_info.description, default), + help=utils.argument_description(field.field_info.description, default), dest=field.name, + metavar=field.name.upper(), required=False, ) diff --git a/pydantic_argparse/parsers/json.py b/pydantic_argparse/parsers/json.py index 1efc55c..07dd9a2 100644 --- a/pydantic_argparse/parsers/json.py +++ b/pydantic_argparse/parsers/json.py @@ -6,9 +6,6 @@ """ -from __future__ import annotations - - # Standard import argparse import ast @@ -17,7 +14,7 @@ import pydantic # Local -from ..utils import argument_description, argument_name, type_caster +from pydantic_argparse import utils def parse_json_field( @@ -27,7 +24,7 @@ def parse_json_field( """Adds json pydantic field to argument parser. Args: - parser: (argparse.ArgumentParser): Argument parser to add to. + parser (argparse.ArgumentParser): Argument parser to add to. field (pydantic.fields.ModelField): Field to be added to parser. """ # JSON (Dictionary) @@ -47,19 +44,20 @@ def _parse_json_field_required( """Adds required json pydantic field to argument parser. Args: - parser: (argparse.ArgumentParser): Argument parser to add to. + parser (argparse.ArgumentParser): Argument parser to add to. field (pydantic.fields.ModelField): Field to be added to parser. """ # Define Custom Type Caster - caster = type_caster(field.name, ast.literal_eval) + caster = utils.type_caster(field.name, ast.literal_eval) # Add Required JSON Field parser.add_argument( - argument_name(field.name), + utils.argument_name(field.name), action=argparse._StoreAction, # pylint: disable=protected-access type=caster, - help=argument_description(field.field_info.description), + help=utils.argument_description(field.field_info.description), dest=field.name, + metavar=field.name.upper(), required=True, ) @@ -71,22 +69,23 @@ def _parse_json_field_optional( """Adds optional json pydantic field to argument parser. Args: - parser: (argparse.ArgumentParser): Argument parser to add to. + parser (argparse.ArgumentParser): Argument parser to add to. field (pydantic.fields.ModelField): Field to be added to parser. """ # Define Custom Type Caster - caster = type_caster(field.name, ast.literal_eval) + caster = utils.type_caster(field.name, ast.literal_eval) # Get Default default = field.get_default() # Add Optional JSON Field parser.add_argument( - argument_name(field.name), + utils.argument_name(field.name), action=argparse._StoreAction, # pylint: disable=protected-access type=caster, default=default, - help=argument_description(field.field_info.description, default), + help=utils.argument_description(field.field_info.description, default), dest=field.name, + metavar=field.name.upper(), required=False, ) diff --git a/pydantic_argparse/parsers/literal.py b/pydantic_argparse/parsers/literal.py index 9f6a21e..8cf8ca6 100644 --- a/pydantic_argparse/parsers/literal.py +++ b/pydantic_argparse/parsers/literal.py @@ -6,9 +6,6 @@ """ -from __future__ import annotations - - # Standard import argparse @@ -17,7 +14,7 @@ import typing_inspect # Local -from ..utils import argument_description, argument_name, type_caster +from pydantic_argparse import utils # Typing from typing import TypeVar # pylint: disable=wrong-import-order @@ -34,7 +31,7 @@ def parse_literal_field( """Adds enum pydantic field to argument parser. Args: - parser: (argparse.ArgumentParser): Argument parser to add to. + parser (argparse.ArgumentParser): Argument parser to add to. field (pydantic.fields.ModelField): Field to be added to parser. """ # Literals are treated as constant flags, or choices @@ -54,23 +51,24 @@ def _parse_literal_field_required( """Adds required literal pydantic field to argument parser. Args: - parser: (argparse.ArgumentParser): Argument parser to add to. + parser (argparse.ArgumentParser): Argument parser to add to. field (pydantic.fields.ModelField): Field to be added to parser. """ # Get choices from literal choices = list(typing_inspect.get_args(field.outer_type_)) # Define Custom Type Caster - caster = type_caster(field.name, _arg_to_choice, choices=choices) + caster = utils.type_caster(field.name, _arg_to_choice, choices=choices) # Add Required Literal Field parser.add_argument( - argument_name(field.name), + utils.argument_name(field.name), action=argparse._StoreAction, # pylint: disable=protected-access type=caster, choices=choices, - help=argument_description(field.field_info.description), + help=utils.argument_description(field.field_info.description), dest=field.name, + metavar=field.name.upper(), required=True, ) @@ -82,14 +80,14 @@ def _parse_literal_field_optional( """Adds optional literal pydantic field to argument parser. Args: - parser: (argparse.ArgumentParser): Argument parser to add to. + parser (argparse.ArgumentParser): Argument parser to add to. field (pydantic.fields.ModelField): Field to be added to parser. """ # Get choices from literal choices = list(typing_inspect.get_args(field.outer_type_)) # Define Custom Type Caster - caster = type_caster(field.name, _arg_to_choice, choices=choices) + caster = utils.type_caster(field.name, _arg_to_choice, choices=choices) # Get Default default = field.get_default() @@ -100,37 +98,40 @@ def _parse_literal_field_optional( if default is not None and field.allow_none: # Optional Flag (Default Not None) parser.add_argument( - argument_name("no-" + field.name), + utils.argument_name("no-" + field.name), action=argparse._StoreConstAction, # pylint: disable=protected-access const=None, default=default, - help=argument_description(field.field_info.description, default), + help=utils.argument_description(field.field_info.description, default), dest=field.name, + metavar=field.name.upper(), required=False, ) else: # Optional Flag (Default None) parser.add_argument( - argument_name(field.name), + utils.argument_name(field.name), action=argparse._StoreConstAction, # pylint: disable=protected-access const=choices[0], default=default, - help=argument_description(field.field_info.description, default), + help=utils.argument_description(field.field_info.description, default), dest=field.name, + metavar=field.name.upper(), required=False, ) else: # Optional Choice parser.add_argument( - argument_name(field.name), + utils.argument_name(field.name), action=argparse._StoreAction, # pylint: disable=protected-access type=caster, choices=choices, default=default, - help=argument_description(field.field_info.description, default), + help=utils.argument_description(field.field_info.description, default), dest=field.name, + metavar=field.name.upper(), required=False, ) diff --git a/pydantic_argparse/parsers/standard.py b/pydantic_argparse/parsers/standard.py index 0a59eb6..3e19d64 100644 --- a/pydantic_argparse/parsers/standard.py +++ b/pydantic_argparse/parsers/standard.py @@ -6,9 +6,6 @@ """ -from __future__ import annotations - - # Standard import argparse @@ -16,7 +13,7 @@ import pydantic # Local -from ..utils import argument_description, argument_name +from pydantic_argparse import utils def parse_standard_field( @@ -26,7 +23,7 @@ def parse_standard_field( """Adds standard pydantic field to argument parser. Args: - parser: (argparse.ArgumentParser): Argument parser to add to. + parser (argparse.ArgumentParser): Argument parser to add to. field (pydantic.fields.ModelField): Field to be added to parser. """ # All other types are treated in a standard way @@ -46,15 +43,16 @@ def _parse_standard_field_required( """Adds required standard pydantic field to argument parser. Args: - parser: (argparse.ArgumentParser): Argument parser to add to. + parser (argparse.ArgumentParser): Argument parser to add to. field (pydantic.fields.ModelField): Field to be added to parser. """ # Add Required Standard Field parser.add_argument( - argument_name(field.name), + utils.argument_name(field.name), action=argparse._StoreAction, # pylint: disable=protected-access - help=argument_description(field.field_info.description), + help=utils.argument_description(field.field_info.description), dest=field.name, + metavar=field.name.upper(), required=True, ) @@ -66,7 +64,7 @@ def _parse_standard_field_optional( """Adds optional standard pydantic field to argument parser. Args: - parser: (argparse.ArgumentParser): Argument parser to add to. + parser (argparse.ArgumentParser): Argument parser to add to. field (pydantic.fields.ModelField): Field to be added to parser. """ # Get Default @@ -74,10 +72,11 @@ def _parse_standard_field_optional( # Add Optional Standard Field parser.add_argument( - argument_name(field.name), + utils.argument_name(field.name), action=argparse._StoreAction, # pylint: disable=protected-access default=default, - help=argument_description(field.field_info.description, default), + help=utils.argument_description(field.field_info.description, default), dest=field.name, + metavar=field.name.upper(), required=False, ) diff --git a/pydantic_argparse/utils/__init__.py b/pydantic_argparse/utils/__init__.py index d9f3856..3a00958 100644 --- a/pydantic_argparse/utils/__init__.py +++ b/pydantic_argparse/utils/__init__.py @@ -5,6 +5,5 @@ @author Hayden Richards """ - # Local -from .utils import argument_description, argument_name, type_caster +from .utils import argument_description, argument_name, namespace_to_dict, type_caster diff --git a/pydantic_argparse/utils/utils.py b/pydantic_argparse/utils/utils.py index edd6878..7b4acb5 100644 --- a/pydantic_argparse/utils/utils.py +++ b/pydantic_argparse/utils/utils.py @@ -6,10 +6,8 @@ """ -from __future__ import annotations - - # Standard +import argparse import functools # Typing @@ -19,8 +17,28 @@ # Constants # Arbitrary 'MISSING' object is required for functions where 'None' is a valid # and possible argument to specify. -MISSING = object() T = TypeVar("T") +MISSING = object() + + +def argument_description( + description: Optional[str], + default: Optional[Any]=MISSING, + ) -> str: + """Standardises argument description. + + Args: + description (Optional[str]): Optional description for argument. + default (Optional[Any]): Default value for argument if applicable. + + Returns: + str: Standardised description of the argument. + """ + # Construct Default String + default = f"(default: {default})" if default is not MISSING else None + + # Return Standardised Description String + return " ".join(filter(None, [description, default])) def argument_name(name: str) -> str: @@ -36,24 +54,27 @@ def argument_name(name: str) -> str: return f"--{name.replace('_', '-')}" -def argument_description( - description: Optional[str], - default: Optional[Any]=MISSING, - ) -> str: - """Standardises argument description. +def namespace_to_dict(namespace: argparse.Namespace) -> dict[str, Any]: + """Converts a nested namespace to a dictionary recursively. Args: - description (Optional[str]): Optional description for argument. - default (Optional[Any]): Default value for argument if applicable. + namespace (argparse.Namespace): Namespace object to convert. Returns: - str: Standardised description of the argument. + dict[str, Any]: Nested dictionary generated from namespace. """ - # Construct Default String - default = f"(default: {default})" if default is not MISSING else None + # Get Dictionary from Namespace Vars + dictionary = vars(namespace) - # Return Standardised Description String - return " ".join(filter(None, [description, default])) + # Loop Through Dictionary + for (key, value) in dictionary.items(): + # Check for Namespace Objects + if isinstance(value, argparse.Namespace): + # Recurse + dictionary[key] = namespace_to_dict(value) + + # Return + return dictionary def type_caster( diff --git a/pyproject.toml b/pyproject.toml index 7f28889..64c8736 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,8 +30,6 @@ pytest = "^6.2.5" pytest-cov = {extras = ["toml"], version = "^3.0.0"} pytest-mock = "^3.6.1" poethepoet = "^0.10.0" -toml = "^0.10.2" -types-toml = "^0.10.0" [tool.poe.tasks] test = "pytest tests --cov=pydantic_argparse" diff --git a/tests/argparse/__init__.py b/tests/argparse/__init__.py new file mode 100644 index 0000000..86efe65 --- /dev/null +++ b/tests/argparse/__init__.py @@ -0,0 +1,7 @@ +"""__init__.py + +This file is required to mark the unit tests as a package, so they can resolve +and import the actual top level package code + +@author Hayden Richards +""" diff --git a/tests/argparse/test_actions.py b/tests/argparse/test_actions.py new file mode 100644 index 0000000..a565ec3 --- /dev/null +++ b/tests/argparse/test_actions.py @@ -0,0 +1,104 @@ +"""test_actions.py + +Tests actions Module. + +@author Hayden Richards +""" + + +# Standard +import argparse + +# Third-Party +import pytest + +# Local +from pydantic_argparse.argparse import actions + + +def test_invalid_command(sub_parsers_action: actions.SubParsersAction) -> None: + """Tests SubParsersAction with invalid command. + + Args: + sub_parsers_action (SubParsersAction): PyTest SubParsersAction fixture. + """ + # Assert Raises + with pytest.raises(argparse.ArgumentError): + # Test Invalid Command + sub_parsers_action( + parser=argparse.ArgumentParser(), + namespace=argparse.Namespace(), + values=["fake", "--not-real"], + ) + + +def test_valid_command(sub_parsers_action: actions.SubParsersAction) -> None: + """Tests SubParsersAction with valid command. + + Args: + sub_parsers_action (SubParsersAction): PyTest SubParsersAction fixture. + """ + # Add Test Argument + sub_parsers_action.add_parser("test") + + # Create Namespace + namespace = argparse.Namespace() + + # Test Valid Command + sub_parsers_action( + parser=argparse.ArgumentParser(), + namespace=namespace, + values=["test"], + ) + + # Assert + assert getattr(namespace, "test") == argparse.Namespace() + + +def test_unrecognised_args(sub_parsers_action: actions.SubParsersAction) -> None: + """Tests SubParsersAction with unrecognised args. + + Args: + sub_parsers_action (SubParsersAction): PyTest SubParsersAction fixture. + """ + # Add Test Argument + sub_parsers_action.add_parser("test") + + # Create Namespace + namespace = argparse.Namespace() + + # Test Unrecognised Args + sub_parsers_action( + parser=argparse.ArgumentParser(), + namespace=namespace, + values=["test", "--flag"], + ) + + # Assert + assert getattr(namespace, "test") == argparse.Namespace() + assert getattr(namespace, argparse._UNRECOGNIZED_ARGS_ATTR) == ["--flag"] # pylint: disable=protected-access + + +def test_deep_unrecognised_args(sub_parsers_action: actions.SubParsersAction) -> None: + """Tests SubParsersAction with deeply nested unrecognised args. + + Args: + sub_parsers_action (SubParsersAction): PyTest SubParsersAction fixture. + """ + # Add Test Argument + deep: argparse.ArgumentParser = sub_parsers_action.add_parser("test") + deep.add_subparsers(action=actions.SubParsersAction).add_parser("deep") + + # Create Namespace + namespace = argparse.Namespace() + + # Test Deeply Nested Unrecognised Args + sub_parsers_action( + parser=argparse.ArgumentParser(), + namespace=namespace, + values=["test", "--a", "deep", "--b"], + ) + + # Assert + assert getattr(namespace, "test") == argparse.Namespace(deep=argparse.Namespace()) + assert getattr(namespace, argparse._UNRECOGNIZED_ARGS_ATTR) == ["--a", "--b"] # pylint: disable=protected-access diff --git a/tests/test_parser.py b/tests/argparse/test_parser.py similarity index 56% rename from tests/test_parser.py rename to tests/argparse/test_parser.py index 3e1856f..22caa9e 100644 --- a/tests/test_parser.py +++ b/tests/argparse/test_parser.py @@ -6,17 +6,10 @@ """ -from __future__ import absolute_import -from __future__ import annotations -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals - - # Standard import argparse -from collections import deque -from datetime import date, datetime, time, timedelta +import collections as coll +import datetime as dt import re import textwrap @@ -25,8 +18,8 @@ import pytest # Local -from pydantic_argparse import ArgumentParser -from tests.conftest import ExampleModel, ExampleEnum, ExampleEnumSingle +import pydantic_argparse +import tests.conftest as conf # Typing from typing import Any, Literal, Optional, Tuple, TypeVar # pylint: disable=wrong-import-order @@ -61,8 +54,8 @@ def test_create_argparser( exit_on_error (bool): Whether to exit on error for testing. """ # Create ArgumentParser - parser = ArgumentParser( - model=ExampleModel, + parser = pydantic_argparse.ArgumentParser( + model=conf.TestModel, prog=prog, description=description, version=version, @@ -72,7 +65,7 @@ def test_create_argparser( ) # Asserts - assert isinstance(parser, ArgumentParser) + assert isinstance(parser, pydantic_argparse.ArgumentParser) @pytest.mark.parametrize( @@ -84,26 +77,26 @@ def test_create_argparser( ], [ # Required Arguments - (int, ..., "--test 123", 123), - (float, ..., "--test 4.56", 4.56), - (str, ..., "--test hello", "hello"), - (bytes, ..., "--test bytes", b"bytes"), - (list[str], ..., "--test a b c", list(("a", "b", "c"))), - (Tuple[str, str, str], ..., "--test a b c", tuple(("a", "b", "c"))), - (set[str], ..., "--test a b c", set(("a", "b", "c"))), - (frozenset[str], ..., "--test a b c", frozenset(("a", "b", "c"))), - (deque[str], ..., "--test a b c", deque(("a", "b", "c"))), - (dict[str, int], ..., "--test {'a':2}", dict(a=2)), - (date, ..., "--test 2021-12-25", date(2021, 12, 25)), - (datetime, ..., "--test 2021-12-25T12:34", datetime(2021, 12, 25, 12, 34)), - (time, ..., "--test 12:34", time(12, 34)), - (timedelta, ..., "--test PT12H", timedelta(hours=12)), - (bool, ..., "--test", True), - (bool, ..., "--no-test", False), - (Literal["A"], ..., "--test A", "A"), - (Literal["A", 1], ..., "--test 1", 1), - (ExampleEnumSingle, ..., "--test D", ExampleEnumSingle.D), - (ExampleEnum, ..., "--test C", ExampleEnum.C), + (int, ..., "--test 123", 123), + (float, ..., "--test 4.56", 4.56), + (str, ..., "--test hello", "hello"), + (bytes, ..., "--test bytes", b"bytes"), + (list[str], ..., "--test a b c", list(("a", "b", "c"))), + (Tuple[str, str, str], ..., "--test a b c", tuple(("a", "b", "c"))), + (set[str], ..., "--test a b c", set(("a", "b", "c"))), + (frozenset[str], ..., "--test a b c", frozenset(("a", "b", "c"))), + (coll.deque[str], ..., "--test a b c", coll.deque(("a", "b", "c"))), + (dict[str, int], ..., "--test {'a':2}", dict(a=2)), + (dt.date, ..., "--test 2021-12-25", dt.date(2021, 12, 25)), + (dt.datetime, ..., "--test 2021-12-25T12:34", dt.datetime(2021, 12, 25, 12, 34)), + (dt.time, ..., "--test 12:34", dt.time(12, 34)), + (dt.timedelta, ..., "--test PT12H", dt.timedelta(hours=12)), + (bool, ..., "--test", True), + (bool, ..., "--no-test", False), + (Literal["A"], ..., "--test A", "A"), + (Literal["A", 1], ..., "--test 1", 1), + (conf.TestEnumSingle, ..., "--test D", conf.TestEnumSingle.D), + (conf.TestEnum, ..., "--test C", conf.TestEnum.C), # Optional Arguments (With Default) (int, 456, "--test 123", 123), @@ -114,18 +107,18 @@ def test_create_argparser( (Tuple[str, str, str], tuple(("d", "e", "f")), "--test a b c", tuple(("a", "b", "c"))), (set[str], set(("d", "e", "f")), "--test a b c", set(("a", "b", "c"))), (frozenset[str], frozenset(("d", "e", "f")), "--test a b c", frozenset(("a", "b", "c"))), - (deque[str], deque(("d", "e", "f")), "--test a b c", deque(("a", "b", "c"))), + (coll.deque[str], coll.deque(("d", "e", "f")), "--test a b c", coll.deque(("a", "b", "c"))), (dict[str, int], dict(b=3), "--test {'a':2}", dict(a=2)), - (date, date(2021, 7, 21), "--test 2021-12-25", date(2021, 12, 25)), - (datetime, datetime(2021, 7, 21, 3, 21), "--test 2021-04-03T02:01", datetime(2021, 4, 3, 2, 1)), - (time, time(3, 21), "--test 12:34", time(12, 34)), - (timedelta, timedelta(hours=6), "--test PT12H", timedelta(hours=12)), + (dt.date, dt.date(2021, 7, 21), "--test 2021-12-25", dt.date(2021, 12, 25)), + (dt.datetime, dt.datetime(2021, 7, 21, 3), "--test 2021-04-03T02:00", dt.datetime(2021, 4, 3, 2)), + (dt.time, dt.time(3, 21), "--test 12:34", dt.time(12, 34)), + (dt.timedelta, dt.timedelta(hours=6), "--test PT12H", dt.timedelta(hours=12)), (bool, False, "--test", True), (bool, True, "--no-test", False), (Literal["A"], "A", "--test", "A"), (Literal["A", 1], "A", "--test 1", 1), - (ExampleEnumSingle, ExampleEnumSingle.D, "--test", ExampleEnumSingle.D), - (ExampleEnum, ExampleEnum.B, "--test C", ExampleEnum.C), + (conf.TestEnumSingle, conf.TestEnumSingle.D, "--test", conf.TestEnumSingle.D), + (conf.TestEnum, conf.TestEnum.B, "--test C", conf.TestEnum.C), # Optional Arguments (With Default) (No Value Given) (int, 456, "", 456), @@ -136,18 +129,18 @@ def test_create_argparser( (Tuple[str, str, str], tuple(("d", "e", "f")), "", tuple(("d", "e", "f"))), (set[str], set(("d", "e", "f")), "", set(("d", "e", "f"))), (frozenset[str], frozenset(("d", "e", "f")), "", frozenset(("d", "e", "f"))), - (deque[str], deque(("d", "e", "f")), "", deque(("d", "e", "f"))), + (coll.deque[str], coll.deque(("d", "e", "f")), "", coll.deque(("d", "e", "f"))), (dict[str, int], dict(b=3), "", dict(b=3)), - (date, date(2021, 7, 21), "", date(2021, 7, 21)), - (datetime, datetime(2021, 7, 21, 3, 21), "", datetime(2021, 7, 21, 3, 21)), - (time, time(3, 21), "", time(3, 21)), - (timedelta, timedelta(hours=6), "", timedelta(hours=6)), + (dt.date, dt.date(2021, 7, 21), "", dt.date(2021, 7, 21)), + (dt.datetime, dt.datetime(2021, 7, 21, 3, 7), "", dt.datetime(2021, 7, 21, 3, 7)), + (dt.time, dt.time(3, 21), "", dt.time(3, 21)), + (dt.timedelta, dt.timedelta(hours=6), "", dt.timedelta(hours=6)), (bool, False, "", False), (bool, True, "", True), (Literal["A"], "A", "", "A"), (Literal["A", 1], "A", "", "A"), - (ExampleEnumSingle, ExampleEnumSingle.D, "", ExampleEnumSingle.D), - (ExampleEnum, ExampleEnum.B, "", ExampleEnum.B), + (conf.TestEnumSingle, conf.TestEnumSingle.D, "", conf.TestEnumSingle.D), + (conf.TestEnum, conf.TestEnum.B, "", conf.TestEnum.B), # Optional Arguments (No Default) (Optional[int], None, "--test 123", 123), @@ -158,17 +151,17 @@ def test_create_argparser( (Optional[Tuple[str, str, str]], None, "--test a b c", tuple(("a", "b", "c"))), (Optional[set[str]], None, "--test a b c", set(("a", "b", "c"))), (Optional[frozenset[str]], None, "--test a b c", frozenset(("a", "b", "c"))), - (Optional[deque[str]], None, "--test a b c", deque(("a", "b", "c"))), + (Optional[coll.deque[str]], None, "--test a b c", coll.deque(("a", "b", "c"))), (Optional[dict[str, int]], None, "--test {'a':2}", dict(a=2)), - (Optional[date], None, "--test 2021-12-25", date(2021, 12, 25)), - (Optional[datetime], None, "--test 2021-12-25T12:34", datetime(2021, 12, 25, 12, 34)), - (Optional[time], None, "--test 12:34", time(12, 34)), - (Optional[timedelta], None, "--test PT12H", timedelta(hours=12)), + (Optional[dt.date], None, "--test 2021-12-25", dt.date(2021, 12, 25)), + (Optional[dt.datetime], None, "--test 2021-12-25T12:34", dt.datetime(2021, 12, 25, 12, 34)), + (Optional[dt.time], None, "--test 12:34", dt.time(12, 34)), + (Optional[dt.timedelta], None, "--test PT12H", dt.timedelta(hours=12)), (Optional[bool], None, "--test", True), (Optional[Literal["A"]], None, "--test", "A"), (Optional[Literal["A", 1]], None, "--test 1", 1), - (Optional[ExampleEnumSingle], None, "--test", ExampleEnumSingle.D), - (Optional[ExampleEnum], None, "--test C", ExampleEnum.C), + (Optional[conf.TestEnumSingle], None, "--test", conf.TestEnumSingle.D), + (Optional[conf.TestEnum], None, "--test C", conf.TestEnum.C), # Optional Arguments (No Default) (No Value Given) (Optional[int], None, "", None), @@ -179,30 +172,46 @@ def test_create_argparser( (Optional[Tuple[str, str, str]], None, "", None), (Optional[set[str]], None, "", None), (Optional[frozenset[str]], None, "", None), - (Optional[deque[str]], None, "", None), + (Optional[coll.deque[str]], None, "", None), (Optional[dict[str, int]], None, "", None), - (Optional[date], None, "", None), - (Optional[datetime], None, "", None), - (Optional[time], None, "", None), - (Optional[timedelta], None, "", None), + (Optional[dt.date], None, "", None), + (Optional[dt.datetime], None, "", None), + (Optional[dt.time], None, "", None), + (Optional[dt.timedelta], None, "", None), (Optional[bool], None, "", None), (Optional[Literal["A"]], None, "", None), (Optional[Literal["A", 1]], None, "", None), - (Optional[ExampleEnumSingle], None, "", None), - (Optional[ExampleEnum], None, "", None), + (Optional[conf.TestEnumSingle], None, "", None), + (Optional[conf.TestEnum], None, "", None), # Special Enums and Literals Optional Flag Behaviour - (Optional[Literal["A"]], None, "--test", "A"), - (Optional[Literal["A"]], None, "", None), - (Optional[Literal["A"]], "A", "--no-test", None), - (Optional[Literal["A"]], "A", "", "A"), - (Optional[ExampleEnumSingle], None, "--test", ExampleEnumSingle.D), - (Optional[ExampleEnumSingle], None, "", None), - (Optional[ExampleEnumSingle], ExampleEnumSingle.D, "--no-test", None), - (Optional[ExampleEnumSingle], ExampleEnumSingle.D, "", ExampleEnumSingle.D), + (Optional[Literal["A"]], None, "--test", "A"), + (Optional[Literal["A"]], None, "", None), + (Optional[Literal["A"]], "A", "--no-test", None), + (Optional[Literal["A"]], "A", "", "A"), + (Optional[conf.TestEnumSingle], None, "--test", conf.TestEnumSingle.D), + (Optional[conf.TestEnumSingle], None, "", None), + (Optional[conf.TestEnumSingle], conf.TestEnumSingle.D, "--no-test", None), + (Optional[conf.TestEnumSingle], conf.TestEnumSingle.D, "", conf.TestEnumSingle.D), + + # Commands + (conf.TestCommand, ..., "test", conf.TestCommand()), + (conf.TestCommands, ..., "test cmd_01", conf.TestCommands(cmd_01=conf.TestCommand())), + (conf.TestCommands, ..., "test cmd_02", conf.TestCommands(cmd_02=conf.TestCommand())), + (conf.TestCommands, ..., "test cmd_03", conf.TestCommands(cmd_03=conf.TestCommand())), + (conf.TestCommands, ..., "test cmd_01 --flag", conf.TestCommands(cmd_01=conf.TestCommand(flag=True))), + (conf.TestCommands, ..., "test cmd_02 --flag", conf.TestCommands(cmd_02=conf.TestCommand(flag=True))), + (conf.TestCommands, ..., "test cmd_03 --flag", conf.TestCommands(cmd_03=conf.TestCommand(flag=True))), + (Optional[conf.TestCommand], ..., "test", conf.TestCommand()), + (Optional[conf.TestCommands], ..., "test cmd_01", conf.TestCommands(cmd_01=conf.TestCommand())), + (Optional[conf.TestCommands], ..., "test cmd_02", conf.TestCommands(cmd_02=conf.TestCommand())), + (Optional[conf.TestCommands], ..., "test cmd_03", conf.TestCommands(cmd_03=conf.TestCommand())), + (Optional[conf.TestCommands], ..., "test cmd_01 --flag", conf.TestCommands(cmd_01=conf.TestCommand(flag=True))), + (Optional[conf.TestCommands], ..., "test cmd_02 --flag", conf.TestCommands(cmd_02=conf.TestCommand(flag=True))), + (Optional[conf.TestCommands], ..., "test cmd_03 --flag", conf.TestCommands(cmd_03=conf.TestCommand(flag=True))), ] ) -def test_arguments( +def test_valid_arguments( argument_type: type[ArgumentT], argument_default: ArgumentT, arguments: str, @@ -223,7 +232,7 @@ def test_arguments( ) # Create ArgumentParser - parser = ArgumentParser(model) + parser = pydantic_argparse.ArgumentParser(model) # Parse args = parser.parse_typed_args(arguments.split()) @@ -247,17 +256,17 @@ def test_arguments( (Tuple[int, int, int], ..., "--test invalid"), (set[int], ..., "--test invalid"), (frozenset[int], ..., "--test invalid"), - (deque[int], ..., "--test invalid"), + (coll.deque[int], ..., "--test invalid"), (dict[str, int], ..., "--test invalid"), - (date, ..., "--test invalid"), - (datetime, ..., "--test invalid"), - (time, ..., "--test invalid"), - (timedelta, ..., "--test invalid"), + (dt.date, ..., "--test invalid"), + (dt.datetime, ..., "--test invalid"), + (dt.time, ..., "--test invalid"), + (dt.timedelta, ..., "--test invalid"), (bool, ..., "--test invalid"), (Literal["A"], ..., "--test invalid"), (Literal["A", 1], ..., "--test invalid"), - (ExampleEnumSingle, ..., "--test invalid"), - (ExampleEnum, ..., "--test invalid"), + (conf.TestEnumSingle, ..., "--test invalid"), + (conf.TestEnum, ..., "--test invalid"), # Missing Argument Values (int, ..., "--test"), @@ -268,16 +277,16 @@ def test_arguments( (Tuple[int, int, int], ..., "--test"), (set[int], ..., "--test"), (frozenset[int], ..., "--test"), - (deque[int], ..., "--test"), + (coll.deque[int], ..., "--test"), (dict[str, int], ..., "--test"), - (date, ..., "--test"), - (datetime, ..., "--test"), - (time, ..., "--test"), - (timedelta, ..., "--test"), + (dt.date, ..., "--test"), + (dt.datetime, ..., "--test"), + (dt.time, ..., "--test"), + (dt.timedelta, ..., "--test"), (Literal["A"], ..., "--test"), (Literal["A", 1], ..., "--test"), - (ExampleEnumSingle, ..., "--test"), - (ExampleEnum, ..., "--test"), + (conf.TestEnumSingle, ..., "--test"), + (conf.TestEnum, ..., "--test"), # Missing Arguments (int, ..., ""), @@ -288,17 +297,17 @@ def test_arguments( (Tuple[int, int, int], ..., ""), (set[int], ..., ""), (frozenset[int], ..., ""), - (deque[int], ..., ""), + (coll.deque[int], ..., ""), (dict[str, int], ..., ""), - (date, ..., ""), - (datetime, ..., ""), - (time, ..., ""), - (timedelta, ..., ""), + (dt.date, ..., ""), + (dt.datetime, ..., ""), + (dt.time, ..., ""), + (dt.timedelta, ..., ""), (bool, ..., ""), (Literal["A"], ..., ""), (Literal["A", 1], ..., ""), - (ExampleEnumSingle, ..., ""), - (ExampleEnum, ..., ""), + (conf.TestEnumSingle, ..., ""), + (conf.TestEnum, ..., ""), # Invalid Optional Arguments (Optional[int], None, "--test invalid"), @@ -307,17 +316,17 @@ def test_arguments( (Optional[Tuple[int, int, int]], None, "--test invalid"), (Optional[set[int]], None, "--test invalid"), (Optional[frozenset[int]], None, "--test invalid"), - (Optional[deque[int]], None, "--test invalid"), + (Optional[coll.deque[int]], None, "--test invalid"), (Optional[dict[str, int]], None, "--test invalid"), - (Optional[date], None, "--test invalid"), - (Optional[datetime], None, "--test invalid"), - (Optional[time], None, "--test invalid"), - (Optional[timedelta], None, "--test invalid"), + (Optional[dt.date], None, "--test invalid"), + (Optional[dt.datetime], None, "--test invalid"), + (Optional[dt.time], None, "--test invalid"), + (Optional[dt.timedelta], None, "--test invalid"), (Optional[bool], None, "--test invalid"), (Optional[Literal["A"]], None, "--test invalid"), (Optional[Literal["A", 1]], None, "--test invalid"), - (Optional[ExampleEnumSingle], None, "--test invalid"), - (Optional[ExampleEnum], None, "--test invalid"), + (Optional[conf.TestEnumSingle], None, "--test invalid"), + (Optional[conf.TestEnum], None, "--test invalid"), # Missing Optional Argument Values (Optional[int], None, "--test"), @@ -328,14 +337,24 @@ def test_arguments( (Optional[Tuple[int, int, int]], None, "--test"), (Optional[set[int]], None, "--test"), (Optional[frozenset[int]], None, "--test"), - (Optional[deque[int]], None, "--test"), + (Optional[coll.deque[int]], None, "--test"), (Optional[dict[str, int]], None, "--test"), - (Optional[date], None, "--test"), - (Optional[datetime], None, "--test"), - (Optional[time], None, "--test"), - (Optional[timedelta], None, "--test"), + (Optional[dt.date], None, "--test"), + (Optional[dt.datetime], None, "--test"), + (Optional[dt.time], None, "--test"), + (Optional[dt.timedelta], None, "--test"), (Optional[Literal["A", 1]], None, "--test"), - (Optional[ExampleEnum], None, "--test"), + (Optional[conf.TestEnum], None, "--test"), + + # Commands + (conf.TestCommand, ..., ""), + (conf.TestCommand, ..., "invalid"), + (conf.TestCommands, ..., "test"), + (conf.TestCommands, ..., "test invalid"), + (Optional[conf.TestCommand], ..., ""), + (Optional[conf.TestCommand], ..., "invalid"), + (Optional[conf.TestCommands], ..., "test"), + (Optional[conf.TestCommands], ..., "test invalid"), ] ) @pytest.mark.parametrize( @@ -371,7 +390,7 @@ def test_invalid_arguments( ) # Create ArgumentParser - parser = ArgumentParser(model, exit_on_error=exit_on_error) + parser = pydantic_argparse.ArgumentParser(model, exit_on_error=exit_on_error) # Assert Parser Raises Error with pytest.raises(error): @@ -389,7 +408,7 @@ def test_help_message(capsys: pytest.CaptureFixture[str]) -> None: model: Any = pydantic.create_model("model") # Create ArgumentParser - parser = ArgumentParser( + parser = pydantic_argparse.ArgumentParser( model=model, prog="AA", description="BB", @@ -429,7 +448,7 @@ def test_version_message(capsys: pytest.CaptureFixture[str]) -> None: model: Any = pydantic.create_model("model") # Create ArgumentParser - parser = ArgumentParser( + parser = pydantic_argparse.ArgumentParser( model=model, prog="AA", description="BB", @@ -456,7 +475,7 @@ def test_version_message(capsys: pytest.CaptureFixture[str]) -> None: "argument_name", "argument_field", ], - ExampleModel.__fields__.items() + conf.TestModel.__fields__.items() ) def test_argument_descriptions( argument_name: str, @@ -471,7 +490,7 @@ def test_argument_descriptions( capsys (pytest.CaptureFixture[str]): Fixture to capture STDOUT/STDERR. """ # Create ArgumentParser - parser = ArgumentParser(ExampleModel) + parser = pydantic_argparse.ArgumentParser(conf.TestModel) # Assert Parser Exits with pytest.raises(SystemExit): @@ -482,27 +501,53 @@ def test_argument_descriptions( captured = capsys.readouterr() # Process STDOUT + # Capture all arguments below 'commands:' # Capture all arguments below 'required arguments:' # Capture all arguments below 'optional arguments:' - _, required, optional, _ = re.split(r".+:\n", captured.out) + _, commands, required, optional, _ = re.split(r".+:\n", captured.out) + + # Check if Command, Required or Optional + if isinstance(argument_field.outer_type_, pydantic.main.ModelMetaclass): + # Assert Argument Name in Commands Section + assert argument_name in commands + assert argument_name not in required + assert argument_name not in optional + + # Assert Argument Description in Commands Section + assert argument_field.field_info.description in commands + assert argument_field.field_info.description not in required + assert argument_field.field_info.description not in optional - # Format Argument Name - argument_name = argument_name.replace("_", "-") + elif argument_field.required: + # Format Argument Name + argument_name = argument_name.replace("_", "-") - # Check if Required or Optional - if argument_field.required: - # Assert Argument in Required Args Section + # Assert Argument Name in Required Args Section assert argument_name in required + assert argument_name not in commands assert argument_name not in optional + + # Assert Argument Description in Required Args Section assert argument_field.field_info.description in required + assert argument_field.field_info.description not in commands assert argument_field.field_info.description not in optional else: - # Assert Argument in Optional Args Section - default = argument_field.get_default() + # Format Argument Name and Default + argument_name = argument_name.replace("_", "-") + default = f"(default: {argument_field.get_default()})" + + # Assert Argument Name in Optional Args Section assert argument_name in optional + assert argument_name not in commands assert argument_name not in required + + # Assert Argument Description in Optional Args Section assert argument_field.field_info.description in optional + assert argument_field.field_info.description not in commands assert argument_field.field_info.description not in required - assert f"(default: {default})" in optional - assert f"(default: {default})" not in required + + # Assert Argument Default in Optional Args Section + assert default in optional + assert default not in commands + assert default not in required diff --git a/tests/conftest.py b/tests/conftest.py index 2190a62..dce7394 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,124 +6,139 @@ """ -from __future__ import absolute_import -from __future__ import annotations -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals - - # Standard -from collections import deque -from datetime import date, datetime, time, timedelta -from enum import Enum -import pathlib +import argparse +import collections +import datetime +import enum # Third-Party import pydantic import pytest -import toml -# Typing -from typing import Any, Literal, Optional, Tuple # pylint: disable=wrong-import-order +# Local +from pydantic_argparse.argparse import actions - -# Constants -PROJECT_ROOT = pathlib.Path(__file__).resolve().parent.parent -PROJECT_TOML = PROJECT_ROOT.joinpath("pyproject.toml") +# Typing +from typing import Literal, Optional # pylint: disable=wrong-import-order @pytest.fixture -def pyproject() -> dict[str, Any]: - """Loads the project pyproject.toml. +def sub_parsers_action() -> actions.SubParsersAction: + """PyTest Fixture for actions.SubParsersAction. Returns: - dict[str, Any]: pyproject.toml parsed as a dictionary. + actions.SubParsersAction. """ - # Load Project TOML - return dict(toml.load(PROJECT_TOML)) + # Instantiate Action + action = actions.SubParsersAction( + option_strings=[], # Always empty for the SubParsersAction + prog="example", + parser_class=argparse.ArgumentParser, + ) + + # Return + return action + + +class TestEnum(enum.Enum): + """Test Enum for Testing""" + A = enum.auto() + B = enum.auto() + C = enum.auto() + + +class TestEnumSingle(enum.Enum): + """Test Enum with Single Member for Testing""" + D = enum.auto() -class ExampleEnum(Enum): - """Example Enum for Testing""" - A = 1 - B = 2 - C = 3 +class TestCommand(pydantic.BaseModel): + """Test Command Model for Testing""" + flag: bool = pydantic.Field(False, description="flag") -class ExampleEnumSingle(Enum): - """Example Enum with Single Member for Testing""" - D = 4 +class TestCommands(pydantic.BaseModel): + """Test Commands Model for Testing""" + cmd_01: Optional[TestCommand] = pydantic.Field(description="cmd_01") + cmd_02: Optional[TestCommand] = pydantic.Field(description="cmd_02") + cmd_03: Optional[TestCommand] = pydantic.Field(description="cmd_03") -class ExampleModel(pydantic.BaseModel): - """Example Model for Testing""" +class TestModel(pydantic.BaseModel): + """Test Model for Testing""" # Required Arguments - arg_01: int = pydantic.Field(description="arg_01") - arg_02: float = pydantic.Field(description="arg_02") - arg_03: str = pydantic.Field(description="arg_03") - arg_04: bytes = pydantic.Field(description="arg_04") - arg_05: list[str] = pydantic.Field(description="arg_05") - arg_06: Tuple[str, str, str] = pydantic.Field(description="arg_06") - arg_07: set[str] = pydantic.Field(description="arg_07") - arg_08: frozenset[str] = pydantic.Field(description="arg_08") - arg_09: deque[str] = pydantic.Field(description="arg_09") - arg_10: dict[str, int] = pydantic.Field(description="arg_10") - arg_11: date = pydantic.Field(description="arg_11") - arg_12: datetime = pydantic.Field(description="arg_12") - arg_13: time = pydantic.Field(description="arg_13") - arg_14: timedelta = pydantic.Field(description="arg_14") - arg_15: bool = pydantic.Field(description="arg_15") - arg_16: Literal["A"] = pydantic.Field(description="arg_16") - arg_17: Literal["A", 1] = pydantic.Field(description="arg_17") - arg_18: ExampleEnumSingle = pydantic.Field(description="arg_18") - arg_19: ExampleEnum = pydantic.Field(description="arg_19") + arg_01: int = pydantic.Field(description="arg_01") + arg_02: float = pydantic.Field(description="arg_02") + arg_03: str = pydantic.Field(description="arg_03") + arg_04: bytes = pydantic.Field(description="arg_04") + arg_05: list[str] = pydantic.Field(description="arg_05") + arg_06: tuple[str, str, str] = pydantic.Field(description="arg_06") + arg_07: set[str] = pydantic.Field(description="arg_07") + arg_08: frozenset[str] = pydantic.Field(description="arg_08") + arg_09: collections.deque[str] = pydantic.Field(description="arg_09") + arg_10: dict[str, int] = pydantic.Field(description="arg_10") + arg_11: datetime.date = pydantic.Field(description="arg_11") + arg_12: datetime.datetime = pydantic.Field(description="arg_12") + arg_13: datetime.time = pydantic.Field(description="arg_13") + arg_14: datetime.timedelta = pydantic.Field(description="arg_14") + arg_15: bool = pydantic.Field(description="arg_15") + arg_16: Literal["A"] = pydantic.Field(description="arg_16") + arg_17: Literal["A", 1] = pydantic.Field(description="arg_17") + arg_18: TestEnumSingle = pydantic.Field(description="arg_18") + arg_19: TestEnum = pydantic.Field(description="arg_19") # Optional Arguments (With Default) - arg_20: int = pydantic.Field(12345, description="arg_20") - arg_21: float = pydantic.Field(6.789, description="arg_21") - arg_22: str = pydantic.Field("ABC", description="arg_22") - arg_23: bytes = pydantic.Field(b"ABC", description="arg_23") - arg_24: list[str] = pydantic.Field(list(("A", "B", "C")), description="arg_24") - arg_25: Tuple[str, str, str] = pydantic.Field(tuple(("A", "B", "C")), description="arg_25") - arg_26: set[str] = pydantic.Field(set(("A", "B", "C")), description="arg_26") - arg_27: frozenset[str] = pydantic.Field(frozenset(("A", "B", "C")), description="arg_27") - arg_28: deque[str] = pydantic.Field(deque(("A", "B", "C")), description="arg_28") - arg_29: dict[str, int] = pydantic.Field(dict(A=123), description="arg_29") - arg_30: date = pydantic.Field(date(2021, 12, 25), description="arg_30") - arg_31: datetime = pydantic.Field(datetime(2021, 12, 25, 7, 30), description="arg_31") - arg_32: time = pydantic.Field(time(7, 30), description="arg_32") - arg_33: timedelta = pydantic.Field(timedelta(hours=5), description="arg_33") - arg_34: bool = pydantic.Field(False, description="arg_34") - arg_35: bool = pydantic.Field(True, description="arg_35") - arg_36: Literal["A"] = pydantic.Field("A", description="arg_36") - arg_37: Literal["A", 1] = pydantic.Field("A", description="arg_37") - arg_38: ExampleEnumSingle = pydantic.Field(ExampleEnumSingle.D, description="arg_38") - arg_39: ExampleEnum = pydantic.Field(ExampleEnum.A, description="arg_39") + arg_20: int = pydantic.Field(12345, description="arg_20") + arg_21: float = pydantic.Field(6.789, description="arg_21") + arg_22: str = pydantic.Field("ABC", description="arg_22") + arg_23: bytes = pydantic.Field(b"ABC", description="arg_23") + arg_24: list[str] = pydantic.Field(list(("A", "B", "C")), description="arg_24") + arg_25: tuple[str, str, str] = pydantic.Field(tuple(("A", "B", "C")), description="arg_25") + arg_26: set[str] = pydantic.Field(set(("A", "B", "C")), description="arg_26") + arg_27: frozenset[str] = pydantic.Field(frozenset(("A", "B", "C")), description="arg_27") + arg_28: collections.deque[str] = pydantic.Field(collections.deque(("A", "B", "C")), description="arg_28") + arg_29: dict[str, int] = pydantic.Field(dict(A=123), description="arg_29") + arg_30: datetime.date = pydantic.Field(datetime.date(2021, 12, 25), description="arg_30") + arg_31: datetime.datetime = pydantic.Field(datetime.datetime(2021, 12, 25, 7), description="arg_31") + arg_32: datetime.time = pydantic.Field(datetime.time(7, 30), description="arg_32") + arg_33: datetime.timedelta = pydantic.Field(datetime.timedelta(hours=5), description="arg_33") + arg_34: bool = pydantic.Field(False, description="arg_34") + arg_35: bool = pydantic.Field(True, description="arg_35") + arg_36: Literal["A"] = pydantic.Field("A", description="arg_36") + arg_37: Literal["A", 1] = pydantic.Field("A", description="arg_37") + arg_38: TestEnumSingle = pydantic.Field(TestEnumSingle.D, description="arg_38") + arg_39: TestEnum = pydantic.Field(TestEnum.A, description="arg_39") # Optional Arguments (No Default) - arg_40: Optional[int] = pydantic.Field(description="arg_40") - arg_41: Optional[float] = pydantic.Field(description="arg_41") - arg_42: Optional[str] = pydantic.Field(description="arg_42") - arg_43: Optional[bytes] = pydantic.Field(description="arg_43") - arg_44: Optional[list[str]] = pydantic.Field(description="arg_44") - arg_45: Optional[Tuple[str, str, str]] = pydantic.Field(description="arg_45") - arg_46: Optional[set[str]] = pydantic.Field(description="arg_46") - arg_47: Optional[frozenset[str]] = pydantic.Field(description="arg_47") - arg_48: Optional[deque[str]] = pydantic.Field(description="arg_48") - arg_49: Optional[dict[str, int]] = pydantic.Field(description="arg_49") - arg_50: Optional[date] = pydantic.Field(description="arg_50") - arg_51: Optional[datetime] = pydantic.Field(description="arg_51") - arg_52: Optional[time] = pydantic.Field(description="arg_52") - arg_53: Optional[timedelta] = pydantic.Field(description="arg_53") - arg_54: Optional[bool] = pydantic.Field(description="arg_54") - arg_55: Optional[Literal["A"]] = pydantic.Field(description="arg_55") - arg_56: Optional[Literal["A", 1]] = pydantic.Field(description="arg_56") - arg_57: Optional[ExampleEnumSingle] = pydantic.Field(description="arg_57") - arg_58: Optional[ExampleEnum] = pydantic.Field(description="arg_58") + arg_40: Optional[int] = pydantic.Field(description="arg_40") + arg_41: Optional[float] = pydantic.Field(description="arg_41") + arg_42: Optional[str] = pydantic.Field(description="arg_42") + arg_43: Optional[bytes] = pydantic.Field(description="arg_43") + arg_44: Optional[list[str]] = pydantic.Field(description="arg_44") + arg_45: Optional[tuple[str, str, str]] = pydantic.Field(description="arg_45") + arg_46: Optional[set[str]] = pydantic.Field(description="arg_46") + arg_47: Optional[frozenset[str]] = pydantic.Field(description="arg_47") + arg_48: Optional[collections.deque[str]] = pydantic.Field(description="arg_48") + arg_49: Optional[dict[str, int]] = pydantic.Field(description="arg_49") + arg_50: Optional[datetime.date] = pydantic.Field(description="arg_50") + arg_51: Optional[datetime.datetime] = pydantic.Field(description="arg_51") + arg_52: Optional[datetime.time] = pydantic.Field(description="arg_52") + arg_53: Optional[datetime.timedelta] = pydantic.Field(description="arg_53") + arg_54: Optional[bool] = pydantic.Field(description="arg_54") + arg_55: Optional[Literal["A"]] = pydantic.Field(description="arg_55") + arg_56: Optional[Literal["A", 1]] = pydantic.Field(description="arg_56") + arg_57: Optional[TestEnumSingle] = pydantic.Field(description="arg_57") + arg_58: Optional[TestEnum] = pydantic.Field(description="arg_58") # Special Enums and Literals Optional Flag Behaviour - arg_59: Optional[Literal["A"]] = pydantic.Field(description="arg_59") - arg_60: Optional[Literal["A"]] = pydantic.Field("A", description="arg_60") - arg_61: Optional[ExampleEnumSingle] = pydantic.Field(description="arg_61") - arg_62: Optional[ExampleEnumSingle] = pydantic.Field(ExampleEnumSingle.D, description="arg_62") + arg_59: Optional[Literal["A"]] = pydantic.Field(description="arg_59") + arg_60: Optional[Literal["A"]] = pydantic.Field("A", description="arg_60") + arg_61: Optional[TestEnumSingle] = pydantic.Field(description="arg_61") + arg_62: Optional[TestEnumSingle] = pydantic.Field(TestEnumSingle.D, description="arg_62") + + # Commands + arg_63: Optional[TestCommand] = pydantic.Field(description="arg_63") + arg_64: TestCommand = pydantic.Field(description="arg_64") + arg_65: Optional[TestCommands] = pydantic.Field(description="arg_65") + arg_66: TestCommands = pydantic.Field(description="arg_66") diff --git a/tests/parsers/__init__.py b/tests/parsers/__init__.py new file mode 100644 index 0000000..86efe65 --- /dev/null +++ b/tests/parsers/__init__.py @@ -0,0 +1,7 @@ +"""__init__.py + +This file is required to mark the unit tests as a package, so they can resolve +and import the actual top level package code + +@author Hayden Richards +""" diff --git a/tests/test_version.py b/tests/test_version.py deleted file mode 100644 index b729c89..0000000 --- a/tests/test_version.py +++ /dev/null @@ -1,72 +0,0 @@ -"""test_pkginfo.py - -Tests __version__ Module. - -@author Hayden Richards -""" - - -from __future__ import absolute_import -from __future__ import annotations -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals - - -# Local -from pydantic_argparse import __app__, __description__, __version__, __authors__ - -# Typing -from typing import Any # pylint: disable=wrong-import-order - - -def test_correct_app_name(pyproject: dict[str, Any]) -> None: - """Tests __app__ in __pkginfo__ matches pyproject.toml. - - Args: - pyproject (dict[str, Any]): pyproject.toml loaded as dictionary as per - pytest fixture. - """ - # Assert App Name is Correct - assert ( - __app__ == pyproject["tool"]["poetry"]["name"] - ), "Project app name should match in package and package config" - - -def test_correct_description(pyproject: dict[str, Any]) -> None: - """Tests __description__ in __pkginfo__ matches pyproject.toml. - - Args: - pyproject (dict[str, Any]): pyproject.toml loaded as dictionary as per - pytest fixture. - """ - # Assert Description is Correct - assert ( - __description__ == pyproject["tool"]["poetry"]["description"] - ), "Project description should match in package and package config" - - -def test_correct_version(pyproject: dict[str, Any]) -> None: - """Tests __version__ in __pkginfo__ matches pyproject.toml. - - Args: - pyproject (dict[str, Any]): pyproject.toml loaded as dictionary as per - pytest fixture. - """ - # Assert Version is Correct - assert ( - __version__ == pyproject["tool"]["poetry"]["version"] - ), "Project version should match in package and package config" - - -def test_correct_authors(pyproject: dict[str, Any]) -> None: - """Tests __authors__ in __pkginfo__ matches pyproject.toml. - - Args: - pyproject (dict[str, Any]): pyproject.toml loaded as dictionary as per - pytest fixture. - """ - # Assert Authors is Correct - assert ( - __authors__ == pyproject["tool"]["poetry"]["authors"] - ), "Project authors should match in package and package config" diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..86efe65 --- /dev/null +++ b/tests/utils/__init__.py @@ -0,0 +1,7 @@ +"""__init__.py + +This file is required to mark the unit tests as a package, so they can resolve +and import the actual top level package code + +@author Hayden Richards +""" diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py new file mode 100644 index 0000000..80c0e90 --- /dev/null +++ b/tests/utils/test_utils.py @@ -0,0 +1,130 @@ +"""test_utils.py + +Tests utils Module. + +@author Hayden Richards +""" + + +# Standard +import argparse + +# Third-Party +import pytest + +# Local +from pydantic_argparse.utils import utils + +# Typing +from typing import Any, Optional # pylint: disable=wrong-import-order + + +@pytest.mark.parametrize( + [ + "description", + "default", + "expected", + ], + [ + ("A", "A", "A (default: A)"), + ("A", 5, "A (default: 5)"), + ("A", None, "A (default: None)"), + ("A", utils.MISSING, "A"), + (None, "A", "(default: A)"), + (None, 5, "(default: 5)"), + (None, None, "(default: None)"), + (None, utils.MISSING, ""), + ] +) +def test_argument_description( + description: Optional[str], + default: Optional[Any], + expected: str, + ) -> None: + """Tests utils.argument_description Function. + + Args: + description (Optional[str]): Optional argument description to test. + default (Optional[Any]): Optional argument default to test. + expected (str): Expected result of the test. + """ + # Generate Argument Description + result = utils.argument_description(description, default) + + # Assert + assert result == expected + + +@pytest.mark.parametrize( + [ + "name", + "expected", + ], + [ + ("test", "--test"), + ("test_two", "--test-two"), + ] +) +def test_argument_name( + name: str, + expected: str, + ) -> None: + """Tests utils.argument_name Function. + + Args: + name (str): Argument name to test. + expected (str): Expected result of the test. + """ + # Generate Argument Name + result = utils.argument_name(name) + + # Assert + assert result == expected + + +def test_namespace_to_dict() -> None: + """Tests utils.namespace_to_dict Function.""" + # Generate Dictionary + result = utils.namespace_to_dict( + argparse.Namespace( + a="1", + b=2, + c=argparse.Namespace( + d="3", + e=4, + f=argparse.Namespace( + g=5, + h="6", + i=7, + ) + ) + ) + ) + + # Assert + assert result == { + "a": "1", + "b": 2, + "c": { + "d": "3", + "e": 4, + "f": { + "g": 5, + "h": "6", + "i": 7, + } + } + } + + +def test_type_caster() -> None: + """Tests utils.type_caster Function.""" + # Create Lambda to Test + test = lambda x, y, z: x + y + z + + # Generate Type Caster + result = utils.type_caster("abc", test, y="y", z="z") + + # Assert + assert result.__name__ == "abc" + assert result("x") == "xyz"