diff --git a/CHANGELOG.md b/docs/CHANGELOG.md similarity index 86% rename from CHANGELOG.md rename to docs/CHANGELOG.md index c827dfb..f6ce685 100644 --- a/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## [0.0.5] - 2023-12-09 + +### Changed + +- Use Click for command-line parsing + +### Added + +- `repo-man add` can now take multiple `--type` values to add a repo to many flavors at once + ## [0.0.4] - 2023-11-14 ### Changed diff --git a/docs/architecture/decisions/0005-use-click-for-cli-parsing.md b/docs/architecture/decisions/0005-use-click-for-cli-parsing.md new file mode 100644 index 0000000..78171ce --- /dev/null +++ b/docs/architecture/decisions/0005-use-click-for-cli-parsing.md @@ -0,0 +1,32 @@ +# 5. Use Click for CLI parsing + +Date: 2023-12-09 + +## Status + +Accepted + +## Context + +Parsing command-line arguments is a challenging problem. +A tool of sufficient complexity may need to expose its behavior through arguments, options, and subcommands. +It may also need to perform common validations such as the existence of files and a controlled vocabulary of option values. +Doing this with `argparse` works up to a point, but becomes very difficult to reason about very quickly. + +An ideal outcome is that each command can be reasoned about on its own, written in a compact form that just about fits on a screen. +A contributor should be able to see the name of the command, whether it's a subcommand, what options and arguments it takes, without losing the context. +Common validations are abstracted such that they can be supplied in short forms with minimal duplication. + +A solution will provide a testable way of building a CLI so that the behavior of the tool can be verified. + +## Decision + +Use the Click package for command-line parsing. + +## Consequences + +- Cognitive load drops significantly to increase confidence in adding new features +- Subcommands can be generated quickly using `@click.group` +- Validations can be generated quickly using `click.Choice`, `required=True`, and so on +- Arguments and options can be generated quickly using `@click.argument` and `@click.option` +- `CliRunner` can be used for testing diff --git a/docs/index.rst b/docs/index.rst index 8aa8b5e..375e101 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,6 +9,7 @@ Manage repositories of different flavors. :hidden: reference/modules + CHANGELOG .. toctree:: :glob: diff --git a/setup.cfg b/setup.cfg index da87641..01366d1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = repo-man -version = 0.0.4 +version = 0.0.5 description = Manage repositories of different flavors. long_description = file: README.md long_description_content_type = text/markdown @@ -29,6 +29,8 @@ package_dir = =src packages = find_namespace: include_package_data = True +install_requires = + click>=8.1.7 [options.packages.find] where = src @@ -97,7 +99,7 @@ deps = pytest types-termcolor commands = - mypy --ignore-missing-imports {posargs:src test} + mypy {posargs:src test} [testenv:format] skip_install = True diff --git a/src/repo_man/cli.py b/src/repo_man/cli.py index 3cc6529..1a1bd4e 100644 --- a/src/repo_man/cli.py +++ b/src/repo_man/cli.py @@ -1,9 +1,10 @@ -import argparse import configparser import sys from pathlib import Path from typing import NoReturn, Union +import click + FAIL = "\033[91m" ENDC = "\033[0m" @@ -18,81 +19,7 @@ def check_if_allowed(path: Path) -> Union[bool, NoReturn]: return True -def configure_arguments(parser: argparse.ArgumentParser, repo_types: dict[str, set[str]]) -> None: - subparsers = parser.add_subparsers(description="Subcommands for managing repositories", dest="subcommand") - - # List repos - list_parser = subparsers.add_parser("list", help="List matching repositories") - - list_parser.add_argument( - "-t", - "--type", - required=True, - choices=sorted(set(repo_types.keys()) - {"ignore", "all"}), - metavar="TYPE", - help="The type of repository to manage", - ) - - # Add a new repo - add_parser = subparsers.add_parser("add", help="Add a new repository") - - add_parser.add_argument( - "repository", - choices=[str(directory) for directory in Path(".").iterdir() if directory.is_dir()], - metavar="REPOSITORY", - help="The name of the repository", - ) - - add_parser.add_argument( - "-t", - "--type", - required=True, - help="The type of the repository", - ) - - # Check a repo - flavor_parser = subparsers.add_parser("flavors", help="List the configured types for a repository") - - flavor_parser.add_argument( - "repository", - choices=[str(directory) for directory in Path(".").iterdir() if directory.is_dir()], - metavar="REPOSITORY", - help="The name of the repository", - ) - - # Inspect repos - parser.add_argument( - "-k", - "--known", - action="store_true", - help="List known repository types", - ) - - parser.add_argument( - "-d", - "--duplicates", - action="store_true", - help="List repositories without a configured type", - ) - - parser.add_argument( - "-u", - "--unconfigured", - action="store_true", - help="List repositories without a configured type", - ) - - parser.add_argument( - "-m", - "--missing", - action="store_true", - help="List configured repositories that aren't cloned", - ) - - def parse_repo_types(config: configparser.ConfigParser) -> dict[str, set[str]]: - config.read(REPO_TYPES_CFG) - repo_types: dict[str, set[str]] = {"all": set()} for section in config.sections(): repos = {repo for repo in config[section]["known"].split("\n") if repo} @@ -120,99 +47,118 @@ def check_missing_repos(path: Path, repo_types: dict[str, set[str]]) -> None: return None -def handle_list( - path: Path, config: configparser.ConfigParser, args, repo_types: dict[str, set[str]] -) -> Union[None, NoReturn]: - if args.type not in repo_types: - print(f"\n{FAIL}Unknown type {args.type}. Valid types are:{ENDC}") - for repo_type in repo_types: - if repo_type != "all" and repo_type != "ignore": - print(f"\t{repo_type}") - sys.exit(1) - - for repo in repo_types[args.type]: - print(repo) - - return None - - -def handle_add(path: Path, config: configparser.ConfigParser, args, repo_types: dict[str, set[str]]) -> None: - if args.type in config: - original_config = config[args.type]["known"] - else: - original_config = "" - config.add_section(args.type) +def get_valid_repo_types(): + config = configparser.ConfigParser() + config.read(REPO_TYPES_CFG) + valid_repo_types = parse_repo_types(config) + return sorted(set(valid_repo_types.keys())) - if "known" not in config[args.type] or args.repository not in config[args.type]["known"].split("\n"): - config.set(args.type, "known", f"{original_config}\n{args.repository}") - with open(REPO_TYPES_CFG, "w") as config_file: - config.write(config_file) +def main(): + path = Path(".") + check_if_allowed(path) - return None + config = configparser.ConfigParser() + config.read(REPO_TYPES_CFG) + valid_repo_types = parse_repo_types(config) + check_missing_repos(path, valid_repo_types) + cli() -def handle_flavors(path: Path, config: configparser.ConfigParser, args, repo_types: dict[str, set[str]]) -> None: - found = set() - for section in config.sections(): - if section == "ignore": - continue - if args.repository in config[section]["known"].split("\n"): - found.add(section) - for repository in sorted(found): - print(repository) - return None +@click.group(invoke_without_command=True, context_settings={"help_option_names": ["-h", "--help"]}) +@click.version_option(package_name="repo-man") +@click.option("-k", "--known", is_flag=True, help="List known repository types") +@click.option("-u", "--unconfigured", is_flag=True, help="List repositories without a configured type") +@click.option("-d", "--duplicates", is_flag=True, help="List repositories with more than one configured type") +# @click.option("-v", "--verbose", "verbosity", count=True) +def cli(known: bool, unconfigured: bool, duplicates: bool): + """Manage repositories of different types""" + path = Path(".") + config = configparser.ConfigParser() + config.read(REPO_TYPES_CFG) + valid_repo_types = parse_repo_types(config) -def handle_meta(path: Path, config: configparser.ConfigParser, args, repo_types: dict[str, set[str]]) -> None: - if args.known: + if known: known_repo_types = sorted( - [repo_type for repo_type in repo_types if repo_type != "all" and repo_type != "ignore"] + [repo_type for repo_type in valid_repo_types if repo_type != "all" and repo_type != "ignore"] ) for repo_type in known_repo_types: print(repo_type) - if args.unconfigured: + if unconfigured: for directory in sorted(path.iterdir()): if ( directory.is_dir() - and str(directory) not in repo_types["all"] - and str(directory) not in repo_types.get("ignore", []) + and str(directory) not in valid_repo_types["all"] + and str(directory) not in valid_repo_types.get("ignore", []) ): print(directory) - if args.duplicates: + if duplicates: seen = set() - for repo_type in repo_types: + for repo_type in valid_repo_types: if repo_type != "all" and repo_type != "ignore": - for repo in repo_types[repo_type]: + for repo in valid_repo_types[repo_type]: if repo in seen: print(repo) seen.add(repo) -def main(): - path = Path(".") +@cli.command(name="list", help="The type of repository to manage") +@click.option("-t", "--type", "repo_type", type=click.Choice(get_valid_repo_types()), show_choices=False, required=True) +def list_repos(repo_type: str): + """List matching repositories""" + + config = configparser.ConfigParser() + config.read(REPO_TYPES_CFG) + valid_repo_types = parse_repo_types(config) + + for repo in valid_repo_types[repo_type]: + print(repo) + + return None + + +@cli.command +@click.argument("repo", type=click.Path(exists=True, file_okay=False)) +def flavors(repo: str): + """List the configured types for a repository""" + + config = configparser.ConfigParser() + config.read(REPO_TYPES_CFG) + + found = set() + + for section in config.sections(): + if section == "ignore": + continue + if repo in config[section]["known"].split("\n"): + found.add(section) + + for repository in sorted(found): + print(repository) - check_if_allowed(path) - parser = argparse.ArgumentParser( - prog="repo-man", - description="Manage repositories of different types", - ) +@cli.command +@click.option("-t", "--type", "repo_types", multiple=True, help="The type of the repository", required=True) +@click.argument("repo", type=click.Path(exists=True, file_okay=False)) +def add(repo: str, repo_types: list): + """Add a new repository""" config = configparser.ConfigParser() - repo_types = parse_repo_types(config) - configure_arguments(parser, repo_types) - args = parser.parse_args() - check_missing_repos(path, repo_types) - - if args.subcommand == "list": - handle_list(path, config, args, repo_types) - elif args.subcommand == "add": - handle_add(path, config, args, repo_types) - elif args.subcommand == "flavors": - handle_flavors(path, config, args, repo_types) - else: - handle_meta(path, config, args, repo_types) + config.read(REPO_TYPES_CFG) + + for repo_type in repo_types: + if repo_type in config: + original_config = config[repo_type]["known"] + else: + original_config = "" + config.add_section(repo_type) + + if "known" not in config[repo_type] or repo not in config[repo_type]["known"].split("\n"): + config.set(repo_type, "known", f"{original_config}\n{repo}") + + with open(REPO_TYPES_CFG, "w") as config_file: + config.write(config_file)