Skip to content

Commit

Permalink
[ADD] Main program entry, logging and prompting
Browse files Browse the repository at this point in the history
Add an entry point with basic checks for odev.
Enable logging and prompting to the console with limited helper methods.

[FIX] Pre-Commit PyLint hook version

The PyLint hook was using an older version not compatible with
Python 3.11.

Ref: GrahamDumpleton/wrapt#196
  • Loading branch information
brinkflew committed Dec 16, 2022
1 parent f995120 commit c1e662a
Show file tree
Hide file tree
Showing 7 changed files with 239 additions and 1 deletion.
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ repos:
- --keep-percent-format

- repo: https://github.com/PyCQA/pylint
rev: v2.12.2
rev: v2.15.8
hooks:
- id: pylint
exclude: /static/util\.py$
Expand Down
7 changes: 7 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/usr/bin/env python3

import odev.__main__ as main


if __name__ == "__main__":
main.main()
48 changes: 48 additions & 0 deletions odev/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Entry point for the odev executable."""


import os
import signal
from types import FrameType
from typing import Optional

from odev._version import __version__
from odev.common import logging, prompt


logger = logging.getLogger(__name__)


# --- Handle signals and interrupts --------------------------------------------


def signal_handler(signal_number: int, frame: Optional[FrameType]):
prompt.clear_line(0) # Hide control characters
logger.warning(f"Received signal ({signal_number}), aborting execution")
exit(signal_number)


signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)


# --- Main entry method --------------------------------------------------------


def main():
"""Setup wizard for odev."""

logger.debug(f"Starting odev version {__version__}")

try:
logger.debug("Checking runtime permissions")
if os.geteuid() == 0:
raise Exception("Odev should not be run as root")

# TODO: Implement framework logic

except Exception:
logger.exception("Execution failed due to an unhandled exception")
exit(1)

logger.debug("Execution completed successfully")
3 changes: 3 additions & 0 deletions odev/_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Version information for odev."""

__version__ = "0.0.0"
4 changes: 4 additions & 0 deletions odev/common/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Common utilities for odev."""

from .logging import logging
from .prompt import *
92 changes: 92 additions & 0 deletions odev/common/logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Logging configuration for odev.
To be used with:
```py
from odev.logging import logging
logger = logging.getLogger(__name__)
```
"""

import logging
from logging import LogRecord
from typing import Dict, Literal, Union

from rich.console import Console
from rich.highlighter import ReprHighlighter, _combine_regex
from rich.logging import RichHandler
from rich.text import Text
from rich.theme import Theme


__all__ = ["logging"]


class OdevRichHandler(RichHandler):
"""Custom `RichHandler` to show the log level as a single character.
See `rich.logging.RichHandler`.
"""

symbols: Dict[Union[int, Literal["default"]], str] = {
logging.CRITICAL: "~",
logging.ERROR: "-",
logging.WARNING: "!",
logging.INFO: "i",
logging.DEBUG: "#",
"default": "?",
}

def get_level_text(self, record: LogRecord) -> Text:
"""Get the level name from the record.
:param LogRecord record: LogRecord instance.
:return: A tuple of the style and level name.
:rtype: Text
"""

symbol = self.symbols.get(record.levelno, self.symbols["default"])
level_text = super().get_level_text(record)
level_text.plain = f"[{symbol}]".ljust(3)
return level_text


class OdevReprHighlighter(ReprHighlighter):
"""Extension of `ReprHighlighter` to highlight odev version numbers."""

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.highlights[-1] = _combine_regex(
r"(?P<odev>odev)",
r"(?P<version>([0-9]+\.)+[0-9]+)",
self.highlights[-1],
)


console = Console(
highlighter=OdevReprHighlighter(),
theme=Theme(
{
"repr.odev": "bold cyan",
"repr.version": "bold cyan",
"logging.level.critical": "bold red",
"logging.level.error": "bold red",
"logging.level.warning": "bold yellow",
"logging.level.info": "bold cyan",
"logging.level.debug": "bold bright_black",
}
),
)

logging.basicConfig(
level="NOTSET",
format="%(message)s",
handlers=[
OdevRichHandler(
rich_tracebacks=True,
tracebacks_show_locals=True,
show_time=False,
console=console,
markup=True,
)
],
)
84 changes: 84 additions & 0 deletions odev/common/prompt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Prompt for user input."""

from pathlib import Path
from typing import Optional

from InquirerPy import get_style, inquirer
from InquirerPy.validator import PathValidator
from prompt_toolkit.validation import ValidationError


__all__ = ["directory", "clear_line"]


MARK = "[?]"
STYLE = get_style(
style_override=False,
style={
"questionmark": "fg:#af5faf bold",
"answermark": "fg:#af5faf bold",
"answer": "#af5faf",
"input": "#00afaf",
"validator": "fg:red bg: bold",
},
)


class PurportedPathValidator(PathValidator):
"""Path validator that doesn't check if the path exists."""

def validate(self, document) -> None:
"""Check if user input is a valid path."""

path = Path(document.text).expanduser()

if self._is_file and path.is_dir():
raise ValidationError(
message=self._message,
cursor_position=document.cursor_position,
)

if self._is_dir and path.is_file():
raise ValidationError(
message=self._message,
cursor_position=document.cursor_position,
)


def clear_line(count: int = 1) -> None:
"""Clear up a number of lines from the terminal.
If a count is provided, the same amount of lines will be cleared.
:param int count: Number of lines to clear
"""

print("\x1b[F".join(["\x1b[2K\x1b[0G" for _ in range(count + 1)]), end="")


def directory(message: str, default: str = None) -> Optional[str]:
"""Prompt for a directory path.
:param message: Question to ask the user
:param default: Set the default text value of the prompt
:return: Path to the directory to use
:rtype: str or None
"""

try:
path = inquirer.filepath(
message=message,
default=default,
only_directories=True,
validate=PurportedPathValidator(
is_dir=True,
message="Path must be a directory",
),
raise_keyboard_interrupt=True,
style=STYLE,
amark=MARK,
qmark=MARK,
).execute()
except KeyboardInterrupt:
return None

return path

0 comments on commit c1e662a

Please sign in to comment.