Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

config: Improve input checking in config command #209

Merged
merged 1 commit into from
Jan 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ revup config - Edit revup configuration files.

# SYNOPSIS

`revup config [--help] [--repo] <flag> <value>`
`revup config [--help] [--repo] [--delete] <flag> <value>`

# DESCRIPTION

Expand Down Expand Up @@ -40,6 +40,9 @@ revup will warn if attempting to specify them directly.
: If specified, configuration value will be written to the file in the current
repo. Otherwise, value will apply globally.

**--delete, -d**
: Delete the value with the given flag key.

# EXAMPLES

The default value for `revup upload --skip-confirm` is `false`. The user
Expand Down
142 changes: 123 additions & 19 deletions revup/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,87 @@
import logging
import os
import re
from argparse import _StoreAction, _StoreFalseAction, _StoreTrueAction
from typing import Any, Dict, List, Optional

from revup.types import RevupUsageException


class RevupArgParser(argparse.ArgumentParser):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)

def add_argument(self, *args: Any, **kwargs: Any) -> argparse.Action:
"""
For each boolean store_true action, add a corresponding "no" store_false action
with the same target.
"""
action = super().add_argument(*args, **kwargs)

if isinstance(action, _StoreTrueAction):
no_options = []
for option_string in action.option_strings:
if option_string.startswith("--"):
no_options.append("--no-" + option_string[2:])
elif option_string.startswith("-"):
no_options.append("-n" + option_string[1:])
Comment on lines +29 to +30
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this idiomatic? I tend to think short cli args can be stacked like tool -abcdef, and yeah that's how argparse works also: https://docs.python.org/3/library/argparse.html#option-value-syntax

It seems like it'd be confusing to pass e.g. -nx and not know if that's short for -n -x or --no-xlong-argument, I think the way revup is set up here it'd always be --no-xlong-argument but I'm not sure if I've seen a cli tool where that'd be the case

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah true, fwiw we don't have any flags that use -n for roughly this reason (except stragglers in toolkit that can be changed) would this be better if we formalized that?

we can also get rid of the short syntax and use only --no- longform syntax, i'm not that attached to it

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'd rather use only --no- longform

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sg i'll make that a new pr though, its not strictly related to this

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SGTM


if no_options:
action = _StoreFalseAction(
no_options, action.dest, action.default, False, "autogenerated negation"
)
for op in no_options:
self._option_string_actions[op] = action
self._actions.append(action)
elif isinstance(action, _StoreFalseAction):
# We assume all store false actions are the autogenerated one
raise RuntimeError("Direct store_false actions are not allowed")

return action

def set_defaults_from_config(self, conf: configparser.ConfigParser) -> None:
cmd = self.get_command()
for option, action in self.get_actions().items():
if conf.has_option(cmd, option):
self.set_option_default(option, action, conf.get(cmd, option))

def get_command(self) -> str:
return self.prog.split()[-1].replace("-", "_")

def get_actions(self) -> Dict[str, argparse.Action]:
ret: Dict[str, argparse.Action] = {}
for action in self._actions:
if not isinstance(action, (_StoreTrueAction, _StoreAction)):
# Ignore nonconfigurable actions (help, auto-generated negation)
continue

if len(action.option_strings) > 0 and action.option_strings[0].startswith("--"):
option = action.option_strings[0][2:].replace("-", "_")
ret[option] = action
return ret

def set_option_default(self, option: str, action: argparse.Action, value: str) -> None:
if isinstance(action, _StoreTrueAction):
override = value.lower()
if override in ("true", "false"):
action.default = override == "true"
else:
raise ValueError(
f'"{override}" not a valid override for boolean flag {option}, must'
' be "true" or "false"'
)
elif isinstance(action, _StoreAction):
if action.choices and value not in action.choices:
raise ValueError(
f"Value {value} for {self.get_command()}.{option} is not"
f" one of the choices {action.choices}"
)

action.default = value
else:
raise RuntimeError("Unknown option type!: ", option, action)


