Skip to content

Commit

Permalink
Convert to Typer (#31)
Browse files Browse the repository at this point in the history
* Convert to Typer

* Fix quality feedback

* Ignore broken Typer Context reference in docs

* Bump version
  • Loading branch information
daneah authored May 10, 2024
1 parent 1a0cfe8 commit 16d8230
Show file tree
Hide file tree
Showing 27 changed files with 255 additions and 184 deletions.
6 changes: 6 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

## [0.0.10] - 2024-05-10

### Changed

- Use Typer for command-line parsing

## [0.0.9] - 2024-04-17

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture/decisions/0005-use-click-for-cli-parsing.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ Date: 2023-12-09

Accepted

Superseded by [7. Use Typer for CLI parsing](0007-use-typer-for-cli-parsing.md)

## Context

Parsing command-line arguments is a challenging problem.
Expand Down
24 changes: 24 additions & 0 deletions docs/architecture/decisions/0007-use-typer-for-cli-parsing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# 7. Use Typer for CLI parsing

Date: 2024-05-10

## Status

Accepted

Supersedes [5. Use Click for CLI parsing](0005-use-click-for-cli-parsing.md)

## Context

Type safety is important to maintainability and correctness of code, especially in systems that accept arbitrary user input.
Command-line arguments and options in most CLI frameworks can be annotated with type hints, but many frameworks require specifying information about the CLI arguments and the handler function arguments in a redundant way.
Typer is a library that uses Python type hints to generate a CLI parser, reducing the amount of boilerplate code needed to create a CLI.

## Decision

Use Typer for CLI parsing in the project.

## Consequences

- Developers can use type hints to specify the types of CLI arguments and options.
- repo-man can be composed into other Typer-based applications.
3 changes: 3 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
nitpick_ignore = [('py:class', 'typer.models.Context')]



# -- Options for HTML output -------------------------------------------------
Expand All @@ -66,6 +68,7 @@

intersphinx_mapping = {
"python": ("https://docs.python.org/3/", None),
"typer": ("https://sphinxcontrib-typer.readthedocs.io/en/latest/", None),
}

# -- Setup for sphinx-apidoc -------------------------------------------------
Expand Down
4 changes: 2 additions & 2 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[metadata]
name = repo-man
version = 0.0.9
version = 0.0.10
description = Manage repositories of a variety of different types.
long_description = file: README.md
long_description_content_type = text/markdown
Expand Down Expand Up @@ -30,7 +30,7 @@ package_dir =
packages = find_namespace:
include_package_data = True
install_requires =
click>=8.1.7
typer[all]>=0.9.0

[options.packages.find]
where = src
Expand Down
44 changes: 30 additions & 14 deletions src/repo_man/cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import configparser
from typing import Annotated, Optional

import click
import typer

from repo_man.commands.add import add
from repo_man.commands.edit import edit
Expand All @@ -10,27 +11,42 @@
from repo_man.commands.remove import remove
from repo_man.commands.sniff import sniff
from repo_man.commands.types import types
from repo_man.consts import REPO_TYPES_CFG
from repo_man.consts import RELEASE_VERSION, REPO_TYPES_CFG


@click.group(context_settings={"help_option_names": ["-h", "--help"]})
@click.version_option(package_name="repo-man")
@click.pass_context
def cli(context: click.Context) -> None: # pragma: no cover
def version_callback(value: bool) -> None:
if value:
print(RELEASE_VERSION)
raise typer.Exit()


cli = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]})


@cli.callback(invoke_without_command=True)
def default(
context: typer.Context,
version: Annotated[
Optional[bool],
typer.Option("--version", callback=version_callback, is_eager=True, help="Print the version of this tool."),
] = None,
) -> None:
"""Manage repositories of different types"""

config = configparser.ConfigParser()
config.read(REPO_TYPES_CFG)
context.obj = config


cli.command()(add)
cli.command()(edit)
cli.command()(types)
cli.command()(implode)
cli.command()(init)
cli.command(name="list")(list_repos)
cli.command()(remove)
cli.command()(sniff)


def main() -> None: # pragma: no cover
cli.add_command(add)
cli.add_command(edit)
cli.add_command(types)
cli.add_command(implode)
cli.add_command(init)
cli.add_command(list_repos)
cli.add_command(remove)
cli.add_command(sniff)
cli()
22 changes: 12 additions & 10 deletions src/repo_man/commands/add.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
import configparser
from pathlib import Path
from typing import Annotated

import click
import typer

