From 3585e4469546d1936c62247df95359c90d3141df Mon Sep 17 00:00:00 2001 From: esynr3z Date: Mon, 14 Oct 2024 23:31:12 +0300 Subject: [PATCH] refactor: rework entry point of the application and add logger (#79) --- corsair/__main__.py | 218 ++------------------------------------------ corsair/app.py | 66 ++++++++++++++ corsair/cli.py | 101 ++++++++++++++++++++ corsair/log.py | 81 ++++++++++++++++ corsair/project.py | 13 +++ corsair/utils.py | 185 ++++--------------------------------- pyproject.toml | 11 ++- 7 files changed, 296 insertions(+), 379 deletions(-) create mode 100644 corsair/app.py create mode 100644 corsair/cli.py create mode 100644 corsair/log.py create mode 100644 corsair/project.py diff --git a/corsair/__main__.py b/corsair/__main__.py index 3ab6c45..7dcd7f4 100755 --- a/corsair/__main__.py +++ b/corsair/__main__.py @@ -1,208 +1,10 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -"""Run Corsair from command line with arguments. -""" - -import sys -import os -import argparse -from pathlib import Path -import corsair -from . import utils -from contextlib import contextmanager -import importlib.util - -__all__ = ['main'] - - -@contextmanager -def cwd(path): - oldpwd = os.getcwd() - os.chdir(path) - try: - yield - finally: - os.chdir(oldpwd) - - -class ArgumentParser(argparse.ArgumentParser): - """Inherit ArgumentParser to override the behaviour of error method.""" - - def error(self, message): - self.print_help(sys.stderr) - self.exit(2, '\n%s: error: %s\n' % (self.prog, message)) - - -def parse_arguments(): - """Parse and validate arguments.""" - parser = ArgumentParser(prog=corsair.__title__, - description=corsair.__description__) - parser.add_argument('-v', '--version', - action='version', - version='%(prog)s v' + corsair.__version__) - parser.add_argument(metavar='WORKDIR', - nargs='?', - dest='workdir_path', - default=os.getcwd(), - help='working directory (default is the current directory)') - parser.add_argument('-r', - metavar='REGMAP', - dest='regmap_path', - help='read register map from file') - parser.add_argument('-c', - metavar='CONFIG', - dest='config_path', - help='read configuration from file') - template_choices = ['json', 'yaml', 'txt'] - parser.add_argument('-t', - metavar='FORMAT', - choices=template_choices, - dest='template_format', - help='create templates (choose from %s)' % ', '.join(template_choices)) - return parser.parse_args() - - -def generate_templates(format): - print("... templates format: '%s'" % format) - # global configuration - globcfg = corsair.config.default_globcfg() - globcfg['data_width'] = 32 - globcfg['address_width'] = 16 - globcfg['register_reset'] = 'sync_pos' - corsair.config.set_globcfg(globcfg) - - # targets - targets = {} - targets.update(corsair.generators.Verilog(path="hw/regs.v").make_target('v_module')) - targets.update(corsair.generators.Vhdl(path="hw/regs.vhd").make_target('vhdl_module')) - targets.update(corsair.generators.VerilogHeader(path="hw/regs.vh").make_target('v_header')) - targets.update(corsair.generators.SystemVerilogPackage(path="hw/regs_pkg.sv").make_target('sv_pkg')) - targets.update(corsair.generators.Python(path="sw/regs.py").make_target('py')) - targets.update(corsair.generators.CHeader(path="sw/regs.h").make_target('c_header')) - targets.update(corsair.generators.Markdown(path="doc/regs.md", image_dir="md_img").make_target('md_doc')) - targets.update(corsair.generators.Asciidoc(path="doc/regs.adoc", image_dir="adoc_img").make_target('asciidoc_doc')) - - # create templates - if format == 'txt': - rmap = corsair.utils.create_template_simple() - else: - rmap = corsair.utils.create_template() - # register map template - if format == 'json': - gen = corsair.generators.Json(rmap) - regmap_path = 'regs.json' - elif format == 'yaml': - gen = corsair.generators.Yaml(rmap) - regmap_path = 'regs.yaml' - elif format == 'txt': - gen = corsair.generators.Txt(rmap) - regmap_path = 'regs.txt' - print("... generate register map file '%s'" % regmap_path) - gen.generate() - # configuration file template - config_path = 'csrconfig' - globcfg['regmap_path'] = regmap_path - print("... generate configuration file '%s'" % config_path) - corsair.config.write_csrconfig(config_path, globcfg, targets) - - -def die(msg): - print("Error: %s" % msg) - exit(1) - - -def finish(): - print('Success!') - exit(0) - - -def app(args): - print("... set working directory '%s'" % args.workdir_path) - - # check if teplates are needed - if args.template_format: - generate_templates(args.template_format) - finish() - - # check if configuration file path was provided - if args.config_path: - config_path = Path(args.config_path) - else: - config_path = Path('csrconfig') - # check it existance - if not config_path.is_file(): - die("Can't find configuration file '%s'!" % config_path) - # try to read it - print("... read configuration file '%s'" % config_path) - globcfg, targets = corsair.config.read_csrconfig(config_path) - - # check if regiter map file path was provided - if args.regmap_path: - regmap_path = Path(args.regmap_path) - globcfg['regmap_path'] = regmap_path - elif 'regmap_path' in globcfg.keys(): - regmap_path = Path(globcfg['regmap_path']) - else: - regmap_path = None - print("Warning: No register map file was specified!") - corsair.config.set_globcfg(globcfg) - - if regmap_path: - # check it existance - if not regmap_path.is_file(): - die("Can't find register map file '%s'!" % regmap_path) - # try to read it - print("... read register map file '%s'" % regmap_path) - rmap = corsair.RegisterMap() - rmap.read_file(regmap_path) - print("... validate register map") - rmap.validate() - else: - rmap = None - - # make targets - if not targets: - die("No targets were specified! Nothing to do!") - for t in targets: - print("... make '%s': " % t, end='') - if 'generator' not in targets[t].keys(): - die("No generator was specified for the target!") - - if '.py::' in targets[t]['generator']: - custom_module_path, custom_generator_name = targets[t]['generator'].split('::') - custom_module_name = utils.get_file_name(custom_module_path) - spec = importlib.util.spec_from_file_location(custom_module_name, custom_module_path) - custom_module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(custom_module) - try: - gen_obj = getattr(custom_module, custom_generator_name) - gen_name = custom_generator_name - except AttributeError: - die("Generator '%s' from module '%s' does not exist!" % (custom_generator_name, custom_module_path)) - else: - gen_name = targets[t]['generator'] - try: - gen_obj = getattr(corsair.generators, gen_name) - except AttributeError: - die("Generator '%s' does not exist!" % gen_name) - - gen_args = targets[t] - print("%s -> '%s': " % (gen_name, gen_args['path'])) - gen_obj(rmap, **gen_args).generate() - - -def main(): - """Program main""" - # parse arguments - args = parse_arguments() - - # do all the things inside working directory - args.workdir_path = str(Path(args.workdir_path).absolute()) - with cwd(args.workdir_path): - app(args) - finish() - - -if __name__ == '__main__': - main() +#!/usr/bin/env python3 + +"""With this Corsair can be run as a module.""" + +from __future__ import annotations + +from . import app + +if __name__ == "__main__": + app.main() diff --git a/corsair/app.py b/corsair/app.py new file mode 100644 index 0000000..f8d41b5 --- /dev/null +++ b/corsair/app.py @@ -0,0 +1,66 @@ +"""Main code of the Corsair application.""" + +from __future__ import annotations + +import sys + +from . import log, utils, version +from .cli import Args, parse_args + +logger = log.get_logger() + +Dummy = int + + +def main() -> None: + """Entry point of the application.""" + try: + app(parse_args()) + except Exception: + logger.exception("Application finished with error. See the exception description below.") + sys.exit(1) + + +def app(args: Args) -> None: + """Application body.""" + log.set_debug(args.debug) + log.set_color(not args.no_color) + + logger.debug("corsair v%s", version.__version__) + logger.debug("args=%s", args) + + with utils.chdir(args.workdir): + logger.info("Working directory is '%s'", args.workdir) + if args.init_project: + create_project(args) + else: + globcfg, targets = read_config(args) + regmap = read_regmap(args, globcfg) + make_targets(args, globcfg, targets, regmap) + + +def create_project(args: Args) -> None: + """Create a simple project, which can be used as a template for user.""" + logger.info("Start creating simple %s project ...", args.init_project) + + +def read_config(args: Args) -> tuple[Dummy, list[Dummy]]: + """Read configuration file.""" + logger.debug("args=%s", args) # TODO: remove when argument is used below + logger.info("Start reading configuration file ...") + raise NotImplementedError("read_config() is not implemented!") + + +def read_regmap(args: Args, globcfg: Dummy) -> Dummy | None: + """Read register map.""" + logger.debug("args=%s globcfg=%s", args, globcfg) # TODO: remove when arguments are used below + logger.info("Start reading register map ...") + raise NotImplementedError("read_regmap() is not implemented!") + + +def make_targets(args: Args, globcfg: Dummy, targets: list[Dummy], regmap: Dummy | None) -> None: + """Make required targets.""" + # TODO: remove when arguments are used below + logger.debug("args=%s globcfg=%s targets=%s regmap=%s", args, globcfg, targets, regmap) + logger.info("Start generation ...") + raise NotImplementedError("make_targets() is not implemented!") diff --git a/corsair/cli.py b/corsair/cli.py new file mode 100644 index 0000000..46a119f --- /dev/null +++ b/corsair/cli.py @@ -0,0 +1,101 @@ +"""Command-line interface of Corsair.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path + +from . import utils +from .project import ProjectKind +from .version import __version__ + + +@dataclass +class Args: + """Application CLI arguments.""" + + workdir: Path + regmap: Path | None + config: Path | None + targets: list[str] + init_project: ProjectKind | None + debug: bool + no_color: bool + + +class ArgumentParser(argparse.ArgumentParser): + """Corsair CLI argument parser.""" + + def __init__(self) -> None: + """Parser constructor.""" + super().__init__( + prog="corsair", + description="Control and status register (CSR) map generator for HDL projects.", + ) + + self.add_argument("-v", "--version", action="version", version=f"%(prog)s v{__version__}") + self.add_argument( + "--debug", + action="store_true", + dest="debug", + help="increase logging verbosity level", + ) + self.add_argument( + "--no-color", + action="store_true", + dest="no_color", + help="disable use of colors for logging", + ) + self.add_argument( + metavar="WORKDIR", + nargs="?", + dest="workdir", + type=Path, + default=Path(), + help="working directory (default is the current directory)", + ) + self.add_argument( + "-r", + metavar="PATH", + dest="regmap", + type=Path, + help="register map file", + ) + self.add_argument( + "-c", + "--cfg", + metavar="PATH", + type=Path, + dest="config", + help="configuration file", + ) + self.add_argument( + "-t", + "--target", + nargs="*", + metavar="NAME", + type=str, + dest="targets", + default=[], + help="make ony selected target(s) from configuration file", + ) + self.add_argument( + "-i", + "--init", + metavar="KIND", + type=ProjectKind, + choices=[k.value for k in ProjectKind], + dest="init_project", + help="initialize simple project from template and exit", + ) + + +def parse_args() -> Args: + """Parse CLI arguments.""" + parser = ArgumentParser() + + args = parser.parse_args() + args.workdir = utils.resolve_path(args.workdir) + + return Args(**vars(args)) diff --git a/corsair/log.py b/corsair/log.py new file mode 100644 index 0000000..c14e48c --- /dev/null +++ b/corsair/log.py @@ -0,0 +1,81 @@ +"""Application level logger.""" + +from __future__ import annotations + +import logging +import sys +from typing import Any + + +class _ColorFormatter(logging.Formatter): + """Record formatter with color support.""" + + def __init__(self, *args: tuple[Any], **kwargs: dict[str, Any]) -> None: + self.use_colors = False + super().__init__(*args, **kwargs) + + self._reset_code = "\x1b[0m" + self._color_codes = { + logging.DEBUG: "\x1b[32m", # Green + logging.INFO: "\x1b[34m", # Cyan + logging.WARNING: "\x1b[33m", # Yellow + logging.ERROR: "\x1b[31m", # Red + logging.CRITICAL: "\x1b[41m", # Red background + } + + def format(self, record: logging.LogRecord) -> str: + levelname_color = record.levelname + msg_color = record.msg + + if self.use_colors: + color = self._color_codes.get(record.levelno, self._reset_code) + if record.levelno == logging.INFO: + msg_color = f"{self._reset_code}{msg_color}" + else: + msg_color = f"{msg_color}{self._reset_code}" + levelname_color = f"{color}{levelname_color}" + + record.levelname = levelname_color + record.msg = msg_color + return super().format(record) + + +def _init_logger(logger: logging.Logger) -> None: + """Logger init.""" + logger.handlers.clear() + logger.setLevel(logging.DEBUG) + + console_handler = logging.StreamHandler(sys.stderr) + console_handler.setLevel(logging.DEBUG) + console_handler.setFormatter(_ColorFormatter("%(levelname)s: %(message)s")) + logger.addHandler(console_handler) + + +def set_debug(enable: bool) -> None: + """Set DEBUG verbosity level for the logger.""" + logger = get_logger() + + if enable: + logger.setLevel(logging.DEBUG) + else: + logger.setLevel(logging.INFO) + + +def set_color(enable: bool) -> None: + """Set color mode for the logger.""" + logger = get_logger() + + for handler in logger.handlers: + if isinstance(handler.formatter, _ColorFormatter): + handler.formatter.use_colors = enable + + +def get_logger(name: str = "corsair") -> logging.Logger: + """Get the package logger.""" + logger = logging.getLogger(name) + + # Create console handler with custom formatting for the first time + if not logger.hasHandlers(): + _init_logger(logger) + + return logger diff --git a/corsair/project.py b/corsair/project.py new file mode 100644 index 0000000..ab787e1 --- /dev/null +++ b/corsair/project.py @@ -0,0 +1,13 @@ +"""Boilerpalte to create a simple example project for a user.""" + +from __future__ import annotations + +from enum import Enum + + +class ProjectKind(str, Enum): + """Project kind to generate.""" + + JSON = "json" + YAML = "yaml" + TXT = "txt" diff --git a/corsair/utils.py b/corsair/utils.py index 9577fd1..4b77679 100755 --- a/corsair/utils.py +++ b/corsair/utils.py @@ -1,179 +1,26 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""Utility functions and classes -""" +"""Utility functions and classes.""" + +from __future__ import annotations -from . import generators import os -from . import config -from .reg import Register -from .enum import EnumValue -from .bitfield import BitField -from .regmap import RegisterMap +from contextlib import contextmanager from pathlib import Path +from typing import Iterator -def str2int(val, base=None): - """String to integer conversion""" +@contextmanager +def chdir(path: Path) -> Iterator[None]: + """Temporarily change directory for the context.""" + oldpwd = Path.cwd() + os.chdir(path) try: - if isinstance(val, int) or val is None: - return val - elif base: - return int(val, base) - elif '0x' in val: - return int(val, 16) - elif '0b' in val: - return int(val, 2) - else: - return int(val) - except (ValueError, TypeError) as e: - raise ValueError("Can't convert '%s' to int!" % val) - - -def str2bool(val): - """String to integer conversion""" - try: - if isinstance(val, bool): - return val - elif not isinstance(val, str): - return bool(val) - elif val.lower() in ["true", "t", "yes", "y"]: - return True - elif val.lower() in ["false", "f", "no", "n"]: - return False - else: - raise ValueError - except (ValueError, AttributeError, TypeError) as e: - raise ValueError("Can't convert '%s' to bool!" % val) - - -def int2str(val, max_dec=1024): - """Make string from int. Hexademical representaion will be used if input value greater that 'max_dec'.""" - if val > max_dec: - return "0x%x" % val - else: - return "%d" % val - - -def is_non_neg_int(val): - """Check if value is non negative integer""" - return isinstance(val, int) and val >= 0 - - -def is_pos_int(val): - """Check if value is positive integer""" - return isinstance(val, int) and val > 0 - - -def is_str(val): - """Check if value is string""" - return isinstance(val, str) - - -def is_list(val): - """Check if value is list""" - return isinstance(val, list) - - -def is_first_letter(val): - """Check if string starts from a letter""" - return ord(val[0].lower()) in range(ord('a'), ord('z') + 1) - - -def listify(obj): - """Make lists from single objects. No changes are made for the argument of the 'list' type.""" - if is_list(obj): - return obj - else: - return [obj] - - -def get_file_ext(path): - _, ext = os.path.splitext(path) - return ext.lower() - - -def get_file_name(path): - return Path(path).stem - - -def create_dirs(path): - dirs = Path(path).parent - Path(dirs).mkdir(parents=True, exist_ok=True) - - -def force_name_case(name): - if config.globcfg["force_name_case"] == "upper": - return name.upper() - elif config.globcfg["force_name_case"] == "lower": - return name.lower() - else: - return name - - -def create_template_simple(): - """Generate simple register map template""" - rmap = RegisterMap() - - rmap.add_registers(Register('DATA', 'Data register', 0x0).add_bitfields( - BitField(width=32, access='rw', hardware='ioe'))) - - rmap.add_registers(Register('CTRL', 'Control register', 0x4).add_bitfields( - BitField(width=16, access='rw', reset=0x0100, hardware='o'))) - - rmap.add_registers(Register('STATUS', 'Status register', 0x8).add_bitfields( - BitField(width=8, access='ro', hardware='i'))) - - rmap.add_registers(Register('START', 'Start register', 0x100).add_bitfields( - BitField(width=1, access='wosc', hardware='o'))) - - return rmap - - -def create_template(): - """Generate register map template""" - # register map - rmap = RegisterMap() - - rmap.add_registers(Register('DATA', 'Data register', 0x4).add_bitfields([ - BitField("FIFO", "Write to push value to TX FIFO, read to get data from RX FIFO", - width=8, lsb=0, access='rw', hardware='q'), - BitField("FERR", "Frame error flag. Read to clear.", width=1, lsb=16, access='rolh', hardware='i'), - BitField("PERR", "Parity error flag. Read to clear.", width=1, lsb=17, access='rolh', hardware='i'), - ])) - - rmap.add_registers(Register('STAT', 'Status register', 0xC).add_bitfields([ - BitField("BUSY", "Transciever is busy", width=1, lsb=2, access='ro', hardware='ie'), - BitField("RXE", "RX FIFO is empty", width=1, lsb=4, access='ro', hardware='i'), - BitField("TXF", "TX FIFO is full", width=1, lsb=8, access='ro', hardware='i'), - ])) - - rmap.add_registers(Register('CTRL', 'Control register', 0x10).add_bitfields([ - BitField("BAUD", "Baudrate value", width=2, lsb=0, access='rw', hardware='o').add_enums([ - EnumValue("B9600", 0, "9600 baud"), - EnumValue("B38400", 1, "38400 baud"), - EnumValue("B115200", 2, "115200 baud"), - ]), - BitField("TXEN", "Transmitter enable. Can be disabled by hardware on error.", - width=1, lsb=4, access='rw', hardware='oie'), - BitField("RXEN", "Receiver enable. Can be disabled by hardware on error.", - width=1, lsb=5, access='rw', hardware='oie'), - BitField("TXST", "Force transmission start", width=1, lsb=6, access='wosc', hardware='o'), - ])) - - rmap.add_registers(Register('LPMODE', 'Low power mode control', 0x14).add_bitfields([ - BitField("DIV", "Clock divider in low power mode", width=8, lsb=0, access='rw', hardware='o'), - BitField("EN", "Low power mode enable", width=1, lsb=31, access='rw', hardware='o'), - ])) - - rmap.add_registers(Register('INTSTAT', 'Interrupt status register', 0x20).add_bitfields([ - BitField("TX", "Transmitter interrupt flag. Write 1 to clear.", width=1, lsb=0, access='rw1c', hardware='s'), - BitField("RX", "Receiver interrupt. Write 1 to clear.", width=1, lsb=1, access='rw1c', hardware='s'), - ])) + yield + finally: + os.chdir(oldpwd) - rmap.add_registers(Register('ID', 'IP-core ID register', 0x40).add_bitfields([ - BitField("UID", "Unique ID", width=32, lsb=0, access='ro', hardware='f', reset=0xcafe0666), - ])) - return rmap +def resolve_path(p: Path) -> Path: + """Get absolute path resolving any inderiction.""" + return p.expanduser().resolve(strict=False) diff --git a/pyproject.toml b/pyproject.toml index 3060ef3..510deca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ classifiers = [ ] [tool.poetry.scripts] -corsair = "corsair.__main__:main" +corsair = "corsair.app:main" [tool.poetry.dependencies] python = "^3.8" @@ -85,8 +85,15 @@ ignore = [ "COM819", # prohibited-trailing-comma "ISC001", # single-line-implicit-string-concatenation "ISC002", # multi-line-implicit-string-concatenation + "TD001", # invalid-todo-tag + "TD002", # missing-todo-author + "TD003", # missing-todo-link + "FIX", # flake8-fixme + "EM101", # raw-string-in-exception + "FBT", # flake8-boolean-trap ] + [tool.ruff.lint.isort] required-imports = ["from __future__ import annotations"] @@ -94,4 +101,4 @@ required-imports = ["from __future__ import annotations"] docstring-code-format = true [tool.pyright] -exclude = ["*"] # need to relax this as codebase is improved +exclude = ["*"] # need to relax this as codebase is improved