Skip to content

Commit

Permalink
Merge branch 'release/2.5.0'
Browse files Browse the repository at this point in the history
  • Loading branch information
glentner committed Jun 5, 2021
2 parents a049a71 + 7186994 commit 8cd664b
Show file tree
Hide file tree
Showing 9 changed files with 284 additions and 85 deletions.
94 changes: 65 additions & 29 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -40,49 +36,89 @@ understand.
Features
--------

- An `Interface <https://cmdkit.readthedocs.io/en/latest/api/cli.html#cmdkit.cli.Interface>`_
class for parsing command-line arguments.
- An `Application <https://cmdkit.readthedocs.io/en/latest/api/app.html#cmdkit.app.Application>`_
class that provides the boilerplate for a good entry-point.
- A `Configuration <https://cmdkit.readthedocs.io/en/latest/api/config.html#cmdkit.config.Configuration>`_
class built on top of a recursive
`Namespace <https://cmdkit.readthedocs.io/en/latest/api/config.html#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 `Application <https://cmdkit.readthedocs.io/en/latest/api/app.html#cmdkit.app.Application>`_
class provides the boilerplate for a good entry-point.
Building your command-line application in layers with
`ApplicationGroup <https://cmdkit.readthedocs.io/en/latest/api/app.html#cmdkit.app.ApplicationGroup>`_
let's you develop simple structures and modules that mirror your CLI.

An `Interface <https://cmdkit.readthedocs.io/en/latest/api/cli.html#cmdkit.cli.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 <https://cmdkit.readthedocs.io/en/latest/api/config.html#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
`Namespace <https://cmdkit.readthedocs.io/en/latest/api/config.html#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
`Namespace <https://cmdkit.readthedocs.io/en/latest/api/config.html#cmdkit.config.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 <https://cmdkit.readthedocs.io/en/latest/tutorial/>`_ for examples.
Checkout the `Tutorial <https://cmdkit.readthedocs.io/en/latest/tutorial/>`_ for examples.

You can also checkout how `cmdkit` is being used by other projects.

======================================================== =======================================================
Project Description
======================================================== =======================================================
`REFITT <https://github.com/refitt/refitt>`_ Recommender Engine for Intelligent Transient Tracking
`hyper-shell <https://github.com/glentner/hyper-shell>`_ Hyper-shell is an elegant, cross-platform, high-performance
computing utility for processing shell commands over a
distributed, asynchronous queue.
`delete-cli <https://github.com/glentner/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 <https://github.com/refitt/refitt>`_ and `HyperShell <https://github.com/glentner/hyper-shell>`_.

|

Documentation
-------------

Expand Down
6 changes: 3 additions & 3 deletions cmdkit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
2 changes: 1 addition & 1 deletion cmdkit/__meta__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@


__pkgname__ = 'cmdkit'
__version__ = '2.4.0'
__version__ = '2.5.0'
__authors__ = 'Geoffrey Lentner'
__contact__ = '[email protected]'
__license__ = 'Apache License'
Expand Down
8 changes: 3 additions & 5 deletions cmdkit/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
85 changes: 67 additions & 18 deletions cmdkit/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand All @@ -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:
Expand All @@ -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()

Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Loading

0 comments on commit 8cd664b

Please sign in to comment.