class Config:
# Object containing configuration values. Populated by read(), and can then
# be modified by set_value()
Expand Down Expand Up @@ -44,7 +121,13 @@ def write(self) -> None:
self.config.write(f)
self.dirty = False

def set_value(self, section: str, key: str, value: str) -> None:
def set_value(self, section: str, key: str, value: Optional[str]) -> None:
if value is None:
if self.config.has_option(section, key):
self.config.remove_option(section, key)
self.dirty = True
return

if not self.config.has_section(section):
self.config.add_section(section)
self.dirty = True
Expand All @@ -57,7 +140,7 @@ def get_config(self) -> configparser.ConfigParser:
return self.config


def config_main(conf: Config, args: argparse.Namespace) -> int:
def config_main(conf: Config, args: argparse.Namespace, all_parsers: List[RevupArgParser]) -> int:
split_key = args.flag[0].replace("-", "_").split(".")
if len(split_key) == 1:
command = "revup"
Expand All @@ -68,34 +151,55 @@ def config_main(conf: Config, args: argparse.Namespace) -> int:
else:
raise RevupUsageException("Invalid flag argument (must be <key> or <command>.<key>)")

if not args.value:
value = getpass.getpass(f"Input value for {command}.{key}: ").strip()
else:
value = args.value
all_commands = {p.get_command(): p for p in all_parsers}
if command not in all_commands:
raise RevupUsageException(
f"Invalid command section {command}, choose from {list(all_commands.keys())}"
)

parser = all_commands[command]
actions = parser.get_actions()

if not args.delete:
if key not in actions:
raise RevupUsageException(
f"Invalid option key {key}, choose from {list(actions.keys())}"
)

if args.delete:
value = None
if args.value:
raise RevupUsageException("Can't provide a value when using --delete")
elif args.value:
value = args.value
if command == "revup" and key == "github_oauth":
logging.warning(
"Prefer to omit the value on command line when entering sensitive info. "
"You may want to clear your shell history."
)
else:
value = getpass.getpass(f"Input value for {command}.{key}: ").strip()

config_path = conf.repo_config_path if args.repo else conf.config_path
this_config = Config(config_path)
this_config.read()

if command == "revup" and key == "github_username":
# From https://www.npmjs.com/package/github-username-regex :
# Github username may only contain alphanumeric characters or hyphens.
# Github username cannot have multiple consecutive hyphens.
# Github username cannot begin or end with a hyphen.
# Maximum is 39 characters.
if not re.match(r"^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$", value, re.I):
raise ValueError(f"{args.value} is not a valid GitHub username")
elif command == "revup" and key == "github_oauth":
if not re.match(r"^[a-z\d_]+$", value, re.I):
raise ValueError("Input string is not a valid oauth")
elif command == "config":
raise RevupUsageException("Can't set defaults for the config command itself")
if value is not None:
# Check whether this value is allowed by the parser by attempting to set it
# (this may throw if the value is not allowed)
parser.set_option_default(key, actions[key], value)

if command == "revup" and key == "github_username":
# From https://www.npmjs.com/package/github-username-regex :
# Github username may only contain alphanumeric characters or hyphens.
# Github username cannot have multiple consecutive hyphens.
# Github username cannot begin or end with a hyphen.
# Maximum is 39 characters.
if not re.match(r"^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$", value, re.I):
raise ValueError(f"{value} is not a valid GitHub username")
elif command == "revup" and key == "github_oauth":
if not re.match(r"^[a-z\d_]+$", value, re.I):
raise ValueError("Input string is not a valid oauth")

this_config.set_value(command, key, value)
this_config.write()
Expand Down
82 changes: 22 additions & 60 deletions revup/revup.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
from __future__ import annotations

import argparse
import configparser
import logging
import os
import stat
import subprocess
import sys
from argparse import _StoreAction, _StoreFalseAction, _StoreTrueAction
from builtins import FileNotFoundError
from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator, Tuple
from typing import Any, AsyncGenerator, List, Tuple

import revup
from revup import config, git, logs, shell
from revup.config import RevupArgParser
from revup.types import RevupUsageException

