Skip to content

Commit

Permalink
feat: adds a basic cli example
Browse files Browse the repository at this point in the history
  • Loading branch information
MultifokalHirn committed Dec 7, 2023
1 parent dc4839e commit cfbac50
Show file tree
Hide file tree
Showing 25 changed files with 307 additions and 65 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ dev: ## install all dependencies in lock file
$(VENV)/pdm install -G :all
.PHONY: dev

ci: ## Runs ci
ci: ## Runs ci
ci:
pdm run ci
.PHONY: ci
Expand Down
17 changes: 0 additions & 17 deletions src/__init__.py

This file was deleted.

20 changes: 0 additions & 20 deletions src/app/__init__.py

This file was deleted.

5 changes: 0 additions & 5 deletions src/app/__main__.py

This file was deleted.

File renamed without changes.
43 changes: 43 additions & 0 deletions src/python_template_repo/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""<your project name here>
<your project description here>
"""

# Local
from typing import Any

from . import _exceptions, _types, cli, utils
from .__metadata__ import __author__, __description__, __license__, __title__
from .__version__ import __version__

_deprecated: dict[str, Any] = {}


def __getattr__(name: str) -> Any:
if name in _deprecated:
import warnings

real = _deprecated[name]
warnings.warn(
f"{name} is deprecated, please use {real.__name__} instead",
DeprecationWarning,
stacklevel=2,
)
return real
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


# Public Re-Exports
__all__ = [
"cli",
"utils",
"_exceptions",
"_types",
"__title__",
"__description__",
"__version__",
"__author__",
"__license__",
]

__all__.extend(_deprecated)
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""project version"""

__version__ = "1.0.0"
__version__: str = "1.0.0"
__full_version__ = (0, 0, 0)
File renamed without changes.
34 changes: 34 additions & 0 deletions src/python_template_repo/_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from typing import NamedTuple


class MyClass(NamedTuple):
name: str
secret: str | None = None

def __rich__(self) -> str:
lines: list[str] = []
lines.append(f"[primary]name[/] = {self.name}")
if self.secret is not None:
lines.append(f"[error]secret[/] = {self.secret}")
return "\n".join(lines)


# if TYPE_CHECKING:
if True:
from typing import Any, Protocol, TypeVar

class RichProtocol(Protocol):
def __rich__(self) -> str:
...

SpinnerT = TypeVar("SpinnerT", bound="Spinner")

class Spinner(Protocol):
def update(self, text: str) -> None:
...

def __enter__(self: SpinnerT) -> SpinnerT:
...

def __exit__(self, *args: Any) -> None:
...
21 changes: 21 additions & 0 deletions src/python_template_repo/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import logging

from ..utils.logging import setup_logger
from .cli import CLI

__title__ = "cli"
__description__ = """
This is the cli module of the python_template_repo package.
"""


def main() -> int:
"""Main entry point for the application."""
LOG = setup_logger(logger_name=__name__, log_level=logging.DEBUG)
LOG.info("Starting cli...")
cli = CLI()
cli.echo("hello world")
return 0


__all__ = ["CLI", "main"]
5 changes: 5 additions & 0 deletions src/python_template_repo/cli/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import sys

from . import main

sys.exit(main())
117 changes: 117 additions & 0 deletions src/python_template_repo/cli/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import contextlib
import logging
import warnings
from tempfile import mktemp
from typing import Any
from collections.abc import Iterator

from rich.console import Console
from rich.progress import Progress, ProgressColumn

from .._types import RichProtocol, Spinner
from .config import LOG_LEVELS, Verbosity, _console, _err_console
from .spinner import SPINNER, DummySpinner

logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
logger.addHandler(logging.NullHandler())


def is_interactive(console: Console | None = None) -> bool:
"""Check if the terminal is run under interactive mode"""
if console is None:
console = _console
return console.is_interactive


class CLI:
"""Terminal UI object"""

def __init__(self, verbosity: Verbosity = Verbosity.NORMAL) -> None:
self.verbosity = verbosity

def set_verbosity(self, verbosity: int) -> None:
self.verbosity = Verbosity(verbosity)
if self.verbosity == Verbosity.QUIET:
warnings.simplefilter("ignore", FutureWarning, append=True)

def echo(
self,
message: str | RichProtocol = "",
err: bool = False,
verbosity: Verbosity = Verbosity.QUIET,
**kwargs: Any,
) -> None:
"""print message using rich console
:param message: message with rich markup, defaults to "".
:param err: if true print to stderr, defaults to False.
:param verbosity: verbosity level, defaults to QUIET.
"""
if self.verbosity >= verbosity:
console = _err_console if err else _console
if not console.is_interactive:
kwargs.setdefault("crop", False)
kwargs.setdefault("overflow", "ignore")
console.print(message, **kwargs)

@contextlib.contextmanager
def logging(self, type_: str = "install") -> Iterator[logging.Logger]:
"""A context manager that opens a file for logging when verbosity is NORMAL or
print to the stdout otherwise.
"""
file_name: str | None = None
if self.verbosity >= Verbosity.DETAIL:
handler: logging.Handler = logging.StreamHandler()
handler.setLevel(LOG_LEVELS[self.verbosity])
else:
file_name = mktemp(".log")
handler = logging.FileHandler(file_name, encoding="utf-8")
handler.setLevel(logging.DEBUG)
handler.setFormatter(logging.Formatter("%(name)s: %(message)s"))
logger.addHandler(handler)

try:
yield logger
except Exception:
if self.verbosity < Verbosity.DETAIL:
logger.exception("Error occurs")
self.echo(
f"See [warning]{file_name}[/] for detailed debug log.",
style="error",
err=True,
)
raise
# else:
# atexit.register(cleanup)
finally:
logger.removeHandler(handler)
handler.close()

def open_spinner(self, title: str) -> Spinner:
"""Open a spinner as a context manager."""
if self.verbosity >= Verbosity.DETAIL or not is_interactive():
return DummySpinner(title)
else:
return _err_console.status(title, spinner=SPINNER, spinner_style="primary") # type: ignore

def make_progress(self, *columns: str | ProgressColumn, **kwargs: Any) -> Progress:
"""create a progress instance for indented spinners"""
return Progress(
*columns,
console=_console,
disable=self.verbosity >= Verbosity.DETAIL,
**kwargs,
)

def info(self, message: str, verbosity: Verbosity = Verbosity.QUIET) -> None:
"""Print a message to stdout."""
self.echo(f"[info]INFO:[/] [dim]{message}[/]", err=True, verbosity=verbosity)

def warn(self, message: str, verbosity: Verbosity = Verbosity.QUIET) -> None:
"""Print a message to stdout."""
self.echo(f"[warning]WARNING:[/] {message}", err=True, verbosity=verbosity)

def error(self, message: str, verbosity: Verbosity = Verbosity.QUIET) -> None:
"""Print a message to stdout."""
self.echo(f"[error]WARNING:[/] {message}", err=True, verbosity=verbosity)
36 changes: 36 additions & 0 deletions src/python_template_repo/cli/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import enum
import logging

from rich.console import Console
from rich.theme import Theme

logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
logger.addHandler(logging.NullHandler())


DEFAULT_THEME: dict[str, str] = {
"primary": "cyan",
"success": "green",
"warning": "yellow",
"error": "red",
"info": "blue",
"req": "bold green",
}

_console = Console(highlight=False, theme=Theme(DEFAULT_THEME))
_err_console = Console(stderr=True, theme=Theme(DEFAULT_THEME))


class Verbosity(enum.IntEnum):
QUIET = -1
NORMAL = 0
DETAIL = 1
DEBUG = 2


LOG_LEVELS = {
Verbosity.NORMAL: logging.WARN,
Verbosity.DETAIL: logging.INFO,
Verbosity.DEBUG: logging.DEBUG,
}
34 changes: 34 additions & 0 deletions src/python_template_repo/cli/spinner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from typing import Any

from .._types import SpinnerT
from .config import _err_console

SPINNER = "dots"


class DummySpinner:
"""A dummy spinner class implementing needed interfaces.
But only display text onto screen.
"""

def __init__(self, text: str) -> None:
self.text = text

def _show(self) -> None:
_err_console.print(f"[primary]STATUS:[/] {self.text}")

def update(self, text: str) -> None:
self.text = text
self._show()

def __enter__(self: SpinnerT) -> SpinnerT:
self._show() # type: ignore[attr-defined]
return self

def __exit__(self, *args: Any) -> None:
pass


class SilentSpinner(DummySpinner):
def _show(self) -> None:
pass
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def set_logger_level(*, logger: BoundLogger, level: str) -> None:
logger.info("Log level changed", new_level=clean_level, new_level_numeric=numeric_level)


def _configure_logger_handlers() -> logging.StreamHandler: # pragma: no cover
def _configure_logger_handlers() -> logging.StreamHandler: # type: ignore
"""Internal helper to add handlers."""
logger_handler = logging.StreamHandler(sys.stdout)
return logger_handler
Expand Down
File renamed without changes.
File renamed without changes.
7 changes: 7 additions & 0 deletions tests/cli/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from python_template_repo.cli import CLI


def test_app():
a = CLI()
a.echo("hello world")
assert True
7 changes: 7 additions & 0 deletions tests/test_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from __future__ import annotations


def test_version() -> None:
from python_template_repo import __version__

assert __version__
Empty file removed tests/unit/__init__.py
Empty file.
Empty file removed tests/unit/app/__init__.py
Empty file.
Loading

0 comments on commit cfbac50

Please sign in to comment.