diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c5b478a28..824a487be 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,7 +25,7 @@ repos: cookiecutter/.*| docs/.*| samples/.*\.json| - tests/snapshots/.*/.*\.json + tests/snapshots/.* )$ - id: trailing-whitespace exclude: | diff --git a/singer_sdk/about.py b/singer_sdk/about.py new file mode 100644 index 000000000..a4ce18c7b --- /dev/null +++ b/singer_sdk/about.py @@ -0,0 +1,192 @@ +"""About information for a plugin.""" + +from __future__ import annotations + +import abc +import dataclasses +import json +import typing as t +from collections import OrderedDict +from textwrap import dedent + +if t.TYPE_CHECKING: + from singer_sdk.helpers.capabilities import CapabilitiesEnum + +__all__ = [ + "AboutInfo", + "AboutFormatter", + "JSONFormatter", + "MarkdownFormatter", +] + + +@dataclasses.dataclass +class AboutInfo: + """About information for a plugin.""" + + name: str + description: str | None + version: str + sdk_version: str + + capabilities: list[CapabilitiesEnum] + settings: dict + + +class AboutFormatter(abc.ABC): + """Abstract base class for about formatters.""" + + formats: t.ClassVar[dict[str, type[AboutFormatter]]] = {} + format_name: str + + def __init_subclass__(cls, format_name: str) -> None: + """Initialize subclass. + + Args: + format_name: Name of the format. + """ + cls.formats[format_name] = cls + super().__init_subclass__() + + @classmethod + def get_formatter(cls, name: str) -> AboutFormatter: + """Get a formatter by name. + + Args: + name: Name of the formatter. + + Returns: + A formatter. + """ + return cls.formats[name]() + + @abc.abstractmethod + def format_about(self, about_info: AboutInfo) -> str: + """Render about information. + + Args: + about_info: About information. + """ + ... + + +class TextFormatter(AboutFormatter, format_name="text"): + """About formatter for text output.""" + + def format_about(self, about_info: AboutInfo) -> str: + """Render about information. + + Args: + about_info: About information. + + Returns: + A formatted string. + """ + return dedent( + f"""\ + Name: {about_info.name} + Description: {about_info.description} + Version: {about_info.version} + SDK Version: {about_info.sdk_version} + Capabilities: {about_info.capabilities} + Settings: {about_info.settings}""", + ) + + +class JSONFormatter(AboutFormatter, format_name="json"): + """About formatter for JSON output.""" + + def __init__(self) -> None: + """Initialize a JSONAboutFormatter.""" + self.indent = 2 + self.default = str + + def format_about(self, about_info: AboutInfo) -> str: + """Render about information. + + Args: + about_info: About information. + + Returns: + A formatted string. + """ + data = OrderedDict( + [ + ("name", about_info.name), + ("description", about_info.description), + ("version", about_info.version), + ("sdk_version", about_info.sdk_version), + ("capabilities", [c.value for c in about_info.capabilities]), + ("settings", about_info.settings), + ], + ) + return json.dumps(data, indent=self.indent, default=self.default) + + +class MarkdownFormatter(AboutFormatter, format_name="markdown"): + """About formatter for Markdown output.""" + + def format_about(self, about_info: AboutInfo) -> str: + """Render about information. + + Args: + about_info: About information. + + Returns: + A formatted string. + """ + max_setting_len = t.cast( + int, + max(len(k) for k in about_info.settings["properties"]), + ) + + # Set table base for markdown + table_base = ( + f"| {'Setting':{max_setting_len}}| Required | Default | Description |\n" + f"|:{'-' * max_setting_len}|:--------:|:-------:|:------------|\n" + ) + + # Empty list for string parts + md_list = [] + # Get required settings for table + required_settings = about_info.settings.get("required", []) + + # Iterate over Dict to set md + md_list.append( + f"# `{about_info.name}`\n\n" + f"{about_info.description}\n\n" + f"Built with the [Meltano Singer SDK](https://sdk.meltano.com).\n\n", + ) + + # Process capabilities and settings + + capabilities = "## Capabilities\n\n" + capabilities += "\n".join([f"* `{v}`" for v in about_info.capabilities]) + capabilities += "\n\n" + md_list.append(capabilities) + + setting = "## Settings\n\n" + + for k, v in about_info.settings.get("properties", {}).items(): + md_description = v.get("description", "").replace("\n", "
") + table_base += ( + f"| {k}{' ' * (max_setting_len - len(k))}" + f"| {'True' if k in required_settings else 'False':8} | " + f"{v.get('default', 'None'):7} | " + f"{md_description:11} |\n" + ) + + setting += table_base + setting += ( + "\n" + + "\n".join( + [ + "A full list of supported settings and capabilities " + f"is available by running: `{about_info.name} --about`", + ], + ) + + "\n" + ) + md_list.append(setting) + + return "".join(md_list) diff --git a/singer_sdk/internal/__init__.py b/singer_sdk/internal/__init__.py new file mode 100644 index 000000000..e143e7773 --- /dev/null +++ b/singer_sdk/internal/__init__.py @@ -0,0 +1 @@ +"""Internal utilities for the Singer SDK.""" diff --git a/singer_sdk/plugin_base.py b/singer_sdk/plugin_base.py index a2e6fee40..78dc9016e 100644 --- a/singer_sdk/plugin_base.py +++ b/singer_sdk/plugin_base.py @@ -3,10 +3,8 @@ from __future__ import annotations import abc -import json import logging import os -from collections import OrderedDict from pathlib import PurePath from types import MappingProxyType from typing import TYPE_CHECKING, Any, Callable, Mapping, cast @@ -14,7 +12,7 @@ import click from jsonschema import Draft7Validator -from singer_sdk import metrics +from singer_sdk import about, metrics from singer_sdk.configuration._dict_config import parse_environment_config from singer_sdk.exceptions import ConfigValidationError from singer_sdk.helpers._classproperty import classproperty @@ -149,19 +147,49 @@ def _env_var_config(cls) -> dict[str, Any]: # noqa: N805 # Core plugin metadata: - @classproperty - def plugin_version(cls) -> str: # noqa: N805 - """Get version. + @staticmethod + def _get_package_version(package: str) -> str: + """Return the package version number. + + Args: + package: The package name. Returns: The package version number. """ try: - version = metadata.version(cls.name) + version = metadata.version(package) except metadata.PackageNotFoundError: version = "[could not be detected]" return version + @classmethod + def get_plugin_version(cls) -> str: + """Return the package version number. + + Returns: + The package version number. + """ + return cls._get_package_version(cls.name) + + @classmethod + def get_sdk_version(cls) -> str: + """Return the package version number. + + Returns: + The package version number. + """ + return cls._get_package_version(SDK_PACKAGE_NAME) + + @classproperty + def plugin_version(cls) -> str: # noqa: N805 + """Get version. + + Returns: + The package version number. + """ + return cls.get_plugin_version() + @classproperty def sdk_version(cls) -> str: # noqa: N805 """Return the package version number. @@ -169,11 +197,7 @@ def sdk_version(cls) -> str: # noqa: N805 Returns: Meltano Singer SDK version number. """ - try: - version = metadata.version(SDK_PACKAGE_NAME) - except metadata.PackageNotFoundError: - version = "[could not be detected]" - return version + return cls.get_sdk_version() # Abstract methods: @@ -278,23 +302,23 @@ 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]) -> about.AboutInfo: """Returns capabilities and other tap metadata. Returns: A dictionary containing the relevant 'about' information. """ - info: dict[str, Any] = OrderedDict({}) - info["name"] = cls.name - info["description"] = cls.__doc__ - info["version"] = cls.plugin_version - info["sdk_version"] = cls.sdk_version - info["capabilities"] = cls.capabilities - config_jsonschema = cls.config_jsonschema cls.append_builtin_config(config_jsonschema) - info["settings"] = config_jsonschema - return info + + return about.AboutInfo( + name=cls.name, + description=cls.__doc__, + version=cls.get_plugin_version(), + sdk_version=cls.get_sdk_version(), + capabilities=cls.capabilities, + settings=config_jsonschema, + ) @classmethod def append_builtin_config(cls: type[PluginBase], config_jsonschema: dict) -> None: @@ -337,67 +361,8 @@ def print_about( output_format: Render option for the plugin information. """ info = cls._get_about_info() - - if output_format == "json": - print(json.dumps(info, indent=2, default=str)) # noqa: T201 - - elif output_format == "markdown": - max_setting_len = cast( - int, - max(len(k) for k in info["settings"]["properties"]), - ) - - # Set table base for markdown - table_base = ( - f"| {'Setting':{max_setting_len}}| Required | Default | Description |\n" - f"|:{'-' * max_setting_len}|:--------:|:-------:|:------------|\n" - ) - - # Empty list for string parts - md_list = [] - # Get required settings for table - required_settings = info["settings"].get("required", []) - - # Iterate over Dict to set md - md_list.append( - f"# `{info['name']}`\n\n" - f"{info['description']}\n\n" - f"Built with the [Meltano Singer SDK](https://sdk.meltano.com).\n\n", - ) - for key, value in info.items(): - if key == "capabilities": - capabilities = f"## {key.title()}\n\n" - capabilities += "\n".join([f"* `{v}`" for v in value]) - capabilities += "\n\n" - md_list.append(capabilities) - - if key == "settings": - setting = f"## {key.title()}\n\n" - for k, v in info["settings"].get("properties", {}).items(): - md_description = v.get("description", "").replace("\n", "
") - table_base += ( - f"| {k}{' ' * (max_setting_len - len(k))}" - f"| {'True' if k in required_settings else 'False':8} | " - f"{v.get('default', 'None'):7} | " - f"{md_description:11} |\n" - ) - setting += table_base - setting += ( - "\n" - + "\n".join( - [ - "A full list of supported settings and capabilities " - f"is available by running: `{info['name']} --about`", - ], - ) - + "\n" - ) - md_list.append(setting) - - print("".join(md_list)) # noqa: T201 - else: - formatted = "\n".join([f"{k.title()}: {v}" for k, v in info.items()]) - print(formatted) # noqa: T201 + formatter = about.AboutFormatter.get_formatter(output_format or "text") + print(formatter.format_about(info)) # noqa: T201 @classproperty def cli(cls) -> Callable: # noqa: N805 diff --git a/tests/core/test_about.py b/tests/core/test_about.py new file mode 100644 index 000000000..5deeb7b70 --- /dev/null +++ b/tests/core/test_about.py @@ -0,0 +1,73 @@ +"""Test the AboutInfo class.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from singer_sdk.about import AboutFormatter, AboutInfo +from singer_sdk.helpers.capabilities import TapCapabilities + +if t.TYPE_CHECKING: + from pathlib import Path + + from pytest_snapshot.plugin import Snapshot + +_format_to_extension = { + "text": "txt", + "json": "json", + "markdown": "md", +} + + +@pytest.fixture(scope="module") +def about_info() -> AboutInfo: + return AboutInfo( + name="tap-example", + description="Example tap for Singer SDK", + version="0.1.1", + sdk_version="1.0.0", + capabilities=[ + TapCapabilities.CATALOG, + TapCapabilities.DISCOVER, + TapCapabilities.STATE, + ], + settings={ + "properties": { + "start_date": { + "type": "string", + "format": "date-time", + "description": "Start date for the tap to extract data from.", + }, + "api_key": { + "type": "string", + "description": "API key for the tap to use.", + }, + }, + "required": ["api_key"], + }, + ) + + +@pytest.mark.snapshot() +@pytest.mark.parametrize( + "about_format", + [ + "text", + "json", + "markdown", + ], +) +def test_about_format( + snapshot: Snapshot, + snapshot_dir: Path, + about_info: AboutInfo, + about_format: str, +): + snapshot.snapshot_dir = snapshot_dir.joinpath("about_format") + + formatter = AboutFormatter.get_formatter(about_format) + output = formatter.format_about(about_info) + snapshot_name = f"{about_format}.snap.{_format_to_extension[about_format]}" + snapshot.assert_match(output, snapshot_name) diff --git a/tests/snapshots/about_format/json.snap.json b/tests/snapshots/about_format/json.snap.json new file mode 100644 index 000000000..15f947f8f --- /dev/null +++ b/tests/snapshots/about_format/json.snap.json @@ -0,0 +1,27 @@ +{ + "name": "tap-example", + "description": "Example tap for Singer SDK", + "version": "0.1.1", + "sdk_version": "1.0.0", + "capabilities": [ + "catalog", + "discover", + "state" + ], + "settings": { + "properties": { + "start_date": { + "type": "string", + "format": "date-time", + "description": "Start date for the tap to extract data from." + }, + "api_key": { + "type": "string", + "description": "API key for the tap to use." + } + }, + "required": [ + "api_key" + ] + } +} \ No newline at end of file diff --git a/tests/snapshots/about_format/markdown.snap.md b/tests/snapshots/about_format/markdown.snap.md new file mode 100644 index 000000000..6da32938c --- /dev/null +++ b/tests/snapshots/about_format/markdown.snap.md @@ -0,0 +1,20 @@ +# `tap-example` + +Example tap for Singer SDK + +Built with the [Meltano Singer SDK](https://sdk.meltano.com). + +## Capabilities + +* `catalog` +* `discover` +* `state` + +## Settings + +| Setting | Required | Default | Description | +|:----------|:--------:|:-------:|:------------| +| start_date| False | None | Start date for the tap to extract data from. | +| api_key | True | None | API key for the tap to use. | + +A full list of supported settings and capabilities is available by running: `tap-example --about` diff --git a/tests/snapshots/about_format/text.snap.txt b/tests/snapshots/about_format/text.snap.txt new file mode 100644 index 000000000..b40d9d37b --- /dev/null +++ b/tests/snapshots/about_format/text.snap.txt @@ -0,0 +1,6 @@ +Name: tap-example +Description: Example tap for Singer SDK +Version: 0.1.1 +SDK Version: 1.0.0 +Capabilities: [catalog, discover, state] +Settings: {'properties': {'start_date': {'type': 'string', 'format': 'date-time', 'description': 'Start date for the tap to extract data from.'}, 'api_key': {'type': 'string', 'description': 'API key for the tap to use.'}}, 'required': ['api_key']} \ No newline at end of file