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