diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 716626ce36..97bf0df9a0 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -82,7 +82,7 @@ Settings: {'type': 'object', 'properties': {}} This information can also be printed in JSON format for consumption by other applications ```console -$ poetry run sdk-tap-countries-sample --about=json +$ poetry run sdk-tap-countries-sample --about --format json { "name": "sample-tap-countries", "version": "[could not be detected]", @@ -179,7 +179,7 @@ plugins: | ------------------- | :-----------------------------------------------------------------------------------------: | :------------------------------------------------------------------: | | Configuration store | Config JSON file (`--config=path/to/config.json`) or environment variables (`--config=ENV`) | `meltano.yml`, `.env`, environment variables, or Meltano's system db | | Simple invocation | `my-tap --config=...` | `meltano invoke my-tap` | -| Other CLI options | `my-tap --about=json` | `meltano invoke my-tap --about=json` | +| Other CLI options | `my-tap --about --format=json` | `meltano invoke my-tap --about --format=json` | | ELT | `my-tap --config=... \| path/to/target-jsonl --config=...` | `meltano elt my-tap target-jsonl` | [Meltano]: https://www.meltano.com diff --git a/docs/implementation/cli.md b/docs/implementation/cli.md index 49f95c65d1..e4f78c26bc 100644 --- a/docs/implementation/cli.md +++ b/docs/implementation/cli.md @@ -14,9 +14,7 @@ This page describes how SDK-based taps and targets can be invoked via the comman - [`--help`](#--help) - [`--version`](#--version) - [`--about`](#--about) - - [`--about=plain`](#--about-plain) - - [`--about=json`](#--about-json) - - [`--about=markdown`](#--about-markdown) + - [`--format`](#--format) - [`--config`](#--config) - [`--config=ENV`](#--config-env) - [Tap-Specific CLI Options](#tap-specific-cli-options) @@ -45,19 +43,13 @@ Prints the version of the tap or target along with the SDK version and then exit Prints important information about the tap or target, including the list of supported CLI commands, the `--version` metadata, and list of supported capabilities. -_Note: By default, the format of `--about` is plain text. You can pass a value to `--about` from one of the options described below._ +_Note: By default, the format of `--about` is plain text. You can invoke `--about` in combination with the `--format` option described below to have the output printed in different formats._ -#### `--about plain` +#### `--format` -Prints the plain text version of the `--about` output. This is the default. +When `--format=json` is specified, the `--about` information will be printed as `json` in order to easily process the metadata in automated workflows. -#### `--about json` - -Information will be printed as `json` in order to easily process the metadata in automated workflows. - -#### `--about markdown` - -Information will be printed as Markdown, optimized for copy-pasting into the maintainer's `README.md` file. Among other helpful guidance, this automatically creates a markdown table of all settings, their descriptions, and their default values. +When `--format=markdown` is specified, the `--about` information will be printed as Markdown, optimized for copy-pasting into the maintainer's `README.md` file. Among other helpful guidance, this automatically creates a markdown table of all settings, their descriptions, and their default values. ### `--config` diff --git a/docs/porting.md b/docs/porting.md index 7b9122d005..05b88a582b 100644 --- a/docs/porting.md +++ b/docs/porting.md @@ -225,7 +225,7 @@ To handle the conversion operation, you'll override [`Tap.load_state()`](singer_ The SDK provides autogenerated markdown you can paste into your README: ```console -poetry run tap-mysource --about=markdown +poetry run tap-mysource --about --format=markdown ``` This text will automatically document all settings, including setting descriptions. Optionally, paste this into your existing `README.md` file. diff --git a/singer_sdk/cli/__init__.py b/singer_sdk/cli/__init__.py new file mode 100644 index 0000000000..38b8b25d19 --- /dev/null +++ b/singer_sdk/cli/__init__.py @@ -0,0 +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/options.py b/singer_sdk/cli/options.py new file mode 100644 index 0000000000..6a5d93c170 --- /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/plugin_base.py b/singer_sdk/plugin_base.py index bd1bf9c01c..aee8cf3cd0 100644 --- a/singer_sdk/plugin_base.py +++ b/singer_sdk/plugin_base.py @@ -1,5 +1,7 @@ """Shared parent class for Tap, Target (future), and Transform (future).""" +from __future__ import annotations + import abc import json import logging @@ -7,25 +9,14 @@ from collections import OrderedDict from pathlib import Path from types import MappingProxyType -from typing import ( - Any, - Callable, - Dict, - List, - Mapping, - Optional, - Sequence, - Tuple, - Type, - Union, - cast, -) +from typing import Any, Callable, Dict, 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 @@ -80,7 +71,7 @@ def logger(cls) -> logging.Logger: def __init__( self, - config: Optional[Union[dict, _FilePath, Sequence[_FilePath]]] = None, + config: dict | _FilePath | Sequence[_FilePath] | None = None, parse_env_config: bool = False, validate_config: bool = True, ) -> None: @@ -125,7 +116,7 @@ def __init__( self.metrics_logger = metrics.get_metrics_logger() @classproperty - def capabilities(self) -> List[CapabilitiesEnum]: + def capabilities(self) -> list[CapabilitiesEnum]: """Get capabilities. Developers may override this property in oder to add or remove @@ -140,7 +131,7 @@ def capabilities(self) -> List[CapabilitiesEnum]: ] @classproperty - def _env_var_config(cls) -> Dict[str, Any]: + def _env_var_config(cls) -> dict[str, Any]: """Return any config specified in environment variables. Variables must match the convention "_", @@ -221,7 +212,7 @@ def _is_secret_config(config_key: str) -> bool: def _validate_config( self, raise_errors: bool = True, warnings_as_errors: bool = False - ) -> Tuple[List[str], List[str]]: + ) -> tuple[list[str], list[str]]: """Validate configuration input against the plugin configuration JSON schema. Args: @@ -234,8 +225,8 @@ def _validate_config( Raises: ConfigValidationError: If raise_errors is True and validation fails. """ - warnings: List[str] = [] - errors: List[str] = [] + warnings: list[str] = [] + errors: list[str] = [] log_fn = self.logger.info config_jsonschema = self.config_jsonschema if config_jsonschema: @@ -270,7 +261,7 @@ def _validate_config( @classmethod def print_version( - cls: Type["PluginBase"], + cls: type[PluginBase], print_fn: Callable[[Any], None] = print, ) -> None: """Print help text for the tap. @@ -284,13 +275,13 @@ def print_version( print_fn(f"{cls.name} v{cls.plugin_version}, Meltano SDK v{cls.sdk_version}") @classmethod - def _get_about_info(cls: Type["PluginBase"]) -> Dict[str, Any]: + def _get_about_info(cls: type[PluginBase]) -> dict[str, Any]: """Returns capabilities and other tap metadata. Returns: A dictionary containing the relevant 'about' information. """ - info: Dict[str, Any] = OrderedDict({}) + info: dict[str, Any] = OrderedDict({}) info["name"] = cls.name info["description"] = cls.__doc__ info["version"] = cls.plugin_version @@ -303,7 +294,7 @@ def _get_about_info(cls: Type["PluginBase"]) -> Dict[str, Any]: return info @classmethod - def append_builtin_config(cls: Type["PluginBase"], config_jsonschema: dict) -> None: + def append_builtin_config(cls: type[PluginBase], config_jsonschema: dict) -> None: """Appends built-in config to `config_jsonschema` if not already set. To customize or disable this behavior, developers may either override this class @@ -333,7 +324,7 @@ def _merge_missing(source_jsonschema: dict, target_jsonschema: dict) -> None: _merge_missing(FLATTENING_CONFIG, config_jsonschema) @classmethod - def print_about(cls: Type["PluginBase"], format: Optional[str] = None) -> None: + def print_about(cls: type[PluginBase], format: str | None = None) -> None: """Print capabilities and other tap metadata. Args: @@ -403,7 +394,7 @@ def print_about(cls: Type["PluginBase"], format: Optional[str] = None) -> None: print(formatted) @staticmethod - def config_from_cli_args(*args: str) -> Tuple[List[Path], bool]: + def config_from_cli_args(*args: str) -> tuple[list[Path], bool]: """Parse CLI arguments into a config dictionary. Args: @@ -448,7 +439,7 @@ def invoke(cls, *args: Any, **kwargs: Any) -> None: @classmethod def cb_version( - cls: Type["PluginBase"], + cls: type[PluginBase], ctx: click.Context, param: click.Option, value: bool, @@ -467,7 +458,7 @@ def cb_version( @classmethod def cb_about( - cls: Type["PluginBase"], + cls: type[PluginBase], ctx: click.Context, param: click.Option, value: str, @@ -481,11 +472,11 @@ def cb_about( """ if not value: return - cls.print_about(format=value) + cls.print_about(format=ctx.obj["about"]["about_format"]) ctx.exit() @classmethod - def get_command(cls: Type["PluginBase"]) -> click.Command: + def get_command(cls: type[PluginBase]) -> click.Command: """Handle command line execution. Returns: @@ -504,19 +495,27 @@ def get_command(cls: Type["PluginBase"]) -> click.Command: expose_value=False, callback=cls.cb_version, ), - click.Option( + *NestedOption( ["--about"], - type=click.Choice( - ["plain", "json", "markdown"], - case_sensitive=False, - ), help="Display package metadata and settings.", - is_flag=False, - is_eager=True, + is_flag=True, expose_value=False, callback=cls.cb_about, - flag_value="plain", - ), + 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, @@ -532,7 +531,7 @@ def get_command(cls: Type["PluginBase"]) -> click.Command: ) @classmethod - def cli(cls: Type["PluginBase"]) -> Any: # noqa: ANN401 + def cli(cls: type[PluginBase]) -> Any: # noqa: ANN401 """Execute standard CLI handler for taps. Returns: