From 0e4bb7a5b4cce4f1b530520a078ef95f0c727937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Rami=CC=81rez=20Mondrago=CC=81n?= Date: Tue, 18 Oct 2022 16:29:00 -0500 Subject: [PATCH] refactor: Use inheritance for plugins' CLI --- singer_sdk/_python_types.py | 6 + singer_sdk/cli/__init__.py | 4 + singer_sdk/cli/common_options.py | 39 ---- singer_sdk/cli/options.py | 70 +++++++ singer_sdk/configuration/_dict_config.py | 8 +- singer_sdk/helpers/_util.py | 18 +- singer_sdk/mapper_base.py | 105 ++++------ singer_sdk/plugin_base.py | 157 ++++++++++++++- singer_sdk/tap_base.py | 243 ++++++++++++----------- singer_sdk/target_base.py | 117 ++++------- tests/samples/test_target_csv.py | 2 +- 11 files changed, 451 insertions(+), 318 deletions(-) create mode 100644 singer_sdk/_python_types.py delete mode 100644 singer_sdk/cli/common_options.py create mode 100644 singer_sdk/cli/options.py diff --git a/singer_sdk/_python_types.py b/singer_sdk/_python_types.py new file mode 100644 index 000000000..4f9e8415f --- /dev/null +++ b/singer_sdk/_python_types.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +import os +from typing import Union + +_FilePath = Union[str, os.PathLike] diff --git a/singer_sdk/cli/__init__.py b/singer_sdk/cli/__init__.py index 76b58f748..38b8b25d1 100644 --- a/singer_sdk/cli/__init__.py +++ b/singer_sdk/cli/__init__.py @@ -1,3 +1,7 @@ """Helpers for the tap, target and mapper CLIs.""" from __future__ import annotations + +from singer_sdk.cli.options import NestedOption + +__all__ = ["NestedOption"] diff --git a/singer_sdk/cli/common_options.py b/singer_sdk/cli/common_options.py deleted file mode 100644 index 4e575311b..000000000 --- a/singer_sdk/cli/common_options.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Common CLI options for plugins.""" - -from __future__ import annotations - -import click - -PLUGIN_VERSION = click.option( - "--version", - is_flag=True, - help="Display the package version.", -) - -PLUGIN_ABOUT = click.option( - "--about", - is_flag=True, - help="Display package metadata and settings.", -) - -PLUGIN_ABOUT_FORMAT = click.option( - "--format", - help="Specify output style for --about", - type=click.Choice(["json", "markdown"], case_sensitive=False), - default=None, -) - -PLUGIN_CONFIG = click.option( - "--config", - multiple=True, - help="Configuration file location or 'ENV' to use environment variables.", - type=click.STRING, - default=(), -) - -PLUGIN_FILE_INPUT = click.option( - "--input", - "file_input", - help="A path to read messages from instead of from standard in.", - type=click.File("r"), -) diff --git a/singer_sdk/cli/options.py b/singer_sdk/cli/options.py new file mode 100644 index 000000000..6a5d93c17 --- /dev/null +++ b/singer_sdk/cli/options.py @@ -0,0 +1,70 @@ +"""Helpers for building the CLI for a Singer tap or target.""" + +from __future__ import annotations + +from typing import Any, Mapping, Sequence + +import click + + +class NestedOption(click.Option): + """A Click option that has suboptions.""" + + def __init__( + self, + *args: Any, + suboptions: Sequence[click.Option] | None = None, + **kwargs: Any, + ) -> None: + """Initialize the option. + + Args: + *args: Positional arguments to pass to the parent class. + suboptions: A list of suboptions to be added to the context. + **kwargs: Keyword arguments to pass to the parent class. + """ + self.suboptions = suboptions or [] + super().__init__(*args, **kwargs) + + def handle_parse_result( + self, + ctx: click.Context, + opts: Mapping[str, Any], + args: list[Any], + ) -> tuple[Any, list[str]]: + """Handle the parse result. + + Args: + ctx: The Click context. + opts: The options. + args: The arguments. + + Raises: + UsageError: If an option is used without the parent option. + + Returns: + The parse result. + """ + ctx.ensure_object(dict) + ctx.obj[self.name] = {} + + if self.name in opts: + for option in self.suboptions: + if option.name: + value = opts.get(option.name, option.get_default(ctx)) + ctx.obj[self.name][option.name] = value + else: + for option in self.suboptions: + if option.name in opts: + errmsg = f"{option.opts[0]} is not allowed without {self.opts[0]}" + raise click.UsageError(errmsg) + + return super().handle_parse_result(ctx, opts, args) + + def as_params(self) -> list[click.Option]: + """Return a list of options, including this one and its suboptions. + + Returns: + List of options. + """ + return [self, *self.suboptions] diff --git a/singer_sdk/configuration/_dict_config.py b/singer_sdk/configuration/_dict_config.py index f5061f07a..03bbd9f1d 100644 --- a/singer_sdk/configuration/_dict_config.py +++ b/singer_sdk/configuration/_dict_config.py @@ -84,13 +84,15 @@ def merge_config_sources( A single configuration dictionary. """ config: dict[str, Any] = {} - for config_path in inputs: - if config_path == "ENV": + for config_input in inputs: + if config_input == "ENV": env_config = parse_environment_config(config_schema, prefix=env_prefix) config.update(env_config) continue - if not Path(config_path).is_file(): + config_path = Path(config_input) + + if not config_path.is_file(): raise FileNotFoundError( f"Could not locate config file at '{config_path}'." "Please check that the file exists." diff --git a/singer_sdk/helpers/_util.py b/singer_sdk/helpers/_util.py index f3c8def83..ee3b90f88 100644 --- a/singer_sdk/helpers/_util.py +++ b/singer_sdk/helpers/_util.py @@ -3,25 +3,29 @@ from __future__ import annotations import json -from pathlib import Path, PurePath +from pathlib import Path from typing import Any, cast import pendulum +from singer_sdk._python_types import _FilePath -def read_json_file(path: PurePath | str) -> dict[str, Any]: - """Read json file, thowing an error if missing.""" + +def read_json_file(path: _FilePath) -> dict[str, Any]: + """Read json file, throwing an error if missing.""" if not path: raise RuntimeError("Could not open file. Filepath not provided.") - if not Path(path).exists(): - msg = f"File at '{path}' was not found." - for template in [f"{path}.template"]: + path_obj = Path(path) + + if not path_obj.exists(): + msg = f"File at '{path!r}' was not found." + for template in [f"{path!r}.template"]: if Path(template).exists(): msg += f"\nFor more info, please see the sample template at: {template}" raise FileExistsError(msg) - return cast(dict, json.loads(Path(path).read_text())) + return cast(dict, json.loads(path_obj.read_text())) def utc_now() -> pendulum.DateTime: diff --git a/singer_sdk/mapper_base.py b/singer_sdk/mapper_base.py index e17b23df5..9a2087884 100644 --- a/singer_sdk/mapper_base.py +++ b/singer_sdk/mapper_base.py @@ -3,14 +3,11 @@ from __future__ import annotations import abc -from io import FileIO -from typing import Callable, Iterable +from typing import IO, Iterable import click import singer_sdk._singerlib as singer -from singer_sdk.cli import common_options -from singer_sdk.configuration._dict_config import merge_config_sources from singer_sdk.helpers._classproperty import classproperty from singer_sdk.helpers.capabilities import CapabilitiesEnum, PluginCapabilities from singer_sdk.io_base import SingerReader @@ -108,68 +105,48 @@ def map_batch_message( """ raise NotImplementedError("BATCH messages are not supported by mappers.") - @classproperty - def cli(cls) -> Callable: + # CLI handler + + @classmethod + def invoke( # type: ignore[override] + cls: type[InlineMapper], + config: tuple[str, ...] = (), + file_input: IO[str] | None = None, + ) -> None: + """Invoke the mapper. + + Args: + config: Configuration file location or 'ENV' to use environment + variables. Accepts multiple inputs as a tuple. + file_input: Optional file to read input from. + """ + cls.print_version(print_fn=cls.logger.info) + config_files, parse_env_config = cls.config_from_cli_args(*config) + + mapper = cls( + config=config_files, + validate_config=True, + parse_env_config=parse_env_config, + ) + mapper.listen(file_input) + + @classmethod + def get_command(cls: type[InlineMapper]) -> click.Command: """Execute standard CLI handler for inline mappers. Returns: - A callable CLI object. + A click.Command object. """ - - @common_options.PLUGIN_VERSION - @common_options.PLUGIN_ABOUT - @common_options.PLUGIN_ABOUT_FORMAT - @common_options.PLUGIN_CONFIG - @common_options.PLUGIN_FILE_INPUT - @click.command( - help="Execute the Singer mapper.", - context_settings={"help_option_names": ["--help"]}, + command = super().get_command() + command.help = "Execute the Singer mapper." + command.params.extend( + [ + click.Option( + ["--input", "file_input"], + help="A path to read messages from instead of from standard in.", + type=click.File("r"), + ), + ], ) - def cli( - version: bool = False, - about: bool = False, - config: tuple[str, ...] = (), - format: str | None = None, - file_input: FileIO | None = None, - ) -> None: - """Handle command line execution. - - Args: - version: Display the package version. - about: Display package metadata and settings. - format: Specify output style for `--about`. - config: Configuration file location or 'ENV' to use environment - variables. Accepts multiple inputs as a tuple. - file_input: Specify a path to an input file to read messages from. - Defaults to standard in if unspecified. - """ - if version: - cls.print_version() - return - - if not about: - cls.print_version(print_fn=cls.logger.info) - - validate_config: bool = True - if about: - validate_config = False - - cls.print_version(print_fn=cls.logger.info) - - config_dict = merge_config_sources( - config, - cls.config_jsonschema, - cls._env_prefix, - ) - - mapper = cls( # type: ignore # Ignore 'type not callable' - config=config_dict, - validate_config=validate_config, - ) - - if about: - mapper.print_about(format) - else: - mapper.listen(file_input) - - return cli + + return command diff --git a/singer_sdk/plugin_base.py b/singer_sdk/plugin_base.py index 9ab87bf72..66ff88c26 100644 --- a/singer_sdk/plugin_base.py +++ b/singer_sdk/plugin_base.py @@ -7,14 +7,16 @@ import logging import os from collections import OrderedDict -from pathlib import PurePath +from pathlib import Path from types import MappingProxyType -from typing import Any, Callable, Mapping, cast +from typing import Any, Callable, Mapping, Sequence, cast import click from jsonschema import Draft7Validator, SchemaError, ValidationError from singer_sdk import metrics +from singer_sdk._python_types import _FilePath +from singer_sdk.cli import NestedOption from singer_sdk.configuration._dict_config import parse_environment_config from singer_sdk.exceptions import ConfigValidationError from singer_sdk.helpers._classproperty import classproperty @@ -70,7 +72,7 @@ def logger(cls) -> logging.Logger: def __init__( self, - config: dict | PurePath | str | list[PurePath | str] | None = None, + config: dict | _FilePath | Sequence[_FilePath] | None = None, parse_env_config: bool = False, validate_config: bool = True, ) -> None: @@ -87,7 +89,7 @@ def __init__( """ if not config: config_dict = {} - elif isinstance(config, str) or isinstance(config, PurePath): + elif isinstance(config, (str, bytes, os.PathLike)): config_dict = read_json_file(config) elif isinstance(config, list): config_dict = {} @@ -240,7 +242,7 @@ def _validate_config( errors.append(str(ex.message)) if errors: summary = ( - f"Config validation failed: {f'; '.join(errors)}\n" + f"Config validation failed: {'; '.join(errors)}\n" f"JSONSchema was: {config_jsonschema}" ) if raise_errors: @@ -391,16 +393,149 @@ def print_about(cls: type[PluginBase], format: str | None = None) -> None: formatted = "\n".join([f"{k.title()}: {v}" for k, v in info.items()]) print(formatted) - @classproperty - def cli(cls) -> Callable: + @staticmethod + def config_from_cli_args(*args: str) -> tuple[list[Path], bool]: + """Parse CLI arguments into a config dictionary. + + Args: + args: CLI arguments. + + Raises: + FileNotFoundError: If the config file does not exist. + + Returns: + A tuple containing the config dictionary and a boolean indicating whether + the config file was found. + """ + config_files = [] + parse_env_config = False + + for config_path in args: + if config_path == "ENV": + # Allow parse from env vars: + parse_env_config = True + continue + + # Validate config file paths before adding to list + if not Path(config_path).is_file(): + raise FileNotFoundError( + f"Could not locate config file at '{config_path}'." + "Please check that the file exists." + ) + + config_files.append(Path(config_path)) + + return config_files, parse_env_config + + @abc.abstractclassmethod + def invoke(cls, *args: Any, **kwargs: Any) -> None: + """Invoke the plugin. + + Args: + args: Plugin arguments. + kwargs: Plugin keyword arguments. + """ + ... + + @classmethod + def cb_version( + cls: type[PluginBase], + ctx: click.Context, + param: click.Option, + value: bool, + ) -> None: + """CLI callback to print the plugin version and exit. + + Args: + ctx: Click context. + param: Click parameter. + value: Boolean indicating whether to print the version. + """ + if not value: + return + cls.print_version(print_fn=click.echo) + ctx.exit() + + @classmethod + def cb_about( + cls: type[PluginBase], + ctx: click.Context, + param: click.Option, + value: str, + ) -> None: + """CLI callback to print the plugin information and exit. + + Args: + ctx: Click context. + param: Click parameter. + value: String indicating the format of the information to print. + """ + if not value: + return + cls.print_about(format=ctx.obj["about"]["about_format"]) + ctx.exit() + + @classmethod + def get_command(cls: type[PluginBase]) -> click.Command: """Handle command line execution. Returns: A callable CLI object. """ + return click.Command( + name=cls.name, + callback=cls.invoke, + context_settings={"help_option_names": ["--help"]}, + params=[ + click.Option( + ["--version"], + is_flag=True, + help="Display the package version.", + is_eager=True, + expose_value=False, + callback=cls.cb_version, + ), + *NestedOption( + ["--about"], + help="Display package metadata and settings.", + is_flag=True, + expose_value=False, + callback=cls.cb_about, + suboptions=[ + click.Option( + ["--format", "about_format"], + type=click.Choice( + ["plain", "json", "markdown"], + case_sensitive=False, + ), + help="Format for the --about option.", + is_flag=False, + is_eager=True, + expose_value=False, + flag_value="plain", + ), + ], + ).as_params(), + click.Option( + ["--config"], + multiple=True, + help=( + "Configuration file location or 'ENV' to use environment " + + "variables." + ), + type=click.STRING, + default=(), + is_eager=True, + ), + ], + ) - @click.command() - def cli() -> None: - pass + @classmethod + def cli(cls: type[PluginBase]) -> Any: # noqa: ANN401 + """Execute standard CLI handler for taps. - return cli + Returns: + The return value of the CLI handler. + """ + command = cls.get_command() + return command.main() diff --git a/singer_sdk/tap_base.py b/singer_sdk/tap_base.py index fccc80fa9..cc68bc62d 100644 --- a/singer_sdk/tap_base.py +++ b/singer_sdk/tap_base.py @@ -5,13 +5,13 @@ import abc import json from enum import Enum -from pathlib import Path, PurePath -from typing import Any, Callable, Sequence, cast +from pathlib import PurePath +from typing import Any, Sequence, cast import click +from singer_sdk._python_types import _FilePath from singer_sdk._singerlib import Catalog -from singer_sdk.cli import common_options from singer_sdk.exceptions import MaxRecordsLimitException from singer_sdk.helpers import _state from singer_sdk.helpers._classproperty import classproperty @@ -35,7 +35,7 @@ class CliTestOptionValue(Enum): All = "all" Schema = "schema" - Disabled = False + Disabled = "disabled" class Tap(PluginBase, metaclass=abc.ABCMeta): @@ -49,7 +49,7 @@ class Tap(PluginBase, metaclass=abc.ABCMeta): def __init__( self, - config: dict | PurePath | str | list[PurePath | str] | None = None, + config: dict | _FilePath | Sequence[_FilePath] | None = None, catalog: PurePath | str | dict | Catalog | None = None, state: PurePath | str | dict | None = None, parse_env_config: bool = False, @@ -389,126 +389,137 @@ def sync_all(self) -> None: # Command Line Execution - @classproperty - def cli(cls) -> Callable: - """Execute standard CLI handler for taps. + @classmethod + def invoke( # type: ignore[override] + cls: type[Tap], + config: tuple[str, ...] = (), + state: str | None = None, + catalog: str | None = None, + ) -> None: + """Invoke the tap's command line interface. - Returns: - A callable CLI object. + Args: + config: Configuration file location or 'ENV' to use environment + variables. Accepts multiple inputs as a tuple. + catalog: Use a Singer catalog file with the tap.", + state: Use a bookmarks file for incremental replication. """ + cls.print_version(print_fn=cls.logger.info) + config_files, parse_env_config = cls.config_from_cli_args(*config) - @common_options.PLUGIN_VERSION - @common_options.PLUGIN_ABOUT - @common_options.PLUGIN_ABOUT_FORMAT - @common_options.PLUGIN_CONFIG - @click.option( - "--discover", - is_flag=True, - help="Run the tap in discovery mode.", - ) - @click.option( - "--test", - is_flag=False, - flag_value=CliTestOptionValue.All.value, - default=CliTestOptionValue.Disabled, - help=( - "Use --test to sync a single record for each stream. " - + "Use --test=schema to test schema output without syncing " - + "records." - ), - ) - @click.option( - "--catalog", - help="Use a Singer catalog file with the tap.", - type=click.Path(), + tap = cls( + config=config_files, + state=state, + catalog=catalog, + parse_env_config=parse_env_config, + validate_config=True, ) - @click.option( - "--state", - help="Use a bookmarks file for incremental replication.", - type=click.Path(), + tap.sync_all() + + @classmethod + def cb_discover( + cls: type[Tap], + ctx: click.Context, + param: click.Option, + value: bool, + ) -> None: + """CLI callback to run the tap in discovery mode. + + Args: + ctx: Click context. + param: Click option. + value: Whether to run in discovery mode. + """ + if not value: + return + + config_args = ctx.params.get("config", ()) + config_files, parse_env_config = cls.config_from_cli_args(*config_args) + tap = cls( + config=config_files, + parse_env_config=parse_env_config, + validate_config=False, ) - @click.command( - help="Execute the Singer tap.", - context_settings={"help_option_names": ["--help"]}, + tap.run_discovery() + ctx.exit() + + @classmethod + def cb_test( + cls: type[Tap], + ctx: click.Context, + param: click.Option, + value: bool, + ) -> None: + """CLI callback to run the tap in test mode. + + Args: + ctx: Click context. + param: Click option. + value: Whether to run in test mode. + """ + if value == CliTestOptionValue.Disabled.value: + return + + config_args = ctx.params.get("config", ()) + config_files, parse_env_config = cls.config_from_cli_args(*config_args) + tap = cls( + config=config_files, + parse_env_config=parse_env_config, + validate_config=True, ) - def cli( - version: bool = False, - about: bool = False, - discover: bool = False, - test: CliTestOptionValue = CliTestOptionValue.Disabled, - config: tuple[str, ...] = (), - state: str | None = None, - catalog: str | None = None, - format: str | None = None, - ) -> None: - """Handle command line execution. - - Args: - version: Display the package version. - about: Display package metadata and settings. - discover: Run the tap in discovery mode. - test: Test connectivity by syncing a single record and exiting. - format: Specify output style for `--about`. - config: Configuration file location or 'ENV' to use environment - variables. Accepts multiple inputs as a tuple. - catalog: Use a Singer catalog file with the tap.", - state: Use a bookmarks file for incremental replication. - - Raises: - FileNotFoundError: If the config file does not exist. - """ - if version: - cls.print_version() - return - - if not about: - cls.print_version(print_fn=cls.logger.info) - else: - cls.print_about(format=format) - return - - validate_config: bool = True - if discover: - # Don't abort on validation failures - validate_config = False - - parse_env_config = False - config_files: list[PurePath] = [] - for config_path in config: - if config_path == "ENV": - # Allow parse from env vars: - parse_env_config = True - continue - - # Validate config file paths before adding to list - if not Path(config_path).is_file(): - raise FileNotFoundError( - f"Could not locate config file at '{config_path}'." - "Please check that the file exists." - ) - config_files.append(Path(config_path)) + if value == CliTestOptionValue.Schema.value: + tap.write_schemas() + else: + tap.run_connection_test() - tap = cls( # type: ignore # Ignore 'type not callable' - config=config_files or None, - state=state, - catalog=catalog, - parse_env_config=parse_env_config, - validate_config=validate_config, - ) + ctx.exit() + + @classmethod + def get_command(cls: type[Tap]) -> click.Command: + """Execute standard CLI handler for taps. - if discover: - tap.run_discovery() - if test == CliTestOptionValue.All.value: - tap.run_connection_test() - elif test == CliTestOptionValue.All.value: - tap.run_connection_test() - elif test == CliTestOptionValue.Schema.value: - tap.write_schemas() - else: - tap.sync_all() + Returns: + A click.Command object. + """ + command = super().get_command() + command.help = "Execute the Singer tap." + command.params.extend( + [ + click.Option( + ["--discover"], + is_flag=True, + help="Run the tap in discovery mode.", + callback=cls.cb_discover, + expose_value=False, + ), + click.Option( + ["--test"], + is_flag=False, + flag_value=CliTestOptionValue.All.value, + default=CliTestOptionValue.Disabled.value, + help=( + "Use --test to sync a single record for each stream. " + + "Use --test=schema to test schema output without syncing " + + "records." + ), + callback=cls.cb_test, + expose_value=False, + ), + click.Option( + ["--catalog"], + help="Use a Singer catalog file with the tap.", + type=click.Path(), + ), + click.Option( + ["--state"], + help="Use a bookmarks file for incremental replication.", + type=click.Path(), + ), + ], + ) - return cli + return command class SQLTap(Tap): @@ -519,7 +530,7 @@ class SQLTap(Tap): def __init__( self, - config: dict | PurePath | str | list[PurePath | str] | None = None, + config: dict | _FilePath | Sequence[_FilePath] | None = None, catalog: PurePath | str | dict | None = None, state: PurePath | str | dict | None = None, parse_env_config: bool = False, diff --git a/singer_sdk/target_base.py b/singer_sdk/target_base.py index fe2249338..118337a84 100644 --- a/singer_sdk/target_base.py +++ b/singer_sdk/target_base.py @@ -7,14 +7,12 @@ import json import sys import time -from io import FileIO -from pathlib import Path, PurePath -from typing import IO, Callable, Counter +from typing import IO, Counter, Sequence import click from joblib import Parallel, delayed, parallel_backend -from singer_sdk.cli import common_options +from singer_sdk._python_types import _FilePath from singer_sdk.exceptions import RecordsWithoutSchemaException from singer_sdk.helpers._batch import BaseBatchFileEncoding from singer_sdk.helpers._classproperty import classproperty @@ -51,7 +49,7 @@ class Target(PluginBase, SingerReader, metaclass=abc.ABCMeta): def __init__( self, - config: dict | PurePath | str | list[PurePath | str] | None = None, + config: dict | _FilePath | Sequence[_FilePath] | None = None, parse_env_config: bool = False, validate_config: bool = True, ) -> None: @@ -494,84 +492,49 @@ def _write_state_message(self, state: dict) -> None: # CLI handler - @classproperty - def cli(cls) -> Callable: - """Execute standard CLI handler for taps. + @classmethod + def invoke( # type: ignore[override] + cls: type[Target], + config: tuple[str, ...] = (), + file_input: IO[str] | None = None, + ) -> None: + """Invoke the target. - Returns: - A callable CLI object. + Args: + config: Configuration file location or 'ENV' to use environment + variables. Accepts multiple inputs as a tuple. + file_input: Optional file to read input from. """ + cls.print_version(print_fn=cls.logger.info) + config_files, parse_env_config = cls.config_from_cli_args(*config) - @common_options.PLUGIN_VERSION - @common_options.PLUGIN_ABOUT - @common_options.PLUGIN_ABOUT_FORMAT - @common_options.PLUGIN_CONFIG - @common_options.PLUGIN_FILE_INPUT - @click.command( - help="Execute the Singer target.", - context_settings={"help_option_names": ["--help"]}, + target = cls( + config=config_files, + validate_config=True, + parse_env_config=parse_env_config, ) - def cli( - version: bool = False, - about: bool = False, - config: tuple[str, ...] = (), - format: str | None = None, - file_input: FileIO | None = None, - ) -> None: - """Handle command line execution. - - Args: - version: Display the package version. - about: Display package metadata and settings. - format: Specify output style for `--about`. - config: Configuration file location or 'ENV' to use environment - variables. Accepts multiple inputs as a tuple. - file_input: Specify a path to an input file to read messages from. - Defaults to standard in if unspecified. - - Raises: - FileNotFoundError: If the config file does not exist. - """ - if version: - cls.print_version() - return - - if not about: - cls.print_version(print_fn=cls.logger.info) - else: - cls.print_about(format=format) - return - - validate_config: bool = True - - cls.print_version(print_fn=cls.logger.info) - - parse_env_config = False - config_files: list[PurePath] = [] - for config_path in config: - if config_path == "ENV": - # Allow parse from env vars: - parse_env_config = True - continue - - # Validate config file paths before adding to list - if not Path(config_path).is_file(): - raise FileNotFoundError( - f"Could not locate config file at '{config_path}'." - "Please check that the file exists." - ) - - config_files.append(Path(config_path)) - - target = cls( # type: ignore # Ignore 'type not callable' - config=config_files or None, - parse_env_config=parse_env_config, - validate_config=validate_config, - ) + target.listen(file_input) + + @classmethod + def get_command(cls: type[Target]) -> click.Command: + """Execute standard CLI handler for taps. - target.listen(file_input) + Returns: + A click.Command object. + """ + command = super().get_command() + command.help = "Execute the Singer target." + command.params.extend( + [ + click.Option( + ["--input", "file_input"], + help="A path to read messages from instead of from standard in.", + type=click.File("r"), + ), + ], + ) - return cli + return command class SQLTarget(Target): diff --git a/tests/samples/test_target_csv.py b/tests/samples/test_target_csv.py index cfbc67619..f5b59082d 100644 --- a/tests/samples/test_target_csv.py +++ b/tests/samples/test_target_csv.py @@ -205,7 +205,7 @@ def config_file_path(target): def test_input_arg(cli_runner, config_file_path, target): result = cli_runner.invoke( - target.cli, + target.get_command(), [ "--config", config_file_path,