REVUP_CONFIG_ENV_VAR = "REVUP_CONFIG_PATH"
Expand All @@ -38,55 +37,6 @@ def __call__(self, parser: Any, namespace: Any, values: Any, option_string: Any
sys.exit(0)


class RevupArgParser(argparse.ArgumentParser):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)

def add_argument(self, *args: Any, **kwargs: Any) -> argparse.Action:
"""
For each boolean store_true action, add a corresponding "no" store_false action
with the same target.
"""
action = super().add_argument(*args, **kwargs)

if isinstance(action, _StoreTrueAction):
no_options = []
for option_string in action.option_strings:
if option_string.startswith("--"):
no_options.append("--no-" + option_string[2:])
elif option_string.startswith("-"):
no_options.append("-n" + option_string[1:])

if no_options:
action = _StoreFalseAction(
no_options, action.dest, action.default, False, "autogenerated negation"
)
for op in no_options:
self._option_string_actions[op] = action
self._actions.append(action)
return action

def set_defaults_from_config(self, conf: configparser.ConfigParser) -> None:
cmds = self.prog.split()
cmd = cmds[0] if len(cmds) == 1 else cmds[1]
for action in self._actions:
if len(action.option_strings) > 0 and action.option_strings[0].startswith("--"):
option = action.option_strings[0][2:].replace("-", "_")
if isinstance(action, _StoreTrueAction):
if conf.has_option(cmd, option):
override = conf.get(cmd, option).lower()
if override in ("true", "false"):
action.default = override == "true"
else:
raise ValueError(
f'"{override}" not a valid override for boolean flag {option}, must'
' be "true" or "false"'
)
elif isinstance(action, _StoreAction):
if conf.has_option(cmd, option):
action.default = conf.get(cmd, option)


def make_toplevel_parser() -> RevupArgParser:
revup_parser = RevupArgParser(add_help=False, prog="revup")
revup_parser.add_argument("--help", "-h", action=HelpAction, nargs=0)
Expand Down Expand Up @@ -247,6 +197,18 @@ async def main() -> int:
"config",
add_help=False,
)
toolkit_parser = subparsers.add_parser(
"toolkit", description="Test various subfunctionalities."
)

# Intentionally does not contain config or toolkit parsers since the those are not configurable
all_parsers: List[RevupArgParser] = [
revup_parser,
amend_parser,
cherry_pick_parser,
restack_parser,
upload_parser,
]

for p in [upload_parser, restack_parser, amend_parser]:
# Some args are used by both upload and restack
Expand Down Expand Up @@ -301,10 +263,8 @@ async def main() -> int:
config_parser.add_argument("flag", nargs=1)
config_parser.add_argument("value", nargs="?")
config_parser.add_argument("--repo", "-r", action="store_true")
config_parser.add_argument("--delete", "-d", action="store_true")

toolkit_parser = subparsers.add_parser(
"toolkit", description="Test various subfunctionalities."
)
toolkit_subparsers = toolkit_parser.add_subparsers(dest="toolkit_cmd", required=True)
detect_branch = toolkit_subparsers.add_parser(
"detect-branch", description="Detect the base branch of the current branch."
Expand Down Expand Up @@ -368,10 +328,15 @@ async def main() -> int:

# Do an initial parsing pass, which handles HelpAction
args = revup_parser.parse_args()

conf = await get_config()

for p in [revup_parser, amend_parser, cherry_pick_parser, restack_parser, upload_parser]:
# Run config before setting the config, in order to avoid the situation where a broken
# config prevents you from running config at all.
if args.cmd == "config":
logs.configure_logger(False, {})
return config.config_main(conf, args, all_parsers)

for p in all_parsers:
assert isinstance(p, RevupArgParser)
p.set_defaults_from_config(conf.get_config())
args = revup_parser.parse_args()
Expand All @@ -383,9 +348,6 @@ async def main() -> int:
)
dump_args(args)

if args.cmd == "config":
return config.config_main(conf, args)

git_ctx = await get_git(args)

if args.cmd == "toolkit":
Expand Down
Loading