from repo_man.consts import REPO_TYPES_CFG
from repo_man.utils import ensure_config_file_exists, pass_config
from repo_man.utils import ensure_config_file_exists


@click.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))
@pass_config
def add(config: configparser.ConfigParser, repo: str, repo_types: list[str]) -> None:
def add(
ctx: typer.Context,
repo: Annotated[Path, typer.Argument(exists=True, file_okay=False)],
repo_types: Annotated[list[str], typer.Option("-t", "--types", help="The type of the repository")],
) -> None:
"""Add a new repository"""

config = ctx.obj
ensure_config_file_exists(confirm=True)

new_types = [repo_type for repo_type in repo_types if repo_type not in config]
if new_types:
message = "\n\t".join(new_types)
click.confirm(f"The following types are unknown and will be added:\n\n\t{message}\n\nContinue?", abort=True)
typer.confirm(f"The following types are unknown and will be added:\n\n\t{message}\n\nContinue?", abort=True)

for repo_type in repo_types:
if repo_type in config:
Expand All @@ -27,7 +29,7 @@ def add(config: configparser.ConfigParser, repo: str, repo_types: list[str]) ->
original_config = ""
config.add_section(repo_type)

if "known" not in config[repo_type] or repo not in config[repo_type]["known"].split("\n"):
if "known" not in config[repo_type] or str(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:
Expand Down
5 changes: 2 additions & 3 deletions src/repo_man/commands/edit.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import click
import typer

from repo_man.consts import REPO_TYPES_CFG
from repo_man.utils import ensure_config_file_exists


@click.command
def edit() -> None:
"""Edit the repo-man configuration manually"""

ensure_config_file_exists()

click.edit(filename=REPO_TYPES_CFG)
typer.edit(filename=REPO_TYPES_CFG)
9 changes: 4 additions & 5 deletions src/repo_man/commands/implode.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
from pathlib import Path
from typing import Annotated

import click
import typer

from repo_man.consts import REPO_TYPES_CFG


@click.command
@click.argument("path", type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path))
def implode(path: Path) -> None:
def implode(path: Annotated[Path, typer.Argument(exists=True, file_okay=False, dir_okay=True)]) -> None:
"""Remove repo-man configuration for the specified directory"""

click.confirm(click.style("Are you sure you want to do this?", fg="yellow"), abort=True)
typer.confirm(typer.style("Are you sure you want to do this?", fg="yellow"), abort=True)
(path / REPO_TYPES_CFG).unlink(missing_ok=True)
11 changes: 5 additions & 6 deletions src/repo_man/commands/init.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
from pathlib import Path
from typing import Annotated

import click
import typer

from repo_man.consts import REPO_TYPES_CFG


@click.command
@click.argument("path", type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path))
def init(path: Path) -> None:
def init(path: Annotated[Path, typer.Argument(exists=True, file_okay=False, dir_okay=True)]) -> None:
"""Initialize repo-man to track repositories located at the specified path"""

if (path / REPO_TYPES_CFG).exists():
click.confirm(
click.style(f"{REPO_TYPES_CFG} file already exists. Overwrite with empty configuration?", fg="yellow"),
typer.confirm(
typer.style(f"{REPO_TYPES_CFG} file already exists. Overwrite with empty configuration?", fg="yellow"),
abort=True,
)

Expand Down
16 changes: 8 additions & 8 deletions src/repo_man/commands/list_repos.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import configparser
from typing import Annotated

import click
import typer

from repo_man.utils import ensure_config_file_exists, parse_repo_types, pass_config
from repo_man.utils import ensure_config_file_exists, parse_repo_types


@click.command(name="list")
@click.option("-t", "--type", "repo_types", multiple=True, show_choices=False, required=True)
@pass_config
def list_repos(config: configparser.ConfigParser, repo_types: list[str]) -> None:
def list_repos(ctx: typer.Context, repo_types: Annotated[list[str], typer.Option("-t", "--type")]) -> None:
"""List matching repositories"""

config = ctx.obj

ensure_config_file_exists()

valid_repo_types = parse_repo_types(config)
Expand All @@ -19,12 +19,12 @@ def list_repos(config: configparser.ConfigParser, repo_types: list[str]) -> None
for repo_type in repo_types:
if repo_type not in valid_repo_types:
repo_list = "\n\t".join(valid_repo_types)
raise click.BadParameter(f"Invalid repository type '{repo_type}'. Valid types are:\n\n\t{repo_list}")
raise typer.BadParameter(f"Invalid repository type '{repo_type}'. Valid types are:\n\n\t{repo_list}")
found_repos.update(valid_repo_types[repo_type])

repos = sorted(found_repos)

if len(repos) > 25:
click.echo_via_pager("\n".join(repos))
else:
click.echo("\n".join(repos))
typer.echo("\n".join(repos))
29 changes: 17 additions & 12 deletions src/repo_man/commands/remove.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,40 @@
import configparser
from pathlib import Path
from typing import Annotated

import click
import typer

from repo_man.consts import REPO_TYPES_CFG
from repo_man.utils import ensure_config_file_exists, parse_repo_types, pass_config
from repo_man.utils import ensure_config_file_exists, parse_repo_types


@click.command(name="remove")
@click.option("-t", "--type", "repo_types", multiple=True, help="The types from which to remove the repository")
@click.argument("repo", type=click.Path(exists=True, file_okay=False))
@pass_config
def remove(config: configparser.ConfigParser, repo: str, repo_types: list[str]) -> None:
def remove(
ctx: typer.Context,
repo: Annotated[Path, typer.Argument(exists=True, file_okay=False)],
repo_types: Annotated[
list[str], typer.Option("-t", "--type", help="The types from which to remove the repository")
],
) -> None:
"""Remove a repository from some or all types"""

config = ctx.obj

ensure_config_file_exists()

valid_repo_types = parse_repo_types(config)

for repo_type in repo_types:
if repo_type not in config:
repo_list = "\n\t".join(valid_repo_types)
raise click.BadParameter(f"Invalid repository type '{repo_type}'. Valid types are:\n\n\t{repo_list}")
raise typer.BadParameter(f"Invalid repository type '{repo_type}'. Valid types are:\n\n\t{repo_list}")

if "known" not in config[repo_type] or repo not in config[repo_type]["known"].split("\n"):
click.confirm(f"Repository '{repo}' is not configured for type '{repo_type}'. Continue?", abort=True)
if "known" not in config[repo_type] or str(repo) not in config[repo_type]["known"].split("\n"):
typer.confirm(f"Repository '{repo}' is not configured for type '{repo_type}'. Continue?", abort=True)

original_config = config[repo_type]["known"]
config.set(
repo_type,
"known",
"\n".join(original_repo for original_repo in original_config.split("\n") if original_repo != repo),
"\n".join(original_repo for original_repo in original_config.split("\n") if original_repo != str(repo)),
)

with open(REPO_TYPES_CFG, "w") as config_file:
Expand Down
30 changes: 18 additions & 12 deletions src/repo_man/commands/sniff.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import configparser
from pathlib import Path
from typing import Annotated, Optional

import click
import typer

from repo_man.utils import parse_repo_types, pass_config
from repo_man.utils import parse_repo_types


@click.command
@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")
@pass_config
def sniff(config: configparser.ConfigParser, known: bool, unconfigured: bool, duplicates: bool) -> None:
def sniff(
ctx: typer.Context,
known: Annotated[Optional[bool], typer.Option("-k", "--known", help="List known repository types")] = False,
unconfigured: Annotated[
Optional[bool], typer.Option("-u", "--unconfigured", help="List repositories without a configured type")
] = False,
duplicates: Annotated[
Optional[bool], typer.Option("-d", "--duplicates", help="List repositories with more than one configured type")
] = False,
) -> None:
"""Show information and potential issues with configuration"""

config = ctx.obj

path = Path(".")
valid_repo_types = parse_repo_types(config)

Expand All @@ -22,7 +28,7 @@ def sniff(config: configparser.ConfigParser, known: bool, unconfigured: bool, du
[repo_type for repo_type in valid_repo_types if repo_type != "all" and repo_type != "ignore"]
)
for repo_type in known_repo_types:
click.echo(repo_type)
typer.echo(repo_type)

if unconfigured:
for directory in sorted(path.iterdir()):
Expand All @@ -31,7 +37,7 @@ def sniff(config: configparser.ConfigParser, known: bool, unconfigured: bool, du
and str(directory) not in valid_repo_types["all"]
and str(directory) not in valid_repo_types.get("ignore", [])
):
click.echo(directory)
typer.echo(directory)

if duplicates:
seen_repos = set()
Expand All @@ -43,4 +49,4 @@ def sniff(config: configparser.ConfigParser, known: bool, unconfigured: bool, du
duplicate_repos.add(repo)
seen_repos.add(repo)

click.echo("\n".join(sorted(duplicate_repos)))
typer.echo("\n".join(sorted(duplicate_repos)))
Loading

0 comments on commit 16d8230

Please sign in to comment.