blob: fc2dc53b0316e1b476796ef13aec6085f84f7dbf [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Misc directives for the ADBC docs."""
import collections
import dataclasses
import functools
import typing
from pathlib import Path
import docutils
import docutils.nodes
import sphinx
from docutils.statemachine import StringList
from sphinx.util.docutils import SphinxDirective
from sphinx.util.nodes import nested_parse_with_titles
from sphinx.util.typing import OptionSpec
LOGGER = sphinx.util.logging.getLogger(__name__)
# conda-forge is handled specially
_REPO_TO_LANGUAGE = {
"CRAN": "R",
"crates.io": "Rust",
"Go": "Go",
"Maven": "Java",
"NuGet": "C#",
"RubyGems": "Ruby",
"R-multiverse": "R",
"PyPI": "Python",
}
_LANGUAGE_TO_KEY = {
"C/C++": "cpp",
"C#": "csharp",
}
@dataclasses.dataclass(frozen=True)
class DriverStatus:
vendor: str
implementation: str
status: typing.Literal["Experimental", "Beta", "Stable"]
packages: typing.List[typing.Tuple[str, str, str]] # (repo, package, URL)
@property
def badge_type(self) -> str:
if self.status == "Experimental":
return "danger"
elif self.status == "Beta":
return "warning"
elif self.status == "Stable":
return "success"
else:
raise ValueError(f"Unknown status {self.status} for {self.implementation}")
@functools.cache
def _driver_status(path: Path) -> DriverStatus:
# we could pull in a full markdown parser, but for now just munge the text
meta: typing.Dict[str, str] = {}
packages = []
with path.open() as source:
for line in source:
if "img.shields.io" in line:
before, _, after = line.partition("img.shields.io")
tag = before[before.index("![") + 2 : before.index("]")].strip()
key, _, value = tag.partition(": ")
key = key.strip()
value = value.strip()
if key.lower() in {"vendor", "implementation", "status"}:
meta[key.lower()] = value
else:
repo = key
url = after[after.rfind("(") + 1 : after.rfind(")")].strip()
packages.append((repo, value, url))
return DriverStatus(**meta, packages=packages)
def driver_status(path: Path) -> DriverStatus:
return _driver_status(path.resolve())
class DriverInstallationDirective(SphinxDirective):
has_content = False
required_arguments = 1
optional_arguments = 0
option_spec: OptionSpec = {}
def run(self):
rel_filename, filename = self.env.relfn2path(self.arguments[0])
self.env.note_dependency(rel_filename)
path = Path(filename).resolve()
status = driver_status(path)
is_native = status.implementation in {"C/C++", "C#", "Go", "Rust"}
generated_lines = []
if not status.packages:
generated_lines.append("No packages available; install from source.")
else:
generated_lines.append(".. tab-set::")
# language : list of (repo, package, url)
languages = collections.defaultdict(list)
for i, (repo, package, url) in enumerate(status.packages):
language = None
if repo == "conda-forge":
if package.startswith("lib"):
language = "C/C++"
else:
language = "Python"
else:
language = _REPO_TO_LANGUAGE.get(repo)
if language is None:
LOGGER.warning(
f"Unknown language mapping for package repo {repo}",
type="adbc_misc",
)
continue
languages[language].append((repo, package, url))
if "Go" not in languages and is_native:
languages["Go"] = []
for language, packages in sorted(languages.items(), key=lambda x: x[0]):
generated_lines.append("")
generated_lines.append(f" .. tab-item:: {language}")
generated_lines.append(
f" :sync: {_LANGUAGE_TO_KEY.get(language, language.lower())}"
)
generated_lines.append("")
for repo, package, url in sorted(
packages, key=lambda x: (x[0].lower(), x[1])
):
generated_lines.append(
f" Install `{package} <{url}>`__ from {repo}:"
)
generated_lines.append("")
if repo == "conda-forge":
generated_lines.append(" .. code-block:: shell")
generated_lines.append("")
generated_lines.append(f" mamba install {package}")
elif repo == "crates.io":
generated_lines.append(" .. code-block:: shell")
generated_lines.append("")
generated_lines.append(f" cargo add {package}")
elif repo == "CRAN":
generated_lines.append(" .. code-block:: r")
generated_lines.append("")
generated_lines.append(
f' install.packages("{package}")'
)
elif repo == "Go":
generated_lines.append(" .. code-block:: shell")
generated_lines.append("")
generated_lines.append(f" go get {package}")
elif repo == "Maven":
group, artifact = package.split(":")
generated_lines.append(" .. code-block:: xml")
generated_lines.append("")
generated_lines.append(" <dependency>")
generated_lines.append(f" <groupId>{group}</groupId>")
generated_lines.append(
f" <artifactId>{artifact}</artifactId>"
)
generated_lines.append(" </dependency>")
elif repo == "NuGet":
generated_lines.append(" .. code-block:: shell")
generated_lines.append("")
generated_lines.append(f" dotnet package add {package}")
elif repo == "PyPI":
generated_lines.append(" .. code-block:: shell")
generated_lines.append("")
generated_lines.append(f" pip install {package}")
elif repo == "R-multiverse":
generated_lines.append(" .. code-block:: r")
generated_lines.append("")
generated_lines.append(
f' install.packages("{package}", '
'repos = "https://community.r-multiverse.org")'
)
else:
LOGGER.warning(f"Unknown package repo {repo}", type="adbc_misc")
continue
generated_lines.append("")
if not packages and is_native:
if language == "Go":
generated_lines.append(
" Install the C/C++ driver, "
"then use the Go driver manager. "
"Requires CGO."
)
generated_lines.append("")
generated_lines.append(" .. code-block:: shell")
generated_lines.append("")
generated_lines.append(
" go get "
"github.com/apache/arrow-adbc/go/adbc/drivermgr"
)
else:
LOGGER.warning(
f"No packages and unknown language {language}",
type="adbc_misc",
)
if is_native:
generated_lines.append("")
generated_lines.append(
"Additionally, the driver may be used from C/C++, C#, GLib, "
"Go, R, Ruby, and Rust via the driver manager."
)
parsed = docutils.nodes.Element()
nested_parse_with_titles(
self.state,
StringList(generated_lines, source=""),
parsed,
)
return parsed.children
class DriverStatusDirective(SphinxDirective):
has_content = False
required_arguments = 1
optional_arguments = 0
option_spec: OptionSpec = {}
def run(self):
rel_filename, filename = self.env.relfn2path(self.arguments[0])
self.env.note_dependency(rel_filename)
path = Path(filename).resolve()
status = driver_status(path)
generated_lines = [
f":bdg-primary:`Language: {status.implementation}`",
f":bdg-{status.badge_type}:`Status: {status.status}`",
]
parsed = docutils.nodes.Element()
nested_parse_with_titles(
self.state,
StringList(generated_lines, source=""),
parsed,
)
return parsed.children
def package_badge_role(name, rawtext, text, lineno, inliner, options={}, content=[]):
"""Create a two-part badge for package managers.
Usage: :package-badge:`PackageManager|package-name|URL`
Example: :package-badge:`PyPI|adbc-driver-postgresql|https://pypi.org/project/adbc-driver-postgresql/`
"""
parts = text.split("|")
if len(parts) != 3:
msg = inliner.reporter.error(
f"package-badge must have exactly 3 parts separated by |, got: {text}",
line=lineno,
)
return [inliner.problematic(rawtext, rawtext, msg)], [msg]
left_text, right_text, url = [p.strip() for p in parts]
# Create the two-part badge structure
# Left part (label): darker background
left_node = docutils.nodes.inline("", left_text, classes=["package-badge-left"])
# Right part (value): lighter background
right_node = docutils.nodes.inline("", right_text, classes=["package-badge-right"])
# Container to hold both parts together
container = docutils.nodes.inline(
"", "", left_node, right_node, classes=["package-badge-container"]
)
# Wrap in a reference/link
ref_node = docutils.nodes.reference(
"", "", container, refuri=url, classes=["package-badge-link"]
)
return [ref_node], []
def iconlink_role(name, rawtext, text, lineno, inliner, options={}, content=[]):
"""Create an icon-only link (e.g. a GitHub logo linking to a repo).
Usage: :iconlink:`fa-classes|URL|tooltip`
Example: :iconlink:`fa-brands fa-github|https://github.com/apache/arrow-adbc|Source`
The ``tooltip`` is used as the link's accessible label (``title`` and
``aria-label``) so the icon-only link remains usable without visible text.
"""
parts = text.split("|")
if len(parts) != 3:
msg = inliner.reporter.error(
f"iconlink must have exactly 3 parts separated by |, got: {text}",
line=lineno,
)
return [inliner.problematic(rawtext, rawtext, msg)], [msg]
fa_classes, url, tooltip = [p.strip() for p in parts]
icon_html = (
f'<a class="icon-link" href="{url}" title="{tooltip}" '
f'aria-label="{tooltip}"><i class="{fa_classes}" aria-hidden="true">'
f"</i></a>"
)
return [docutils.nodes.raw("", icon_html, format="html")], []
def setup(app) -> None:
app.add_directive("adbc_driver_installation", DriverInstallationDirective)
app.add_directive("adbc_driver_status", DriverStatusDirective)
app.add_role("package-badge", package_badge_role)
app.add_role("iconlink", iconlink_role)
return {
"version": "0.1",
"parallel_read_safe": True,
"parallel_write_safe": True,
}