2019-08-31 15:17:10 +02:00
|
|
|
|
import argparse
|
|
|
|
|
import getpass
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
2020-05-27 15:58:15 +02:00
|
|
|
|
from copy import deepcopy
|
|
|
|
|
from typing import List, Optional, Union
|
2019-08-31 15:17:10 +02:00
|
|
|
|
|
2021-05-05 14:13:39 +02:00
|
|
|
|
from .constants import DEFAULT_FORMAT_OPTIONS, SEPARATOR_CREDENTIALS
|
|
|
|
|
from ..sessions import VALID_SESSION_NAME_PATTERN
|
2019-08-31 15:17:10 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class KeyValueArg:
|
|
|
|
|
"""Base key-value pair parsed from CLI."""
|
|
|
|
|
|
2019-09-17 09:21:49 +02:00
|
|
|
|
def __init__(self, key: str, value: Optional[str], sep: str, orig: str):
|
2019-08-31 15:17:10 +02:00
|
|
|
|
self.key = key
|
|
|
|
|
self.value = value
|
|
|
|
|
self.sep = sep
|
|
|
|
|
self.orig = orig
|
|
|
|
|
|
2019-09-17 09:21:49 +02:00
|
|
|
|
def __eq__(self, other: 'KeyValueArg'):
|
2019-08-31 15:17:10 +02:00
|
|
|
|
return self.__dict__ == other.__dict__
|
|
|
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
|
return repr(self.__dict__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SessionNameValidator:
|
|
|
|
|
|
2019-09-17 09:21:49 +02:00
|
|
|
|
def __init__(self, error_message: str):
|
2019-08-31 15:17:10 +02:00
|
|
|
|
self.error_message = error_message
|
|
|
|
|
|
2019-09-17 09:21:49 +02:00
|
|
|
|
def __call__(self, value: str) -> str:
|
2019-08-31 15:17:10 +02:00
|
|
|
|
# Session name can be a path or just a name.
|
|
|
|
|
if (os.path.sep not in value
|
|
|
|
|
and not VALID_SESSION_NAME_PATTERN.search(value)):
|
|
|
|
|
raise argparse.ArgumentError(None, self.error_message)
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Escaped(str):
|
|
|
|
|
"""Represents an escaped character."""
|
|
|
|
|
|
2019-09-17 09:07:12 +02:00
|
|
|
|
def __repr__(self):
|
|
|
|
|
return f"Escaped({repr(str(self))})"
|
|
|
|
|
|
2019-08-31 15:17:10 +02:00
|
|
|
|
|
|
|
|
|
class KeyValueArgType:
|
|
|
|
|
"""A key-value pair argument type used with `argparse`.
|
|
|
|
|
|
2019-09-17 09:07:12 +02:00
|
|
|
|
Parses a key-value arg and constructs a `KeyValueArg` instance.
|
2019-08-31 15:17:10 +02:00
|
|
|
|
Used for headers, form data, and other key-value pair types.
|
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
key_value_class = KeyValueArg
|
|
|
|
|
|
2019-09-17 09:21:49 +02:00
|
|
|
|
def __init__(self, *separators: str):
|
2019-08-31 15:17:10 +02:00
|
|
|
|
self.separators = separators
|
2022-01-04 10:04:20 +01:00
|
|
|
|
self.special_characters = set()
|
2019-08-31 15:17:10 +02:00
|
|
|
|
for separator in separators:
|
|
|
|
|
self.special_characters.update(separator)
|
|
|
|
|
|
2019-09-17 09:21:49 +02:00
|
|
|
|
def __call__(self, s: str) -> KeyValueArg:
|
2021-10-19 10:24:01 +02:00
|
|
|
|
"""Parse raw string arg and return `self.key_value_class` instance.
|
2019-08-31 15:17:10 +02:00
|
|
|
|
|
|
|
|
|
The best of `self.separators` is determined (first found, longest).
|
|
|
|
|
Back slash escaped characters aren't considered as separators
|
|
|
|
|
(or parts thereof). Literal back slash characters have to be escaped
|
|
|
|
|
as well (r'\\').
|
|
|
|
|
|
|
|
|
|
"""
|
2019-09-17 09:21:49 +02:00
|
|
|
|
tokens = self.tokenize(s)
|
2019-08-31 15:17:10 +02:00
|
|
|
|
|
|
|
|
|
# Sorting by length ensures that the longest one will be
|
|
|
|
|
# chosen as it will overwrite any shorter ones starting
|
|
|
|
|
# at the same position in the `found` dictionary.
|
|
|
|
|
separators = sorted(self.separators, key=len)
|
|
|
|
|
|
|
|
|
|
for i, token in enumerate(tokens):
|
|
|
|
|
|
|
|
|
|
if isinstance(token, Escaped):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
found = {}
|
|
|
|
|
for sep in separators:
|
|
|
|
|
pos = token.find(sep)
|
|
|
|
|
if pos != -1:
|
|
|
|
|
found[pos] = sep
|
|
|
|
|
|
|
|
|
|
if found:
|
|
|
|
|
# Starting first, longest separator found.
|
|
|
|
|
sep = found[min(found.keys())]
|
|
|
|
|
|
|
|
|
|
key, value = token.split(sep, 1)
|
|
|
|
|
|
|
|
|
|
# Any preceding tokens are part of the key.
|
|
|
|
|
key = ''.join(tokens[:i]) + key
|
|
|
|
|
|
|
|
|
|
# Any following tokens are part of the value.
|
|
|
|
|
value += ''.join(tokens[i + 1:])
|
|
|
|
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
else:
|
2019-09-17 09:21:49 +02:00
|
|
|
|
raise argparse.ArgumentTypeError(f'{s!r} is not a valid value')
|
2019-08-31 15:17:10 +02:00
|
|
|
|
|
2019-09-17 09:21:49 +02:00
|
|
|
|
return self.key_value_class(key=key, value=value, sep=sep, orig=s)
|
2019-08-31 15:17:10 +02:00
|
|
|
|
|
2019-09-17 09:07:12 +02:00
|
|
|
|
def tokenize(self, s: str) -> List[Union[str, Escaped]]:
|
|
|
|
|
r"""Tokenize the raw arg string
|
|
|
|
|
|
|
|
|
|
There are only two token types - strings and escaped characters:
|
|
|
|
|
|
|
|
|
|
>>> KeyValueArgType('=').tokenize(r'foo\=bar\\baz')
|
2022-01-04 10:04:20 +01:00
|
|
|
|
['foo', Escaped('='), 'bar\\\\baz']
|
2019-09-17 09:07:12 +02:00
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
tokens = ['']
|
|
|
|
|
characters = iter(s)
|
|
|
|
|
for char in characters:
|
|
|
|
|
if char == '\\':
|
|
|
|
|
char = next(characters, '')
|
|
|
|
|
if char not in self.special_characters:
|
|
|
|
|
tokens[-1] += '\\' + char
|
|
|
|
|
else:
|
|
|
|
|
tokens.extend([Escaped(char), ''])
|
|
|
|
|
else:
|
|
|
|
|
tokens[-1] += char
|
|
|
|
|
return tokens
|
|
|
|
|
|
2019-08-31 15:17:10 +02:00
|
|
|
|
|
2022-03-01 15:16:37 +01:00
|
|
|
|
class PromptMixin:
|
|
|
|
|
def _prompt_password(self, prompt: str) -> str:
|
|
|
|
|
prompt_text = f'http: {prompt}: '
|
2019-08-31 15:17:10 +02:00
|
|
|
|
try:
|
2022-03-01 15:16:37 +01:00
|
|
|
|
return self._getpass(prompt_text)
|
2019-08-31 15:17:10 +02:00
|
|
|
|
except (EOFError, KeyboardInterrupt):
|
|
|
|
|
sys.stderr.write('\n')
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
2019-09-17 09:21:49 +02:00
|
|
|
|
@staticmethod
|
|
|
|
|
def _getpass(prompt):
|
|
|
|
|
# To allow easy mocking.
|
|
|
|
|
return getpass.getpass(str(prompt))
|
|
|
|
|
|
2019-08-31 15:17:10 +02:00
|
|
|
|
|
2022-03-01 15:16:37 +01:00
|
|
|
|
class SSLCredentials(PromptMixin):
|
|
|
|
|
"""Represents the passphrase for the certificate's key."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, value: Optional[str]) -> None:
|
|
|
|
|
self.value = value
|
|
|
|
|
|
|
|
|
|
def prompt_password(self, key_file: str) -> None:
|
|
|
|
|
self.value = self._prompt_password(f'passphrase for {key_file}')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AuthCredentials(KeyValueArg, PromptMixin):
|
|
|
|
|
"""Represents parsed credentials."""
|
|
|
|
|
|
|
|
|
|
def has_password(self) -> bool:
|
|
|
|
|
return self.value is not None
|
|
|
|
|
|
|
|
|
|
def prompt_password(self, host: str) -> None:
|
|
|
|
|
self.value = self._prompt_password(f'password for {self.key}@{host}:')
|
|
|
|
|
|
|
|
|
|
|
2019-08-31 15:17:10 +02:00
|
|
|
|
class AuthCredentialsArgType(KeyValueArgType):
|
|
|
|
|
"""A key-value arg type that parses credentials."""
|
|
|
|
|
|
|
|
|
|
key_value_class = AuthCredentials
|
|
|
|
|
|
2019-09-17 09:21:49 +02:00
|
|
|
|
def __call__(self, s):
|
|
|
|
|
"""Parse credentials from `s`.
|
2019-08-31 15:17:10 +02:00
|
|
|
|
|
|
|
|
|
("username" or "username:password").
|
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
try:
|
2019-09-17 09:21:49 +02:00
|
|
|
|
return super().__call__(s)
|
2019-08-31 15:17:10 +02:00
|
|
|
|
except argparse.ArgumentTypeError:
|
|
|
|
|
# No password provided, will prompt for it later.
|
|
|
|
|
return self.key_value_class(
|
2019-09-17 09:21:49 +02:00
|
|
|
|
key=s,
|
2019-08-31 15:17:10 +02:00
|
|
|
|
value=None,
|
|
|
|
|
sep=SEPARATOR_CREDENTIALS,
|
2019-09-17 09:21:49 +02:00
|
|
|
|
orig=s
|
2019-08-31 15:17:10 +02:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
parse_auth = AuthCredentialsArgType(SEPARATOR_CREDENTIALS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def readable_file_arg(filename):
|
|
|
|
|
try:
|
|
|
|
|
with open(filename, 'rb'):
|
|
|
|
|
return filename
|
2021-05-31 10:10:41 +02:00
|
|
|
|
except OSError as ex:
|
|
|
|
|
raise argparse.ArgumentTypeError(f'{ex.filename}: {ex.strerror}')
|
2020-05-27 15:58:15 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_format_options(s: str, defaults: Optional[dict]) -> dict:
|
|
|
|
|
"""
|
|
|
|
|
Parse `s` and update `defaults` with the parsed values.
|
|
|
|
|
|
|
|
|
|
>>> parse_format_options(
|
|
|
|
|
... defaults={'json': {'indent': 4, 'sort_keys': True}},
|
2020-05-28 12:26:06 +02:00
|
|
|
|
... s='json.indent:2,json.sort_keys:False',
|
2020-05-27 15:58:15 +02:00
|
|
|
|
... )
|
|
|
|
|
{'json': {'indent': 2, 'sort_keys': False}}
|
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
value_map = {
|
|
|
|
|
'true': True,
|
|
|
|
|
'false': False,
|
|
|
|
|
}
|
|
|
|
|
options = deepcopy(defaults or {})
|
|
|
|
|
for option in s.split(','):
|
|
|
|
|
try:
|
2020-05-28 12:26:06 +02:00
|
|
|
|
path, value = option.lower().split(':')
|
2020-05-27 15:58:15 +02:00
|
|
|
|
section, key = path.split('.')
|
|
|
|
|
except ValueError:
|
2020-05-27 16:19:32 +02:00
|
|
|
|
raise argparse.ArgumentTypeError(f'invalid option {option!r}')
|
2020-05-27 15:58:15 +02:00
|
|
|
|
|
|
|
|
|
if value in value_map:
|
|
|
|
|
parsed_value = value_map[value]
|
|
|
|
|
else:
|
|
|
|
|
if value.isnumeric():
|
|
|
|
|
parsed_value = int(value)
|
|
|
|
|
else:
|
|
|
|
|
parsed_value = value
|
|
|
|
|
|
|
|
|
|
if defaults is None:
|
|
|
|
|
options.setdefault(section, {})
|
|
|
|
|
else:
|
|
|
|
|
try:
|
|
|
|
|
default_value = defaults[section][key]
|
|
|
|
|
except KeyError:
|
|
|
|
|
raise argparse.ArgumentTypeError(
|
2020-05-27 16:19:32 +02:00
|
|
|
|
f'invalid key {path!r}')
|
2020-05-27 15:58:15 +02:00
|
|
|
|
|
|
|
|
|
default_type, parsed_type = type(default_value), type(parsed_value)
|
|
|
|
|
if parsed_type is not default_type:
|
|
|
|
|
raise argparse.ArgumentTypeError(
|
2020-05-27 16:19:32 +02:00
|
|
|
|
'invalid value'
|
2020-05-27 15:58:15 +02:00
|
|
|
|
f' {value!r} in {option!r}'
|
|
|
|
|
f' (expected {default_type.__name__}'
|
|
|
|
|
f' got {parsed_type.__name__})'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
options[section][key] = parsed_value
|
|
|
|
|
|
|
|
|
|
return options
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
PARSED_DEFAULT_FORMAT_OPTIONS = parse_format_options(
|
|
|
|
|
s=','.join(DEFAULT_FORMAT_OPTIONS),
|
|
|
|
|
defaults=None,
|
|
|
|
|
)
|
2021-10-06 17:27:07 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def response_charset_type(encoding: str) -> str:
|
|
|
|
|
try:
|
|
|
|
|
''.encode(encoding)
|
|
|
|
|
except LookupError:
|
|
|
|
|
raise argparse.ArgumentTypeError(
|
|
|
|
|
f'{encoding!r} is not a supported encoding')
|
|
|
|
|
return encoding
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def response_mime_type(mime_type: str) -> str:
|
|
|
|
|
if mime_type.count('/') != 1:
|
|
|
|
|
raise argparse.ArgumentTypeError(
|
|
|
|
|
f'{mime_type!r} doesn’t look like a mime type; use type/subtype')
|
|
|
|
|
return mime_type
|