import argparse
import logging
import sys
from contextlib import contextmanager
from itertools import chain
from os import pathsep
from pathlib import Path
from textwrap import dedent, wrap
from typing import Dict, List, Optional, Sequence
from . import __version__
from .translator import Translator
from .types import CLIChoice, Profile, ProfileAugmentation
_logger = logging.getLogger(__package__)
DEFAULT_PROFILE = "best_effort"
[docs]
@contextmanager
def critical_logging():
"""Make sure the logging level is set even before parsing the CLI args"""
try:
yield
except Exception:
if "-vv" in sys.argv or "--very-verbose" in sys.argv:
setup_logging(logging.DEBUG)
raise
META: Dict[str, dict] = {
"version": dict(
flags=("-V", "--version"),
action="version",
version=f"{__package__} {__version__}",
),
"input_file": dict(
dest="input_file",
type=argparse.FileType("r"),
help=".cfg/.ini file to be converted",
),
"output_file": dict(
flags=("-o", "--output-file"),
default="-",
type=argparse.FileType("w"),
help="file where to write the converted TOML (`stdout` by default)",
),
"profile": dict(
flags=("-p", "--profile"),
default=None,
help=f"a translation profile name, that will instruct {__package__} how "
"to perform the most appropriate conversion. Available profiles:\n",
),
"enable": dict(
flags=("-E", "--enable"),
nargs="+",
dest="enable",
metavar="TRANSFORMATION",
help="enable one or more of the following processing options (optional):\n",
),
"disable": dict(
flags=("-D", "--disable"),
nargs="+",
dest="disable",
default=(),
metavar="TRANSFORMATION",
help="disable one or more of the following processing options "
"(active by default):\n",
),
"verbose": dict(
flags=("-v", "--verbose"),
dest="loglevel",
action="store_const",
const=logging.INFO,
help="set logging level to INFO",
),
"very_verbose": dict(
flags=("-vv", "--very-verbose"),
dest="loglevel",
action="store_const",
const=logging.DEBUG,
help="set logging level to DEBUG",
),
}
try:
from pyproject_fmt import Config, format_pyproject
META["auto_format"] = dict(
flags=("-F", "--auto-format"),
action="store_true",
help="**EXPERIMENTAL** - format output with `pyproject-fmt`\n"
"(note that auto-formatting is intrusive and may cause loss of comments).",
)
def apply_auto_formatting(text: str) -> str:
try:
return format_pyproject(Config(Path("pyproject.toml"), text))
except Exception as ex: # pragma: no cover
_logger.debug(f"pyproject-fmt failed: {ex}", exc_info=True)
_logger.warning("Auto-formatting failed, falling back to original text")
return text
except ImportError: # pragma: no cover
def __meta__(
profiles: Sequence[Profile], augmentations: Sequence[ProfileAugmentation]
) -> Dict[str, dict]:
"""'Hyper parameters' to instruct :mod:`argparse` how to create the CLI"""
meta = {k: v.copy() for k, v in META.items()}
meta["profile"]["help"] += _choices_help(profiles, lambda x: x.help_text.strip())
enable: List[ProfileAugmentation] = []
disable: List[ProfileAugmentation] = []
for aug in sorted(augmentations, key=lambda x: x.name):
target = disable if aug.active_by_default else enable
target.append(aug)
for key, value in {"enable": enable, "disable": disable}.items():
meta[key]["choices"] = [aug.name for aug in value]
meta[key]["help"] += _choices_help(value)
if not value:
meta.pop(key, None)
return meta
[docs]
def parse_args(
args: Sequence[str],
profiles: Sequence[Profile],
augmentations: Sequence[ProfileAugmentation],
) -> argparse.Namespace:
"""Parse command line parameters
Args:
args: command line parameters as list of strings (for example ``["--help"]``).
Returns: command line parameters namespace
"""
description = "Automatically converts .cfg/.ini files into TOML"
parser = argparse.ArgumentParser(description=description, formatter_class=Formatter)
for opts in __meta__(profiles, augmentations).values():
parser.add_argument(*opts.pop("flags", ()), **opts)
parser.set_defaults(loglevel=logging.WARNING)
params = parser.parse_args(args)
profile_names = [p.name for p in profiles]
profile = guess_profile(params.profile, params.input_file.name, profile_names)
params.profile = profile
opts = vars(params)
active_augmentations = {k: True for k in (opts.get("enable") or ())}
active_augmentations.update({k: False for k in (opts.get("disable") or ())})
params.active_augmentations = active_augmentations
return params
[docs]
def setup_logging(loglevel: int):
"""Setup basic logging
Args:
loglevel: minimum loglevel for emitting messages
"""
logformat = "[%(levelname)s] %(message)s"
logging.basicConfig(level=loglevel, stream=sys.stderr, format=logformat)
[docs]
@contextmanager
def exceptions2exit():
try:
yield
except Exception as ex:
_logger.error(f"{ex.__class__.__name__}: {ex}")
_logger.debug("Please check the following information:", exc_info=True)
raise SystemExit(1)
[docs]
@exceptions2exit()
def run(args: Sequence[str] = ()):
"""Wrapper allowing :obj:`Translator` to be called in a CLI fashion.
Instead of returning the value from :func:`Translator.translate`, it prints the
result to the given ``output_file`` or ``stdout``.
Args:
args (List[str]): command line parameters as list of strings
(for example ``["--verbose", "setup.cfg"]``).
"""
with critical_logging():
args = args or sys.argv[1:]
translator = Translator()
profiles = list(translator.profiles.values())
profile_augmentations = list(translator.augmentations.values())
params = parse_args(args, profiles, profile_augmentations)
setup_logging(params.loglevel)
out = translator.translate(
params.input_file.read(), params.profile, params.active_augmentations
)
if getattr(params, "auto_format", False):
out = apply_auto_formatting(out)
params.output_file.write(out)
[docs]
def guess_profile(profile: Optional[str], file_name: str, available: List[str]) -> str:
if profile:
return profile
name = Path(file_name).name
if name in available:
# Optimize for the easy case
_logger.info(f"Profile not explicitly set, {name!r} inferred.")
return name
fname = file_name.replace(pathsep, "/")
for name in available:
if fname.endswith(name):
_logger.info(f"Profile not explicitly set, {name!r} inferred.")
return name
_logger.warning(f"Profile not explicitly set, using {DEFAULT_PROFILE!r}.")
return DEFAULT_PROFILE
def _choices_help(choices: Sequence[CLIChoice], filt=lambda _: True) -> str:
"""``filt``: predicate function, only choices for which ``filt(c)`` is ``True`` will
be included in the help text.
"""
return "\n".join(_format_choice_help(c) for c in choices if filt(c))
def _flatten_str(text: str) -> str:
if not text:
return text
text = " ".join(x.strip() for x in dedent(text).splitlines()).strip()
text = text.rstrip(".,;").strip()
return (text[0].lower() + text[1:]).strip()
def _format_choice_help(choice: CLIChoice) -> str:
return f"- {choice.name!r}: {_flatten_str(choice.help_text)}."