Skip to content

Commit 1ae17a6

Browse files
authored
Use Click for CLI parsing (#14)
* Use Click for CLI parsing * Update toctree
1 parent 93d1c96 commit 1ae17a6

File tree

5 files changed

+134
-143
lines changed

5 files changed

+134
-143
lines changed

CHANGELOG.md renamed to docs/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
## [0.0.5] - 2023-12-09
11+
12+
### Changed
13+
14+
- Use Click for command-line parsing
15+
16+
### Added
17+
18+
- `repo-man add` can now take multiple `--type` values to add a repo to many flavors at once
19+
1020
## [0.0.4] - 2023-11-14
1121

1222
### Changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# 5. Use Click for CLI parsing
2+
3+
Date: 2023-12-09
4+
5+
## Status
6+
7+
Accepted
8+
9+
## Context
10+
11+
Parsing command-line arguments is a challenging problem.
12+
A tool of sufficient complexity may need to expose its behavior through arguments, options, and subcommands.
13+
It may also need to perform common validations such as the existence of files and a controlled vocabulary of option values.
14+
Doing this with `argparse` works up to a point, but becomes very difficult to reason about very quickly.
15+
16+
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.
17+
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.
18+
Common validations are abstracted such that they can be supplied in short forms with minimal duplication.
19+
20+
A solution will provide a testable way of building a CLI so that the behavior of the tool can be verified.
21+
22+
## Decision
23+
24+
Use the Click package for command-line parsing.
25+
26+
## Consequences
27+
28+
- Cognitive load drops significantly to increase confidence in adding new features
29+
- Subcommands can be generated quickly using `@click.group`
30+
- Validations can be generated quickly using `click.Choice`, `required=True`, and so on
31+
- Arguments and options can be generated quickly using `@click.argument` and `@click.option`
32+
- `CliRunner` can be used for testing

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Manage repositories of different flavors.
99
:hidden:
1010

1111
reference/modules
12+
CHANGELOG
1213

1314
.. toctree::
1415
:glob:

setup.cfg

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[metadata]
22
name = repo-man
3-
version = 0.0.4
3+
version = 0.0.5
44
description = Manage repositories of different flavors.
55
long_description = file: README.md
66
long_description_content_type = text/markdown
@@ -29,6 +29,8 @@ package_dir =
2929
=src
3030
packages = find_namespace:
3131
include_package_data = True
32+
install_requires =
33+
click>=8.1.7
3234

3335
[options.packages.find]
3436
where = src
@@ -97,7 +99,7 @@ deps =
9799
pytest
98100
types-termcolor
99101
commands =
100-
mypy --ignore-missing-imports {posargs:src test}
102+
mypy {posargs:src test}
101103

102104
[testenv:format]
103105
skip_install = True

src/repo_man/cli.py

Lines changed: 87 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import argparse
21
import configparser
32
import sys
43
from pathlib import Path
54
from typing import NoReturn, Union
65

6+
import click
7+
78

89
FAIL = "\033[91m"
910
ENDC = "\033[0m"
@@ -18,81 +19,7 @@ def check_if_allowed(path: Path) -> Union[bool, NoReturn]:
1819
return True
1920

2021

21-
def configure_arguments(parser: argparse.ArgumentParser, repo_types: dict[str, set[str]]) -> None:
22-
subparsers = parser.add_subparsers(description="Subcommands for managing repositories", dest="subcommand")
23-
24-
# List repos
25-
list_parser = subparsers.add_parser("list", help="List matching repositories")
26-
27-
list_parser.add_argument(
28-
"-t",
29-
"--type",
30-
required=True,
31-
choices=sorted(set(repo_types.keys()) - {"ignore", "all"}),
32-
metavar="TYPE",
33-
help="The type of repository to manage",
34-
)
35-
36-
# Add a new repo
37-
add_parser = subparsers.add_parser("add", help="Add a new repository")
38-
39-
add_parser.add_argument(
40-
"repository",
41-
choices=[str(directory) for directory in Path(".").iterdir() if directory.is_dir()],
42-
metavar="REPOSITORY",
43-
help="The name of the repository",
44-
)
45-
46-
add_parser.add_argument(
47-
"-t",
48-
"--type",
49-
required=True,
50-
help="The type of the repository",
51-
)
52-
53-
# Check a repo
54-
flavor_parser = subparsers.add_parser("flavors", help="List the configured types for a repository")
55-
56-
flavor_parser.add_argument(
57-
"repository",
58-
choices=[str(directory) for directory in Path(".").iterdir() if directory.is_dir()],
59-
metavar="REPOSITORY",
60-
help="The name of the repository",
61-
)
62-
63-
# Inspect repos
64-
parser.add_argument(
65-
"-k",
66-
"--known",
67-
action="store_true",
68-
help="List known repository types",
69-
)
70-
71-
parser.add_argument(
72-
"-d",
73-
"--duplicates",
74-
action="store_true",
75-
help="List repositories without a configured type",
76-
)
77-
78-
parser.add_argument(
79-
"-u",
80-
"--unconfigured",
81-
action="store_true",
82-
help="List repositories without a configured type",
83-
)
84-
85-
parser.add_argument(
86-
"-m",
87-
"--missing",
88-
action="store_true",
89-
help="List configured repositories that aren't cloned",
90-
)
91-
92-
9322
def parse_repo_types(config: configparser.ConfigParser) -> dict[str, set[str]]:
94-
config.read(REPO_TYPES_CFG)
95-
9623
repo_types: dict[str, set[str]] = {"all": set()}
9724
for section in config.sections():
9825
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:
12047
return None
12148

12249

123-
def handle_list(
124-
path: Path, config: configparser.ConfigParser, args, repo_types: dict[str, set[str]]
125-
) -> Union[None, NoReturn]:
126-
if args.type not in repo_types:
127-
print(f"\n{FAIL}Unknown type {args.type}. Valid types are:{ENDC}")
128-
for repo_type in repo_types:
129-
if repo_type != "all" and repo_type != "ignore":
130-
print(f"\t{repo_type}")
131-
sys.exit(1)
132-
133-
for repo in repo_types[args.type]:
134-
print(repo)
135-
136-
return None
137-
138-
139-
def handle_add(path: Path, config: configparser.ConfigParser, args, repo_types: dict[str, set[str]]) -> None:
140-
if args.type in config:
141-
original_config = config[args.type]["known"]
142-
else:
143-
original_config = ""
144-
config.add_section(args.type)
50+
def get_valid_repo_types():
51+
config = configparser.ConfigParser()
52+
config.read(REPO_TYPES_CFG)
53+
valid_repo_types = parse_repo_types(config)
54+
return sorted(set(valid_repo_types.keys()))
14555

146-
if "known" not in config[args.type] or args.repository not in config[args.type]["known"].split("\n"):
147-
config.set(args.type, "known", f"{original_config}\n{args.repository}")
14856

149-
with open(REPO_TYPES_CFG, "w") as config_file:
150-
config.write(config_file)
57+
def main():
58+
path = Path(".")
59+
check_if_allowed(path)
15160

152-
return None
61+
config = configparser.ConfigParser()
62+
config.read(REPO_TYPES_CFG)
63+
valid_repo_types = parse_repo_types(config)
64+
check_missing_repos(path, valid_repo_types)
15365

66+
cli()
15467

155-
def handle_flavors(path: Path, config: configparser.ConfigParser, args, repo_types: dict[str, set[str]]) -> None:
156-
found = set()
157-
for section in config.sections():
158-
if section == "ignore":
159-
continue
160-
if args.repository in config[section]["known"].split("\n"):
161-
found.add(section)
162-
for repository in sorted(found):
163-
print(repository)
16468

165-
return None
69+
@click.group(invoke_without_command=True, context_settings={"help_option_names": ["-h", "--help"]})
70+
@click.version_option(package_name="repo-man")
71+
@click.option("-k", "--known", is_flag=True, help="List known repository types")
72+
@click.option("-u", "--unconfigured", is_flag=True, help="List repositories without a configured type")
73+
@click.option("-d", "--duplicates", is_flag=True, help="List repositories with more than one configured type")
74+
# @click.option("-v", "--verbose", "verbosity", count=True)
75+
def cli(known: bool, unconfigured: bool, duplicates: bool):
76+
"""Manage repositories of different types"""
16677

78+
path = Path(".")
79+
config = configparser.ConfigParser()
80+
config.read(REPO_TYPES_CFG)
81+
valid_repo_types = parse_repo_types(config)
16782

168-
def handle_meta(path: Path, config: configparser.ConfigParser, args, repo_types: dict[str, set[str]]) -> None:
169-
if args.known:
83+
if known:
17084
known_repo_types = sorted(
171-
[repo_type for repo_type in repo_types if repo_type != "all" and repo_type != "ignore"]
85+
[repo_type for repo_type in valid_repo_types if repo_type != "all" and repo_type != "ignore"]
17286
)
17387
for repo_type in known_repo_types:
17488
print(repo_type)
17589

176-
if args.unconfigured:
90+
if unconfigured:
17791
for directory in sorted(path.iterdir()):
17892
if (
17993
directory.is_dir()
180-
and str(directory) not in repo_types["all"]
181-
and str(directory) not in repo_types.get("ignore", [])
94+
and str(directory) not in valid_repo_types["all"]
95+
and str(directory) not in valid_repo_types.get("ignore", [])
18296
):
18397
print(directory)
18498

185-
if args.duplicates:
99+
if duplicates:
186100
seen = set()
187-
for repo_type in repo_types:
101+
for repo_type in valid_repo_types:
188102
if repo_type != "all" and repo_type != "ignore":
189-
for repo in repo_types[repo_type]:
103+
for repo in valid_repo_types[repo_type]:
190104
if repo in seen:
191105
print(repo)
192106
seen.add(repo)
193107

194108

195-
def main():
196-
path = Path(".")
109+
@cli.command(name="list", help="The type of repository to manage")
110+
@click.option("-t", "--type", "repo_type", type=click.Choice(get_valid_repo_types()), show_choices=False, required=True)
111+
def list_repos(repo_type: str):
112+
"""List matching repositories"""
113+
114+
config = configparser.ConfigParser()
115+
config.read(REPO_TYPES_CFG)
116+
valid_repo_types = parse_repo_types(config)
117+
118+
for repo in valid_repo_types[repo_type]:
119+
print(repo)
120+
121+
return None
122+
123+
124+
@cli.command
125+
@click.argument("repo", type=click.Path(exists=True, file_okay=False))
126+
def flavors(repo: str):
127+
"""List the configured types for a repository"""
128+
129+
config = configparser.ConfigParser()
130+
config.read(REPO_TYPES_CFG)
131+
132+
found = set()
133+
134+
for section in config.sections():
135+
if section == "ignore":
136+
continue
137+
if repo in config[section]["known"].split("\n"):
138+
found.add(section)
139+
140+
for repository in sorted(found):
141+
print(repository)
197142

198-
check_if_allowed(path)
199143

200-
parser = argparse.ArgumentParser(
201-
prog="repo-man",
202-
description="Manage repositories of different types",
203-
)
144+
@cli.command
145+
@click.option("-t", "--type", "repo_types", multiple=True, help="The type of the repository", required=True)
146+
@click.argument("repo", type=click.Path(exists=True, file_okay=False))
147+
def add(repo: str, repo_types: list):
148+
"""Add a new repository"""
204149

205150
config = configparser.ConfigParser()
206-
repo_types = parse_repo_types(config)
207-
configure_arguments(parser, repo_types)
208-
args = parser.parse_args()
209-
check_missing_repos(path, repo_types)
210-
211-
if args.subcommand == "list":
212-
handle_list(path, config, args, repo_types)
213-
elif args.subcommand == "add":
214-
handle_add(path, config, args, repo_types)
215-
elif args.subcommand == "flavors":
216-
handle_flavors(path, config, args, repo_types)
217-
else:
218-
handle_meta(path, config, args, repo_types)
151+
config.read(REPO_TYPES_CFG)
152+
153+
for repo_type in repo_types:
154+
if repo_type in config:
155+
original_config = config[repo_type]["known"]
156+
else:
157+
original_config = ""
158+
config.add_section(repo_type)
159+
160+
if "known" not in config[repo_type] or repo not in config[repo_type]["known"].split("\n"):
161+
config.set(repo_type, "known", f"{original_config}\n{repo}")
162+
163+
with open(REPO_TYPES_CFG, "w") as config_file:
164+
config.write(config_file)

0 commit comments

Comments
 (0)