├── .github └── workflows │ └── codecov.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── configargparser ├── __init__.py ├── cap.py ├── gap.py └── tap.py ├── pyproject.toml ├── setup.cfg └── tests ├── __init__.py ├── test_cap.py ├── test_gap.py └── test_tap.py /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | name: Codecov 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | run-codecov: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest, macos-latest, windows-latest] 11 | python: ["3.8", "3.9", "3.10"] 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Setup Python 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: ${{ matrix.python }} 18 | - name: Generate coverage report 19 | run: | 20 | pip install pytest 21 | pip install pytest-cov 22 | pytest --cov=configargparser --cov-report=xml 23 | - name: Upload coverage to Codecov 24 | uses: codecov/codecov-action@v4 25 | with: 26 | verbose: true 27 | env: 28 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | example* 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | pip-wheel-metadata/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 97 | __pypackages__/ 98 | 99 | # Celery stuff 100 | celerybeat-schedule 101 | celerybeat.pid 102 | 103 | # SageMath parsed files 104 | *.sage.py 105 | 106 | # Environments 107 | .env 108 | .venv 109 | env/ 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | .dmypy.json 128 | dmypy.json 129 | 130 | # Pyre type checker 131 | .pyre/ 132 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.3.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: trailing-whitespace 7 | args: [--markdown-linebreak-ext=md] 8 | - repo: https://github.com/astral-sh/ruff-pre-commit 9 | rev: v0.6.9 10 | hooks: 11 | - id: ruff 12 | args: [--fix, --show-fixes] 13 | - id: ruff-format 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Xiao Yuan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # config-argument-parser 2 | 3 | [![PyPI version](https://badge.fury.io/py/config-argument-parser.svg)](https://badge.fury.io/py/config-argument-parser) 4 | [![Downloads](https://static.pepy.tech/badge/config-argument-parser/month)](https://pepy.tech/project/config-argument-parser) 5 | [![Codacy Badge](https://app.codacy.com/project/badge/Grade/b15383188a354af684ba9d49b09cc253)](https://app.codacy.com/gh/yuanx749/config-argument-parser/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) 6 | [![Maintainability](https://api.codeclimate.com/v1/badges/288bbabbf406afe66e37/maintainability)](https://codeclimate.com/github/yuanx749/config-argument-parser/maintainability) 7 | [![codecov](https://codecov.io/gh/yuanx749/config-argument-parser/branch/dev/graph/badge.svg?token=W34MFRGVMY)](https://codecov.io/gh/yuanx749/config-argument-parser) 8 | [![Documentation Status](https://readthedocs.org/projects/config-argument-parser/badge/?version=latest)](https://config-argument-parser.readthedocs.io/en/latest/?badge=latest) 9 | 10 | A package to help automatically create command-line interface from configuration or code. 11 | 12 | It contains three modules CAP🧢(`ConfigArgumentParser`), TAP🚰(`TypeArgumentParser`), and GAP🕳️(`GlobalArgumentParser`). 13 | 14 | Read the documentation [here](http://config-argument-parser.readthedocs.io/). 15 | 16 | ## Motivation 17 | 18 | Configuration files are highly readable and useful for specifying options, but sometimes they are not convenient as command-line interface. However, it requires writing a lot of code to produce a CLI. This package automates the building process, by utilizing the Python standard libraries `configparser` and `argparse`. 19 | 20 | The design is to minimize the changes to your original scripts, so as to facilitate maintenance. 21 | 22 | ## Features 23 | 24 | - Only few extra lines are needed to build a CLI from an existing script. 25 | - The comments are parsed as help messages. (Most libraries do not preserve the comments.) 26 | - Consistent format between configuration and script provides ease of use. 27 | 28 | ## Usage 29 | 30 | ### Case 1: create CLI from an object 31 | 32 | If you use class to store arguments, such as the script `example.py` below. 33 | 34 | ```Python 35 | import configargparser 36 | 37 | class Args: 38 | # Help message of the first argument. Help is optional. 39 | a_string = "abc" 40 | a_float = 1.23 # inline comments are omitted 41 | # Help can span multiple lines. 42 | # This is another line. 43 | a_boolean = False 44 | an_integer = 0 45 | 46 | args = Args() 47 | 48 | parser = configargparser.ConfigArgumentParser() 49 | parser.parse_obj(args, shorts="sfb") 50 | 51 | print(args.a_string) 52 | print(args.a_float) 53 | print(args.a_boolean) 54 | print(args.an_integer) 55 | ``` 56 | 57 | In fact, only the snippet below is added to the original script. Moreover, removing this minimal modification does not affect the original script. `shorts` is optional. If provided, add short options for the first few arguments in order. 58 | 59 | ```Python 60 | import configargparser 61 | parser = configargparser.ConfigArgumentParser() 62 | parser.parse_obj(args) 63 | ``` 64 | 65 | Default arguments are defined as class attributes, and parsed arguments are stored as instance attributes. The good is that auto-completion can be triggered in editors. 66 | 67 | Show help, `python example.py -h`: 68 | 69 | ```console 70 | $ python example.py -h 71 | usage: example.py [-h] [-s A_STRING] [-f A_FLOAT] [-b] [--an-integer AN_INTEGER] 72 | 73 | options: 74 | -h, --help show this help message and exit 75 | -s A_STRING, --a-string A_STRING 76 | Help message of the first argument. Help is optional. (default: abc) 77 | -f A_FLOAT, --a-float A_FLOAT 78 | a_float (default: 1.23) 79 | -b, --a-boolean Help can span multiple lines. This is another line. (default: False) 80 | --an-integer AN_INTEGER 81 | an_integer (default: 0) 82 | ``` 83 | 84 | Run with options, for example, `python example.py -b -f 1`: 85 | 86 | ```console 87 | $ python example.py -b -f 1 88 | abc 89 | 1.0 90 | True 91 | 0 92 | ``` 93 | 94 | Note that the values are changed. 95 | 96 | For the best practice, see [Case 4](#case-4-create-cli-from-a-dataclass-object-preferred). 97 | 98 | ### Case 2: create CLI from configuration 99 | 100 | If you use configuration file, create an example script `example.py`: 101 | 102 | ```Python 103 | import configargparser 104 | 105 | parser = configargparser.ConfigArgumentParser() 106 | parser.read("config.ini") 107 | parser.parse_args(shorts="sfb") 108 | 109 | print("Configs:", parser.defaults) 110 | print("Args: ", parser.args) 111 | ``` 112 | 113 | Create a configuration file `config.ini` in the same directory: 114 | 115 | ```ini 116 | [DEFAULT] 117 | # Help message of the first argument. Help is optional. 118 | a_string = 'abc' 119 | a_float = 1.23 # inline comments are omitted 120 | # Help can span multiple lines. 121 | # This is another line. 122 | a_boolean = False 123 | an_integer = 0 124 | ``` 125 | 126 | Regular run, `python example.py`: 127 | 128 | ```console 129 | $ python example.py 130 | Configs: {'a_string': 'abc', 'a_float': 1.23, 'a_boolean': False, 'an_integer': 0} 131 | Args: {'a_string': 'abc', 'a_float': 1.23, 'a_boolean': False, 'an_integer': 0} 132 | ``` 133 | 134 | Run with options, such as `python example.py -b -f 1`: 135 | 136 | ```console 137 | $ python example.py -b -f 1 138 | Configs: {'a_string': 'abc', 'a_float': 1.23, 'a_boolean': False, 'an_integer': 0} 139 | Args: {'a_string': 'abc', 'a_float': 1.0, 'a_boolean': True, 'an_integer': 0} 140 | ``` 141 | 142 | ### Case 3: create CLI from global variables 143 | 144 | If you use global variables, define the variables at top of file as the script `example.py` below: 145 | 146 | ```Python 147 | # [DEFAULT] 148 | # Help message of the first argument. Help is optional. 149 | a_string = "abc" 150 | a_float = 1.23 # inline comments are omitted 151 | # Help can span multiple lines. 152 | # This is another line. 153 | a_boolean = False 154 | an_integer = 0 155 | # [END] 156 | 157 | import configargparser 158 | 159 | parser = configargparser.ConfigArgumentParser() 160 | parser.read_py("example.py") 161 | parser.parse_args(shorts="sfb") 162 | 163 | # update global variables 164 | globals().update(parser.args) 165 | print(a_string) 166 | print(a_float) 167 | print(a_boolean) 168 | print(an_integer) 169 | ``` 170 | 171 | Use it as in case 1. For example, `python example.py -b -f 1`: 172 | 173 | ```console 174 | $ python example.py -b -f 1 175 | abc 176 | 1.0 177 | True 178 | 0 179 | ``` 180 | 181 | ### Case 4: create CLI from a dataclass object (preferred) 182 | 183 | Suppose you have a script `example.py` below, which uses a `dataclass` object to store arguments: 184 | 185 | ```Python 186 | from dataclasses import dataclass 187 | 188 | @dataclass 189 | class Args: 190 | # Help message of the first argument. Help is optional. 191 | a_string: str = "abc" 192 | a_float: float = 1.23 # inline comments are omitted 193 | # Help can span multiple lines. 194 | # This is another line. 195 | a_boolean: bool = False 196 | an_integer: int = 0 197 | 198 | args = Args() 199 | ``` 200 | 201 | Add these lines to the script to create CLI: 202 | 203 | ```Python 204 | import configargparser 205 | parser = configargparser.TypeArgumentParser() 206 | parser.parse_obj(args, shorts="sfb") 207 | 208 | print(args) 209 | ``` 210 | 211 | Use it as in case 1. For example, `python example.py -b -f 1` to change the values: 212 | 213 | ```console 214 | $ python example.py -b -f 1 215 | Args(a_string='abc', a_float=1.0, a_boolean=True, an_integer=0) 216 | ``` 217 | 218 | ### Case 5: create CLI from global variables (without comments) 219 | 220 | This requires less code than case 3, but the comments are not parsed, as the script `example.py` below: 221 | 222 | ```Python 223 | a_string = "abc" 224 | a_float = 1.23 225 | a_boolean = False 226 | an_integer = 0 227 | 228 | import configargparser 229 | 230 | parser = configargparser.GlobalArgumentParser() 231 | parser.parse_globals(shorts="sfb") 232 | 233 | print(a_string) 234 | print(a_float) 235 | print(a_boolean) 236 | print(an_integer) 237 | ``` 238 | 239 | Use it as in case 1. For example, `python example.py -b -f 1`: 240 | 241 | ```console 242 | $ python example.py -b -f 1 243 | abc 244 | 1.0 245 | True 246 | 0 247 | ``` 248 | 249 | ## Installation 250 | 251 | Install from PyPI: 252 | 253 | ```bash 254 | python -m pip install --upgrade pip 255 | pip install config-argument-parser 256 | ``` 257 | 258 | Alternatively, install from source: 259 | 260 | ```bash 261 | git clone https://github.com/yuanx749/config-argument-parser.git 262 | cd config-argument-parser 263 | ``` 264 | 265 | then install in development mode: 266 | 267 | ```bash 268 | git checkout main 269 | python -m pip install --upgrade pip 270 | pip install -e . 271 | ``` 272 | 273 | or: 274 | 275 | ```bash 276 | git checkout dev 277 | python -m pip install --upgrade pip 278 | pip install -e .[dev] 279 | pre-commit install 280 | ``` 281 | 282 | Uninstall: 283 | 284 | ```bash 285 | pip uninstall config-argument-parser 286 | ``` 287 | 288 | ## Notes 289 | 290 | This package uses [Semantic Versioning](https://semver.org/). 291 | -------------------------------------------------------------------------------- /configargparser/__init__.py: -------------------------------------------------------------------------------- 1 | """A package to help automatically create command-line interface from configuration or code.""" 2 | 3 | __version__ = "1.5.0" 4 | 5 | from .cap import ConfigArgumentParser 6 | from .gap import GlobalArgumentParser 7 | from .tap import TypeArgumentParser 8 | 9 | __all__ = ["ConfigArgumentParser", "TypeArgumentParser", "GlobalArgumentParser"] 10 | -------------------------------------------------------------------------------- /configargparser/cap.py: -------------------------------------------------------------------------------- 1 | """A module for building command-line interface from file.""" 2 | 3 | import argparse 4 | import configparser 5 | import inspect 6 | import re 7 | from ast import literal_eval 8 | 9 | 10 | class ConfigArgumentParser: 11 | """Wrapper combining ConfigParser and ArgumentParser. 12 | 13 | Attributes: 14 | config: A `~configparser.ConfigParser`. 15 | parser: An `~argparse.ArgumentParser`. 16 | defaults: A `dict` contains the default arguments. 17 | namespace: An object returned by `~argparse.ArgumentParser.parse_args`. 18 | args: A `dict` contains the parsed arguments. 19 | help: A `dict` contains the help messages. 20 | """ 21 | 22 | def __init__(self): 23 | """Initialize ConfigArgumentParser.""" 24 | self._init_config() 25 | self._init_parser() 26 | self.defaults = {} 27 | self.namespace = object() 28 | self.args = {} 29 | self.help = {} 30 | self._comment_prefix = "#" 31 | self._sect_header_default = self.config.SECTCRE 32 | self._sect_header_py = re.compile(r"# \[(?P
.+)\]") 33 | 34 | def _init_config(self): 35 | self.config = configparser.ConfigParser( 36 | allow_no_value=True, delimiters="=", comment_prefixes=";", strict=False 37 | ) 38 | self.config.optionxform = lambda x: x # override the default 39 | 40 | def _convert_defaults(self): 41 | """Convert configuration to :attr:`defaults` and parse the comments into :attr:`help`.""" 42 | msg_lst = [] 43 | for key, value in self.config.defaults().items(): 44 | if key.startswith(self._comment_prefix): 45 | msg = key.lstrip(self._comment_prefix) 46 | msg = msg.strip() 47 | msg_lst.append(msg) 48 | else: 49 | self.defaults[key] = literal_eval(value) 50 | # A non-whitespace string is needed to show the default in help. 51 | self.help[key] = " ".join(msg_lst) if msg_lst else str(key) 52 | msg_lst = [] 53 | 54 | def read(self, filenames): 55 | """Read and parse a filename or an iterable of filenames. 56 | 57 | Return list of successfully read files. 58 | """ 59 | f_lst = self.config.read(filenames) 60 | self._convert_defaults() 61 | return f_lst 62 | 63 | def read_string(self, string): 64 | """Read configuration from a given string.""" 65 | self.config.read_string(string) 66 | self._convert_defaults() 67 | 68 | def read_py(self, filename): 69 | """Read and parse a filename of Python script.""" 70 | self.config.SECTCRE = self._sect_header_py 71 | self.config.read(filename) 72 | self._convert_defaults() 73 | self.config.SECTCRE = self._sect_header_default 74 | 75 | def _add_arguments(self, shorts=""): 76 | """Add arguments to parser according to the configuration. 77 | 78 | Args: 79 | shorts: A sequence of short option letters for the leading options. 80 | """ 81 | boolean_to_action = {True: "store_false", False: "store_true"} 82 | for i, (option, value) in enumerate(self.defaults.items()): 83 | flags = [f"--{option.replace('_', '-')}"] 84 | if i < len(shorts): 85 | flags.insert(0, f"-{shorts[i]}") 86 | if isinstance(value, bool): 87 | self.parser.add_argument( 88 | *flags, 89 | action=boolean_to_action[value], 90 | help=self.help[option], 91 | ) 92 | else: 93 | self.parser.add_argument( 94 | *flags, default=value, type=type(value), help=self.help[option] 95 | ) 96 | 97 | def _init_parser(self): 98 | self.parser = argparse.ArgumentParser( 99 | formatter_class=argparse.ArgumentDefaultsHelpFormatter 100 | ) 101 | 102 | def _parse_args(self, args=None): 103 | """Convert argument strings to dictionary :attr:`args`. 104 | 105 | Return a `dict` containing arguments. 106 | """ 107 | self.namespace = self.parser.parse_args(args) 108 | self.args = vars(self.namespace) 109 | return self.args 110 | 111 | def parse_args(self, args=None, *, shorts=""): 112 | """Add arguments to parser and parse arguments. 113 | 114 | Args: 115 | args: A list of strings to parse. The default is taken from `sys.argv`. 116 | shorts: A sequence of short option letters for the leading options. 117 | 118 | Returns: 119 | A `dict` containing arguments. 120 | """ 121 | self._add_arguments(shorts=shorts) 122 | self._parse_args(args=args) 123 | return self.args 124 | 125 | def _read_obj(self, obj): 126 | """Read and parse the attributes of an object.""" 127 | source_lines, _ = inspect.getsourcelines(type(obj)) 128 | source_lines[0] = "[DEFAULT]\n" 129 | self.config.read_string("".join(source_lines)) 130 | self._convert_defaults() 131 | 132 | def _change_obj(self, obj): 133 | """Update object attributes.""" 134 | obj.__dict__.update(self.args) 135 | 136 | def parse_obj(self, obj, args=None, *, shorts=""): 137 | """Parse arguments and update object attributes. 138 | 139 | Args: 140 | obj: An object with attributes as default arguments. 141 | args: A list of strings to parse. The default is taken from `sys.argv`. 142 | shorts: A sequence of short option letters for the leading options. 143 | 144 | Returns: 145 | A `dict` containing updated arguments. 146 | """ 147 | self._read_obj(obj) 148 | self._add_arguments(shorts=shorts) 149 | self._parse_args(args=args) 150 | self._change_obj(obj) 151 | return self.args 152 | -------------------------------------------------------------------------------- /configargparser/gap.py: -------------------------------------------------------------------------------- 1 | """A module for building command-line interface from globals.""" 2 | 3 | import argparse 4 | import inspect 5 | 6 | 7 | class GlobalArgumentParser: 8 | """Parser parsing and updating global variables. 9 | 10 | Attributes: 11 | parser: An `~argparse.ArgumentParser`. 12 | defaults: A `dict` contains the default arguments. 13 | args: A `dict` contains the parsed arguments. 14 | """ 15 | 16 | def __init__(self): 17 | """Initialize GlobalArgumentParser.""" 18 | self._init_parser() 19 | self.defaults = {} 20 | self.args = {} 21 | self.help = {} 22 | self._globals = {} 23 | 24 | def _init_parser(self): 25 | self.parser = argparse.ArgumentParser( 26 | formatter_class=argparse.ArgumentDefaultsHelpFormatter 27 | ) 28 | 29 | def _read_globals(self, stack=2): 30 | """Read and parse the attributes of global variables. 31 | 32 | Convert attributes to :attr:`defaults`. 33 | """ 34 | self._globals = dict(inspect.getmembers(inspect.stack()[stack][0]))["f_globals"] 35 | self.defaults = { 36 | k: v 37 | for k, v in self._globals.items() 38 | if not k.startswith("_") 39 | and not inspect.ismodule(v) 40 | and not inspect.isclass(v) 41 | and not inspect.isfunction(v) 42 | and not isinstance(v, GlobalArgumentParser) 43 | } 44 | self.help = {k: str(k) for k in self.defaults} 45 | 46 | def _add_arguments(self, shorts=""): 47 | """Add arguments to parser according to the default. 48 | 49 | Args: 50 | shorts: A sequence of short option letters for the leading options. 51 | """ 52 | boolean_to_action = {True: "store_false", False: "store_true"} 53 | for i, (option, value) in enumerate(self.defaults.items()): 54 | flags = [f"--{option.replace('_', '-')}"] 55 | if i < len(shorts): 56 | flags.insert(0, f"-{shorts[i]}") 57 | if isinstance(value, bool): 58 | self.parser.add_argument( 59 | *flags, 60 | action=boolean_to_action[value], 61 | help=self.help[option], 62 | ) 63 | else: 64 | self.parser.add_argument( 65 | *flags, default=value, type=type(value), help=self.help[option] 66 | ) 67 | 68 | def _parse_args(self, args=None): 69 | """Convert argument strings to dictionary :attr:`args`. 70 | 71 | Return a `dict` containing arguments. 72 | """ 73 | namespace = self.parser.parse_args(args) 74 | self.args = vars(namespace) 75 | return self.args 76 | 77 | def _change_globals(self): 78 | """Update global variables.""" 79 | self._globals.update(self.args) 80 | 81 | def parse_globals(self, args=None, *, shorts=""): 82 | """Parse arguments and update global variables. 83 | 84 | Args: 85 | args: A list of strings to parse. The default is taken from `sys.argv`. 86 | shorts: A sequence of short option letters for the leading options. 87 | 88 | Returns: 89 | A `dict` containing updated arguments. 90 | """ 91 | self._read_globals() 92 | self._add_arguments(shorts=shorts) 93 | self._parse_args(args=args) 94 | self._change_globals() 95 | return self.args 96 | -------------------------------------------------------------------------------- /configargparser/tap.py: -------------------------------------------------------------------------------- 1 | """A module for building command-line interface from dataclass.""" 2 | 3 | import argparse 4 | import inspect 5 | 6 | 7 | class TypeArgumentParser: 8 | """Parser parsing and updating a dataclass object. 9 | 10 | Attributes: 11 | parser: An `~argparse.ArgumentParser`. 12 | defaults: A `dict` contains the default arguments. 13 | args: A `dict` contains the parsed arguments. 14 | help: A `dict` contains the help messages. 15 | """ 16 | 17 | def __init__(self): 18 | """Initialize TypeArgumentParser.""" 19 | self._init_parser() 20 | self.defaults = {} 21 | self.args = {} 22 | self.help = {} 23 | 24 | def _init_parser(self): 25 | self.parser = argparse.ArgumentParser( 26 | formatter_class=argparse.ArgumentDefaultsHelpFormatter 27 | ) 28 | 29 | def _read_obj(self, obj: object): 30 | """Read and parse the attributes of a dataclass object. 31 | 32 | Convert attributes to :attr:`defaults` and parse the comments into :attr:`help`. 33 | """ 34 | source_lines, _ = inspect.getsourcelines(type(obj)) 35 | self.defaults = obj.__dict__.copy() 36 | msg_lst = [] 37 | args_iter = iter(self.defaults.keys()) 38 | for line in source_lines: 39 | if line.strip().startswith(("@", "class ")): 40 | pass 41 | elif line.strip().startswith("#"): 42 | msg = line.lstrip(" #").strip() 43 | msg_lst.append(msg) 44 | else: 45 | key = next(args_iter) 46 | # A non-whitespace string is needed to show the default in help. 47 | self.help[key] = " ".join(msg_lst) if msg_lst else str(key) 48 | msg_lst = [] 49 | 50 | def _add_arguments(self, shorts=""): 51 | """Add arguments to parser according to the default. 52 | 53 | Args: 54 | shorts: A sequence of short option letters for the leading options. 55 | """ 56 | boolean_to_action = {True: "store_false", False: "store_true"} 57 | for i, (option, value) in enumerate(self.defaults.items()): 58 | flags = [f"--{option.replace('_', '-')}"] 59 | if i < len(shorts): 60 | flags.insert(0, f"-{shorts[i]}") 61 | if isinstance(value, bool): 62 | self.parser.add_argument( 63 | *flags, 64 | action=boolean_to_action[value], 65 | help=self.help[option], 66 | ) 67 | else: 68 | self.parser.add_argument( 69 | *flags, default=value, type=type(value), help=self.help[option] 70 | ) 71 | 72 | def _parse_args(self, args=None): 73 | """Convert argument strings to dictionary :attr:`args`. 74 | 75 | Return a `dict` containing arguments. 76 | """ 77 | namespace = self.parser.parse_args(args) 78 | self.args = vars(namespace) 79 | return self.args 80 | 81 | def _change_obj(self, obj): 82 | """Update object attributes.""" 83 | obj.__dict__.update(self.args) 84 | 85 | def parse_obj(self, obj, args=None, *, shorts=""): 86 | """Parse arguments and update object attributes. 87 | 88 | Args: 89 | obj: A `~dataclasses.dataclass` object with attributes as default arguments. 90 | args: A list of strings to parse. The default is taken from `sys.argv`. 91 | shorts: A sequence of short option letters for the leading options. 92 | 93 | Returns: 94 | A `dict` containing updated arguments. 95 | """ 96 | self._read_obj(obj) 97 | self._add_arguments(shorts=shorts) 98 | self._parse_args(args=args) 99 | self._change_obj(obj) 100 | return self.args 101 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.ruff.lint] 6 | select = ["E4", "E7", "E9", "F", "I"] 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = config-argument-parser 3 | version = attr: configargparser.__version__ 4 | url = https://github.com/yuanx749/config-argument-parser 5 | project_urls = 6 | Documentation=http://config-argument-parser.readthedocs.io/ 7 | Source=https://github.com/yuanx749/config-argument-parser 8 | author = Xiao Yuan 9 | author_email = yuanx749@gmail.com 10 | classifiers = 11 | Programming Language :: Python :: 3 12 | License :: OSI Approved :: MIT License 13 | Operating System :: OS Independent 14 | license = MIT License 15 | description = A package to help automatically create command-line interface from configuration or code. 16 | long_description = file: README.md 17 | long_description_content_type = text/markdown 18 | keywords = CLI, option, argument, parameter, flag, configuration, parser, command, comment, dataclass, Python 19 | 20 | [options] 21 | python_requires = >=3.8 22 | packages = configargparser 23 | 24 | [options.extras_require] 25 | dev = 26 | pre-commit 27 | pytest 28 | pytest-cov 29 | ruff 30 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yuanx749/config-argument-parser/b5e1ea624c5e711ec53cdb299d95710951c4ba90/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_cap.py: -------------------------------------------------------------------------------- 1 | import os 2 | from tempfile import mkstemp 3 | 4 | from configargparser import cap 5 | 6 | conf_str = """[DEFAULT] 7 | # Help message of the first argument. Help is optional. 8 | a_string = 'abc' 9 | a_float = 1.23 # inline comments are omitted 10 | # Help can span multiple lines. 11 | # This is another line. 12 | a_boolean = False 13 | an_integer = 0 14 | """ 15 | 16 | 17 | class Args: 18 | # Help message of the first argument. Help is optional. 19 | a_string = "abc" 20 | a_float = 1.23 # inline comments are omitted 21 | # Help can span multiple lines. 22 | # This is another line. 23 | a_boolean = False 24 | an_integer = 0 25 | 26 | 27 | class TestConfigArgumentParser: 28 | parser = cap.ConfigArgumentParser() 29 | 30 | def setup_method(self): 31 | self.parser.read_string(conf_str) 32 | 33 | def teardown_method(self): 34 | self.parser._init_parser() 35 | 36 | def test_read_file(self): 37 | fd, fname = mkstemp() 38 | with open(fname, "w") as fp: 39 | fp.write(conf_str) 40 | parser = cap.ConfigArgumentParser() 41 | parser.read(fname) 42 | assert parser.defaults == self.parser.defaults 43 | assert parser.help == self.parser.help 44 | os.close(fd) 45 | os.remove(fname) 46 | 47 | def test_read_py(self): 48 | fd, fname = mkstemp(suffix=".py") 49 | with open(fname, "w") as fp: 50 | fp.write("# " + conf_str) 51 | parser = cap.ConfigArgumentParser() 52 | parser.read_py(fname) 53 | assert parser.defaults == self.parser.defaults 54 | assert parser.help == self.parser.help 55 | os.close(fd) 56 | os.remove(fname) 57 | 58 | def test_parse_comments(self): 59 | assert ( 60 | self.parser.help["a_string"] 61 | == "Help message of the first argument. Help is optional." 62 | ) 63 | assert self.parser.help["a_float"] == "a_float" 64 | assert ( 65 | self.parser.help["a_boolean"] 66 | == "Help can span multiple lines. This is another line." 67 | ) 68 | 69 | def test_parse_args_default(self): 70 | self.parser._add_arguments() 71 | self.parser._parse_args([]) 72 | assert ( 73 | self.parser.defaults 74 | == self.parser.args 75 | == {"a_string": "abc", "a_float": 1.23, "a_boolean": False, "an_integer": 0} 76 | ) 77 | 78 | def test_parse_args_separate(self): 79 | self.parser._add_arguments() 80 | self.parser._parse_args("--a-float 1".split()) 81 | assert self.parser.args["a_float"] == 1.0 82 | self.parser._parse_args(["--a-boolean"]) 83 | assert self.parser.args["a_boolean"] 84 | 85 | def test_parse_args_short(self): 86 | self.parser._add_arguments(shorts="sfb") 87 | self.parser._parse_args("-b -f 1".split()) 88 | assert self.parser.args["a_float"] == 1.0 89 | assert self.parser.args["a_boolean"] 90 | 91 | def test_parse_args(self): 92 | self.parser.parse_args("-b -f 1".split(), shorts="sfb") 93 | assert self.parser.args["a_string"] == "abc" 94 | assert self.parser.args["a_float"] == 1.0 95 | assert self.parser.args["a_boolean"] 96 | assert self.parser.args["an_integer"] == 0 97 | 98 | def test_read_obj(self): 99 | args = Args() 100 | parser = cap.ConfigArgumentParser() 101 | parser._read_obj(args) 102 | assert parser.defaults == self.parser.defaults 103 | assert parser.help == self.parser.help 104 | 105 | def test_parse_obj(self): 106 | args = Args() 107 | self.parser.parse_obj(args, "-b -f 1".split(), shorts="sfb") 108 | assert args.__dict__ == { 109 | "a_string": "abc", 110 | "a_float": 1.0, 111 | "a_boolean": True, 112 | "an_integer": 0, 113 | } 114 | -------------------------------------------------------------------------------- /tests/test_gap.py: -------------------------------------------------------------------------------- 1 | from configargparser import gap 2 | 3 | a_string = "abc" 4 | a_float = 1.23 5 | a_boolean = False 6 | an_integer = 0 7 | 8 | 9 | class TestConfigArgumentParser: 10 | def setup_method(self): 11 | self.args = { 12 | "a_string": a_string, 13 | "a_float": a_float, 14 | "a_boolean": a_boolean, 15 | "an_integer": an_integer, 16 | } 17 | self.parser = gap.GlobalArgumentParser() 18 | self.parser._read_globals(stack=1) 19 | 20 | def teardown_method(self): 21 | self.args = { 22 | "a_string": a_string, 23 | "a_float": a_float, 24 | "a_boolean": a_boolean, 25 | "an_integer": an_integer, 26 | } 27 | self.parser = gap.GlobalArgumentParser() 28 | 29 | def test_read_globals(self): 30 | assert self.parser.defaults == self.args 31 | 32 | def test_parse_args_default(self): 33 | self.parser._add_arguments() 34 | self.parser._parse_args([]) 35 | assert self.parser.args == self.args 36 | 37 | def test_parse_args_separate(self): 38 | self.parser._add_arguments() 39 | self.parser._parse_args("--a-float 1".split()) 40 | assert self.parser.args["a_float"] == 1.0 41 | self.parser._parse_args(["--a-boolean"]) 42 | assert self.parser.args["a_boolean"] 43 | 44 | def test_parse_args_short(self): 45 | self.parser._add_arguments(shorts="sfb") 46 | self.parser._parse_args("-b -f 1".split()) 47 | assert self.parser.args["a_float"] == 1.0 48 | assert self.parser.args["a_boolean"] 49 | 50 | def test_update_globals(self): 51 | self.parser.parse_globals("-b -f 1".split(), shorts="sfb") 52 | assert a_string == "abc" 53 | assert a_float == 1.0 54 | assert a_boolean 55 | assert an_integer == 0 56 | -------------------------------------------------------------------------------- /tests/test_tap.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from configargparser import tap 4 | 5 | 6 | @dataclass 7 | class Args: 8 | # Help message of the first argument. Help is optional. 9 | a_string: str = "abc" 10 | a_float: float = 1.23 # inline comments are omitted 11 | # Help can span multiple lines. 12 | # This is another line. 13 | a_boolean: bool = False 14 | an_integer: int = 0 15 | 16 | 17 | class TestConfigArgumentParser: 18 | def setup_method(self): 19 | self.args = Args() 20 | self.parser = tap.TypeArgumentParser() 21 | self.parser._read_obj(self.args) 22 | 23 | def teardown_method(self): 24 | self.args = Args() 25 | self.parser = tap.TypeArgumentParser() 26 | 27 | def test_read_obj(self): 28 | assert self.parser.defaults == self.args.__dict__ 29 | 30 | def test_parse_comments(self): 31 | assert ( 32 | self.parser.help["a_string"] 33 | == "Help message of the first argument. Help is optional." 34 | ) 35 | assert self.parser.help["a_float"] == "a_float" 36 | assert ( 37 | self.parser.help["a_boolean"] 38 | == "Help can span multiple lines. This is another line." 39 | ) 40 | 41 | def test_parse_args_default(self): 42 | self.parser._add_arguments() 43 | self.parser._parse_args([]) 44 | assert self.parser.args == self.args.__dict__ 45 | 46 | def test_parse_args_separate(self): 47 | self.parser._add_arguments() 48 | self.parser._parse_args("--a-float 1".split()) 49 | assert self.parser.args["a_float"] == 1.0 50 | self.parser._parse_args(["--a-boolean"]) 51 | assert self.parser.args["a_boolean"] 52 | 53 | def test_parse_args_short(self): 54 | self.parser._add_arguments(shorts="sfb") 55 | self.parser._parse_args("-b -f 1".split()) 56 | assert self.parser.args["a_float"] == 1.0 57 | assert self.parser.args["a_boolean"] 58 | 59 | def test_update_obj(self): 60 | self.parser.parse_obj(self.args, "-b -f 1".split(), shorts="sfb") 61 | assert self.args.__dict__ == { 62 | "a_string": "abc", 63 | "a_float": 1.0, 64 | "a_boolean": True, 65 | "an_integer": 0, 66 | } 67 | --------------------------------------------------------------------------------