From 600676ec5f9f43f714338684ea7f5931d5de0703 Mon Sep 17 00:00:00 2001 From: fabian Date: Thu, 4 Jan 2024 20:23:23 +0100 Subject: [PATCH] feat: Generate argparser from pydantic classes --- pyproject.toml | 4 + src/gallia/cli.py | 609 ++++++++---------- src/gallia/command/base.py | 419 +++++------- src/gallia/command/config.py | 392 +++++++++++ src/gallia/command/uds.py | 191 +++--- src/gallia/commands/__init__.py | 56 +- src/gallia/commands/discover/doip.py | 147 ++--- src/gallia/commands/discover/find_xcp.py | 258 ++++---- src/gallia/commands/discover/hsfz.py | 79 +-- src/gallia/commands/discover/uds/isotp.py | 174 ++--- src/gallia/commands/fuzz/uds/pdu.py | 136 ++-- src/gallia/commands/primitive/generic/pdu.py | 34 +- src/gallia/commands/primitive/uds/dtc.py | 195 +++--- .../commands/primitive/uds/ecu_reset.py | 55 +- src/gallia/commands/primitive/uds/iocbi.py | 114 ++-- src/gallia/commands/primitive/uds/pdu.py | 66 +- src/gallia/commands/primitive/uds/ping.py | 51 +- src/gallia/commands/primitive/uds/rdbi.py | 46 +- src/gallia/commands/primitive/uds/rmba.py | 53 +- src/gallia/commands/primitive/uds/rtcl.py | 154 ++--- src/gallia/commands/primitive/uds/vin.py | 22 +- src/gallia/commands/primitive/uds/wdbi.py | 79 +-- src/gallia/commands/primitive/uds/wmba.py | 73 +-- src/gallia/commands/primitive/uds/xcp.py | 58 +- src/gallia/commands/scan/uds/identifiers.py | 184 +++--- src/gallia/commands/scan/uds/memory.py | 103 ++- src/gallia/commands/scan/uds/reset.py | 90 ++- src/gallia/commands/scan/uds/sa_dump_seeds.py | 144 ++--- src/gallia/commands/scan/uds/services.py | 107 ++- src/gallia/commands/scan/uds/sessions.py | 129 ++-- src/gallia/commands/script/flexray.py | 77 +-- src/gallia/commands/script/rerun.py | 95 +++ src/gallia/commands/script/vecu.py | 165 ++--- src/gallia/db/handler.py | 23 +- src/gallia/plugins.py | 153 ----- src/gallia/plugins/__init__.py | 0 src/gallia/plugins/plugin.py | 147 +++++ src/gallia/plugins/uds.py | 173 +++++ src/gallia/plugins/xcp.py | 51 ++ src/gallia/services/uds/ecu.py | 4 +- src/gallia/services/uds/server.py | 163 +++-- src/gallia/utils.py | 101 +-- src/opennetzteil/cli.py | 179 ++--- tests/bats/001-invocation.bats | 2 +- tests/bats/002-scans.bats | 8 +- tests/bats/run_bats.sh | 7 +- .../.github/workflows/linting.yml | 36 -- .../.github/workflows/tests.yml | 64 -- .../.github/workflows/typing.yml | 36 -- vendor/pydantic-argparse/.gitignore | 89 --- vendor/pydantic-argparse/README.md | 179 ----- vendor/pydantic-argparse/docs/CNAME | 1 - vendor/pydantic-argparse/docs/CNAME.license | 3 - .../docs/assets/images/logo.svg | 95 --- .../docs/assets/images/logo.svg.license | 3 - .../docs/assets/images/showcase_01.png | Bin 18144 -> 0 bytes .../assets/images/showcase_01.png.license | 3 - .../docs/assets/images/showcase_02.png | Bin 6224 -> 0 bytes .../assets/images/showcase_02.png.license | 3 - .../docs/assets/images/showcase_03.png | Bin 12391 -> 0 bytes .../assets/images/showcase_03.png.license | 3 - .../docs/assets/images/showcase_04.png | Bin 8885 -> 0 bytes .../assets/images/showcase_04.png.license | 3 - .../docs/assets/images/showcase_05.png | Bin 37119 -> 0 bytes .../assets/images/showcase_05.png.license | 3 - .../docs/assets/images/showcase_06.png | Bin 12832 -> 0 bytes .../assets/images/showcase_06.png.license | 3 - .../docs/assets/stylesheets/reference.css | 6 - .../assets/stylesheets/reference.css.license | 3 - vendor/pydantic-argparse/docs/background.md | 143 ---- .../docs/examples/commands.md | 61 -- .../pydantic-argparse/docs/examples/simple.md | 40 -- vendor/pydantic-argparse/docs/index.md | 68 -- .../docs/reference/reference.py | 81 --- .../docs/reference/reference.py.license | 3 - vendor/pydantic-argparse/docs/showcase.md | 136 ---- .../docs/usage/argument_parser.md | 66 -- .../docs/usage/arguments/choices.md | 245 ------- .../docs/usage/arguments/commands.md | 106 --- .../docs/usage/arguments/flags.md | 246 ------- .../docs/usage/arguments/index.md | 238 ------- .../docs/usage/arguments/regular.md | 160 ----- .../docs/usage/arguments/variadic.md | 132 ---- vendor/pydantic-argparse/examples/commands.py | 56 -- vendor/pydantic-argparse/examples/simple.py | 42 -- vendor/pydantic-argparse/mkdocs.yml | 96 --- .../pydantic_argparse/__init__.py | 2 +- .../pydantic_argparse/__metadata__.py | 1 - .../pydantic_argparse/argparse/__init__.py | 4 +- .../pydantic_argparse/argparse/parser.py | 226 ++++--- .../pydantic_argparse/argparse/patches.py | 50 -- .../pydantic_argparse/parsers/__init__.py | 30 +- .../pydantic_argparse/parsers/boolean.py | 39 +- .../pydantic_argparse/parsers/command.py | 29 +- .../pydantic_argparse/parsers/container.py | 31 +- .../pydantic_argparse/parsers/enum.py | 56 +- .../pydantic_argparse/parsers/literal.py | 53 +- .../pydantic_argparse/parsers/mapping.py | 62 -- .../pydantic_argparse/parsers/standard.py | 26 +- .../pydantic_argparse/parsers/utils.py | 5 +- .../pydantic_argparse/utils/__init__.py | 2 +- .../pydantic_argparse/utils/errors.py | 31 - .../pydantic_argparse/utils/field.py | 76 +++ .../pydantic_argparse/utils/namespaces.py | 2 +- .../pydantic_argparse/utils/nesting.py | 76 ++- .../pydantic_argparse/utils/pydantic.py | 302 ++++----- .../pydantic_argparse/utils/types.py | 1 - vendor/pydantic-argparse/tests/__init__.py | 9 - .../tests/argparse/__init__.py | 9 - .../tests/argparse/test_actions.py | 105 --- .../tests/argparse/test_parser.py | 554 ---------------- vendor/pydantic-argparse/tests/conftest.py | 212 ------ .../tests/functional/__init__.py | 9 - .../functional/test_environment_variables.py | 344 ---------- .../tests/parsers/__init__.py | 9 - .../pydantic-argparse/tests/utils/__init__.py | 9 - .../tests/utils/test_arguments.py | 94 --- .../tests/utils/test_errors.py | 87 --- .../tests/utils/test_namespaces.py | 51 -- .../tests/utils/test_types.py | 90 --- 120 files changed, 3367 insertions(+), 7341 deletions(-) create mode 100644 src/gallia/command/config.py create mode 100644 src/gallia/commands/script/rerun.py delete mode 100644 src/gallia/plugins.py create mode 100644 src/gallia/plugins/__init__.py create mode 100644 src/gallia/plugins/plugin.py create mode 100644 src/gallia/plugins/uds.py create mode 100644 src/gallia/plugins/xcp.py delete mode 100644 vendor/pydantic-argparse/.github/workflows/linting.yml delete mode 100644 vendor/pydantic-argparse/.github/workflows/tests.yml delete mode 100644 vendor/pydantic-argparse/.github/workflows/typing.yml delete mode 100644 vendor/pydantic-argparse/.gitignore delete mode 100644 vendor/pydantic-argparse/docs/CNAME delete mode 100644 vendor/pydantic-argparse/docs/CNAME.license delete mode 100644 vendor/pydantic-argparse/docs/assets/images/logo.svg delete mode 100644 vendor/pydantic-argparse/docs/assets/images/logo.svg.license delete mode 100644 vendor/pydantic-argparse/docs/assets/images/showcase_01.png delete mode 100644 vendor/pydantic-argparse/docs/assets/images/showcase_01.png.license delete mode 100644 vendor/pydantic-argparse/docs/assets/images/showcase_02.png delete mode 100644 vendor/pydantic-argparse/docs/assets/images/showcase_02.png.license delete mode 100644 vendor/pydantic-argparse/docs/assets/images/showcase_03.png delete mode 100644 vendor/pydantic-argparse/docs/assets/images/showcase_03.png.license delete mode 100644 vendor/pydantic-argparse/docs/assets/images/showcase_04.png delete mode 100644 vendor/pydantic-argparse/docs/assets/images/showcase_04.png.license delete mode 100644 vendor/pydantic-argparse/docs/assets/images/showcase_05.png delete mode 100644 vendor/pydantic-argparse/docs/assets/images/showcase_05.png.license delete mode 100644 vendor/pydantic-argparse/docs/assets/images/showcase_06.png delete mode 100644 vendor/pydantic-argparse/docs/assets/images/showcase_06.png.license delete mode 100644 vendor/pydantic-argparse/docs/assets/stylesheets/reference.css delete mode 100644 vendor/pydantic-argparse/docs/assets/stylesheets/reference.css.license delete mode 100644 vendor/pydantic-argparse/docs/background.md delete mode 100644 vendor/pydantic-argparse/docs/examples/commands.md delete mode 100644 vendor/pydantic-argparse/docs/examples/simple.md delete mode 100644 vendor/pydantic-argparse/docs/index.md delete mode 100644 vendor/pydantic-argparse/docs/reference/reference.py delete mode 100644 vendor/pydantic-argparse/docs/reference/reference.py.license delete mode 100644 vendor/pydantic-argparse/docs/showcase.md delete mode 100644 vendor/pydantic-argparse/docs/usage/argument_parser.md delete mode 100644 vendor/pydantic-argparse/docs/usage/arguments/choices.md delete mode 100644 vendor/pydantic-argparse/docs/usage/arguments/commands.md delete mode 100644 vendor/pydantic-argparse/docs/usage/arguments/flags.md delete mode 100644 vendor/pydantic-argparse/docs/usage/arguments/index.md delete mode 100644 vendor/pydantic-argparse/docs/usage/arguments/regular.md delete mode 100644 vendor/pydantic-argparse/docs/usage/arguments/variadic.md delete mode 100644 vendor/pydantic-argparse/examples/commands.py delete mode 100644 vendor/pydantic-argparse/examples/simple.py delete mode 100644 vendor/pydantic-argparse/mkdocs.yml delete mode 100644 vendor/pydantic-argparse/pydantic_argparse/argparse/patches.py delete mode 100644 vendor/pydantic-argparse/pydantic_argparse/parsers/mapping.py delete mode 100644 vendor/pydantic-argparse/pydantic_argparse/utils/errors.py create mode 100644 vendor/pydantic-argparse/pydantic_argparse/utils/field.py delete mode 100644 vendor/pydantic-argparse/tests/__init__.py delete mode 100644 vendor/pydantic-argparse/tests/argparse/__init__.py delete mode 100644 vendor/pydantic-argparse/tests/argparse/test_actions.py delete mode 100644 vendor/pydantic-argparse/tests/argparse/test_parser.py delete mode 100644 vendor/pydantic-argparse/tests/conftest.py delete mode 100644 vendor/pydantic-argparse/tests/functional/__init__.py delete mode 100644 vendor/pydantic-argparse/tests/functional/test_environment_variables.py delete mode 100644 vendor/pydantic-argparse/tests/parsers/__init__.py delete mode 100644 vendor/pydantic-argparse/tests/utils/__init__.py delete mode 100644 vendor/pydantic-argparse/tests/utils/test_arguments.py delete mode 100644 vendor/pydantic-argparse/tests/utils/test_errors.py delete mode 100644 vendor/pydantic-argparse/tests/utils/test_namespaces.py delete mode 100644 vendor/pydantic-argparse/tests/utils/test_types.py diff --git a/pyproject.toml b/pyproject.toml index ff2dda9ae..92db97fb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,10 @@ construct-typing = ">=0.5.2,<0.7.0" pytest-cov = ">=4,<6" ruff = ">=0.6,<0.8" +[tool.poetry.plugins."gallia_plugins"] +"uds" = "gallia.plugins.uds:UDSPlugin" +"xcp" = "gallia.plugins.xcp:XCPPlugin" + [tool.poetry.scripts] "gallia" = "gallia.cli:main" "netzteil" = "opennetzteil.cli:main" diff --git a/src/gallia/cli.py b/src/gallia/cli.py index 9bfece54f..211a18752 100644 --- a/src/gallia/cli.py +++ b/src/gallia/cli.py @@ -1,422 +1,329 @@ # SPDX-FileCopyrightText: AISEC Pentesting Team # # SPDX-License-Identifier: Apache-2.0 - -# PYTHON_ARGCOMPLETE_OK - -from __future__ import annotations - import argparse +import json import os import sys -from collections.abc import Iterable -from importlib.metadata import EntryPoint, version -from pathlib import Path +from collections.abc import Callable, Mapping, MutableMapping, Sequence +from importlib.metadata import version as meta_version from pprint import pprint -from typing import Any +from textwrap import wrap +from types import UnionType +from typing import Any, Never import argcomplete +from pydantic import Field, create_model +from pydantic_argparse import ArgumentParser +from pydantic_argparse import BaseCommand as PydanticBaseCommand +from pydantic_core import PydanticUndefined from gallia import exitcodes -from gallia.command.base import BaseCommand -from gallia.commands import registry as cmd_registry +from gallia.command import BaseCommand +from gallia.command.base import BaseCommandConfig +from gallia.command.config import GalliaBaseModel from gallia.config import Config, load_config_file -from gallia.log import setup_logging -from gallia.plugins import ( - Parsers, - add_cli_group, - load_cli_init_plugin_eps, - load_cli_init_plugins, - load_command_plugin_eps, - load_command_plugins, - load_ecu_plugin_eps, - load_transport_plugin_eps, -) +from gallia.log import Loglevel, setup_logging +from gallia.plugins.plugin import CommandTree, load_commands, load_plugins from gallia.utils import get_log_level +setup_logging(Loglevel.DEBUG) -def load_parsers() -> Parsers: - parser = argparse.ArgumentParser( - description="""gallia COMMANDs are grouped by GROUP and SUBGROUP. - Each GROUP, SUBGROUP, or COMMAND contains a help page which can be accessed via `-h` or `--help`. - A few command line option can be set via a TOML config file. Check `gallia --template` for a starting point. - """, - epilog="""https://fraunhofer-aisec.github.io/gallia/index.html""", - ) - parser.set_defaults(usage_func=parser.print_usage) - parser.set_defaults(help_func=parser.print_help) - parser.add_argument( - "--version", - action="version", - version=f'%(prog)s {version("gallia")}', - ) - parser.add_argument( - "--show-config", - action="store_true", - help="show information about the loaded config", - ) - parser.add_argument( - "--show-defaults", - action="store_true", - help="show defaults of all flags", - ) - parser.add_argument( - "--show-cli", - action="store_true", - help="show the subcommand tree", - ) - parser.add_argument( - "--show-plugins", - action="store_true", - help="show loaded plugins", - ) - parser.add_argument( - "--template", - action="store_true", - help="print a config template", - ) - subparsers = parser.add_subparsers(metavar="GROUP") - parsers: Parsers = { - "parser": parser, - "subparsers": subparsers, - "siblings": {}, - } - - command = "COMMAND" - subgroup = "SUBGROUP" - - add_cli_group( - parsers, - "discover", - "discover scanners for hosts and endpoints", - metavar=subgroup, - ) - add_cli_group( - parsers["siblings"]["discover"], - "uds", - "Universal Diagnostic Services", - description="find UDS endpoints on specific transports using discovery scanning techniques", - epilog="https://fraunhofer-aisec.github.io/gallia/uds/scan_modes.html#discovery-scan", - metavar=command, - ) - add_cli_group( - parsers, - "primitive", - "protocol specific primitives", - metavar=subgroup, - ) - add_cli_group( - parsers["siblings"]["primitive"], - "uds", - "Universal Diagnostic Services", - description="primitives for the UDS protocol according to the ISO standard", - metavar=command, - ) - add_cli_group( - parsers["siblings"]["primitive"], - "generic", - "generic networks primitives", - description="generic primitives for network protocols, e.g. send a pdu", - metavar=command, - ) +defaults = dict[type, dict[str, Any]] +_CLASS_ATTR = "_dynamic_gallia_command_class_reference" - add_cli_group( - parsers, - "scan", - "scanners for network protocol parameters", - metavar=subgroup, - ) - add_cli_group( - parsers["siblings"]["scan"], - "uds", - "Universal Diagnostic Services", - description="scan UDS parameters", - epilog="https://fraunhofer-aisec.github.io/gallia/uds/scan_modes.html", - metavar=command, - ) - add_cli_group( - parsers, - "fuzz", - "fuzzing tools", - metavar=subgroup, - ) - add_cli_group( - parsers["siblings"]["fuzz"], - "uds", - "Universal Diagnostic Services", - metavar=command, - ) +def _create_parser_from_command( + command: type[BaseCommand], config: Config, extra_defaults: defaults, model_counter: int = 0 +) -> tuple[type[PydanticBaseCommand], defaults, int]: + config_attributes = command.CONFIG_TYPE.attributes_from_config(config) + env_attributes = command.CONFIG_TYPE.attributes_from_env() + config_attributes.update(env_attributes) - add_cli_group( - parsers, - "script", - "miscellaneous helper scripts", - description="miscellaneous uncategorized helper scripts", - metavar=command, + # it is necessary to create a submodel, because several commands can use the same config + # (e.g. of their base class if no additional arguments are required) + model_type = create_model( + f"_dynamic_{command.CONFIG_TYPE}_{model_counter}", __base__=command.CONFIG_TYPE ) + extra_defaults[model_type] = config_attributes + + setattr(model_type, _CLASS_ATTR, command) + + return model_type, extra_defaults, model_counter - return parsers +def _create_parser_from_tree( + command_tree: CommandTree, config: Config, extra_defaults: defaults, model_counter: int = 0 +) -> tuple[type[PydanticBaseCommand], defaults, int]: + model_name = f"_dynamic_gallia_hierarchy_model_{model_counter}" + args: MutableMapping[str, tuple[type | UnionType, Any]] = {} -def build_cli( - parsers: Parsers, - config: Config, - registry: list[type[BaseCommand]], -) -> None: - for cls in registry: - if cls.GROUP is None: - continue + for key, value in command_tree.subtree.items(): + model_counter += 1 - if cls.SUBGROUP is not None: - subparsers = parsers["siblings"][cls.GROUP]["siblings"][cls.SUBGROUP]["subparsers"] + if isinstance(value, CommandTree): + model_type, extra_defaults, model_counter = _create_parser_from_tree( + value, config, extra_defaults, model_counter + ) + description = value.description else: - subparsers = parsers["siblings"][cls.GROUP]["subparsers"] - - # Seems like a mypy bug. This is already covered by the check above. - assert cls.COMMAND is not None - subparser = subparsers.add_parser( - cls.COMMAND, - description=cls.__doc__, - help=cls.SHORT_HELP, - epilog=cls.EPILOG, - ) - cmd = cls(subparser, config) - subparser.set_defaults(cls_object=cmd) + model_type, extra_defaults, model_counter = _create_parser_from_command( + value, config, extra_defaults, model_counter + ) + description = value.SHORT_HELP + args[key] = (model_type | None, Field(None, description=description)) -def cmd_show_config( - args: argparse.Namespace, - config: Config, - config_path: Path | None, -) -> None: - if (p := os.getenv("GALLIA_CONFIG")) is not None: - print(f"path to config set by env variable: {p}", file=sys.stderr) + return ( + create_model(model_name, __base__=PydanticBaseCommand, **args), # type: ignore[call-overload] + extra_defaults, + model_counter, + ) - if config_path is not None: - print(f"loaded config: {config_path}", file=sys.stderr) - pprint(config) + +def create_parser( + commands: type[BaseCommand] | MutableMapping[str, CommandTree | type[BaseCommand]], +) -> ArgumentParser[PydanticBaseCommand]: + """ + Creates an argument parser out of the given command hierarchy. + + For accessing the command after parsing, see get_command(). + See parse_and_run() for an easy-to-use one call alternative. + + :param commands: A hierarchy of commands. + :return The argument parser for the given commands. + """ + + config, _ = load_config_file() + + if isinstance(commands, Mapping): + command_tree = CommandTree("", subtree=commands) + model, extra_defaults, _ = _create_parser_from_tree(command_tree, config, {}) else: - print("no config available", file=sys.stderr) - sys.exit(1) - - -def _get_cli_defaults(parser: argparse.ArgumentParser, out: dict[str, Any]) -> None: - for action in parser.__dict__["_actions"]: - if isinstance( - action, - argparse._StoreAction - | argparse._StoreTrueAction - | argparse._StoreFalseAction - | argparse.BooleanOptionalAction, - ): - opts = action.__dict__["option_strings"] - if len(opts) == 2: - if opts[0].startswith("--"): - opts_str = opts[0] - else: - opts_str = opts[1] - elif len(opts) == 1: - opts_str = opts[0] - else: - continue + model, extra_defaults, _ = _create_parser_from_command(commands, config, {}) - keys = f"{parser.prog} {opts_str.removeprefix('--').replace('-', '_')}".split() - value = action.default + return ArgumentParser(model=model, extra_defaults=extra_defaults) - d = out - for i, key in enumerate(keys): - if key not in d: - d[key] = {} - d = d[key] +def get_command(config: BaseCommandConfig) -> BaseCommand: + """ + Retrieve the command out of the config returned by an argument parser as created by create_parser(). - if i == len(keys) - 2: - d[keys[-1]] = value - break + :param config: + :return: The command initiated with the given config. + """ + cmd: type[BaseCommand] = getattr(config, _CLASS_ATTR) - if isinstance(action, argparse._SubParsersAction): - for subparser in action.__dict__["choices"].values(): - _get_cli_defaults(subparser, out) + return cmd(config) -def get_cli_defaults(parser: argparse.ArgumentParser) -> dict[str, Any]: - out: dict[str, Any] = {} - _get_cli_defaults(parser, out) - return out +def parse_and_run( + commands: type[BaseCommand] | MutableMapping[str, CommandTree | type[BaseCommand]], + auto_complete: bool = True, + setup_log: bool = True, + top_level_options: Mapping[str, Callable[[], None]] | None = None, + show_help_on_zero_args: bool = True, +) -> Never: + """ + Creates an argument parser out of the given command hierarchy and runs the command with its argument. + This function never returns. + A set of commands is simply generated by a dict with command name as key and a Command object as value. + For a full hierarchy of commands CommandTrees can be used. -def _get_command_tree(parser: argparse.ArgumentParser, out: dict[str, Any]) -> None: - for action in parser.__dict__["_actions"]: - if isinstance(action, argparse._SubParsersAction): - for cmd, subparser in action.__dict__["choices"].items(): - out[cmd] = {} - d = out[cmd] - _get_command_tree(subparser, d) + :param commands: A hierarchy of commands. + :param auto_complete: Turns auto-complete functionality on. + :param setup_log: Setup logging according to the parameters in the parsed config. + :param top_level_options: Optional top-level actions, such as "--version", given by a mapping of arguments and + functions. The program redirects control to the given function, once the program is + called with the corresponding argument and terminates after it returns. + :param show_help_on_zero_args: Show the help message instead of an error in case no arguments are submitted at all. + """ + parser = create_parser(commands) -def get_command_tree(parser: argparse.ArgumentParser) -> dict[str, Any]: - out: dict[str, Any] = {"gallia": {}} - _get_command_tree(parser, out["gallia"]) - return out + def make_f(c: Callable[[], None]) -> Callable[[Any], None]: + def f(_: Any) -> None: + c() + return f -def _print_tree( - current: str, - tree: dict[str, Any], - marker: str, - level_markers: list[bool], -) -> None: - indent = " " * len(marker) - connection = "|" + indent[:-1] - level = len(level_markers) + if top_level_options is not None: + for name, func in top_level_options.items(): - def mapper(draw: bool) -> str: - return connection if draw else indent + class Action(argparse.Action): + f = make_f(func) - markers = "".join(map(mapper, level_markers[:-1])) - markers += marker if level > 0 else "" + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: str | Sequence[Any] | None, + option_string: str | None = None, + ) -> None: + self.f() + sys.exit(exitcodes.OK) - print(f"{markers}{current}") - for i, child in enumerate(tree.keys()): - is_last = i == len(tree.keys()) - 1 - _print_tree(child, tree[child], marker, [*level_markers, not is_last]) + parser.add_argument( + name if name.startswith("-") else f"--{name}", nargs=0, action=Action + ) + if show_help_on_zero_args and len(sys.argv) == 1: + parser.print_help(sys.stderr) + sys.exit(exitcodes.USAGE) -def print_tree(tree: dict[str, Any]) -> None: - # Assumption: first level of dict has only one element -> root node. - if len(tree) != 1: - raise ValueError("invalid tree") + if auto_complete: + argcomplete.autocomplete(parser) - root = list(tree.keys())[0] - _print_tree(root, tree[root], "+-", []) + _, config = parser.parse_typed_args() + assert isinstance(config, BaseCommandConfig) -def cmd_show_cli(parser: argparse.ArgumentParser) -> None: - print_tree(get_command_tree(parser)) + if setup_log: + setup_logging( + level=get_log_level(config.verbose), + no_volatile_info=not config.volatile_info, + ) + sys.exit(get_command(config).entry_point()) -def cmd_show_defaults(parser: argparse.ArgumentParser) -> None: - pprint(get_cli_defaults(parser)) + +def main() -> None: + gallia_commands = load_commands() + parse_and_run( + gallia_commands, + top_level_options={ + "version": version, + "-v": version, + "show-plugins": show_plugins, + "show-config": show_config, + "template": template, + }, + show_help_on_zero_args=True, + ) + + +def version() -> None: + """ + Prints the currently installed version of gallia. + """ + print(f"gallia {meta_version('gallia')}") -def _print_plugin(description: str, eps: list[EntryPoint]) -> None: - print(f"{description}:") - for ep in eps: - print(f" EntryPoint.name: {ep.name}") - ep_loaded = ep.load() - if isinstance(ep_loaded, Iterable): - for cls in ep_loaded: - print(f" * {cls}") +def _walk_commands( + commands: Mapping[str, CommandTree | type[BaseCommand]], level: int = 0 +) -> tuple[str, int]: + command_str = "" + command_ctr = 0 + + for name, command in commands.items(): + command_str += f"{' ' * (level + 2)}{name}" + + if isinstance(command, CommandTree): + command_str += "\n" + + sub_command_str, sub_command_ctr = _walk_commands(command.subtree, level + 1) + command_ctr += sub_command_ctr + command_str += sub_command_str else: - print(f" * {ep_loaded}") - - -def cmd_show_plugins() -> None: - _print_plugin("initialization callbacks (gallia_cli_init)", load_cli_init_plugin_eps()) - _print_plugin("commands (gallia_commands)", load_command_plugin_eps()) - _print_plugin("transports (gallia_transports)", load_transport_plugin_eps()) - _print_plugin("ecus (gallia_ecus)", load_ecu_plugin_eps()) - - -def cmd_template(args: argparse.Namespace) -> None: - template = """# [gallia] -# verbosity = -# no-volatile-info = -# trace_log = -# lock_file = -# db = - -# [gallia.hooks] -# enable = -# pre = -# post = - -# [gallia.scanner] -# target = -# power_supply = -# power_cycle = -# power_cycle_sleep = -# dumpcap = -# artifacts_dir = -# artifacts_base = - -# [gallia.protocols.uds] -# dumpcap = -# ecu_reset = -# oem = -# timeout = -# max_retries = -# ping = -# tester_present_interval = -# tester_present = -# properties = -# compare_properties = -""" - print(template.strip()) - - -def build_parser() -> tuple[argparse.ArgumentParser, Config, Path | None]: - registry = cmd_registry[:] - - plugin_cmds = load_command_plugins() - if len(plugin_cmds) > 0: - registry += plugin_cmds - - parsers = load_parsers() - - # Load plugins. - for fn in load_cli_init_plugins(): - fn(parsers) - - try: - config, config_path = load_config_file() - except ValueError as e: - print(f"invalid config: {e}", file=sys.stderr) + command_ctr += 1 + command_str += f": {command.__module__}.{command.__name__}\n" + + return command_str, command_ctr + + +def show_plugins() -> None: + """ + Prints the currently installed plugins. + """ + plugins = load_plugins() + + print(f"There are currently {len(plugins)} plugins installed:") + + for plugin in plugins: + print() + print(plugin.name()) + + ecus = plugin.ecus() + print(f" ECUs ({len(ecus)}):") + + for ecu in ecus: + print(f" {ecu.OEM}: {ecu.__module__}.{ecu.__name__}") + + transports = plugin.transports() + print(f"\n Transports ({len(transports)}):") + + for transport in transports: + print(f" {transport.SCHEME}: {transport.__module__}.{transport.__name__}") + + commands = plugin.commands() + command_str, command_ctr = _walk_commands(commands) + + print(f"\n Commands ({command_ctr}):") + print(command_str) + + +def show_config() -> None: + """ + Prints the currently loaded config. + """ + if (p := os.getenv("GALLIA_CONFIG")) is not None: + print(f"path to config set by env variable: {p}", file=sys.stderr) + + config, config_path = load_config_file() + + if config_path is not None: + print(f"loaded config: {config_path}", file=sys.stderr) + pprint(config) + else: + print("no config available", file=sys.stderr) sys.exit(exitcodes.CONFIG) - build_cli(parsers, config, registry) - parser = parsers["parser"] - return parser, config, config_path +def template() -> None: + """ + Prints a template config with default according to the programmatic defaults. + """ + groups: dict[str, dict[str, tuple[str, Any]]] = {} + for key, value in GalliaBaseModel.registry().items(): + tmp = key.split(".") + group = ".".join(tmp[:-1]) + attribute = tmp[-1] -def main() -> None: - parser, config, config_path = build_parser() - argcomplete.autocomplete(parser) - args = parser.parse_args() + if group not in groups: + groups[group] = {} - if args.show_config: - cmd_show_config(args, config, config_path) - sys.exit(exitcodes.OK) + groups[group][attribute] = value - if args.show_defaults: - cmd_show_defaults(parser) - sys.exit(exitcodes.OK) + output = "" - if args.show_cli: - cmd_show_cli(parser) - sys.exit(exitcodes.OK) + for group in sorted(groups): + if group != "": + output += f"[{group}]\n" - if args.show_plugins: - cmd_show_plugins() - sys.exit(exitcodes.OK) + for attribute in sorted(groups[group]): + description, default_value = groups[group][attribute] - if args.template: - cmd_template(args) - sys.exit(exitcodes.OK) + output += "\n".join(wrap(f"# {description}\n", subsequent_indent="# ")) + "\n" - if not hasattr(args, "cls_object"): - args.help_func() - parser.exit(exitcodes.USAGE) + # Heuristic TOML dump + if default_value is not None and default_value is not PydanticUndefined: + try: + default_repr = json.dumps(default_value) + except TypeError: + default_repr = json.dumps(str(default_value)) - setup_logging( - level=get_log_level(args), - no_volatile_info=args.no_volatile_info if hasattr(args, "no_volatile_info") else True, - ) + output += f"{attribute} = {default_repr}\n" + else: + output += f"# {attribute} = ...\n" + + output += "\n" + + output += "\n" - sys.exit(args.cls_object.entry_point(args)) + print(output.strip()) if __name__ == "__main__": diff --git a/src/gallia/command/base.py b/src/gallia/command/base.py index dcccc5d6c..1b7bbb1bb 100644 --- a/src/gallia/command/base.py +++ b/src/gallia/command/base.py @@ -2,35 +2,35 @@ # # SPDX-License-Identifier: Apache-2.0 -import argparse import asyncio +import json import os import os.path import shutil import signal import sys from abc import ABC, abstractmethod -from argparse import ArgumentParser, Namespace +from collections.abc import MutableMapping from datetime import UTC, datetime from enum import Enum, unique from logging import Handler from pathlib import Path from subprocess import CalledProcessError, run from tempfile import gettempdir -from typing import Protocol, cast +from typing import Any, Protocol, Self, cast import msgspec +from pydantic import ConfigDict, field_serializer, model_validator from gallia import exitcodes -from gallia.config import Config +from gallia.command.config import Field, GalliaBaseModel, Idempotent from gallia.db.handler import DBHandler from gallia.dumpcap import Dumpcap from gallia.log import add_zst_log_handler, get_logger, tz -from gallia.plugins import load_transport from gallia.powersupply import PowerSupply, PowerSupplyURI from gallia.services.uds.core.exception import UDSException from gallia.transports import BaseTransport, TargetURI -from gallia.utils import camel_to_snake, dump_args, get_file_log_level +from gallia.utils import camel_to_snake, get_file_log_level @unique @@ -48,21 +48,12 @@ class HookVariant(Enum): POST = "post" -class CommandMeta(msgspec.Struct): - command: str | None - group: str | None - subgroup: str | None - - def json(self) -> str: - return msgspec.json.encode(self).decode() - - class RunMeta(msgspec.Struct): - command: list[str] - command_meta: CommandMeta + command: str start_time: str end_time: str exit_code: int + config: MutableMapping[str, Any] def json(self) -> str: return msgspec.json.encode(self).decode() @@ -118,6 +109,44 @@ def _release_flock(self) -> None: pass +class BaseCommandConfig(GalliaBaseModel, cli_group="generic", config_section="gallia"): + model_config = ConfigDict(arbitrary_types_allowed=True) + + verbose: int = Field(0, description="increase verbosity on the console", short="v") + volatile_info: bool = Field( + True, description="Overwrite log lines with level info or lower in terminal output" + ) + trace_log: bool = Field(False, description="set the loglevel of the logfile to TRACE") + pre_hook: str | None = Field( + None, + description="shell script to run before the main entry_point", + metavar="SCRIPT", + config_section="gallia.hooks", + ) + post_hook: str | None = Field( + None, + description="shell script to run after the main entry_point", + metavar="SCRIPT", + config_section="gallia.hooks", + ) + hooks: bool = Field( + True, description="execute pre and post hooks", config_section="gallia.hooks" + ) + lock_file: Path | None = Field( + None, description="path to file used for a posix lock", metavar="PATH" + ) + db: Path | None = Field(None, description="Path to sqlite3 database") + artifacts_dir: Path | None = Field( + None, description="Folder for artifacts", metavar="DIR", config_section="gallia.scanner" + ) + artifacts_base: Path = Field( + Path(gettempdir()).joinpath("gallia"), + description="Base directory for artifacts", + metavar="DIR", + config_section="gallia.scanner", + ) + + class BaseCommand(FlockMixin, ABC): """BaseCommand is the baseclass for all gallia commands. This class can be used in standalone scripts via the @@ -132,12 +161,10 @@ class BaseCommand(FlockMixin, ABC): The main entry_point is :meth:`entry_point()`. """ - #: The command name when used in the gallia CLI. - COMMAND: str | None = None - #: The group name when used in the gallia CLI. - GROUP: str | None = None - #: The subgroup name when used in the gallia CLI. - SUBGROUP: str | None = None + # The config type which is accepted by this class + # This is used for automatically creating the CLI + CONFIG_TYPE: type[BaseCommandConfig] = BaseCommandConfig + #: The string which is shown on the cli with --help. SHORT_HELP: str | None = None #: The string which is shown at the bottom of --help. @@ -153,38 +180,26 @@ class BaseCommand(FlockMixin, ABC): log_file_handlers: list[Handler] - def __init__(self, parser: ArgumentParser, config: Config = Config()) -> None: + def __init__(self, config: BaseCommandConfig) -> None: self.id = camel_to_snake(self.__class__.__name__) - self.parser = parser self.config = config self.artifacts_dir = Path() self.run_meta = RunMeta( - command=sys.argv, - command_meta=CommandMeta( - command=self.COMMAND, - group=self.GROUP, - subgroup=self.SUBGROUP, - ), + command=f"{type(self).__module__}.{type(self).__name__}", start_time=datetime.now(tz).isoformat(), exit_code=0, end_time="", + config=json.loads(config.model_dump_json()), ) self._lock_file_fd: int | None = None self.db_handler: DBHandler | None = None - self.configure_class_parser() - self.configure_parser() self.log_file_handlers = [] @abstractmethod - def run(self, args: Namespace) -> int: ... - - def run_hook( - self, - variant: HookVariant, - args: Namespace, - exit_code: int | None = None, - ) -> None: - script = args.pre_hook if variant == HookVariant.PRE else args.post_hook + def run(self) -> int: ... + + def run_hook(self, variant: HookVariant, exit_code: int | None = None) -> None: + script = self.config.pre_hook if variant == HookVariant.PRE else self.config.post_hook if script is None or script == "": return @@ -201,24 +216,11 @@ def run_hook( if variant == HookVariant.POST: env["GALLIA_META"] = self.run_meta.json() - if self.COMMAND is not None: - env["GALLIA_COMMAND"] = self.COMMAND - if self.GROUP is not None: - env["GALLIA_GROUP"] = self.GROUP - if self.SUBGROUP is not None: - env["GALLIA_GROUP"] = self.SUBGROUP if exit_code is not None: env["GALLIA_EXIT_CODE"] = str(exit_code) try: - p = run( - script, - env=env, - text=True, - capture_output=True, - shell=True, - check=True, - ) + p = run(script, env=env, text=True, capture_output=True, shell=True, check=True) stdout = p.stdout stderr = p.stderr except CalledProcessError as e: @@ -231,91 +233,14 @@ def run_hook( if stderr: logger.info(p.stderr.strip(), extra={"tags": [hook_id, "stderr"]}) - def configure_class_parser(self) -> None: - group = self.parser.add_argument_group("generic arguments") - group.add_argument( - "-v", - "--verbose", - action="count", - default=self.config.get_value("gallia.verbosity", 0), - help="increase verbosity on the console", - ) - group.add_argument( - "--no-volatile-info", - action="store_true", - default=self.config.get_value("gallia.no-volatile-info", False), - help="do not overwrite log lines with level info or lower in terminal output", - ) - group.add_argument( - "--trace-log", - action=argparse.BooleanOptionalAction, - default=self.config.get_value("gallia.trace_log", False), - help="set the loglevel of the logfile to TRACE", - ) - group.add_argument( - "--pre-hook", - metavar="SCRIPT", - default=self.config.get_value("gallia.hooks.pre", None), - help="shell script to run before the main entry_point", - ) - group.add_argument( - "--post-hook", - metavar="SCRIPT", - default=self.config.get_value("gallia.hooks.post", None), - help="shell script to run after the main entry_point", - ) - group.add_argument( - "--hooks", - action=argparse.BooleanOptionalAction, - default=self.config.get_value("gallia.hooks.enable", True), - help="execute pre and post hooks", - ) - group.add_argument( - "--lock-file", - type=Path, - metavar="PATH", - default=self.config.get_value("gallia.lock_file", None), - help="path to file used for a posix lock", - ) - group.add_argument( - "--db", - default=self.config.get_value("gallia.db"), - type=Path, - help="Path to sqlite3 database", - ) - - if self.HAS_ARTIFACTS_DIR: - mutex_group = group.add_mutually_exclusive_group() - mutex_group.add_argument( - "--artifacts-dir", - default=self.config.get_value("gallia.scanner.artifacts_dir"), - type=Path, - metavar="DIR", - help="Folder for artifacts", - ) - mutex_group.add_argument( - "--artifacts-base", - default=self.config.get_value( - "gallia.scanner.artifacts_base", - Path(gettempdir()).joinpath("gallia"), - ), - type=Path, - metavar="DIR", - help="Base directory for artifacts", - ) - - def configure_parser(self) -> None: ... - - async def _db_insert_run_meta(self, args: Namespace) -> None: - if args.db is not None: - self.db_handler = DBHandler(args.db) + async def _db_insert_run_meta(self) -> None: + if self.config.db is not None: + self.db_handler = DBHandler(self.config.db) await self.db_handler.connect() await self.db_handler.insert_run_meta( - script=sys.argv[0].split()[-1], - arguments=sys.argv[1:], - command_meta=msgspec.json.encode(self.run_meta.command_meta), - settings=dump_args(args), + script=self.run_meta.command, + config=self.config, start_time=datetime.now(UTC).astimezone(), path=self.artifacts_dir, ) @@ -325,9 +250,7 @@ async def _db_finish_run_meta(self) -> None: if self.db_handler.meta is not None: try: await self.db_handler.complete_run_meta( - datetime.now(UTC).astimezone(), - self.run_meta.exit_code, - self.artifacts_dir, + datetime.now(UTC).astimezone(), self.run_meta.exit_code, self.artifacts_dir ) except Exception as e: logger.warning(f"Could not write the run meta to the database: {e!r}") @@ -362,9 +285,7 @@ def _add_latest_link(self, path: Path) -> None: logger.warn(f"symlink error: {e}") def prepare_artifactsdir( - self, - base_dir: Path | None = None, - force_path: Path | None = None, + self, base_dir: Path | None = None, force_path: Path | None = None ) -> Path: if force_path is not None: if force_path.is_dir(): @@ -374,23 +295,7 @@ def prepare_artifactsdir( return force_path if base_dir is not None: - _command_dir = "" - if self.GROUP is not None: - _command_dir += self.GROUP - if self.SUBGROUP is not None: - _command_dir += f"_{self.SUBGROUP}" - if self.COMMAND is not None: - _command_dir += f"_{self.COMMAND}" - - # When self.GROUP is None, then - # _command_dir starts with "_"; remove it. - if _command_dir.startswith("_"): - _command_dir = _command_dir.removeprefix("_") - - # If self.GROUP, self.SUBGROUP, and - # self.COMMAND are None, then fallback to self.id. - if _command_dir == "": - _command_dir = self.id + _command_dir = self.id command_dir = base_dir.joinpath(_command_dir) @@ -405,8 +310,8 @@ def prepare_artifactsdir( raise ValueError("base_dir or force_path must be different from None") - def entry_point(self, args: Namespace) -> int: - if (p := args.lock_file) is not None: + def entry_point(self) -> int: + if (p := self.config.lock_file) is not None: try: self._lock_file_fd = self._open_lockfile(p) self._aquire_flock() @@ -416,25 +321,24 @@ def entry_point(self, args: Namespace) -> int: if self.HAS_ARTIFACTS_DIR: self.artifacts_dir = self.prepare_artifactsdir( - args.artifacts_base, - args.artifacts_dir, + self.config.artifacts_base, self.config.artifacts_dir ) self.log_file_handlers.append( add_zst_log_handler( logger_name="gallia", filepath=self.artifacts_dir.joinpath(FileNames.LOGFILE.value), - file_log_level=get_file_log_level(args), + file_log_level=get_file_log_level(self.config), ) ) - if args.hooks: - self.run_hook(HookVariant.PRE, args) + if self.config.hooks: + self.run_hook(HookVariant.PRE) - asyncio.run(self._db_insert_run_meta(args)) + asyncio.run(self._db_insert_run_meta()) exit_code = 0 try: - exit_code = self.run(args) + exit_code = self.run() except KeyboardInterrupt: exit_code = 128 + signal.SIGINT # Ensure that META.json gets written in the case a @@ -468,8 +372,8 @@ def entry_point(self, args: Namespace) -> int: ) logger.notice(f"Stored artifacts at {self.artifacts_dir}") - if args.hooks: - self.run_hook(HookVariant.POST, args, exit_code) + if self.config.hooks: + self.run_hook(HookVariant.POST, exit_code) if self._lock_file_fd is not None: self._release_flock() @@ -477,6 +381,15 @@ def entry_point(self, args: Namespace) -> int: return exit_code +class ScriptConfig( + BaseCommandConfig, + ABC, + cli_group=BaseCommandConfig._cli_group, + config_section=BaseCommandConfig._config_section, +): + pass + + class Script(BaseCommand, ABC): """Script is a base class for a synchronous gallia command. To implement a script, create a subclass and implement the @@ -484,23 +397,32 @@ class Script(BaseCommand, ABC): GROUP = "script" - def setup(self, args: Namespace) -> None: ... + def setup(self) -> None: ... @abstractmethod - def main(self, args: Namespace) -> None: ... + def main(self) -> None: ... - def teardown(self, args: Namespace) -> None: ... + def teardown(self) -> None: ... - def run(self, args: Namespace) -> int: - self.setup(args) + def run(self) -> int: + self.setup() try: - self.main(args) + self.main() finally: - self.teardown(args) + self.teardown() return exitcodes.OK +class AsyncScriptConfig( + BaseCommandConfig, + ABC, + cli_group=BaseCommandConfig._cli_group, + config_section=BaseCommandConfig._config_section, +): + pass + + class AsyncScript(BaseCommand, ABC): """AsyncScript is a base class for a asynchronous gallia command. To implement an async script, create a subclass and implement @@ -508,25 +430,60 @@ class AsyncScript(BaseCommand, ABC): GROUP = "script" - async def setup(self, args: Namespace) -> None: ... + async def setup(self) -> None: ... @abstractmethod - async def main(self, args: Namespace) -> None: ... + async def main(self) -> None: ... - async def teardown(self, args: Namespace) -> None: ... + async def teardown(self) -> None: ... - async def _run(self, args: Namespace) -> None: - await self.setup(args) + async def _run(self) -> None: + await self.setup() try: - await self.main(args) + await self.main() finally: - await self.teardown(args) + await self.teardown() - def run(self, args: Namespace) -> int: - asyncio.run(self._run(args)) + def run(self) -> int: + asyncio.run(self._run()) return exitcodes.OK +class ScannerConfig(AsyncScriptConfig, cli_group="scanner", config_section="gallia.scanner"): + dumpcap: bool = Field( + sys.platform.startswith("linux"), description="Enable/Disable creating a pcap file" + ) + target: Idempotent[TargetURI] = Field( + description="URI that describes the target", metavar="TARGET" + ) + power_supply: Idempotent[PowerSupplyURI] | None = Field( + None, + description="URI specifying the location of the relevant opennetzteil server", + metavar="URI", + ) + power_cycle: bool = Field( + False, + description="use the configured power supply to power-cycle the ECU when needed (e.g. before starting the scan, or to recover bad state during scanning)", + ) + power_cycle_sleep: float = Field( + 5.0, description="time to sleep after the power-cycle", metavar="SECs" + ) + + @field_serializer("target", "power_supply") + def serialize_target_uri(self, target_uri: TargetURI | None) -> Any: + if target_uri is None: + return None + + return target_uri.raw + + @model_validator(mode="after") + def check_power_supply_required(self) -> Self: + if self.power_cycle and self.power_supply is None: + raise ValueError("power-cycle needs power-supply") + + return self + + class Scanner(AsyncScript, ABC): """Scanner is a base class for all scanning related commands. A scanner has the following properties: @@ -544,98 +501,44 @@ class Scanner(AsyncScript, ABC): - `main()` is the relevant entry_point for the scanner and must be implemented. """ - GROUP = "scan" HAS_ARTIFACTS_DIR = True - CATCHED_EXCEPTIONS: list[type[Exception]] = [ - ConnectionError, - UDSException, - ] + CATCHED_EXCEPTIONS: list[type[Exception]] = [ConnectionError, UDSException] - def __init__(self, parser: ArgumentParser, config: Config = Config()) -> None: - super().__init__(parser, config) + def __init__(self, config: ScannerConfig): + super().__init__(config) + self.config: ScannerConfig = config self.power_supply: PowerSupply | None = None self.transport: BaseTransport self.dumpcap: Dumpcap | None = None @abstractmethod - async def main(self, args: Namespace) -> None: ... + async def main(self) -> None: ... - async def setup(self, args: Namespace) -> None: - if args.target is None: - self.parser.error("--target is required") + async def setup(self) -> None: + from gallia.plugins.plugin import load_transport - if args.power_supply is not None: - self.power_supply = await PowerSupply.connect(args.power_supply) - if args.power_cycle is True: + if self.config.power_supply is not None: + self.power_supply = await PowerSupply.connect(self.config.power_supply) + if self.config.power_cycle is True: await self.power_supply.power_cycle( - args.power_cycle_sleep, lambda: asyncio.sleep(2) + self.config.power_cycle_sleep, lambda: asyncio.sleep(2) ) - elif args.power_cycle is True: - self.parser.error("--power-cycle needs --power-supply") # Start dumpcap as the first subprocess; otherwise network # traffic might be missing. - if args.dumpcap: + if self.config.dumpcap: if shutil.which("dumpcap") is None: - self.parser.error("--dumpcap specified but `dumpcap` is not available") - self.dumpcap = await Dumpcap.start(args.target, self.artifacts_dir) + raise RuntimeError("--dumpcap specified but `dumpcap` is not available") + self.dumpcap = await Dumpcap.start(self.config.target, self.artifacts_dir) if self.dumpcap is None: logger.error("`dumpcap` could not be started!") else: await self.dumpcap.sync() - self.transport = await load_transport(args.target).connect(args.target) + self.transport = await load_transport(self.config.target).connect(self.config.target) - async def teardown(self, args: Namespace) -> None: + async def teardown(self) -> None: await self.transport.close() if self.dumpcap: await self.dumpcap.stop() - - def configure_class_parser(self) -> None: - super().configure_class_parser() - - group = self.parser.add_argument_group("scanner related arguments") - group.add_argument( - "--dumpcap", - action=argparse.BooleanOptionalAction, - default=self.config.get_value( - "gallia.scanner.dumpcap", - default=sys.platform == "linux", - ), - help="Enable/Disable creating a pcap file", - ) - - group = self.parser.add_argument_group("transport mode related arguments") - group.add_argument( - "--target", - metavar="TARGET", - default=self.config.get_value("gallia.scanner.target"), - type=TargetURI, - help="URI that describes the target", - ) - - group = self.parser.add_argument_group("power supply related arguments") - group.add_argument( - "--power-supply", - metavar="URI", - default=self.config.get_value("gallia.scanner.power_supply"), - type=PowerSupplyURI, - help="URI specifying the location of the relevant opennetzteil server", - ) - group.add_argument( - "--power-cycle", - action=argparse.BooleanOptionalAction, - default=self.config.get_value("gallia.scanner.power_cycle", False), - help=( - "use the configured power supply to power-cycle the ECU when needed " - "(e.g. before starting the scan, or to recover bad state during scanning)" - ), - ) - group.add_argument( - "--power-cycle-sleep", - metavar="SECs", - type=float, - default=self.config.get_value("gallia.scanner.power_cycle_sleep", 5.0), - help="time to sleep after the power-cycle", - ) diff --git a/src/gallia/command/config.py b/src/gallia/command/config.py new file mode 100644 index 000000000..393e29604 --- /dev/null +++ b/src/gallia/command/config.py @@ -0,0 +1,392 @@ +# SPDX-FileCopyrightText: AISEC Pentesting Team +# +# SPDX-License-Identifier: Apache-2.0 + +import binascii +import os +import tomllib +from abc import ABC +from collections.abc import Callable +from enum import Enum +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + TypeAlias, + TypeVar, + Unpack, + get_args, +) + +from pydantic import BeforeValidator +from pydantic.fields import _FromFieldInfoInputs +from pydantic_argparse import BaseCommand +from pydantic_argparse.utils.field import ArgFieldInfo +from pydantic_core import PydanticUndefined + +from gallia.config import Config +from gallia.utils import unravel, unravel_2d + + +def err_int(x: str, base: int) -> int: + try: + return int(x, base) + except ValueError: + base_suffix = "" + + if base != 0: + base_suffix = f" with base {base}" + + raise ValueError( + f"{repr(x)} is not a valid representation for an integer{base_suffix}" + ) from None + + +AutoInt = Annotated[int, BeforeValidator(lambda x: x if isinstance(x, int) else err_int(x, 0))] +""" +Special type for a field, which automatically parses int from a string. + +See int() with base=0 for more information on the syntax. + +Usage: x: HexInt = .... +""" + + +HexInt = Annotated[int, BeforeValidator(lambda x: x if isinstance(x, int) else err_int(x, 16))] +""" +Special type for a field, which parses int from a hex string. + +See int() with base=16 for more information on the syntax. + +Usage: x: HexInt = .... +""" + +HexBytes = Annotated[ + bytes, + BeforeValidator(lambda x: x if isinstance(x, bytes) else binascii.unhexlify(x)), +] +""" +Special type for a field, which parses bytes from hex strings. + +See binascii.unhexlify() for more information on the syntax. + +Usage: x: HexBytes = .... +""" + + +def _process_ranges(value: Any) -> Any: + if isinstance(value, str): + return unravel(",".join(value.split())) + elif isinstance(value, list) and all(isinstance(x, str) for x in value): + return unravel(",".join(value)) + return value + + +Ranges = Annotated[list[int], BeforeValidator(_process_ranges)] +""" +Special type for a field, which parses one-dimensional ranges. + +See unravel() for more information on the syntax. + +Usage: x: Ranges = .... +""" + +Ranges2D = Annotated[ + dict[int, list[int] | None], + BeforeValidator( + lambda x: x + if isinstance(x, dict) + else unravel_2d(" ".join(x)) + if isinstance(x, list) + else unravel_2d(x) + ), +] +""" +Special type for a field, which parses two-dimensional ranges. + +See unravel_2d() for more information on the syntax. + +Usage: x: Ranges2D = .... +""" + + +T = TypeVar("T") +EnumType = TypeVar("EnumType", bound=Enum) +LiteralType = TypeVar("LiteralType") + + +def auto_enum(x: str, enum_type: type[EnumType]) -> EnumType: + try: + return enum_type[x] + except KeyError: + try: + return enum_type(x) + except ValueError: + try: + return enum_type(int(x, 0)) + except ValueError: + pass + + raise ValueError(f"{x} is not a valid key or value for {enum_type}") + + +if TYPE_CHECKING: + Idempotent: TypeAlias = Annotated[T, ""] + EnumArg: TypeAlias = Annotated[EnumType, ""] + AutoLiteral: TypeAlias = Annotated[LiteralType, ""] +else: + + class _TrickType: + def __init__(self, function: Callable[[type[T]], type[T]]): + self.function = function + + def __getitem__(self, cls: type[T]) -> type[T]: + return self.function(cls) + + Idempotent = _TrickType( + lambda cls: Annotated[cls, BeforeValidator(lambda x: x if isinstance(x, cls) else cls(x))] + ) + """ + Wrapper for fields of types which can be instantiated by a certain value, but not by an instance of its own type. + + This way, it is possible to fill the corresponding parameter of the model with both the value as well as an already instantiated object of that class. + + Usage: x: Idempotent[SomeClass] = ... + """ + + EnumArg = _TrickType( + lambda cls: Annotated[ + cls, BeforeValidator(lambda x: x if isinstance(x, cls) else auto_enum(x, cls)) + ] + ) + """ + Wrapper for enum fields to provide automatic parsing of enum values by either name or value similar to AutoInt. + + Usage: x: EnumArg[SomeEnum] = ... + """ + + def auto_literal(cls: type[T]): + args = get_args(cls) + + mapping = {} + + for arg in args: + if isinstance(arg, Enum): + mapping[arg.value] = arg + mapping[arg.name] = arg + elif isinstance(arg, bytes): + mapping[binascii.hexlify(arg)] = arg + + def try_auto_literal(value: Any): + if value in args: + return value + + try: + return mapping[value] + except KeyError: + pass + + try: + return mapping[err_int(value, 0)] + except (KeyError, ValueError): + pass + + return value + + return Annotated[cls, BeforeValidator(try_auto_literal)] + + AutoLiteral = _TrickType(auto_literal) + """ + Wrapper for Literal fields to provide automatic handling of enum, int and bytes parsing for values defined in Literals similar to EnumArg, AutoInt and HexBytes. + + Usage: x: AutoLiteral[Literal[1, 2, 3]] = ... + """ + + +class ConfigArgFieldInfo(ArgFieldInfo): + def __init__( + self, + default: Any, + positional: bool, + short: str | None, + metavar: str | None, + cli_group: str | None, + const: Any, + hidden: bool, + config_section: str | None, + **kwargs: Unpack[_FromFieldInfoInputs], + ): + """ + Creates a new ConfigArgFieldInfo. + + This is a special variant of pydantic's FieldInfo, which adds several arguments, + mainly related to CLI arguments and config sections. + For general usage and details on the generic parameters see https://docs.pydantic.dev/latest/concepts/fields. + Just as with pydantic's FieldInfo, this should usually not be called directly. + Instead use the Field() function of this module. + + :param default: The default value, if non is given explicitly. + :param positional: Specifies, if the argument is shown as positional, as opposed to optional (default), on the CLI. + :param short: An optional alternative name for the CLI, which is auto-prefixed with "-". + :param metavar: The type hint which is shown on the CLI for an argument. If none is specified, it is automatically inferred from its type. + :param cli_group: The group in the CLI under which the argument is listed. If none is specified, it is automatically set to the CLI group of the config class to which this argument belongs. + :param const: Specifies, a default value, if the argument is set with no explicit value. + :param hidden: Specifies, that the argument is part of neither the CLI nor the config file. + :param config_section: Specifies the config section under which the argument is listed. If none is specified, it is automatically set to the config section of the config class to which this argument belongs. + :param kwargs: Generic pydantic Field() arguments (see https://docs.pydantic.dev/latest/api/fields/#pydantic.fields.FieldInfo). + """ + super().__init__( + default=default, + positional=positional, + short=short, + metavar=metavar, + cli_group=cli_group, + const=const, + hidden=hidden, + **kwargs, + ) + + self.config_section = config_section + + +def Field( + default: Any = PydanticUndefined, + positional: bool = False, + short: str | None = None, + metavar: str | None = None, + cli_group: str | None = None, + const: Any = PydanticUndefined, + hidden: bool = False, + config_section: str | None = None, + **kwargs: Unpack[_FromFieldInfoInputs], +) -> Any: + """ + Creates a new ConfigArgFieldInfo. + + This is a special variant of pydantic's Field() function, which adds several arguments, + mainly related to CLI arguments and config sections. + For general usage and details on the generic parameters see https://docs.pydantic.dev/latest/concepts/fields. + + :param default: The default value, if non is given explicitly. + :param positional: Specifies, if the argument is shown as positional, as opposed to optional (default), on the CLI. + :param short: An optional alternative name for the CLI, which is auto-prefixed with "-". + :param metavar: The type hint which is shown on the CLI for an argument. If none is specified, it is automatically inferred from its type. + :param cli_group: The group in the CLI under which the argument is listed. If none is specified, it is automatically set to the CLI group of the config class to which this argument belongs. + :param const: Specifies, a default value, if the argument is set with no explicit value. + :param hidden: Specifies, that the argument is part of neither the CLI nor the config file. + :param config_section: Specifies the config section under which the argument is listed. If none is specified, it is automatically set to the config section of the config class to which this argument belongs. + :param kwargs: Generic pydantic Field() arguments (see https://docs.pydantic.dev/latest/api/fields/#pydantic.fields.Field). + :return: A ConfigArgFieldInfo. + """ + return ConfigArgFieldInfo( + default, positional, short, metavar, cli_group, const, hidden, config_section, **kwargs + ) + + +class GalliaBaseModel(BaseCommand, ABC): + """ + Base class for config classes for commands. + + + """ + + init_kwargs: dict[str, Any] | None = Field( + None, + hidden=True, + description="This allows to initialize parts or all of the fields safely. Required args may be specified explicitly to please the linter", + ) + _cli_group: str | None + _config_section: str | None + __config_registry: dict[str, tuple[str, Any]] + + def __init__(self, **data: Any): + init_kwargs = data.pop("init_kwargs", {}) + + if init_kwargs is None: + init_kwargs = {} + + init_kwargs.update(data) + + super().__init__(**init_kwargs) + + def __init_subclass__( + cls, + /, + cli_group: str | None = None, + config_section: str | None = None, + **kwargs: Any, + ) -> None: + super().__init_subclass__(**kwargs) + + cls._config_section = config_section + cls._cli_group = cli_group + + for attribute, info in vars(cls).items(): + # Attribute specific annotation takes precedence + if isinstance(info, ArgFieldInfo) and info.group is None: + info.group = cli_group + + if isinstance(info, ConfigArgFieldInfo): + # Attribute specific annotation takes precedence + if info.config_section is None: + info.config_section = config_section + + # Add config to registry + if info.config_section is not None: + config_attribute = ( + f"{info.config_section}.{attribute}" + if info.config_section != "" + else attribute + ) + description = "" if info.description is None else info.description + + try: + GalliaBaseModel.__config_registry[config_attribute] = ( + description, + info.default, + ) + except TypeError: + GalliaBaseModel.__config_registry = {} + + GalliaBaseModel.__config_registry[config_attribute] = ( + description, + info.default, + ) + + @staticmethod + def registry() -> dict[str, tuple[str, Any]]: + return GalliaBaseModel.__config_registry + + @classmethod + def attributes_from_toml(cls, path: Path) -> dict[str, Any]: + toml_config = tomllib.loads(path.read_text()) + return cls.attributes_from_config(Config(toml_config)) + + @classmethod + def attributes_from_config(cls, config: Config, source: str = "config file") -> dict[str, Any]: + result = {} + + for name, info in cls.model_fields.items(): + if isinstance(info, ConfigArgFieldInfo): + config_attribute = ( + f"{info.config_section}.{name}" if info.config_section != "" else name + ) + + if (value := config.get_value(config_attribute)) is not None: + result[name] = (f"{source} ({info.config_section}:{name})", value) + + return result + + @classmethod + def attributes_from_env(cls) -> dict[str, Any]: + result = {} + + for name, info in cls.model_fields.items(): + if isinstance(info, ConfigArgFieldInfo): + config_attribute = f"GALLIA_{name.upper()}" + + if (value := os.getenv(config_attribute)) is not None: + result[name] = (f"environment variable ({config_attribute})", value) + + return result diff --git a/src/gallia/command/uds.py b/src/gallia/command/uds.py index ad82dd77c..0a7f881ee 100644 --- a/src/gallia/command/uds.py +++ b/src/gallia/command/uds.py @@ -3,14 +3,15 @@ # SPDX-License-Identifier: Apache-2.0 import json -from argparse import ArgumentParser, BooleanOptionalAction, Namespace +from abc import ABC import aiofiles +from pydantic import field_validator -from gallia.command.base import FileNames, Scanner -from gallia.config import Config +from gallia.command.base import FileNames, Scanner, ScannerConfig +from gallia.command.config import Field from gallia.log import get_logger -from gallia.plugins import load_ecu, load_ecu_plugins +from gallia.plugins.plugin import load_ecu, load_ecus from gallia.services.uds.core.service import NegativeResponse, UDSResponse from gallia.services.uds.ecu import ECU from gallia.services.uds.helpers import raise_for_error @@ -18,7 +19,53 @@ logger = get_logger(__name__) -class UDSScanner(Scanner): +class UDSScannerConfig(ScannerConfig, cli_group="uds", config_section="gallia.protocols.uds"): + ecu_reset: int | None = Field( + None, + description="Trigger an initial ecu_reset via UDS; reset level is optional", + const=0x01, + ) + oem: str = Field( + ECU.OEM, + description="The OEM of the ECU, used to choose a OEM specific ECU implementation", + metavar="OEM", + ) + timeout: float = Field( + 2, description="Timeout value to wait for a response from the ECU", metavar="SECONDS" + ) + max_retries: int = Field( + 3, + description="Number of maximum retries while sending UDS requests. If supported by the transport, this will trigger reconnects if required.", + metavar="INT", + ) + ping: bool = Field(True, description="Enable/Disable initial TesterPresent request") + tester_present_interval: float = Field( + 0.5, + description="Modify the interval of the cyclic tester present packets", + metavar="SECONDS", + ) + tester_present: bool = Field( + True, description="Enable/Disable tester present background worker" + ) + properties: bool = Field( + True, description="Read and store the ECU proporties prior and after scan" + ) + compare_properties: bool = Field( + True, description="Compare properties before and after the scan" + ) + + @field_validator("oem") + @classmethod + def check_oem(cls, v: str) -> str: + ecu_names = [ecu.OEM for ecu in load_ecus()] + + if v not in ecu_names: + raise ValueError(f"Not a valid OEM. Use any of {ecu_names}.") + + return v + + +class UDSScanner(Scanner, ABC): """UDSScanner is a baseclass, particularly for scanning tasks related to the UDS protocol. The differences to Scanner are: @@ -26,80 +73,14 @@ class UDSScanner(Scanner): - A background tasks sends TesterPresent regularly to avoid timeouts. """ - GROUP = "scan" SUBGROUP: str | None = "uds" - def __init__(self, parser: ArgumentParser, config: Config = Config()) -> None: - super().__init__(parser, config) + def __init__(self, config: UDSScannerConfig): + super().__init__(config) + self.config: UDSScannerConfig = config self.ecu: ECU self._implicit_logging = True - def configure_class_parser(self) -> None: - super().configure_class_parser() - - group = self.parser.add_argument_group("UDS scanner related arguments") - - choices = ["default"] + [x.OEM for x in load_ecu_plugins()] - group.add_argument( - "--ecu-reset", - const=0x01, - nargs="?", - default=self.config.get_value("gallia.protocols.uds.ecu_reset"), - help="Trigger an initial ecu_reset via UDS; reset level is optional", - ) - group.add_argument( - "--oem", - default=self.config.get_value("gallia.protocols.uds.oem", "default"), - choices=choices, - metavar="OEM", - help="The OEM of the ECU, used to choose a OEM specific ECU implementation", - ) - group.add_argument( - "--timeout", - default=self.config.get_value("gallia.protocols.uds.timeout", 2), - type=float, - metavar="SECONDS", - help="Timeout value to wait for a response from the ECU", - ) - group.add_argument( - "--max-retries", - default=self.config.get_value("gallia.protocols.uds.max_retries", 3), - type=int, - metavar="INT", - help="Number of maximum retries while sending UDS requests. If supported by the transport, this will trigger reconnects if required.", - ) - group.add_argument( - "--ping", - action=BooleanOptionalAction, - default=self.config.get_value("gallia.protocols.uds.ping", True), - help="Enable/Disable initial TesterPresent request", - ) - group.add_argument( - "--tester-present-interval", - default=self.config.get_value("gallia.protocols.uds.tester_present_interval", 0.5), - type=float, - metavar="SECONDS", - help="Modify the interval of the cyclic tester present packets", - ) - group.add_argument( - "--tester-present", - action=BooleanOptionalAction, - default=self.config.get_value("gallia.protocols.uds.tester_present", True), - help="Enable/Disable tester present background worker", - ) - group.add_argument( - "--properties", - default=self.config.get_value("gallia.protocols.uds.properties", True), - action=BooleanOptionalAction, - help="Read and store the ECU proporties prior and after scan", - ) - group.add_argument( - "--compare-properties", - default=self.config.get_value("gallia.protocols.uds.compare_properties", True), - action=BooleanOptionalAction, - help="Compare properties before and after the scan", - ) - @property def implicit_logging(self) -> bool: return self._implicit_logging @@ -114,13 +95,13 @@ def implicit_logging(self, value: bool) -> None: def _apply_implicit_logging_setting(self) -> None: self.ecu.implicit_logging = self._implicit_logging - async def setup(self, args: Namespace) -> None: - await super().setup(args) + async def setup(self) -> None: + await super().setup() - self.ecu = load_ecu(args.oem)( + self.ecu = load_ecu(self.config.oem)( self.transport, - timeout=args.timeout, - max_retry=args.max_retries, + timeout=self.config.timeout, + max_retry=self.config.max_retries, power_supply=self.power_supply, ) @@ -130,32 +111,32 @@ async def setup(self, args: Namespace) -> None: try: # No idea, but str(args.target) fails with a strange traceback. # Lets use the attribute directly… - await self.db_handler.insert_scan_run(args.target.raw) + await self.db_handler.insert_scan_run(self.config.target.raw) self._apply_implicit_logging_setting() except Exception as e: logger.warning(f"Could not write the scan run to the database: {e:!r}") - if args.ecu_reset is not None: - resp: UDSResponse = await self.ecu.ecu_reset(args.ecu_reset) + if self.config.ecu_reset is not None: + resp: UDSResponse = await self.ecu.ecu_reset(self.config.ecu_reset) if isinstance(resp, NegativeResponse): logger.warning(f"ECUReset failed: {resp}") logger.warning("Switching to default session") raise_for_error(await self.ecu.set_session(0x01)) - resp = await self.ecu.ecu_reset(args.ecu_reset) + resp = await self.ecu.ecu_reset(self.config.ecu_reset) if isinstance(resp, NegativeResponse): logger.warning(f"ECUReset in session 0x01 failed: {resp}") # Handles connecting to the target and waits # until it is ready. - if args.ping: + if self.config.ping: await self.ecu.wait_for_ecu() await self.ecu.connect() - if args.tester_present: - await self.ecu.start_cyclic_tester_present(args.tester_present_interval) + if self.config.tester_present: + await self.ecu.start_cyclic_tester_present(self.config.tester_present_interval) - if args.properties is True: + if self.config.properties is True: path = self.artifacts_dir.joinpath(FileNames.PROPERTIES_PRE.value) async with aiofiles.open(path, "w") as file: await file.write(json.dumps(await self.ecu.properties(True), indent=4)) @@ -164,7 +145,7 @@ async def setup(self, args: Namespace) -> None: if self.db_handler is not None: self._apply_implicit_logging_setting() - if args.properties is True: + if self.config.properties is True: try: await self.db_handler.insert_scan_run_properties_pre( await self.ecu.properties() @@ -172,8 +153,8 @@ async def setup(self, args: Namespace) -> None: except Exception as e: logger.warning(f"Could not write the properties_pre to the database: {e!r}") - async def teardown(self, args: Namespace) -> None: - if args.properties is True and not self.ecu.transport.is_closed: + async def teardown(self) -> None: + if self.config.properties is True and (not self.ecu.transport.is_closed): path = self.artifacts_dir.joinpath(FileNames.PROPERTIES_POST.value) async with aiofiles.open(path, "w") as file: await file.write(json.dumps(await self.ecu.properties(True), indent=4)) @@ -183,40 +164,36 @@ async def teardown(self, args: Namespace) -> None: async with aiofiles.open(path_pre) as file: prop_pre = json.loads(await file.read()) - if args.compare_properties and await self.ecu.properties(False) != prop_pre: + if self.config.compare_properties and await self.ecu.properties(False) != prop_pre: logger.warning("ecu properties differ, please investigate!") - if self.db_handler is not None and args.properties is True: + if self.db_handler is not None and self.config.properties is True: try: await self.db_handler.complete_scan_run(await self.ecu.properties(False)) except Exception as e: logger.warning(f"Could not write the scan run to the database: {e!r}") - if args.tester_present: + if self.config.tester_present: await self.ecu.stop_cyclic_tester_present() # This must be the last one. - await super().teardown(args) + await super().teardown() -class UDSDiscoveryScanner(Scanner): - GROUP = "discover" +class UDSDiscoveryScannerConfig(ScannerConfig): + timeout: float = Field(0.5, description="timeout value for request") - def configure_class_parser(self) -> None: - super().configure_class_parser() - self.parser.add_argument( - "--timeout", - type=float, - default=self.config.get_value("gallia.scanner.timeout", 0.5), - help="timeout value for request", - ) +class UDSDiscoveryScanner(Scanner, ABC): + def __init__(self, config: UDSDiscoveryScannerConfig): + super().__init__(config) + self.config: UDSDiscoveryScannerConfig = config - async def setup(self, args: Namespace) -> None: - await super().setup(args) + async def setup(self) -> None: + await super().setup() if self.db_handler is not None: try: - await self.db_handler.insert_discovery_run(args.target.url.scheme) + await self.db_handler.insert_discovery_run(self.config.target.url.scheme) except Exception as e: logger.warning(f"Could not write the discovery run to the database: {e!r}") diff --git a/src/gallia/commands/__init__.py b/src/gallia/commands/__init__.py index 0f9416594..9ba686f17 100644 --- a/src/gallia/commands/__init__.py +++ b/src/gallia/commands/__init__.py @@ -8,7 +8,11 @@ from gallia.commands.discover.doip import DoIPDiscoverer from gallia.commands.discover.hsfz import HSFZDiscoverer from gallia.commands.primitive.generic.pdu import GenericPDUPrimitive -from gallia.commands.primitive.uds.dtc import DTCPrimitive +from gallia.commands.primitive.uds.dtc import ( + ClearDTCPrimitive, + ControlDTCPrimitive, + ReadDTCPrimitive, +) from gallia.commands.primitive.uds.ecu_reset import ECUResetPrimitive from gallia.commands.primitive.uds.iocbi import IOCBIPrimitive from gallia.commands.primitive.uds.pdu import SendPDUPrimitive @@ -27,7 +31,6 @@ from gallia.commands.scan.uds.sessions import SessionsScanner registry: list[type[BaseCommand]] = [ - DTCPrimitive, DoIPDiscoverer, ECUResetPrimitive, GenericPDUPrimitive, @@ -41,6 +44,18 @@ ResetScanner, SASeedsDumper, ScanIdentifiers, + SessionsScanner, + ServicesScanner, + ClearDTCPrimitive, + ControlDTCPrimitive, + ReadDTCPrimitive, + ECUResetPrimitive, + VINPrimitive, + IOCBIPrimitive, + PingPrimitive, + RMBAPrimitive, + RTCLPrimitive, + GenericPDUPrimitive, SendPDUPrimitive, ServicesScanner, SessionsScanner, @@ -51,7 +66,6 @@ # TODO: Investigate why linters didn't catch faulty strings in here. __all__ = [ - "DTCPrimitive", "DoIPDiscoverer", "ECUResetPrimitive", "GenericPDUPrimitive", @@ -65,6 +79,18 @@ "ResetScanner", "SASeedsDumper", "ScanIdentifiers", + "SessionsScanner", + "ServicesScanner", + "ClearDTCPrimitive", + "ControlDTCPrimitive", + "ReadDTCPrimitive", + "ECUResetPrimitive", + "VINPrimitive", + "IOCBIPrimitive", + "PingPrimitive", + "RMBAPrimitive", + "RTCLPrimitive", + "GenericPDUPrimitive", "SendPDUPrimitive", "ServicesScanner", "SessionsScanner", @@ -75,31 +101,37 @@ if sys.platform.startswith("linux"): - from gallia.commands.discover.find_xcp import FindXCP + from gallia.commands.discover.find_xcp import CanFindXCP, TcpFindXCP, UdpFindXCP from gallia.commands.discover.uds.isotp import IsotpDiscoverer from gallia.commands.fuzz.uds.pdu import PDUFuzzer from gallia.commands.primitive.uds.xcp import SimpleTestXCP - from gallia.commands.script.vecu import VirtualECU + from gallia.commands.script.vecu import DbVirtualECU, RngVirtualECU registry += [ - FindXCP, + CanFindXCP, + UdpFindXCP, + TcpFindXCP, IsotpDiscoverer, PDUFuzzer, SimpleTestXCP, - VirtualECU, + DbVirtualECU, + RngVirtualECU, ] __all__ += [ - "FindXCP", + "CanFindXCP", + "UDSFindXCP", + "TCPFindXCP", "IsotpDiscoverer", "PDUFuzzer", "SimpleTestXCP", - "VirtualECU", + "DbVirtualECU", + "RngVirtualECU", ] if sys.platform == "win32": - from gallia.commands.script.flexray import FRDump, FRDumpConfig + from gallia.commands.script.flexray import FRConfigDump, FRDump - registry += [FRDump, FRDumpConfig] - __all__ += ["FRDump", "FRDumpConfig"] + registry += [FRDump, FRConfigDump] + __all__ += ["FRDump", "FRConfigDump"] diff --git a/src/gallia/commands/discover/doip.py b/src/gallia/commands/discover/doip.py index a655676d3..a1ebb0819 100644 --- a/src/gallia/commands/discover/doip.py +++ b/src/gallia/commands/discover/doip.py @@ -4,7 +4,6 @@ import asyncio import socket -from argparse import Namespace from collections.abc import Iterable from itertools import product from urllib.parse import parse_qs, urlparse @@ -13,11 +12,10 @@ import psutil from gallia.command import AsyncScript +from gallia.command.base import AsyncScriptConfig +from gallia.command.config import AutoInt, Field from gallia.log import get_logger -from gallia.services.uds.core.service import ( - TesterPresentRequest, - TesterPresentResponse, -) +from gallia.services.uds.core.service import TesterPresentRequest, TesterPresentResponse from gallia.transports.doip import ( DiagnosticMessage, DiagnosticMessageNegativeAckCodes, @@ -37,70 +35,58 @@ logger = get_logger(__name__) +class DoIPDiscovererConfig(AsyncScriptConfig): + start: AutoInt = Field( + 0x00, description="Set start address of TargetAddress search range", metavar="INT" + ) + stop: AutoInt = Field( + 0xFFFF, description="Set stop address of TargetAddress search range", metavar="INT" + ) + target: str | None = Field( + None, + description="The more you give, the more automatic detection will be skipped: IP, Port, RoutingActivationType, SourceAddress", + metavar="", + ) + timeout: float | None = Field( + None, + description="This flag overrides the default timeout of DiagnosticMessages, which can be used to fine-tune classification of unresponsive ECUs or broadcast detection", + metavar="SECONDS (FLOAT)", + ) + tcp_connect_delay: float = Field( + 0.0, + description="This flag delays subsequent TCP connect attempts during all enumerations. Useful if the DoIP entity requires some time before accepting new TCP requests.", + metavar="SECONDS (FLOAT)", + ) + + class DoIPDiscoverer(AsyncScript): """This script scans for active DoIP endpoints and automatically enumerates allowed RoutingActivationTypes and known SourceAddresses. Once valid endpoints are acquired, the script continues to discover valid TargetAddresses that are accepted and respond to UDS TesterPresent requests.""" - GROUP = "discover" - COMMAND = "doip" + CONFIG_TYPE = DoIPDiscovererConfig SHORT_HELP = "zero-knowledge DoIP enumeration scanner" HAS_ARTIFACTS_DIR = True protocol_version = ProtocolVersions.ISO_13400_2_2019.value - def configure_parser(self) -> None: - self.parser.add_argument( - "--start", - metavar="INT", - type=lambda x: int(x, 0), - default=0x00, - help="Set start address of TargetAddress search range", - ) - self.parser.add_argument( - "--stop", - metavar="INT", - type=lambda x: int(x, 0), - default=0xFFFF, - help="Set stop address of TargetAddress search range", - ) - self.parser.add_argument( - "--target", - metavar="", - type=str, - default=None, - help="The more you give, the more automatic detection will be skipped: IP, Port, RoutingActivationType, SourceAddress", - ) - self.parser.add_argument( - "--timeout", - metavar="SECONDS (FLOAT)", - type=float, - default=None, - help="This flag overrides the default timeout of DiagnosticMessages, which can be used to fine-tune classification of unresponsive ECUs or broadcast detection", - ) - self.parser.add_argument( - "--tcp-connect-delay", - metavar="SECONDS (FLOAT)", - type=float, - default=0.0, - help=( - "This flag delays subsequent TCP connect attempts during all enumerations. " - "Useful if the DoIP entity requires some time before accepting new TCP requests." - ), - ) + def __init__(self, config: DoIPDiscovererConfig): + super().__init__(config) + self.config: DoIPDiscovererConfig = config # This is an ugly hack to circumvent AsyncScript's shortcomings regarding return codes - def run(self, args: Namespace) -> int: - return asyncio.run(self.main2(args)) - async def main(self, args: Namespace) -> None: + def run(self) -> int: + return asyncio.run(self.main2()) + + async def main(self) -> None: pass - async def main2(self, args: Namespace) -> int: + async def main2(self) -> int: logger.notice("[👋] Welcome to @realDoIP-Discovery powered by MoarMemes…") - target = urlparse(args.target) if args.target is not None else None + target = urlparse(self.config.target) if self.config.target is not None else None if target is not None and target.scheme != "doip": logger.error("[🫣] --target must be doip://…") return 2 @@ -112,12 +98,12 @@ async def main2(self, args: Namespace) -> int: logger.warning(f"Could not write the discovery run to the database: {e!r}") # Set TCP connect delay for RoutingActivationType and SourceAddress enumeration - tcp_connect_delay: float = args.tcp_connect_delay + tcp_connect_delay: float = self.config.tcp_connect_delay # Discover Hostname and Port tgt_hostname: str tgt_port: int - if target is not None and target.hostname is not None and target.port is not None: + if target is not None and target.hostname is not None and (target.port is not None): logger.notice("[📋] Skipping host/port discovery because given by --target") tgt_hostname = target.hostname tgt_port = target.port @@ -180,8 +166,7 @@ async def main2(self, args: Namespace) -> int: if len(targets) != 1: logger.error( - f"[💣] I found {len(targets)} valid RoutingActivationType/SourceAddress tuples, " - "but can only continue with exactly one; choose your weapon with --target!" + f"[💣] I found {len(targets)} valid RoutingActivationType/SourceAddress tuples, but can only continue with exactly one; choose your weapon with --target!" ) return 20 @@ -193,7 +178,7 @@ async def main2(self, args: Namespace) -> int: return 3 logger.notice( - f"[🔍] Enumerating all TargetAddresses from {args.start:#x} to {args.stop:#x}" + f"[🔍] Enumerating all TargetAddresses from {self.config.start:#x} to {self.config.stop:#x}" ) target = urlparse(targets[0]) @@ -205,20 +190,16 @@ async def main2(self, args: Namespace) -> int: tgt_port, tgt_rat, tgt_src, - args.start, - args.stop, + self.config.start, + self.config.stop, tcp_connect_delay, - args.timeout, + self.config.timeout, ) logger.notice("[🛩️] All done, thanks for flying with us!") return 0 - async def gather_doip_details( - self, - tgt_hostname: str, - tgt_port: int, - ) -> None: + async def gather_doip_details(self, tgt_hostname: str, tgt_port: int) -> None: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) sock.setblocking(False) sock.bind(("0.0.0.0", 0)) @@ -258,21 +239,19 @@ async def gather_doip_details( elif hdr.PayloadType == PayloadTypes.DoIPEntityStatusResponse: status = DoIPEntityStatusResponse.unpack(data[8:]) logger.notice( - f"[👏] This DoIP entity is a {status.NodeType.name} with " - f"{status.CurrentlyOpenTCP_DATASockets}/{status.MaximumConcurrentTCP_DATASockets} " - f"concurrent TCP sockets currently open and a maximum data size of {status.MaximumDataSize}." + f"[👏] This DoIP entity is a {status.NodeType.name} with {status.CurrentlyOpenTCP_DATASockets}/{status.MaximumConcurrentTCP_DATASockets} concurrent TCP sockets currently open and a maximum data size of {status.MaximumDataSize}." ) sock.close() - async def enumerate_routing_activation_requests( # noqa: PLR0913 + async def enumerate_routing_activation_requests( self, tgt_hostname: str, tgt_port: int, routing_activation_types: Iterable[int], source_addresses: Iterable[int], tcp_connect_delay: float, - ) -> tuple[list[int], list[int], list[str]]: + ) -> tuple[list[int], list[int], list[str]]: # noqa: PLR0913 rat_not_unsupported: list[int] = [] rat_not_unknown: list[int] = [] targets: list[str] = [] @@ -280,13 +259,14 @@ async def enumerate_routing_activation_requests( # noqa: PLR0913 for routing_activation_type, source_address in product( routing_activation_types, source_addresses ): - try: + try: # Dummy target address, never actually used + # Ensure that connections do not remain in TIME_WAIT conn = await DoIPConnection.connect( tgt_hostname, tgt_port, source_address, - 0xAFFE, # Dummy target address, never actually used - so_linger=True, # Ensure that connections do not remain in TIME_WAIT + 0xAFFE, + so_linger=True, protocol_version=self.protocol_version, ) except OSError as e: @@ -307,7 +287,6 @@ async def enumerate_routing_activation_requests( # noqa: PLR0913 if e.rac_code != RoutingActivationResponseCodes.UnknownSourceAddress: rat_not_unknown.append(source_address) continue - finally: await conn.close() await asyncio.sleep(tcp_connect_delay) @@ -326,9 +305,9 @@ async def enumerate_routing_activation_requests( # noqa: PLR0913 for item in targets: logger.notice(item) - return rat_not_unsupported, rat_not_unknown, targets + return (rat_not_unsupported, rat_not_unknown, targets) - async def enumerate_target_addresses( # noqa: PLR0913 + async def enumerate_target_addresses( self, tgt_hostname: str, tgt_port: int, @@ -338,7 +317,7 @@ async def enumerate_target_addresses( # noqa: PLR0913 stop: int, tcp_connect_delay: float, timeout: None | float = None, - ) -> None: + ) -> None: # noqa: PLR0913 known_targets = [] unreachable_targets = [] responsive_targets = [] @@ -467,22 +446,22 @@ async def enumerate_target_addresses( # noqa: PLR0913 f"[🧭] Check out the content of the log files at {self.artifacts_dir} as well!" ) - async def create_DoIP_conn( # noqa: PLR0913 + async def create_DoIP_conn( self, hostname: str, port: int, routing_activation_type: int, src_addr: int, target_addr: int, - ) -> DoIPConnection: + ) -> DoIPConnection: # noqa: PLR0913 while True: - try: + try: # Ensure that connections do not remain in TIME_WAIT conn = await DoIPConnection.connect( hostname, port, src_addr, target_addr, - so_linger=True, # Ensure that connections do not remain in TIME_WAIT + so_linger=True, protocol_version=self.protocol_version, ) logger.info("[📫] Sending RoutingActivationRequest") @@ -491,7 +470,7 @@ async def create_DoIP_conn( # noqa: PLR0913 ) except Exception as e: # TODO this probably is too broad logger.warning( - f"[🫨] Got me some good errors when it should be working (dis an infinite loop): {e!r}" + f"[\U0001fae8] Got me some good errors when it should be working (dis an infinite loop): {e!r}" ) continue return conn @@ -501,15 +480,15 @@ async def read_diag_request_custom(self, conn: DoIPConnection) -> tuple[int | No hdr, payload = await conn.read_frame() if not isinstance(payload, DiagnosticMessage): logger.warning(f"[🧨] Unexpected DoIP message: {hdr} {payload}") - return None, b"" + return (None, b"") if payload.SourceAddress != conn.target_addr: - return payload.SourceAddress, payload.UserData + return (payload.SourceAddress, payload.UserData) if payload.TargetAddress != conn.src_addr: logger.warning( f"[🤌] You talking to me?! Unexpected DoIP target address: {payload.TargetAddress:#04x}" ) continue - return None, payload.UserData + return (None, payload.UserData) async def run_udp_discovery(self) -> list[tuple[str, int]]: all_ips = [] diff --git a/src/gallia/commands/discover/find_xcp.py b/src/gallia/commands/discover/find_xcp.py index 9d0423e9e..d46d06344 100644 --- a/src/gallia/commands/discover/find_xcp.py +++ b/src/gallia/commands/discover/find_xcp.py @@ -5,103 +5,55 @@ import socket import struct import sys -from argparse import ArgumentParser, Namespace +from abc import ABC assert sys.platform.startswith("linux"), "unsupported platform" from gallia.command import AsyncScript -from gallia.config import Config +from gallia.command.base import AsyncScriptConfig +from gallia.command.config import AutoInt, Field, Ranges from gallia.log import get_logger from gallia.services.uds.core.utils import bytes_repr, g_repr from gallia.transports import RawCANTransport, TargetURI -from gallia.utils import auto_int, can_id_repr +from gallia.utils import can_id_repr logger = get_logger(__name__) -class FindXCP(AsyncScript): - """Find XCP Slave""" +class FindXCPConfig(AsyncScriptConfig): + pass - GROUP = "discover" - COMMAND = "xcp" - SHORT_HELP = "XCP enumeration scanner" - HAS_ARTIFACTS_DIR = True - def __init__(self, parser: ArgumentParser, config: Config = Config()) -> None: - super().__init__(parser, config) - self.socket: socket.socket +class CanFindXCPConfig(FindXCPConfig): + xcp_can_iface: str = Field(description="CAN interface used for XCP communication") + can_fd: bool = Field(False, description="use can FD") + extended: bool = Field(False, description="use extended CAN address space") + sniff_time: int = Field( + 60, description="Time in seconds to sniff on bus for current traffic", metavar="SECONDS" + ) + can_id_start: AutoInt = Field(description="First CAN id to test") + can_id_end: AutoInt = Field(description="Last CAN id to test") - def configure_parser(self) -> None: - subparsers = self.parser.add_subparsers(dest="mode", required=True, help="Transport mode") - sp = subparsers.add_parser("can") - sp.add_argument( - "--xcp-can-iface", - type=str, - default="", - required=True, - help="CAN interface used for XCP communication", - ) - sp.add_argument("--can-fd", action="store_true", default=False, help="use can FD") - sp.add_argument( - "--extended", - action="store_true", - default=False, - help="use extended CAN address space", - ) - sp.add_argument( - "--sniff-time", - default=60, - type=int, - metavar="SECONDS", - help="Time in seconds to sniff on bus for current traffic", - ) - sp.add_argument( - "--can-id-start", - type=auto_int, - default="", - required=True, - help="First CAN id to test", - ) - sp.add_argument( - "--can-id-end", - type=auto_int, - default="", - required=True, - help="Last CAN id to test", - ) +class TcpFindXCPConfig(FindXCPConfig): + xcp_ip: str = Field(description="XCP destination IP Address") + tcp_ports: Ranges = Field(description="List of TCP ports to test for XCP") - sp = subparsers.add_parser("tcp") - sp.add_argument( - "--xcp-ip", - type=str, - default="", - required=True, - help="XCP destination IP Address", - ) - sp.add_argument( - "--tcp-ports", - type=str, - default="", - required=True, - help="Comma separated list of TCP ports to test for XCP", - ) - sp = subparsers.add_parser("udp") - sp.add_argument( - "--xcp-ip", - type=str, - default="", - required=True, - help="XCP destination IP Address", - ) - sp.add_argument( - "--udp-ports", - type=str, - default="", - required=True, - help="Comma separated list of UDP ports to test for XCP", - ) +class UdpFindXCPConfig(FindXCPConfig): + xcp_ip: str = Field(description="XCP destination IP Address") + udp_ports: Ranges = Field(description="List of UDP ports to test for XCP") + + +class FindXCP(AsyncScript, ABC): + """Find XCP Slave""" + + HAS_ARTIFACTS_DIR = True + + def __init__(self, config: FindXCPConfig): + super().__init__(config) + self.config: FindXCPConfig = config + self.socket: socket.socket def pack_xcp_eth(self, data: bytes, ctr: int = 0) -> bytes: length = len(data) @@ -112,28 +64,78 @@ def pack_xcp_eth(self, data: bytes, ctr: int = 0) -> bytes: def unpack_xcp_eth(self, data: bytes) -> tuple[int, int, bytes]: length, ctr = struct.unpack_from(" None: + try: + self.socket.sendto(self.pack_xcp_eth(bytes([0xFE, 0x00]), 1), server) + self.socket.recv(1024) + except Exception: + pass + - async def main(self, args: Namespace) -> None: - if args.mode == "can": - await self.test_can(args) +class CanFindXCP(FindXCP): + CONFIG_TYPE = CanFindXCPConfig + SHORT_HELP = "XCP enumeration scanner for CAN" - elif args.mode == "tcp": - await self.test_tcp(args) + def __init__(self, config: CanFindXCPConfig): + super().__init__(config) + self.config: CanFindXCPConfig = config + + async def main(self) -> None: + target = TargetURI( + f"{RawCANTransport.SCHEME}://{self.config.xcp_can_iface}?is_extended={str(self.config.extended).lower()}" + + ("&is_fd=true" if self.config.can_fd else "") + ) + transport = await RawCANTransport.connect(target) + endpoints = [] - elif args.mode == "udp": - self.test_eth_broadcast(args) - await self.test_udp(args) + sniff_time: int = self.config.sniff_time + logger.result(f"Listening to idle bus communication for {sniff_time}s...") + addr_idle = await transport.get_idle_traffic(sniff_time) + logger.result(f"Found {len(addr_idle)} CAN Addresses on idle Bus") + transport.set_filter(addr_idle, inv_filter=True) + # flush receive queue + await transport.get_idle_traffic(2) - async def test_tcp(self, args: Namespace) -> None: + for can_id in range(self.config.can_id_start, self.config.can_id_end + 1): + logger.info(f"Testing CAN ID: {can_id_repr(can_id)}") + pdu = bytes([0xFF, 0x00]) + await transport.sendto(pdu, can_id, timeout=0.1) + + try: + while True: + master, data = await transport.recvfrom(timeout=0.1) + if data[0] == 0xFF: + msg = f"Found XCP endpoint [master:slave]: CAN: {can_id_repr(master)}:{can_id_repr(can_id)} data: {bytes_repr(data)}" + logger.result(msg) + endpoints.append((can_id, master)) + else: + logger.info( + f"Received non XCP answer for CAN-ID {can_id_repr(can_id)}: {can_id_repr(master)}:{bytes_repr(data)}" + ) + except TimeoutError: + pass + + logger.result(f"Finished; Found {len(endpoints)} XCP endpoints via CAN") + + +class TcpFindXCP(FindXCP): + CONFIG_TYPE = TcpFindXCPConfig + SHORT_HELP = "XCP enumeration scanner for TCP" + + def __init__(self, config: TcpFindXCPConfig): + super().__init__(config) + self.config: TcpFindXCPConfig = config + + async def main(self) -> None: # TODO: rewrite as async data = bytes([0xFF, 0x00]) endpoints = [] - for port in args.tcp_ports.split(","): - port = int(port, 0) # noqa + for port in self.config.tcp_ports: logger.info(f"Testing TCP port: {port}") - server = (args.xcp_ip, port) + server = (self.config.xcp_ip, port) self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.socket.settimeout(0.2) try: @@ -161,24 +163,29 @@ async def test_tcp(self, args: Namespace) -> None: logger.result(f"Finished; Found {len(endpoints)} XCP endpoints via TCP") - def xcp_disconnect(self, server: tuple[str, int]) -> None: - try: - self.socket.sendto(self.pack_xcp_eth(bytes([0xFE, 0x00]), 1), server) - self.socket.recv(1024) - except Exception: - pass - async def test_udp(self, args: Namespace) -> None: +class UdpFindXCP(FindXCP): + CONFIG_TYPE = UdpFindXCPConfig + SHORT_HELP = "XCP enumeration scanner for Udp" + + def __init__(self, config: UdpFindXCPConfig): + super().__init__(config) + self.config: UdpFindXCPConfig = config + + async def main(self) -> None: + self.test_eth_broadcast() + await self.test_udp() + + async def test_udp(self) -> None: # TODO: rewrite as async data = bytes([0xFF, 0x00]) endpoints = [] - for port in args.udp_ports.split(","): - port = int(port, 0) # noqa + for port in self.config.udp_ports: logger.info(f"Testing UDP port: {port}") self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.socket.settimeout(0.5) - server = (args.xcp_ip, port) + server = (self.config.xcp_ip, port) self.socket.sendto(self.pack_xcp_eth(data), server) try: _, _, data_ret = self.unpack_xcp_eth(self.socket.recv(1024)) @@ -198,48 +205,7 @@ async def test_udp(self, args: Namespace) -> None: logger.result(f"Finished; Found {len(endpoints)} XCP endpoints via UDP") - async def test_can(self, args: Namespace) -> None: - target = TargetURI( - f"{RawCANTransport.SCHEME}://{args.xcp_can_iface}?is_extended={str(args.extended).lower()}" - + ("&is_fd=true" if args.can_fd else "") - ) - transport = await RawCANTransport.connect(target) - endpoints = [] - - sniff_time: int = args.sniff_time - logger.result(f"Listening to idle bus communication for {sniff_time}s...") - addr_idle = await transport.get_idle_traffic(sniff_time) - logger.result(f"Found {len(addr_idle)} CAN Addresses on idle Bus") - transport.set_filter(addr_idle, inv_filter=True) - # flush receive queue - await transport.get_idle_traffic(2) - - for can_id in range(args.can_id_start, args.can_id_end + 1): - logger.info(f"Testing CAN ID: {can_id_repr(can_id)}") - pdu = bytes([0xFF, 0x00]) - await transport.sendto(pdu, can_id, timeout=0.1) - - try: - while True: - master, data = await transport.recvfrom(timeout=0.1) - if data[0] == 0xFF: - msg = ( - f"Found XCP endpoint [master:slave]: CAN: {can_id_repr(master)}:{can_id_repr(can_id)} " - f"data: {bytes_repr(data)}" - ) - logger.result(msg) - endpoints.append((can_id, master)) - else: - logger.info( - f"Received non XCP answer for CAN-ID {can_id_repr(can_id)}: {can_id_repr(master)}:" - f"{bytes_repr(data)}" - ) - except TimeoutError: - pass - - logger.result(f"Finished; Found {len(endpoints)} XCP endpoints via CAN") - - def test_eth_broadcast(self, args: Namespace) -> None: + def test_eth_broadcast(self) -> None: # TODO: rewrite as async multicast_group = ("239.255.0.0", 5556) @@ -248,7 +214,7 @@ def test_eth_broadcast(self, args: Namespace) -> None: ) self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - self.socket.connect((args.xcp_ip, 5555)) + self.socket.connect((self.config.xcp_ip, 5555)) addr = self.socket.getsockname()[0] self.socket.close() logger.info(f"xcp interface ip for multicast group: {addr}") diff --git a/src/gallia/commands/discover/hsfz.py b/src/gallia/commands/discover/hsfz.py index a2f802b07..cd96f2514 100644 --- a/src/gallia/commands/discover/hsfz.py +++ b/src/gallia/commands/discover/hsfz.py @@ -3,9 +3,10 @@ # SPDX-License-Identifier: Apache-2.0 import asyncio -from argparse import Namespace from gallia.command import UDSDiscoveryScanner +from gallia.command.config import AutoInt, Field +from gallia.command.uds import UDSDiscoveryScannerConfig from gallia.log import get_logger from gallia.services.uds.core.service import ( DiagnosticSessionControlRequest, @@ -15,50 +16,30 @@ from gallia.services.uds.helpers import raise_for_mismatch from gallia.transports.base import TargetURI from gallia.transports.hsfz import HSFZConnection -from gallia.utils import auto_int, write_target_list +from gallia.utils import write_target_list logger = get_logger(__name__) +class HSFZDiscovererConfig(UDSDiscoveryScannerConfig): + reversed: bool = Field(False, description="scan in reversed order") + src_addr: AutoInt = Field(0xF4, description="HSFZ source address") + start: AutoInt = Field(0x00, description="set start address", metavar="INT") + stop: AutoInt = Field(0xFF, description="set end address", metavar="INT") + + class HSFZDiscoverer(UDSDiscoveryScanner): """ECU and routing discovery scanner for HSFZ""" - COMMAND = "hsfz" SHORT_HELP = "" - def configure_parser(self) -> None: - self.parser.add_argument( - "--reversed", - action="store_true", - help="scan in reversed order", - ) - self.parser.add_argument( - "--src-addr", - type=auto_int, - default=0xF4, - help="HSFZ source address", - ) - self.parser.add_argument( - "--start", - metavar="INT", - type=auto_int, - default=0x00, - help="set start address", - ) - self.parser.add_argument( - "--stop", - metavar="INT", - type=auto_int, - default=0xFF, - help="set end address", - ) + CONFIG_TYPE = HSFZDiscovererConfig - async def _probe( - self, - conn: HSFZConnection, - req: UDSRequest, - timeout: float, - ) -> bool: + def __init__(self, config: HSFZDiscovererConfig): + super().__init__(config) + self.config: HSFZDiscovererConfig = config + + async def _probe(self, conn: HSFZConnection, req: UDSRequest, timeout: float) -> bool: data = req.pdu result = False @@ -88,13 +69,7 @@ async def probe( req = DiagnosticSessionControlRequest(0x01) try: - conn = await HSFZConnection.connect( - host, - port, - src_addr, - dst_addr, - ack_timeout, - ) + conn = await HSFZConnection.connect(host, port, src_addr, dst_addr, ack_timeout) except TimeoutError: return None @@ -118,21 +93,29 @@ async def probe( ) return None - async def main(self, args: Namespace) -> None: + async def main(self) -> None: found = [] gen = ( - range(args.stop + 1, args.start) if args.reversed else range(args.start, args.stop + 1) + range(self.config.stop + 1, self.config.start) + if self.config.reversed + else range(self.config.start, self.config.stop + 1) ) for dst_addr in gen: logger.info(f"testing target {dst_addr:#02x}") + hostname = self.config.target.hostname + port = self.config.target.port + + assert hostname is not None + assert port is not None + target = await self.probe( - args.target.hostname, - args.target.port, - args.src_addr, + hostname, + port, + self.config.src_addr, dst_addr, - args.timeout, + self.config.timeout, ) if target is not None: diff --git a/src/gallia/commands/discover/uds/isotp.py b/src/gallia/commands/discover/uds/isotp.py index 86dc78b56..c7f4914b6 100644 --- a/src/gallia/commands/discover/uds/isotp.py +++ b/src/gallia/commands/discover/uds/isotp.py @@ -4,21 +4,48 @@ import asyncio import sys -from argparse import Namespace -from binascii import unhexlify +from typing import Self + +from pydantic import model_validator assert sys.platform.startswith("linux"), "unsupported platform" from gallia.command import UDSDiscoveryScanner +from gallia.command.config import AutoInt, Field, HexBytes +from gallia.command.uds import UDSDiscoveryScannerConfig from gallia.log import get_logger from gallia.services.uds import NegativeResponse, UDSClient, UDSRequest from gallia.services.uds.core.utils import g_repr from gallia.transports import ISOTPTransport, RawCANTransport, TargetURI -from gallia.utils import auto_int, can_id_repr, write_target_list +from gallia.utils import can_id_repr, write_target_list logger = get_logger(__name__) +class IsotpDiscovererConfig(UDSDiscoveryScannerConfig): + start: AutoInt = Field(description="set start address", metavar="INT") + stop: AutoInt = Field(description="set end address", metavar="INT") + padding: AutoInt | None = Field(None, description="set isotp padding") + pdu: HexBytes = Field(bytes([0x3E, 0x00]), description="set pdu used for discovery") + sleep: float = Field(0.01, description="set sleeptime between loop iterations") + extended_addr: bool = Field(False, description="use extended isotp addresses") + tester_addr: AutoInt = Field(0x6F1, description="tester address for --extended") + query: bool = Field(False, description="query ECU description via RDBID") + info_did: AutoInt = Field(0xF197, description="DID to query ECU description", metavar="DID") + sniff_time: int = Field( + 5, description="Time in seconds to sniff on bus for current traffic", metavar="SECONDS" + ) + + @model_validator(mode="after") + def check_transport_requirements(self) -> Self: + if self.target is not None and (not self.target.scheme == RawCANTransport.SCHEME): + raise ValueError(f"Unsupported transport schema {self.target.scheme}; must be can-raw!") + if self.extended_addr and (self.start > 0xFF or self.stop > 0xFF): + raise ValueError("start/stop maximum value is 0xFF") + + return self + + class IsotpDiscoverer(UDSDiscoveryScanner): """Discovers all UDS endpoints on an ECU using ISO-TP normal addressing. This is the default protocol used by OBD. @@ -26,82 +53,12 @@ class IsotpDiscoverer(UDSDiscoveryScanner): Addressing is only done via CAN IDs. Every endpoint has a source and destination CAN ID. Typically, there is also a broadcast destination ID to address all endpoints.""" - SUBGROUP = "uds" - COMMAND = "isotp" + CONFIG_TYPE = IsotpDiscovererConfig SHORT_HELP = "ISO-TP enumeration scanner" - def configure_parser(self) -> None: - self.parser.add_argument( - "--start", - metavar="INT", - type=auto_int, - required=True, - help="set start address", - ) - self.parser.add_argument( - "--stop", - metavar="INT", - type=auto_int, - required=True, - help="set end address", - ) - self.parser.add_argument( - "--padding", - type=auto_int, - default=None, - help="set isotp padding", - ) - self.parser.add_argument( - "--pdu", - type=unhexlify, - default=bytes([0x3E, 0x00]), - help="set pdu used for discovery", - ) - self.parser.add_argument( - "--sleep", - type=float, - default=0.01, - help="set sleeptime between loop iterations", - ) - self.parser.add_argument( - "--extended-addr", - action="store_true", - help="use extended isotp addresses", - ) - self.parser.add_argument( - "--tester-addr", - type=auto_int, - default=0x6F1, - help="tester address for --extended", - ) - self.parser.add_argument( - "--query", - action="store_true", - help="query ECU description via RDBID", - ) - self.parser.add_argument( - "--info-did", - metavar="DID", - type=auto_int, - default=0xF197, - help="DID to query ECU description", - ) - self.parser.add_argument( - "--sniff-time", - default=5, - type=int, - metavar="SECONDS", - help="Time in seconds to sniff on bus for current traffic", - ) - - async def setup(self, args: Namespace) -> None: - if args.target is not None and not args.target.scheme == RawCANTransport.SCHEME: - self.parser.error( - f"Unsupported transport schema {args.target.scheme}; must be can-raw!" - ) - if args.extended_addr and (args.start > 0xFF or args.stop > 0xFF): - self.parser.error("--start/--stop maximum value is 0xFF") - await super().setup(args) + def __init__(self, config: IsotpDiscovererConfig): + super().__init__(config) + self.config: IsotpDiscovererConfig = config async def query_description(self, target_list: list[TargetURI], did: int) -> None: logger.info("reading info DID from all discovered endpoints") @@ -121,11 +78,7 @@ async def query_description(self, target_list: list[TargetURI], did: int) -> Non except Exception as e: logger.result(f"reading description failed: {e!r}") - def _build_isotp_frame_extended( - self, - pdu: bytes, - ext_addr: int, - ) -> bytes: + def _build_isotp_frame_extended(self, pdu: bytes, ext_addr: int) -> bytes: isotp_hdr = bytes([ext_addr, len(pdu) & 0x0F]) return isotp_hdr + pdu @@ -134,10 +87,7 @@ def _build_isotp_frame(self, pdu: bytes) -> bytes: return isotp_hdr + pdu def build_isotp_frame( - self, - req: UDSRequest, - ext_addr: int | None = None, - padding: int | None = None, + self, req: UDSRequest, ext_addr: int | None = None, padding: int | None = None ) -> bytes: pdu = req.pdu max_pdu_len = 7 if ext_addr is None else 6 @@ -155,26 +105,26 @@ def build_isotp_frame( return frame - async def main(self, args: Namespace) -> None: - transport = await RawCANTransport.connect(args.target) + async def main(self) -> None: + transport = await RawCANTransport.connect(self.config.target) found = [] - sniff_time: int = args.sniff_time + sniff_time: int = self.config.sniff_time logger.result(f"Recording idle bus communication for {sniff_time}s") addr_idle = await transport.get_idle_traffic(sniff_time) logger.result(f"Found {len(addr_idle)} CAN Addresses on idle Bus") transport.set_filter(addr_idle, inv_filter=True) - req = UDSRequest.parse_dynamic(args.pdu) - pdu = self.build_isotp_frame(req, padding=args.padding) + req = UDSRequest.parse_dynamic(self.config.pdu) + pdu = self.build_isotp_frame(req, padding=self.config.padding) - for ID in range(args.start, args.stop + 1): - await asyncio.sleep(args.sleep) + for ID in range(self.config.start, self.config.stop + 1): + await asyncio.sleep(self.config.sleep) - dst_addr = args.tester_addr if args.extended_addr else ID - if args.extended_addr: - pdu = self.build_isotp_frame(req, ID, padding=args.padding) + dst_addr = self.config.tester_addr if self.config.extended_addr else ID + if self.config.extended_addr: + pdu = self.build_isotp_frame(req, ID, padding=self.config.padding) logger.info(f"Testing ID {can_id_repr(ID)}") is_broadcast = False @@ -196,8 +146,7 @@ async def main(self, args: Namespace) -> None: if new_addr != addr: is_broadcast = True logger.result( - f"seems that broadcast was triggered on CAN ID {can_id_repr(ID)}, " - f"got answer from {can_id_repr(new_addr)}" + f"seems that broadcast was triggered on CAN ID {can_id_repr(ID)}, got answer from {can_id_repr(new_addr)}" ) else: logger.info( @@ -206,8 +155,7 @@ async def main(self, args: Namespace) -> None: except TimeoutError: if is_broadcast: logger.result( - f"seems that broadcast was triggered on CAN ID {can_id_repr(ID)}, " - f"got answer from {can_id_repr(addr)}" + f"seems that broadcast was triggered on CAN ID {can_id_repr(ID)}, got answer from {can_id_repr(addr)}" ) else: logger.result( @@ -217,25 +165,25 @@ async def main(self, args: Namespace) -> None: target_args["is_fd"] = str(transport.config.is_fd).lower() target_args["is_extended"] = str(transport.config.is_extended).lower() - if args.extended_addr: + if self.config.extended_addr: target_args["ext_address"] = hex(ID) - target_args["rx_ext_address"] = hex(args.tester_addr & 0xFF) - target_args["src_addr"] = hex(args.tester_addr) + target_args["rx_ext_address"] = hex(self.config.tester_addr & 0xFF) + target_args["src_addr"] = hex(self.config.tester_addr) target_args["dst_addr"] = hex(addr) else: target_args["src_addr"] = hex(ID) target_args["dst_addr"] = hex(addr) - if args.padding is not None: - target_args["tx_padding"] = f"{args.padding}" - if args.padding is not None: - target_args["rx_padding"] = f"{args.padding}" + if self.config.padding is not None: + target_args["tx_padding"] = f"{self.config.padding}" + if self.config.padding is not None: + target_args["rx_padding"] = f"{self.config.padding}" + + hostname = self.config.target.hostname + assert hostname is not None target = TargetURI.from_parts( - ISOTPTransport.SCHEME, - args.target.hostname, - None, - target_args, + ISOTPTransport.SCHEME, hostname, None, target_args ) found.append(target) break @@ -245,5 +193,5 @@ async def main(self, args: Namespace) -> None: logger.result(f"Writing urls to file: {ecus_file}") await write_target_list(ecus_file, found, self.db_handler) - if args.query: - await self.query_description(found, args.info_did) + if self.config.query: + await self.query_description(found, self.config.info_did) diff --git a/src/gallia/commands/fuzz/uds/pdu.py b/src/gallia/commands/fuzz/uds/pdu.py index a61250181..d796e24a8 100644 --- a/src/gallia/commands/fuzz/uds/pdu.py +++ b/src/gallia/commands/fuzz/uds/pdu.py @@ -3,14 +3,15 @@ # SPDX-License-Identifier: Apache-2.0 import asyncio -import binascii import random import sys -from argparse import Namespace +from typing import Literal assert sys.platform.startswith("linux"), "unsupported platform" from gallia.command import UDSScanner +from gallia.command.config import AutoInt, AutoLiteral, Field, HexBytes, Ranges +from gallia.command.uds import UDSScannerConfig from gallia.log import get_logger from gallia.services.uds.core.client import UDSRequestConfig from gallia.services.uds.core.constants import UDSErrorCodes, UDSIsoServices @@ -18,83 +19,44 @@ from gallia.services.uds.core.service import NegativeResponse, UDSResponse from gallia.services.uds.helpers import suggests_identifier_not_supported from gallia.transports import RawCANTransport, TargetURI -from gallia.utils import auto_int, handle_task_error, set_task_handler_ctx_variable +from gallia.utils import handle_task_error, set_task_handler_ctx_variable logger = get_logger(__name__) +class PDUFuzzerConfig(UDSScannerConfig): + sessions: Ranges = Field([1], description="Set list of sessions to be tested; 0x01 if None") + service: AutoLiteral[ + Literal[UDSIsoServices.WriteDataByIdentifier, UDSIsoServices.RoutineControl] + ] = Field( + UDSIsoServices.WriteDataByIdentifier, + description="Service ID to create payload for; defaults to 0x2e WriteDataByIdentifier;\ncurrently supported:\n0x2e WriteDataByIdentifier, 0x31 RoutineControl (startRoutine)\n", + ) + max_length: AutoInt = Field(42, description="maximum length of the payload") + min_length: AutoInt = Field(1, description="minimum length of the payload") + iterations: AutoInt = Field(1, description="number of iterations", short="i") + dids: Ranges = Field(description="data identifiers to fuzz") + prefixed_payload: HexBytes = Field( + b"", description="static payload, which precedes the fuzzed payload", metavar="HEXSTRING" + ) + observe_can_ids: Ranges = Field([], description="can ids to observe while fuzzing") + + class PDUFuzzer(UDSScanner): """Payload fuzzer""" - GROUP = "fuzz" - COMMAND = "pdu" + CONFIG_TYPE = PDUFuzzerConfig SHORT_HELP = "fuzz the UDS pdu of selected services" - def configure_parser(self) -> None: - self.parser.add_argument( - "--sessions", - type=auto_int, - default=[1], - nargs="*", - help="Set list of sessions to be tested; 0x01 if None", - ) - self.parser.add_argument( - "--serviceid", - type=auto_int, - default=0x2E, - choices=[0x2E, 0x31], - help=""" - Service ID to create payload for; defaults to 0x2e WriteDataByIdentifier; - currently supported: - 0x2e WriteDataByIdentifier, 0x31 RoutineControl (startRoutine) - """, - ) - self.parser.add_argument( - "--max-length", - type=auto_int, - default=42, - help="maximum length of the payload", - ) - self.parser.add_argument( - "--min-length", - type=auto_int, - default=1, - help="minimum length of the payload", - ) - self.parser.add_argument( - "-i", - "--iterations", - type=auto_int, - default=1, - help="number of iterations", - ) - self.parser.add_argument( - "--dids", - type=auto_int, - nargs="*", - required=True, - help="data identifiers to fuzz", - ) - self.parser.add_argument( - "--prefixed-payload", - metavar="HEXSTRING", - type=binascii.unhexlify, - default=b"", - help="static payload, which precedes the fuzzed payload", - ) - self.parser.add_argument( - "--observe-can-ids", - type=auto_int, - default=[], - nargs="*", - help="can ids to observe while fuzzing", - ) - - def generate_payload(self, args: Namespace) -> bytes: - return random.randbytes(random.randint(args.min_length, args.max_length)) - - async def observe_can_messages(self, can_ids: list[int], args: Namespace) -> None: - can_url = args.target.url._replace(scheme=RawCANTransport.SCHEME) + def __init__(self, config: PDUFuzzerConfig): + super().__init__(config) + self.config: PDUFuzzerConfig = config + + def generate_payload(self) -> bytes: + return random.randbytes(random.randint(self.config.min_length, self.config.max_length)) + + async def observe_can_messages(self, can_ids: list[int]) -> None: + can_url = self.config.target.url._replace(scheme=RawCANTransport.SCHEME) transport = await RawCANTransport.connect(TargetURI(can_url.geturl())) transport.set_filter(can_ids, inv_filter=False) @@ -117,22 +79,22 @@ async def observe_can_messages(self, can_ids: list[int], args: Namespace) -> Non except asyncio.CancelledError: logger.debug("Can message observer task cancelled") - async def main(self, args: Namespace) -> None: - if args.observe_can_ids: - recv_task = asyncio.create_task(self.observe_can_messages(args.observe_can_ids, args)) + async def main(self) -> None: + if len(self.config.observe_can_ids) > 0: + recv_task = asyncio.create_task(self.observe_can_messages(self.config.observe_can_ids)) recv_task.add_done_callback( handle_task_error, context=set_task_handler_ctx_variable(__name__, "ReceiveTask"), ) - logger.info(f"testing sessions {args.sessions}") + logger.info(f"testing sessions {self.config.sessions}") - for did in args.dids: - if args.serviceid == UDSIsoServices.RoutineControl: - pdu = bytes([args.serviceid, 0x01, did >> 8, did & 0xFF]) - elif args.serviceid == UDSIsoServices.WriteDataByIdentifier: - pdu = bytes([args.serviceid, did >> 8, did & 0xFF]) - for session in args.sessions: + for did in self.config.dids: + if self.config.service == UDSIsoServices.RoutineControl: + pdu = bytes([self.config.service.value, 0x01, did >> 8, did & 0xFF]) + elif self.config.service == UDSIsoServices.WriteDataByIdentifier: + pdu = bytes([self.config.service.value, did >> 8, did & 0xFF]) + for session in self.config.sessions: logger.notice(f"Switching to session 0x{session:02x}") resp: UDSResponse = await self.ecu.set_session(session) if isinstance(resp, NegativeResponse): @@ -146,18 +108,16 @@ async def main(self, args: Namespace) -> None: illegal_resp = 0 flow_control_miss = 0 - for _ in range(args.iterations): - payload = args.prefixed_payload + self.generate_payload(args) + for _ in range(self.config.iterations): + payload = self.config.prefixed_payload + self.generate_payload() try: resp = await self.ecu.send_raw( - pdu + payload, - config=UDSRequestConfig(tags=["ANALYZE"], max_retry=3), + pdu + payload, config=UDSRequestConfig(tags=["ANALYZE"], max_retry=3) ) if isinstance(resp, NegativeResponse): if not suggests_identifier_not_supported(resp): logger.result(f"0x{did:0x}: {resp}") - else: logger.info(f"0x{did:0x}: {resp}") if resp.response_code in negative_responses: @@ -169,7 +129,7 @@ async def main(self, args: Namespace) -> None: positive_DIDs += 1 except TimeoutError: - logger.warning(f"0x{did :0x}: Retries exceeded") + logger.warning(f"0x{did:0x}: Retries exceeded") timeout_DIDs += 1 except IllegalResponse as e: logger.warning(f"{repr(e)}") @@ -192,8 +152,8 @@ async def main(self, args: Namespace) -> None: logger.result(f"Flow control frames missing: {flow_control_miss}") logger.info(f"Leaving session 0x{session:02x} via hook") - await self.ecu.leave_session(session, sleep=args.power_cycle_sleep) + await self.ecu.leave_session(session, sleep=self.config.power_cycle_sleep) - if args.observe_can_ids: + if len(self.config.observe_can_ids) > 0: recv_task.cancel() await recv_task diff --git a/src/gallia/commands/primitive/generic/pdu.py b/src/gallia/commands/primitive/generic/pdu.py index d9638d0f8..84de4e5b3 100644 --- a/src/gallia/commands/primitive/generic/pdu.py +++ b/src/gallia/commands/primitive/generic/pdu.py @@ -2,28 +2,32 @@ # # SPDX-License-Identifier: Apache-2.0 -import binascii -from argparse import Namespace from gallia.command import Scanner +from gallia.command.base import ScannerConfig +from gallia.command.config import Field, HexBytes +from gallia.command.uds import UDSScannerConfig + + +class GenericPDUPrimitiveConfig(ScannerConfig): + properties: bool = Field( + False, + description="Read and store the ECU proporties prior and after scan", + cli_group=UDSScannerConfig._cli_group, + config_section=UDSScannerConfig._config_section, + ) + pdu: HexBytes = Field(description="raw pdu to send", positional=True) class GenericPDUPrimitive(Scanner): """A raw scanner to send a plain pdu""" - GROUP = "primitive" - SUBGROUP = "generic" - COMMAND = "pdu" + CONFIG_TYPE = GenericPDUPrimitiveConfig SHORT_HELP = "send a plain PDU" - def configure_parser(self) -> None: - self.parser.set_defaults(properties=False) - - self.parser.add_argument( - "pdu", - type=binascii.unhexlify, - help="raw pdu to send", - ) + def __init__(self, config: GenericPDUPrimitiveConfig): + super().__init__(config) + self.config: GenericPDUPrimitiveConfig = config - async def main(self, args: Namespace) -> None: - await self.transport.write(args.pdu) + async def main(self) -> None: + await self.transport.write(self.config.pdu) diff --git a/src/gallia/commands/primitive/uds/dtc.py b/src/gallia/commands/primitive/uds/dtc.py index 91df3ad64..37c560d3d 100644 --- a/src/gallia/commands/primitive/uds/dtc.py +++ b/src/gallia/commands/primitive/uds/dtc.py @@ -3,12 +3,12 @@ # SPDX-License-Identifier: Apache-2.0 import sys -from argparse import Namespace -from functools import partial from tabulate import tabulate from gallia.command import UDSScanner +from gallia.command.config import AutoInt, Field, HexInt +from gallia.command.uds import UDSScannerConfig from gallia.log import get_logger from gallia.services.uds.core.constants import ( CDTCSSubFuncs, @@ -17,78 +17,62 @@ ) from gallia.services.uds.core.service import NegativeResponse from gallia.services.uds.core.utils import g_repr -from gallia.utils import auto_int logger = get_logger(__name__) -class DTCPrimitive(UDSScanner): - """Read out the Diagnostic Troube Codes (DTC)""" - - GROUP = "primitive" - COMMAND = "dtc" - SHORT_HELP = "DiagnosticTroubleCodes" - - def configure_parser(self) -> None: - self.parser.set_defaults(properties=False) - - self.parser.add_argument( - "--session", - default=DiagnosticSessionControlSubFuncs.defaultSession.value, - type=auto_int, - help="Session to perform test in", - ) - sub_parser = self.parser.add_subparsers(dest="cmd", required=True) - read_parser = sub_parser.add_parser( - "read", help="Read the DTCs using the ReadDTCInformation service" - ) - read_parser.add_argument( - "--mask", - type=partial(int, base=16), - default=0xFF, - help="The bitmask which is sent to the ECU in order to select the relevant DTCs according to their " - "error state. By default, all error codes are returned (c.f. ISO 14229-1,D.2).", - ) - read_parser.add_argument( - "--show-legend", - action="store_true", - help="Show the legend of the bit interpretation according to ISO 14229-1,D.2", - ) - read_parser.add_argument( - "--show-failed", - action="store_true", - help="Show a summary of the codes which failed", - ) - read_parser.add_argument( - "--show-uncompleted", - action="store_true", - help="Show a summary of the codes which have not completed", - ) - clear_parser = sub_parser.add_parser( - "clear", help="Clear the DTCs using the ClearDiagnosticInformation service" - ) - clear_parser.add_argument( - "--group-of-dtc", - type=int, - default=0xFFFFFF, - help="Only clear a particular DTC or the DTCs belonging to the given group. " - "By default, all error codes are cleared.", - ) - control_parser = sub_parser.add_parser( - "control", - help="Stop or resume the setting of DTCs using the " "ControlDTCSetting service", - ) - control_group = control_parser.add_mutually_exclusive_group(required=True) - control_group.add_argument( - "--stop", - action="store_true", - help="Stop the setting of DTCs. If already disabled, this has no effect.", - ) - control_group.add_argument( - "--resume", - action="store_true", - help="Resume the setting of DTCs. If already enabled, this has no effect.", - ) +class DTCPrimitiveConfig(UDSScannerConfig): + properties: bool = Field( + False, + description="Read and store the ECU proporties prior and after scan", + cli_group=UDSScannerConfig._cli_group, + config_section=UDSScannerConfig._config_section, + ) + session: AutoInt = Field( + DiagnosticSessionControlSubFuncs.defaultSession.value, + description="Session to perform test in", + ) + + +class ReadDTCPrimitiveConfig(DTCPrimitiveConfig): + mask: HexInt = Field( + 0xFF, + description="The bitmask which is sent to the ECU in order to select the relevant DTCs according to their error state. By default, all error codes are returned (c.f. ISO 14229-1,D.2).", + ) + show_legend: bool = Field( + False, description="Show the legend of the bit interpretation according to ISO 14229-1,D.2" + ) + show_failed: bool = Field(False, description="Show a summary of the codes which failed") + show_uncompleted: bool = Field( + False, description="Show a summary of the codes which have not completed" + ) + + +class ClearDTCPrimitiveConfig(DTCPrimitiveConfig): + group_of_dtc: int = Field( + 0xFFFFFF, + description="Only clear a particular DTC or the DTCs belonging to the given group. By default, all error codes are cleared.", + ) + + +class ControlDTCPrimitiveConfig(DTCPrimitiveConfig): + stop: bool = Field( + False, description="Stop the setting of DTCs. If already disabled, this has no effect." + ) + resume: bool = Field( + False, description="Resume the setting of DTCs. If already enabled, this has no effect." + ) + + +class ReadDTCPrimitive(UDSScanner): + """Read out the Diagnostic Trouble Codes (DTC)""" + + CONFIG_TYPE = ReadDTCPrimitiveConfig + SHORT_HELP = "Read the DTCs using the ReadDTCInformation service" + + def __init__(self, config: ReadDTCPrimitiveConfig): + super().__init__(config) + self.config: ReadDTCPrimitiveConfig = config async def fetch_error_codes(self, mask: int, split: bool = True) -> dict[int, int]: ecu_response = await self.ecu.read_dtc_information_report_dtc_by_status_mask(mask) @@ -97,8 +81,7 @@ async def fetch_error_codes(self, mask: int, split: bool = True) -> dict[int, in if isinstance(ecu_response, NegativeResponse): if ecu_response.response_code == UDSErrorCodes.responseTooLong: logger.error( - f"There are too many codes for (sub)mask {mask}. Consider setting --mask " - f"with a parameter that excludes one or more of the corresponding bits." + f"There are too many codes for (sub)mask {mask}. Consider setting --mask with a parameter that excludes one or more of the corresponding bits." ) if split: logger.warning("Trying to fetch the error codes iteratively.") @@ -117,8 +100,8 @@ async def fetch_error_codes(self, mask: int, split: bool = True) -> dict[int, in return dtcs - async def read(self, args: Namespace) -> None: - dtcs = await self.fetch_error_codes(args.mask) + async def main(self) -> None: + dtcs = await self.fetch_error_codes(self.config.mask) failed_dtcs: list[list[str]] = [] uncompleted_dtcs: list[list[str]] = [] @@ -141,16 +124,16 @@ async def read(self, args: Namespace) -> None: logger.result(raw_output) uncompleted_dtcs.append(table_output) - if args.show_legend: + if self.config.show_legend: logger.result("") self.show_bit_legend() - if args.show_failed: + if self.config.show_failed: logger.result("") logger.result("Failed codes:") self.show_summary(failed_dtcs) - if args.show_uncompleted: + if self.config.show_uncompleted: logger.result("") logger.result("Uncompleted codes:") self.show_summary(uncompleted_dtcs) @@ -167,32 +150,30 @@ def show_bit_legend(self) -> None: "7 = warningIndicatorRequested: existing warning indicators (e.g. lamp, display)", ] - for line in ( - tabulate([[d] for d in bit_descriptions], headers=["bit descriptions"]) + for line in tabulate( + [[d] for d in bit_descriptions], headers=["bit descriptions"] ).splitlines(): logger.result(line) def show_summary(self, dtcs: list[list[str]]) -> None: dtcs.sort() - header = [ - "DTC", - "error state", - "0", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - ] + header = ["DTC", "error state", "0", "1", "2", "3", "4", "5", "6", "7"] for line in tabulate(dtcs, headers=header, tablefmt="fancy_grid").splitlines(): logger.result(line) - async def clear(self, args: Namespace) -> None: - group_of_dtc: int = args.group_of_dtc + +class ClearDTCPrimitive(UDSScanner): + CONFIG_TYPE = ClearDTCPrimitiveConfig + SHORT_HELP = "Clear the DTCs using the ClearDiagnosticInformation service" + + def __init__(self, config: ClearDTCPrimitiveConfig): + super().__init__(config) + self.config: ClearDTCPrimitiveConfig = config + + async def main(self) -> None: + group_of_dtc: int = self.config.group_of_dtc min_group_of_dtc = 0 max_group_of_dtc = 0xFFFFFF @@ -209,21 +190,19 @@ async def clear(self, args: Namespace) -> None: else: logger.result("Success") - async def control(self, args: Namespace) -> None: - if args.stop: - await self.ecu.control_dtc_setting(CDTCSSubFuncs.OFF) - else: - await self.ecu.control_dtc_setting(CDTCSSubFuncs.ON) - async def main(self, args: Namespace) -> None: - await self.ecu.set_session(args.session) +class ControlDTCPrimitive(UDSScanner): + CONFIG_TYPE = ControlDTCPrimitiveConfig + SHORT_HELP = "Stop or resume the setting of DTCs using the ControlDTCSetting service" + + def __init__(self, config: ControlDTCPrimitiveConfig): + super().__init__(config) + self.config: ControlDTCPrimitiveConfig = config + + async def main(self) -> None: + assert isinstance(self.config, ControlDTCPrimitiveConfig) - if args.cmd == "clear": - await self.clear(args) - elif args.cmd == "control": - await self.control(args) - elif args.cmd == "read": - await self.read(args) + if self.config.stop: + await self.ecu.control_dtc_setting(CDTCSSubFuncs.OFF) else: - logger.critical("Unhandled command") - sys.exit(1) + await self.ecu.control_dtc_setting(CDTCSSubFuncs.ON) diff --git a/src/gallia/commands/primitive/uds/ecu_reset.py b/src/gallia/commands/primitive/uds/ecu_reset.py index a36df4485..3316e7651 100644 --- a/src/gallia/commands/primitive/uds/ecu_reset.py +++ b/src/gallia/commands/primitive/uds/ecu_reset.py @@ -3,59 +3,56 @@ # SPDX-License-Identifier: Apache-2.0 import asyncio -from argparse import Namespace from gallia.command import UDSScanner +from gallia.command.config import AutoInt, Field +from gallia.command.uds import UDSScannerConfig from gallia.log import get_logger from gallia.services.uds import NegativeResponse, UDSResponse from gallia.services.uds.core.utils import g_repr -from gallia.utils import auto_int logger = get_logger(__name__) +class ECUResetPrimitiveConfig(UDSScannerConfig): + properties: bool = Field( + False, + description="Read and store the ECU proporties prior and after scan", + cli_group=UDSScannerConfig._cli_group, + config_section=UDSScannerConfig._config_section, + ) + session: AutoInt = Field(0x01, description="set session perform test in") + subfunc: AutoInt = Field(0x01, description="subfunc", short="f") + + class ECUResetPrimitive(UDSScanner): """Use the ECUReset UDS service to reset the ECU""" - GROUP = "primitive" - COMMAND = "ecu-reset" + CONFIG_TYPE = ECUResetPrimitiveConfig SHORT_HELP = "ECUReset" - def configure_parser(self) -> None: - self.parser.set_defaults(properties=False) - - self.parser.add_argument( - "--session", - type=auto_int, - default=0x01, - help="set session perform test in", - ) - self.parser.add_argument( - "-f", - "--subfunc", - type=auto_int, - default=0x01, - help="subfunc", - ) - - async def main(self, args: Namespace) -> None: - resp: UDSResponse = await self.ecu.set_session(args.session) + def __init__(self, config: ECUResetPrimitiveConfig): + super().__init__(config) + self.config: ECUResetPrimitiveConfig = config + + async def main(self) -> None: + resp: UDSResponse = await self.ecu.set_session(self.config.session) if isinstance(resp, NegativeResponse): - logger.error(f"could not change to session: {g_repr(args.session)}") + logger.error(f"could not change to session: {g_repr(self.config.session)}") return try: - logger.info(f"try sub-func: {g_repr(args.subfunc)}") - resp = await self.ecu.ecu_reset(args.subfunc) + logger.info(f"try sub-func: {g_repr(self.config.subfunc)}") + resp = await self.ecu.ecu_reset(self.config.subfunc) if isinstance(resp, NegativeResponse): - msg = f"ECU Reset {g_repr(args.subfunc)} failed in session: {g_repr(args.session)}: {resp}" + msg = f"ECU Reset {g_repr(self.config.subfunc)} failed in session: {g_repr(self.config.session)}: {resp}" logger.error(msg) else: - logger.result(f"ECU Reset {g_repr(args.subfunc)} succeeded") + logger.result(f"ECU Reset {g_repr(self.config.subfunc)} succeeded") except TimeoutError: logger.error("Timeout") await asyncio.sleep(10) except ConnectionError: - msg = f"Lost connection to ECU, session: {g_repr(args.session)} subFunc: {g_repr(args.subfunc)}" + msg = f"Lost connection to ECU, session: {g_repr(self.config.session)} subFunc: {g_repr(self.config.subfunc)}" logger.error(msg) return diff --git a/src/gallia/commands/primitive/uds/iocbi.py b/src/gallia/commands/primitive/uds/iocbi.py index b54ef74ca..cc0540b13 100644 --- a/src/gallia/commands/primitive/uds/iocbi.py +++ b/src/gallia/commands/primitive/uds/iocbi.py @@ -2,106 +2,92 @@ # # SPDX-License-Identifier: Apache-2.0 -import binascii import sys -from argparse import Namespace +from typing import Literal from gallia.command import UDSScanner +from gallia.command.config import AutoInt, Field, HexBytes +from gallia.command.uds import UDSScannerConfig from gallia.log import get_logger from gallia.services.uds import NegativeResponse from gallia.services.uds.core.utils import g_repr -from gallia.utils import auto_int logger = get_logger(__name__) +class IOCBIPrimitiveConfig(UDSScannerConfig): + properties: bool = Field( + False, + description="Read and store the ECU proporties prior and after scan", + cli_group=UDSScannerConfig._cli_group, + config_section=UDSScannerConfig._config_section, + ) + session: AutoInt = Field(0x01, description="The session in which the requests are made") + data_identifier: AutoInt = Field(description="The data identifier", positional=True) + control_parameter: Literal[ + "return-control-to-ecu", + "reset-to-default", + "freeze-current-state", + "short-term-adjustment", + "without-control-parameter", + ] = Field( + description='Control parameter sent to the ECU. "short-term-adjustment" and "without-control-parameter" require passing a new state as well.', + positional=True, + ) + new_state: HexBytes = Field( + b"", + description='The new state required in use with the two control parameters "short-term-adjustment" and "without-control-parameter".', + metavar="HEXSTRING", + ) + control_enable_mask: HexBytes = Field( + b"", + description="This parameter is used if the data-identifier corresponds to multiple signals.In that case each bit enables or disables setting of each corresponding signal.Can only be used in combination with a control parameter.", + metavar="HEXSTRING", + ) + + class IOCBIPrimitive(UDSScanner): """Input output control""" - GROUP = "primitive" - COMMAND = "iocbi" + CONFIG_TYPE = IOCBIPrimitiveConfig SHORT_HELP = "InputOutputControl" - def configure_parser(self) -> None: - self.parser.set_defaults(properties=False) - - self.parser.add_argument( - "--session", - type=auto_int, - default=0x01, - help="The session in which the requests are made", - ) - self.parser.add_argument( - "data_identifier", - type=auto_int, - help="The data identifier", - ) - self.parser.add_argument( - "control_parameter", - type=str, - choices=[ - "return-control-to-ecu", - "reset-to-default", - "freeze-current-state", - "short-term-adjustment", - "without-control-parameter", - ], - help='Control parameter sent to the ECU. "short-term-adjustment" and "without-control-parameter"' - " require passing a new state as well.", - ) - self.parser.add_argument( - "--new-state", - metavar="HEXSTRING", - type=binascii.unhexlify, - default=b"", - help='The new state required in use with the two control parameters "short-term-adjustment"' - ' and "without-control-parameter".', - ) - self.parser.add_argument( - "--control-enable-mask", - metavar="HEXSTRING", - type=binascii.unhexlify, - default=b"", - help="This parameter is used if the data-identifier corresponds to multiple signals." - "In that case each bit enables or disables setting of each corresponding signal." - "Can only be used in combination with a control parameter.", - ) + def __init__(self, config: IOCBIPrimitiveConfig): + super().__init__(config) + self.config: IOCBIPrimitiveConfig = config - async def main(self, args: Namespace) -> None: + async def main(self) -> None: try: - await self.ecu.check_and_set_session(args.session) + await self.ecu.check_and_set_session(self.config.session) except Exception as e: - logger.critical(f"Could not change to session: {g_repr(args.session)}: {e!r}") + logger.critical(f"Could not change to session: {g_repr(self.config.session)}: {e!r}") sys.exit(1) - did = args.data_identifier - control_enable_mask_record = args.control_enable_mask + did = self.config.data_identifier + control_enable_mask_record = self.config.control_enable_mask uses_control_parameter = True - if args.control_parameter == "return-control-to-ecu": + if self.config.control_parameter == "return-control-to-ecu": resp = await self.ecu.input_output_control_by_identifier_return_control_to_ecu( did, control_enable_mask_record ) - elif args.control_parameter == "reset-to-default": + elif self.config.control_parameter == "reset-to-default": resp = await self.ecu.input_output_control_by_identifier_reset_to_default( did, control_enable_mask_record ) - elif args.control_parameter == "freeze-current-state": + elif self.config.control_parameter == "freeze-current-state": resp = await self.ecu.input_output_control_by_identifier_freeze_current_state( did, control_enable_mask_record ) - elif args.control_parameter == "short-term-adjustment": + elif self.config.control_parameter == "short-term-adjustment": resp = await self.ecu.input_output_control_by_identifier_short_term_adjustment( - did, args.new_state, control_enable_mask_record + did, self.config.new_state, control_enable_mask_record ) - elif args.control_parameter == "without-control-parameter": + elif self.config.control_parameter == "without-control-parameter": resp = await self.ecu.input_output_control_by_identifier( - did, args.new_state, control_enable_mask_record + did, self.config.new_state, control_enable_mask_record ) uses_control_parameter = False - else: - logger.critical("Unhandled control parameter") - sys.exit(1) if isinstance(resp, NegativeResponse): logger.error(resp) diff --git a/src/gallia/commands/primitive/uds/pdu.py b/src/gallia/commands/primitive/uds/pdu.py index 8d6eff3be..dced7fbd0 100644 --- a/src/gallia/commands/primitive/uds/pdu.py +++ b/src/gallia/commands/primitive/uds/pdu.py @@ -2,59 +2,48 @@ # # SPDX-License-Identifier: Apache-2.0 -import binascii import sys -from argparse import Namespace from gallia.command import UDSScanner +from gallia.command.config import AutoInt, Field, HexBytes +from gallia.command.uds import UDSScannerConfig from gallia.log import get_logger -from gallia.services.uds import ( - NegativeResponse, - UDSRequest, - UDSRequestConfig, - UDSResponse, -) +from gallia.services.uds import NegativeResponse, UDSRequest, UDSRequestConfig, UDSResponse from gallia.services.uds.core.exception import UDSException from gallia.services.uds.core.service import RawRequest, RawResponse from gallia.services.uds.helpers import raise_for_error -from gallia.utils import auto_int logger = get_logger(__name__) +class SendPDUPrimitiveConfig(UDSScannerConfig): + properties: bool = Field( + False, + description="Read and store the ECU proporties prior and after scan", + cli_group=UDSScannerConfig._cli_group, + config_section=UDSScannerConfig._config_section, + ) + pdu: HexBytes = Field(description="The raw pdu to send to the ECU", positional=True) + max_retry: int = Field(3, description="Set the uds' stack max_retry argument", short="r") + session: AutoInt | None = Field( + None, description="Change to this session prior to sending the pdu" + ) + + class SendPDUPrimitive(UDSScanner): """A raw scanner to send a plain pdu""" - GROUP = "primitive" - COMMAND = "pdu" + CONFIG_TYPE = SendPDUPrimitiveConfig SHORT_HELP = "send a plain PDU" - def configure_parser(self) -> None: - self.parser.set_defaults(properties=False) - - self.parser.add_argument( - "pdu", - type=binascii.unhexlify, - help="The raw pdu to send to the ECU", - ) - self.parser.add_argument( - "-r", - "--max-retry", - type=int, - default=3, - help="Set the uds' stack max_retry argument", - ) - self.parser.add_argument( - "--session", - type=auto_int, - default=None, - help="Change to this session prior to sending the pdu", - ) - - async def main(self, args: Namespace) -> None: - pdu = args.pdu - if args.session is not None: - resp: UDSResponse = await self.ecu.set_session(args.session) + def __init__(self, config: SendPDUPrimitiveConfig): + super().__init__(config) + self.config: SendPDUPrimitiveConfig = config + + async def main(self) -> None: + pdu = self.config.pdu + if self.config.session is not None: + resp: UDSResponse = await self.ecu.set_session(self.config.session) raise_for_error(resp) parsed_request = UDSRequest.parse_dynamic(pdu) @@ -66,8 +55,7 @@ async def main(self, args: Namespace) -> None: try: response = await self.ecu.send_raw( - pdu, - config=UDSRequestConfig(max_retry=args.max_retry), + pdu, config=UDSRequestConfig(max_retry=self.config.max_retry) ) except UDSException as e: logger.error(repr(e)) diff --git a/src/gallia/commands/primitive/uds/ping.py b/src/gallia/commands/primitive/uds/ping.py index e0f7c5f32..0985f0963 100644 --- a/src/gallia/commands/primitive/uds/ping.py +++ b/src/gallia/commands/primitive/uds/ping.py @@ -4,56 +4,51 @@ import asyncio import sys -from argparse import Namespace from gallia.command import UDSScanner +from gallia.command.config import AutoInt, Field +from gallia.command.uds import UDSScannerConfig from gallia.log import get_logger from gallia.services.uds.core.service import NegativeResponse -from gallia.utils import auto_int logger = get_logger(__name__) +class PingPrimitiveConfig(UDSScannerConfig): + properties: bool = Field( + False, + description="Read and store the ECU proporties prior and after scan", + cli_group=UDSScannerConfig._cli_group, + config_section=UDSScannerConfig._config_section, + ) + session: AutoInt = Field(0x01, description="set session to perform test") + count: AutoInt | None = Field(None, description="limit number of pings to this amount") + interval: float = Field(0.5, description="time interval between two pings", metavar="SECONDS") + + class PingPrimitive(UDSScanner): """Ping ECU via TesterPresent""" - GROUP = "primitive" - COMMAND = "ping" + CONFIG_TYPE = PingPrimitiveConfig SHORT_HELP = "ping ECU via TesterPresent" - def configure_parser(self) -> None: - self.parser.set_defaults(properties=False) - - self.parser.add_argument( - "--session", type=auto_int, default=0x01, help="set session to perform test" - ) - self.parser.add_argument( - "--count", - type=auto_int, - default=None, - help="limit number of pings to this amount", - ) - self.parser.add_argument( - "--interval", - type=float, - default=0.5, - metavar="SECONDS", - help="time interval between two pings", - ) - - async def main(self, args: Namespace) -> None: - resp = await self.ecu.set_session(args.session) + def __init__(self, config: PingPrimitiveConfig): + super().__init__(config) + self.config: PingPrimitiveConfig = config + + async def main(self) -> None: + resp = await self.ecu.set_session(self.config.session) if isinstance(resp, NegativeResponse): logger.error(f"Could not change to requested session: {resp}") sys.exit(1) i = 1 while True: - if args.count is not None and i > args.count: + if self.config.count is not None and i > self.config.count: break ret = await self.ecu.ping() if isinstance(ret, NegativeResponse): logger.warning(ret) logger.result("ECU is alive!") - await asyncio.sleep(args.interval) + await asyncio.sleep(self.config.interval) i += 1 diff --git a/src/gallia/commands/primitive/uds/rdbi.py b/src/gallia/commands/primitive/uds/rdbi.py index 2449d3d59..6dff56ef2 100644 --- a/src/gallia/commands/primitive/uds/rdbi.py +++ b/src/gallia/commands/primitive/uds/rdbi.py @@ -3,47 +3,47 @@ # SPDX-License-Identifier: Apache-2.0 import sys -from argparse import Namespace from gallia.command import UDSScanner +from gallia.command.config import AutoInt, Field +from gallia.command.uds import UDSScannerConfig from gallia.log import get_logger from gallia.services.uds.core.service import NegativeResponse -from gallia.utils import auto_int logger = get_logger(__name__) +class ReadByIdentifierPrimitiveConfig(UDSScannerConfig): + properties: bool = Field( + False, + description="Read and store the ECU proporties prior and after scan", + cli_group=UDSScannerConfig._cli_group, + config_section=UDSScannerConfig._config_section, + ) + data_identifier: AutoInt = Field(description="The data identifier", positional=True) + session: AutoInt = Field(0x01, description="set session perform test in") + + class ReadByIdentifierPrimitive(UDSScanner): """Read data via the ReadDataByIdentifier service""" - GROUP = "primitive" - COMMAND = "rdbi" + CONFIG_TYPE = ReadByIdentifierPrimitiveConfig SHORT_HELP = "ReadDataByIdentifier" - def configure_parser(self) -> None: - self.parser.set_defaults(properties=False) - - self.parser.add_argument( - "data_identifier", - type=auto_int, - help="The data identifier", - ) - self.parser.add_argument( - "--session", - type=auto_int, - default=0x01, - help="set session perform test in", - ) - - async def main(self, args: Namespace) -> None: + def __init__(self, config: ReadByIdentifierPrimitiveConfig): + super().__init__(config) + self.config: ReadByIdentifierPrimitiveConfig = config + self.result: bytes | None = None + + async def main(self) -> None: try: - if args.session != 0x01: - await self.ecu.set_session(args.session) + if self.config.session != 0x01: + await self.ecu.set_session(self.config.session) except Exception as e: logger.critical(f"fatal error: {e!r}") sys.exit(1) - resp = await self.ecu.read_data_by_identifier(args.data_identifier) + resp = await self.ecu.read_data_by_identifier(self.config.data_identifier) if isinstance(resp, NegativeResponse): logger.error(resp) else: diff --git a/src/gallia/commands/primitive/uds/rmba.py b/src/gallia/commands/primitive/uds/rmba.py index f87dfea36..12bd7084f 100644 --- a/src/gallia/commands/primitive/uds/rmba.py +++ b/src/gallia/commands/primitive/uds/rmba.py @@ -3,52 +3,49 @@ # SPDX-License-Identifier: Apache-2.0 import sys -from argparse import Namespace from gallia.command import UDSScanner +from gallia.command.config import AutoInt, Field +from gallia.command.uds import UDSScannerConfig from gallia.log import get_logger from gallia.services.uds import NegativeResponse from gallia.services.uds.core.utils import g_repr -from gallia.utils import auto_int logger = get_logger(__name__) +class RMBAPrimitiveConfig(UDSScannerConfig): + properties: bool = Field( + False, + description="Read and store the ECU proporties prior and after scan", + cli_group=UDSScannerConfig._cli_group, + config_section=UDSScannerConfig._config_section, + ) + session: AutoInt = Field(0x01, description="The session in which the requests are made") + address: AutoInt = Field( + description="The start address from which data should be read", positional=True + ) + length: AutoInt = Field(description="The number of bytes which should be read", positional=True) + + class RMBAPrimitive(UDSScanner): """Read memory by address""" - GROUP = "primitive" - COMMAND = "rmba" + CONFIG_TYPE = RMBAPrimitiveConfig SHORT_HELP = "ReadMemoryByAddress" - def configure_parser(self) -> None: - self.parser.set_defaults(properties=False) - - self.parser.add_argument( - "--session", - type=auto_int, - default=0x01, - help="The session in which the requests are made", - ) - self.parser.add_argument( - "address", - type=auto_int, - help="The start address from which data should be read", - ) - self.parser.add_argument( - "length", - type=auto_int, - help="The number of bytes which should be read", - ) - - async def main(self, args: Namespace) -> None: + def __init__(self, config: RMBAPrimitiveConfig): + super().__init__(config) + self.config: RMBAPrimitiveConfig = config + + async def main(self) -> None: try: - await self.ecu.check_and_set_session(args.session) + await self.ecu.check_and_set_session(self.config.session) except Exception as e: - logger.critical(f"Could not change to session: {g_repr(args.session)}: {e!r}") + logger.critical(f"Could not change to session: {g_repr(self.config.session)}: {e!r}") sys.exit(1) - resp = await self.ecu.read_memory_by_address(args.address, args.length) + resp = await self.ecu.read_memory_by_address(self.config.address, self.config.length) if isinstance(resp, NegativeResponse): logger.error(resp) diff --git a/src/gallia/commands/primitive/uds/rtcl.py b/src/gallia/commands/primitive/uds/rtcl.py index 5ef5ac1cc..463fcaf20 100644 --- a/src/gallia/commands/primitive/uds/rtcl.py +++ b/src/gallia/commands/primitive/uds/rtcl.py @@ -3,108 +3,96 @@ # SPDX-License-Identifier: Apache-2.0 import asyncio -import binascii import sys -from argparse import Namespace from gallia.command import UDSScanner +from gallia.command.config import AutoInt, Field, HexBytes +from gallia.command.uds import UDSScannerConfig from gallia.log import get_logger from gallia.services.uds import NegativeResponse from gallia.services.uds.core.service import RoutineControlResponse from gallia.services.uds.core.utils import g_repr -from gallia.utils import auto_int logger = get_logger(__name__) +class RTCLPrimitiveConfig(UDSScannerConfig): + properties: bool = Field( + False, + description="Read and store the ECU proporties prior and after scan", + cli_group=UDSScannerConfig._cli_group, + config_section=UDSScannerConfig._config_section, + ) + session: AutoInt = Field(0x01, description="The session in which the requests are made") + routine_identifier: AutoInt = Field(description="The routine identifier", positional=True) + start: bool = Field( + False, + description="Start the routine with a startRoutine request (this task is always executed first)", + ) + stop: bool = Field( + False, + description="Stop the routine with a stopRoutine request (this task is executed after starting the routine if --start is given as well)", + ) + results: bool = Field( + False, + description="Read the routine results with a requestRoutineResults request (this task is always executed last)", + ) + start_parameters: HexBytes = Field( + b"", + description="The routineControlOptionRecord passed to the startRoutine request", + metavar="HEXSTRING", + ) + stop_parameters: HexBytes = Field( + b"", + description="The routineControlOptionRecord passed to the stopRoutine request", + metavar="HEXSTRING", + ) + results_parameters: HexBytes = Field( + b"", + description="The routineControlOptionRecord passed to the stopRoutine request", + metavar="HEXSTRING", + ) + stop_delay: float = Field( + 0.0, + description="Delay the stopRoutine request by the given amount of seconds", + metavar="SECONDS", + ) + results_delay: float = Field( + 0.0, + description="Delay the requestRoutineResults request by the given amount of seconds", + metavar="SECONDS", + ) + + class RTCLPrimitive(UDSScanner): """Start or stop a provided routine or request its results""" - GROUP = "primitive" - COMMAND = "rtcl" + CONFIG_TYPE = RTCLPrimitiveConfig SHORT_HELP = "RoutineControl" - def configure_parser(self) -> None: - self.parser.set_defaults(properties=False) - - self.parser.add_argument( - "--session", - type=auto_int, - default=0x01, - help="The session in which the requests are made", - ) - self.parser.add_argument( - "routine_identifier", - type=auto_int, - help="The routine identifier", - ) - self.parser.add_argument( - "--start", - action="store_true", - help="Start the routine with a startRoutine request (this task is always executed first)", - ) - self.parser.add_argument( - "--stop", - action="store_true", - help="Stop the routine with a stopRoutine request " - "(this task is executed after starting the routine if --start is given as well)", - ) - self.parser.add_argument( - "--results", - action="store_true", - help="Read the routine results with a requestRoutineResults request (this task is always executed last)", - ) - self.parser.add_argument( - "--start-parameters", - metavar="HEXSTRING", - type=binascii.unhexlify, - default=b"", - help="The routineControlOptionRecord passed to the startRoutine request", - ) - self.parser.add_argument( - "--stop-parameters", - metavar="HEXSTRING", - type=binascii.unhexlify, - default=b"", - help="The routineControlOptionRecord passed to the stopRoutine request", - ) - self.parser.add_argument( - "--results-parameters", - metavar="HEXSTRING", - type=binascii.unhexlify, - default=b"", - help="The routineControlOptionRecord passed to the stopRoutine request", - ) - self.parser.add_argument( - "--stop-delay", - metavar="SECONDS", - type=float, - default=0.0, - help="Delay the stopRoutine request by the given amount of seconds", - ) - self.parser.add_argument( - "--results-delay", - metavar="SECONDS", - type=float, - default=0.0, - help="Delay the requestRoutineResults request by the given amount of seconds", - ) - - async def main(self, args: Namespace) -> None: + def __init__(self, config: RTCLPrimitiveConfig): + super().__init__(config) + self.config: RTCLPrimitiveConfig = config + + async def main(self) -> None: try: - await self.ecu.check_and_set_session(args.session) + await self.ecu.check_and_set_session(self.config.session) except Exception as e: - logger.critical(f"Could not change to session: {g_repr(args.session)}: {e!r}") + logger.critical(f"Could not change to session: {g_repr(self.config.session)}: {e!r}") sys.exit(1) - if args.start is False and args.stop is False and args.results is False: + if ( + self.config.start is False + and self.config.stop is False + and (self.config.results is False) + ): logger.warning("No instructions were given (start/stop/results)") - if args.start: + if self.config.start: resp: ( NegativeResponse | RoutineControlResponse ) = await self.ecu.routine_control_start_routine( - args.routine_identifier, args.start_parameters + self.config.routine_identifier, self.config.start_parameters ) if isinstance(resp, NegativeResponse): @@ -114,15 +102,15 @@ async def main(self, args: Namespace) -> None: logger.result(f"hex: {resp.routine_status_record.hex()}") logger.result(f"raw: {resp.routine_status_record!r}") - if args.stop: - delay = args.stop_delay + if self.config.stop: + delay = self.config.stop_delay if delay > 0: logger.info(f"Delaying the request for stopping the routine by {delay} seconds") await asyncio.sleep(delay) resp = await self.ecu.routine_control_stop_routine( - args.routine_identifier, args.stop_parameters + self.config.routine_identifier, self.config.stop_parameters ) if isinstance(resp, NegativeResponse): @@ -132,15 +120,15 @@ async def main(self, args: Namespace) -> None: logger.result(f"hex: {resp.routine_status_record.hex()}") logger.result(f"raw: {resp.routine_status_record!r}") - if args.results: - delay = args.results_delay + if self.config.results: + delay = self.config.results_delay if delay > 0: logger.info(f"Delaying the request for the routine results by {delay} seconds") await asyncio.sleep(delay) resp = await self.ecu.routine_control_request_routine_results( - args.routine_identifier, args.results_parameters + self.config.routine_identifier, self.config.results_parameters ) if isinstance(resp, NegativeResponse): diff --git a/src/gallia/commands/primitive/uds/vin.py b/src/gallia/commands/primitive/uds/vin.py index e37030bb9..8329e8509 100644 --- a/src/gallia/commands/primitive/uds/vin.py +++ b/src/gallia/commands/primitive/uds/vin.py @@ -2,26 +2,36 @@ # # SPDX-License-Identifier: Apache-2.0 -from argparse import Namespace from gallia.command import UDSScanner +from gallia.command.config import Field +from gallia.command.uds import UDSScannerConfig from gallia.log import get_logger from gallia.services.uds.core.service import NegativeResponse logger = get_logger(__name__) +class VINPrimitiveConfig(UDSScannerConfig): + properties: bool = Field( + False, + description="Read and store the ECU proporties prior and after scan", + cli_group=UDSScannerConfig._cli_group, + config_section=UDSScannerConfig._config_section, + ) + + class VINPrimitive(UDSScanner): """Request VIN""" - GROUP = "primitive" - COMMAND = "vin" + CONFIG_TYPE = VINPrimitiveConfig SHORT_HELP = "request VIN" - def configure_parser(self) -> None: - self.parser.set_defaults(properties=False) + def __init__(self, config: VINPrimitiveConfig): + super().__init__(config) + self.config: VINPrimitiveConfig = config - async def main(self, args: Namespace) -> None: + async def main(self) -> None: resp = await self.ecu.read_vin() if isinstance(resp, NegativeResponse): logger.warning(f"ECU said: {resp}") diff --git a/src/gallia/commands/primitive/uds/wdbi.py b/src/gallia/commands/primitive/uds/wdbi.py index cb5de73d1..52ce67866 100644 --- a/src/gallia/commands/primitive/uds/wdbi.py +++ b/src/gallia/commands/primitive/uds/wdbi.py @@ -2,56 +2,57 @@ # # SPDX-License-Identifier: Apache-2.0 -import binascii import sys -from argparse import Namespace from pathlib import Path +from typing import Self + +from pydantic import model_validator from gallia.command import UDSScanner +from gallia.command.config import AutoInt, Field, HexBytes +from gallia.command.uds import UDSScannerConfig from gallia.log import get_logger from gallia.services.uds import NegativeResponse, UDSResponse -from gallia.utils import auto_int logger = get_logger(__name__) +class WriteByIdentifierPrimitiveConfig(UDSScannerConfig): + properties: bool = Field( + False, + description="Read and store the ECU proporties prior and after scan", + cli_group=UDSScannerConfig._cli_group, + config_section=UDSScannerConfig._config_section, + ) + data_identifier: AutoInt = Field(description="The data identifier", positional=True) + session: AutoInt = Field(0x01, description="set session perform test in") + data: HexBytes | None = Field(None, description="The data which should be written") + data_file: Path | None = Field( + None, description="The path to a file with the binary data which should be written" + ) + + @model_validator(mode="after") + def check_data_source(self) -> Self: + if not (self.data is None) ^ (self.data_file is None): + raise ValueError("Exactly one of data or data-file is required") + + return self + + class WriteByIdentifierPrimitive(UDSScanner): """A simple scanner to talk to the write by identifier service""" - GROUP = "primitive" - COMMAND = "wdbi" + CONFIG_TYPE = WriteByIdentifierPrimitiveConfig SHORT_HELP = "WriteDataByIdentifier" - def configure_parser(self) -> None: - self.parser.set_defaults(properties=False) - - self.parser.add_argument( - "data_identifier", - type=auto_int, - help="The data identifier", - ) - self.parser.add_argument( - "--session", - type=auto_int, - default=0x01, - help="set session perform test in", - ) - data_group = self.parser.add_mutually_exclusive_group(required=True) - data_group.add_argument( - "--data", - type=binascii.unhexlify, - help="The data which should be written", - ) - data_group.add_argument( - "--data-file", - type=Path, - help="The path to a file with the binary data which should be written", - ) - - async def main(self, args: Namespace) -> None: + def __init__(self, config: WriteByIdentifierPrimitiveConfig): + super().__init__(config) + self.config: WriteByIdentifierPrimitiveConfig = config + + async def main(self) -> None: try: - if args.session != 0x01: - resp: UDSResponse = await self.ecu.set_session(args.session) + if self.config.session != 0x01: + resp: UDSResponse = await self.ecu.set_session(self.config.session) if isinstance(resp, NegativeResponse): logger.critical(f"could not change to session: {resp}") sys.exit(1) @@ -59,13 +60,15 @@ async def main(self, args: Namespace) -> None: logger.critical(f"fatal error: {e!r}") sys.exit(1) - if args.data is not None: - data = args.data + if self.config.data is not None: + data = self.config.data else: - with args.data_file.open("rb") as file: + assert self.config.data_file is not None + + with self.config.data_file.open("rb") as file: data = file.read() - resp = await self.ecu.write_data_by_identifier(args.data_identifier, data) + resp = await self.ecu.write_data_by_identifier(self.config.data_identifier, data) if isinstance(resp, NegativeResponse): logger.error(resp) else: diff --git a/src/gallia/commands/primitive/uds/wmba.py b/src/gallia/commands/primitive/uds/wmba.py index 84c697cd9..fa109aaae 100644 --- a/src/gallia/commands/primitive/uds/wmba.py +++ b/src/gallia/commands/primitive/uds/wmba.py @@ -2,65 +2,66 @@ # # SPDX-License-Identifier: Apache-2.0 -import binascii import sys -from argparse import Namespace from pathlib import Path +from typing import Self + +from pydantic import model_validator from gallia.command import UDSScanner +from gallia.command.config import AutoInt, Field, HexBytes +from gallia.command.uds import UDSScannerConfig from gallia.log import get_logger from gallia.services.uds import NegativeResponse from gallia.services.uds.core.utils import g_repr -from gallia.utils import auto_int logger = get_logger(__name__) +class WMBAPrimitiveConfig(UDSScannerConfig): + session: AutoInt = Field(0x01, description="The session in which the requests are made") + address: AutoInt = Field( + description="The start address to which data should be written", positional=True + ) + data: HexBytes | None = Field(None, description="The data which should be written") + data_file: Path | None = Field( + None, description="The path to a file with the binary data which should be written" + ) + + @model_validator(mode="after") + def check_data_source(self) -> Self: + if not (self.data is None) ^ (self.data_file is None): + raise ValueError("Exactly one of data or data-file is required") + + return self + + class WMBAPrimitive(UDSScanner): """Write memory by address""" - COMMAND = "wmba" - GROUP = "primitive" + CONFIG_TYPE = WMBAPrimitiveConfig SHORT_HELP = "WriteMemoryByAddress" - def configure_parser(self) -> None: - self.parser.add_argument( - "--session", - type=auto_int, - default=0x01, - help="The session in which the requests are made", - ) - self.parser.add_argument( - "address", - type=auto_int, - help="The start address to which data should be written", - ) - data_group = self.parser.add_mutually_exclusive_group(required=True) - data_group.add_argument( - "--data", - type=binascii.unhexlify, - help="The data which should be written", - ) - data_group.add_argument( - "--data-file", - type=Path, - help="The path to a file with the binary data which should be written", - ) - - async def main(self, args: Namespace) -> None: + def __init__(self, config: WMBAPrimitiveConfig): + super().__init__(config) + self.config: WMBAPrimitiveConfig = config + + async def main(self) -> None: try: - await self.ecu.check_and_set_session(args.session) + await self.ecu.check_and_set_session(self.config.session) except Exception as e: - logger.critical(f"Could not change to session: {g_repr(args.session)}: {e!r}") + logger.critical(f"Could not change to session: {g_repr(self.config.session)}: {e!r}") sys.exit(1) - if args.data is not None: - data = args.data + if self.config.data is not None: + data = self.config.data else: - with args.data_file.open("rb") as file: + assert self.config.data_file is not None + + with self.config.data_file.open("rb") as file: data = file.read() - resp = await self.ecu.write_memory_by_address(args.address, data) + resp = await self.ecu.write_memory_by_address(self.config.address, data) if isinstance(resp, NegativeResponse): logger.error(resp) diff --git a/src/gallia/commands/primitive/uds/xcp.py b/src/gallia/commands/primitive/uds/xcp.py index d48d74556..815501de7 100644 --- a/src/gallia/commands/primitive/uds/xcp.py +++ b/src/gallia/commands/primitive/uds/xcp.py @@ -3,51 +3,63 @@ # SPDX-License-Identifier: Apache-2.0 import sys -from argparse import ArgumentParser, Namespace +from typing import Self + +from pydantic import model_validator assert sys.platform.startswith("linux"), "unsupported platform" from gallia.command import Scanner -from gallia.config import Config -from gallia.plugins import load_transport +from gallia.command.base import ScannerConfig +from gallia.command.config import AutoInt, Field +from gallia.plugins.plugin import load_transport from gallia.services.xcp import CANXCPSerivce, XCPService from gallia.transports import ISOTPTransport, RawCANTransport -from gallia.utils import auto_int, catch_and_log_exception +from gallia.utils import catch_and_log_exception + + +class SimpleTestXCPConfig(ScannerConfig): + can_master: AutoInt | None = Field(None) + can_slave: AutoInt | None = Field(None) + + @model_validator(mode="after") + def check_transport_requirements(self) -> Self: + if self.target.scheme == RawCANTransport.SCHEME and ( + self.can_master is None or self.can_slave is None + ): + raise ValueError("For CAN interfaces, master and slave address are required!") + + if self.target.scheme == ISOTPTransport.SCHEME: + raise ValueError("Use can-raw for CAN interfaces!") + + return self class SimpleTestXCP(Scanner): """Test XCP Slave""" - GROUP = "primitive" - COMMAND = "xcp" + CONFIG_TYPE = SimpleTestXCPConfig SHORT_HELP = "XCP tester" - def __init__(self, parser: ArgumentParser, config: Config): + def __init__(self, config: SimpleTestXCPConfig): + super().__init__(config) + self.config: SimpleTestXCPConfig = config self.service: XCPService - super().__init__(parser, config) - - def configure_parser(self) -> None: - self.parser.add_argument("--can-master", type=auto_int, default=None) - self.parser.add_argument("--can-slave", type=auto_int, default=None) - - async def setup(self, args: Namespace) -> None: - transport_type = load_transport(args.target) - transport = await transport_type.connect(args.target) + async def setup(self) -> None: + transport_type = load_transport(self.config.target) + transport = await transport_type.connect(self.config.target) if isinstance(transport, RawCANTransport): - if args.can_master is None or args.can_slave is None: - self.parser.error("For CAN interfaces, master and slave address are required!") + assert self.config.can_master is not None and self.config.can_slave is not None - self.service = CANXCPSerivce(transport, args.can_master, args.can_slave) - elif isinstance(transport, ISOTPTransport): - self.parser.error("Use can-raw for CAN interfaces!") + self.service = CANXCPSerivce(transport, self.config.can_master, self.config.can_slave) else: self.service = XCPService(transport) - await super().setup(args) + await super().setup() - async def main(self, args: Namespace) -> None: + async def main(self) -> None: await catch_and_log_exception(self.service.connect) await catch_and_log_exception(self.service.get_status) await catch_and_log_exception(self.service.get_comm_mode_info) diff --git a/src/gallia/commands/scan/uds/identifiers.py b/src/gallia/commands/scan/uds/identifiers.py index c98301557..1d061d5f7 100644 --- a/src/gallia/commands/scan/uds/identifiers.py +++ b/src/gallia/commands/scan/uds/identifiers.py @@ -2,119 +2,78 @@ # # SPDX-License-Identifier: Apache-2.0 -import binascii import reprlib -from argparse import Namespace from itertools import product from gallia.command import UDSScanner +from gallia.command.config import AutoInt, EnumArg, Field, HexBytes, Ranges, Ranges2D +from gallia.command.uds import UDSScannerConfig from gallia.log import get_logger from gallia.services.uds.core.client import UDSRequestConfig -from gallia.services.uds.core.constants import ( - RoutineControlSubFuncs, - UDSErrorCodes, - UDSIsoServices, -) +from gallia.services.uds.core.constants import RoutineControlSubFuncs, UDSErrorCodes, UDSIsoServices from gallia.services.uds.core.exception import IllegalResponse from gallia.services.uds.core.service import NegativeResponse, UDSResponse from gallia.services.uds.core.utils import g_repr, service_repr from gallia.services.uds.helpers import suggests_service_not_supported -from gallia.utils import ParseSkips, auto_int logger = get_logger(__name__) +class ScanIdentifiersConfig(UDSScannerConfig): + sessions: Ranges | None = Field( + None, description="Set list of sessions to be tested; all if None", metavar="SESSION_ID" + ) + start: AutoInt = Field(0, description="start scan at this dataIdentifier") + end: AutoInt = Field(0xFFFF, description="end scan at this dataIdentifier") + payload: HexBytes | None = Field( + None, description="Payload which will be appended for each request as hex string" + ) + service: EnumArg[UDSIsoServices] = Field( + UDSIsoServices.ReadDataByIdentifier, + description="Service (ID) to scan; defaults to ReadDataByIdentifier;\ncurrently supported:\n0x27 Security Access;\n0x22 Read Data By Identifier;\n0x2e Write Data By Identifier;\n0x31 Routine Control;\n", + ) + check_session: int | None = Field( + None, + description="Check current session via read DID [for every nth DataIdentifier] and try to recover session; only takes affect if --sessions is given", + const=1, + ) + skip: Ranges2D = Field( + {}, + metavar="SESSION_ID:ID", + description="The data identifiers to be skipped per session.\nA session specific skip is given by :\nwhere is a comma separated list of single ids or id ranges using a dash.\nExamples:\n - 0x01:0xf3\n - 0x10-0x2f\n - 0x01:0xf3,0x10-0x2f\nMultiple session specific skips are separated by space.\nOnly takes affect if --sessions is given.\n", + ) + skip_not_supported: bool = Field( + False, description="Stop scanning in session if service seems to be not available" + ) + + class ScanIdentifiers(UDSScanner): """This scanner scans DataIdentifiers of various services. Specific requirements such as for RoutineControl or SecurityAccess are considered and implemented in the script. """ - COMMAND = "identifiers" + CONFIG_TYPE = ScanIdentifiersConfig SHORT_HELP = "identifier scan of a UDS service" - def configure_parser(self) -> None: - self.parser.add_argument( - "--sessions", - type=auto_int, - nargs="*", - help="Set list of sessions to be tested; all if None", - ) - self.parser.add_argument( - "--start", - type=auto_int, - default=0, - help="start scan at this dataIdentifier (default: 0x%(default)x)", - ) - self.parser.add_argument( - "--end", - type=auto_int, - default=0xFFFF, - help="end scan at this dataIdentifier (default: 0x%(default)x)", - ) - self.parser.add_argument( - "--payload", - default=None, - type=binascii.unhexlify, - help="Payload which will be appended for each request as hex string", - ) - self.parser.add_argument( - "--sid", - type=auto_int, - default=0x22, - help=""" - Service ID to scan; defaults to ReadDataByIdentifier (default: 0x%(default)x); - currently supported: - 0x27 Security Access; - 0x22 Read Data By Identifier; - 0x2e Write Data By Identifier; - 0x31 Routine Control; - """, - ) - self.parser.add_argument( - "--check-session", - nargs="?", - const=1, - type=int, - help="Check current session via read DID [for every nth DataIdentifier] and try to recover session; only takes affect if --sessions is given", - ) - self.parser.add_argument( - "--skip", - nargs="+", - default={}, - type=str, - action=ParseSkips, - help=""" - The data identifiers to be skipped per session. - A session specific skip is given by : - where is a comma separated list of single ids or id ranges using a dash. - Examples: - - 0x01:0xf3 - - 0x10-0x2f - - 0x01:0xf3,0x10-0x2f - Multiple session specific skips are separated by space. - Only takes affect if --sessions is given. - """, - ) - self.parser.add_argument( - "--skip-not-supported", - action="store_true", - help="Stop scanning in session if service seems to be not available", - ) - - async def main(self, args: Namespace) -> None: - if args.sessions is None: - logger.notice("Performing scan in current session") - await self.perform_scan(args) + def __init__(self, config: ScanIdentifiersConfig): + super().__init__(config) + self.config: ScanIdentifiersConfig = config + async def main(self) -> None: + if self.config.sessions is None: + logger.notice("Performing scan in current session") + await self.perform_scan() else: sessions: list[int] = [ - s for s in args.sessions if s not in args.skip or args.skip[s] is not None + s + for s in self.config.sessions + if s not in self.config.skip or self.config.skip[s] is not None ] logger.info(f"testing sessions {g_repr(sessions)}") # TODO: Unified shortened output necessary here - logger.info(f"skipping identifiers {reprlib.repr(args.skip)}") + logger.info(f"skipping identifiers {reprlib.repr(self.config.skip)}") for session in sessions: logger.notice(f"Switching to session {g_repr(session)}") @@ -125,20 +84,20 @@ async def main(self, args: Namespace) -> None: logger.result(f"Starting scan in session: {g_repr(session)}") - await self.perform_scan(args, session) + await self.perform_scan(session) logger.result(f"Scan in session {g_repr(session)} is complete!") logger.info(f"Leaving session {g_repr(session)} via hook") - await self.ecu.leave_session(session, sleep=args.power_cycle_sleep) + await self.ecu.leave_session(session, sleep=self.config.power_cycle_sleep) - async def perform_scan(self, args: Namespace, session: None | int = None) -> None: + async def perform_scan(self, session: None | int = None) -> None: positive_DIDs = 0 abnormal_DIDs = 0 timeout_DIDs = 0 sub_functions = [0x00] - if args.sid == UDSIsoServices.RoutineControl: - if not args.payload: + if self.config.service == UDSIsoServices.RoutineControl: + if not self.config.payload: logger.warning( "Scanning RoutineControl with empty payload can successfully execute some " + "routines that might have irreversible effects without elevated privileges" @@ -147,19 +106,27 @@ async def perform_scan(self, args: Namespace, session: None | int = None) -> Non # Scan all three subfunctions (startRoutine, stopRoutine, requestRoutineResults) sub_functions = list(map(int, RoutineControlSubFuncs)) - if args.sid == UDSIsoServices.SecurityAccess and args.end > 0xFF: + if self.config.service == UDSIsoServices.SecurityAccess and self.config.end > 0xFF: logger.warning( "Service 0x27 SecurityAccess only accepts subFunctions (1-byte identifiers); " - + f"limiting END to {g_repr(0xff)} instead of {g_repr(args.end)}" + + f"limiting END to {g_repr(0xff)} instead of {g_repr(self.config.end)}" ) - args.end = 0xFF - - for DID, sub_function in product(range(args.start, args.end + 1), sub_functions): - if session in args.skip and DID in args.skip[session]: + self.config.end = 0xFF + + for DID, sub_function in product( + range(self.config.start, self.config.end + 1), sub_functions + ): + if session in self.config.skip and ( + (session_skip := self.config.skip[session]) is None or DID in session_skip + ): logger.info(f"{g_repr(DID)}: skipped") continue - if session is not None and args.check_session and DID % args.check_session == 0: + if ( + session is not None + and self.config.check_session + and (DID % self.config.check_session == 0) + ): # Check session and try to recover from wrong session (max 3 times), else skip session if not await self.ecu.check_and_set_session(session): logger.error( @@ -167,25 +134,24 @@ async def perform_scan(self, args: Namespace, session: None | int = None) -> Non ) break - if args.sid == UDSIsoServices.SecurityAccess: - if DID & 0b10000000: + if self.config.service == UDSIsoServices.SecurityAccess: + if DID & 128: logger.info( "Keep in mind that you set the SuppressResponse Bit (8th bit): " + f"{g_repr(DID)} = 0b{DID:b}" ) - pdu = bytes([args.sid, DID]) + pdu = bytes([self.config.service, DID]) - elif args.sid == UDSIsoServices.RoutineControl: + elif self.config.service == UDSIsoServices.RoutineControl: pdu = bytes( - [args.sid, sub_function, DID >> 8, DID & 0xFF] + [self.config.service, sub_function, DID >> 8, DID & 0xFF] ) # Needs extra byte for sub function - - # DefaultBehavior, e.g. for ReadDataByIdentifier/WriteDataByIdentifier else: - pdu = bytes([args.sid, DID >> 8, DID & 0xFF]) + # DefaultBehavior, e.g. for ReadDataByIdentifier/WriteDataByIdentifier + pdu = bytes([self.config.service, DID >> 8, DID & 0xFF]) - if args.payload: - pdu += args.payload + if self.config.payload: + pdu += self.config.payload try: resp = await self.ecu.send_raw( @@ -202,11 +168,10 @@ async def perform_scan(self, args: Namespace, session: None | int = None) -> Non if isinstance(resp, NegativeResponse): if suggests_service_not_supported(resp): logger.info( - f"{g_repr(DID)}: {resp}; does session {g_repr(session)} " - f"support service {service_repr(args.sid)}?" + f"{g_repr(DID)}: {resp}; does session {g_repr(session)} support service {service_repr(self.config.service)}?" ) - if args.skip_not_supported: + if self.config.skip_not_supported: break # RequestOutOfRange is a common reply for invalid/unknown DataIdentifiers @@ -216,7 +181,6 @@ async def perform_scan(self, args: Namespace, session: None | int = None) -> Non UDSErrorCodes.subFunctionNotSupported, ): logger.info(f"{g_repr(DID)}: {resp}") - else: logger.result(f"{g_repr(DID)}: {resp}") abnormal_DIDs += 1 diff --git a/src/gallia/commands/scan/uds/memory.py b/src/gallia/commands/scan/uds/memory.py index 5fe64ae4d..286482843 100644 --- a/src/gallia/commands/scan/uds/memory.py +++ b/src/gallia/commands/scan/uds/memory.py @@ -3,18 +3,42 @@ # SPDX-License-Identifier: Apache-2.0 import sys -from argparse import Namespace -from binascii import unhexlify +from typing import Literal from gallia.command import UDSScanner +from gallia.command.config import AutoInt, AutoLiteral, Field, HexBytes +from gallia.command.uds import UDSScannerConfig from gallia.log import get_logger from gallia.services.uds import NegativeResponse, UDSErrorCodes, UDSRequestConfig +from gallia.services.uds import UDSIsoServices as Services from gallia.services.uds.core.utils import g_repr, uds_memory_parameters -from gallia.utils import auto_int logger = get_logger(__name__) +class MemoryFunctionsScannerConfig(UDSScannerConfig): + session: AutoInt = Field(0x03, description="set session to perform test") + check_session: int | None = Field( + None, + description="Check current session via read DID [for every nth MemoryAddress] and try to recover session", + const=1, + ) + service: AutoLiteral[ + Literal[ + Services.ReadMemoryByAddress, + Services.WriteMemoryByAddress, + Services.RequestDownload, + Services.RequestUpload, + ] + ] = Field( + description="Choose between 0x23 ReadMemoryByAddress 0x3d WriteMemoryByAddress, 0x34 RequestDownload and 0x35 RequestUpload" + ) + data: HexBytes = Field( + bytes(8), + description="Service 0x3d requires a data payload which can be specified with this flag as a hex string", + ) + + class MemoryFunctionsScanner(UDSScanner): """This scanner scans functions with direct access to memory. Specifically, these are service 0x3d WriteMemoryByAddress, 0x34 RequestDownload @@ -22,79 +46,52 @@ class MemoryFunctionsScanner(UDSScanner): 0x3d which requires an additional data field. """ + CONFIG_TYPE = MemoryFunctionsScannerConfig SHORT_HELP = "scan services with direct memory access" - COMMAND = "memory" - - def configure_parser(self) -> None: - self.parser.add_argument( - "--session", - type=auto_int, - default=0x03, - help="set session to perform test", - ) - self.parser.add_argument( - "--check-session", - nargs="?", - const=1, - type=int, - help="Check current session via read DID [for every nth MemoryAddress] and try to recover session", - ) - self.parser.add_argument( - "--sid", - required=True, - choices=[0x23, 0x3D, 0x34, 0x35], - type=auto_int, - help="Choose between 0x23 ReadMemoryByAddress 0x3d WriteMemoryByAddress, " - "0x34 RequestDownload and 0x35 RequestUpload", - ) - self.parser.add_argument( - "--data", - default="0000000000000000", - type=unhexlify, - help="Service 0x3d requires a data payload which can be specified with this flag as a hex string", - ) - - async def main(self, args: Namespace) -> None: - resp = await self.ecu.set_session(args.session) + + def __init__(self, config: MemoryFunctionsScannerConfig): + super().__init__(config) + self.config: MemoryFunctionsScannerConfig = config + + async def main(self) -> None: + resp = await self.ecu.set_session(self.config.session) if isinstance(resp, NegativeResponse): logger.critical(f"could not change to session: {resp}") sys.exit(1) for i in range(5): - await self.scan_memory_address(args, i) + await self.scan_memory_address(i) - logger.info(f"Scan in session {g_repr(args.session)} is complete!") - logger.info(f"Leaving session {g_repr(args.session)} via hook") - await self.ecu.leave_session(args.session, sleep=args.power_cycle_sleep) + logger.info(f"Scan in session {g_repr(self.config.session)} is complete!") + logger.info(f"Leaving session {g_repr(self.config.session)} via hook") + await self.ecu.leave_session(self.config.session, sleep=self.config.power_cycle_sleep) - async def scan_memory_address(self, args: Namespace, addr_offset: int = 0) -> None: - sid = args.sid - data = args.data if sid == 0x3D else None # Only service 0x3d has a data field + async def scan_memory_address(self, addr_offset: int = 0) -> None: + sid = self.config.service.value + data = self.config.data if sid == 0x3D else None # Only service 0x3d has a data field memory_size = len(data) if data else 0x1000 for i in range(0x100): - addr = i << (addr_offset * 8) + addr = i << addr_offset * 8 - ( - addr_and_length_identifier, - addr_bytes, - mem_size_bytes, - ) = uds_memory_parameters(addr, memory_size) + addr_and_length_identifier, addr_bytes, mem_size_bytes = uds_memory_parameters( + addr, memory_size + ) pdu = bytes([sid]) if sid in [0x34, 0x35]: # RequestUpload and RequestDownload require a DataFormatIdentifier # byte that defines encryption and compression. 00 is neither. - pdu += bytes([00]) + pdu += bytes([0]) pdu += bytes([addr_and_length_identifier]) pdu += addr_bytes + mem_size_bytes pdu += data if data else b"" - if args.check_session and i % args.check_session == 0: + if self.config.check_session and i % self.config.check_session == 0: # Check session and try to recover from wrong session (max 3 times), else skip session - if not await self.ecu.check_and_set_session(args.session): + if not await self.ecu.check_and_set_session(self.config.session): logger.error( - f"Aborting scan on session {g_repr(args.session)}; " + f"Aborting scan on session {g_repr(self.config.session)}; " + f"current memory address was {g_repr(addr)}" ) sys.exit(1) diff --git a/src/gallia/commands/scan/uds/reset.py b/src/gallia/commands/scan/uds/reset.py index 54f1a6727..ea5f4511d 100644 --- a/src/gallia/commands/scan/uds/reset.py +++ b/src/gallia/commands/scan/uds/reset.py @@ -4,69 +4,53 @@ import reprlib import sys -from argparse import Namespace from typing import Any from gallia.command import UDSScanner +from gallia.command.config import Field, Ranges, Ranges2D +from gallia.command.uds import UDSScannerConfig from gallia.log import get_logger from gallia.services.uds import NegativeResponse, UDSRequestConfig, UDSResponse -from gallia.services.uds.core.exception import ( - IllegalResponse, - UnexpectedNegativeResponse, -) +from gallia.services.uds.core.exception import IllegalResponse, UnexpectedNegativeResponse from gallia.services.uds.core.utils import g_repr from gallia.services.uds.helpers import suggests_sub_function_not_supported -from gallia.utils import ParseSkips, auto_int logger = get_logger(__name__) +class ResetScannerConfig(UDSScannerConfig): + sessions: Ranges | None = Field( + None, description="Set list of sessions to be tested; all if None" + ) + skip: Ranges2D = Field( + {}, + metavar="SESSION_ID:ID", + description="The sub functions to be skipped per session.\nA session specific skip is given by :\nwhere is a comma separated list of single ids or id ranges using a dash.\nExamples:\n - 0x01:0xf3\n - 0x10-0x2f\n - 0x01:0xf3,0x10-0x2f\nMultiple session specific skips are separated by space.\nOnly takes affect if --sessions is given.\n", + ) + skip_check_session: bool = Field( + False, description="skip check current session; only takes affect if --sessions is given" + ) + + class ResetScanner(UDSScanner): """Scan ecu_reset""" + CONFIG_TYPE = ResetScannerConfig SHORT_HELP = "identifier scan in ECUReset" - COMMAND = "reset" - - def configure_parser(self) -> None: - self.parser.add_argument( - "--sessions", - type=auto_int, - nargs="*", - help="Set list of sessions to be tested; all if None", - ) - self.parser.add_argument( - "--skip", - nargs="+", - default={}, - type=str, - action=ParseSkips, - help=""" - The sub functions to be skipped per session. - A session specific skip is given by : - where is a comma separated list of single ids or id ranges using a dash. - Examples: - - 0x01:0xf3 - - 0x10-0x2f - - 0x01:0xf3,0x10-0x2f - Multiple session specific skips are separated by space. - Only takes affect if --sessions is given. - """, - ) - self.parser.add_argument( - "--skip-check-session", - action="store_true", - help="skip check current session; only takes affect if --sessions is given", - ) - - async def main(self, args: Namespace) -> None: - if args.sessions is None: - await self.perform_scan(args) + + def __init__(self, config: ResetScannerConfig): + super().__init__(config) + self.config: ResetScannerConfig = config + + async def main(self) -> None: + if self.config.sessions is None: + await self.perform_scan() else: - sessions = args.sessions + sessions = self.config.sessions logger.info(f"testing sessions {g_repr(sessions)}") # TODO: Unified shortened output necessary here - logger.info(f"skipping identifiers {reprlib.repr(args.skip)}") + logger.info(f"skipping identifiers {reprlib.repr(self.config.skip)}") for session in sessions: logger.notice(f"Switching to session {g_repr(session)}") @@ -76,21 +60,23 @@ async def main(self, args: Namespace) -> None: continue logger.result(f"Scanning in session: {g_repr(session)}") - await self.perform_scan(args, session) + await self.perform_scan(session) - await self.ecu.leave_session(session, sleep=args.power_cycle_sleep) + await self.ecu.leave_session(session, sleep=self.config.power_cycle_sleep) - async def perform_scan(self, args: Namespace, session: None | int = None) -> None: + async def perform_scan(self, session: None | int = None) -> None: l_ok: list[int] = [] l_timeout: list[int] = [] l_error: list[Any] = [] for sub_func in range(0x01, 0x80): - if session in args.skip and sub_func in args.skip[session]: + if session in self.config.skip and ( + (session_skip := self.config.skip[session]) is None or sub_func in session_skip + ): logger.notice(f"skipping subFunc: {g_repr(sub_func)} because of --skip") continue - if session is not None and not args.skip_check_session: + if session is not None and (not self.config.skip_check_session): # Check session and try to recover from wrong session (max 3 times), else skip session if not await self.ecu.check_and_set_session(session): logger.error( @@ -130,7 +116,7 @@ async def perform_scan(self, args: Namespace, session: None | int = None) -> Non except TimeoutError: l_timeout.append(sub_func) - if not args.power_cycle: + if not self.config.power_cycle: logger.error(f"ECU did not respond after reset level {g_repr(sub_func)}; exit") sys.exit(1) @@ -138,7 +124,7 @@ async def perform_scan(self, args: Namespace, session: None | int = None) -> Non f"ECU did not respond after reset level {g_repr(sub_func)}; try power cycle…" ) try: - await self.ecu.power_cycle(args.power_cycle_sleep) + await self.ecu.power_cycle(self.config.power_cycle_sleep) await self.ecu.wait_for_ecu() except (TimeoutError, ConnectionError) as e: logger.error(f"Failed to recover ECU: {g_repr(e)}; exit") @@ -150,7 +136,7 @@ async def perform_scan(self, args: Namespace, session: None | int = None) -> Non continue # We reach this code only for positive responses - if session is not None and not args.skip_check_session: + if session is not None and (not self.config.skip_check_session): try: current_session = await self.ecu.read_session() logger.info( diff --git a/src/gallia/commands/scan/uds/sa_dump_seeds.py b/src/gallia/commands/scan/uds/sa_dump_seeds.py index 453cfbd1e..c3150cc6b 100644 --- a/src/gallia/commands/scan/uds/sa_dump_seeds.py +++ b/src/gallia/commands/scan/uds/sa_dump_seeds.py @@ -3,95 +3,66 @@ # SPDX-License-Identifier: Apache-2.0 import asyncio -import binascii import sys import time -from argparse import ArgumentParser, Namespace from pathlib import Path import aiofiles from gallia.command import UDSScanner -from gallia.config import Config +from gallia.command.config import AutoInt, Field, HexBytes +from gallia.command.uds import UDSScannerConfig from gallia.log import get_logger from gallia.services.uds import NegativeResponse, UDSRequestConfig from gallia.services.uds.core.utils import g_repr -from gallia.utils import auto_int logger = get_logger(__name__) +class SASeedsDumperConfig(UDSScannerConfig): + session: AutoInt = Field( + 0x02, description="Set diagnostic session to perform test in", metavar="INT" + ) + check_session: bool = Field(False, description="Check current session with read DID") + level: AutoInt = Field( + 0x11, description="Set security access level to request seed from", metavar="INT" + ) + send_zero_key: int = Field( + 0, + description="Attempt to fool brute force protection by pretending to send a key after requesting a seed (all zero bytes, length can be specified)", + metavar="BYTE_LENGTH", + const=96, + ) + reset: int | None = Field( + None, + description="Attempt to fool brute force protection by resetting the ECU after every nth requested seed.", + const=1, + ) + duration: float = Field( + 0, + description="Run script for N minutes; zero or negative for infinite runtime (default)", + metavar="FLOAT", + ) + data_record: HexBytes = Field( + b"", description="Append an optional data record to each seed request", metavar="HEXSTRING" + ) + sleep: float | None = Field( + None, + description="Attempt to fool brute force protection by sleeping for N seconds between seed requests.", + ) + + class SASeedsDumper(UDSScanner): """This scanner tries to enable ProgrammingSession and dump seeds for 12h.""" - COMMAND = "dump-seeds" + CONFIG_TYPE = SASeedsDumperConfig SHORT_HELP = "dump security access seeds" - def __init__(self, parser: ArgumentParser, config: Config = Config()) -> None: - super().__init__(parser, config) - + def __init__(self, config: SASeedsDumperConfig): + super().__init__(config) + self.config: SASeedsDumperConfig = config self.implicit_logging = False - def configure_parser(self) -> None: - self.parser.add_argument( - "--session", - metavar="INT", - type=auto_int, - default=0x02, - help="Set diagnostic session to perform test in", - ) - self.parser.add_argument( - "--check-session", - action="store_true", - default=False, - help="Check current session with read DID", - ) - self.parser.add_argument( - "--level", - default=0x11, - metavar="INT", - type=auto_int, - help="Set security access level to request seed from", - ) - self.parser.add_argument( - "--send-zero-key", - metavar="BYTE_LENGTH", - nargs="?", - const=96, - default=0, - type=int, - help="Attempt to fool brute force protection by pretending to send a key after requesting a seed " - "(all zero bytes, length can be specified)", - ) - self.parser.add_argument( - "--reset", - nargs="?", - const=1, - default=None, - type=int, - help="Attempt to fool brute force protection by resetting the ECU after every nth requested seed.", - ) - self.parser.add_argument( - "--duration", - default=0, - type=float, - metavar="FLOAT", - help="Run script for N minutes; zero or negative for infinite runtime (default)", - ) - self.parser.add_argument( - "--data-record", - metavar="HEXSTRING", - type=binascii.unhexlify, - default=b"", - help="Append an optional data record to each seed request", - ) - self.parser.add_argument( - "--sleep", - type=float, - metavar="FLOAT", - help="Attempt to fool brute force protection by sleeping for N seconds between seed requests.", - ) - async def request_seed(self, level: int, data: bytes) -> bytes | None: resp = await self.ecu.security_access_request_seed( level, data, config=UDSRequestConfig(tags=["ANALYZE"]) @@ -124,8 +95,8 @@ def log_size(self, path: Path, time_delta: float) -> None: size_unit = "MiB" logger.notice(f"Dumping seeds with {rate:.2f}{rate_unit}/h: {size:.2f}{size_unit}") - async def main(self, args: Namespace) -> None: - session = args.session + async def main(self) -> None: + session = self.config.session logger.info(f"scanning in session: {g_repr(session)}") resp = await self.ecu.set_session(session) @@ -136,34 +107,34 @@ async def main(self, args: Namespace) -> None: i = -1 seeds_file = Path.joinpath(self.artifacts_dir, "seeds.bin") file = await aiofiles.open(seeds_file, "wb", buffering=0) - duration = args.duration * 60 + duration = self.config.duration * 60 start_time = time.time() last_seed = b"" reset = False runs_since_last_reset = 0 print_speed = False - while duration <= 0 or (time.time() - start_time) < duration: + while duration <= 0 or time.time() - start_time < duration: # Print information about current dump speed every `interval` seconds. # As request/response times can jitter a few seconds, we 'arm' the print # in one half and 'shoot' once in the other half. interval = 60 i = int(time.time() - start_time) % interval - if i >= (interval // 2): + if i >= interval // 2: print_speed = True - elif i < (interval // 2) and print_speed is True: + elif i < interval // 2 and print_speed is True: self.log_size(seeds_file, time.time() - start_time) print_speed = False - if args.check_session or reset: - if not await self.ecu.check_and_set_session(args.session): - logger.error(f"ECU persistently lost session {g_repr(args.session)}") + if self.config.check_session or reset: + if not await self.ecu.check_and_set_session(self.config.session): + logger.error(f"ECU persistently lost session {g_repr(self.config.session)}") sys.exit(1) reset = False try: - seed = await self.request_seed(args.level, args.data_record) + seed = await self.request_seed(self.config.level, self.config.data_record) except TimeoutError: logger.error("Timeout while requesting seed") continue @@ -183,9 +154,9 @@ async def main(self, args: Namespace) -> None: last_seed = seed - if args.send_zero_key > 0: + if self.config.send_zero_key > 0: try: - if await self.send_key(args.level, bytes(args.send_zero_key)): + if await self.send_key(self.config.level, bytes(self.config.send_zero_key)): break except TimeoutError: logger.warning("Timeout while sending key") @@ -196,7 +167,7 @@ async def main(self, args: Namespace) -> None: runs_since_last_reset += 1 - if runs_since_last_reset == args.reset: + if runs_since_last_reset == self.config.reset: reset = True runs_since_last_reset = 0 @@ -210,18 +181,17 @@ async def main(self, args: Namespace) -> None: sys.exit(1) except ConnectionError: logger.warning( - "Lost connection to the ECU after performing a reset. " - "Attempting to reconnect…" + "Lost connection to the ECU after performing a reset. Attempting to reconnect…" ) await self.ecu.reconnect() # Re-enter session. Checking/logging will be done at the beginning of next iteration await self.ecu.set_session(session) - if args.sleep is not None: - logger.info(f"Sleeping for {args.sleep} seconds between seed requests…") - await asyncio.sleep(args.sleep) + if self.config.sleep is not None: + logger.info(f"Sleeping for {self.config.sleep} seconds between seed requests…") + await asyncio.sleep(self.config.sleep) await file.close() self.log_size(seeds_file, time.time() - start_time) - await self.ecu.leave_session(session, sleep=args.power_cycle_sleep) + await self.ecu.leave_session(session, sleep=self.config.power_cycle_sleep) diff --git a/src/gallia/commands/scan/uds/services.py b/src/gallia/commands/scan/uds/services.py index 00cb1f079..8fd65ceac 100644 --- a/src/gallia/commands/scan/uds/services.py +++ b/src/gallia/commands/scan/uds/services.py @@ -3,10 +3,11 @@ # SPDX-License-Identifier: Apache-2.0 import reprlib -from argparse import BooleanOptionalAction, Namespace from typing import Any from gallia.command import UDSScanner +from gallia.command.config import Field, Ranges, Ranges2D +from gallia.command.uds import UDSScannerConfig from gallia.log import get_logger from gallia.services.uds import ( NegativeResponse, @@ -17,76 +18,55 @@ ) from gallia.services.uds.core.exception import MalformedResponse, UDSException from gallia.services.uds.core.utils import g_repr -from gallia.utils import ParseSkips, auto_int logger = get_logger(__name__) +class ServicesScannerConfig(UDSScannerConfig): + sessions: Ranges | None = Field( + None, description="Set list of sessions to be tested; all if None" + ) + check_session: bool = Field( + False, description="check current session; only takes affect if --sessions is given" + ) + scan_response_ids: bool = Field(False, description="Include IDs in scan with reply flag set") + auto_reset: bool = Field(False, description="Reset ECU with UDS ECU Reset before every request") + skip: Ranges2D = Field( + {}, + metavar="SESSION_ID:ID", + description="\nThe service IDs to be skipped per session.\nA session specific skip is given by :\nwhere is a comma separated list of single ids or id ranges using a dash.\nExamples:\n - 0x01:0xf3\n - 0x10-0x2f\n - 0x01:0xf3,0x10-0x2f\nMultiple session specific skips are separated by space.\nOnly takes affect if --sessions is given.\n", + ) + + class ServicesScanner(UDSScanner): """Iterate sessions and services and find endpoints""" - COMMAND = "services" + CONFIG_TYPE = ServicesScannerConfig SHORT_HELP = "service scan on an ECU" EPILOG = "https://fraunhofer-aisec.github.io/gallia/uds/scan_modes.html#service-scan" - def configure_parser(self) -> None: - self.parser.add_argument( - "--sessions", - nargs="*", - type=auto_int, - default=None, - help="Set list of sessions to be tested; all if None", - ) - self.parser.add_argument( - "--check-session", - action="store_true", - default=False, - help="check current session; only takes affect if --sessions is given", - ) - self.parser.add_argument( - "--scan-response-ids", - default=False, - action=BooleanOptionalAction, - help="Include IDs in scan with reply flag set", - ) - self.parser.add_argument( - "--auto-reset", - action="store_true", - default=False, - help="Reset ECU with UDS ECU Reset before every request", - ) - self.parser.add_argument( - "--skip", - nargs="+", - default={}, - type=str, - action=ParseSkips, - help=""" - The service IDs to be skipped per session. - A session specific skip is given by : - where is a comma separated list of single ids or id ranges using a dash. - Examples: - - 0x01:0xf3 - - 0x10-0x2f - - 0x01:0xf3,0x10-0x2f - Multiple session specific skips are separated by space. - Only takes affect if --sessions is given. - """, - ) - - async def main(self, args: Namespace) -> None: + def __init__(self, config: ServicesScannerConfig): + super().__init__(config) + self.config: ServicesScannerConfig = config + self.result: list[tuple[int, int]] = [] + + async def main(self) -> None: self.ecu.max_retry = 0 found: dict[int, dict[int, Any]] = {} - if args.sessions is None: - found[0] = await self.perform_scan(args) + if self.config.sessions is None: + found[0] = await self.perform_scan() else: - sessions = [s for s in args.sessions if s not in args.skip or args.skip[s] is not None] + sessions = [ + s + for s in self.config.sessions + if s not in self.config.skip or self.config.skip[s] is not None + ] logger.info(f"testing sessions {g_repr(sessions)}") # TODO: Unified shortened output necessary here - logger.info(f"skipping identifiers {reprlib.repr(args.skip)}") + logger.info(f"skipping identifiers {reprlib.repr(self.config.skip)}") for session in sessions: logger.info(f"Changing to session {g_repr(session)}") @@ -94,10 +74,7 @@ async def main(self, args: Namespace) -> None: resp: UDSResponse = await self.ecu.set_session( session, UDSRequestConfig(tags=["preparation"]) ) - except ( - UDSException, - RuntimeError, - ) as e: # FIXME why catch RuntimeError? + except (UDSException, RuntimeError) as e: # FIXME why catch RuntimeError? logger.warning( f"Could not complete session change to {g_repr(session)}: {g_repr(e)}; skipping session" ) @@ -110,9 +87,9 @@ async def main(self, args: Namespace) -> None: logger.result(f"scanning in session {g_repr(session)}") - found[session] = await self.perform_scan(args, session) + found[session] = await self.perform_scan(session) - await self.ecu.leave_session(session, sleep=args.power_cycle_sleep) + await self.ecu.leave_session(session, sleep=self.config.power_cycle_sleep) for key, value in found.items(): logger.result(f"findings in session 0x{key:02X}:") @@ -123,21 +100,23 @@ async def main(self, args: Namespace) -> None: except Exception: logger.result(f" [{g_repr(sid)}] vendor specific sid: {data}") - async def perform_scan(self, args: Namespace, session: None | int = None) -> dict[int, Any]: + async def perform_scan(self, session: None | int = None) -> dict[int, Any]: result: dict[int, Any] = {} # Starts at 0x00, see first loop iteration. sid = -1 while sid < 0xFF: sid += 1 - if sid & 0x40 and not args.scan_response_ids: + if sid & 0x40 and (not self.config.scan_response_ids): continue - if session in args.skip and sid in args.skip[session]: + if session in self.config.skip and ( + (session_skip := self.config.skip[session]) is None or sid in session_skip + ): logger.info(f"{g_repr(sid)}: skipped") continue - if session is not None and args.check_session: + if session is not None and self.config.check_session: if not await self.ecu.check_and_set_session(session): logger.error( f"Aborting scan on session {g_repr(session)}; current SID was {g_repr(sid)}" @@ -163,7 +142,7 @@ async def perform_scan(self, args: Namespace, session: None | int = None) -> dic break if isinstance(resp, NegativeResponse) and resp.response_code in [ - UDSErrorCodes.incorrectMessageLengthOrInvalidFormat, + UDSErrorCodes.incorrectMessageLengthOrInvalidFormat ]: continue diff --git a/src/gallia/commands/scan/uds/sessions.py b/src/gallia/commands/scan/uds/sessions.py index 4ac76e796..fea900144 100644 --- a/src/gallia/commands/scan/uds/sessions.py +++ b/src/gallia/commands/scan/uds/sessions.py @@ -4,68 +4,53 @@ import asyncio import sys -from argparse import Namespace from typing import Any from gallia.command import UDSScanner +from gallia.command.config import AutoInt, Field, Ranges +from gallia.command.uds import UDSScannerConfig from gallia.log import get_logger -from gallia.services.uds import ( - NegativeResponse, - UDSErrorCodes, - UDSRequestConfig, - UDSResponse, -) +from gallia.services.uds import NegativeResponse, UDSErrorCodes, UDSRequestConfig, UDSResponse from gallia.services.uds.core.constants import EcuResetSubFuncs from gallia.services.uds.core.service import DiagnosticSessionControlResponse from gallia.services.uds.core.utils import g_repr -from gallia.utils import auto_int logger = get_logger(__name__) +class SessionsScannerConfig(UDSScannerConfig): + depth: AutoInt | None = Field(None, description="Specify max scanning depth.") + sleep: AutoInt = Field( + 0, + description="Sleep this amount of seconds after changing to DefaultSession", + metavar="SECONDS", + ) + skip: Ranges = Field( + [], description="List with session IDs to skip while scanning", metavar="SESSION_ID" + ) + with_hooks: bool = Field(False, description="Use hooks in case of a ConditionsNotCorrect error") + reset: AutoInt | None = Field( + None, + description="Reset the ECU after each iteration with the optionally given reset level", + const=0x01, + ) + fast: bool = Field( + False, + description="Only search for new sessions once in a particular session, i.e. ignore different stacks", + ) + + class SessionsScanner(UDSScanner): """Iterate Sessions""" - COMMAND = "sessions" + CONFIG_TYPE = SessionsScannerConfig SHORT_HELP = "session scan on an ECU" - def configure_parser(self) -> None: - self.parser.add_argument( - "--depth", type=auto_int, default=None, help="Specify max scanning depth." - ) - self.parser.add_argument( - "--sleep", - metavar="SECONDS", - type=auto_int, - default=0, - help="Sleep this amount of seconds after changing to DefaultSession", - ) - self.parser.add_argument( - "--skip", - metavar="SESSION_ID", - type=auto_int, - default=[], - nargs="*", - help="List with session IDs to skip while scanning", - ) - self.parser.add_argument( - "--with-hooks", - action="store_true", - help="Use hooks in case of a ConditionsNotCorrect error", - ) - self.parser.add_argument( - "--reset", - nargs="?", - default=None, - const=0x01, - type=lambda x: int(x, 0), - help="Reset the ECU after each iteration with the optionally given reset level", - ) - self.parser.add_argument( - "--fast", - action="store_true", - help="Only search for new sessions once in a particular session, i.e. ignore different stacks", - ) + def __init__(self, config: SessionsScannerConfig): + super().__init__(config) + self.config: SessionsScannerConfig = config + + self.result: list[int] = [] async def set_session_with_hooks_handling( self, session: int, use_hooks: bool @@ -81,9 +66,7 @@ async def set_session_with_hooks_handling( logger.notice(f"Received conditionsNotCorrect for session {g_repr(session)}") if not use_hooks: logger.warning( - f"Session {g_repr(session)} is potentially available but could not be entered. " - f"Use --with-hooks to try to enter the session using hooks to scan for " - f"transitions available from that session." + f"Session {g_repr(session)} is potentially available but could not be entered. Use --with-hooks to try to enter the session using hooks to scan for transitions available from that session." ) return resp @@ -108,20 +91,17 @@ async def recover_stack(self, stack: list[int], use_hooks: bool) -> bool: if isinstance(resp, NegativeResponse): logger.error( - f"Could not change to session {g_repr(session)} as part of stack: {resp}. " - f"Try with --reset to reset between each iteration." + f"Could not change to session {g_repr(session)} as part of stack: {resp}. Try with --reset to reset between each iteration." ) return False except Exception as e: logger.error( - f"Could not change to session {g_repr(session)} as part of stack: {g_repr(e)}. " - f"Try with --reset to reset between each iteration." + f"Could not change to session {g_repr(session)} as part of stack: {g_repr(e)}. Try with --reset to reset between each iteration." ) return False return True - async def main(self, args: Namespace) -> None: - self.result: list[int] = [] + async def main(self) -> None: found: dict[int, list[list[int]]] = {0: [[0x01]]} positive_results: list[dict[str, Any]] = [] negative_results: list[dict[str, Any]] = [] @@ -131,14 +111,14 @@ async def main(self, args: Namespace) -> None: sessions = list(range(1, 0x80)) depth = 0 - while (args.depth is None or depth < args.depth) and len(found[depth]) > 0: + while (self.config.depth is None or depth < self.config.depth) and len(found[depth]) > 0: depth += 1 found[depth] = [] logger.info(f"Depth: {depth}") for stack in found[depth - 1]: - if args.fast and stack[-1] in search_sessions: + if self.config.fast and stack[-1] in search_sessions: continue search_sessions.append(stack[-1]) @@ -147,27 +127,25 @@ async def main(self, args: Namespace) -> None: logger.info(f"Starting from session: {g_repr(stack[-1])}") for session in sessions: - if session in args.skip: + if session in self.config.skip: logger.info(f"Skipping session {g_repr(session)} as requested") continue - if args.reset: + if self.config.reset: try: logger.info("Resetting the ECU") - resp: UDSResponse = await self.ecu.ecu_reset(args.reset) + resp: UDSResponse = await self.ecu.ecu_reset(self.config.reset) if isinstance(resp, NegativeResponse): logger.warning( - f"Could not reset ECU with {EcuResetSubFuncs(args.reset).name if args.reset in iter(EcuResetSubFuncs) else args.reset}: {resp}" - f"; continuing without reset" + f"Could not reset ECU with {(EcuResetSubFuncs(self.config.reset).name if self.config.reset in iter(EcuResetSubFuncs) else self.config.reset)}: {resp}; continuing without reset" # type: ignore[operator] ) else: logger.info("Waiting for the ECU to recover…") - await self.ecu.wait_for_ecu(timeout=args.timeout) + await self.ecu.wait_for_ecu(timeout=self.config.timeout) except (TimeoutError, ConnectionError): logger.warning( - "Lost connection to the ECU after performing a reset. " - "Attempting to reconnect…" + "Lost connection to the ECU after performing a reset. Attempting to reconnect…" ) await self.ecu.reconnect() @@ -181,16 +159,20 @@ async def main(self, args: Namespace) -> None: logger.error(f"Could not change to default session: {e!r}") sys.exit(1) - logger.debug(f"Sleeping for {args.sleep}s after changing to DefaultSession") - await asyncio.sleep(args.sleep) + logger.debug( + f"Sleeping for {self.config.sleep}s after changing to DefaultSession" + ) + await asyncio.sleep(self.config.sleep) logger.debug("Recovering the current session stack") - if not await self.recover_stack(stack, args.with_hooks): + if not await self.recover_stack(stack, self.config.with_hooks): sys.exit(1) try: logger.debug(f"Attempting to change to session {session:#04x}") - resp = await self.set_session_with_hooks_handling(session, args.with_hooks) + resp = await self.set_session_with_hooks_handling( + session, self.config.with_hooks + ) # do not ignore NCR subFunctionNotSupportedInActiveSession in this case if ( @@ -215,11 +197,7 @@ async def main(self, args: Namespace) -> None: ) else: negative_results.append( - { - "session": session, - "stack": stack, - "error": resp.response_code, - } + {"session": session, "stack": stack, "error": resp.response_code} ) except TimeoutError: @@ -262,6 +240,5 @@ async def main(self, args: Namespace) -> None: await self.db_handler.insert_session_transition(session, res["stack"]) logger.result( - f"\tvia stack: {'->'.join([f'{g_repr(i)}' for i in res['stack']])} " - f"(NRC: {res['error']})" + f"\tvia stack: {'->'.join([f'{g_repr(i)}' for i in res['stack']])} (NRC: {res['error']})" ) diff --git a/src/gallia/commands/script/flexray.py b/src/gallia/commands/script/flexray.py index 0d10e30c6..e57a5a182 100644 --- a/src/gallia/commands/script/flexray.py +++ b/src/gallia/commands/script/flexray.py @@ -5,40 +5,33 @@ import base64 import pickle import sys -from argparse import BooleanOptionalAction, Namespace + +from gallia.command.base import AsyncScriptConfig, ScriptConfig +from gallia.command.config import AutoInt, Field, Ranges assert sys.platform == "win32" from gallia.command import AsyncScript, Script from gallia.transports._ctypes_vector_xl_wrapper import FlexRayCtypesBackend from gallia.transports.flexray_vector import FlexRayFrame, RawFlexRayTransport, parse_frame_type -from gallia.utils import auto_int + + +class FRDumpConfig(AsyncScriptConfig): + target_slot: AutoInt | None = Field(description="the target flexray slot") + isotp: bool = Field(False, description="the target flexray slot") + filter_null_frames: bool = Field(True, description="filter mysterious null frames out") + slot: Ranges = Field([], description="filter on flexray slot") class FRDump(AsyncScript): """Dump the content of the flexray bus""" - COMMAND = "fr-dump" + CONFIG_TYPE = FRDumpConfig SHORT_HELP = "runs a helper tool that dumps flexray bus traffic to stdout" - def configure_parser(self) -> None: - self.parser.add_argument( - "--target-slot", - type=auto_int, - help="the target flexray slot", - ) - self.parser.add_argument( - "--isotp", - action="store_true", - help="the target flexray slot", - ) - self.parser.add_argument( - "--filter-null-frames", - action=BooleanOptionalAction, - default=True, - help="filter mysterious null frames out", - ) - self.parser.add_argument("slot", type=auto_int, help="filter on flexray slot", nargs="*") + def __init__(self, config: FRDumpConfig): + super().__init__(config) + self.config: FRDumpConfig = config @staticmethod def poor_mans_dissect(frame: FlexRayFrame) -> str: @@ -53,12 +46,12 @@ def poor_mans_dissect(frame: FlexRayFrame) -> str: return res - async def main(self, args: Namespace) -> None: + async def main(self) -> None: tp = await RawFlexRayTransport.connect("fr-raw:", None) - if args.slot: + if len(self.config.slot) > 0: tp.add_block_all_filter() - for slot in args.slot: + for slot in self.config.slot: tp.set_acceptance_filter(slot) tp.activate_channel() @@ -66,43 +59,39 @@ async def main(self, args: Namespace) -> None: while True: frame = await tp.read_frame() - if args.filter_null_frames is True: + if self.config.filter_null_frames is True: # Best effort; in our use case this was the ISO-TP header. # The first ISO-TP header byte is never 0x00. if frame.data[0] == 0x00: continue - if args.isotp: + if self.config.isotp: print(self.poor_mans_dissect(frame)) else: print(f"slot_id: {frame.slot_id:03d}; data: {frame.data.hex()}") -class FRDumpConfig(Script): +class FRConfigDumpConfig(ScriptConfig): + channel: int | None = Field(description="the channel number of the flexray device") + pretty: bool = Field(False, description="pretty print the configuration", short="p") + + +class FRConfigDump(Script): """Dump the flexray configuration as base64""" - COMMAND = "fr-dump-config" + CONFIG_TYPE = FRConfigDumpConfig SHORT_HELP = "Dump the flexray configuration as base64" - def configure_parser(self) -> None: - self.parser.add_argument( - "--channel", - help="the channel number of the flexray device", - ) - self.parser.add_argument( - "-p", - "--pretty", - action="store_true", - default=False, - help="pretty print the configuration", - ) - - def main(self, args: Namespace) -> None: - backend = FlexRayCtypesBackend.create(args.channel) + def __init__(self, config: FRConfigDumpConfig): + super().__init__(config) + self.config: FRConfigDumpConfig = config + + def main(self) -> None: + backend = FlexRayCtypesBackend.create(self.config.channel) raw_config = backend.get_configuration() config = pickle.dumps(raw_config) - if args.pretty: + if self.config.pretty: print(raw_config) else: print(base64.b64encode(config)) diff --git a/src/gallia/commands/script/rerun.py b/src/gallia/commands/script/rerun.py new file mode 100644 index 000000000..ec92b2d1a --- /dev/null +++ b/src/gallia/commands/script/rerun.py @@ -0,0 +1,95 @@ +# SPDX-FileCopyrightText: AISEC Pentesting Team +# +# SPDX-License-Identifier: Apache-2.0 + +import asyncio +import importlib +import json +import sys +from collections.abc import Mapping +from pathlib import Path +from typing import Any, Self + +import aiosqlite +from pydantic import model_validator + +from gallia.command import BaseCommand +from gallia.command.base import Script, ScriptConfig +from gallia.command.config import Field +from gallia.log import get_logger + +logger = get_logger(__name__) + + +class RerunnerConfig(ScriptConfig): + id: int | None = Field(None, description="The id of the run_meta entry in the db") + file: Path | None = Field(None, description="The path of the META.json in the logs") + + @model_validator(mode="after") + def check_transport_requirements(self) -> Self: + if self.id is not None and self.db is None: + raise ValueError("This script requires a database connection") + + return self + + @model_validator(mode="after") + def check_meta_source(self) -> Self: + if not (self.id is None) ^ (self.file is None): + raise ValueError("Exactly one of id or file is required") + + return self + + +class Rerunner(Script): + CONFIG_TYPE = RerunnerConfig + SHORT_HELP = "Rerun a previous gallia command based on its run_meta in the database" + + def __init__(self, config: RerunnerConfig): + super().__init__(config) + self.config: RerunnerConfig = config + + def main(self) -> None: + if self.config.id is not None: + script, config = self.db() + else: + script, config = self.file() + + script_parts = script.split(".") + module = ".".join(script_parts[:-1]) + class_name = script_parts[-1] + + logger.info(f"Rerunning run {self.config.id} ({class_name}) with: {config}") + + gallia_class: type[BaseCommand] = getattr(importlib.import_module(module), class_name) + command = gallia_class(gallia_class.CONFIG_TYPE(**config)) + + sys.exit(command.entry_point()) + + def db(self) -> tuple[str, Mapping[str, Any]]: + assert self.config.id is not None + + query = "SELECT script, config " "FROM run_meta " "WHERE id = ?" + parameters = (self.config.id,) + + assert self.db_handler is not None + + connection = self.db_handler.connection + + assert connection is not None + + cursor: aiosqlite.Cursor = asyncio.run(connection.execute(query, parameters)) + row = asyncio.run(cursor.fetchone()) + + if row is None: + logger.error(f"There id no run_meta entry with the id {self.config.id}") + sys.exit(1) + + return row[0], json.loads(row[1]) + + def file(self) -> tuple[str, Mapping[str, Any]]: + assert self.config.file is not None + + with self.config.file.open("r") as f: + content = json.load(f) + + return content["command"], content["config"] diff --git a/src/gallia/commands/script/vecu.py b/src/gallia/commands/script/vecu.py index 2c4655bfc..ba7e667ba 100644 --- a/src/gallia/commands/script/vecu.py +++ b/src/gallia/commands/script/vecu.py @@ -2,15 +2,18 @@ # # SPDX-License-Identifier: Apache-2.0 -import json import random import sys -from argparse import Namespace +from abc import ABC, abstractmethod from pathlib import Path +from typing import Any, Self + +from pydantic import field_serializer, model_validator from gallia.command import AsyncScript +from gallia.command.base import AsyncScriptConfig +from gallia.command.config import Field, Idempotent from gallia.log import get_logger -from gallia.services.uds.core.constants import UDSIsoServices from gallia.services.uds.server import ( DBUDSServer, RandomUDSServer, @@ -20,84 +23,66 @@ ) from gallia.transports import TargetURI, TransportScheme -dynamic_attr_prefix = "dynamic_attr_" +logger = get_logger(__name__) + +class VirtualECUConfig(AsyncScriptConfig): + target: Idempotent[TargetURI] = Field(positional=True) -logger = get_logger(__name__) + @field_serializer("target") + def serialize_target_uri(self, target_uri: TargetURI | None) -> Any: + if target_uri is None: + return None + + return target_uri.raw + + @model_validator(mode="after") + def check_transport_requirements(self) -> Self: + supported: list[TransportScheme] = [] + + if sys.platform.startswith("linux"): + supported = [TransportScheme.TCP, TransportScheme.ISOTP, TransportScheme.UNIX_LINES] + + if sys.platform.startswith("win32"): + supported = [TransportScheme.TCP] + + if self.target.scheme not in supported: + raise ValueError(f"Unsupported transport scheme! Use any of {supported}") + + return self + + +class DbVirtualECUConfig(VirtualECUConfig, DBUDSServer.Behavior): + path: Path = Field(positional=True) + ecu: str | None + properties: dict[str, Any] | None = Field(metavar="PROPERTIES") -class VirtualECU(AsyncScript): +class RngVirtualECUConfig( + VirtualECUConfig, RandomUDSServer.Behavior, RandomUDSServer.RandomnessParameters +): + seed: int = Field( + random.randint(0, sys.maxsize), + description="Set the seed of the internal random number generator. This supports reproducibility.", + ) + + +class VirtualECU(AsyncScript, ABC): """Spawn a virtual ECU for testing purposes""" - COMMAND = "vecu" - SHORT_HELP = "spawn a virtual UDS ECU" EPILOG = "https://fraunhofer-aisec.github.io/gallia/uds/virtual_ecu.html" - def configure_parser(self) -> None: - self.parser.add_argument( - "target", - type=TargetURI, - ) - - sub_parsers = self.parser.add_subparsers(dest="cmd") - sub_parsers.required = True - - db = sub_parsers.add_parser("db") - db.add_argument( - "path", - type=Path, - ) - db.add_argument("--ecu", type=str) - db.add_argument("--properties", type=json.loads) - # db.set_defaults(yolo=True) - - rng = sub_parsers.add_parser("rng") - rng.add_argument( - "--seed", - default=random.randint(0, sys.maxsize), - help="Set the seed of the internal random number generator. This supports reproducibility.", - ) - - # Expose all other parameters of the random UDS server - tmp = RandomUDSServer(0) - parent_attrs = True - - for v in tmp.__dict__: - if parent_attrs: - if v == "seed": - parent_attrs = False - - if v.startswith("use_default_response_if"): - self.parser.add_argument(f"--{v}", dest=f"{dynamic_attr_prefix}{v}") - - continue - - if not v.startswith("_"): - rng.add_argument(f"--{v}", dest=f"{dynamic_attr_prefix}{v}") - - async def main(self, args: Namespace) -> None: - cmd: str = args.cmd - server: UDSServer - - if cmd == "db": - server = DBUDSServer(args.path, args.ecu, args.properties) - elif cmd == "rng": - server = RandomUDSServer(args.seed) - else: - raise AssertionError() - - for key, value in vars(args).items(): - if key.startswith(dynamic_attr_prefix) and value is not None: - setattr( - server, - key[len(dynamic_attr_prefix) :], - eval( - value, - {service.name: service for service in UDSIsoServices}, - ), - ) - - target: TargetURI = args.target + def __init__(self, config: VirtualECUConfig): + super().__init__(config) + self.config: VirtualECUConfig = config + + @abstractmethod + def _server(self) -> UDSServer: ... + + async def main(self) -> None: + server = self._server() + + target: TargetURI = self.config.target transport: UDSServerTransport if sys.platform.startswith("linux"): @@ -114,21 +99,41 @@ async def main(self, args: Namespace) -> None: case TransportScheme.UNIX_LINES: transport = UnixUDSServerTransport(server, target) case _: - self.parser.error( - f"Unsupported transport scheme! Use any of [" - f"{TransportScheme.TCP}, {TransportScheme.ISOTP}, {TransportScheme.UNIX_LINES}]" - ) + assert False + if sys.platform.startswith("win32"): match target.scheme: case TransportScheme.TCP: transport = TCPUDSServerTransport(server, target) case _: - self.parser.error( - f"Unsupported transport scheme! Use any of [" f"{TransportScheme.TCP}]" - ) + assert False try: await server.setup() await transport.run() finally: await server.teardown() + + +class RngVirtualECU(VirtualECU): + CONFIG_TYPE = RngVirtualECUConfig + SHORT_HELP = "Virtual ECU with randomized behavior" + + def __init__(self, config: RngVirtualECUConfig): + super().__init__(config) + self.config: RngVirtualECUConfig = config + + def _server(self) -> RandomUDSServer: + return RandomUDSServer(self.config.seed, self.config, self.config) + + +class DbVirtualECU(VirtualECU): + CONFIG_TYPE = DbVirtualECUConfig + SHORT_HELP = "Virtual ECU which mimics the behavior of an ECU according to logs in the database" + + def __init__(self, config: DbVirtualECUConfig): + super().__init__(config) + self.config: DbVirtualECUConfig = config + + def _server(self) -> DBUDSServer: + return DBUDSServer(self.config.path, self.config.ecu, self.config.properties, self.config) diff --git a/src/gallia/db/handler.py b/src/gallia/db/handler.py index c192f3dfc..9203e76c4 100644 --- a/src/gallia/db/handler.py +++ b/src/gallia/db/handler.py @@ -10,6 +10,7 @@ import aiosqlite +from gallia.command.config import GalliaBaseModel from gallia.db.log import LogMode from gallia.log import get_logger from gallia.services.uds.core.service import ( @@ -28,7 +29,7 @@ def bytes_repr(data: bytes) -> str: return bytes_repr_(data, False, None) -schema_version = "3" +schema_version = "4.0" DB_SCHEMA = f""" CREATE TABLE IF NOT EXISTS version ( @@ -50,16 +51,14 @@ def bytes_repr(data: bytes) -> str: CREATE TABLE IF NOT EXISTS run_meta ( id integer primary key, script text not null, - arguments json not null check(json_valid(arguments)), - command_meta json not null check(json_valid(arguments)), - settings json not null check(json_valid(arguments)), + config json not null check(json_valid(config)), start_time real not null, start_timezone text not null, end_time real, end_timezone text check((end_timezone is null) = (end_time is null)), exit_code int, path text, - exclude BOOLEAN + exclude boolean ); CREATE TABLE IF NOT EXISTS error_log ( level int not null, @@ -70,7 +69,7 @@ def bytes_repr(data: bytes) -> str: ); CREATE TABLE IF NOT EXISTS discovery_run ( id integer primary key, - protocol str not null, + protocol text not null, meta int references run_meta(id) on update cascade on delete cascade ); CREATE TABLE IF NOT EXISTS scan_run ( @@ -199,9 +198,7 @@ async def check_version(self) -> None: async def insert_run_meta( # noqa: PLR0913 self, script: str, - arguments: list[str], - command_meta: bytes, - settings: dict[str, str | int | float], + config: GalliaBaseModel, start_time: datetime, path: Path, ) -> None: @@ -209,16 +206,14 @@ async def insert_run_meta( # noqa: PLR0913 query = ( "INSERT INTO " - "run_meta(script, arguments, command_meta, settings, start_time, start_timezone, path, exclude) " - "VALUES (?, ?, ?, ?, ?, ?, ?, FALSE)" + "run_meta(script, config, start_time, start_timezone, path, exclude) " + "VALUES (?, ?, ?, ?, ?, FALSE)" ) cursor = await self.connection.execute( query, ( script, - json.dumps(arguments), - command_meta, - json.dumps(settings), + config.model_dump_json(), start_time.timestamp(), start_time.tzname(), str(path), diff --git a/src/gallia/plugins.py b/src/gallia/plugins.py deleted file mode 100644 index 2239a4d4f..000000000 --- a/src/gallia/plugins.py +++ /dev/null @@ -1,153 +0,0 @@ -# SPDX-FileCopyrightText: AISEC Pentesting Team -# -# SPDX-License-Identifier: Apache-2.0 - -from __future__ import annotations - -import argparse -from collections.abc import Callable -from importlib.metadata import EntryPoint, entry_points -from typing import TYPE_CHECKING, TypedDict - -from gallia.services.uds.ecu import ECU -from gallia.transports import BaseTransport, TargetURI, registry - -if TYPE_CHECKING: - from gallia.command import BaseCommand - - -class Parsers(TypedDict): - siblings: dict[str, Parsers] - parser: argparse.ArgumentParser - subparsers: argparse._SubParsersAction[argparse.ArgumentParser] - - -def load_ecu_plugin_eps() -> list[EntryPoint]: - """Loads the ``gallia_uds_ecus`` entry_point.""" - eps = entry_points() - return list(eps.select(group="gallia_uds_ecus")) - - -def load_ecu_plugins() -> list[type[ECU]]: - """Loads the ``gallia_uds_ecus`` entry_point and - imports the ecu classes with some sanity checks.""" - ecus = [] - for ep in load_ecu_plugin_eps(): - for t in ep.load(): - if not issubclass(t, ECU): - raise ValueError(f"entry_point {t} not derived from ECU") - ecus.append(t) - return ecus - - -def load_ecu(vendor: str) -> type[ECU]: - """Selects an ecu class depending on a vendor string. - The lookup is performed in builtin ecus and all - classes behind the ``gallia_uds_ecus`` entry_point. - The vendor string ``default`` selects a generic ECU. - """ - if vendor == "default": - return ECU - - for ecu in load_ecu_plugins(): - if vendor == ecu.OEM: - return ecu - - raise ValueError(f"no such OEM: '{vendor}'") - - -def load_command_plugin_eps() -> list[EntryPoint]: - """Loads the ``gallia_commands`` entry_point.""" - eps = entry_points() - return list(eps.select(group="gallia_commands")) - - -def load_command_plugins() -> list[type[BaseCommand]]: - """Loads the ``gallia_commands`` entry_point and - imports the command classes with some sanity checks.""" - out = [] - for ep in load_command_plugin_eps(): - for t in ep.load(): - # TODO: Find out how to avoid the circular dep. - # if not issubclass(t, BaseCommand): - # raise ValueError(f"{type(t)} not derived from BaseCommand") - out.append(t) - return out - - -def load_cli_init_plugin_eps() -> list[EntryPoint]: - """Loads the ``gallia_cli_init`` entry_point.""" - eps = entry_points() - return list(eps.select(group="gallia_cli_init")) - - -def load_cli_init_plugins() -> list[Callable[[Parsers], None]]: - """Loads the ``gallia_cli_init`` entry_point and - imports the functions behind it.""" - out = [] - for entry in load_cli_init_plugin_eps(): - out.append(entry.load()) - return out - - -def load_transport_plugin_eps() -> list[EntryPoint]: - """Loads the ``gallia_transports`` entry_point.""" - eps = entry_points() - return list(eps.select(group="gallia_transports")) - - -def load_transport_plugins() -> list[type[BaseTransport]]: - """Loads the ``gallia_transports`` entry_point and - imports the transport classes with some sanity checks. - """ - out = [] - for ep in load_transport_plugin_eps(): - for t in ep.load(): - if not issubclass(t, BaseTransport): - raise ValueError(f"{type(t)} not derived from BaseTransport") - out.append(t) - return out - - -def load_transport(target: TargetURI) -> type[BaseTransport]: - """Selects a transport class depending on a TargetURI. - The lookup is performed in builtin transports and all - classes behind the ``gallia_transports`` entry_point. - """ - transports = registry[:] - transports += load_transport_plugins() - - for transport in transports: - if target.scheme == transport.SCHEME: - return transport - - raise ValueError(f"no transport for {target}") - - -def add_cli_group( # noqa: PLR0913 - parent: Parsers, - group: str, - help_: str, - metavar: str, - description: str | None = None, - epilog: str | None = None, -) -> None: - """Adds a group to the gallia CLI interface. The arguments - correspond to the arguments of :meth:`argparse.ArgumentParser.add_argument()`. - The ``parent`` argument must contain the relevant entry point to the cli - parse tree. The parse tree is passed to the entry_point ``gallia_cli_init``. - """ - parser = parent["subparsers"].add_parser( - group, - help=help_, - description=description, - epilog=epilog, - ) - parser.set_defaults(usage_func=parser.print_usage) - parser.set_defaults(help_func=parser.print_help) - - parent["siblings"][group] = { - "siblings": {}, - "parser": parser, - "subparsers": parser.add_subparsers(metavar=metavar), - } diff --git a/src/gallia/plugins/__init__.py b/src/gallia/plugins/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/gallia/plugins/plugin.py b/src/gallia/plugins/plugin.py new file mode 100644 index 000000000..6a30726c1 --- /dev/null +++ b/src/gallia/plugins/plugin.py @@ -0,0 +1,147 @@ +# SPDX-FileCopyrightText: AISEC Pentesting Team +# +# SPDX-License-Identifier: Apache-2.0 + +from abc import ABC, abstractmethod +from collections.abc import Mapping, MutableMapping +from dataclasses import dataclass +from importlib.metadata import entry_points +from typing import Union + +from gallia.command import BaseCommand +from gallia.services.uds import ECU +from gallia.transports import BaseTransport, TargetURI + + +@dataclass +class CommandTree: + description: str | None + subtree: MutableMapping[str, Union["CommandTree", type[BaseCommand]]] + + +class Plugin(ABC): + @classmethod + @abstractmethod + def name(cls) -> str: ... + + @classmethod + def description(cls) -> str: + return "" + + @classmethod + def transports(cls) -> list[type[BaseTransport]]: + return [] + + @classmethod + def ecus(cls) -> list[type[ECU]]: + return [] + + @classmethod + def commands(cls) -> Mapping[str, CommandTree | type[BaseCommand]]: + return {} + + +def load_plugins() -> list[type[Plugin]]: + """Loads the ``gallia_transports`` entry_point.""" + plugins: list[type[Plugin]] = [] + + for plugin_ep in entry_points(group="gallia_plugins"): + plugin = plugin_ep.load() + + if not issubclass(plugin, Plugin): + raise ValueError( + f"{plugin.__name__} from {plugin_ep.name} is not derived from {Plugin.__name__}" + ) + + plugins.append(plugin) + + return plugins + + +def load_transports() -> list[type[BaseTransport]]: + transports = [] + + for plugin in load_plugins(): + for transport in plugin.transports(): + transports.append(transport) + + return transports + + +def load_transport(target: TargetURI) -> type[BaseTransport]: + """Selects a transport class depending on a TargetURI. + The lookup is performed in builtin transports and all + classes behind the ``gallia_transports`` entry_point. + """ + for plugin in load_plugins(): + for transport in plugin.transports(): + if target.scheme == transport.SCHEME: + return transport + + raise ValueError(f"no transport for {target}") + + +def load_ecus() -> list[type[ECU]]: + ecus = [] + + for plugin in load_plugins(): + for ecu in plugin.ecus(): + ecus.append(ecu) + + return ecus + + +def load_ecu(vendor: str) -> type[ECU]: + """Selects an ecu class depending on a vendor string. + The lookup is performed in builtin ecus and all + classes behind the ``gallia_uds_ecus`` entry_point. + The vendor string ``default`` selects a generic ECU. + """ + for plugin in load_plugins(): + for ecu in plugin.ecus(): + if vendor == ecu.OEM: + return ecu + + raise ValueError(f"no such OEM: '{vendor}'") + + +def _merge_commands( + c1: MutableMapping[str, CommandTree | type[BaseCommand]], + c2: Mapping[str, CommandTree | type[BaseCommand]], +) -> None: + for key, value in c2.items(): + if key not in c1: + c1[key] = value + elif isinstance(value, CommandTree) and isinstance(cmd := c1[key], CommandTree): + try: + _merge_command_trees(cmd, value) + except ValueError as e: + raise ValueError(f"{key} {str(e)}") + else: + raise ValueError(f"{key} ]: There already exists a leaf command") + + +def _merge_command_trees(tree1: CommandTree, tree2: CommandTree) -> None: + if ( + tree1.description is not None + and tree2.description is not None + and tree1.description != tree2.description + ): + raise ValueError("]: Incompatible descriptions") + + _merge_commands(tree1.subtree, tree2.subtree) + + +def load_commands() -> MutableMapping[str, CommandTree | type[BaseCommand]]: + plugins = load_plugins() + commands: MutableMapping[str, CommandTree | type[BaseCommand]] = {} + + for plugin in plugins: + try: + _merge_commands(commands, plugin.commands()) + except ValueError as e: + raise ValueError( + f'Plugin "{plugin.name()}" conflicts with other plugins on command [ {str(e)}' + ) from None + + return commands diff --git a/src/gallia/plugins/uds.py b/src/gallia/plugins/uds.py new file mode 100644 index 000000000..915622fa2 --- /dev/null +++ b/src/gallia/plugins/uds.py @@ -0,0 +1,173 @@ +# SPDX-FileCopyrightText: AISEC Pentesting Team +# +# SPDX-License-Identifier: Apache-2.0 + +import sys +from collections.abc import Mapping + +from gallia.command import BaseCommand +from gallia.commands import HSFZDiscoverer +from gallia.commands.discover.doip import DoIPDiscoverer +from gallia.commands.primitive.generic.pdu import GenericPDUPrimitive +from gallia.commands.primitive.uds.dtc import ( + ClearDTCPrimitive, + ControlDTCPrimitive, + ReadDTCPrimitive, +) +from gallia.commands.primitive.uds.ecu_reset import ECUResetPrimitive +from gallia.commands.primitive.uds.iocbi import IOCBIPrimitive +from gallia.commands.primitive.uds.pdu import SendPDUPrimitive +from gallia.commands.primitive.uds.ping import PingPrimitive +from gallia.commands.primitive.uds.rdbi import ( + ReadByIdentifierPrimitive, +) +from gallia.commands.primitive.uds.rmba import RMBAPrimitive +from gallia.commands.primitive.uds.rtcl import RTCLPrimitive +from gallia.commands.primitive.uds.vin import VINPrimitive +from gallia.commands.primitive.uds.wdbi import ( + WriteByIdentifierPrimitive, +) +from gallia.commands.primitive.uds.wmba import WMBAPrimitive +from gallia.commands.scan.uds.identifiers import ScanIdentifiers +from gallia.commands.scan.uds.memory import MemoryFunctionsScanner +from gallia.commands.scan.uds.reset import ResetScanner +from gallia.commands.scan.uds.sa_dump_seeds import SASeedsDumper +from gallia.commands.scan.uds.services import ServicesScanner +from gallia.commands.scan.uds.sessions import SessionsScanner +from gallia.commands.script.rerun import Rerunner +from gallia.commands.script.vecu import DbVirtualECU, RngVirtualECU +from gallia.plugins.plugin import CommandTree, Plugin +from gallia.services.uds import ECU +from gallia.transports import BaseTransport, registry + + +class UDSPlugin(Plugin): + @classmethod + def name(cls) -> str: + return "Gallia UDS" + + @classmethod + def description(cls) -> str: + return "Default Gallia plugin for Unified Diagnostic Services (UDS) functionality" + + @classmethod + def transports(cls) -> list[type[BaseTransport]]: + return registry + + @classmethod + def ecus(cls) -> list[type[ECU]]: + return [ECU] + + @classmethod + def commands(cls) -> Mapping[str, CommandTree | type[BaseCommand]]: + tree = { + "discover": CommandTree( + description="discover scanners for hosts and endpoints", + subtree={"doip": DoIPDiscoverer, "hsfz": HSFZDiscoverer}, + ), + "primitive": CommandTree( + description="protocol specific primitives", + subtree={ + "uds": CommandTree( + description="Universal Diagnostic Services", + subtree={ + "rdbi": ReadByIdentifierPrimitive, + "dtc": CommandTree( + description="DiagnosticTroubleCodes", + subtree={ + "clear": ClearDTCPrimitive, + "control": ControlDTCPrimitive, + "read": ReadDTCPrimitive, + }, + ), + "ecu-reset": ECUResetPrimitive, + "vin": VINPrimitive, + "iocbi": IOCBIPrimitive, + "ping": PingPrimitive, + "rmba": RMBAPrimitive, + "rtcl": RTCLPrimitive, + "pdu": SendPDUPrimitive, + "wmba": WMBAPrimitive, + "wdbi": WriteByIdentifierPrimitive, + }, + ), + "generic": CommandTree( + description="generic networks primitives", + subtree={"pdu": GenericPDUPrimitive}, + ), + }, + ), + "scan": CommandTree( + description="scanners for network protocol parameters", + subtree={ + "uds": CommandTree( + description="Universal Diagnostic Services", + subtree={ + "memory": MemoryFunctionsScanner, + "reset": ResetScanner, + "dump-seeds": SASeedsDumper, + "identifiers": ScanIdentifiers, + "sessions": SessionsScanner, + "services": ServicesScanner, + }, + ) + }, + ), + "script": CommandTree( + description="miscellaneous helper scripts", + subtree={"rerun": Rerunner}, + ), + } + + if sys.platform.startswith("linux"): + from gallia.commands.discover.uds.isotp import IsotpDiscoverer + from gallia.commands.fuzz.uds.pdu import PDUFuzzer + + tree["discover"].subtree.update( + { + "uds": CommandTree( + description="Universal Diagnostic Services", + subtree={ + "isotp": IsotpDiscoverer, + }, + ), + } + ) + + tree.update( + { + "fuzz": CommandTree( + description="fuzzing tools", + subtree={ + "uds": CommandTree( + description="Universal Diagnostic Services", + subtree={"pdu": PDUFuzzer}, + ) + }, + ), + } + ) + + tree["script"].subtree.update( + { + "vecu": CommandTree( + description="spawn a virtual UDS ECU", + subtree={"db": DbVirtualECU, "rng": RngVirtualECU}, + ), + } + ) + + if sys.platform == "win32": + from gallia.commands.script.flexray import ( + FRConfigDump, + FRDump, + ) + + tree["script"].subtree.update( + { + "fr-dump": FRDump, + "fr-dump-config": FRConfigDump, + } + ) + + return tree diff --git a/src/gallia/plugins/xcp.py b/src/gallia/plugins/xcp.py new file mode 100644 index 000000000..5f7d88f9c --- /dev/null +++ b/src/gallia/plugins/xcp.py @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: AISEC Pentesting Team +# +# SPDX-License-Identifier: Apache-2.0 + +import sys +from collections.abc import Mapping + +from gallia.command import BaseCommand +from gallia.plugins.plugin import CommandTree, Plugin + + +class XCPPlugin(Plugin): + @classmethod + def name(cls) -> str: + return "Gallia XCP" + + @classmethod + def description(cls) -> str: + return "Default Gallia plugin for Universal Measurement and Calibration Protocol (XCP) functionality" + + @classmethod + def commands(cls) -> Mapping[str, CommandTree | type[BaseCommand]]: + tree: dict[str, CommandTree | type[BaseCommand]] = {} + + if sys.platform.startswith("linux"): + from gallia.commands.discover.find_xcp import CanFindXCP, TcpFindXCP, UdpFindXCP + from gallia.commands.primitive.uds.xcp import SimpleTestXCP + + tree = { + "discover": CommandTree( + description=None, + subtree={ + "xcp": CommandTree( + description="XCP enumeration scanner", + subtree={ + "can": CanFindXCP, + "tcp": TcpFindXCP, + "udp": UdpFindXCP, + }, + ), + }, + ), + "primitive": CommandTree( + description=None, + subtree={ + "xcp": SimpleTestXCP, + }, + ), + } + + return tree diff --git a/src/gallia/services/uds/ecu.py b/src/gallia/services/uds/ecu.py index cf52dd9c0..980846802 100644 --- a/src/gallia/services/uds/ecu.py +++ b/src/gallia/services/uds/ecu.py @@ -196,7 +196,7 @@ async def check_and_set_session( ) return False - async def power_cycle(self, sleep: int = 5) -> bool: + async def power_cycle(self, sleep: float = 5) -> bool: if self.power_supply is None: logger.debug("no power_supply available") return False @@ -212,7 +212,7 @@ async def leave_session( self, level: int, config: UDSRequestConfig | None = None, - sleep: int | None = None, + sleep: float | None = None, ) -> bool: """leave_session() is a hook which can be called explicitly by a scanner when a session is to be disabled. Use this hook if resetting diff --git a/src/gallia/services/uds/server.py b/src/gallia/services/uds/server.py index 7bdfbd355..b146a4521 100644 --- a/src/gallia/services/uds/server.py +++ b/src/gallia/services/uds/server.py @@ -16,6 +16,7 @@ import aiosqlite +from gallia.command.config import AutoInt, EnumArg, GalliaBaseModel from gallia.log import get_logger from gallia.services.uds.core import service from gallia.services.uds.core.constants import ( @@ -34,18 +35,23 @@ class UDSServer(ABC): - def __init__(self) -> None: + class Behavior(GalliaBaseModel): + default_response_if_service_not_supported: bool = True + default_response_if_missing_sub_function: bool = True + default_response_if_sub_function_not_supported: bool = True + default_response_if_incorrect_format: bool = True + default_response_if_session_change: bool = True + default_response_if_session_read: bool = True + default_response_if_tester_present: bool = True + default_response_if_none: bool = True + default_response_if_suppress: bool = True + + def __init__(self, behavior: Behavior | None = None) -> None: self.state = ECUState() - - self.use_default_response_if_service_not_supported = True - self.use_default_response_if_missing_sub_function = True - self.use_default_response_if_sub_function_not_supported = True - self.use_default_response_if_incorrect_format = True - self.use_default_response_if_session_change = True - self.use_default_response_if_session_read = True - self.use_default_response_if_tester_present = True - self.use_default_response_if_none = True - self.use_default_response_if_suppress = True + if behavior is None: + self.behavior = self.Behavior() + else: + self.behavior = behavior @property @abstractmethod @@ -222,44 +228,44 @@ async def respond_without_state_change( response: service.UDSResponse | None if ( - self.use_default_response_if_service_not_supported + self.behavior.default_response_if_service_not_supported and (response := self.default_response_if_service_not_supported(request)) is not None ): return response if ( - self.use_default_response_if_missing_sub_function + self.behavior.default_response_if_missing_sub_function and (response := self.default_response_if_missing_sub_function(request)) is not None ): return response if ( - self.use_default_response_if_sub_function_not_supported + self.behavior.default_response_if_sub_function_not_supported and (response := self.default_response_if_sub_function_not_supported(request)) is not None ): return response if ( - self.use_default_response_if_incorrect_format + self.behavior.default_response_if_incorrect_format and (response := self.default_response_if_incorrect_format(request)) is not None ): return response if ( - self.use_default_response_if_session_change + self.behavior.default_response_if_session_change and (response := self.default_response_if_session_change(request)) is not None ): return response if ( - self.use_default_response_if_session_read + self.behavior.default_response_if_session_read and (response := self.default_response_if_session_read(request)) is not None ): return response if ( - self.use_default_response_if_tester_present + self.behavior.default_response_if_tester_present and (response := self.default_response_if_tester_present(request)) is not None ): return response @@ -267,7 +273,7 @@ async def respond_without_state_change( if (response := await self.respond_after_default(request)) is not None: return response - if self.use_default_response_if_none: + if self.behavior.default_response_if_none: return self.default_response_if_none(request) return None @@ -283,7 +289,7 @@ async def respond(self, request: service.UDSRequest) -> service.UDSResponse | No if self.state.__dict__ != old_state.__dict__: logger.debug(f"Changed state to {self.state}") - if self.use_default_response_if_suppress: + if self.behavior.default_response_if_suppress: return self.default_response_if_suppress(request, response) return response @@ -337,28 +343,38 @@ def reset(self) -> None: class RandomUDSServer(UDSServer): - def __init__(self, seed: int): - super().__init__() + class RandomnessParameters(GalliaBaseModel): + mandatory_sessions: list[AutoInt] = [1] + optional_sessions: list[AutoInt] = [2, 3, 4] + list(range(0x40, 0x7F)) + p_session: float = 0.05 + mandatory_services: list[EnumArg[UDSIsoServices]] = [ + UDSIsoServices.DiagnosticSessionControl + ] + optional_services: list[EnumArg[UDSIsoServices]] = list( + set(UDSIsoServices) - set(mandatory_services + [UDSIsoServices.NegativeResponse]) + ) + p_service: float = 0.2 + p_sub_function: float = 0.05 + p_identifier: float = 0.005 + p_correct_payload_format: float = 0.1 + p_dtc_status_mask: float = 0.9 - self.state: RNGEcuState = RNGEcuState() + def __init__( + self, + seed: int, + randomness_parameters: RandomnessParameters | None = None, + behavior: UDSServer.Behavior | None = None, + ): + super().__init__(behavior) + self.state: RNGEcuState = RNGEcuState() self.seed = seed - - self.mandatory_sessions = [1] - self.optional_sessions = [2, 3, 4] + list(range(0x40, 0x7F)) - self.p_session = 0.05 - self.services: dict[int, dict[UDSIsoServices, list[int] | None]] = {} - self.mandatory_services = [UDSIsoServices.DiagnosticSessionControl] - self.optional_services = list( - set(UDSIsoServices) - set(self.mandatory_services + [UDSIsoServices.NegativeResponse]) - ) - self.p_service = 0.2 - self.p_sub_function = 0.05 - self.p_identifier = 0.005 - self.p_correct_payload_format = 0.1 - self.p_dtc_status_mask = 0.9 + if randomness_parameters is None: + self.randomness_parameters = self.RandomnessParameters() + else: + self.randomness_parameters = randomness_parameters async def setup(self) -> None: self.randomize() @@ -390,10 +406,15 @@ def randomize(self) -> None: level_sessions = {default_session} session_transitions: list[set[int]] = [set() for _ in range(0x7F)] session_transitions[default_session] = {default_session} - combined_sessions = self.mandatory_sessions + self.optional_sessions + combined_sessions = ( + self.randomness_parameters.mandatory_sessions + + self.randomness_parameters.optional_sessions + ) while len(level_sessions) > 0: - p_transition = self.p_session / len(level_sessions) / 2 ** (level + 0.5) + p_transition = ( + self.randomness_parameters.p_session / len(level_sessions) / 2 ** (level + 0.5) + ) next_level_sessions = set() available_sessions = [ i @@ -414,7 +435,7 @@ def randomize(self) -> None: level_sessions = next_level_sessions - set(available_sessions) level += 1 - for session in self.mandatory_sessions: + for session in self.randomness_parameters.mandatory_sessions: if len(session_transitions[session]) == 0: available_sessions = [ i @@ -432,8 +453,10 @@ def randomize(self) -> None: self.services[session] = {} - for supported_service in self.mandatory_services + [ - s for s in self.optional_services if rng.random() < self.p_service + for supported_service in self.randomness_parameters.mandatory_services + [ + s + for s in self.randomness_parameters.optional_services + if rng.random() < self.randomness_parameters.p_service ]: supported_sub_functions: list[int] | None = None @@ -446,7 +469,9 @@ def randomize(self) -> None: supported_sub_functions = sorted(session_specific_transitions) elif supported_service == UDSIsoServices.SecurityAccess: supported_sub_functions_tmp = [ - sf for sf in range(1, 0x7E, 2) if rng.random() < self.p_sub_function / 2 + sf + for sf in range(1, 0x7E, 2) + if rng.random() < self.randomness_parameters.p_sub_function / 2 ] supported_sub_functions = [] @@ -460,7 +485,9 @@ def randomize(self) -> None: supported_sub_functions = [ReadDTCInformationSubFuncs.reportDTCByStatusMask] else: supported_sub_functions = [ - sf for sf in range(1, 0x80, 1) if rng.random() < self.p_sub_function + sf + for sf in range(1, 0x80, 1) + if rng.random() < self.randomness_parameters.p_sub_function ] self.services[session][supported_service] = supported_sub_functions @@ -549,7 +576,7 @@ def security_access(self, request: service._SecurityAccessRequest) -> service.UD def routine_control(self, request: service.RoutineControlRequest) -> service.UDSResponse: rng = self.stateful_rng(request.service_id, request.routine_identifier) - if not rng.random_bool(self.p_identifier): + if not rng.random_bool(self.randomness_parameters.p_identifier): return service.NegativeResponse(request.service_id, UDSErrorCodes.requestOutOfRange) rng.add_seeds(request.sub_function) @@ -561,7 +588,7 @@ def routine_control(self, request: service.RoutineControlRequest) -> service.UDS rng = self.stateful_rng(request.pdu) - if not rng.random_bool(self.p_correct_payload_format): + if not rng.random_bool(self.randomness_parameters.p_correct_payload_format): return service.NegativeResponse( request.service_id, UDSErrorCodes.incorrectMessageLengthOrInvalidFormat ) @@ -573,7 +600,7 @@ def read_data_by_identifier( ) -> service.UDSResponse: rng = self.stateful_rng(request.pdu) - if not rng.random_bool(self.p_identifier): + if not rng.random_bool(self.randomness_parameters.p_identifier): return service.NegativeResponse(request.service_id, UDSErrorCodes.requestOutOfRange) return service.ReadDataByIdentifierResponse( @@ -585,12 +612,12 @@ def write_data_by_identifier( ) -> service.UDSResponse: rng = self.stateful_rng(request.service_id, request.data_identifier) - if not rng.random_bool(self.p_identifier): + if not rng.random_bool(self.randomness_parameters.p_identifier): return service.NegativeResponse(request.service_id, UDSErrorCodes.requestOutOfRange) rng = self.stateful_rng(request.pdu) - if not rng.random_bool(self.p_correct_payload_format): + if not rng.random_bool(self.randomness_parameters.p_correct_payload_format): return service.NegativeResponse( request.service_id, UDSErrorCodes.incorrectMessageLengthOrInvalidFormat ) @@ -602,12 +629,12 @@ def input_output_control_by_identifier( ) -> service.UDSResponse: rng = self.stateful_rng(request.service_id, request.data_identifier) - if not rng.random_bool(self.p_identifier): + if not rng.random_bool(self.randomness_parameters.p_identifier): return service.NegativeResponse(request.service_id, UDSErrorCodes.requestOutOfRange) rng = self.stateful_rng(request.pdu) - if not rng.random_bool(self.p_correct_payload_format): + if not rng.random_bool(self.randomness_parameters.p_correct_payload_format): return service.NegativeResponse( request.service_id, UDSErrorCodes.incorrectMessageLengthOrInvalidFormat ) @@ -619,7 +646,7 @@ def clear_diagnostic_information( ) -> service.UDSResponse: rng = self.stateful_rng(request.service_id, request.group_of_dtc) - if not rng.random_bool(self.p_dtc_status_mask): + if not rng.random_bool(self.randomness_parameters.p_dtc_status_mask): return service.NegativeResponse(request.service_id, UDSErrorCodes.requestOutOfRange) return service.ClearDiagnosticInformationResponse() @@ -650,8 +677,25 @@ def read_dtc_information(self, request: service.UDSRequest) -> service.UDSRespon class DBUDSServer(UDSServer): - def __init__(self, db_path: Path, ecu: str | None, properties: dict[str, Any] | None): - super().__init__() + class Behavior(UDSServer.Behavior): + default_response_if_service_not_supported: bool = False + default_response_if_missing_sub_function: bool = False + default_response_if_sub_function_not_supported: bool = False + default_response_if_incorrect_format: bool = False + default_response_if_session_change: bool = False + default_response_if_session_read: bool = False + default_response_if_tester_present: bool = False + default_response_if_none: bool = False + default_response_if_suppress: bool = False + + def __init__( + self, + db_path: Path, + ecu: str | None, + properties: dict[str, Any] | None, + behavior: Behavior | None = None, + ): + super().__init__(behavior) self.db_path = db_path self.ecu = ecu @@ -659,17 +703,6 @@ def __init__(self, db_path: Path, ecu: str | None, properties: dict[str, Any] | self.connection: aiosqlite.Connection | None = None self.last_response = -1 - # Override defaults - self.use_default_response_if_service_not_supported = False - self.use_default_response_if_missing_sub_function = False - self.use_default_response_if_sub_function_not_supported = False - self.use_default_response_if_incorrect_format = False - self.use_default_response_if_session_change = False - self.use_default_response_if_session_read = False - self.use_default_response_if_tester_present = False - self.use_default_response_if_none = False - self.use_default_response_if_suppress = False - async def setup(self) -> None: self.connection = await aiosqlite.connect(self.db_path) diff --git a/src/gallia/utils.py b/src/gallia/utils.py index 36737f36e..65b58efcb 100644 --- a/src/gallia/utils.py +++ b/src/gallia/utils.py @@ -11,8 +11,7 @@ import logging import re import sys -from argparse import Action, ArgumentError, ArgumentParser, Namespace -from collections.abc import Awaitable, Callable, Sequence +from collections.abc import Awaitable, Callable from pathlib import Path from types import ModuleType from typing import TYPE_CHECKING, Any, TypeVar @@ -101,7 +100,23 @@ def can_id_repr(i: int) -> str: return f"{i:03x}" -def _unravel(listing: str) -> list[int]: +def unravel(listing: str) -> list[int]: + """ + Parses a string representing a one-dimensional list of ranges into an equivalent python data structure. + + Ranges are delimited by hyphens ('-'). + Enumerations are delimited by commas (','). + + Ranges are allowed to overlap and are merged. + Ranges are always unraveled, which could lead to high memory consumption for distant limits. + + Example: 0,10,8-11 + This would result in [0,8,9,10,11]. + + :param listing: The string representation of the one-dimensional list of ranges. + :return: A list of numbers. + """ + listing_delimiter = "," range_delimiter = "-" result = set() @@ -121,43 +136,54 @@ def _unravel(listing: str) -> list[int]: return sorted(result) -class ParseSkips(Action): - def __call__( - self, - parser: ArgumentParser, - namespace: Namespace, - values: str | Sequence[Any] | None, - option_string: str | None = None, - ) -> None: - skip_sids: dict[int, list[int] | None] = {} +def unravel_2d(listing: str) -> dict[int, list[int] | None]: + """ + Parses a string representing a two-dimensional list of ranges into an equivalent python data structure. + + The outer dimension entries are separated by spaces (' '). + Inner dimension ranges and outer dimension ranges are separated by colons (':'). + Ranges in both dimensions are delimited by hyphens ('-'). + Enumerations in both dimensions are delimited by commas (','). - try: - if values is not None: - for session_skips in values: - # Whole sessions can be skipped by only giving the session number without ids - if ":" not in session_skips: - session_ids = _unravel(session_skips) + Ranges are allowed to overlap and are merged. + Ranges are always unraveled, which could lead to high memory consumption for distant limits. + If a range with only outer dimensions is given, this will result in None for the inner list and overrides other values. - for session_id in session_ids: - skip_sids[session_id] = None - else: - session_ids_tmp, identifier_ids_tmp = session_skips.split(":") - session_ids = _unravel(session_ids_tmp) - identifier_ids = _unravel(identifier_ids_tmp) - skips = session_skips + Example: "1:1,2 1-3:0,2-4 3" + This would result in {1: [0,1,2,3,4], 2: [0,2,3,4], 3: None}. + + :param listing: The string representation of the two-dimensional list of ranges. + :return: A mapping of numbers in the outer dimension to numbers in the inner dimension. + """ - for session_id in session_ids: - if session_id not in skip_sids: - skip_sids[session_id] = [] + listing_delimiter = " " + level_delimiter = ":" - skips = skip_sids[session_id] + unsorted_result: dict[int, set[int] | None] = {} + + for range_element in listing.split(listing_delimiter): + if level_delimiter in range_element: + first_tmp, second_tmp = range_element.split(level_delimiter) + first = unravel(first_tmp) + second = unravel(second_tmp) + + for x in first: + if x not in unsorted_result: + unsorted_result[x] = set() + + if (ur := unsorted_result[x]) is not None: + for y in second: + ur.add(y) + else: + first = unravel(range_element) - if skips is not None: - skips += identifier_ids + for x in first: + unsorted_result[x] = None - setattr(namespace, self.dest, skip_sids) - except Exception as e: - raise ArgumentError(self, "malformed argument") from e + return { + x: None if (ur := unsorted_result[x]) is None else sorted(ur) + for x in sorted(unsorted_result) + } T = TypeVar("T") @@ -217,7 +243,8 @@ def lazy_import(name: str) -> ModuleType: return module -def dump_args(args: Namespace) -> dict[str, str | int | float]: +# TODO: (Re)move these functions +def dump_args(args: Any) -> dict[str, str | int | float]: settings = {} for key, value in args.__dict__.items(): match value: @@ -227,7 +254,7 @@ def dump_args(args: Namespace) -> dict[str, str | int | float]: return settings -def get_log_level(args: Namespace) -> Loglevel: +def get_log_level(args: Any) -> Loglevel: level = Loglevel.INFO if hasattr(args, "verbose"): if args.verbose == 1: @@ -237,7 +264,7 @@ def get_log_level(args: Namespace) -> Loglevel: return level -def get_file_log_level(args: Namespace) -> Loglevel: +def get_file_log_level(args: Any) -> Loglevel: level = Loglevel.DEBUG if hasattr(args, "trace_log"): if args.trace_log: diff --git a/src/opennetzteil/cli.py b/src/opennetzteil/cli.py index 059a159ce..acbf64aa8 100644 --- a/src/opennetzteil/cli.py +++ b/src/opennetzteil/cli.py @@ -1,98 +1,121 @@ # SPDX-FileCopyrightText: AISEC Pentesting Team # # SPDX-License-Identifier: Apache-2.0 +from abc import ABC +from typing import Any, Literal, Self -import sys -from argparse import ArgumentParser, Namespace +from pydantic import field_serializer, model_validator +from gallia.cli import parse_and_run from gallia.command import AsyncScript -from gallia.config import load_config_file +from gallia.command.base import AsyncScriptConfig, ScannerConfig +from gallia.command.config import Field, Idempotent from gallia.powersupply import PowerSupplyURI +from gallia.transports import TargetURI from gallia.utils import strtobool from opennetzteil import netzteile +from opennetzteil.netzteil import BaseNetzteil -class CLI(AsyncScript): - COMMAND = "netzteil-cli" - - def configure_parser(self) -> None: - self.parser.add_argument( - "-t", - "--target", - metavar="URI", - type=PowerSupplyURI, - default=self.config.get_value( - "opennetzteil.target", - self.config.get_value("gallia.scanner.power_supply"), - ), - help="URI specifying the location of the powersupply", - ) - self.parser.add_argument( - "-c", - "--channel", - type=int, - required=True, - help="the channel number to control", - ) - self.parser.add_argument( - "-a", - "--attr", - choices=["voltage", "current", "output"], - required=True, - help="the attribute to control", - ) - - subparsers = self.parser.add_subparsers() - - get_parser = subparsers.add_parser("get") - get_parser.set_defaults(subcommand="get") - - set_parser = subparsers.add_parser("set") - set_parser.set_defaults(subcommand="set") - set_parser.add_argument("VALUE") - - async def setup(self, args: Namespace) -> None: - if args.target is None: - self.parser.error("specify -t/--target!") - - async def main(self, args: Namespace) -> None: +class CLIConfig(AsyncScriptConfig): + power_supply: Idempotent[PowerSupplyURI] = Field( + description="URI specifying the location of the powersupply", + metavar="URI", + short="t", + config_section=ScannerConfig._config_section, + ) + channel: int = Field(description="the channel number to control", short="c") + attr: Literal["voltage", "current", "output"] = Field( + description="the attribute to control", short="a" + ) + + @field_serializer("power_supply") + def serialize_target_uri(self, target_uri: TargetURI | None) -> Any: + if target_uri is None: + return None + + return target_uri.raw + + @model_validator(mode="after") + def check_power_supply_requirements(self) -> Self: for netzteil in netzteile: - if args.target.product_id == netzteil.PRODUCT_ID: - client = await netzteil.connect(args.target, timeout=1.0) + if self.power_supply.product_id == netzteil.PRODUCT_ID: break else: - self.parser.error(f"powersupply {args.power_supply.product_id} is not supported") - - match args.subcommand: - case "get": - match args.attr: - case "voltage": - print(await client.get_voltage(args.channel)) - case "current": - print(await client.get_current(args.channel)) - case "output": - if args.channel == 0: - print(await client.get_master()) - else: - print(await client.get_output(args.channel)) - case "set": - match args.attr: - case "voltage": - await client.set_voltage(args.channel, float(args.VALUE)) - case "current": - await client.set_current(args.channel, float(args.VALUE)) - case "output": - if args.channel == 0: - await client.set_master(strtobool(args.VALUE)) - else: - await client.set_output(args.channel, strtobool(args.VALUE)) + raise ValueError(f"powersupply {self.power_supply.product_id} is not supported") + + return self + + +class GetCLIConfig(CLIConfig): + pass + + +class SetCLIConfig(CLIConfig): + value: str = Field(positional=True) + + +class CLI(AsyncScript, ABC): + def __init__(self, config: CLIConfig): + super().__init__(config) + self.config: CLIConfig = config + + async def _client(self) -> BaseNetzteil: + for netzteil in netzteile: + if self.config.power_supply.product_id == netzteil.PRODUCT_ID: + return await netzteil.connect(self.config.power_supply, timeout=1.0) + + assert False + + +class GetCLI(CLI, ABC): + CONFIG_TYPE = GetCLIConfig + SHORT_HELP = "Get properties of the power supply" + + def __init__(self, config: GetCLIConfig): + super().__init__(config) + self.config: GetCLIConfig = config + + async def main(self) -> None: + client = await self._client() + + match self.config.attr: + case "voltage": + print(await client.get_voltage(self.config.channel)) + case "current": + print(await client.get_current(self.config.channel)) + case "output": + if self.config.channel == 0: + print(await client.get_master()) + else: + print(await client.get_output(self.config.channel)) + + +class SetCLI(CLI, ABC): + CONFIG_TYPE = SetCLIConfig + SHORT_HELP = "Set properties of the power supply" + + def __init__(self, config: SetCLIConfig): + super().__init__(config) + self.config: SetCLIConfig = config + + async def main(self) -> None: + client = await self._client() + + match self.config.attr: + case "voltage": + await client.set_voltage(self.config.channel, float(self.config.value)) + case "current": + await client.set_current(self.config.channel, float(self.config.value)) + case "output": + if self.config.channel == 0: + await client.set_master(strtobool(self.config.value)) + else: + await client.set_output(self.config.channel, strtobool(self.config.value)) def main() -> None: - parser = ArgumentParser() - config, _ = load_config_file() - cli = CLI(parser, config) - sys.exit(cli.entry_point(parser.parse_args())) + parse_and_run({"get": GetCLI, "set": SetCLI}) if __name__ == "__main__": diff --git a/tests/bats/001-invocation.bats b/tests/bats/001-invocation.bats index bdd249026..f15b0867a 100644 --- a/tests/bats/001-invocation.bats +++ b/tests/bats/001-invocation.bats @@ -12,7 +12,7 @@ load "helpers" } @test "invoke gallia without config" { - run -1 gallia --show-config + run -78 gallia --show-config } @test "invoke gallia with config" { diff --git a/tests/bats/002-scans.bats b/tests/bats/002-scans.bats index 869db0e06..e4bee1fda 100644 --- a/tests/bats/002-scans.bats +++ b/tests/bats/002-scans.bats @@ -31,15 +31,15 @@ teardown() { } @test "scan identifiers sid 0x22" { - gallia scan uds identifiers --start 0 --end 100 --sid 0x22 + gallia scan uds identifiers --start 0 --end 100 --service 0x22 } @test "scan identifiers sid 0x2e" { - gallia scan uds identifiers --start 0 --end 100 --sid 0x2e + gallia scan uds identifiers --start 0 --end 100 --service 0x2e } @test "scan identifiers sid 0x31" { - gallia scan uds identifiers --start 0 --end 100 --sid 0x31 + gallia scan uds identifiers --start 0 --end 100 --service 0x31 } @test "scan reset" { @@ -52,6 +52,6 @@ teardown() { @test "scan memory" { for sid in 0x23 0x34 0x35 0x3d; do - gallia scan uds memory --sid "$sid" + gallia scan uds memory --service "$sid" done } diff --git a/tests/bats/run_bats.sh b/tests/bats/run_bats.sh index cc704c47b..5a9548d75 100755 --- a/tests/bats/run_bats.sh +++ b/tests/bats/run_bats.sh @@ -6,10 +6,11 @@ set -eu -gallia script vecu --no-volatile-info "unix-lines:///tmp/vecu.sock" rng \ +gallia script vecu rng "unix-lines:///tmp/vecu.sock" \ + --no-volatile-info \ --seed 3 \ - --mandatory_sessions "[1, 2, 3]" \ - --mandatory_services "[DiagnosticSessionControl, EcuReset, ReadDataByIdentifier, WriteDataByIdentifier, RoutineControl, SecurityAccess, ReadMemoryByAddress, WriteMemoryByAddress, RequestDownload, RequestUpload, TesterPresent, ReadDTCInformation, ClearDiagnosticInformation, InputOutputControlByIdentifier]" 2>vecu.log & + --mandatory-sessions 1 2 3 \ + --mandatory-services DiagnosticSessionControl EcuReset ReadDataByIdentifier WriteDataByIdentifier RoutineControl SecurityAccess ReadMemoryByAddress WriteMemoryByAddress RequestDownload RequestUpload TesterPresent ReadDTCInformation ClearDiagnosticInformation InputOutputControlByIdentifier 2>vecu.log & # https://superuser.com/a/553236 trap 'kill "$(jobs -p)"' SIGINT SIGTERM EXIT diff --git a/vendor/pydantic-argparse/.github/workflows/linting.yml b/vendor/pydantic-argparse/.github/workflows/linting.yml deleted file mode 100644 index 7db013354..000000000 --- a/vendor/pydantic-argparse/.github/workflows/linting.yml +++ /dev/null @@ -1,36 +0,0 @@ -# SPDX-FileCopyrightText: Hayden Richards -# -# SPDX-License-Identifier: MIT - -name: Linting - -on: - pull_request: - branches: - - master - push: - branches: - - master - schedule: - - cron: "0 0 * * 0" - -jobs: - linting: - name: Linting - runs-on: ubuntu-latest - steps: - - name: Checkout Repository - uses: actions/checkout@v3 - - name: Install Poetry - run: pipx install poetry - - name: Setup Python - uses: actions/setup-python@v4 - with: - python-version: "3.11" - cache: poetry - - name: Install Dependencies - run: poetry install --no-interaction --no-root - - name: Install Package - run: poetry install --no-interaction - - name: Run Linting - run: poetry run poe lint diff --git a/vendor/pydantic-argparse/.github/workflows/tests.yml b/vendor/pydantic-argparse/.github/workflows/tests.yml deleted file mode 100644 index a37dc54f8..000000000 --- a/vendor/pydantic-argparse/.github/workflows/tests.yml +++ /dev/null @@ -1,64 +0,0 @@ -# SPDX-FileCopyrightText: Hayden Richards -# -# SPDX-License-Identifier: MIT - -name: Tests - -on: - pull_request: - branches: - - master - push: - branches: - - master - schedule: - - cron: "0 0 * * 0" - -jobs: - tests: - name: Tests - strategy: - matrix: - os: [ "ubuntu-latest", "macos-latest", "windows-latest" ] - python-version: [ "3.7", "3.8", "3.9", "3.10", "3.11" ] - fail-fast: false - defaults: - run: - shell: bash - runs-on: ${{ matrix.os }} - steps: - - name: Checkout Repository - uses: actions/checkout@v3 - - name: Install Poetry - run: pipx install poetry - - name: Setup Python - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - cache: poetry - - name: Install Dependencies - run: poetry install --no-interaction --no-root - - name: Install Package - run: poetry install --no-interaction - - name: Run Tests - run: | - poetry run poe test - poetry run coverage lcov - - name: Coverage Results - uses: coverallsapp/github-action@master - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - path-to-lcov: coverage.lcov - flag-name: ${{ matrix.os }}-${{ matrix.python-version }} - parallel: true - - coverage: - name: Coverage - needs: tests - runs-on: ubuntu-latest - steps: - - name: Coveralls Finished - uses: coverallsapp/github-action@master - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - parallel-finished: true diff --git a/vendor/pydantic-argparse/.github/workflows/typing.yml b/vendor/pydantic-argparse/.github/workflows/typing.yml deleted file mode 100644 index b162588a0..000000000 --- a/vendor/pydantic-argparse/.github/workflows/typing.yml +++ /dev/null @@ -1,36 +0,0 @@ -# SPDX-FileCopyrightText: Hayden Richards -# -# SPDX-License-Identifier: MIT - -name: Typing - -on: - pull_request: - branches: - - master - push: - branches: - - master - schedule: - - cron: "0 0 * * 0" - -jobs: - typing: - name: Typing - runs-on: ubuntu-latest - steps: - - name: Checkout Repository - uses: actions/checkout@v3 - - name: Install Poetry - run: pipx install poetry - - name: Setup Python - uses: actions/setup-python@v4 - with: - python-version: "3.11" - cache: poetry - - name: Install Dependencies - run: poetry install --no-interaction --no-root - - name: Install Package - run: poetry install --no-interaction - - name: Run Type Checking - run: poetry run poe type diff --git a/vendor/pydantic-argparse/.gitignore b/vendor/pydantic-argparse/.gitignore deleted file mode 100644 index b18107864..000000000 --- a/vendor/pydantic-argparse/.gitignore +++ /dev/null @@ -1,89 +0,0 @@ -# SPDX-FileCopyrightText: Hayden Richards -# -# SPDX-License-Identifier: MIT - -# IDE settings / virtual environment -.venv/ -.vscode/ - -# Caches -.mypy_cache/ -.pytest_cache/ -.ruff_cache/ - -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Sphinx documentation -docs/_build/ - -# pyenv -.python-version - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# macOS -.DS_Store - -# Misc -/TODO.md -Dockerfile \ No newline at end of file diff --git a/vendor/pydantic-argparse/README.md b/vendor/pydantic-argparse/README.md index 97afe7a91..e69de29bb 100644 --- a/vendor/pydantic-argparse/README.md +++ b/vendor/pydantic-argparse/README.md @@ -1,179 +0,0 @@ - - -
- - - -

- Pydantic Argparse -

-

- Typed Argument Parsing with Pydantic -

- - - - - - - - - - - - -
- - - - - - - - - - - - -
- -## Fork major changes -1. Upgrade to only be compatible with `Pydantic` v2+ - - `Pydantic` recently released version 2, which heavily relies on a Rust backend for major speed improvements in data validation. - - However, there are many breaking changes that were introduced in the process. -2. Nested `Pydantic` models now default to argument **groups** instead of subcommands. This leads to large argument lists being much more composable for large applications since the arguments can be broken into smaller groups. - - Subcommands are now explicitly *opt-in* features. A convenience base class `pydantic_argparse.BaseCommand` has been provided that sets the queried configuration variable, which can then be used as a typical `pydantic.BaseModel` otherwise. -3. The `metavar` option for `argparser.ArgumentParser.add_argument` now (almost always) defaults to the type of the argument instead of the argument name. - - -### Argument Groups example -```python -from pydantic import Field, BaseModel -from pydantic_argparse import ArgumentParser, BaseArgument - -# BaseArgument is just a pydantic.BaseModel that explicitly opted out from subcommands -# however, pydantic.BaseModel classes have implicitly opted out as well - -class Group1(BaseArgument): - string: str = Field(description="a required string") - integer: int = Field(description="a required integer") - decimal: float = Field(description="a required float") - flag: bool = Field(False, description="a flag") - -class Group2(BaseArgument): - name: str = Field(description="your name") - age: int = Field(82, description="your age") - -class Arguments(BaseModel): - first: Group1 - second: Group2 - -if __name__ == "__main__": - parser = ArgumentParser(model=Arguments) - parser.parse_typed_args() -``` - -```console -$ python3 example_groups.py --help -usage: example_groups.py [-h] [-v] --string STR --integer INT [--flag] - --name STR [--age INT] - -FIRST: - --string STR a required string - --integer INT a required integer - --decimal FLOAT a required float - --flag a flag - -SECOND: - --name STR your name - --age INT you age (default: 82) - -help: - -h, --help show this help message and exit - -v, --version show program's version number and exit -``` - -### TODO - -- [ ] Look into short arg names at the command line. - - This may involve the use of the model field `.alias` option - -## Help -See [documentation](https://pydantic-argparse.supimdos.com) for help. - -## Installation -Installation with `pip` is simple: -```console -$ pip install pydantic-argparse -``` - -## Example -```py -import pydantic -import pydantic_argparse - - -class Arguments(pydantic.BaseModel): - # Required Args - string: str = pydantic.Field(description="a required string") - integer: int = pydantic.Field(description="a required integer") - flag: bool = pydantic.Field(description="a required flag") - - # Optional Args - second_flag: bool = pydantic.Field(False, description="an optional flag") - third_flag: bool = pydantic.Field(True, description="an optional flag") - - -def main() -> None: - # Create Parser and Parse Args - parser = pydantic_argparse.ArgumentParser( - model=Arguments, - prog="Example Program", - description="Example Description", - version="0.0.1", - epilog="Example Epilog", - ) - args = parser.parse_typed_args() - - # Print Args - print(args) - - -if __name__ == "__main__": - main() -``` - -```console -$ python3 example.py --help -usage: Example Program [-h] [-v] --string STRING --integer INTEGER --flag | - --no-flag [--second-flag] [--no-third-flag] - -Example Description - -required arguments: - --string STRING a required string - --integer INTEGER a required integer - --flag, --no-flag a required flag - -optional arguments: - --second-flag an optional flag (default: False) - --no-third-flag an optional flag (default: True) - -help: - -h, --help show this help message and exit - -v, --version show program's version number and exit - -Example Epilog -``` - -```console -$ python3 example.py --string hello --integer 42 --flag -string='hello' integer=42 flag=True second_flag=False third_flag=True -``` - -## License -This project is licensed under the terms of the MIT license. diff --git a/vendor/pydantic-argparse/docs/CNAME b/vendor/pydantic-argparse/docs/CNAME deleted file mode 100644 index 778b19381..000000000 --- a/vendor/pydantic-argparse/docs/CNAME +++ /dev/null @@ -1 +0,0 @@ -pydantic-argparse.supimdos.com \ No newline at end of file diff --git a/vendor/pydantic-argparse/docs/CNAME.license b/vendor/pydantic-argparse/docs/CNAME.license deleted file mode 100644 index 25fd365b4..000000000 --- a/vendor/pydantic-argparse/docs/CNAME.license +++ /dev/null @@ -1,3 +0,0 @@ -SPDX-FileCopyrightText: Hayden Richards - -SPDX-License-Identifier: MIT diff --git a/vendor/pydantic-argparse/docs/assets/images/logo.svg b/vendor/pydantic-argparse/docs/assets/images/logo.svg deleted file mode 100644 index e2703d861..000000000 --- a/vendor/pydantic-argparse/docs/assets/images/logo.svg +++ /dev/null @@ -1,95 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/vendor/pydantic-argparse/docs/assets/images/logo.svg.license b/vendor/pydantic-argparse/docs/assets/images/logo.svg.license deleted file mode 100644 index 25fd365b4..000000000 --- a/vendor/pydantic-argparse/docs/assets/images/logo.svg.license +++ /dev/null @@ -1,3 +0,0 @@ -SPDX-FileCopyrightText: Hayden Richards - -SPDX-License-Identifier: MIT diff --git a/vendor/pydantic-argparse/docs/assets/images/showcase_01.png b/vendor/pydantic-argparse/docs/assets/images/showcase_01.png deleted file mode 100644 index 117873c9aa9cc84edc6d6a87dcd1f2c5cbfb8e69..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18144 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%0A`=H_6U zgUxj1c7gsh5h;u7OWhh|K}6S*kZt#cGG?rL#FF)_)J}6a<|fznEWMQUJ&c<&i1;T% zw85V;Vboc#GGy}7(_c(LPJGP@<}5~h3O|0lU8q+huOf!fD6fH2*wsG=Si)5GyA0EQ zuhRm(kx6I*R2KET=rkFzqMNewL^Bo?Vf}~wgr@y zmR7qQlfVGNA!Zv4z@u1?GHoUz&F=0h28D=j-*{o-E`XVty5MjFe@wgxdgNZ~xq&FD zs+Lyv@UWVuMpViKJUHX9Q}{jFB8m#2erxElt%pOZQA`&-b20JnpV{HlxQ_RvXM zq=Cmc=paH9A|b1YHFJ8vO5Q{qk8k^4Y{aW_`^q!Ot|AC~l>CT-ydDYdgdt!Za5)&!g+S^$e8D%1r1#e17egXG!CCj<4=6oD} zv8X@`T8y>4QWVB)JL$=kh;7ciDzV2ok68}ak8JhVhX#rwTFgbW_}hN|Tx-EH8%+?f z9Ayq6=Y#g|=c(IizI*r1-#H*4z_R@-*~Vy>r$xe0hV15Manra2YG2KNRXN_Nu-0W; zXZ5r@I{#=?T_x$r8;`;ztv!^AHD@I6-lL-7tXO?0h3)sp<&%1=9z15^1$_wuo>Vkk zhJM*9O^}wB7X6Om`U81DyQ=Hz2;wPif_8{c8u}<*J%z4Iv~7Mztc$BTPmEzxg|ubG+7h^JvzUbwlhVkY?QK3iVK>)^+zt_|ZX*<=%YDwGS@4)I}#G`r+R7(ZoQwd7| zM|ghU!$6N%vW(2`{^Ce@S~Ry#DXw5N^UQ?t{)@1wT}V{o^6l4TheHAp@8O& z8a!Ry1jDxTg&clCl`m>Pefs2qI$IT6Ut2R*;WAwKA+1wwzwmU7gp#r#luBfy1Hs1!mCt6Kp$u zDcDYH^XMBlsggsT-c%$^QV0jG3hLKUse!PHYYc2st2?bemnRcb`qtF|+lt$aUl#M; zYr)b7=9@S(Cj7cbolnA`_4PBX1IrE;z|8bJ zYMs{8`E91;BE-+vjEVtXv^{eciMoKh*DeG+RhqI@=vUKr2yHBV#fK6Ss{MMLiEQ+K z6@y6O_r8kW(r6Am>8d}=uf2F+txda_90E-~;vAqeJ1{IZl$4b83&6{=`UV^(pf0~Y z3qZjLx;7Fp0NSDX`G^7E{vr5PfA+**#}V|thGfolD|8Hab@~xYC7PD22kzYvUT}2V znf~&J$GZ8*lBYrDDJw$bWkxBco(4c<0`Kn6%!uNVa!I12qhsUa7x4(eHN=reBaw|- zVbrr?DPaVJl%-DV!;A!&yd*JOi*vZoNSFArI&F>tDT$20Eld-vfmQ4zFCTOZ1A_si z5Mw;*83L(uu>i8IyrSZg><|r-8hF)yq7Z4jAM=W=3VMEtM_%6IXt8;_&G<` z%(VEY4JDnOo&9^RC_QHkxe{Uge7t!EDa7nLauN8Hf`jHYhYX~uVq4;qSjuASyFE7e zNAiImEB3r>3sic#@eic_Zk68%kZt>>es{Fzj#PV&X77?ZQ+rmeR<~+pL0BbT?WEVR zaCT;7_+ZBNFx1WZ3@@`2E7-tA$l+hBva{P)FZzevGc7S^p~7#_JhQ$A9)m(HYfK_U zR=#Jw4`sFHrt%JMUU%a6Vh@aL`76==?JΝ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 diff --git a/vendor/pydantic-argparse/docs/assets/images/showcase_04.png.license b/vendor/pydantic-argparse/docs/assets/images/showcase_04.png.license deleted file mode 100644 index 25fd365b4..000000000 --- a/vendor/pydantic-argparse/docs/assets/images/showcase_04.png.license +++ /dev/null @@ -1,3 +0,0 @@ -SPDX-FileCopyrightText: Hayden Richards - -SPDX-License-Identifier: MIT diff --git a/vendor/pydantic-argparse/docs/assets/images/showcase_05.png b/vendor/pydantic-argparse/docs/assets/images/showcase_05.png deleted file mode 100644 index e7f340954bf541d76807837f20626c4becdf5d21..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37119 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 WHJm-Wc5>*OO|EC%of}UF{`z0ryYYSi diff --git a/vendor/pydantic-argparse/docs/assets/images/showcase_05.png.license b/vendor/pydantic-argparse/docs/assets/images/showcase_05.png.license deleted file mode 100644 index 25fd365b4..000000000 --- a/vendor/pydantic-argparse/docs/assets/images/showcase_05.png.license +++ /dev/null @@ -1,3 +0,0 @@ -SPDX-FileCopyrightText: Hayden Richards - -SPDX-License-Identifier: MIT diff --git a/vendor/pydantic-argparse/docs/assets/images/showcase_06.png b/vendor/pydantic-argparse/docs/assets/images/showcase_06.png deleted file mode 100644 index dec909436405fe03f825ccb3dd5e7bd4e9d462d8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12832 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 diff --git a/vendor/pydantic-argparse/docs/assets/images/showcase_06.png.license b/vendor/pydantic-argparse/docs/assets/images/showcase_06.png.license deleted file mode 100644 index 25fd365b4..000000000 --- a/vendor/pydantic-argparse/docs/assets/images/showcase_06.png.license +++ /dev/null @@ -1,3 +0,0 @@ -SPDX-FileCopyrightText: Hayden Richards - -SPDX-License-Identifier: MIT diff --git a/vendor/pydantic-argparse/docs/assets/stylesheets/reference.css b/vendor/pydantic-argparse/docs/assets/stylesheets/reference.css deleted file mode 100644 index 27dd8cad9..000000000 --- a/vendor/pydantic-argparse/docs/assets/stylesheets/reference.css +++ /dev/null @@ -1,6 +0,0 @@ -/* Code Reference Indentation. */ -div.doc-contents:not(.first) { - padding-left: 25px; - border-left: 4px solid rgba(230, 230, 230); - margin-bottom: 80px; -} diff --git a/vendor/pydantic-argparse/docs/assets/stylesheets/reference.css.license b/vendor/pydantic-argparse/docs/assets/stylesheets/reference.css.license deleted file mode 100644 index 25fd365b4..000000000 --- a/vendor/pydantic-argparse/docs/assets/stylesheets/reference.css.license +++ /dev/null @@ -1,3 +0,0 @@ -SPDX-FileCopyrightText: Hayden Richards - -SPDX-License-Identifier: MIT diff --git a/vendor/pydantic-argparse/docs/background.md b/vendor/pydantic-argparse/docs/background.md deleted file mode 100644 index 92a13391a..000000000 --- a/vendor/pydantic-argparse/docs/background.md +++ /dev/null @@ -1,143 +0,0 @@ - - -## Overview -Before delving into the documentation, examples and code reference, it is first -necessary to explore and understand why you may want to use this package. - -## Tenets -The design goals of `pydantic-argparse` are summarised by these core tenets. - -#### Simple -: `pydantic-argparse` has a simple API and code-base. - -#### Opinionated -: `pydantic-argparse` is deliberately limited with *one way* of doing things. - -#### Typed -: `pydantic-argparse` fully supports type-hinting and `mypy`. - -## Rationale -There are many benefits to using `pydantic-argparse` over a more traditional -argument parsing package that uses a functional api. Some of the most valuable -benefits are outlined below. - -#### Declarative Arguments -!!! success "" - Arguments are defined declaratively using `pydantic` models. This means the - command-line interface for your application has a strict schema, that is - easy to view, modify or even export to other formats such as `JSON Schema`. - -#### Familiar Syntax -!!! success "" - Due to the use of `pydantic` models and standard type-hinting, there is - almost no new syntax or API to learn. Just declare your interface with a - *dataclass-like* `pydantic` model, and let `pydantic-argparse` parse your - arguments. - -#### Type Hints -!!! success "" - Due to the use of `pydantic` models, your parsed command-line arguments are - just an instance of a type-hinted class. This means that your arguments can - support auto-completion, linting, mypy and other tools in your IDE. - -#### Pydantic Validation -!!! success "" - Due to the use of `pydantic` models, your command-line interface is able to - heavily leverage `pydantic`'s validation system to provide a *very* large - number of different types. - -#### Confidence -!!! success "" - As a result of type-hinting and `pydantic` validation, you can have the - confidence that once your command-line arguments have been parsed, their - type and validity have been confirmed - you don't have to check or worry - about them again. - -## Drawbacks -There are also some drawbacks to using `pydantic-argparse`, depending on the -size of your project, the features you require and the programming paradigms -that you agree with. Some of the possible drawbacks are outlined below. - -#### Extra Dependencies -!!! warning "" - While `pydantic-argparse` itself depends *only* on `pydantic`, it has a - number of transient dependencies due to the dependencies of `pydantic` - itself. If your application is small, it may not be suitable to pull in - `pydantic` and its dependencies for a simple command-line interface. - -#### Opinionated Design -!!! warning "" - `pydantic-argparse` is a very opinionated package by design. It aims for a - simple API, and to be both full featured while limiting excessive choices. - For example, there are no *positional* arguments in `pydantic-argparse`; - only *optional* and *required* arguments. If your opinions do not align - with these design choices, then you may not want to use the package. - -#### Nested Models -!!! warning "" - Sub-commands are supported by *nesting* `pydantic` models. This means that - for each sub-command, an additional model must be defined. If your - application requires many different sub-commands, it may result in a large - number of `pydantic` models. - -## Alternatives -There are many alternative argument parsing packages that already exist for -Python. Some of the most popular are outlined below. - -#### [Argparse][1] -> `argparse` is a standard-library module that makes it easy to write -> user-friendly command-line interfaces. The program defines what arguments it -> requires, and `argparse` will figure out how to parse those out of -> `sys.argv`. The `argparse` module also automatically generates help and usage -> messages and issues errors when users give the program invalid arguments. - -#### [Click][2] -> `click` is a Python package for creating beautiful command line interfaces in -> a composable way with as little code as necessary. It’s the “Command Line -> Interface Creation Kit”. It’s highly configurable but comes with sensible -> defaults out of the box. - -#### [Typer][3] -> `typer` is a library for building CLI applications that users will love using -> and developers will love creating. Based on Python 3.6+ type hints. The key -> features are that it is intuitive to write, easy to use, short and starts -> simple but can grow large. It aims to be the `fastapi` of command-line -> interfaces. - -## Comparison -A feature comparison matrix of the alternatives outlined above is shown below. - -| | `argparse` | `click` | `typer` | `pydantic-argparse` | -| ------------------------------: | :----------------: | :----------------: | :----------------: | :-----------------: | -| **Arguments** | -| *Optional Arguments* | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| *Required Arguments* | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| *Positional Arguments* | :white_check_mark: | :white_check_mark: | :white_check_mark: | | -| *Sub-Commands* | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| **Argument Types** | -| *Regular Arguments* | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| *Variadic Arguments* | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| *Flag Arguments* | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| *Choice Arguments* | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| **Validation** | -| *Type Validation* | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| *Automatic Validation* | | | :white_check_mark: | :white_check_mark: | -| *Pydantic Validation* | | | | :white_check_mark: | -| **Design Pattern** | -| *Functional Definition* | :white_check_mark: | :white_check_mark: | :white_check_mark: | | -| *Declarative Definition* | | | | :white_check_mark: | -| *Function Decorators* | | :white_check_mark: | :white_check_mark: | | -| *Function Signature Inspection* | | | :white_check_mark: | | -| **Extra Features** | -| *Typing Hinting* | | :white_check_mark: | :white_check_mark: | :white_check_mark: | -| *Shell Completion* | | :white_check_mark: | :white_check_mark: | | -| *Environment Variables* | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | - - -[1]: https://docs.python.org/3/library/argparse.html -[2]: https://click.palletsprojects.com/ -[3]: https://typer.tiangolo.com/ diff --git a/vendor/pydantic-argparse/docs/examples/commands.md b/vendor/pydantic-argparse/docs/examples/commands.md deleted file mode 100644 index 870b0abb9..000000000 --- a/vendor/pydantic-argparse/docs/examples/commands.md +++ /dev/null @@ -1,61 +0,0 @@ - - -### Define Model -```python title="commands.py" ---8<-- "examples/commands.py" -``` - -### Check Help -```console -$ python3 examples/commands.py --help -usage: Example Program [-h] [-v] [--verbose] {build,serve} ... - -Example Description - -commands: - {build,serve} - build build command - serve serve command - -optional arguments: - --verbose verbose flag (default: False) - -help: - -h, --help show this help message and exit - -v, --version show program's version number and exit - -Example Epilog -``` - -### Check Commands Help -```console -$ python3 examples/commands.py build --help -usage: Example Program build [-h] --location LOCATION - -required arguments: - --location LOCATION build location - -help: - -h, --help show this help message and exit -``` -```console -$ python3 examples/commands.py serve --help -usage: Example Program serve [-h] --address ADDRESS --port PORT - -required arguments: - --address ADDRESS serve address - --port PORT serve port - -help: - -h, --help show this help message and exit -``` - -### Parse Arguments -```console -$ python3 examples/commands.py --verbose serve --address 127.0.0.1 --port 8080 -verbose=True build=None serve=ServeCommand(address=IPv4Address('127.0.0.1'), port=8080) -``` diff --git a/vendor/pydantic-argparse/docs/examples/simple.md b/vendor/pydantic-argparse/docs/examples/simple.md deleted file mode 100644 index a1b5dd3c3..000000000 --- a/vendor/pydantic-argparse/docs/examples/simple.md +++ /dev/null @@ -1,40 +0,0 @@ - - -### Define Model -```python title="simple.py" ---8<-- "examples/simple.py" -``` - -### Check Help -```console -$ python3 simple.py --help -usage: Example Program [-h] [-v] --string STRING --integer INTEGER --flag | - --no-flag [--second-flag] [--no-third-flag] - -Example Description - -required arguments: - --string STRING a required string - --integer INTEGER a required integer - --flag, --no-flag a required flag - -optional arguments: - --second-flag an optional flag (default: False) - --no-third-flag an optional flag (default: True) - -help: - -h, --help show this help message and exit - -v, --version show program's version number and exit - -Example Epilog -``` - -### Parse Arguments -```console -$ python3 simple.py --string hello --integer 42 --flag -string='hello' integer=42 flag=True second_flag=False third_flag=True -``` diff --git a/vendor/pydantic-argparse/docs/index.md b/vendor/pydantic-argparse/docs/index.md deleted file mode 100644 index 7aa253ef1..000000000 --- a/vendor/pydantic-argparse/docs/index.md +++ /dev/null @@ -1,68 +0,0 @@ - - -
- - - -

- Pydantic Argparse -

-

- Typed Argument Parsing with Pydantic -

- - - - - - - - - - - - -
- - - - - - - - - - - - -
---- - -## Overview -`pydantic-argparse` is a Python package built on top of [`pydantic`][1] which -provides declarative *typed* argument parsing using `pydantic` models. - -## Requirements -`pydantic-argparse` requires Python 3.7+ - -## Installation -Installation with `pip` is simple: -```console -$ pip install pydantic-argparse -``` - -## Quick Start ---8<-- "docs/examples/simple.md" - -## Credits -This project is made possible by [`pydantic`][1]. - -## License -This project is licensed under the terms of the MIT license. - - -[1]: https://docs.pydantic.dev/ diff --git a/vendor/pydantic-argparse/docs/reference/reference.py b/vendor/pydantic-argparse/docs/reference/reference.py deleted file mode 100644 index 9e8c22c25..000000000 --- a/vendor/pydantic-argparse/docs/reference/reference.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Automatic Code Reference Documentation Generation.""" - - -# Standard -import pathlib - -# Third-Party -import mkdocs_gen_files - - -# Configuration -PACKAGE = pathlib.Path("pydantic_argparse") -DOCS = pathlib.Path("reference") - -# Constants -FILENAME_NAVIGATION = "SUMMARY.md" -FILENAME_INDEX = "index.md" -PYTHON_GLOB = "**/*.py" -DUNDER = "__" -DOT_MD = ".md" -PREFIX_H1 = "# " -PREFIX_H2 = "## " -PREFIX_CODE = "::: " -ESCAPE_MD = "_", "\\_" - - -def generate(package: pathlib.Path, docs: pathlib.Path) -> None: - """Generates the Code Reference Documentation. - - Args: - package (pathlib.Path): Location of the package to generate docs. - docs (pathlib.Path): Location to write out docs to. - """ - # Instantiate Documentation and Navigation Generators - files_editor = mkdocs_gen_files.FilesEditor.current() - nav = mkdocs_gen_files.Nav() - - # Loop through regular files in the package - for source in sorted(package.glob(PYTHON_GLOB)): - # Generate Reference - reference = PREFIX_CODE + ".".join(source.with_suffix("").parts) - - # Check if file is "dunder" module - if source.stem.startswith(DUNDER) and source.stem.endswith(DUNDER): - # Generate docs for dunder files - path = docs / source.with_name(FILENAME_INDEX) - heading = PREFIX_H1 + source.parent.stem - subheading = PREFIX_H2 + source.name.replace(*ESCAPE_MD) - titles = source.parent.parts - - else: - # Generate docs for regular files - path = docs / source.with_suffix(DOT_MD) - heading = PREFIX_H1 + source.stem - subheading = "" - titles = source.parts - - # Docs - with files_editor.open(str(path), "a") as file_object: - # Check if the file is empty - if not file_object.tell(): - # Heading - file_object.write(heading + "\n") - - # Sub Heading - file_object.write(subheading + "\n") - - # Code Reference - file_object.write(reference + "\n") - - # Build Nav - nav[titles] = str(path.relative_to(docs)) - - # Nav - with files_editor.open(str(docs / FILENAME_NAVIGATION), "w") as file_object: - # Write Nav - file_object.writelines(nav.build_literate_nav()) - - -# Run -generate(PACKAGE, DOCS) diff --git a/vendor/pydantic-argparse/docs/reference/reference.py.license b/vendor/pydantic-argparse/docs/reference/reference.py.license deleted file mode 100644 index 25fd365b4..000000000 --- a/vendor/pydantic-argparse/docs/reference/reference.py.license +++ /dev/null @@ -1,3 +0,0 @@ -SPDX-FileCopyrightText: Hayden Richards - -SPDX-License-Identifier: MIT diff --git a/vendor/pydantic-argparse/docs/showcase.md b/vendor/pydantic-argparse/docs/showcase.md deleted file mode 100644 index 1b594264a..000000000 --- a/vendor/pydantic-argparse/docs/showcase.md +++ /dev/null @@ -1,136 +0,0 @@ - - -## Feature Showcase -This showcase demonstrates how `pydantic-argparse` can be useful, by -highlighting some of its features and showing how they can be utilised. - -### CLI Construction -The `pydantic-argparse` command-line interface construction is simple. - -=== "Pydantic Argparse" - ```python - import pydantic - import pydantic_argparse - - # Declare Arguments - class Arguments(pydantic.BaseModel): - # Required Arguments - string: str = pydantic.Field(description="a required string") - integer: int = pydantic.Field(description="a required integer") - flag: bool = pydantic.Field(description="a required flag") - - # Optional Arguments - second_flag: bool = pydantic.Field(False, description="an optional flag") - third_flag: bool = pydantic.Field(True, description="an optional flag") - - # Create Parser - parser = pydantic_argparse.ArgumentParser( - model=Arguments, - prog="Example Program", - description="Example Description", - version="0.0.1", - epilog="Example Epilog", - ) - - # Parse Arguments - args = parser.parse_typed_args() - ``` - -=== "Argparse" - ```python - import argparse - - # Create Parser - parser = argparse.ArgumentParser( - prog="Example Program", - description="Example Description", - epilog="Example Epilog", - add_help=False, - ) - - # Functionally Add Argument Groups - required = parser.add_argument_group(title="required arguments") - optional = parser.add_argument_group(title="optional arguments") - help = parser.add_argument_group("help") - - # Add Help Actions - help.add_argument( - "-h", - "--help", - action="help", - help="show this help message and exit", - ) - help.add_argument( - "-v", - "--version", - action="version", - version="0.0.1", - help="show program's version number and exit", - ) - - # Add Required Arguments - required.add_argument( - "--string", - type=str, - required=True, - help="a required string", - ) - required.add_argument( - "--integer", - type=int, - required=True, - help="a required integer", - ) - required.add_argument( - "--flag", - action=argparse.BooleanOptionalAction, - required=True, - help="a required flag", - ) - - # Add Optional Arguments - optional.add_argument( - "--second-flag", - action="store_true", - help="an optional flag (default: False)", - ) - optional.add_argument( - "--third-flag", - action="store_false", - help="an optional flag (default: True)", - ) - - # Parse Arguments - args = parser.parse_args() - ``` - -### Auto Completion -The `pydantic-argparse` parsed `args` support auto-completion in your IDE. - -=== "Pydantic Argparse" - ![Pydantic Argparse - Auto Completion](assets/images/showcase_01.png) - -=== "Argparse" - ![Argparse - Auto Completion](assets/images/showcase_02.png) - -### Type Hints -The `pydantic-argparse` parsed `args` support type-hinting in your IDE. - -=== "Pydantic Argparse" - ![Pydantic Argparse - Type Hints](assets/images/showcase_03.png) - -=== "Argparse" - ![Argparse - Type Hints](assets/images/showcase_04.png) - -### Type Safety -The `pydantic-argparse` parsed `args` support type-safety with `mypy`. - -=== "Pydantic Argparse" - ![Pydantic Argparse - Type Safety](assets/images/showcase_05.png) - -=== "Argparse" - ![Argparse - Type Safety](assets/images/showcase_06.png) diff --git a/vendor/pydantic-argparse/docs/usage/argument_parser.md b/vendor/pydantic-argparse/docs/usage/argument_parser.md deleted file mode 100644 index 3c0eff17f..000000000 --- a/vendor/pydantic-argparse/docs/usage/argument_parser.md +++ /dev/null @@ -1,66 +0,0 @@ - - -## Overview -The interface for `pydantic-argparse` is the custom typed -[`ArgumentParser`][pydantic_argparse.argparse.parser.ArgumentParser] class, -which provides declarative, typed argument parsing. - -This `ArgumentParser` class presents a very *similar* interface to the `python` -standard library `argparse.ArgumentParser`, in an attempt to provide as close -to a drop-in-replacement as possible. - -## Parser Instantiation -To create an instance of the `ArgumentParser`: -```python -parser = pydantic_argparse.ArgumentParser( - model=Arguments, - prog="Program Name", - description="Program Description", - version="1.2.3", - epilog="Program Epilog", - add_help=True, - exit_on_error=True, -) -``` - -### Required Parameters -The *required* parameters for the `ArgumentParser` are outlined below: - -* `model` (`Type[pydantic.BaseModel]`): - The model that defines the command-line arguments - -### Optional Parameters -The *optional* parameters for the `ArgumentParser` are outlined below: - -* `prog` (`Optional[str]`): - The program name that appears in the help message -* `description` (`Optional[str]`): - The program description that appears in the help message -* `version` (`Optional[str]`): - The program version that appears in the help message -* `epilog` (`Optional[str]`): - The program epilog that appears in the help message -* `add_help` (`bool`): - Whether to add the `-h / --help` help message action -* `exit_on_error` (`bool`): - Whether to exit, or raise an `ArgumentError` upon an error - -## Argument Parsing -To parse command-line arguments into the `model` using the `ArgumentParser`: -```python -args = parser.parse_typed_args() -``` - -!!! info - The `ArgumentParser` is *generic* over its `pydantic` `model`. This means - that the parsed `args` object is type-hinted as an instance of its `model`. - -### Optional Parameters -The *optional* parameters for the `parse_typed_args` method are outlined below: - -* `args` (`Optional[List[str]]`): - Optional list of arguments to parse *instead* of `sys.argv` diff --git a/vendor/pydantic-argparse/docs/usage/arguments/choices.md b/vendor/pydantic-argparse/docs/usage/arguments/choices.md deleted file mode 100644 index 4699874fc..000000000 --- a/vendor/pydantic-argparse/docs/usage/arguments/choices.md +++ /dev/null @@ -1,245 +0,0 @@ - - -## Overview -`pydantic-argparse` provides functionality for choice arguments. A choice is a -command-line argument that allows a restricted set of values. For example: -`--choice X` or `--choice Y`. - -This section covers the following standard `argparse` argument functionality: - -```python -# Enum Choices -parser.add_argument("--choice", choices=[Enum.A, Enum.B, Enum.B]) -# Literal Choices -parser.add_argument("--choice", choices=["A", "B", "C"]) -``` - -## Usage -The intended usage of choice arguments is to restrict the set of valid options -for the user. For example: - -```console -$ python3 example.py --choice PAPER -``` - -```python -if args.choice == "PAPER": - # Choice PAPER - ... -elif args.choice == "SCISSORS": - # Choice SCISSORS - ... -elif args.choice == "ROCK": - # Choice ROCK - ... -else: - # This cannot occur! - # Something must have gone wrong... - ... -``` - -## Enums -Enum choices can be created by adding a `pydantic` `Field` with the type of an -`:::python enum.Enum` class, which contains more than one enumeration. There -are different kinds of enum choice arguments, which are outlined below. - -### Required -A *required* enum choice argument is defined as follows: - -```python -class Choices(enum.Enum): - A = enum.auto() - B = enum.auto() - C = enum.auto() - -class Arguments(BaseModel): - # Required Choice - choice: Choices = Field(description="this is a required choice") -``` - -This `Arguments` model generates the following command-line interface: - -```console -$ python3 example.py --help -usage: example.py [-h] --choice {A, B, C} - -required arguments: - --choice {A, B, C} this is a required choice - -help: - -h, --help show this help message and exit -``` - -Outcomes: - -* Providing an argument of `--choice A` will set `args.choice` to `Choices.A`. -* Providing an argument of `--choice B` will set `args.choice` to `Choices.B`. -* Providing an argument of `--choice C` will set `args.choice` to `Choices.C`. -* This argument cannot be omitted. - -### Optional (Default `None`) -An *optional* enum choice argument with a default of `None` is defined as -follows: - -```python -class Choices(enum.Enum): - A = enum.auto() - B = enum.auto() - C = enum.auto() - -class Arguments(BaseModel): - # Optional Choice (Default None) - choice: Optional[Choices] = Field(description="this is an optional choice") -``` - -This `Arguments` model generates the following command-line interface: - -```console -$ python3 example.py --help -usage: example.py [-h] [--choice {A, B, C}] - -optional arguments: - --choice {A, B, C} this is an optional choice (default: None) - -help: - -h, --help show this help message and exit -``` - -Outcomes: - -* Providing an argument of `--choice A` will set `args.choice` to `Choices.A`. -* Providing an argument of `--choice B` will set `args.choice` to `Choices.B`. -* Providing an argument of `--choice C` will set `args.choice` to `Choices.C`. -* Omitting this argument will set `args.choice` to `None` (the default). - -### Optional (Default `Value`) -An *optional* enum choice argument with a default choice is defined as follows: - -```python -class Choices(enum.Enum): - A = enum.auto() - B = enum.auto() - C = enum.auto() - -class Arguments(BaseModel): - # Optional Choice (Default Choices.A) - choice: Choices = Field(Choices.A, description="this is an optional choice") -``` - -This `Arguments` model generates the following command-line interface: - -```console -$ python3 example.py --help -usage: example.py [-h] [--choice {A, B, C}] - -optional arguments: - --choice {A, B, C} this is an optional choice (default: Choices.A) - -help: - -h, --help show this help message and exit -``` - -Outcomes: - -* Providing an argument of `--choice A` will set `args.choice` to `Choices.A`. -* Providing an argument of `--choice B` will set `args.choice` to `Choices.B`. -* Providing an argument of `--choice C` will set `args.choice` to `Choices.C`. -* Omitting this argument will set `args.choice` to `Choices.A` (the default). - -## Literals -Literal choices can be created by adding a `pydantic` `Field` with the type of -`:::python typing.Literal`, which contains more than one literal value. There -are different kinds of literal flag arguments, which are outlined below. - -### Required -A *required* literal choice argument is defined as follows: - -```python -class Arguments(BaseModel): - # Required Choice - choice: Literal["A", "B", "C"] = Field(description="this is a required choice") -``` - -This `Arguments` model generates the following command-line interface: - -```console -$ python3 example.py --help -usage: example.py [-h] --choice {A, B, C} - -required arguments: - --choice {A, B, C} this is a required choice - -help: - -h, --help show this help message and exit -``` - -Outcomes: - -* Providing an argument of `--choice A` will set `args.choice` to `"A"`. -* Providing an argument of `--choice B` will set `args.choice` to `"B"`. -* Providing an argument of `--choice C` will set `args.choice` to `"C"`. -* This argument cannot be omitted. - -### Optional (Default `None`) -An *optional* literal choice argument with a default of `None` is defined as -follows: - -```python -class Arguments(BaseModel): - # Optional Choice (Default None) - choice: Optional[Literal["A", "B", "C"]] = Field(description="this is an optional choice") -``` - -This `Arguments` model generates the following command-line interface: - -```console -$ python3 example.py --help -usage: example.py [-h] [--choice {A, B, C}] - -optional arguments: - --choice {A, B, C} this is an optional choice (default: None) - -help: - -h, --help show this help message and exit -``` - -Outcomes: - -* Providing an argument of `--choice A` will set `args.choice` to `"A"`. -* Providing an argument of `--choice B` will set `args.choice` to `"B"`. -* Providing an argument of `--choice C` will set `args.choice` to `"C"`. -* Omitting this argument will set `args.choice` to `None` (the default). - -### Optional (Default `Value`) -An *optional* literal choice argument with a default choice is defined as -follows: - -```python -class Arguments(BaseModel): - # Optional Choice (Default "A") - choice: Literal["A", "B", "C"] = Field("A", description="this is an optional choice") -``` - -This `Arguments` model generates the following command-line interface: - -```console -$ python3 example.py --help -usage: example.py [-h] [--choice {A, B, C}] - -optional arguments: - --choice {A, B, C} this is an optional choice (default: A) - -help: - -h, --help show this help message and exit -``` - -Outcomes: - -* Providing an argument of `--choice A` will set `args.choice` to `"A"`. -* Providing an argument of `--choice B` will set `args.choice` to `"B"`. -* Providing an argument of `--choice C` will set `args.choice` to `"C"`. -* Omitting this argument will set `args.choice` to `"A"` (the default). diff --git a/vendor/pydantic-argparse/docs/usage/arguments/commands.md b/vendor/pydantic-argparse/docs/usage/arguments/commands.md deleted file mode 100644 index 2d6a78340..000000000 --- a/vendor/pydantic-argparse/docs/usage/arguments/commands.md +++ /dev/null @@ -1,106 +0,0 @@ - - -## Overview -`pydantic-argparse` provides functionality for commands. A command is a -positional command-line argument that can be followed by its own specific -subset of command-line arguments. For example: `command --arg abc`. - -This section covers the following standard `argparse` argument functionality: - -```python -# Subparser Commands -subparsers = parser.add_subparsers() -command = subparsers.add_parser("command") -command.add_argument(...) -``` - -## Usage -The intended usage of commands is to provide the user with different -application behaviours, each with their own subset of arguments. For example: - -```console -$ python3 example.py serve --address 127.0.0.1 --port 8080 -``` - -```python -if args.serve: - # The serve command was chosen - # We have typed access to any of the command model arguments we defined - # For example: `args.serve.address`, `args.serve.port`, etc. - ... -``` - -## Pydantic Models -Commands can be created by first defining a `pydantic` model for the command -(e.g., `Command`), containing its own subset of arguments. The command can then -be added to the command-line interface by adding a `pydantic` field with the -type of `Optional[Command]`. Despite each command itself being *optional*, -overall a command is *always* required, as outlined below. - -### Required -*Required* commands are defined as follows: - -```python -class Command1(BaseModel): - arg1: str = Field(description="this is sub-argument 1") - -class Command2(BaseModel): - arg2: str = Field(description="this is sub-argument 2") - -class Arguments(BaseModel): - # Commands - command1: Optional[Command1] = Field(description="this is command 1") - command2: Optional[Command2] = Field(description="this is command 2") -``` - -This `Arguments` model generates the following command-line interface: - -```console -$ python3 example.py --help -usage: example.py [-h] {command1,command2} ... - -commands: - {command1,command2} - command1 this is command 1 - command2 this is command 2 - -help: - -h, --help show this help message and exit -``` - -This `Arguments` model also generates command-line interfaces for each of its -commands: - -```console -$ python3 example.py command1 --help -usage: example.py command1 [-h] --arg1 ARG1 - -required arguments: - --arg1 ARG1 this is sub-argument 1 - -help: - -h, --help show this help message and exit -``` - -```console -$ python3 example.py command2 --help -usage: example.py command2 [-h] --arg2 ARG2 - -required arguments: - --arg2 ARG2 this is sub-argument 2 - -help: - -h, --help show this help message and exit -``` - -Outcomes: - -* Providing arguments of `command1 --arg1 abc` will set `args.command1` to - to `:::python Command1(arg1="abc")`, and `args.command2` to `None`. -* Providing arguments of `command2 --arg2 xyz` will set `args.command2` to - to `:::python Command2(arg2="xyz")`, and `args.command1` to `None`. -* Commands cannot be omitted. diff --git a/vendor/pydantic-argparse/docs/usage/arguments/flags.md b/vendor/pydantic-argparse/docs/usage/arguments/flags.md deleted file mode 100644 index 5904c1337..000000000 --- a/vendor/pydantic-argparse/docs/usage/arguments/flags.md +++ /dev/null @@ -1,246 +0,0 @@ - - -## Overview -`pydantic-argparse` provides functionality for flag arguments. A flag is a -command-line argument that has no following value. For example: `--flag` or -`--no-flag`. - -This section covers the following standard `argparse` argument functionality: - -```python -# Boolean Flags -parser.add_argument("--flag", action=argparse.BooleanOptionalAction) -parser.add_argument("--flag", action="store_true") -parser.add_argument("--no-flag", action="store_false") -# Constant Flags -parser.add_argument("--flag", action="store_const", const="A") -parser.add_argument("--flag", action="store_const", const=Enum.A) -``` - -## Usage -The intended usage of flags is to enable or disable features. For example: - -```console -$ python3 example.py --debug -``` - -```python -if args.debug: - # Set logging to DEBUG - ... -``` - -## Booleans -Boolean flags can be created by adding a `pydantic` `Field` with the type of -`:::python bool`. There are different kinds of boolean flag arguments, which -are outlined below. - -### Required -A *required* boolean flag is defined as follows: - -```python -class Arguments(BaseModel): - # Required Flag - flag: bool = Field(description="this is a required flag") -``` - -This `Arguments` model generates the following command-line interface: - -```console -$ python3 example.py --help -usage: example.py [-h] --flag | --no-flag - -required arguments: - --flag, --no-flag this is a required flag - -help: - -h, --help show this help message and exit -``` - -Outcomes: - -* Providing an argument of `--flag` will set `args.flag` to `True`. -* Providing an argument of `--no-flag` will set `args.flag` to `False`. -* This argument cannot be omitted. - -### Optional (Default `False`) -An *optional* boolean flag with a default of `False` is defined as follows: - -```python -class Arguments(BaseModel): - # Optional Flag (Default False) - flag: bool = Field(False, description="this is an optional flag") -``` - -This `Arguments` model generates the following command-line interface: - -```console -$ python3 example.py --help -usage: example.py [-h] [--flag] - -optional arguments: - --flag this is an optional flag (default: False) - -help: - -h, --help show this help message and exit -``` - -Outcomes: - -* Providing an argument of `--flag` will set `args.flag` to `True`. -* Omitting this argument will set `args.flag` to `False` (the default). - -### Optional (Default `True`) -An *optional* boolean flag with a default of `True` is defined as follows: - -```python -class Arguments(BaseModel): - # Optional Flag (Default True) - flag: bool = Field(True, description="this is an optional flag") -``` - -This `Arguments` model generates the following command-line interface: - -```console -$ python3 example.py --help -usage: example.py [-h] [--no-flag] - -optional arguments: - --no-flag this is an optional flag (default: True) - -help: - -h, --help show this help message and exit -``` - -Outcomes: - -* Providing an argument of `--no-flag` will set `args.flag` to `False`. -* Omitting this argument will set `args.flag` to `True` (the default). - -## Enums -Enum flags can be created by adding a `pydantic` `Field` with the type of an -`:::python enum.Enum` class, which contains only one enumeration. There are -different kinds of enum flag arguments, which are outlined below. - -### Optional (Default `None`) -An *optional* enum flag with a default of `None` is defined as follows: - -```python -class Constant(enum.Enum): - VALUE = enum.auto() - -class Arguments(BaseModel): - # Optional Flag (Default None) - constant: Optional[Constant] = Field(description="this is a constant flag") -``` - -This `Arguments` model generates the following command-line interface: - -```console -$ python3 example.py --help -usage: example.py [-h] [--constant] - -optional arguments: - --constant this is a constant flag (default: None) - -help: - -h, --help show this help message and exit -``` - -Outcomes: - -* Providing an argument of `--constant` will set `args.constant` to `Constant.VALUE`. -* Omitting this argument will set `args.constant` to `None` (the default). - -### Optional (Default `Constant`) -An *optional* enum flag with a constant default value is defined as follows: - -```python -class Constant(enum.Enum): - VALUE = enum.auto() - -class Arguments(BaseModel): - # Optional Flag (Default Constant.VALUE) - constant: Optional[Constant] = Field(Constant.VALUE, description="this is a constant flag") -``` - -This `Arguments` model generates the following command-line interface: - -```console -$ python3 example.py --help -usage: example.py [-h] [--no-constant] - -optional arguments: - --no-constant this is a constant flag (default: Constant.VALUE) - -help: - -h, --help show this help message and exit -``` - -Outcomes: - -* Providing an argument of `--no-constant` will set `args.constant` to `None`. -* Omitting this argument will set `args.constant` to `Constant.VALUE` (the default). - -## Literals -Literal flags can be created by adding a `pydantic` `Field` with the type of -`:::python typing.Literal`, which contains only one literal value. There are -different kinds of literal flag arguments, which are outlined below. - -### Optional (Default `None`) -An *optional* literal flag with a default of `None` is defined as follows: - -```python -class Arguments(BaseModel): - # Optional Flag (Default None) - constant: Optional[Literal["VALUE"]] = Field(description="this is a constant flag") -``` - -This `Arguments` model generates the following command-line interface: - -```console -$ python3 example.py --help -usage: example.py [-h] [--constant] - -optional arguments: - --constant this is a constant flag (default: None) - -help: - -h, --help show this help message and exit -``` - -Outcomes: - -* Providing an argument of `--constant` will set `args.constant` to `"VALUE"`. -* Omitting this argument will set `args.constant` to `None` (the default). - -### Optional (Default `Constant`) -An *optional* literal flag with a constant default value is defined as follows: - -```python -class Arguments(BaseModel): - # Optional Flag (Default "VALUE") - constant: Optional[Literal["VALUE"]] = Field("VALUE", description="this is a constant flag") -``` - -This `Arguments` model generates the following command-line interface: - -```console -$ python3 example.py --help -usage: example.py [-h] [--no-constant] - -optional arguments: - --no-constant this is a constant flag (default: VALUE) - -help: - -h, --help show this help message and exit -``` - -Outcomes: - -* Providing an argument of `--no-constant` will set `args.constant` to `None`. -* Omitting this argument will set `args.constant` to `"VALUE"` (the default). diff --git a/vendor/pydantic-argparse/docs/usage/arguments/index.md b/vendor/pydantic-argparse/docs/usage/arguments/index.md deleted file mode 100644 index 22829a963..000000000 --- a/vendor/pydantic-argparse/docs/usage/arguments/index.md +++ /dev/null @@ -1,238 +0,0 @@ - - -## Overview -At the core of `pydantic-argparse` is the `pydantic` *model*, in which -arguments are declared with `pydantic` *fields*. This combination of the -*model* and its *fields* defines the *schema* for your command-line arguments. - -## Pydantic -### Models -A `pydantic` model is simply a *dataclass-like* class that inherits from the -`pydantic.BaseModel` base class. In `pydantic-argparse`, this model is used to -declaratively define your command-line arguments. - -```python -class Arguments(BaseModel): - # Required - string: str - integer: int - number: float - - # Optional - boolean: bool = False -``` - -Arbitrary data, such as raw command-line arguments, can be passed to a model. -After parsing and validation `pydantic` guarantees that the fields of the -resultant model instance will conform to the field types defined on the model. - -!!! info - For more information about `pydantic` models, see the `pydantic` [docs][1]. - -### Fields -A `pydantic` model contains *fields*, which are the model class attributes. -These fields define each `pydantic-argparse` command-line argument, and they -can be declared either *implicitly* (as above), or *explicitly* (as below). - -```python -class Arguments(BaseModel): - # Required - string: str = Field(description="this argument is a string") - integer: int = Field(description="this argument is an integer") - number: float = Field(description="this argument is a number") - - # Optional - boolean: bool = Field(False, description="this argument is a boolean") -``` - -Explicitly defining fields can provide extra information about an argument, -either for the command-line interface, the model schema or features such as -complex validation. - -!!! info - For more information about `pydantic` fields, see the `pydantic` [docs][2]. - -## Arguments -### Required -A field defines a required argument if it has no default value, or a default -value of the `Ellipses` (`...`) singleton object. - -```python -class Arguments(BaseModel): - a: int - b: int = ... - c: int = Field() - d: int = Field(...) -``` - -### Optional -A field defines an optional argument if it has a default value. - -```python -class Arguments(BaseModel): - a: int = 42 - b: int = Field(42) -``` - -A field can also define an optional argument if it is type-hinted as -`Optional`. This type-hinting also allows the value of `None` for the field. - -```python -class Arguments(BaseModel): - a: Optional[int] - b: Optional[int] = None - c: Optional[int] = Field() - d: Optional[int] = Field(None) -``` - -### Descriptions -A field can be provided with a `description`, which will appear in the -command-line interface help message. - -```python -class Arguments(BaseModel): - a: int = Field(description="this is the command-line description!") -``` - -### Aliases -A field can be provided with an `alias`, which will change the argument name in -the command-line interface. - -```python -class Arguments(BaseModel): - # We want our argument to be named `class` (i.e., `--class`), but `class` - # is a reserved keyword in Python. To accomplish this, we can use the Field - # `alias` to override the argument name. - class_argument: int = Field(alias="class") -``` - -!!! tip - This feature allows you to define arguments that use a reserved python - keyword as the name. For example: `class`, `continue`, `async`. - - You can see the list of reserved keywords in Python at any time by typing - `:::python help("keywords")` into the Python interpreter. - -## Environment Variables -Functionality to parse both required and optional arguments from environment -variables is provided via the `pydantic.BaseSettings` base class. - -Simply inherit from `pydantic.BaseSettings` instead of `pydantic.BaseModel`: - -```python -class Arguments(BaseSettings): - integer: int -``` - -Arguments can then be provided via environment variables: - -```console -$ export INTEGER=123 -$ python3 example.py -Arguments(integer=123) - -$ INTEGER=456 python3 example.py -Arguments(integer=456) -``` - -Arguments supplied via the command-line take precedence over environment -variables: - -```console -$ export INTEGER=123 -$ python3 example.py --integer 42 -Arguments(integer=42) - -$ INTEGER=456 python3 example.py --integer 42 -Arguments(integer=42) -``` - -## Validation -When parsing command-line arguments with `parser.parse_typed_args()`, the raw -values are parsed and validated using `pydantic`. The parser has different -behaviours depending on whether the supplied command-line arguments are valid. - -Consider the following example model: - -```python -class Arguments(BaseModel): - integer: int -``` - -### Success -When the provided command-line arguments satisfy the `pydantic` model, a -populated instance of the model is returned - -```console -$ python3 example.py --integer 42 -Arguments(integer=42) -``` - -### Failure -When the provided command-line arguments do not satisfy the `pydantic` model, -the `ArgumentParser` will provide an error to the user. For example: - -```console -$ python3 example.py -usage: example.py [-h] --integer INTEGER -example.py: error: 1 validation error for Arguments -integer - field required (type=value_error.missing) - -$ python3 example.py --integer hello -usage: example.py [-h] --integer INTEGER -example.py: error: 1 validation error for Arguments -integer - value is not a valid integer (type=type_error.integer) -``` - -!!! note - The validation error shown to the user is the same as the error that - `pydantic` provides with its `pydantic.ValidationError`. - -### Under the Hood -Under the hood `pydantic-argparse` dynamically generates *extra* custom -`@pydantic.validator` class methods for each of your argument fields. - -These validators behave slightly differently for each argument type, but in -general they: - -* Parse empty `str` values to `None`. -* Parse *choices* (i.e., `Enum`s or `Literal`s) from `str`s to their respective - types (if applicable). -* Otherwise, pass values through unchanged. - -The validators are constructed with the `pre=True` argument, ensuring that they -are called *before* any of the user's `@pydantic.validator` class methods and -the built-in `pydantic` field validation. This means they are provided with the -most raw input data possible. - -After the generated validators have been called, the fields are parsed as per -usual by the built-in `pydantic` field validation for their respective types. - -!!! note - `pydantic-argparse` also enhances `pydantic`'s built-in - [environment variable parsing][3] capabilities. - - By default, `pydantic` attempts to parse complex types as `json` values. If - this parsing fails a `pydantic.env_settings.SettingsError` is raised and - the argument parsing fails immediately with an obscure error message. This - means the values never reach the generated `pydantic-argparse` validators, - the user's custom validators or the built-in `pydantic` field validation. - - As a solution, `pydantic-argparse` wraps the existing - `pydantic.BaseSettings.parse_env_var()` environment variable parsing class - method to handle this situation. The wrapped parser passes through raw - `str` values *unchanged* if the `json` parsing fails. This allows the raw - string values to be parsed, validated and handled by the generated - `pydantic-argparse` validators and the built-in `pydantic` field validators - if applicable. - - -[1]: https://docs.pydantic.dev/usage/models/ -[2]: https://docs.pydantic.dev/usage/schema/#field-customization -[3]: https://docs.pydantic.dev/usage/settings/#parsing-environment-variable-values diff --git a/vendor/pydantic-argparse/docs/usage/arguments/regular.md b/vendor/pydantic-argparse/docs/usage/arguments/regular.md deleted file mode 100644 index f8b68cd47..000000000 --- a/vendor/pydantic-argparse/docs/usage/arguments/regular.md +++ /dev/null @@ -1,160 +0,0 @@ - - -## Overview -`pydantic-argparse` provides functionality for regular arguments. A regular -argument is a command-line argument that is followed by *exactly* one value. -For example: `--arg hello`, `--arg 123` or `--arg 42.0`. - -This section covers the following standard `argparse` argument functionality: - -```python -parser.add_argument("--argument", type=T) -``` - -## Usage -The intended usage of regular arguments is to capture and validate a value from -the user for the application. For example: - -```console -$ python3 example.py --name SupImDos -``` - -```python -# We can use the validated command-line arguments in the application -print(f"Hello {args.name}!") -``` - -## Singular Types -Regular arguments can be created by adding a `pydantic` `Field` with any -type that takes "singular" values. - -Some examples of simple "singular" inbuilt types: - -* `str` -* `int` -* `float` -* `dict` - -!!! info - For more information about simple inbuilt types, see the `pydantic` - [docs][1] - -!!! note - `pydantic-argparse` handles some types *specially*, such as: - - * `collections.abc.Container` (e.g., `list`, `tuple`, `set`) - * `bool` - * `enum.Enum` - * `typing.Literal` - * `pydantic.BaseModel` - - The special behaviours of these types are addressed in the following - sections. - -Any type that is able to be validated by `pydantic` can be used. This allows -for advanced argument types, for example: - -* `pydantic.FilePath` -* `pydantic.EmailStr` -* `pydantic.AnyUrl` -* `pydantic.IPvAnyAddress` - -!!! info - For more information about advanced `pydantic` types, see the `pydantic` - [docs][2] - -There are different kinds of regular arguments, which are outlined below. - -### Required -A *required* regular singular argument is defined as follows: - -```python -class Arguments(BaseModel): - # Required Singular Argument - # Note: `int` is just an example, any singular type could be used - arg: int = Field(description="this is a required singular argument") -``` - -This `Arguments` model generates the following command-line interface: - -```console -$ python3 example.py --help -usage: example.py [-h] --arg ARG - -required arguments: - --arg ARG this is a required singular argument - -help: - -h, --help show this help message and exit -``` - -Outcomes: - -* Providing an argument of `--arg 42` will set `args.arg` to `42`. -* This argument cannot be omitted. - -### Optional (Default `None`) -An *optional* regular singular argument with a default of `None` is defined as -follows: - -```python -class Arguments(BaseModel): - # Optional Singular Argument - # Note: `int` is just an example, any singular type could be used - arg: Optional[int] = Field(description="this is an optional singular argument") -``` - -This `Arguments` model generates the following command-line interface: - -```console -$ python3 example.py --help -usage: example.py [-h] [--arg ARG] - -optional arguments: - --arg ARG this is a required singular argument (default: None) - -help: - -h, --help show this help message and exit -``` - -Outcomes: - -* Providing an argument of `--arg 42` will set `args.arg` to `42`. -* Omitting this argument will set `args.arg` to `None` (the default). - -### Optional (Default `Value`) -An *optional* container variadic argument with a constant default value is -defined as follows: - -```python -class Arguments(BaseModel): - # Optional Singular Argument - # Note: `int` is just an example, any singular type could be used - arg: int = Field(42, description="this is an optional singular argument") -``` - -This `Arguments` model generates the following command-line interface: - -```console -$ python3 example.py --help -usage: example.py [-h] [--arg ARG] - -optional arguments: - --arg ARG this is a required singular argument (default: 42) - -help: - -h, --help show this help message and exit -``` - -Outcomes: - -* Providing an argument of `--arg 7` will set `args.arg` to `7`. -* Omitting this argument will set `args.arg` to `42` (the default). - - -[1]: https://docs.pydantic.dev/usage/types/#standard-library-types -[2]: https://docs.pydantic.dev/usage/types/#pydantic-types diff --git a/vendor/pydantic-argparse/docs/usage/arguments/variadic.md b/vendor/pydantic-argparse/docs/usage/arguments/variadic.md deleted file mode 100644 index d75dec73d..000000000 --- a/vendor/pydantic-argparse/docs/usage/arguments/variadic.md +++ /dev/null @@ -1,132 +0,0 @@ - - -## Overview -`pydantic-argparse` provides functionality for variadic arguments. A variadic -argument is a command-line argument that is followed by one *or more* values. -For example: `--variadic a b c` or `--variadic 1 2 3 4 5 6`. - -This section covers the following standard `argparse` argument functionality: - -```python -parser.add_argument("--variadic", nargs="+") -``` - -## Usage -The intended usage of variadic arguments is to capture multiple values for an -argument. For example: - -```console -$ python3 example.py --files a.txt b.txt c.txt -``` - -```python -for file in args.files: - # We can iterate through all of the values provided by the user - ... -``` - -## Container Types -Variadic arguments can be created by adding a `pydantic` `Field` with any -type that is a `:::python collections.abc.Container` type. For example: - -* `list[T]` -* `tuple[T]` -* `set[T]` -* `frozenset[T]` -* `deque[T]` - -There are different kinds of container variadic arguments, which are outlined -below. - -### Required -A *required* container variadic argument is defined as follows: - -```python -class Arguments(BaseModel): - # Required Container Argument - # Note: `list[int]` is just an example, any container type could be used - arg: list[int] = Field(description="this is a required variadic argument") -``` - -This `Arguments` model generates the following command-line interface: - -```console -$ python3 example.py --help -usage: example.py [-h] --arg ARG [ARG ...] - -required arguments: - --arg ARG [ARG ...] this is a required variadic argument - -help: - -h, --help show this help message and exit -``` - -Outcomes: - -* Providing an argument of `--arg 1` will set `args.arg` to `[1]`. -* Providing an argument of `--arg 1 2 3` will set `args.arg` to `[1, 2, 3]`. -* This argument cannot be omitted. - -### Optional (Default `None`) -An *optional* container variadic argument with a default of `None` is defined -as follows: - -```python -class Arguments(BaseModel): - # Optional Container Argument - # Note: `list[int]` is just an example, any container type could be used - arg: Optional[list[int]] = Field(description="this is an optional variadic argument") -``` - -This `Arguments` model generates the following command-line interface: - -```console -$ python3 example.py --help -usage: example.py [-h] [--arg ARG [ARG ...]] - -optional arguments: - --arg ARG [ARG ...] this is a required variadic argument (default: None) - -help: - -h, --help show this help message and exit -``` - -Outcomes: - -* Providing an argument of `--arg 1` will set `args.arg` to `[1]`. -* Providing an argument of `--arg 1 2 3` will set `args.arg` to `[1, 2, 3]`. -* Omitting this argument will set `args.arg` to `None` (the default). - -### Optional (Default `Value`) -An *optional* container variadic argument with a constant default value is -defined as follows: - -```python -class Arguments(BaseModel): - # Optional Container Argument - # Note: `list[int]` is just an example, any container type could be used - arg: list[int] = Field([4, 5, 6], description="this is an optional variadic argument") -``` - -This `Arguments` model generates the following command-line interface: - -```console -$ python3 example.py --help -usage: example.py [-h] [--arg ARG [ARG ...]] - -optional arguments: - --arg ARG [ARG ...] this is an optional variadic argument (default: [4, 5, 6]) - -help: - -h, --help show this help message and exit -``` - -Outcomes: - -* Providing an argument of `--arg 1` will set `args.arg` to `[1]`. -* Providing an argument of `--arg 1 2 3` will set `args.arg` to `[1, 2, 3]`. -* Omitting this argument will set `args.arg` to `[4, 5, 6]` (the default). diff --git a/vendor/pydantic-argparse/examples/commands.py b/vendor/pydantic-argparse/examples/commands.py deleted file mode 100644 index 22d38ccfe..000000000 --- a/vendor/pydantic-argparse/examples/commands.py +++ /dev/null @@ -1,56 +0,0 @@ -# SPDX-FileCopyrightText: Hayden Richards -# -# SPDX-License-Identifier: MIT - -"""Commands Example.""" - - -# Third-Party -import pydantic -import pydantic_argparse - -# Typing -from typing import Optional - - -class BuildCommand(pydantic.BaseModel): - """Build Command Arguments.""" - # Required Args - location: pydantic.FilePath = pydantic.Field(description="build location") - - -class ServeCommand(pydantic.BaseModel): - """Serve Command Arguments.""" - # Required Args - address: pydantic.IPvAnyAddress = pydantic.Field(description="serve address") - port: int = pydantic.Field(description="serve port") - - -class Arguments(pydantic.BaseModel): - """Command-Line Arguments.""" - # Optional Args - verbose: bool = pydantic.Field(False, description="verbose flag") - - # Commands - build: Optional[BuildCommand] = pydantic.Field(description="build command") - serve: Optional[ServeCommand] = pydantic.Field(description="serve command") - - -def main() -> None: - """Main Function.""" - # Create Parser and Parse Args - parser = pydantic_argparse.ArgumentParser( - model=Arguments, - prog="Example Program", - description="Example Description", - version="0.0.1", - epilog="Example Epilog", - ) - args = parser.parse_typed_args() - - # Print Args - print(args) - - -if __name__ == "__main__": - main() diff --git a/vendor/pydantic-argparse/examples/simple.py b/vendor/pydantic-argparse/examples/simple.py deleted file mode 100644 index 7bc2caa6b..000000000 --- a/vendor/pydantic-argparse/examples/simple.py +++ /dev/null @@ -1,42 +0,0 @@ -# SPDX-FileCopyrightText: Hayden Richards -# -# SPDX-License-Identifier: MIT - -"""Simple Example.""" - - -# Third-Party -import pydantic -import pydantic_argparse - - -class Arguments(pydantic.BaseModel): - """Simple Command-Line Arguments.""" - # Required Args - string: str = pydantic.Field(description="a required string") - integer: int = pydantic.Field(description="a required integer") - flag: bool = pydantic.Field(description="a required flag") - - # Optional Args - second_flag: bool = pydantic.Field(False, description="an optional flag") - third_flag: bool = pydantic.Field(True, description="an optional flag") - - -def main() -> None: - """Simple Main Function.""" - # Create Parser and Parse Args - parser = pydantic_argparse.ArgumentParser( - model=Arguments, - prog="Example Program", - description="Example Description", - version="0.0.1", - epilog="Example Epilog", - ) - args = parser.parse_typed_args() - - # Print Args - print(args) - - -if __name__ == "__main__": - main() diff --git a/vendor/pydantic-argparse/mkdocs.yml b/vendor/pydantic-argparse/mkdocs.yml deleted file mode 100644 index ab4626872..000000000 --- a/vendor/pydantic-argparse/mkdocs.yml +++ /dev/null @@ -1,96 +0,0 @@ -# SPDX-FileCopyrightText: Hayden Richards -# -# SPDX-License-Identifier: MIT - -# Site -site_name: Pydantic Argparse -site_description: Typed Argument Parsing with Pydantic -site_url: https://pydantic-argparse.supimdos.com -site_author: SupImDos - -# Repository -repo_name: SupImDos/pydantic-argparse -repo_url: https://github.com/SupImDos/pydantic-argparse - -# Navigation -nav: - - Overview: index.md - - Background: background.md - - Showcase: showcase.md - - Usage: - - Argument Parser: usage/argument_parser.md - - Arguments: - - Arguments: usage/arguments/index.md - - Regular: usage/arguments/regular.md - - Variadic: usage/arguments/variadic.md - - Flags: usage/arguments/flags.md - - Choices: usage/arguments/choices.md - - Commands: usage/arguments/commands.md - - Examples: - - Simple: examples/simple.md - - Commands: examples/commands.md - - Reference: reference/ - -# Theme -theme: - name: material - icon: - logo: material/filter-plus - repo: fontawesome/brands/github - palette: - - media: "(prefers-color-scheme: light)" - scheme: default - primary: deep purple - accent: deep purple - toggle: - icon: material/toggle-switch - name: Switch to dark mode - - media: "(prefers-color-scheme: dark)" - scheme: slate - primary: deep purple - accent: deep purple - toggle: - icon: material/toggle-switch-off-outline - name: Switch to light mode - features: - - content.code.copy - - navigation.footer - - navigation.instant - - navigation.top - - navigation.sections - - navigation.indexes - - search.suggest - - search.highlight - -# Extras -extra_css: - - assets/stylesheets/reference.css - -# Markdown Extensions -markdown_extensions: - - admonition - - def_list - - tables - - pymdownx.emoji - - pymdownx.highlight - - pymdownx.inlinehilite - - pymdownx.snippets - - pymdownx.superfences - - pymdownx.tabbed: - alternate_style: true - -# Plugins -plugins: - - search - - mkdocstrings: - handlers: - python: - options: - show_root_toc_entry: false - show_bases: false - members_order: source - - gen-files: - scripts: - - docs/reference/reference.py - - literate-nav - - autorefs diff --git a/vendor/pydantic-argparse/pydantic_argparse/__init__.py b/vendor/pydantic-argparse/pydantic_argparse/__init__.py index 1bfe452c9..f30c6adf9 100644 --- a/vendor/pydantic-argparse/pydantic_argparse/__init__.py +++ b/vendor/pydantic-argparse/pydantic_argparse/__init__.py @@ -41,7 +41,7 @@ class BaseCommand(BaseModel): have `subcommand=True`. """ - model_config = ConfigDict(json_schema_extra=dict(subcommand=True)) + model_config = ConfigDict(json_schema_extra=dict(subcommand=True), defer_build=True) # Public Re-Exports diff --git a/vendor/pydantic-argparse/pydantic_argparse/__metadata__.py b/vendor/pydantic-argparse/pydantic_argparse/__metadata__.py index 92de0d07a..bbdd1c1c2 100644 --- a/vendor/pydantic-argparse/pydantic_argparse/__metadata__.py +++ b/vendor/pydantic-argparse/pydantic_argparse/__metadata__.py @@ -13,7 +13,6 @@ `license` of the package """ - # Standard import sys diff --git a/vendor/pydantic-argparse/pydantic_argparse/argparse/__init__.py b/vendor/pydantic-argparse/pydantic_argparse/argparse/__init__.py index db9230f9b..efa7495dd 100644 --- a/vendor/pydantic-argparse/pydantic_argparse/argparse/__init__.py +++ b/vendor/pydantic-argparse/pydantic_argparse/argparse/__init__.py @@ -17,6 +17,4 @@ from pydantic_argparse.argparse.parser import ArgumentParser # Public Re-Exports -__all__ = ( - "ArgumentParser", -) +__all__ = ("ArgumentParser",) diff --git a/vendor/pydantic-argparse/pydantic_argparse/argparse/parser.py b/vendor/pydantic-argparse/pydantic_argparse/argparse/parser.py index aa1dc53da..612843b86 100644 --- a/vendor/pydantic-argparse/pydantic_argparse/argparse/parser.py +++ b/vendor/pydantic-argparse/pydantic_argparse/argparse/parser.py @@ -18,15 +18,15 @@ be compatible with an IDE, linter or type checker. """ - import argparse import sys -from typing import Dict, Generic, List, NoReturn, Optional, Type, cast +from typing import Generic, NoReturn, Optional, Type, Any, Never from pydantic import BaseModel, ValidationError -from pydantic_argparse import parsers, utils +from pydantic_argparse import parsers from pydantic_argparse.argparse import actions +from pydantic_argparse.utils.field import ArgFieldInfo from pydantic_argparse.utils.nesting import _NestedArgumentParser from pydantic_argparse.utils.pydantic import PydanticField, PydanticModelT @@ -59,52 +59,51 @@ class ArgumentParser(argparse.ArgumentParser, Generic[PydanticModelT]): def __init__( self, - model: Type[PydanticModelT], - prog: Optional[str] = None, - description: Optional[str] = None, - version: Optional[str] = None, - epilog: Optional[str] = None, + model: type[PydanticModelT], + prog: str | None = None, + description: str | None = None, + version: str | None = None, + epilog: str | None = None, add_help: bool = True, exit_on_error: bool = True, + extra_defaults: dict[type, dict[str, tuple[str, Any]]] | None = None, ) -> None: - """Instantiates the Typed Argument Parser with its `pydantic` model. - - Args: - model (Type[PydanticModelT]): Pydantic argument model class. - prog (Optional[str]): Program name for CLI. - description (Optional[str]): Program description for CLI. - version (Optional[str]): Program version string for CLI. - epilog (Optional[str]): Optional text following help message. - add_help (bool): Whether to add a `-h`/`--help` flag. - exit_on_error (bool): Whether to exit on error. + """Instantiates the typed Argument Parser with its `pydantic` model. + + :param model: Pydantic argument model class. + :param prog: Program name for CLI. + :param description: Program description for CLI. + :param version: Program version string for CLI. + :param epilog: Optional text following help message. + :param add_help: Whether to add a `-h`/`--help` flag. + :param exit_on_error: Whether to exit on error. + :param extra_defaults: Defaults coming from external sources, such as environment variables or config files. """ # Initialise Super Class - if sys.version_info < (3, 9): # pragma: <3.9 cover - super().__init__( - prog=prog, - description=description, - epilog=epilog, - add_help=False, # Always disable the automatic help flag. - argument_default=argparse.SUPPRESS, # Allow `pydantic` to handle defaults. - ) - - else: # pragma: >=3.9 cover - super().__init__( - prog=prog, - description=description, - epilog=epilog, - exit_on_error=exit_on_error, - add_help=False, # Always disable the automatic help flag. - argument_default=argparse.SUPPRESS, # Allow `pydantic` to handle defaults. - ) + super().__init__( + prog=prog, + description=description, + epilog=epilog, + exit_on_error=exit_on_error, + add_help=False, # Always disable the automatic help flag. + argument_default=argparse.SUPPRESS, # Allow `pydantic` to handle defaults. + formatter_class=argparse.RawTextHelpFormatter, + ) # Set Version, Add Help and Exit on Error Flag self.version = version self.add_help = add_help self.exit_on_error = exit_on_error + self.extra_defaults = extra_defaults # Add Arguments Groups self._subcommands: Optional[argparse._SubParsersAction] = None + + # Add Arguments from Model + self._submodels: dict[str, Type[BaseModel]] = dict() + self.model = model + self._add_model(model) + self._help_group = self.add_argument_group(ArgumentParser.HELP) # Add Help and Version Flags @@ -113,52 +112,83 @@ def __init__( if self.version: self._add_version_flag() - # Add Arguments from Model - self._submodels: dict[str, Type[BaseModel]] = dict() - self.model = self._add_model(model) - - @property - def has_submodels(self) -> bool: # noqa: D102 - # this is for simple nested models as arg groups - has_submodels = len(self._submodels) > 0 - - # this is for nested commands - if self._subcommands is not None: - has_submodels = has_submodels or any( - len(subparser._submodels) > 0 - for subparser in self._subcommands.choices.values() - ) - return has_submodels - def parse_typed_args( self, - args: Optional[List[str]] = None, - ) -> PydanticModelT: + args: list[str] | None = None, + ) -> tuple[PydanticModelT, BaseModel]: """Parses command line arguments. If `args` are not supplied by the user, then they are automatically retrieved from the `sys.argv` command-line arguments. - Args: - args (Optional[List[str]]): Optional list of arguments to parse. - - Returns: - PydanticModelT: Populated instance of typed arguments model. - - Raises: - argparse.ArgumentError: Raised upon error, if not exiting on error. - SystemExit: Raised upon error, if exiting on error. + :param args: Optional list of arguments to parse. + :return: A tuple of the whole parsed model, as well as the submodel representing the selected subcommand. """ # Call Super Class Method namespace = self.parse_args(args) + nested_parser = _NestedArgumentParser(model=self.model, namespace=namespace) try: - nested_parser = _NestedArgumentParser(model=self.model, namespace=namespace) - return cast(PydanticModelT, nested_parser.validate()) + return nested_parser.validate() except ValidationError as exc: # Catch exceptions, and use the ArgumentParser.error() method # to report it to the user - self.error(utils.errors.format(exc)) + self._validation_error(exc, nested_parser) + + def _validation_error(self, error: ValidationError, parser: _NestedArgumentParser) -> Never: + self.print_usage(sys.stderr) + + model = parser.model + for scp in parser.subcommand_path: + model = PydanticField(scp, model.model_fields[scp]).model_type + + fields = model.model_fields + msg = "" + + if error.error_count() == 1: + msg += "error: " + else: + msg += f"{error.error_count()} errors: \n" + + for e in error.errors(): + if error.error_count() > 1: + msg += " " + + source = "" + sources = e["loc"][len(parser.subcommand_path) :] + + # If the validation failed for a field validator there is one source level left, + # which equals the name of the field + if len(sources) > 0: + argument = sources[0] + + assert isinstance(argument, str) + + if ( + self.extra_defaults is not None + and model in self.extra_defaults + and argument in self.extra_defaults[model] + and self.extra_defaults[model][argument][1] == e["input"] + ): + source = f"default of {argument} from {self.extra_defaults[model][argument][0]}: " + else: + # Use the same method, that was used for the CLI generation + argument_name = PydanticField(argument, fields[argument]).arg_names() + source = f"argument {', '.join(argument_name)}: " + + try: + error_msg = str(e["ctx"]["error"]) + except KeyError: + error_msg = e["msg"] + + msg += f"{source}{error_msg}\n" + + # Check whether parser should exit + if self.exit_on_error: + self.exit(ArgumentParser.EXIT_ERROR, msg) + + # Raise Error + raise argparse.ArgumentError(None, msg) def error(self, message: str) -> NoReturn: """Prints a usage message to `stderr` and exits if required. @@ -173,12 +203,15 @@ def error(self, message: str) -> NoReturn: # Print usage message self.print_usage(sys.stderr) + msg = f"error: {message}\n" + + # TODO: Investigate why this function is called twice when exit_on_error is respected # Check whether parser should exit - if self.exit_on_error: - self.exit(ArgumentParser.EXIT_ERROR, f"{self.prog}: error: {message}\n") + # if self.exit_on_error: + self.exit(ArgumentParser.EXIT_ERROR, msg) # Raise Error - raise argparse.ArgumentError(None, f"{self.prog}: error: {message}") + # raise argparse.ArgumentError(None, msg) def _commands(self) -> argparse._SubParsersAction: """Creates and Retrieves Subcommands Action for the ArgumentParser. @@ -224,34 +257,28 @@ def _add_version_flag(self) -> None: def _add_model( self, model: Type[BaseModel], - arg_group: Optional[argparse._ArgumentGroup] = None, - ) -> Type[BaseModel]: + arg_group: argparse._ArgumentGroup | None = None, + ) -> None: """Adds the `pydantic` model to the argument parser. - This method also generates "validators" for the arguments derived from - the `pydantic` model, and generates a new subclass from the model - containing these validators. - Args: - model (Type[PydanticModelT]): Pydantic model class to add to the + model (Type[BaseModel]): Pydantic model class to add to the argument parser. arg_group: (Optional[argparse._ArgumentGroup]): argparse ArgumentGroup. This should not normally be passed manually, but only during recursion if the original model is a nested pydantic model. These nested models are then parsed as argument groups. - - Returns: - Type[PydanticModelT]: Pydantic model possibly with new validators. """ # Initialise validators dictionary - validators: Dict[str, utils.pydantic.PydanticValidator] = dict() parser = self if arg_group is None else arg_group + explicit_groups = {} + # Loop through fields in model for field in PydanticField.parse_model(model): if field.is_a(BaseModel): if field.is_subcommand(): - validator = parsers.command.parse_field(self._commands(), field) + parsers.command.parse_field(self._commands(), field, self.extra_defaults) else: # for any nested pydantic models, set default factory to model_construct # method. This allows pydantic to handle if no arguments from a nested @@ -261,24 +288,31 @@ def _add_model( field.info.default_factory = field.model_type.model_construct # create new arg group - group_name = str.upper(field.info.title or field.name) + group_name = field.info.title or field.name arg_group = self.add_argument_group(group_name) # recurse and parse fields below this submodel - # TODO: storage of submodels not needed - self._submodels[field.name] = self._add_model( - model=field.model_type, - arg_group=arg_group, - ) - - validator = None - + self._add_model(model=field.model_type, arg_group=arg_group) else: # Add field - validator = parsers.add_field(parser, field) + added = False + + if ( + self.extra_defaults is not None + and model in self.extra_defaults + and field.name in self.extra_defaults[model] + ): + field.extra_default = self.extra_defaults[model][field.name] + + if isinstance(field.info, ArgFieldInfo) and field.info.hidden: + continue + + if isinstance(field.info, ArgFieldInfo) and field.info.group is not None: + if field.info.group not in explicit_groups: + explicit_groups[field.info.group] = self.add_argument_group(field.info.group) - # Update validators - utils.pydantic.update_validators(validators, validator) + parsers.add_field(explicit_groups[field.info.group], field) + added = True - # Construct and return model with validators - return utils.pydantic.model_with_validators(model, validators) + if not added: + parsers.add_field(parser, field) diff --git a/vendor/pydantic-argparse/pydantic_argparse/argparse/patches.py b/vendor/pydantic-argparse/pydantic_argparse/argparse/patches.py deleted file mode 100644 index 34f35a73a..000000000 --- a/vendor/pydantic-argparse/pydantic_argparse/argparse/patches.py +++ /dev/null @@ -1,50 +0,0 @@ -# SPDX-FileCopyrightText: Hayden Richards -# -# SPDX-License-Identifier: MIT - -"""Monkey patches for ArgumentParser. - -In order to support Python 3.7 and 3.8 while retaining the unit tests, we need -to backport the bugfix for [`BPO-29298`](https://bugs.python.org/issue29298). -""" -import argparse -import sys -from typing import Optional - - -# In Python versions before 3.9, using argparse with required subparsers will -# cause an unhelpful `TypeError` if the 'dest' parameter is not explicitly -# specified, and no arguments are provided. This bug was fixed in 3.11 and -# backported to 3.10 and 3.9. Here, we backport it to 3.7 and 3.8 as well, via -# monkey-patching. -# See: https://github.com/python/cpython/blob/v3.11.1/Lib/argparse.py#L739-L751 -if sys.version_info < (3, 9): # pragma: <3.9 cover - def _get_action_name(argument: Optional[argparse.Action]) -> Optional[str]: # pragma: no cover - """Generates the name for an argument action. - - The behaviour differs depending on what the action contains: - * `option_strings` are concatenated with a slash. - * `metavar` and `dest` are returned verbatim. - * `choices` are joined and represented as a comma-separated set. - - Args: - argument (Optional[argparse.Action]): Argument action. - - Returns: - Optional[str]: Generated action name. - """ - if argument is None: - return None - elif argument.option_strings: - return "/".join(argument.option_strings) - elif argument.metavar not in (None, argparse.SUPPRESS): - return argument.metavar - elif argument.dest not in (None, argparse.SUPPRESS): - return argument.dest - elif argument.choices: - return "{" + ",".join(argument.choices) + "}" - else: - return None - - # Monkey-Patch - argparse._get_action_name = _get_action_name diff --git a/vendor/pydantic-argparse/pydantic_argparse/parsers/__init__.py b/vendor/pydantic-argparse/pydantic_argparse/parsers/__init__.py index ef0ec8aed..2cbaeb606 100644 --- a/vendor/pydantic-argparse/pydantic_argparse/parsers/__init__.py +++ b/vendor/pydantic-argparse/pydantic_argparse/parsers/__init__.py @@ -21,7 +21,6 @@ container, enum, literal, - mapping, standard, ) from .utils import SupportsAddArgument @@ -30,32 +29,23 @@ def add_field( parser: SupportsAddArgument, field: PydanticField, -) -> Optional[PydanticValidator]: +) -> None: """Parses pydantic field type, and then adds it to argument parser. Args: parser (argparse.ArgumentParser | argparse._ArgumentGroup): Sub-parser to add to. field (pydantic.fields.ModelField): Field to be added to parser. - - Returns: - Optional[utils.pydantic.PydanticValidator]: Possible validator method. """ # Switch on Field Type -- for fields that are pydantic models # this gets handled at the top level to distinguish # subcommands from arg groups if boolean.should_parse(field): - return boolean.parse_field(parser, field) - - if container.should_parse(field): - return container.parse_field(parser, field) - - if mapping.should_parse(field): - return mapping.parse_field(parser, field) - - if literal.should_parse(field): - return literal.parse_field(parser, field) - - if enum.should_parse(field): - return enum.parse_field(parser, field) - - return standard.parse_field(parser, field) + boolean.parse_field(parser, field) + elif container.should_parse(field): + container.parse_field(parser, field) + elif literal.should_parse(field): + literal.parse_field(parser, field) + elif enum.should_parse(field): + enum.parse_field(parser, field) + else: + standard.parse_field(parser, field) diff --git a/vendor/pydantic-argparse/pydantic_argparse/parsers/boolean.py b/vendor/pydantic-argparse/pydantic_argparse/parsers/boolean.py index 3336b0a56..632fdeb3d 100644 --- a/vendor/pydantic-argparse/pydantic_argparse/parsers/boolean.py +++ b/vendor/pydantic-argparse/pydantic_argparse/parsers/boolean.py @@ -10,12 +10,10 @@ command-line arguments. """ -import argparse -from typing import Optional +from typing import Any -from pydantic_argparse import utils from pydantic_argparse.argparse import actions -from pydantic_argparse.utils.pydantic import PydanticField, PydanticValidator +from pydantic_argparse.utils.pydantic import PydanticField from .utils import SupportsAddArgument @@ -36,36 +34,21 @@ def should_parse(field: PydanticField) -> bool: def parse_field( parser: SupportsAddArgument, field: PydanticField, -) -> Optional[PydanticValidator]: +) -> None: """Adds boolean pydantic field to argument parser. Args: parser (argparse.ArgumentParser): Argument parser to add to. field (PydanticField): Field to be added to parser. - - Returns: - Optional[PydanticValidator]: Possible validator method. """ - # Compute Argument Intrinsics - is_inverted = not field.info.is_required() and bool(field.info.get_default()) - # Determine Argument Properties - action = ( - actions.BooleanOptionalAction - if field.info.is_required() - else argparse._StoreFalseAction - if is_inverted - else argparse._StoreTrueAction - ) + action = actions.BooleanOptionalAction - # Add Boolean Field - parser.add_argument( - field.argname(is_inverted), - action=action, - help=field.description(), - dest=field.name, - required=field.info.is_required(), - ) + args: dict[str, Any] = {} + args.update(field.arg_required()) + args.update(field.arg_default()) + args.update(field.arg_const()) + args.update(field.arg_dest()) - # Construct and Return Validator - return utils.pydantic.as_validator(field, lambda v: v) + # Add Boolean Field + parser.add_argument(*field.arg_names(), action=action, help=field.description(), **args) diff --git a/vendor/pydantic-argparse/pydantic_argparse/parsers/command.py b/vendor/pydantic-argparse/pydantic_argparse/parsers/command.py index 7dd716499..2f61fe88c 100644 --- a/vendor/pydantic-argparse/pydantic_argparse/parsers/command.py +++ b/vendor/pydantic-argparse/pydantic_argparse/parsers/command.py @@ -11,47 +11,30 @@ """ import argparse -from typing import Optional +from typing import Type, Any from pydantic_argparse.utils.pydantic import ( PydanticField, - PydanticValidator, ) -def should_parse(field: PydanticField) -> bool: - """Checks whether the field should be parsed as a `command`. - - Args: - field (PydanticField): Field to check. - - Returns: - bool: Whether the field should be parsed as a `command`. - """ - # Check and Return - return field.is_subcommand() - - def parse_field( subparser: argparse._SubParsersAction, field: PydanticField, -) -> Optional[PydanticValidator]: + extra_defaults: dict[Type, dict[str, Any]] | None = None, +) -> None: """Adds command pydantic field to argument parser. Args: subparser (argparse._SubParsersAction): Sub-parser to add to. field (PydanticField): Field to be added to parser. - - Returns: - Optional[PydanticValidator]: Possible validator method. + extra_defaults: Defaults coming from external sources, such as environment variables or config files. """ # Add Command subparser.add_parser( field.info.title or field.info.alias or field.name, help=field.info.description, - model=field.model_type, # type: ignore[call-arg] + model=field.model_type, exit_on_error=False, # Allow top level parser to handle exiting + extra_defaults=extra_defaults, ) - - # Return - return None diff --git a/vendor/pydantic-argparse/pydantic_argparse/parsers/container.py b/vendor/pydantic-argparse/pydantic_argparse/parsers/container.py index 0150d7509..4681a272f 100644 --- a/vendor/pydantic-argparse/pydantic_argparse/parsers/container.py +++ b/vendor/pydantic-argparse/pydantic_argparse/parsers/container.py @@ -13,10 +13,9 @@ import argparse import collections.abc import enum -from typing import Optional +from typing import Any -from pydantic_argparse import utils -from pydantic_argparse.utils.pydantic import PydanticField, PydanticValidator +from pydantic_argparse.utils.pydantic import PydanticField from .utils import SupportsAddArgument @@ -31,34 +30,30 @@ def should_parse(field: PydanticField) -> bool: bool: Whether the field should be parsed as a `container`. """ # Check and Return - return field.is_a(collections.abc.Container) and not field.is_a( - (collections.abc.Mapping, enum.Enum, str, bytes) - ) + return field.is_a(collections.abc.Container) and not field.is_a((enum.Enum, str, bytes)) def parse_field( parser: SupportsAddArgument, field: PydanticField, -) -> Optional[PydanticValidator]: +) -> None: """Adds container pydantic field to argument parser. Args: parser (argparse.ArgumentParser): Argument parser to add to. field (PydanticField): Field to be added to parser. - - Returns: - Optional[PydanticValidator]: Possible validator method. """ + args: dict[str, Any] = {} + args.update(field.arg_required()) + args.update(field.arg_default()) + args.update(field.arg_const()) + args.update(field.arg_dest()) + parser.add_argument( - field.argname(), + *field.arg_names(), action=argparse._StoreAction, - nargs=argparse.ONE_OR_MORE, + nargs=argparse.ZERO_OR_MORE, help=field.description(), - dest=field.name, metavar=field.metavar(), - required=field.info.is_required(), + **args, ) - - # Construct and Return Validator - # TODO: this is basically useless? - return utils.pydantic.as_validator(field, lambda v: v) diff --git a/vendor/pydantic-argparse/pydantic_argparse/parsers/enum.py b/vendor/pydantic-argparse/pydantic_argparse/parsers/enum.py index 5a7fc031f..2115b620a 100644 --- a/vendor/pydantic-argparse/pydantic_argparse/parsers/enum.py +++ b/vendor/pydantic-argparse/pydantic_argparse/parsers/enum.py @@ -12,12 +12,12 @@ import argparse import enum -from typing import Optional, Type, cast +from typing import Any -from pydantic_argparse import utils -from pydantic_argparse.utils.pydantic import PydanticField, PydanticValidator +from pydantic_argparse.utils.pydantic import PydanticField from .utils import SupportsAddArgument +from ..utils.field import ArgFieldInfo def should_parse(field: PydanticField) -> bool: @@ -36,44 +36,36 @@ def should_parse(field: PydanticField) -> bool: def parse_field( parser: SupportsAddArgument, field: PydanticField, -) -> Optional[PydanticValidator]: +) -> None: """Adds enum pydantic field to argument parser. Args: parser (argparse.ArgumentParser): Argument parser to add to. field (PydanticField): Field to be added to parser. - - Returns: - Optional[PydanticValidator]: Possible validator method. """ # Extract Enum - enum_type = cast(Type[enum.Enum], field.info.annotation) + types = field.get_type() + assert types is not None - # Compute Argument Intrinsics - is_flag = len(enum_type) == 1 and not field.info.is_required() - is_inverted = is_flag and field.info.get_default() is not None + if isinstance(types, tuple): + enum_type = types[0] + else: + enum_type = types # Determine Argument Properties - metavar = f"{{{', '.join(e.name for e in enum_type)}}}" - action = argparse._StoreConstAction if is_flag else argparse._StoreAction - const = ( - {} - if not is_flag - else {"const": None} - if is_inverted - else {"const": list(enum_type)[0]} - ) + assert enum_type is not None + metavar = enum_type.__name__ + + if isinstance(field.info, ArgFieldInfo) and field.info.metavar is not None: + metavar = field.info.metavar + + action = argparse._StoreAction + + args: dict[str, Any] = {} + args.update(field.arg_required()) + args.update(field.arg_default()) + args.update(field.arg_const()) + args.update(field.arg_dest()) # Add Enum Field - parser.add_argument( - field.argname(is_inverted), - action=action, - help=field.description(), - dest=field.name, - metavar=metavar, - required=field.info.is_required(), - **const, # type: ignore[arg-type] - ) - - # Construct and Return Validator - return utils.pydantic.as_validator(field, lambda v: enum_type[v]) + parser.add_argument(*field.arg_names(), action=action, help=field.description(), metavar=metavar, **args) diff --git a/vendor/pydantic-argparse/pydantic_argparse/parsers/literal.py b/vendor/pydantic-argparse/pydantic_argparse/parsers/literal.py index 18f485db4..e712b46bc 100644 --- a/vendor/pydantic-argparse/pydantic_argparse/parsers/literal.py +++ b/vendor/pydantic-argparse/pydantic_argparse/parsers/literal.py @@ -11,18 +11,13 @@ """ import argparse -import sys -from typing import Optional -from pydantic_argparse import utils -from pydantic_argparse.utils.pydantic import PydanticField, PydanticValidator +from pydantic_argparse.utils.pydantic import PydanticField from .utils import SupportsAddArgument +from ..utils.field import ArgFieldInfo -if sys.version_info < (3, 8): # pragma: <3.8 cover - from typing_extensions import Literal, get_args -else: # pragma: >=3.8 cover - from typing import Literal, get_args +from typing import Literal, get_args, Any def should_parse(field: PydanticField) -> bool: @@ -41,44 +36,28 @@ def should_parse(field: PydanticField) -> bool: def parse_field( parser: SupportsAddArgument, field: PydanticField, -) -> Optional[PydanticValidator]: +) -> None: """Adds enum pydantic field to argument parser. Args: parser (argparse.ArgumentParser): Argument parser to add to. field (PydanticField): Field to be added to parser. - - Returns: - Optional[PydanticValidator]: Possible validator method. """ # Extract Choices choices = get_args(field.info.annotation) - # Compute Argument Intrinsics - is_flag = len(choices) == 1 and not field.info.is_required() - is_inverted = is_flag and field.info.get_default() is not None - - # Determine Argument Properties metavar = f"{{{', '.join(str(c) for c in choices)}}}" - action = argparse._StoreConstAction if is_flag else argparse._StoreAction - const = ( - {} if not is_flag else {"const": None} if is_inverted else {"const": choices[0]} - ) + + if isinstance(field.info, ArgFieldInfo) and field.info.metavar is not None: + metavar = field.info.metavar + + action = argparse._StoreAction + + args: dict[str, Any] = {} + args.update(field.arg_required()) + args.update(field.arg_default()) + args.update(field.arg_const()) + args.update(field.arg_dest()) # Add Literal Field - parser.add_argument( - field.argname(is_inverted), - action=action, - help=field.description(), - dest=field.name, - metavar=metavar, - required=field.info.is_required(), - **const, # type: ignore[arg-type] - ) - - # Construct String Representation Mapping of Choices - # This allows us O(1) parsing of choices from strings - mapping = {str(choice): choice for choice in choices} - - # Construct and Return Validator - return utils.pydantic.as_validator(field, lambda v: mapping[v]) + parser.add_argument(*field.arg_names(), action=action, help=field.description(), metavar=metavar, **args) diff --git a/vendor/pydantic-argparse/pydantic_argparse/parsers/mapping.py b/vendor/pydantic-argparse/pydantic_argparse/parsers/mapping.py deleted file mode 100644 index 11326814d..000000000 --- a/vendor/pydantic-argparse/pydantic_argparse/parsers/mapping.py +++ /dev/null @@ -1,62 +0,0 @@ -# SPDX-FileCopyrightText: Hayden Richards -# -# SPDX-License-Identifier: MIT - -"""Parses Mapping Pydantic Fields to Command-Line Arguments. - -The `mapping` module contains the `should_parse` function, which checks whether -this module should be used to parse the field, as well as the `parse_field` -function, which parses mapping `pydantic` model fields to `ArgumentParser` -command-line arguments. -""" - -import argparse -import ast -import collections.abc -from typing import Optional - -from pydantic_argparse import utils -from pydantic_argparse.utils.pydantic import PydanticField, PydanticValidator - -from .utils import SupportsAddArgument - - -def should_parse(field: PydanticField) -> bool: - """Checks whether the field should be parsed as a `mapping`. - - Args: - field (PydanticField): Field to check. - - Returns: - bool: Whether the field should be parsed as a `mapping`. - """ - # Check and Return - return field.is_a(collections.abc.Mapping) - - -def parse_field( - parser: SupportsAddArgument, - field: PydanticField, -) -> Optional[PydanticValidator]: - """Adds mapping pydantic field to argument parser. - - Args: - parser (argparse.ArgumentParser): Argument parser to add to. - field (PydanticField): Field to be added to parser. - - Returns: - Optional[PydanticValidator]: Possible validator method. - """ - # Add Mapping Field - parser.add_argument( - field.argname(), - action=argparse._StoreAction, - help=field.description(), - dest=field.name, - metavar=field.metavar(), - required=field.info.is_required(), - ) - - # Construct and Return Validator - # TODO: this doesn't seem safe? - return utils.pydantic.as_validator(field, lambda v: ast.literal_eval(v)) diff --git a/vendor/pydantic-argparse/pydantic_argparse/parsers/standard.py b/vendor/pydantic-argparse/pydantic_argparse/parsers/standard.py index 386e7529e..7f9011d1a 100644 --- a/vendor/pydantic-argparse/pydantic_argparse/parsers/standard.py +++ b/vendor/pydantic-argparse/pydantic_argparse/parsers/standard.py @@ -13,10 +13,9 @@ """ import argparse -from typing import Optional +from typing import Any -from pydantic_argparse import utils -from pydantic_argparse.utils.pydantic import PydanticField, PydanticValidator +from pydantic_argparse.utils.pydantic import PydanticField from .utils import SupportsAddArgument @@ -24,25 +23,20 @@ def parse_field( parser: SupportsAddArgument, field: PydanticField, -) -> Optional[PydanticValidator]: +) -> None: """Adds standard pydantic field to argument parser. Args: parser (argparse.ArgumentParser): Argument parser to add to. field (PydanticField): Field to be added to parser. - - Returns: - Optional[PydanticValidator]: Possible validator method. """ + args: dict[str, Any] = {} + args.update(field.arg_required()) + args.update(field.arg_default()) + args.update(field.arg_const()) + args.update(field.arg_dest()) + # Add Standard Field parser.add_argument( - field.argname(), - action=argparse._StoreAction, - help=field.description(), - dest=field.name, - metavar=field.metavar(), - required=field.info.is_required(), + *field.arg_names(), action=argparse._StoreAction, help=field.description(), metavar=field.metavar(), **args ) - - # Construct and Return Validator - return utils.pydantic.as_validator(field, lambda v: v) diff --git a/vendor/pydantic-argparse/pydantic_argparse/parsers/utils.py b/vendor/pydantic-argparse/pydantic_argparse/parsers/utils.py index e0ad2f6af..0a5de700d 100644 --- a/vendor/pydantic-argparse/pydantic_argparse/parsers/utils.py +++ b/vendor/pydantic-argparse/pydantic_argparse/parsers/utils.py @@ -28,6 +28,5 @@ def add_argument( # noqa: D102 metavar: str | tuple[str, ...] | None = ..., dest: str | None = ..., version: str = ..., - **kwargs: Any - ) -> Action: - ... + **kwargs: Any, + ) -> Action: ... diff --git a/vendor/pydantic-argparse/pydantic_argparse/utils/__init__.py b/vendor/pydantic-argparse/pydantic_argparse/utils/__init__.py index b504d88c5..a66511e21 100644 --- a/vendor/pydantic-argparse/pydantic_argparse/utils/__init__.py +++ b/vendor/pydantic-argparse/pydantic_argparse/utils/__init__.py @@ -14,4 +14,4 @@ modules each containing helper functions. """ -from . import errors, namespaces, pydantic, types +from . import namespaces, pydantic, types diff --git a/vendor/pydantic-argparse/pydantic_argparse/utils/errors.py b/vendor/pydantic-argparse/pydantic_argparse/utils/errors.py deleted file mode 100644 index f02f6fe5f..000000000 --- a/vendor/pydantic-argparse/pydantic_argparse/utils/errors.py +++ /dev/null @@ -1,31 +0,0 @@ -# SPDX-FileCopyrightText: Hayden Richards -# -# SPDX-License-Identifier: MIT - -"""Errors Utility Functions for Declarative Typed Argument Parsing. - -The `errors` module contains a utility function used for formatting `pydantic` -Validation Errors to human readable messages. -""" - - -# Third-Party -# Typing - -import pydantic - -# Constants -PydanticError = pydantic.ValidationError - - -def format(error: PydanticError) -> str: # noqa: A001 - """Formats a `pydantic` error into a human readable format. - - Args: - error (PydanticError): `pydantic` error to be formatted. - - Returns: - str: `pydantic` error in a human readable format. - """ - # Format and Return - return str(error) diff --git a/vendor/pydantic-argparse/pydantic_argparse/utils/field.py b/vendor/pydantic-argparse/pydantic_argparse/utils/field.py new file mode 100644 index 000000000..25fa729bf --- /dev/null +++ b/vendor/pydantic-argparse/pydantic_argparse/utils/field.py @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: AISEC Pentesting Team +# +# SPDX-License-Identifier: Apache-2.0 + +from typing import Any, Unpack + +from pydantic.fields import FieldInfo, _FromFieldInfoInputs +from pydantic_core import PydanticUndefined + + +class ArgFieldInfo(FieldInfo): + def __init__( + self, + default: Any, + positional: bool, + short: str | None, + metavar: str | None, + cli_group: str | None, + const: Any, + hidden: bool, + **kwargs: Unpack[_FromFieldInfoInputs], + ): + """Creates a new ArgFieldInfo. + + This is a special variant of pydantic's FieldInfo, which adds several arguments, + mainly related to CLI arguments. + For general usage and details on the generic parameters see https://docs.pydantic.dev/latest/concepts/fields. + Just as with pydantic's FieldInfo, this should usually not be called directly. + Instead use the Field() function of this module. + + :param default: The default value, if non is given explicitly. + :param positional: Specifies, if the argument is shown as positional, as opposed to optional (default), on the CLI. + :param short: An optional alternative name for the CLI, which is auto-prefixed with "-". + :param metavar: The type hint which is shown on the CLI for an argument. If none is specified, it is automatically inferred from its type. + :param cli_group: The group in the CLI under which the argument is listed. + :param const: Specifies, a default value, if the argument is set with no explicit value. + :param hidden: Specifies, that the argument is part of neither the CLI nor the config file. + :param kwargs: Generic pydantic Field() arguments (see https://docs.pydantic.dev/latest/api/fields/#pydantic.fields.FieldInfo). + """ + super().__init__(default=default, **kwargs) + + self.positional = positional + self.short = short + self.metavar = metavar + self.group = cli_group + self.const = const + self.hidden = hidden + + +def Field( + default: Any = PydanticUndefined, + positional: bool = False, + short: str | None = None, + metavar: str | None = None, + group: str | None = None, + const: Any = PydanticUndefined, + hidden: bool = False, + **kwargs: Unpack[_FromFieldInfoInputs], +) -> Any: + """Creates a new ArgFieldInfo. + + This is a special variant of pydantic's Field() function, which adds several arguments, + mainly related to CLI arguments. + For general usage and details on the generic parameters see https://docs.pydantic.dev/latest/concepts/fields. + + :param default: The default value, if non is given explicitly. + :param positional: Specifies, if the argument is shown as positional, as opposed to optional (default), on the CLI. + :param short: An optional alternative name for the CLI, which is auto-prefixed with "-". + :param metavar: The type hint which is shown on the CLI for an argument. If none is specified, it is automatically inferred from its type. + :param cli_group: The group in the CLI under which the argument is listed. + :param const: Specifies, a default value, if the argument is set with no explicit value. + :param hidden: Specifies, that the argument is part of neither the CLI nor the config file. + :param kwargs: Generic pydantic Field() arguments (see https://docs.pydantic.dev/latest/api/fields/#pydantic.fields.Field). + :return: A ConfigArgFieldInfo. + """ + return ArgFieldInfo(default, positional, short, metavar, group, const, hidden, **kwargs) diff --git a/vendor/pydantic-argparse/pydantic_argparse/utils/namespaces.py b/vendor/pydantic-argparse/pydantic_argparse/utils/namespaces.py index 923eae565..e0f720593 100644 --- a/vendor/pydantic-argparse/pydantic_argparse/utils/namespaces.py +++ b/vendor/pydantic-argparse/pydantic_argparse/utils/namespaces.py @@ -22,7 +22,7 @@ def to_dict(namespace: argparse.Namespace) -> Dict[str, Any]: Dict[str, Any]: Nested dictionary generated from namespace. """ # Get Dictionary from Namespace Vars - dictionary = vars(namespace) + dictionary = dict(vars(namespace)) # Loop Through Dictionary for key, value in dictionary.items(): diff --git a/vendor/pydantic-argparse/pydantic_argparse/utils/nesting.py b/vendor/pydantic-argparse/pydantic_argparse/utils/nesting.py index 6b18ea923..f7b2bc0da 100644 --- a/vendor/pydantic-argparse/pydantic_argparse/utils/nesting.py +++ b/vendor/pydantic-argparse/pydantic_argparse/utils/nesting.py @@ -5,7 +5,7 @@ """Utilities to help with parsing arbitrarily nested `pydantic` models.""" from argparse import Namespace -from typing import Any, Dict, Generic, Optional, Tuple, Type +from typing import Any, Generic, Type, TypeAlias from boltons.iterutils import get_path, remap from pydantic import BaseModel @@ -13,73 +13,66 @@ from .namespaces import to_dict from .pydantic import PydanticField, PydanticModelT -ModelT = PydanticModelT | Type[PydanticModelT] | BaseModel | Type[BaseModel] +ModelT: TypeAlias = PydanticModelT | Type[PydanticModelT] | BaseModel | Type[BaseModel] class _NestedArgumentParser(Generic[PydanticModelT]): """Parses arbitrarily nested `pydantic` models and inserts values passed at the command line.""" def __init__( - self, model: PydanticModelT | Type[PydanticModelT], namespace: Namespace + self, + model: PydanticModelT | Type[PydanticModelT], + namespace: Namespace, ) -> None: self.model = model self.args = to_dict(namespace) - self.subcommand = False - self.schema: Dict[str, Any] = self._get_nested_model_fields(self.model) + self.subcommand_path: tuple[str, ...] = tuple() + self.schema: dict[str, Any] = self._get_nested_model_fields(self.model, namespace) self.schema = self._remove_null_leaves(self.schema) - if self.subcommand: - # if there are subcommands, they should only be in the topmost - # level, and the way that the unnesting works is - # that it will populate all subcommands, - # so we need to remove the subcommands that were - # not passed at cli + def _get_nested_model_fields(self, model: ModelT, namespace: Namespace) -> dict[str, Any]: + def contains_subcommand(ns: Namespace, subcommand_path: tuple[str, ...]) -> bool: + for step in subcommand_path: + tmp = getattr(ns, step, None) - # the command should be the very first argument - # after executable/file name - command = list(self.args.keys())[0] - self.schema = self._unset_subcommands(self.schema, command) + if not isinstance(tmp, Namespace): + return False - def _get_nested_model_fields(self, model: ModelT, parent: Optional[Tuple] = None): - model_fields: Dict[str, Any] = dict() + ns = tmp + + return True + + model_fields: dict[str, Any] = dict() for field in PydanticField.parse_model(model): key = field.name if field.is_a(BaseModel): if field.is_subcommand(): - self.subcommand = True + sub_command_path = (*self.subcommand_path, key) + + if not contains_subcommand(namespace, sub_command_path): + continue - new_parent = (*parent, key) if parent is not None else (key,) + self.subcommand_path = sub_command_path # recursively build nestes pydantic models in dict, # which matches the actual schema the nested # schema pydantic will be expecting - model_fields[key] = self._get_nested_model_fields( - field.model_type, new_parent - ) + model_fields[key] = self._get_nested_model_fields(field.model_type, namespace) else: # start with all leaves as None unless key is in top level value = self.args.get(key, None) - if parent is not None: - # however, if travesing nested models, then the parent should - # not be None and then there is potentially a real value to get - # check full path first - # TODO: this may not be needed depending on how nested namespaces work - # since the arg groups are not nested -- just flattened - full_path = (*parent, key) - value = get_path(self.args, full_path, value) - - if value is None: - short_path = (parent[0], key) - value = get_path(self.args, short_path, value) + if len(self.subcommand_path) > 0: + path = (*self.subcommand_path, key) + value = get_path(self.args, path, value) model_fields[key] = value return model_fields - def _remove_null_leaves(self, schema: Dict[str, Any]): + def _remove_null_leaves(self, schema: dict[str, Any]) -> Any: # only remove None leaves # actually CANNOT remove empty containers # this causes problems with nested submodels that don't @@ -89,9 +82,12 @@ def _remove_null_leaves(self, schema: Dict[str, Any]): # the schema return remap(schema, visit=lambda p, k, v: v is not None) - def _unset_subcommands(self, schema: Dict[str, Any], command: str): - return {key: value for key, value in schema.items() if key == command} + def validate(self) -> tuple[PydanticModelT, BaseModel]: + """Return the root of the model, as well as the sub-model for the bottom subcommand""" + model = self.model.model_validate(self.schema) + subcommand = model + + for step in self.subcommand_path: + subcommand = getattr(subcommand, step) - def validate(self): - """Return an instance of the `pydantic` modeled validated with data passed from the command line.""" - return self.model.model_validate(self.schema) + return model, subcommand diff --git a/vendor/pydantic-argparse/pydantic_argparse/utils/pydantic.py b/vendor/pydantic-argparse/pydantic_argparse/utils/pydantic.py index 6194b76b3..35d22c8cf 100644 --- a/vendor/pydantic-argparse/pydantic_argparse/utils/pydantic.py +++ b/vendor/pydantic-argparse/pydantic_argparse/utils/pydantic.py @@ -10,28 +10,28 @@ dynamically generated validators and environment variable parsers. """ -from collections.abc import Container, Mapping +from collections.abc import Container +from dataclasses import dataclass from enum import Enum +from types import UnionType from typing import ( Any, - Callable, - Dict, Iterator, Literal, - NamedTuple, - Optional, - Tuple, Type, TypeVar, Union, cast, get_args, get_origin, + Annotated, ) -import pydantic from pydantic import BaseModel from pydantic.fields import FieldInfo +from pydantic_core import PydanticUndefined + +from pydantic_argparse.utils.field import ArgFieldInfo from .types import all_types @@ -42,7 +42,8 @@ NoneType = type(None) -class PydanticField(NamedTuple): +@dataclass +class PydanticField: """Simple Pydantic v2.0 field wrapper. Pydantic fields no longer store their name, so this named tuple @@ -53,11 +54,10 @@ class PydanticField(NamedTuple): name: str info: FieldInfo + extra_default: tuple[str, Any] | None = None @classmethod - def parse_model( - cls, model: BaseModel | Type[BaseModel] - ) -> Iterator["PydanticField"]: + def parse_model(cls, model: BaseModel | Type[BaseModel]) -> Iterator["PydanticField"]: """Iterator over the pydantic model fields, yielding this wrapper class. Yields: @@ -66,54 +66,47 @@ def parse_model( for name, info in model.model_fields.items(): yield cls(name, info) - @property - def outer_type(self) -> Optional[Type]: - """Returns the outer type for nested types using `typing.get_origin`. + def _get_type(self, annotation: type | None) -> type | tuple[type | None, ...] | None: + origin = get_origin(annotation) - This will return `None` for simple, unnested types. - """ - return get_origin(self.info.annotation) + if origin is Literal or isinstance(origin, type) and issubclass(origin, Container): + return origin + elif origin is Union or origin is UnionType: + args = get_args(annotation) + types = list(arg for arg in args if arg is not NoneType) + elif origin is None: + types = [annotation] + else: + raise AssertionError(f"Unsupported origin {origin} for field {self.name} with annotation {annotation}") - @property - def inner_type(self) -> Tuple[Type, ...]: - """Returns the inner type for nested types using `typing.get_args`. + base_types: list[Type | None] = [] - This will be an empty tuple for simple, unnested types. - """ - return get_args(self.info.annotation) + for t in types: + origin = get_origin(t) - @property - def main_type(self) -> Tuple[Type, ...]: - """Return the main inner types. + if origin is Annotated: + sub_types = self._get_type(get_args(t)[0]) - This excludes the `NoneType` when dealing with `typing.Optional` types. - """ - return tuple(t for t in self.inner_type if t is not NoneType) + if isinstance(sub_types, tuple): + base_types += sub_types + else: + base_types.append(sub_types) + elif origin is not None: + base_types.append(origin) + else: + base_types.append(t) - def get_type(self) -> Union[Type, Tuple[Type, ...], None]: - """Return the type annotation for the `pydantic` field. + return tuple(base_types) - Returns: - Union[Type, Tuple[Type, ...], None] + def get_type(self) -> type | tuple[type | None, ...] | None: + """Return the mainly interesting types according to the type annotation (in the context of argument parsing). + + Returns: One or more types (potentially None). """ - field_type = self.info.annotation - main_type = self.main_type - outer_type = self.outer_type - outer_type_is_type = isinstance(outer_type, type) - if outer_type and ( - isinstance(outer_type, (Container, Mapping)) - or (outer_type_is_type and issubclass(outer_type, (Container, Mapping))) - or outer_type is Literal - ): - # only return if outer_type is a concrete type like list, dict, etc OR typing.Literal - # NOT if outer_type is typing.Union, etc - return outer_type - if main_type and all_types(main_type): - # the all type check is specifically for typing.Literal - return main_type - return field_type - - def is_a(self, types: Union[Any, Tuple[Any, ...]]) -> bool: + annotation = self.info.annotation + return self._get_type(annotation) + + def is_a(self, types: Any | tuple[Any, ...]) -> bool: """Checks whether the subject *is* any of the supplied types. The checks are performed as follows: @@ -150,10 +143,7 @@ def is_a(self, types: Union[Any, Tuple[Any, ...]]) -> bool: is_type = False for t in field_type: is_type = ( - is_type - or t in types - or (is_valid and isinstance(t, types)) - or (is_valid and issubclass(t, types)) # type: ignore + is_type or t in types or (is_valid and isinstance(t, types)) or (is_valid and issubclass(t, types)) # type: ignore ) return is_type @@ -176,9 +166,7 @@ def model_type(self) -> Type[BaseModel]: if isinstance(t, type) and issubclass(t, BaseModel): return t else: - raise TypeError( - "No `pydantic.BaseModel`s were found associated with this field." - ) + raise TypeError("No `pydantic.BaseModel`s were found associated with this field.") def is_subcommand(self) -> bool: """Check whether the input pydantic Model is a subcommand. @@ -210,23 +198,29 @@ def is_subcommand(self) -> bool: # - field is not a pydantic BaseModel or it can't be found return default - def argname(self, invert: bool = False) -> str: + def arg_names(self, invert: bool = False) -> tuple[str, str] | tuple[str]: """Standardises argument name when printing to command line. + This also includes potential short names if specified. + Args: invert (bool): Whether to invert the name by prepending `--no-`. Returns: - str: Standardised name of the argument. Checks `pydantic.Field` title first, - but defaults to the field name. + str: Standardised name of the argument. """ - # TODO: this should return a tuple to allow short name args - # Construct Prefix - prefix = "--no-" if invert else "--" name = self.info.title or self.name - # Prepend prefix, replace '_' with '-' - return f"{prefix}{name.replace('_', '-')}" + if isinstance(self.info, ArgFieldInfo) and self.info.positional: + return (name,) + + prefix = "--no-" if invert else "--" + long_name = f"{prefix}{name.replace('_', '-')}" + + if isinstance(self.info, ArgFieldInfo) and self.info.short is not None: + return f"-{self.info.short}", long_name + + return (long_name,) def description(self) -> str: """Standardises argument description. @@ -235,151 +229,73 @@ def description(self) -> str: str: Standardised description of the argument. """ # Construct Default String - if self.info.is_required(): - default = None - required = "REQUIRED:" - else: + default = "" + + if not self.info.is_required(): _default = self.info.get_default() if isinstance(_default, Enum): _default = _default.name - default = f"(default: {_default})" - required = None + default = f"default: {_default}" + + if self.extra_default is not None: + if len(default) > 0: + default += "; " + default += f"{self.extra_default[0]}: {self.extra_default[1]}" + + if len(default) > 0: + default = f" ({default})" # Return Standardised Description String - return " ".join(filter(None, [required, self.info.description, default])) + description = self.info.description if self.info.description is not None else "" + return f"{description}{default}" - def metavar(self) -> Optional[str]: + def metavar(self) -> str | None: """Generate the metavar name for the field. Returns: - Optional[str]: Field metavar if the `Field.info.alias` exists. + Optional[str]: Field metavar if of type ArgField and has metavar set. Otherwise, return constituent type names. """ - # check alias first - if self.info.alias is not None: - return self.info.alias.upper() + # check metavar first + if isinstance(self.info, ArgFieldInfo): + if self.info.metavar is not None: + return self.info.metavar + + if self.info.positional: + return self.arg_names()[0].upper() # otherwise default to the type field_type = self.get_type() - if field_type: + if field_type is not None: if isinstance(field_type, tuple): - return "|".join(t.__name__.upper() for t in field_type) + return "|".join(t.__name__.upper() for t in field_type if t is not None) return field_type.__name__.upper() - - -def as_validator( - field: PydanticField, - caster: Callable[[str], Any], -) -> PydanticValidator: - """Shortcut to wrap a caster and construct a validator for a given field. - - The provided caster function must cast from a string to the type required - by the field. Once wrapped, the constructed validator will pass through any - non-string values, or any values that cause the caster function to raise an - exception to let the built-in `pydantic` field validation handle them. The - validator will also cast empty strings to `None`. - - Args: - name (str): field name - field (pydantic.fields.FieldInfo): Field to construct validator for. - caster (Callable[[str], Any]): String to field type caster function. - - Returns: - PydanticValidator: Constructed field validator function. - """ - - # Dynamically construct a `pydantic` validator function for the supplied - # field. The constructed validator must be `pre=True` so that the validator - # is called before the built-in `pydantic` field validation occurs and is - # provided with the raw input data. The constructed validator must also be - # `allow_reuse=True` so the `__validator` function name can be reused - # multiple times when being decorated as a `pydantic` validator. Note that - # despite the `__validator` function *name* being reused, each instance of - # the validator function is uniquely constructed for the supplied field. - @pydantic.validator(field.name, pre=True, allow_reuse=True) - def __validator(cls: Type[Any], value: T) -> Optional[Union[T, Any]]: - if not isinstance(value, str): - return value - if not value: + else: return None - try: - return caster(value) - except Exception: - return value - - # Rename the validator uniquely for this field to avoid any collisions. The - # leading `__` and prefix of `pydantic_argparse` should guard against any - # potential collisions with user defined validators. - __validator.__name__ = f"__pydantic_argparse_{field.name}" # type: ignore - - # Return the constructed validator - return __validator # type: ignore - -def update_validators( - validators: Dict[str, PydanticValidator], - validator: Optional[PydanticValidator], -) -> None: - """Updates a validators dictionary with a possible new field validator. - - Note that this function mutates the validators dictionary *in-place*, and - does not return the dictionary. - - Args: - validators (Dict[str, PydanticValidator]): Validators to update. - validator (Optional[PydanticValidator]): Possible field validator. - """ - # Check for Validator - if validator: - # Add Validator - validators[validator.__name__] = validator - - -def model_with_validators( - model: Type[BaseModel], - validators: Dict[str, PydanticValidator], -) -> Type[BaseModel]: - """Generates a new `pydantic` model class with the supplied validators. - - If the supplied base model is a subclass of `pydantic.BaseSettings`, then - the newly generated model will also have a new `parse_env_var` classmethod - monkeypatched onto it that suppresses any exceptions raised when initially - parsing the environment variables. This allows the raw values to still be - passed through to the `pydantic` field validators if initial parsing fails. - - Args: - model (Type[BaseModel]): Model type to use as base class. - validators (Dict[str, PydanticValidator]): Field validators to add. - - Returns: - Type[BaseModel]: New `pydantic` model type with field validators. - """ - # Construct New Model with Validators - model = pydantic.create_model( - model.__name__, - __base__=model, - __validators__=validators, - ) - - # Check if the model is a `BaseSettings` - # if issubclass(model, pydantic.BaseSettings): - # # Hold a reference to the current `parse_env_var` classmethod - # parse_env_var = model.__config__.parse_env_var - - # # Construct a new `parse_env_var` function which suppresses exceptions - # # raised by the current `parse_env_var` classmethod. This allows the - # # raw values to be passed through to the `pydantic` field validator - # # methods if they cannot be parsed initially. - # def __parse_env_var(field_name: str, raw_val: str) -> Any: - # with contextlib.suppress(Exception): - # return parse_env_var(field_name, raw_val) - # return raw_val - - # # Monkeypatch `parse_env_var` - # model.__config__.parse_env_var = __parse_env_var # type: ignore[assignment] - - # Return Constructed Model - return model + def arg_required(self) -> dict[str, bool]: + return ( + {} + if isinstance(self.info, ArgFieldInfo) and self.info.positional + else {"required": self.info.is_required() and self.extra_default is None} + ) + + def arg_default(self) -> dict[str, Any]: + return ( + {} + if self.extra_default is None or isinstance(self.info, ArgFieldInfo) and self.info.positional + else {"default": self.extra_default[1]} + ) + + def arg_const(self) -> dict[str, Any]: + return ( + {"const": self.info.const, "nargs": "?"} + if isinstance(self.info, ArgFieldInfo) and self.info.const is not PydanticUndefined + else {} + ) + + def arg_dest(self) -> dict[str, str]: + return {} if isinstance(self.info, ArgFieldInfo) and self.info.positional else {"dest": self.name} def is_subcommand(model: BaseModel | Type[BaseModel]) -> bool: diff --git a/vendor/pydantic-argparse/pydantic_argparse/utils/types.py b/vendor/pydantic-argparse/pydantic_argparse/utils/types.py index 3a10bc697..d056d70ef 100644 --- a/vendor/pydantic-argparse/pydantic_argparse/utils/types.py +++ b/vendor/pydantic-argparse/pydantic_argparse/utils/types.py @@ -8,7 +8,6 @@ comparing the types of `pydantic fields. """ - import sys from typing import Iterable diff --git a/vendor/pydantic-argparse/tests/__init__.py b/vendor/pydantic-argparse/tests/__init__.py deleted file mode 100644 index 2388f1a62..000000000 --- a/vendor/pydantic-argparse/tests/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# SPDX-FileCopyrightText: Hayden Richards -# -# SPDX-License-Identifier: MIT - -"""Unit Tests for the `pydantic-argparse` Package. - -This file is required to mark the unit tests as a package, so they can resolve -and import the actual top level package code -""" diff --git a/vendor/pydantic-argparse/tests/argparse/__init__.py b/vendor/pydantic-argparse/tests/argparse/__init__.py deleted file mode 100644 index 3e2eb26e9..000000000 --- a/vendor/pydantic-argparse/tests/argparse/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# SPDX-FileCopyrightText: Hayden Richards -# -# SPDX-License-Identifier: MIT - -"""Unit Tests for the `argparse` Package. - -This file is required to mark the unit tests as a package, so they can resolve -and import the actual top level package code -""" diff --git a/vendor/pydantic-argparse/tests/argparse/test_actions.py b/vendor/pydantic-argparse/tests/argparse/test_actions.py deleted file mode 100644 index 2dfb861bb..000000000 --- a/vendor/pydantic-argparse/tests/argparse/test_actions.py +++ /dev/null @@ -1,105 +0,0 @@ -# SPDX-FileCopyrightText: Hayden Richards -# -# SPDX-License-Identifier: MIT - -"""Tests the `actions` Module. - -This module provides full unit test coverage for the `actions` module, testing -all branches of all methods. These unit tests target the `SubParsersAction` -class by testing the expected nested namespace functionality. -""" - - -# Standard -import argparse - -# Third-Party -import pytest - -# Local -from pydantic_argparse.argparse import actions -from tests import conftest as conf - - -def test_invalid_command() -> None: - """Tests SubParsersAction with invalid command.""" - # Construct Subparser - subparser = conf.create_test_subparser() - - # Assert Raises - with pytest.raises(argparse.ArgumentError): - # Test Invalid Command - subparser( - parser=argparse.ArgumentParser(), - namespace=argparse.Namespace(), - values=["fake", "--not-real"], - ) - - -def test_valid_command() -> None: - """Tests SubParsersAction with valid command.""" - # Construct Subparser - subparser = conf.create_test_subparser() - - # Add Test Argument - subparser.add_parser("test") - - # Create Namespace - namespace = argparse.Namespace() - - # Test Valid Command - subparser( - parser=argparse.ArgumentParser(), - namespace=namespace, - values=["test"], - ) - - # Assert - assert getattr(namespace, "test") == argparse.Namespace() # noqa: B009 - - -def test_unrecognised_args() -> None: - """Tests SubParsersAction with unrecognised args.""" - # Construct Subparser - subparser = conf.create_test_subparser() - - # Add Test Argument - subparser.add_parser("test") - - # Create Namespace - namespace = argparse.Namespace() - - # Test Unrecognised Args - subparser( - parser=argparse.ArgumentParser(), - namespace=namespace, - values=["test", "--flag"], - ) - - # Assert - assert getattr(namespace, "test") == argparse.Namespace() # noqa: B009 - assert getattr(namespace, argparse._UNRECOGNIZED_ARGS_ATTR) == ["--flag"] - - -def test_deep_unrecognised_args() -> None: - """Tests SubParsersAction with deeply nested unrecognised args.""" - # Construct Subparser - subparser = conf.create_test_subparser() - - # Add Test Argument - deep: argparse.ArgumentParser = subparser.add_parser("test") - deep.add_subparsers(action=actions.SubParsersAction).add_parser("deep") - - # Create Namespace - namespace = argparse.Namespace() - - # Test Deeply Nested Unrecognised Args - subparser( - parser=argparse.ArgumentParser(), - namespace=namespace, - values=["test", "--a", "deep", "--b"], - ) - - # Assert - assert getattr(namespace, "test") == argparse.Namespace(deep=argparse.Namespace()) # noqa: B009 - assert getattr(namespace, argparse._UNRECOGNIZED_ARGS_ATTR) == ["--a", "--b"] diff --git a/vendor/pydantic-argparse/tests/argparse/test_parser.py b/vendor/pydantic-argparse/tests/argparse/test_parser.py deleted file mode 100644 index 384e5d659..000000000 --- a/vendor/pydantic-argparse/tests/argparse/test_parser.py +++ /dev/null @@ -1,554 +0,0 @@ -# SPDX-FileCopyrightText: Hayden Richards -# -# SPDX-License-Identifier: MIT - -"""Tests the `parser` Module. - -This module provides full unit test coverage for the `parser` module, testing -all branches of all methods. These unit tests target the typed `ArgumentParser` -class by testing a large number of expected use-cases. -""" - - -# Standard -import argparse -import collections as coll -import datetime as dt -import re -import sys -import textwrap - -# Third-Party -import pydantic -import pytest - -# Local -import pydantic_argparse -import tests.conftest as conf - -# Typing -from typing import Deque, Dict, FrozenSet, List, Optional, Set, Tuple, Type, TypeVar - -# Version-Guarded -if sys.version_info < (3, 8): # pragma: <3.8 cover - from typing_extensions import Literal -else: # pragma: >=3.8 cover - from typing import Literal - - -# Constants -ArgumentT = TypeVar("ArgumentT") - - -@pytest.mark.parametrize("prog", ["AA", None]) -@pytest.mark.parametrize("description", ["BB", None]) -@pytest.mark.parametrize("version", ["CC", None]) -@pytest.mark.parametrize("epilog", ["DD", None]) -@pytest.mark.parametrize("add_help", [True, False]) -@pytest.mark.parametrize("exit_on_error", [True, False]) -def test_create_argparser( - prog: Optional[str], - description: Optional[str], - version: Optional[str], - epilog: Optional[str], - add_help: bool, - exit_on_error: bool, -) -> None: - """Tests Constructing the ArgumentParser. - - Args: - prog (Optional[str]): Program name for testing. - description (Optional[str]): Program description for testing. - version (Optional[str]): Program version for testing. - epilog (Optional[str]): Program epilog for testing. - add_help (bool): Whether to add help flag for testing. - exit_on_error (bool): Whether to exit on error for testing. - """ - # Create ArgumentParser - parser = pydantic_argparse.ArgumentParser( - model=conf.TestModel, - prog=prog, - description=description, - version=version, - epilog=epilog, - add_help=add_help, - exit_on_error=exit_on_error, - ) - - # Asserts - assert isinstance(parser, pydantic_argparse.ArgumentParser) - - -@pytest.mark.parametrize( - ( - "argument_type", - "argument_default", - "arguments", - "result", - ), - [ - # Required Arguments - (int, ..., "--test 123", 123), - (float, ..., "--test 4.56", 4.56), - (str, ..., "--test hello", "hello"), - (bytes, ..., "--test bytes", b"bytes"), - (List[str], ..., "--test a b c", list(("a", "b", "c"))), - (Tuple[str, str, str], ..., "--test a b c", tuple(("a", "b", "c"))), - (Set[str], ..., "--test a b c", set(("a", "b", "c"))), - (FrozenSet[str], ..., "--test a b c", frozenset(("a", "b", "c"))), - (Deque[str], ..., "--test a b c", coll.deque(("a", "b", "c"))), - (Dict[str, int], ..., "--test {'a':2}", dict(a=2)), - (dt.date, ..., "--test 2021-12-25", dt.date(2021, 12, 25)), - (dt.datetime, ..., "--test 2021-12-25T12:34", dt.datetime(2021, 12, 25, 12, 34)), - (dt.time, ..., "--test 12:34", dt.time(12, 34)), - (dt.timedelta, ..., "--test PT12H", dt.timedelta(hours=12)), - (bool, ..., "--test", True), - (bool, ..., "--no-test", False), - (Literal["A"], ..., "--test A", "A"), - (Literal["A", 1], ..., "--test 1", 1), - (conf.TestEnumSingle, ..., "--test D", conf.TestEnumSingle.D), - (conf.TestEnum, ..., "--test C", conf.TestEnum.C), - - # Optional Arguments (With Default) - (int, 456, "--test 123", 123), - (float, 1.23, "--test 4.56", 4.56), - (str, "world", "--test hello", "hello"), - (bytes, b"bits", "--test bytes", b"bytes"), - (List[str], list(("d", "e", "f")), "--test a b c", list(("a", "b", "c"))), - (Tuple[str, str, str], tuple(("d", "e", "f")), "--test a b c", tuple(("a", "b", "c"))), - (Set[str], set(("d", "e", "f")), "--test a b c", set(("a", "b", "c"))), - (FrozenSet[str], frozenset(("d", "e", "f")), "--test a b c", frozenset(("a", "b", "c"))), - (Deque[str], coll.deque(("d", "e", "f")), "--test a b c", coll.deque(("a", "b", "c"))), - (Dict[str, int], dict(b=3), "--test {'a':2}", dict(a=2)), - (dt.date, dt.date(2021, 7, 21), "--test 2021-12-25", dt.date(2021, 12, 25)), - (dt.datetime, dt.datetime(2021, 7, 21, 3), "--test 2021-04-03T02:00", dt.datetime(2021, 4, 3, 2)), - (dt.time, dt.time(3, 21), "--test 12:34", dt.time(12, 34)), - (dt.timedelta, dt.timedelta(hours=6), "--test PT12H", dt.timedelta(hours=12)), - (bool, False, "--test", True), - (bool, True, "--no-test", False), - (Literal["A"], "A", "--test", "A"), - (Literal["A", 1], "A", "--test 1", 1), - (conf.TestEnumSingle, conf.TestEnumSingle.D, "--test", conf.TestEnumSingle.D), - (conf.TestEnum, conf.TestEnum.B, "--test C", conf.TestEnum.C), - - # Optional Arguments (With Default) (No Value Given) - (int, 456, "", 456), - (float, 1.23, "", 1.23), - (str, "world", "", "world"), - (bytes, b"bits", "", b"bits"), - (List[str], list(("d", "e", "f")), "", list(("d", "e", "f"))), - (Tuple[str, str, str], tuple(("d", "e", "f")), "", tuple(("d", "e", "f"))), - (Set[str], set(("d", "e", "f")), "", set(("d", "e", "f"))), - (FrozenSet[str], frozenset(("d", "e", "f")), "", frozenset(("d", "e", "f"))), - (Deque[str], coll.deque(("d", "e", "f")), "", coll.deque(("d", "e", "f"))), - (Dict[str, int], dict(b=3), "", dict(b=3)), - (dt.date, dt.date(2021, 7, 21), "", dt.date(2021, 7, 21)), - (dt.datetime, dt.datetime(2021, 7, 21, 3, 7), "", dt.datetime(2021, 7, 21, 3, 7)), - (dt.time, dt.time(3, 21), "", dt.time(3, 21)), - (dt.timedelta, dt.timedelta(hours=6), "", dt.timedelta(hours=6)), - (bool, False, "", False), - (bool, True, "", True), - (Literal["A"], "A", "", "A"), - (Literal["A", 1], "A", "", "A"), - (conf.TestEnumSingle, conf.TestEnumSingle.D, "", conf.TestEnumSingle.D), - (conf.TestEnum, conf.TestEnum.B, "", conf.TestEnum.B), - - # Optional Arguments (No Default) - (Optional[int], None, "--test 123", 123), - (Optional[float], None, "--test 4.56", 4.56), - (Optional[str], None, "--test hello", "hello"), - (Optional[bytes], None, "--test bytes", b"bytes"), - (Optional[List[str]], None, "--test a b c", list(("a", "b", "c"))), - (Optional[Tuple[str, str, str]], None, "--test a b c", tuple(("a", "b", "c"))), - (Optional[Set[str]], None, "--test a b c", set(("a", "b", "c"))), - (Optional[FrozenSet[str]], None, "--test a b c", frozenset(("a", "b", "c"))), - (Optional[Deque[str]], None, "--test a b c", coll.deque(("a", "b", "c"))), - (Optional[Dict[str, int]], None, "--test {'a':2}", dict(a=2)), - (Optional[dt.date], None, "--test 2021-12-25", dt.date(2021, 12, 25)), - (Optional[dt.datetime], None, "--test 2021-12-25T12:34", dt.datetime(2021, 12, 25, 12, 34)), - (Optional[dt.time], None, "--test 12:34", dt.time(12, 34)), - (Optional[dt.timedelta], None, "--test PT12H", dt.timedelta(hours=12)), - (Optional[bool], None, "--test", True), - (Optional[Literal["A"]], None, "--test", "A"), - (Optional[Literal["A", 1]], None, "--test 1", 1), - (Optional[conf.TestEnumSingle], None, "--test", conf.TestEnumSingle.D), - (Optional[conf.TestEnum], None, "--test C", conf.TestEnum.C), - - # Optional Arguments (No Default) (No Value Given) - (Optional[int], None, "", None), - (Optional[float], None, "", None), - (Optional[str], None, "", None), - (Optional[bytes], None, "", None), - (Optional[List[str]], None, "", None), - (Optional[Tuple[str, str, str]], None, "", None), - (Optional[Set[str]], None, "", None), - (Optional[FrozenSet[str]], None, "", None), - (Optional[Deque[str]], None, "", None), - (Optional[Dict[str, int]], None, "", None), - (Optional[dt.date], None, "", None), - (Optional[dt.datetime], None, "", None), - (Optional[dt.time], None, "", None), - (Optional[dt.timedelta], None, "", None), - (Optional[bool], None, "", None), - (Optional[Literal["A"]], None, "", None), - (Optional[Literal["A", 1]], None, "", None), - (Optional[conf.TestEnumSingle], None, "", None), - (Optional[conf.TestEnum], None, "", None), - - # Special Enums and Literals Optional Flag Behaviour - (Optional[Literal["A"]], "A", "--no-test", None), - (Optional[Literal["A"]], "A", "", "A"), - (Optional[conf.TestEnumSingle], conf.TestEnumSingle.D, "--no-test", None), - (Optional[conf.TestEnumSingle], conf.TestEnumSingle.D, "", conf.TestEnumSingle.D), - - # Commands - (conf.TestCommand, ..., "test", conf.TestCommand()), - (conf.TestCommands, ..., "test cmd_01", conf.TestCommands(cmd_01=conf.TestCommand())), - (conf.TestCommands, ..., "test cmd_02", conf.TestCommands(cmd_02=conf.TestCommand())), - (conf.TestCommands, ..., "test cmd_03", conf.TestCommands(cmd_03=conf.TestCommand())), - (conf.TestCommands, ..., "test cmd_01 --flag", conf.TestCommands(cmd_01=conf.TestCommand(flag=True))), - (conf.TestCommands, ..., "test cmd_02 --flag", conf.TestCommands(cmd_02=conf.TestCommand(flag=True))), - (conf.TestCommands, ..., "test cmd_03 --flag", conf.TestCommands(cmd_03=conf.TestCommand(flag=True))), - (Optional[conf.TestCommand], ..., "test", conf.TestCommand()), - (Optional[conf.TestCommands], ..., "test cmd_01", conf.TestCommands(cmd_01=conf.TestCommand())), - (Optional[conf.TestCommands], ..., "test cmd_02", conf.TestCommands(cmd_02=conf.TestCommand())), - (Optional[conf.TestCommands], ..., "test cmd_03", conf.TestCommands(cmd_03=conf.TestCommand())), - (Optional[conf.TestCommands], ..., "test cmd_01 --flag", conf.TestCommands(cmd_01=conf.TestCommand(flag=True))), - (Optional[conf.TestCommands], ..., "test cmd_02 --flag", conf.TestCommands(cmd_02=conf.TestCommand(flag=True))), - (Optional[conf.TestCommands], ..., "test cmd_03 --flag", conf.TestCommands(cmd_03=conf.TestCommand(flag=True))), - ], -) -def test_valid_arguments( - argument_type: Type[ArgumentT], - argument_default: ArgumentT, - arguments: str, - result: ArgumentT, -) -> None: - """Tests ArgumentParser Valid Arguments. - - Args: - argument_type (Type[ArgumentT]): Type of the argument. - argument_default (ArgumentT): Default for the argument. - arguments (str): An example string of arguments for testing. - result (ArgumentT): Result from parsing the argument. - """ - # Construct Pydantic Model - model = conf.create_test_model(test=(argument_type, argument_default)) - - # Create ArgumentParser - parser = pydantic_argparse.ArgumentParser(model) - - # Parse - args = parser.parse_typed_args(arguments.split()) - - # Asserts - assert isinstance(args.test, type(result)) - assert args.test == result - - -@pytest.mark.parametrize( - ( - "argument_type", - "argument_default", - "arguments", - ), - [ - # Invalid Arguments - (int, ..., "--test invalid"), - (float, ..., "--test invalid"), - (List[int], ..., "--test invalid"), - (Tuple[int, int, int], ..., "--test invalid"), - (Set[int], ..., "--test invalid"), - (FrozenSet[int], ..., "--test invalid"), - (Deque[int], ..., "--test invalid"), - (Dict[str, int], ..., "--test invalid"), - (dt.date, ..., "--test invalid"), - (dt.datetime, ..., "--test invalid"), - (dt.time, ..., "--test invalid"), - (dt.timedelta, ..., "--test invalid"), - (bool, ..., "--test invalid"), - (Literal["A"], ..., "--test invalid"), - (Literal["A", 1], ..., "--test invalid"), - (conf.TestEnumSingle, ..., "--test invalid"), - (conf.TestEnum, ..., "--test invalid"), - - # Missing Argument Values - (int, ..., "--test"), - (float, ..., "--test"), - (str, ..., "--test"), - (bytes, ..., "--test"), - (List[int], ..., "--test"), - (Tuple[int, int, int], ..., "--test"), - (Set[int], ..., "--test"), - (FrozenSet[int], ..., "--test"), - (Deque[int], ..., "--test"), - (Dict[str, int], ..., "--test"), - (dt.date, ..., "--test"), - (dt.datetime, ..., "--test"), - (dt.time, ..., "--test"), - (dt.timedelta, ..., "--test"), - (Literal["A"], ..., "--test"), - (Literal["A", 1], ..., "--test"), - (conf.TestEnumSingle, ..., "--test"), - (conf.TestEnum, ..., "--test"), - - # Missing Arguments - (int, ..., ""), - (float, ..., ""), - (str, ..., ""), - (bytes, ..., ""), - (List[int], ..., ""), - (Tuple[int, int, int], ..., ""), - (Set[int], ..., ""), - (FrozenSet[int], ..., ""), - (Deque[int], ..., ""), - (Dict[str, int], ..., ""), - (dt.date, ..., ""), - (dt.datetime, ..., ""), - (dt.time, ..., ""), - (dt.timedelta, ..., ""), - (bool, ..., ""), - (Literal["A"], ..., ""), - (Literal["A", 1], ..., ""), - (conf.TestEnumSingle, ..., ""), - (conf.TestEnum, ..., ""), - - # Invalid Optional Arguments - (Optional[int], None, "--test invalid"), - (Optional[float], None, "--test invalid"), - (Optional[List[int]], None, "--test invalid"), - (Optional[Tuple[int, int, int]], None, "--test invalid"), - (Optional[Set[int]], None, "--test invalid"), - (Optional[FrozenSet[int]], None, "--test invalid"), - (Optional[Deque[int]], None, "--test invalid"), - (Optional[Dict[str, int]], None, "--test invalid"), - (Optional[dt.date], None, "--test invalid"), - (Optional[dt.datetime], None, "--test invalid"), - (Optional[dt.time], None, "--test invalid"), - (Optional[dt.timedelta], None, "--test invalid"), - (Optional[bool], None, "--test invalid"), - (Optional[Literal["A"]], None, "--test invalid"), - (Optional[Literal["A", 1]], None, "--test invalid"), - (Optional[conf.TestEnumSingle], None, "--test invalid"), - (Optional[conf.TestEnum], None, "--test invalid"), - - # Missing Optional Argument Values - (Optional[int], None, "--test"), - (Optional[float], None, "--test"), - (Optional[str], None, "--test"), - (Optional[bytes], None, "--test"), - (Optional[List[int]], None, "--test"), - (Optional[Tuple[int, int, int]], None, "--test"), - (Optional[Set[int]], None, "--test"), - (Optional[FrozenSet[int]], None, "--test"), - (Optional[Deque[int]], None, "--test"), - (Optional[Dict[str, int]], None, "--test"), - (Optional[dt.date], None, "--test"), - (Optional[dt.datetime], None, "--test"), - (Optional[dt.time], None, "--test"), - (Optional[dt.timedelta], None, "--test"), - (Optional[Literal["A", 1]], None, "--test"), - (Optional[conf.TestEnum], None, "--test"), - - # Commands - (conf.TestCommand, ..., ""), - (conf.TestCommand, ..., "invalid"), - (conf.TestCommands, ..., "test"), - (conf.TestCommands, ..., "test invalid"), - (Optional[conf.TestCommand], ..., ""), - (Optional[conf.TestCommand], ..., "invalid"), - (Optional[conf.TestCommands], ..., "test"), - (Optional[conf.TestCommands], ..., "test invalid"), - ], -) -@pytest.mark.parametrize( - ( - "exit_on_error", - "error" - ), - [ - (True, SystemExit), - (False, argparse.ArgumentError), - ], -) -def test_invalid_arguments( - argument_type: Type[ArgumentT], - argument_default: ArgumentT, - arguments: str, - exit_on_error: bool, - error: Type[Exception], -) -> None: - """Tests ArgumentParser Invalid Arguments. - - Args: - argument_type (Type[ArgumentT]): Type of the argument. - argument_default (ArgumentT): Default for the argument. - arguments (str): An example string of arguments for testing. - exit_on_error (bool): Whether to raise or exit on error. - error (Type[Exception]): Exception that should be raised for testing. - """ - # Construct Pydantic Model - model = conf.create_test_model(test=(argument_type, argument_default)) - - # Create ArgumentParser - parser = pydantic_argparse.ArgumentParser(model, exit_on_error=exit_on_error) - - # Assert Parser Raises Error - with pytest.raises(error): - # Parse - parser.parse_typed_args(arguments.split()) - - -def test_help_message(capsys: pytest.CaptureFixture[str]) -> None: - """Tests ArgumentParser Help Message. - - Args: - capsys (pytest.CaptureFixture[str]): Fixture to capture STDOUT/STDERR. - """ - # Construct Pydantic Model - model = conf.create_test_model() - - # Create ArgumentParser - parser = pydantic_argparse.ArgumentParser( - model=model, - prog="AA", - description="BB", - version="CC", - epilog="DD", - ) - - # Assert Parser Exits - with pytest.raises(SystemExit): - # Ask for Help - parser.parse_typed_args(["--help"]) - - # Check STDOUT - captured = capsys.readouterr() - assert captured.out == textwrap.dedent( - """ - usage: AA [-h] [-v] - - BB - - help: - -h, --help show this help message and exit - -v, --version show program's version number and exit - - DD - """ - ).lstrip() - - -def test_version_message(capsys: pytest.CaptureFixture[str]) -> None: - """Tests ArgumentParser Version Message. - - Args: - capsys (pytest.CaptureFixture[str]): Fixture to capture STDOUT/STDERR. - """ - # Construct Pydantic Model - model = conf.create_test_model() - - # Create ArgumentParser - parser = pydantic_argparse.ArgumentParser( - model=model, - prog="AA", - description="BB", - version="CC", - epilog="DD", - ) - - # Assert Parser Exits - with pytest.raises(SystemExit): - # Ask for Version - parser.parse_typed_args(["--version"]) - - # Check STDOUT - captured = capsys.readouterr() - assert captured.out == textwrap.dedent( - """ - CC - """ - ).lstrip() - - -@pytest.mark.parametrize( - ( - "argument_name", - "argument_field", - ), - conf.TestModel.__fields__.items() -) -def test_argument_descriptions( - argument_name: str, - argument_field: pydantic.fields.ModelField, - capsys: pytest.CaptureFixture[str], -) -> None: - """Tests Argument Descriptions. - - Args: - argument_name (str): Argument name. - argument_field (pydantic.fields.ModelField): Argument pydantic field. - capsys (pytest.CaptureFixture[str]): Fixture to capture STDOUT/STDERR. - """ - # Create ArgumentParser - parser = pydantic_argparse.ArgumentParser(conf.TestModel) - - # Assert Parser Exits - with pytest.raises(SystemExit): - # Ask for Help - parser.parse_typed_args(["--help"]) - - # Capture STDOUT - captured = capsys.readouterr() - - # Process STDOUT - # Capture all arguments below 'commands:' - # Capture all arguments below 'required arguments:' - # Capture all arguments below 'optional arguments:' - _, commands, required, optional, _ = re.split(r".+:\n", captured.out) - - # Check if Command, Required or Optional - if isinstance(argument_field.outer_type_, pydantic.main.ModelMetaclass): - # Assert Argument Name in Commands Section - assert argument_name in commands - assert argument_name not in required - assert argument_name not in optional - - # Assert Argument Description in Commands Section - assert argument_field.field_info.description in commands - assert argument_field.field_info.description not in required - assert argument_field.field_info.description not in optional - - elif argument_field.required: - # Format Argument Name - argument_name = argument_name.replace("_", "-") - - # Assert Argument Name in Required Args Section - assert argument_name in required - assert argument_name not in commands - assert argument_name not in optional - - # Assert Argument Description in Required Args Section - assert argument_field.field_info.description in required - assert argument_field.field_info.description not in commands - assert argument_field.field_info.description not in optional - - else: - # Format Argument Name and Default - argument_name = argument_name.replace("_", "-") - default = f"(default: {argument_field.get_default()})" - - # Assert Argument Name in Optional Args Section - assert argument_name in optional - assert argument_name not in commands - assert argument_name not in required - - # Assert Argument Description in Optional Args Section - assert argument_field.field_info.description in optional - assert argument_field.field_info.description not in commands - assert argument_field.field_info.description not in required - - # Assert Argument Default in Optional Args Section - assert default in optional - assert default not in commands - assert default not in required diff --git a/vendor/pydantic-argparse/tests/conftest.py b/vendor/pydantic-argparse/tests/conftest.py deleted file mode 100644 index 6d6047eac..000000000 --- a/vendor/pydantic-argparse/tests/conftest.py +++ /dev/null @@ -1,212 +0,0 @@ -# SPDX-FileCopyrightText: Hayden Richards -# -# SPDX-License-Identifier: MIT - -"""Configures Testing and Defines Pytest Fixtures. - -The `conftest.py` file serves as a means of providing fixtures for an entire -directory. Fixtures defined in a `conftest.py` can be used by any test in the -package without needing to import them. -""" - - -# Standard -import argparse -import collections -import datetime -import enum -import sys - -# Third-Party -import pydantic - -# Local -from pydantic_argparse.argparse import actions - -# Typing -from typing import Any, Deque, Dict, FrozenSet, List, Optional, Set, Tuple, Type - -# Version-Guarded -if sys.version_info < (3, 8): # pragma: <3.8 cover - from typing_extensions import Literal -else: # pragma: >=3.8 cover - from typing import Literal - - -def create_test_model( - name: str = "test", - base: Type[pydantic.BaseModel] = pydantic.BaseSettings, - **fields: Tuple[Type[Any], Any], -) -> Any: - """Constructs a `pydantic` model with sensible defaults for testing. - - This function returns `Any` instead of `Type[pydantic.BaseModel]` because - we cannot accurately type the dynamically constructed fields on the - resultant model. As such, it is more convenient to work with the `Any` type - in the unit tests. - - Args: - name (str): Name of the model. - base (Type[pydantic.BaseModel]): Base class for the model. - fields (Tuple[Type[Any], Any]): Model fields as `name=(type, default)`. - - Returns: - Any: Dynamically constructed `pydantic` model. - """ - # Construct Pydantic Model - return pydantic.create_model( - name, - __base__=base, - **fields, # type: ignore[call-overload] - ) - - -def create_test_field( - name: str = "test", - type: Type[Any] = str, # noqa: A002 - default: Any = ..., - description: Optional[str] = None, -) -> pydantic.fields.ModelField: - """Constructs a `pydantic` field with sensible defaults for testing. - - Args: - name (str): Name of the field. - type (Type[Any]): Type of the field. - default (Any): Default value for the field. - description (Optional[str]): Description for the field. - - Returns: - pydantic.fields.ModelField: Dynamically constructed `pydantic` model. - """ - # Construct Pydantic Field - return pydantic.fields.ModelField.infer( - name=name, - value=pydantic.Field(default, description=description), # type: ignore[arg-type] - annotation=type, - class_validators=None, - config=pydantic.BaseConfig, - ) - - -def create_test_subparser( - name: str = "test", - parser_class: Type[argparse.ArgumentParser] = argparse.ArgumentParser, -) -> actions.SubParsersAction: - """Constructs a `SubParsersAction` with sensible defaults for testing. - - Args: - name (str): Name of the action. - parser_class (Type[argparse.ArgumentParser]): Parser for the action. - - Returns: - actions.SubParsersAction: Dynamically constructed `SubParsersAction`. - """ - # Construct SubParsersAction - return actions.SubParsersAction( - option_strings=[], # Always empty for the `SubParsersAction` - prog=name, - parser_class=parser_class, - ) - - -class TestEnum(enum.Enum): - """Test Enum for Testing.""" - A = enum.auto() - B = enum.auto() - C = enum.auto() - - -class TestEnumSingle(enum.Enum): - """Test Enum with Single Member for Testing.""" - D = enum.auto() - - -class TestCommand(pydantic.BaseModel): - """Test Command Model for Testing.""" - flag: bool = pydantic.Field(False, description="flag") - - -class TestCommands(pydantic.BaseModel): - """Test Commands Model for Testing.""" - cmd_01: Optional[TestCommand] = pydantic.Field(None, description="cmd_01") - cmd_02: Optional[TestCommand] = pydantic.Field(None, description="cmd_02") - cmd_03: Optional[TestCommand] = pydantic.Field(None, description="cmd_03") - - -class TestModel(pydantic.BaseModel): - """Test Model for Testing.""" - # Required Arguments - arg_01: int = pydantic.Field(description="arg_01") - arg_02: float = pydantic.Field(description="arg_02") - arg_03: str = pydantic.Field(description="arg_03") - arg_04: bytes = pydantic.Field(description="arg_04") - arg_05: List[str] = pydantic.Field(description="arg_05") - arg_06: Tuple[str, str, str] = pydantic.Field(description="arg_06") - arg_07: Set[str] = pydantic.Field(description="arg_07") - arg_08: FrozenSet[str] = pydantic.Field(description="arg_08") - arg_09: Deque[str] = pydantic.Field(description="arg_09") - arg_10: Dict[str, int] = pydantic.Field(description="arg_10") - arg_11: datetime.date = pydantic.Field(description="arg_11") - arg_12: datetime.datetime = pydantic.Field(description="arg_12") - arg_13: datetime.time = pydantic.Field(description="arg_13") - arg_14: datetime.timedelta = pydantic.Field(description="arg_14") - arg_15: bool = pydantic.Field(description="arg_15") - arg_16: Literal["A"] = pydantic.Field(description="arg_16") - arg_17: Literal["A", 1] = pydantic.Field(description="arg_17") - arg_18: TestEnumSingle = pydantic.Field(description="arg_18") - arg_19: TestEnum = pydantic.Field(description="arg_19") - - # Optional Arguments (With Default) - arg_20: int = pydantic.Field(12345, description="arg_20") - arg_21: float = pydantic.Field(6.789, description="arg_21") - arg_22: str = pydantic.Field("ABC", description="arg_22") - arg_23: bytes = pydantic.Field(b"ABC", description="arg_23") - arg_24: List[str] = pydantic.Field(list(("A", "B", "C")), description="arg_24") - arg_25: Tuple[str, str, str] = pydantic.Field(("A", "B", "C"), description="arg_25") - arg_26: Set[str] = pydantic.Field(set(("A", "B", "C")), description="arg_26") - arg_27: FrozenSet[str] = pydantic.Field(frozenset(("A", "B", "C")), description="arg_27") - arg_28: Deque[str] = pydantic.Field(collections.deque(("A", "B", "C")), description="arg_28") - arg_29: Dict[str, int] = pydantic.Field(dict(A=123), description="arg_29") - arg_30: datetime.date = pydantic.Field(datetime.date(2021, 12, 25), description="arg_30") - arg_31: datetime.datetime = pydantic.Field(datetime.datetime(2021, 12, 25, 7), description="arg_31") - arg_32: datetime.time = pydantic.Field(datetime.time(7, 30), description="arg_32") - arg_33: datetime.timedelta = pydantic.Field(datetime.timedelta(hours=5), description="arg_33") - arg_34: bool = pydantic.Field(False, description="arg_34") - arg_35: bool = pydantic.Field(True, description="arg_35") - arg_36: Literal["A"] = pydantic.Field("A", description="arg_36") - arg_37: Literal["A", 1] = pydantic.Field("A", description="arg_37") - arg_38: TestEnumSingle = pydantic.Field(TestEnumSingle.D, description="arg_38") - arg_39: TestEnum = pydantic.Field(TestEnum.A, description="arg_39") - - # Optional Arguments (No Default) - arg_40: Optional[int] = pydantic.Field(description="arg_40") - arg_41: Optional[float] = pydantic.Field(description="arg_41") - arg_42: Optional[str] = pydantic.Field(description="arg_42") - arg_43: Optional[bytes] = pydantic.Field(description="arg_43") - arg_44: Optional[List[str]] = pydantic.Field(description="arg_44") - arg_45: Optional[Tuple[str, str, str]] = pydantic.Field(description="arg_45") - arg_46: Optional[Set[str]] = pydantic.Field(description="arg_46") - arg_47: Optional[FrozenSet[str]] = pydantic.Field(description="arg_47") - arg_48: Optional[Deque[str]] = pydantic.Field(description="arg_48") - arg_49: Optional[Dict[str, int]] = pydantic.Field(description="arg_49") - arg_50: Optional[datetime.date] = pydantic.Field(description="arg_50") - arg_51: Optional[datetime.datetime] = pydantic.Field(description="arg_51") - arg_52: Optional[datetime.time] = pydantic.Field(description="arg_52") - arg_53: Optional[datetime.timedelta] = pydantic.Field(description="arg_53") - arg_54: Optional[bool] = pydantic.Field(description="arg_54") - arg_55: Optional[Literal["A"]] = pydantic.Field(description="arg_55") - arg_56: Optional[Literal["A", 1]] = pydantic.Field(description="arg_56") - arg_57: Optional[TestEnumSingle] = pydantic.Field(description="arg_57") - arg_58: Optional[TestEnum] = pydantic.Field(description="arg_58") - - # Special Enums and Literals Optional Flag Behaviour - arg_59: Optional[Literal["A"]] = pydantic.Field(description="arg_59") - arg_60: Optional[Literal["A"]] = pydantic.Field("A", description="arg_60") - arg_61: Optional[TestEnumSingle] = pydantic.Field(description="arg_61") - arg_62: Optional[TestEnumSingle] = pydantic.Field(TestEnumSingle.D, description="arg_62") - - # Commands - arg_63: Optional[TestCommand] = pydantic.Field(description="arg_63") - arg_64: TestCommand = pydantic.Field(description="arg_64") - arg_65: Optional[TestCommands] = pydantic.Field(description="arg_65") - arg_66: TestCommands = pydantic.Field(description="arg_66") diff --git a/vendor/pydantic-argparse/tests/functional/__init__.py b/vendor/pydantic-argparse/tests/functional/__init__.py deleted file mode 100644 index 1ec8b07e0..000000000 --- a/vendor/pydantic-argparse/tests/functional/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# SPDX-FileCopyrightText: Hayden Richards -# -# SPDX-License-Identifier: MIT - -"""Functional Tests for the `pydantic-argparse` Package. - -This file is required to mark the unit tests as a package, so they can resolve -and import the actual top level package code -""" diff --git a/vendor/pydantic-argparse/tests/functional/test_environment_variables.py b/vendor/pydantic-argparse/tests/functional/test_environment_variables.py deleted file mode 100644 index 100e7e8b3..000000000 --- a/vendor/pydantic-argparse/tests/functional/test_environment_variables.py +++ /dev/null @@ -1,344 +0,0 @@ -# SPDX-FileCopyrightText: Hayden Richards -# -# SPDX-License-Identifier: MIT - -"""Tests the `pydantic-argparse` Environment Variables Functionality. - -This module provides functional regression tests for the `pydantic-argparse` -environment variable parsing capabilities. -""" - - -# Standard -import argparse -import collections as coll -import datetime as dt -import os -import sys - -# Third-Party -import pytest -import pytest_mock - -# Local -import pydantic_argparse -import tests.conftest as conf - -# Typing -from typing import Deque, Dict, FrozenSet, List, Optional, Set, Tuple, Type, TypeVar - -# Version-Guarded -if sys.version_info < (3, 8): # pragma: <3.8 cover - from typing_extensions import Literal -else: # pragma: >=3.8 cover - from typing import Literal - - -# Constants -ArgumentT = TypeVar("ArgumentT") - - -@pytest.mark.parametrize( - ( - "argument_type", - "argument_default", - "env", - "result", - ), - [ - # Required Arguments - (int, ..., "TEST=123", 123), - (float, ..., "TEST=4.56", 4.56), - (str, ..., "TEST=hello", "hello"), - (bytes, ..., "TEST=bytes", b"bytes"), - (List[str], ..., 'TEST=["a","b","c"]', list(("a", "b", "c"))), - (Tuple[str, str, str], ..., 'TEST=["a","b","c"]', tuple(("a", "b", "c"))), - (Set[str], ..., 'TEST=["a","b","c"]', set(("a", "b", "c"))), - (FrozenSet[str], ..., 'TEST=["a","b","c"]', frozenset(("a", "b", "c"))), - (Deque[str], ..., 'TEST=["a","b","c"]', coll.deque(("a", "b", "c"))), - (Dict[str, int], ..., 'TEST={"a":2}', dict(a=2)), - (dt.date, ..., "TEST=2021-12-25", dt.date(2021, 12, 25)), - (dt.datetime, ..., "TEST=2021-12-25T12:34", dt.datetime(2021, 12, 25, 12, 34)), - (dt.time, ..., "TEST=12:34", dt.time(12, 34)), - (dt.timedelta, ..., "TEST=PT12H", dt.timedelta(hours=12)), - (bool, ..., "TEST=true", True), - (bool, ..., "TEST=false", False), - (Literal["A"], ..., "TEST=A", "A"), - (Literal["A", 1], ..., "TEST=1", 1), - (conf.TestEnumSingle, ..., "TEST=D", conf.TestEnumSingle.D), - (conf.TestEnum, ..., "TEST=C", conf.TestEnum.C), - - # Optional Arguments (With Default) - (int, 456, "TEST=123", 123), - (float, 1.23, "TEST=4.56", 4.56), - (str, "world", "TEST=hello", "hello"), - (bytes, b"bits", "TEST=bytes", b"bytes"), - (List[str], list(("d", "e", "f")), 'TEST=["a","b","c"]', list(("a", "b", "c"))), - (Tuple[str, str, str], tuple(("d", "e", "f")), 'TEST=["a","b","c"]', tuple(("a", "b", "c"))), - (Set[str], set(("d", "e", "f")), 'TEST=["a","b","c"]', set(("a", "b", "c"))), - (FrozenSet[str], frozenset(("d", "e", "f")), 'TEST=["a","b","c"]', frozenset(("a", "b", "c"))), - (Deque[str], coll.deque(("d", "e", "f")), 'TEST=["a","b","c"]', coll.deque(("a", "b", "c"))), - (Dict[str, int], dict(b=3), 'TEST={"a":2}', dict(a=2)), - (dt.date, dt.date(2021, 7, 21), "TEST=2021-12-25", dt.date(2021, 12, 25)), - (dt.datetime, dt.datetime(2021, 7, 21, 3), "TEST=2021-04-03T02:00", dt.datetime(2021, 4, 3, 2)), - (dt.time, dt.time(3, 21), "TEST=12:34", dt.time(12, 34)), - (dt.timedelta, dt.timedelta(hours=6), "TEST=PT12H", dt.timedelta(hours=12)), - (bool, False, "TEST=true", True), - (bool, True, "TEST=false", False), - (Literal["A"], "A", "TEST=A", "A"), - (Literal["A", 1], "A", "TEST=1", 1), - (conf.TestEnumSingle, conf.TestEnumSingle.D, "TEST=D", conf.TestEnumSingle.D), - (conf.TestEnum, conf.TestEnum.B, "TEST=C", conf.TestEnum.C), - - # Optional Arguments (With Default) (No Value Given) - (int, 456, "", 456), - (float, 1.23, "", 1.23), - (str, "world", "", "world"), - (bytes, b"bits", "", b"bits"), - (List[str], list(("d", "e", "f")), "", list(("d", "e", "f"))), - (Tuple[str, str, str], tuple(("d", "e", "f")), "", tuple(("d", "e", "f"))), - (Set[str], set(("d", "e", "f")), "", set(("d", "e", "f"))), - (FrozenSet[str], frozenset(("d", "e", "f")), "", frozenset(("d", "e", "f"))), - (Deque[str], coll.deque(("d", "e", "f")), "", coll.deque(("d", "e", "f"))), - (Dict[str, int], dict(b=3), "", dict(b=3)), - (dt.date, dt.date(2021, 7, 21), "", dt.date(2021, 7, 21)), - (dt.datetime, dt.datetime(2021, 7, 21, 3, 7), "", dt.datetime(2021, 7, 21, 3, 7)), - (dt.time, dt.time(3, 21), "", dt.time(3, 21)), - (dt.timedelta, dt.timedelta(hours=6), "", dt.timedelta(hours=6)), - (bool, False, "", False), - (bool, True, "", True), - (Literal["A"], "A", "", "A"), - (Literal["A", 1], "A", "", "A"), - (conf.TestEnumSingle, conf.TestEnumSingle.D, "", conf.TestEnumSingle.D), - (conf.TestEnum, conf.TestEnum.B, "", conf.TestEnum.B), - - # Optional Arguments (No Default) - (Optional[int], None, "TEST=123", 123), - (Optional[float], None, "TEST=4.56", 4.56), - (Optional[str], None, "TEST=hello", "hello"), - (Optional[bytes], None, "TEST=bytes", b"bytes"), - (Optional[List[str]], None, 'TEST=["a","b","c"]', list(("a", "b", "c"))), - (Optional[Tuple[str, str, str]], None, 'TEST=["a","b","c"]', tuple(("a", "b", "c"))), - (Optional[Set[str]], None, 'TEST=["a","b","c"]', set(("a", "b", "c"))), - (Optional[FrozenSet[str]], None, 'TEST=["a","b","c"]', frozenset(("a", "b", "c"))), - (Optional[Deque[str]], None, 'TEST=["a","b","c"]', coll.deque(("a", "b", "c"))), - (Optional[Dict[str, int]], None, 'TEST={"a":2}', dict(a=2)), - (Optional[dt.date], None, "TEST=2021-12-25", dt.date(2021, 12, 25)), - (Optional[dt.datetime], None, "TEST=2021-12-25T12:34", dt.datetime(2021, 12, 25, 12, 34)), - (Optional[dt.time], None, "TEST=12:34", dt.time(12, 34)), - (Optional[dt.timedelta], None, "TEST=PT12H", dt.timedelta(hours=12)), - (Optional[bool], None, "TEST=true", True), - (Optional[Literal["A"]], None, "TEST=A", "A"), - (Optional[Literal["A", 1]], None, "TEST=1", 1), - (Optional[conf.TestEnumSingle], None, "TEST=D", conf.TestEnumSingle.D), - (Optional[conf.TestEnum], None, "TEST=C", conf.TestEnum.C), - - # Optional Arguments (No Default) (No Value Given) - (Optional[int], None, "", None), - (Optional[float], None, "", None), - (Optional[str], None, "", None), - (Optional[bytes], None, "", None), - (Optional[List[str]], None, "", None), - (Optional[Tuple[str, str, str]], None, "", None), - (Optional[Set[str]], None, "", None), - (Optional[FrozenSet[str]], None, "", None), - (Optional[Deque[str]], None, "", None), - (Optional[Dict[str, int]], None, "", None), - (Optional[dt.date], None, "", None), - (Optional[dt.datetime], None, "", None), - (Optional[dt.time], None, "", None), - (Optional[dt.timedelta], None, "", None), - (Optional[bool], None, "", None), - (Optional[Literal["A"]], None, "", None), - (Optional[Literal["A", 1]], None, "", None), - (Optional[conf.TestEnumSingle], None, "", None), - (Optional[conf.TestEnum], None, "", None), - - # Special Enums and Literals Optional Flag Behaviour - (Optional[Literal["A"]], "A", "TEST=", None), - (Optional[Literal["A"]], "A", "", "A"), - (Optional[conf.TestEnumSingle], conf.TestEnumSingle.D, "TEST=", None), - (Optional[conf.TestEnumSingle], conf.TestEnumSingle.D, "", conf.TestEnumSingle.D), - - # Missing Optional Argument Values - (Optional[int], None, "TEST=", None), - (Optional[float], None, "TEST=", None), - (Optional[str], None, "TEST=", None), - (Optional[bytes], None, "TEST=", None), - (Optional[List[int]], None, "TEST=", None), - (Optional[Tuple[int, int, int]], None, "TEST=", None), - (Optional[Set[int]], None, "TEST=", None), - (Optional[FrozenSet[int]], None, "TEST=", None), - (Optional[Deque[int]], None, "TEST=", None), - (Optional[Dict[str, int]], None, "TEST=", None), - (Optional[dt.date], None, "TEST=", None), - (Optional[dt.datetime], None, "TEST=", None), - (Optional[dt.time], None, "TEST=", None), - (Optional[dt.timedelta], None, "TEST=", None), - ], -) -def test_valid_environment_variables( - argument_type: Type[ArgumentT], - argument_default: ArgumentT, - env: str, - result: ArgumentT, - mocker: pytest_mock.MockerFixture, -) -> None: - """Tests ArgumentParser Valid Arguments as Environment Variables. - - Args: - argument_type (Type[ArgumentT]): Type of the argument. - argument_default (ArgumentT): Default for the argument. - env (str): An example string of environment variables for testing. - result (ArgumentT): Result from parsing the argument. - mocker (pytest_mock.MockerFixture): PyTest Mocker Fixture. - """ - # Construct Pydantic Model - model = conf.create_test_model(test=(argument_type, argument_default)) - - # Create ArgumentParser - parser = pydantic_argparse.ArgumentParser(model) - - # Construct Environment Variables - environment_variables: Dict[str, str] = dict([env.split("=")]) if env else {} - - # Mock Environment Variables - mocker.patch.dict(os.environ, environment_variables, clear=True) - - # Parse - args = parser.parse_typed_args([]) # Empty Arguments - - # Asserts - assert isinstance(args.test, type(result)) - assert args.test == result - - -@pytest.mark.parametrize( - ( - "argument_type", - "argument_default", - "env", - ), - [ - # Invalid Arguments - (int, ..., "TEST=invalid"), - (float, ..., "TEST=invalid"), - (List[int], ..., "TEST=invalid"), - (Tuple[int, int, int], ..., "TEST=invalid"), - (Set[int], ..., "TEST=invalid"), - (FrozenSet[int], ..., "TEST=invalid"), - (Deque[int], ..., "TEST=invalid"), - (Dict[str, int], ..., "TEST=invalid"), - (dt.date, ..., "TEST=invalid"), - (dt.datetime, ..., "TEST=invalid"), - (dt.time, ..., "TEST=invalid"), - (dt.timedelta, ..., "TEST=invalid"), - (bool, ..., "TEST=invalid"), - (Literal["A"], ..., "TEST=invalid"), - (Literal["A", 1], ..., "TEST=invalid"), - (conf.TestEnumSingle, ..., "TEST=invalid"), - (conf.TestEnum, ..., "TEST=invalid"), - - # Missing Argument Values - (int, ..., "TEST="), - (float, ..., "TEST="), - (List[int], ..., "TEST="), - (Tuple[int, int, int], ..., "TEST="), - (Set[int], ..., "TEST="), - (FrozenSet[int], ..., "TEST="), - (Deque[int], ..., "TEST="), - (Dict[str, int], ..., "TEST="), - (dt.date, ..., "TEST="), - (dt.datetime, ..., "TEST="), - (dt.time, ..., "TEST="), - (dt.timedelta, ..., "TEST="), - (Literal["A"], ..., "TEST="), - (Literal["A", 1], ..., "TEST="), - (conf.TestEnumSingle, ..., "TEST="), - (conf.TestEnum, ..., "TEST="), - - # Missing Arguments - (int, ..., ""), - (float, ..., ""), - (str, ..., ""), - (bytes, ..., ""), - (List[int], ..., ""), - (Tuple[int, int, int], ..., ""), - (Set[int], ..., ""), - (FrozenSet[int], ..., ""), - (Deque[int], ..., ""), - (Dict[str, int], ..., ""), - (dt.date, ..., ""), - (dt.datetime, ..., ""), - (dt.time, ..., ""), - (dt.timedelta, ..., ""), - (bool, ..., ""), - (Literal["A"], ..., ""), - (Literal["A", 1], ..., ""), - (conf.TestEnumSingle, ..., ""), - (conf.TestEnum, ..., ""), - - # Invalid Optional Arguments - (Optional[int], None, "TEST=invalid"), - (Optional[float], None, "TEST=invalid"), - (Optional[List[int]], None, "TEST=invalid"), - (Optional[Tuple[int, int, int]], None, "TEST=invalid"), - (Optional[Set[int]], None, "TEST=invalid"), - (Optional[FrozenSet[int]], None, "TEST=invalid"), - (Optional[Deque[int]], None, "TEST=invalid"), - (Optional[Dict[str, int]], None, "TEST=invalid"), - (Optional[dt.date], None, "TEST=invalid"), - (Optional[dt.datetime], None, "TEST=invalid"), - (Optional[dt.time], None, "TEST=invalid"), - (Optional[dt.timedelta], None, "TEST=invalid"), - (Optional[bool], None, "TEST=invalid"), - (Optional[Literal["A"]], None, "TEST=invalid"), - (Optional[Literal["A", 1]], None, "TEST=invalid"), - (Optional[conf.TestEnumSingle], None, "TEST=invalid"), - (Optional[conf.TestEnum], None, "TEST=invalid"), - ], -) -@pytest.mark.parametrize( - ( - "exit_on_error", - "error" - ), - [ - (True, SystemExit), - (False, argparse.ArgumentError), - ], -) -def test_invalid_environment_variables( - argument_type: Type[ArgumentT], - argument_default: ArgumentT, - env: str, - exit_on_error: bool, - error: Type[Exception], - mocker: pytest_mock.MockerFixture, -) -> None: - """Tests ArgumentParser Invalid Arguments as Environment Variables. - - Args: - argument_type (Type[ArgumentT]): Type of the argument. - argument_default (ArgumentT): Default for the argument. - env (str): An example string of environment variables for testing. - exit_on_error (bool): Whether to raise or exit on error. - error (Type[Exception]): Exception that should be raised for testing. - mocker (pytest_mock.MockerFixture): PyTest Mocker Fixture. - """ - # Construct Pydantic Model - model = conf.create_test_model(test=(argument_type, argument_default)) - - # Create ArgumentParser - parser = pydantic_argparse.ArgumentParser(model, exit_on_error=exit_on_error) - - # Construct Environment Variables - environment_variables: Dict[str, str] = dict([env.split("=")]) if env else {} - - # Mock Environment Variables - mocker.patch.dict(os.environ, environment_variables, clear=True) - - # Assert Parser Raises Error - with pytest.raises(error): - # Parse - parser.parse_typed_args([]) # Empty Arguments diff --git a/vendor/pydantic-argparse/tests/parsers/__init__.py b/vendor/pydantic-argparse/tests/parsers/__init__.py deleted file mode 100644 index 3d25ea943..000000000 --- a/vendor/pydantic-argparse/tests/parsers/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# SPDX-FileCopyrightText: Hayden Richards -# -# SPDX-License-Identifier: MIT - -"""Unit Tests for the `parsers` Package. - -This file is required to mark the unit tests as a package, so they can resolve -and import the actual top level package code -""" diff --git a/vendor/pydantic-argparse/tests/utils/__init__.py b/vendor/pydantic-argparse/tests/utils/__init__.py deleted file mode 100644 index 933098852..000000000 --- a/vendor/pydantic-argparse/tests/utils/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# SPDX-FileCopyrightText: Hayden Richards -# -# SPDX-License-Identifier: MIT - -"""Unit Tests for the `utils` Package. - -This file is required to mark the unit tests as a package, so they can resolve -and import the actual top level package code -""" diff --git a/vendor/pydantic-argparse/tests/utils/test_arguments.py b/vendor/pydantic-argparse/tests/utils/test_arguments.py deleted file mode 100644 index 260defd08..000000000 --- a/vendor/pydantic-argparse/tests/utils/test_arguments.py +++ /dev/null @@ -1,94 +0,0 @@ -# SPDX-FileCopyrightText: Hayden Richards -# -# SPDX-License-Identifier: MIT - -"""Tests the `arguments` Module. - -This module provides full unit test coverage for the `arguments` module, -testing all branches of all functions. -""" - - -# Third-Party -import pytest - -# Local -from pydantic_argparse import utils -from tests import conftest as conf - -# Typing -from typing import Any, Optional - - -@pytest.mark.parametrize( - ( - "name", - "invert", - "expected", - ), - [ - ("test", False, "--test"), - ("test", True, "--no-test"), - ("test_two", False, "--test-two"), - ("test_two", True, "--no-test-two"), - ], -) -def test_argument_name( - name: str, - invert: bool, - expected: str, -) -> None: - """Tests `utils.arguments.name` Function. - - Args: - name (str): Argument name to test. - invert (bool): Whether to invert the name. - expected (str): Expected result of the test. - """ - # Construct Pydantic Field - field = conf.create_test_field(name) - - # Generate Argument Name - result = utils.arguments.name(field, invert) - - # Assert - assert result == expected - - -@pytest.mark.parametrize( - ( - "description", - "default", - "expected", - ), - [ - ("A", "B", "A (default: B)"), - ("A", 5, "A (default: 5)"), - ("A", None, "A (default: None)"), - ("A", ..., "A"), - (None, "B", "(default: B)"), - (None, 5, "(default: 5)"), - (None, None, "(default: None)"), - (None, ..., ""), - ], -) -def test_argument_description( - description: Optional[str], - default: Any, - expected: str, -) -> None: - """Tests `utils.arguments.description` Function. - - Args: - description (Optional[str]): Optional argument description to test. - default (Any): Optional argument default value to test. - expected (str): Expected result of the test. - """ - # Construct Pydantic Field - field = conf.create_test_field(default=default, description=description) - - # Generate Argument Description - result = utils.arguments.description(field) - - # Assert - assert result == expected diff --git a/vendor/pydantic-argparse/tests/utils/test_errors.py b/vendor/pydantic-argparse/tests/utils/test_errors.py deleted file mode 100644 index e11ad8db7..000000000 --- a/vendor/pydantic-argparse/tests/utils/test_errors.py +++ /dev/null @@ -1,87 +0,0 @@ -# SPDX-FileCopyrightText: Hayden Richards -# -# SPDX-License-Identifier: MIT - -"""Tests the `errors` Module. - -This module provides full unit test coverage for the `errors` module, testing -all branches of all functions. -""" - - -# Standard -import textwrap - -# Third-Party -import pydantic -import pytest - -# Local -from pydantic_argparse import utils -from tests import conftest as conf - -# Typing -from typing import Sequence, Tuple, Union - - -# Shortcuts -# An Error Definition is just a tuple containing an Exception and Location -# This allows for more terse unit test parametrization and function typing -ErrorDefinition = Tuple[Exception, Union[str, Tuple[str, ...]]] - - -@pytest.mark.parametrize( - ( - "errors", - "expected", - ), - [ - ( - [ - (pydantic.errors.MissingError(), "argument"), - ], - """ - 1 validation error for TestModel - argument - field required (type=value_error.missing) - """, - ), - ( - [ - (pydantic.errors.IPv4AddressError(), ("a", )), - (pydantic.errors.IntegerError(), ("a", "b")), - (pydantic.errors.UUIDError(), ("a", "b", "c")), - ], - """ - 3 validation errors for TestModel - a - value is not a valid IPv4 address (type=value_error.ipv4address) - a -> b - value is not a valid integer (type=type_error.integer) - a -> b -> c - value is not a valid uuid (type=type_error.uuid) - """, - ), - ], -) -def test_error_format( - errors: Sequence[ErrorDefinition], - expected: str, -) -> None: - """Tests `utils.errors.format` Function. - - Args: - errors (Sequence[ErrorDefinition]): Errors to test. - expected (str): Expected result of the test. - """ - # Construct Validation Error - error = pydantic.ValidationError( - errors=[pydantic.error_wrappers.ErrorWrapper(exc, loc) for (exc, loc) in errors], - model=conf.TestModel, - ) - - # Format Error - result = utils.errors.format(error) - - # Assert - assert result == textwrap.dedent(expected).strip() diff --git a/vendor/pydantic-argparse/tests/utils/test_namespaces.py b/vendor/pydantic-argparse/tests/utils/test_namespaces.py deleted file mode 100644 index 057dba930..000000000 --- a/vendor/pydantic-argparse/tests/utils/test_namespaces.py +++ /dev/null @@ -1,51 +0,0 @@ -# SPDX-FileCopyrightText: Hayden Richards -# -# SPDX-License-Identifier: MIT - -"""Tests the `namespaces` Module. - -This module provides full unit test coverage for the `namespaces` module, -testing all branches of all functions. -""" - - -# Standard -import argparse - -# Local -from pydantic_argparse import utils - - -def test_namespace_to_dict() -> None: - """Tests `utils.namespaces.to_dict` Function.""" - # Generate Dictionary - result = utils.namespaces.to_dict( - argparse.Namespace( - a="1", - b=2, - c=argparse.Namespace( - d="3", - e=4, - f=argparse.Namespace( - g=5, - h="6", - i=7, - ) - ) - ) - ) - - # Assert - assert result == { - "a": "1", - "b": 2, - "c": { - "d": "3", - "e": 4, - "f": { - "g": 5, - "h": "6", - "i": 7, - } - } - } diff --git a/vendor/pydantic-argparse/tests/utils/test_types.py b/vendor/pydantic-argparse/tests/utils/test_types.py deleted file mode 100644 index 1b0ab711a..000000000 --- a/vendor/pydantic-argparse/tests/utils/test_types.py +++ /dev/null @@ -1,90 +0,0 @@ -# SPDX-FileCopyrightText: Hayden Richards -# -# SPDX-License-Identifier: MIT - -"""Tests the `types` Module. - -This module provides full unit test coverage for the `types` module, testing -all branches of all functions. -""" - - -# Standard -import collections -import collections.abc -import enum -import sys - -# Third-Party -import pydantic -import pytest - -# Local -from pydantic_argparse import utils -from tests import conftest as conf - -# Typing -from typing import Any, Deque, Dict, FrozenSet, List, Set, Tuple - -# Version-Guarded -if sys.version_info < (3, 8): # pragma: <3.8 cover - from typing_extensions import Literal -else: # pragma: >=3.8 cover - from typing import Literal - - -@pytest.mark.parametrize( - ( - "field_type", - "expected_type", - ), - [ - (bool, bool), - (int, int), - (float, float), - (str, str), - (bytes, bytes), - (List, list), - (List, collections.abc.Container), - (List[str], list), - (List[str], collections.abc.Container), - (Tuple, tuple), - (Tuple, collections.abc.Container), - (Tuple[str, ...], tuple), - (Tuple[str, ...], collections.abc.Container), - (Set, set), - (Set, collections.abc.Container), - (Set[str], set), - (Set[str], collections.abc.Container), - (FrozenSet, frozenset), - (FrozenSet, collections.abc.Container), - (FrozenSet[str], frozenset), - (FrozenSet[str], collections.abc.Container), - (Deque, collections.deque), - (Deque, collections.abc.Container), - (Deque[str], collections.deque), - (Deque[str], collections.abc.Container), - (Dict, dict), - (Dict, collections.abc.Mapping), - (Dict[str, int], dict), - (Dict[str, int], collections.abc.Mapping), - (Literal["A"], Literal), - (Literal[1, 2, 3], Literal), - (conf.TestCommand, pydantic.BaseModel), - (conf.TestCommands, pydantic.BaseModel), - (conf.TestEnum, enum.Enum), - (conf.TestEnumSingle, enum.Enum), - ], -) -def test_is_field_a(field_type: Any, expected_type: Any) -> None: - """Tests utils.is_field_a Function. - - Args: - field_type (Any): Field type to test. - expected_type (Any): Expected type to check for the field. - """ - # Construct Pydantic Field - field = conf.create_test_field(type=field_type) - - # Check and Assert Field Type - assert utils.types.is_field_a(field, expected_type)