diff --git a/README.rst b/README.rst index d211e79..4d29a3a 100644 --- a/README.rst +++ b/README.rst @@ -23,10 +23,6 @@ A library for developing command-line applications in Python. :target: https://pepy.tech/badge/cmdkit :alt: Downloads -.. image:: https://github.com/glentner/CmdKit/workflows/tests/badge.svg - :target: https://github.com/glentner/cmdkit/actions - :alt: Pytest - | The *cmdkit* library implements a few common patterns needed by well-formed command-line @@ -40,49 +36,89 @@ understand. Features -------- -- An `Interface `_ - class for parsing command-line arguments. -- An `Application `_ - class that provides the boilerplate for a good entry-point. -- A `Configuration `_ - class built on top of a recursive - `Namespace `_ - class that provides automatic depth-first merging of dictionaries from local files, - as well as automatic environment variable discovery and type-coercion. +An `Application `_ +class provides the boilerplate for a good entry-point. +Building your command-line application in layers with +`ApplicationGroup `_ +let's you develop simple structures and modules that mirror your CLI. + +An `Interface `_ class +modifies the behavior of the standard ``argparse.ArgumentParser`` class to raise simple exceptions +instead of exiting. + +.. code-block:: python + + class Add(Application): + """Application class for adding routine.""" + + interface = Interface('add', USAGE_TEXT, HELP_TEXT) + interface.add_argument('-v', '--version', action='version', '0.0.1') + + lhs: int + rhs: int + interface.add_argument('lhs', type=float) + interface.add_argument('rhs', type=float) + + def run(self) -> None: + """Business logic of the application.""" + print(self.lhs + self.rhs) + +| + +A +`Configuration `_ +class makes it basically a one-liner to pull in +a configuration with a dictionary-like interface from a cascade of files as well as +expanding environment variables into a hierarchy and merged. + +The standard behavior for any `good` application is for a configuration to allow for +system-level, user-level, and local configuration to overlap. Merging these should not +clobber the same section in a lower-priority source. The +`Namespace `_ +class extends the behavior of a standard Python `dict` to have a depth-first merge for its +`update` implementation. + +.. code-block:: python + + config = Configuration.from_local(env=True, prefix='MYAPP', default=default, **paths) + +The underlying +`Namespace `_ +also supports the convention of having +parameters with ``_env`` and ``_eval`` automatically expanded. + +.. code-block:: toml + + [database] + password_eval = "gpg ..." + +Accessing the parameter with dot-notation, i.e., ``config.database.password`` would execute +``"gpg ..."`` as a shell command and return the output. | Installation ------------ -*CmdKit* is built on Python 3.7+ and can be installed using Pip. +*CmdKit* is tested on Python 3.7+ for `Windows`, `macOS`, and `Linux`, and can be installed +from the `Python Package Index` using `Pip`. -.. code-block:: +:: - ➜ pip install cmdkit + $ pip install cmdkit | Getting Started --------------- -See the `Tutorial `_ for examples. +Checkout the `Tutorial `_ for examples. -You can also checkout how `cmdkit` is being used by other projects. - -======================================================== ======================================================= -Project Description -======================================================== ======================================================= -`REFITT `_ Recommender Engine for Intelligent Transient Tracking -`hyper-shell `_ Hyper-shell is an elegant, cross-platform, high-performance - computing utility for processing shell commands over a - distributed, asynchronous queue. -`delete-cli `_ A simple, cross-platform, command-line move-to-trash. -======================================================== ======================================================= +You can also checkout how `CmdKit` is being used by other projects, e.g., +`REFITT `_ and `HyperShell `_. | - Documentation ------------- diff --git a/cmdkit/__init__.py b/cmdkit/__init__.py index 7050e3d..bdf0c5d 100644 --- a/cmdkit/__init__.py +++ b/cmdkit/__init__.py @@ -4,10 +4,10 @@ """Package initialization for CmdKit.""" -import logging -from .__meta__ import (__pkgname__, __version__, __authors__, __contact__,\ +# package attributes +from .__meta__ import (__pkgname__, __version__, __authors__, __contact__, __license__, __copyright__, __description__) - # null-handler for library +import logging logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/cmdkit/__meta__.py b/cmdkit/__meta__.py index 3d73f33..efb365e 100644 --- a/cmdkit/__meta__.py +++ b/cmdkit/__meta__.py @@ -5,7 +5,7 @@ __pkgname__ = 'cmdkit' -__version__ = '2.4.0' +__version__ = '2.5.0' __authors__ = 'Geoffrey Lentner' __contact__ = 'glentner@purdue.edu' __license__ = 'Apache License' diff --git a/cmdkit/app.py b/cmdkit/app.py index 414e990..cbe033a 100644 --- a/cmdkit/app.py +++ b/cmdkit/app.py @@ -113,8 +113,8 @@ def main(cls, cmdline: List[str] = None, shared: Namespace = None) -> int: return exit_status.success - except cli.HelpOption as help_text: - cls.handle_help(help_text) + except cli.HelpOption as help_opt: + cls.handle_help(*help_opt.args) return exit_status.success except cli.VersionOption as version: @@ -155,9 +155,7 @@ class CompletedCommand(Exception): class ApplicationGroup(Application): - """ - A group entry-point delegates to member `Application`. - """ + """A group entry-point delegates to a member `Application`.""" interface: cli.Interface = None commands: Dict[str, Type[Application]] = None diff --git a/cmdkit/config.py b/cmdkit/config.py index 1c9edf3..efc87e5 100644 --- a/cmdkit/config.py +++ b/cmdkit/config.py @@ -64,21 +64,21 @@ def __setattr__(self, name: str, value: Any) -> None: super().__setattr__(name, value) @classmethod - def _depth_first_update(cls, original: dict, new: dict) -> dict: + def __depth_first_update(cls, original: dict, new: dict) -> dict: """ - Like normal `dict.update` but if values in both are mappable descend + Like normal `dict.update` but if values in both are mappable, descend a level deeper (recursive) and apply updates there instead. """ for key, value in new.items(): if isinstance(value, dict) and isinstance(original.get(key), dict): - original[key] = cls._depth_first_update(original.get(key, {}), value) + original[key] = cls.__depth_first_update(original.get(key, {}), value) else: original[key] = value return original def update(self, *args, **kwargs) -> None: - """Implements a recursive, depth-first update (i.e., an "override").""" - self._depth_first_update(self, dict(*args, **kwargs)) + """Depth-first update method.""" + self.__depth_first_update(self, dict(*args, **kwargs)) def __getattr__(self, item: str) -> Any: """ @@ -90,16 +90,16 @@ def __getattr__(self, item: str) -> Any: return self[item] for variant in variants: if variant in self: - return self._expand_attr(item) + return self.__expand_attr(item) else: raise AttributeError(f'missing \'{item}\'') - def _expand_attr(self, item: str) -> str: + def __expand_attr(self, item: str) -> str: """Interpolate values if `_env` or `_eval` present.""" getters = {f'{item}': (lambda: self[item]), - f'{item}_env': functools.partial(self._expand_attr_env, item), - f'{item}_eval': functools.partial(self._expand_attr_eval, item)} + f'{item}_env': functools.partial(self.__expand_attr_env, item), + f'{item}_eval': functools.partial(self.__expand_attr_eval, item)} items = [key for key in self if key in getters] if len(items) == 0: @@ -109,11 +109,11 @@ def _expand_attr(self, item: str) -> str: else: raise ConfigurationError(f'\'{item}\' has more than one variant') - def _expand_attr_env(self, item: str) -> str: + def __expand_attr_env(self, item: str) -> str: """Expand `item` as an environment variable.""" return os.getenv(str(self[f'{item}_env']), None) - def _expand_attr_eval(self, item: str) -> str: + def __expand_attr_eval(self, item: str) -> str: """Expand `item` as a shell expression.""" return subprocess.check_output(str(self[f'{item}_eval']), shell=True).decode().strip() @@ -427,18 +427,21 @@ class Configuration(NSCoreMixin): 1 """ - namespaces: Namespace = None + local: Namespace # NOTE: used to track changes to the 'self' + namespaces: Namespace def __init__(self, **namespaces: Namespace) -> None: """Retain source `namespaces` and create master namespace.""" super().__init__() + self.local = Namespace() self.namespaces = Namespace() - # self._master = Namespace() self.extend(**namespaces) def __repr__(self) -> str: """String representation of Configuration.""" kwargs = ', '.join([f'{k}=' + repr(v) for k, v in self.namespaces.items()]) + if self.local: + kwargs += f', _={repr(self.local)}' return f'{self.__class__.__name__}({kwargs})' def extend(self, **others: Union[Namespace, Environ]) -> None: @@ -455,8 +458,11 @@ def extend(self, **others: Union[Namespace, Environ]) -> None: three=Namespace({'y': 5, 'u': {'i': 6, 'j': 7}}) """ for name, mapping in others.items(): - self.namespaces[name] = Namespace(mapping) - self.update(self.namespaces[name]) + if name != '_': + self.namespaces[name] = Namespace(mapping) + super().update(self.namespaces[name]) + else: + self.local.update(mapping) @classmethod def from_local(cls, *, env: bool = False, prefix: str = None, @@ -499,13 +505,56 @@ def which(self, *path: str) -> str: >>> conf.which('u', 'i') 'three' """ - for label in reversed(list(self.namespaces.keys())): + namespaces = Namespace({**self.namespaces, '_': self.local}) + for label in reversed(list(namespaces.keys())): try: - sub = self.namespaces[label] + sub = namespaces[label] for p in path: sub = sub[p] return label except KeyError: pass else: - raise KeyError(f'not found: {path}') + raise KeyError(f'Not found: {path}') + + def __setattr__(self, name: str, value: Any) -> None: + """Intercept parameter assignment.""" + if name in self: + self.update({name: value}) + else: + super().__setattr__(name, value) + + def update(self, *args, **kwargs) -> None: + """ + Update current namespace directly. + + Note: + The :class:`Configuration` class is itself a :class:`Namespace`-like object. + Doing any in-place changes to its underlying `self` does not change its member namespaces. + This may otherwise cause confusion about the provenance of those parameters. + Instead, overrides have been implemented to capture these changes in a `local` namespace. + If you ask :func:`which` namespace a parameter has come from and it was an in-place change, + it will be considered a member of the "_" namespace. + + Example: + >>> conf = Configuration(a=Namespace(x=1)) + >>> conf + Configuration(a=Namespace({'x': 1})) + + >>> conf.update(y=2) + >>> conf + Configuration(a=Namespace({'x': 1}), _=Namespace({'y': 2})) + + >>> conf.x = 2 + >>> conf + Configuration(a=Namespace({'x': 1}), _=Namespace({'x': 2, 'y': 2})) + + >>> conf.update(y=3) + >>> conf + Configuration(a=Namespace({'x': 1}), _=Namespace({'x': 2, 'y': 3})) + + >>> dict(conf) + {'x': 2, 'y': 3} + """ + self.local.update(*args, **kwargs) + super().update(*args, **kwargs) diff --git a/docs/source/api/app.rst b/docs/source/api/app.rst index eb40db0..d8b1499 100644 --- a/docs/source/api/app.rst +++ b/docs/source/api/app.rst @@ -30,7 +30,7 @@ all the entry-points in your project. class MyApp(Application): ... - interface = Interface('myapp', 'usage text', 'help text') + interface = Interface('myapp', USAGE_TEXT, HELP_TEXT) output: str = '-' interface.add_argument('-o', '--output', default=output) @@ -166,6 +166,61 @@ all the entry-points in your project. .. automethod:: __exit__ + | + + .. autoattribute:: shared + + A shared :class:`~cmdkit.config.Namespace` with parameters from parent group(s). + See :class:`ApplicationGroup`. + +| + +------------------- + +| + +.. autoclass:: ApplicationGroup + + .. code-block:: python + + class MainApp(ApplicationGroup): + """Top-level entry-point for a hierarchical interface.""" + + interface = Interface('myapp', USAGE_TEXT, HELP_TEXT) + + command: str + interface.add_argument('command') + + commands = { + 'config': ConfigApp, + 'list': ListApp, + 'run': RunApp, + } + + | + + .. autoattribute:: shared + + .. autoattribute:: ALLOW_PARSE + + By default, the ``cmdline`` list passed to :meth:`~Application.main` has + its first argument popped and used to lookup which member :class:`Application` to run. + + If ``ALLOW_PARSE`` is ``True``, then `known` options of the group ``interface`` + are parsed from ``cmdline`` and retained in a member :class:`~cmdkit.config.Namespace`, + :data:`Application.shared`, with the remainder passed on to the down-line + :class:`Application`. + + .. code-block:: + + class MainApp(ApplicationGroup): + ... + + ALLOW_PARSED = True + + verbose: bool = False + interface.add_argument('--verbose', action='store_true') + | ------------------- diff --git a/docs/source/api/config.rst b/docs/source/api/config.rst index 72e2e3f..4309cde 100644 --- a/docs/source/api/config.rst +++ b/docs/source/api/config.rst @@ -88,20 +88,9 @@ local files and environment variables with appropriate precedent. .. automethod:: which -| - -.. note:: - - Because of an implementation detail regarding the way the :class:`Configuration` - class is implemented, using the :func:`update` method directly will have the intended - effect on the immediate representation of the structure, but knowledge of where that - change occurred will be lost. - - Similarly, directly modifying parameters will work as expected with the exception that - the now current value will lose its provenance. + | - **This behavior is not considered part of the public API and may in future releases be - changed without notice and is not considered a major change.** + .. automethod:: update | diff --git a/docs/source/index.rst b/docs/source/index.rst index 26ca633..47f742f 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -36,13 +36,60 @@ understand. Features -------- -- An :class:`~cmdkit.cli.Interface` class for parsing command-line arguments. -- An :class:`~cmdkit.app.Application` class that provides the boilerplate for - a good entry-point. -- A :class:`~cmdkit.config.Configuration` class built on top of a recursive - :class:`~cmdkit.config.Namespace` class that provides - automatic depth-first merging of dictionaries from local files, - as well as automatic environment variable discovery and type-coercion. +| + +An :class:`~cmdkit.app.Application` class provides the boilerplate for a good entry-point. +Building your command-line application in layers with :class:`~cmdkit.app.ApplicationGroup` +let's you develop simple structures and modules that mirror your CLI. + +An :class:`~cmdkit.cli.Interface` class modifies the behavior of the standard +:class:`argparse.ArgumentParser` class to raise simple exceptions instead of exiting. + +.. code-block:: python + + class Add(Application): + """Application class for adding routine.""" + + interface = Interface('add', USAGE_TEXT, HELP_TEXT) + interface.add_argument('-v', '--version', action='version', '0.0.1') + + lhs: int + rhs: int + interface.add_argument('lhs', type=float) + interface.add_argument('rhs', type=float) + + def run(self) -> None: + """Business logic of the application.""" + print(self.lhs + self.rhs) + + +| + +A :class:`~cmdkit.config.Configuration` class makes it basically a one-liner to pull in +a configuration with a dictionary-like interface from a cascade of files as well as +expanding environment variables into a hierarchy and merged. + +The standard behavior for any `good` application is for a configuration to allow for +system-level, user-level, and local configuration to overlap. Merging these should not +clobber the same section in a lower-priority source. The :class:`~cmdkit.config.Namespace` +class extends the behavior of a standard Python `dict` to have a depth-first merge for its +`update` implementation. + +.. code-block:: python + + config = Configuration.from_local(env=True, prefix='MYAPP', default=default, **paths) + +The underlying :class:`~cmdkit.config.Namespace` also supports the convention of having +parameters with ``_env`` and ``_eval`` automatically expanded. + +.. code-block:: toml + :caption: ~/.myapp/config.toml + + [database] + password_eval = "gpg ..." + +Accessing the parameter with dot-notation, i.e., ``config.database.password`` would execute +``"gpg ..."`` as a shell command and return the output. | @@ -51,11 +98,12 @@ Features Installation ------------ -*CmdKit* is built on Python 3.7+ and can be installed using Pip. +*CmdKit* is tested on Python 3.7+ for `Windows`, `macOS`, and `Linux`, and can be installed +from the `Python Package Index` using `Pip`. -.. code-block:: none +:: - ➜ pip install cmdkit + $ pip install cmdkit | @@ -66,7 +114,7 @@ Getting Started Checkout the :ref:`Tutorial ` for examples. -You can also checkout how `cmdkit` is being used by other projects. +You can also checkout how `CmdKit` is being used by other projects. ======================================================== ======================================================= Project Description diff --git a/tests/test_config.py b/tests/test_config.py index 9e1c912..9b0aecb 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -458,9 +458,9 @@ def test_configuration_from_local() -> None: output.write(data) # clean environment of any existing variables with the item - PREFIX = 'CMDKIT' + prefix = 'CMDKIT' for var in dict(os.environ): - if var.startswith(PREFIX): + if var.startswith(prefix): os.environ.pop(var) # populate environment with test variables @@ -470,7 +470,7 @@ def test_configuration_from_local() -> None: # build configuration default = Namespace.from_toml(StringIO(TEST_CONFIG_DEFAULT)) - cfg = Configuration.from_local(default=default, env=True, prefix=PREFIX, + cfg = Configuration.from_local(default=default, env=True, prefix=prefix, system=f'{TMPDIR}/system.toml', user=f'{TMPDIR}/user.toml', local=f'{TMPDIR}/local.toml') @@ -480,7 +480,7 @@ def test_configuration_from_local() -> None: assert cfg.namespaces['system'] == Namespace.from_toml(StringIO(TEST_CONFIG_SYSTEM)) assert cfg.namespaces['user'] == Namespace.from_toml(StringIO(TEST_CONFIG_USER)) assert cfg.namespaces['local'] == Namespace.from_toml(StringIO(TEST_CONFIG_LOCAL)) - assert cfg.namespaces['env'] == Environ(PREFIX).reduce() + assert cfg.namespaces['env'] == Environ(prefix).reduce() # verify parameter lineage assert cfg['a']['var0'] == 'default_var0' and cfg.which('a', 'var0') == 'default' @@ -489,3 +489,27 @@ def test_configuration_from_local() -> None: assert cfg['b']['var3'] == 'local_var3' and cfg.which('b', 'var3') == 'local' assert cfg['c']['var4'] == 'env_var4' and cfg.which('c', 'var4') == 'env' assert cfg['c']['var5'] == 'env_var5' and cfg.which('c', 'var5') == 'env' + + +def test_configuration_live_update() -> None: + """Do not allow the direct use of `update` on Configuration class.""" + + cfg = Configuration(a=Namespace(x=1)) + assert repr(cfg) == 'Configuration(a=Namespace({\'x\': 1}))' + assert dict(cfg) == {'x': 1} + assert cfg.which('x') == 'a' + + cfg.x = 2 + assert repr(cfg) == 'Configuration(a=Namespace({\'x\': 1}), _=Namespace({\'x\': 2}))' + assert dict(cfg) == {'x': 2} + assert cfg.which('x') == '_' + + cfg.update(y=2) + assert dict(cfg) == {'x': 2, 'y': 2} + assert repr(cfg) == 'Configuration(a=Namespace({\'x\': 1}), _=Namespace({\'x\': 2, \'y\': 2}))' + assert cfg.which('y') == '_' + + cfg.update(y=3) + assert dict(cfg) == {'x': 2, 'y': 3} + assert repr(cfg) == 'Configuration(a=Namespace({\'x\': 1}), _=Namespace({\'x\': 2, \'y\': 3}))' + assert cfg.which('y') == '_'