From 51ffe3f4b935e17086c45cff44463564086de9b4 Mon Sep 17 00:00:00 2001 From: Zach White Date: Sun, 4 Feb 2024 14:30:32 -0800 Subject: [PATCH] Use an interface so we don't have to replace the milc.cli object --- ci_tests | 6 +- custom_logger | 4 +- docs/logging.md | 28 ++++ docs/metadata.md | 14 +- docs/subcommand_config.md | 3 + example | 6 +- generate_docs | 7 +- milc/__init__.py | 20 ++- milc/milc.py | 8 +- milc/milc_interface.py | 269 ++++++++++++++++++++++++++++++++++++++ setup.cfg | 8 ++ spinner | 6 +- spinner_qmk | 2 +- 13 files changed, 339 insertions(+), 42 deletions(-) create mode 100644 milc/milc_interface.py diff --git a/ci_tests b/ci_tests index bfd20f4..c1513f6 100755 --- a/ci_tests +++ b/ci_tests @@ -10,12 +10,10 @@ from pathlib import Path from shutil import rmtree from subprocess import CalledProcessError, DEVNULL, run -from milc import set_metadata - -set_metadata(name='ci_tests', author='MILC', version='1.3.0') - from milc import cli +cli.milc_options(name='ci_tests', author='MILC', version='1.8.0') + @cli.entrypoint('Run CI Tests...') def main(cli): diff --git a/custom_logger b/custom_logger index 32ab762..7c2ee93 100755 --- a/custom_logger +++ b/custom_logger @@ -5,13 +5,13 @@ PYTHON_ARGCOMPLETE_OK """ import logging -from milc import set_metadata +from milc import cli # Setup external logger logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger('custom_logger') -set_metadata(logger=logging.getLogger('custom_logger')) +cli.milc_options(logger=logging.getLogger('custom_logger')) # Import milc from milc import cli diff --git a/docs/logging.md b/docs/logging.md index ce39efe..124a546 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -39,3 +39,31 @@ Users have several CLI arguments they can pass to control the output of logs. Th * Enable or disable ANSI color * `--unicode` and `--no-unicode` * Enable or disable unicode icons + +# Custom Loggers + +You may want to bypass MILC's logging and use your own logger instead. To do this use `cli.milc_options(logger=)`. This should be done before you call `cli()` or do anything else. + +Example: + +```python +import logging + +from milc import cli + + +@cli.entrypoint('Hello, World!') +def hello(cli): + cli.log.info('Hello, World!') + +if __name__ == '__main__': + my_logger = logging.getLogger('my-program') + # Configure my_logger the way you want/need here + + cli.milc_options(logger=my_logger) + cli() + +``` + +!!! warning + You should only call `cli.milc_options()` one time during your program's execution. diff --git a/docs/metadata.md b/docs/metadata.md index 3c57bf4..09933f7 100644 --- a/docs/metadata.md +++ b/docs/metadata.md @@ -1,13 +1,13 @@ # MILC Metadata -In order to initialize some things, such as the configuration file location and the version number reported by `--version`, MILC needs to know some basic information before you import `cli`. If you need to set the program's name, author name, and/or version number do it like this: +In order to initialize some things, such as the configuration file location and the version number reported by `--version`, MILC needs to know some basic information before the entrypoint is called. You can use `cli.milc_options()` to set this information. -```python -from milc import set_metadata - -set_metadata(name='Florzelbop', version='1.0.0', author='Jane Doe') +Example: +```python from milc import cli + +cli.milc_options(name='Florzelbop', version='1.0.0', author='Jane Doe') ``` You should only do this once, and you should do it as early in your program's execution as possible. @@ -28,3 +28,7 @@ set_metadata(logger=custom_logger) from milc import cli ``` + +## Deprecated: set_metadata() + +Earlier versions of MILC used `milc.set_metadata` instead. This is still supported but will throw a Deprecation warning. diff --git a/docs/subcommand_config.md b/docs/subcommand_config.md index 6f392e6..52a58d8 100644 --- a/docs/subcommand_config.md +++ b/docs/subcommand_config.md @@ -8,6 +8,9 @@ Configuration for MILC applications is a key/value system. Each key consists of `import milc.subcommand.config` +!!! warn + This must be imported after `cli.milc_options()` is used. + Read on to see how users can utilize this subcommand. ## Simple Example diff --git a/example b/example index 823fdde..d794336 100755 --- a/example +++ b/example @@ -5,11 +5,11 @@ PYTHON_ARGCOMPLETE_OK """ import os -from milc import set_metadata +from milc import cli -set_metadata(name='example', author='Milc Milcenson', version='1.8.0') +cli.milc_options(name='example', author='Milc Milcenson', version='1.8.0') -from milc import cli +# This needs to be imported after we use cli.milc_options() import milc.subcommand.config # noqa diff --git a/generate_docs b/generate_docs index 8ea7694..79d7c7d 100755 --- a/generate_docs +++ b/generate_docs @@ -7,13 +7,10 @@ import os from pathlib import Path from subprocess import CalledProcessError -summary_demarc = '' - -from milc import set_metadata - -set_metadata(name='generate_docs', author='MILC', version='1.3.0') from milc import cli +cli.milc_options(name='generate_docs', author='MILC', version='1.8.0') + @cli.argument('--commit', arg_only=True, action='store_true', help='Commit changes to git.') @cli.entrypoint('Generate documentation.') diff --git a/milc/__init__.py b/milc/__init__.py index 6eebef9..731454c 100644 --- a/milc/__init__.py +++ b/milc/__init__.py @@ -18,17 +18,17 @@ import os import sys import warnings -from typing import Optional +from typing import Any, Optional from .emoji import EMOJI_LOGLEVELS -from .milc import MILC +from .milc_interface import MILCInterface if 'MILC_IGNORE_DEPRECATED' not in os.environ: for name in ('MILC_APP_NAME', 'MILC_APP_VERSION', 'MILC_APP_AUTHOR'): if name in os.environ: - warnings.warn(f'Using environment variable {name} is deprecated and will not be supported in the future, please use set_metadata() instead.', stacklevel=2) + warnings.warn(f'Using environment variable {name} is deprecated and will not be supported in the future, please use cli.milc_options() instead.', stacklevel=2) -cli = MILC() +cli = MILCInterface() def set_metadata( @@ -37,21 +37,19 @@ def set_metadata( author: Optional[str] = None, version: Optional[str] = None, logger: Optional[logging.Logger] = None, -) -> MILC: +) -> Any: """Set metadata about your program. + Deprecated: Use `cli.milc_options()` instead. + This allows you to set the application's name, version, and/or author before executing your entrypoint. You can also pass your own logger here if you like. It's best to run this only once, and it must be run before you call `cli()`. """ - global cli - - if cli._inside_context_manager: - raise RuntimeError('You must run set_metadata() before cli()!') - - cli = MILC(name, version, author, logger) + warnings.warn("milc.set_metadata has been deprecated, please use cli.milc_options() instead.", stacklevel=2) + cli.milc_options(name=name, author=author, version=version, logger=logger) return cli diff --git a/milc/milc.py b/milc/milc.py index 92fa178..aaf8dd2 100644 --- a/milc/milc.py +++ b/milc/milc.py @@ -36,13 +36,7 @@ class MILC(object): """MILC - An Opinionated Batteries Included Framework """ - def __init__( - self, - name: Optional[str] = None, - version: Optional[str] = None, - author: Optional[str] = None, - logger: Optional[logging.Logger] = None, - ) -> None: + def __init__(self, name: Optional[str] = None, author: Optional[str] = None, version: Optional[str] = None, logger: Optional[logging.Logger] = None) -> None: """Initialize the MILC object. """ # Set some defaults diff --git a/milc/milc_interface.py b/milc/milc_interface.py new file mode 100644 index 0000000..8c98053 --- /dev/null +++ b/milc/milc_interface.py @@ -0,0 +1,269 @@ +"""Public interface for MILC. + +This is where the public interface for `cli` is kept. This allows us to reinstantiate MILC without having to recreate the cli object, as well as allowing us to have a well defined public API. +""" +import sys +from logging import Logger +from pathlib import Path +from types import TracebackType +from typing import Any, Callable, Dict, Optional, Sequence, Type, Union + +from halo import Halo # type: ignore + +from .attrdict import AttrDict +from .configuration import Configuration +from .milc import MILC + + +class MILCInterface: + def __init__(self) -> None: + self._milc: Optional[MILC] = None + + def milc_options(self, *, name: Optional[str] = None, author: Optional[str] = None, version: Optional[str] = None, logger: Optional[Logger] = None) -> None: + if self._milc and self._milc._inside_context_manager: + raise RuntimeError('You must run set_metadata() before cli()!') + + self._milc = MILC(name, author, version, logger) + + @property + def milc(self) -> MILC: + if not self._milc: + self._milc = MILC() + + return self._milc + + @property + def args(self) -> AttrDict: + return self.milc.args + + @property + def config(self) -> Configuration: + return self.milc.config + + @property + def config_dir(self) -> Path: + return self.milc.config_dir + + @property + def config_source(self) -> Configuration: + return self.milc.config_source + + @property + def description(self) -> Optional[str]: + return self.milc.description + + @property + def interactive(self) -> bool: + return self.milc.interactive + + @property + def log(self) -> Logger: + return self.milc.log + + def echo(self, text: str, *args: Any, **kwargs: Any) -> None: + """Print colorized text to stdout. + + ANSI color strings (such as {fg_blue}) will be converted into ANSI + escape sequences, and the ANSI reset sequence will be added to all + strings. + + If *args or **kwargs are passed they will be used to %-format the strings. + """ + return self.milc.echo(text, *args, **kwargs) + + def run(self, command: Sequence[str], capture_output: bool = True, combined_output: bool = False, text: bool = True, **kwargs: Any) -> Any: # FIXME: In python 3.10 we can use subprocess.CompletedProcess[bytes | str] instead + """Run a command using `subprocess.run`, but using some different defaults. + + Unlike subprocess.run you must supply a sequence of arguments. You can use `shlex.split()` to build this from a string. + + The **kwargs arguments get passed directly to `subprocess.run`. + + Args: + command + A sequence where the first item is the command to run, and any remaining items are arguments to pass. + + capture_output + Set to False to have output written to the terminal instead of being available in the returned `subprocess.CompletedProcess` instance. + + combined_output + When true STDERR will be written to STDOUT. Equivalent to the shell construct `2>&1`. + + text + Set to False to disable encoding and get `bytes()` from `.stdout` and `.stderr`. + """ + return self.milc.run(command, capture_output, combined_output, text, **kwargs) + + def print_help(self, *args: Any, **kwargs: Any) -> None: + """Print a help message for the main program or subcommand, depending on context. + """ + return self.milc.print_help(*args, **kwargs) + + def print_usage(self, *args: Any, **kwargs: Any) -> None: + """Print brief description of how the main program or subcommand is invoked, depending on context. + """ + return self.milc.print_usage(*args, **kwargs) + + def add_argument(self, *args: Any, **kwargs: Any) -> None: + """Wrapper to add arguments and track whether they were passed on the command line. + """ + return self.milc.add_argument(*args, **kwargs) + + def acquire_lock(self, blocking: bool = True) -> bool: + """Acquire the MILC lock for exclusive access to properties. + """ + return self.milc.acquire_lock(blocking) + + def release_lock(self) -> None: + """Release the MILC lock. + """ + return self.milc.release_lock() + + def argument(self, *args: Any, **kwargs: Any) -> Callable[..., Any]: + """Decorator to add an argument to a MILC command or subcommand. + """ + return self.milc.argument(*args, **kwargs) + + def save_config(self) -> None: + """Save the current configuration to the config file. + """ + return self.milc.save_config() + + def __call__(self) -> Any: + """Execute the entrypoint function. + """ + return self.milc() + + def entrypoint(self, description: str, deprecated: Optional[str] = None) -> Callable[..., Any]: + """Decorator that marks the entrypoint used when a subcommand is not supplied. + Args: + description + A one-line description to display in --help + + deprecated + Deprecation message. When set the subcommand will marked as deprecated and this message will be displayed in the help output. + """ + return self.milc.entrypoint(description, deprecated) + + def subcommand(self, description: str, hidden: bool = False, **kwargs: Any) -> Callable[..., Any]: + """Decorator to register a subcommand. + + Args: + + description + A one-line description to display in --help + + hidden + When True don't display this command in --help + """ + return self.milc.subcommand(description, hidden, **kwargs) + + def __enter__(self) -> Any: + return self.milc.__enter__() + + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: + return self.milc.__exit__(exc_type, exc_val, exc_tb) + + def add_spinner(self, name: str, spinner: Dict[str, Union[int, Sequence[str]]]) -> None: + """Adds a new spinner to the list of spinners. + + A spinner is a dictionary with two keys: + + interval + An integer that sets how long (in ms) to wait between frames. + + frames + A list of frames for this spinner + """ + return self.milc.add_spinner(name, spinner) + + def spinner( + self, + text: str, + *args: Any, + spinner: Optional[str] = None, + animation: str = 'ellipsed', + placement: str = 'left', + color: str = 'blue', + interval: int = -1, + stream: Any = sys.stdout, + enabled: bool = True, + **kwargs: Any, + ) -> Halo: + """Create a spinner object for showing activity to the user. + + This uses halo behind the scenes, most of the arguments map to Halo objects 1:1. + + There are 3 basic ways to use this: + + * Instantiating a spinner and then using `.start()` and `.stop()` on your object. + * Using a context manager (`with cli.spinner(...):`) + * Decorate a function (`@cli.spinner(...)`) + + #### Instantiating a spinner + + ```python + spinner = cli.spinner(text='Loading', spinner='dots') + spinner.start() + + # Do something here + + spinner.stop() + ``` + + #### Using a context manager + + ```python + with cli.spinner(text='Loading', spinner='dots'): + # Do something here + ``` + + #### Decorate a function + + ```python + @cli.spinner(text='Loading', spinner='dots') + def long_running_function(): + # Do something here + ``` + + ### Arguments + + text + The text to display next to the spinner. ANSI color strings + (such as {fg_blue}) will be converted into ANSI escape + sequences, and the ANSI reset sequence will be added to the + end of the string. + + If *args or **kwargs are passed they will be used to + %-format the text. + + spinner + The name of the spinner to use. Available names are here: + + + animation + The animation to apply to the text if it doesn't fit the + terminal. One of `ellipsed`, `bounce`, `marquee`. + + placement + Which side of the text to display the spinner on. One of + `left`, `right`. + + color + Color of the spinner. One of `blue`, `grey`, `red`, `green`, + `yellow`, `magenta`, `cyan`, `white` + + interval + How long in ms to wait between frames. Defaults to the spinner interval (recommended.) + + stream + Stream to write the output. Defaults to sys.stdout. + + enabled + Enable or disable the spinner. Defaults to `True`. + """ + return self.milc.spinner(text, *args, spinner=spinner, animation=animation, placement=placement, color=color, interval=interval, stream=stream, enabled=enabled, **kwargs) diff --git a/setup.cfg b/setup.cfg index fe5e59f..cd95fa6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,6 +11,14 @@ message = New release: {current_version} → {new_version} [bumpversion:file:setup.cfg] +[bumpversion:file:ci_tests] + +[bumpversion:file:generate_docs] + +[bumpversion:file:spinner] + +[bumpversion:file:spinner_qmk] + [bdist_wheel] universal = 1 diff --git a/spinner b/spinner index 61f48cc..ae7d4cc 100755 --- a/spinner +++ b/spinner @@ -5,12 +5,10 @@ PYTHON_ARGCOMPLETE_OK """ from time import sleep -from milc import set_metadata - -set_metadata(name='spinner', author='MILC', version='1.3.0') - from milc import cli +cli.milc_options(name='spinner', author='MILC', version='1.8.0') + @cli.argument('-n', '--name', help='Name to greet', default='World') @cli.entrypoint('Show off spinners.') diff --git a/spinner_qmk b/spinner_qmk index a17210c..dfc1d48 100755 --- a/spinner_qmk +++ b/spinner_qmk @@ -7,7 +7,7 @@ from time import sleep from milc import set_metadata -set_metadata(name='spinner_qmk', author='MILC', version='1.3.0') +set_metadata(name='spinner_qmk', author='MILC', version='1.8.0') from milc import cli