diff --git a/dargs/__main__.py b/dargs/__main__.py new file mode 100644 index 0000000..f6a9c52 --- /dev/null +++ b/dargs/__main__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from dargs.cli import main + +if __name__ == "__main__": + main() diff --git a/dargs/_test.py b/dargs/_test.py new file mode 100644 index 0000000..9378270 --- /dev/null +++ b/dargs/_test.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import List + +from dargs.dargs import Argument + + +def test_arguments() -> list[Argument]: + """Returns a list of arguments.""" + return [ + Argument(name="test1", dtype=int, doc="Argument 1"), + Argument(name="test2", dtype=[float, None], doc="Argument 2"), + Argument( + name="test3", + dtype=List[str], + default=["test"], + optional=True, + doc="Argument 3", + ), + ] + + +__all__ = [ + "test_arguments", +] diff --git a/dargs/check.py b/dargs/check.py new file mode 100644 index 0000000..2711927 --- /dev/null +++ b/dargs/check.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from dargs.dargs import Argument + + +def check( + arginfo: Argument | list[Argument] | tuple[Argument, ...], + data: dict, + strict: bool = True, + trim_pattern: str = "_*", +) -> dict: + """Check and normalize input data. + + Parameters + ---------- + arginfo : Union[Argument, List[Argument], Tuple[Argument, ...]] + Argument object + data : dict + data to check + strict : bool, optional + If True, raise an error if the key is not pre-defined, by default True + trim_pattern : str, optional + Pattern to trim the key, by default "_*" + + Returns + ------- + dict + normalized data + """ + if isinstance(arginfo, (list, tuple)): + arginfo = Argument("base", dtype=dict, sub_fields=arginfo) + + data = arginfo.normalize_value(data, trim_pattern=trim_pattern) + arginfo.check_value(data, strict=strict) + return data diff --git a/dargs/cli.py b/dargs/cli.py new file mode 100644 index 0000000..045e87b --- /dev/null +++ b/dargs/cli.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import argparse +import json +import sys +from typing import IO + +from dargs._version import __version__ +from dargs.check import check + + +def main_parser() -> argparse.ArgumentParser: + """Create the main parser for the command line interface. + + Returns + ------- + argparse.ArgumentParser + The main parser + """ + parser = argparse.ArgumentParser( + description="dargs: Argument checking for Python programs" + ) + subparsers = parser.add_subparsers(help="Sub-commands") + parser_check = subparsers.add_parser( + "check", + help="Check a JSON file against an Argument", + epilog="Example: dargs check -f dargs._test.test_arguments test_arguments.json", + ) + parser_check.add_argument( + "-f", + "--func", + type=str, + help="Function that returns an Argument object. E.g., `dargs._test.test_arguments`", + required=True, + ) + parser_check.add_argument( + "jdata", + type=argparse.FileType("r"), + default=[sys.stdin], + nargs="*", + help="Path to the JSON file. If not given, read from stdin.", + ) + parser_check.add_argument( + "--no-strict", + action="store_false", + dest="strict", + help="Do not raise an error if the key is not pre-defined", + ) + parser_check.add_argument( + "--trim-pattern", + type=str, + default="_*", + help="Pattern to trim the key", + ) + parser_check.set_defaults(entrypoint=check_cli) + + # --version + parser.add_argument("--version", action="version", version=__version__) + return parser + + +def main(): + """Main entry point for the command line interface.""" + parser = main_parser() + args = parser.parse_args() + + args.entrypoint(**vars(args)) + + +def check_cli( + *, + func: str, + jdata: list[IO], + strict: bool, + **kwargs, +) -> None: + """Normalize and check input data. + + Parameters + ---------- + func : str + Function that returns an Argument object. E.g., `dargs._test.test_arguments` + jdata : IO + File object that contains the JSON data + strict : bool + If True, raise an error if the key is not pre-defined + + Returns + ------- + dict + normalized data + """ + module_name, attr_name = func.rsplit(".", 1) + try: + mod = __import__(module_name, globals(), locals(), [attr_name]) + except ImportError as e: + raise RuntimeError( + f'Failed to import "{attr_name}" from "{module_name}".\n{sys.exc_info()[1]}' + ) from e + + if not hasattr(mod, attr_name): + raise RuntimeError(f'Module "{module_name}" has no attribute "{attr_name}"') + func_obj = getattr(mod, attr_name) + arginfo = func_obj() + for jj in jdata: + data = json.load(jj) + check(arginfo, data, strict=strict) diff --git a/dargs/sphinx.py b/dargs/sphinx.py index ac86f3e..8eb0b9e 100644 --- a/dargs/sphinx.py +++ b/dargs/sphinx.py @@ -57,10 +57,10 @@ def run(self): try: mod = __import__(module_name, globals(), locals(), [attr_name]) - except ImportError: + except ImportError as e: raise self.error( f'Failed to import "{attr_name}" from "{module_name}".\n{sys.exc_info()[1]}' - ) + ) from e if not hasattr(mod, attr_name): raise self.error( @@ -217,18 +217,3 @@ def _test_argument() -> Argument: ), ], ) - - -def _test_arguments() -> list[Argument]: - """Returns a list of arguments.""" - return [ - Argument(name="test1", dtype=int, doc="Argument 1"), - Argument(name="test2", dtype=[float, None], doc="Argument 2"), - Argument( - name="test3", - dtype=List[str], - default=["test"], - optional=True, - doc="Argument 3", - ), - ] diff --git a/docs/cli.rst b/docs/cli.rst new file mode 100644 index 0000000..dab9742 --- /dev/null +++ b/docs/cli.rst @@ -0,0 +1,9 @@ +.. _cli: + +Command line interface +====================== + +.. argparse:: + :module: dargs.cli + :func: main_parser + :prog: dargs diff --git a/docs/conf.py b/docs/conf.py index 34270de..d3588bd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -49,6 +49,7 @@ "numpydoc", "myst_nb", "dargs.sphinx", + "sphinxarg.ext", ] # Add any paths that contain templates here, relative to this directory. diff --git a/docs/index.rst b/docs/index.rst index 83b72a3..cdf7bd9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,6 +11,7 @@ Welcome to dargs's documentation! :caption: Contents: intro + cli sphinx dpgui nb diff --git a/docs/requirements.txt b/docs/requirements.txt index d2c7bc5..63c515c 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -3,3 +3,4 @@ numpydoc deepmodeling_sphinx>=0.1.1 myst-nb sphinx_rtd_theme +sphinx-argparse diff --git a/docs/sphinx.rst b/docs/sphinx.rst index ee16d89..2521beb 100644 --- a/docs/sphinx.rst +++ b/docs/sphinx.rst @@ -15,20 +15,20 @@ Then `dargs` directive will be enabled: .. code-block:: rst .. dargs:: - :module: dargs.sphinx - :func: _test_argument + :module: dargs._test + :func: test_argument -where `_test_argument` returns an :class:`Argument `. The documentation will be rendered as: +where `test_argument` returns an :class:`Argument `. The documentation will be rendered as: .. dargs:: - :module: dargs.sphinx - :func: _test_argument + :module: dargs._test + :func: test_argument A :class:`list` of :class:`Argument ` is also accepted. .. dargs:: - :module: dargs.sphinx - :func: _test_arguments + :module: dargs._test + :func: test_arguments Cross-referencing Arguments --------------------------- diff --git a/pyproject.toml b/pyproject.toml index c4b0900..1370c44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,9 @@ typecheck = [ "ipython", ] +[project.scripts] +dargs = "dargs.cli:main" + [tool.setuptools.packages.find] include = ["dargs*"] @@ -54,6 +57,7 @@ select = [ "RUF", # ruff "I", # isort "TCH", # flake8-type-checking + "B904", # raise-without-from-inside-except ] ignore = [ diff --git a/tests/test_arguments.json b/tests/test_arguments.json new file mode 100644 index 0000000..a3291ee --- /dev/null +++ b/tests/test_arguments.json @@ -0,0 +1,4 @@ +{ + "test1": 1, + "test2": 2 +} diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..a3a76f0 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import subprocess +import sys +import unittest +from pathlib import Path + +this_directory = Path(__file__).parent + + +class TestCli(unittest.TestCase): + def test_check(self): + subprocess.check_call( + [ + "dargs", + "check", + "-f", + "dargs._test.test_arguments", + str(this_directory / "test_arguments.json"), + str(this_directory / "test_arguments.json"), + ] + ) + subprocess.check_call( + [ + sys.executable, + "-m", + "dargs", + "check", + "-f", + "dargs._test.test_arguments", + str(this_directory / "test_arguments.json"), + str(this_directory / "test_arguments.json"), + ] + ) + with (this_directory / "test_arguments.json").open() as f: + subprocess.check_call( + [ + "dargs", + "check", + "-f", + "dargs._test.test_arguments", + ], + stdin=f, + )