Skip to content

Commit

Permalink
Add parsing package
Browse files Browse the repository at this point in the history
 # interactive rebase in progress; onto 53026c5
  • Loading branch information
lgarber-akamai committed Jul 17, 2024
1 parent b9dd336 commit 1677e08
Show file tree
Hide file tree
Showing 5 changed files with 340 additions and 68 deletions.
184 changes: 184 additions & 0 deletions linodecli/baked/parsing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"""
This module contains logic related to string parsing and replacement.
"""

import functools
import re
from html import unescape
from typing import List, Tuple

# Sentence delimiter, split on a period followed by any type of
# whitespace (space, new line, tab, etc.)
REGEX_SENTENCE_DELIMITER = re.compile(r"\.(?:\s|$)")

# Matches on pattern __prefix__ at the beginning of a description
# or after a comma
REGEX_TECHDOCS_PREFIX = re.compile(r"(?:, |\A)__([\w-]+)__")

# Matches on pattern [link title](https://.../)
REGEX_MARKDOWN_LINK = re.compile(r"\[(?P<text>.*?)]\((?P<link>.*?)\)")

MARKDOWN_RICH_TRANSLATION = [
# Inline code blocks (e.g. `cool code`)
(
re.compile(
r"`(?P<text>[^`]+)`",
),
"italic deep_pink3 on grey15",
),
# Bold tag (e.g. `**bold**` or `__bold__`)
(
re.compile(
r"\*\*(?P<text>[^_\s]+)\*\*",
),
"b",
),
(
re.compile(
r"__(?P<text>[^_\s]+)__",
),
"b",
),
# Italics tag (e.g. `*italics*` or `_italics_`)
(
re.compile(
r"\*(?P<text>[^*\s]+)\*",
),
"i",
),
(
re.compile(
r"_(?P<text>[^_\s]+)_",
),
"i",
),
]


def markdown_to_rich_markup(markdown: str) -> str:
"""
This function returns a version of the given argument description
with the appropriate color tags.
NOTE, Rich does support Markdown rendering, but it isn't suitable for this
use-case quite yet due to some Group(...) padding issues and limitations
with syntax themes.
:param markdown: The argument description to colorize.
:type markdown: str
:returns: The translated Markdown
"""

result = markdown

for exp, style in MARKDOWN_RICH_TRANSLATION:
result = exp.sub(
# Necessary to avoid cell-var-in-loop linter fer
functools.partial(
lambda style, match: f"[{style}]{match['text']}[/]", style
),
result,
)

return result


def extract_markdown_links(description: str) -> Tuple[str, List[str]]:
"""
Extracts all Markdown links from the given description and
returns them alongside the stripped description.
:param description: The description of a CLI argument.
:type description: str
:returns: The stripped description and a list of extracted links.
:rtype: Tuple[str, List[str]]
"""
result_links = []

def _sub_handler(match: re.Match) -> str:
link = match["link"]
if link.startswith("/"):
link = f"https://linode.com{link}"

result_links.append(link)
return match["text"]

result_description = REGEX_MARKDOWN_LINK.sub(_sub_handler, description)

return result_description, result_links


def get_short_description(description: str) -> str:
"""
Gets the first relevant sentence in the given description.
:param description: The description of a CLI argument.
:type description: str
:returns: A single sentence from the description.
:rtype: set
"""

target_lines = description.splitlines()
relevant_lines = None

for i, line in enumerate(target_lines):
# Edge case for descriptions starting with a note
if line.lower().startswith("__note__"):
continue

relevant_lines = target_lines[i:]
break

if relevant_lines is None:
raise ValueError(
f"description does not contain any relevant lines: {description}",
)

return REGEX_SENTENCE_DELIMITER.split("\n".join(relevant_lines), 1)[0] + "."


def strip_techdocs_prefixes(description: str) -> str:
"""
Removes all bold prefixes from the given description.
:param description: The description of a CLI argument.
:type description: str
:returns: The stripped description
:rtype: str
"""
result_description = REGEX_TECHDOCS_PREFIX.sub(
"", description.lstrip()
).lstrip()

return result_description


def process_arg_description(description: str) -> Tuple[str, str]:
"""
Processes the given raw request argument description into one suitable
for help pages, etc.
:param description: The original description for a request argument.
:type description: str
:returns: The description in Rich markup and original Markdown format.
:rtype: Tuple[str, str]
"""

if description == "":
return "", ""

result = get_short_description(description)
result = strip_techdocs_prefixes(result)
result = result.replace("\n", " ").replace("\r", " ")

description, links = extract_markdown_links(result)

if len(links) > 0:
description += f" See: {'; '.join(links)}"

return unescape(markdown_to_rich_markup(description)), unescape(description)
13 changes: 10 additions & 3 deletions linodecli/baked/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
Request details for a CLI Operation
"""

from linodecli.baked.parsing import process_arg_description


class OpenAPIRequestArg:
"""
Expand Down Expand Up @@ -44,11 +46,16 @@ def __init__(
#: the larger response model
self.path = prefix + "." + name if prefix else name

#: The description of this argument, for help display
self.description = (
schema.description.split(".")[0] if schema.description else ""
description_rich, description = process_arg_description(
schema.description or ""
)

#: The description of this argument for Markdown/plaintext display
self.description = description

#: The description of this argument for display on the help page
self.description_rich = description_rich

#: If this argument is required for requests
self.required = required

Expand Down
41 changes: 7 additions & 34 deletions linodecli/help_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
help pages.
"""

import re
import textwrap
from collections import defaultdict
from typing import List, Optional
Expand All @@ -13,6 +12,7 @@
from rich.console import Console
from rich.padding import Padding
from rich.table import Table
from rich.text import Text

from linodecli import plugins
from linodecli.baked import OpenAPIOperation
Expand Down Expand Up @@ -239,16 +239,14 @@ def _help_action_print_body_args(

prefix = f" ({', '.join(metadata)})" if len(metadata) > 0 else ""

description = _markdown_links_to_rich(
arg.description.replace("\n", " ").replace("\r", " ")
arg_text = Text.from_markup(
f"[bold green]--{arg.path}[/][bold]{prefix}:[/] {arg.description_rich}"
)

arg_str = (
f"[bold magenta]--{arg.path}[/][bold]{prefix}[/]: {description}"
console.print(
Padding.indent(arg_text, (arg.depth * 2) + 2),
)

console.print(Padding.indent(arg_str.rstrip(), (arg.depth * 2) + 2))

console.print()


Expand Down Expand Up @@ -278,8 +276,8 @@ def _help_group_arguments(
# leave it as is in the result
if len(group) > 1:
groups.append(
# Required arguments should come first in groups
sorted(group, key=lambda v: not v.required),
# Args should be ordered by least depth -> required -> path
sorted(group, key=lambda v: (v.depth, not v.required, v.path)),
)
continue

Expand All @@ -306,28 +304,3 @@ def _help_group_arguments(
result += groups

return result


def _markdown_links_to_rich(text):
"""
Returns the given text with Markdown links converted to Rich-compatible links.
"""

result = text

# Find all Markdown links
r = re.compile(r"\[(?P<text>.*?)]\((?P<link>.*?)\)")

for match in r.finditer(text):
url = match.group("link")

# Expand the URL if necessary
if url.startswith("/"):
url = f"https://linode.com{url}"

# Replace with more readable text
result = result.replace(
match.group(), f"{match.group('text')} ([link={url}]{url}[/link])"
)

return result
67 changes: 36 additions & 31 deletions tests/unit/test_help_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,55 +4,58 @@


class TestHelpPages:
def test_filter_markdown_links(self):
"""
Ensures that Markdown links are properly converted to their rich equivalents.
"""

original_text = "Here's [a relative link](/docs/cool) and [an absolute link](https://cloud.linode.com)."
expected_text = (
"Here's a relative link ([link=https://linode.com/docs/cool]https://linode.com/docs/cool[/link]) "
"and an absolute link ([link=https://cloud.linode.com]https://cloud.linode.com[/link])."
)

assert (
help_pages._markdown_links_to_rich(original_text) == expected_text
)

def test_group_arguments(self, capsys):
# NOTE: We use SimpleNamespace here so we can do deep comparisons using ==
args = [
SimpleNamespace(
read_only=False,
required=True,
path="foo",
read_only=False, required=False, depth=0, path="foobaz"
),
SimpleNamespace(
read_only=False, required=False, depth=0, path="foobar"
),
SimpleNamespace(
read_only=False, required=True, depth=0, path="barfoo"
),
SimpleNamespace(
read_only=False, required=False, depth=0, path="foo"
),
SimpleNamespace(
read_only=False, required=False, depth=1, path="foo.bar"
),
SimpleNamespace(
read_only=False, required=False, depth=1, path="foo.foo"
),
SimpleNamespace(
read_only=False, required=True, depth=1, path="foo.baz"
),
SimpleNamespace(read_only=False, required=False, path="foo.bar"),
SimpleNamespace(read_only=False, required=False, path="foobaz"),
SimpleNamespace(read_only=False, required=False, path="foo.foo"),
SimpleNamespace(read_only=False, required=False, path="foobar"),
SimpleNamespace(read_only=False, required=True, path="barfoo"),
]

expected = [
[
SimpleNamespace(read_only=False, required=True, path="barfoo"),
SimpleNamespace(
read_only=False, required=True, path="barfoo", depth=0
),
],
[
SimpleNamespace(read_only=False, required=False, path="foobar"),
SimpleNamespace(read_only=False, required=False, path="foobaz"),
SimpleNamespace(
read_only=False, required=False, path="foobar", depth=0
),
SimpleNamespace(
read_only=False, required=False, path="foobaz", depth=0
),
],
[
SimpleNamespace(
read_only=False,
required=True,
path="foo",
read_only=False, required=False, path="foo", depth=0
),
SimpleNamespace(
read_only=False, required=True, path="foo.baz", depth=1
),
SimpleNamespace(
read_only=False, required=False, path="foo.bar"
read_only=False, required=False, path="foo.bar", depth=1
),
SimpleNamespace(
read_only=False, required=False, path="foo.foo"
read_only=False, required=False, path="foo.foo", depth=1
),
],
]
Expand Down Expand Up @@ -136,13 +139,15 @@ def test_action_help_post_method(self, capsys, mocker, mock_cli):
required=True,
path="path",
description="test description",
description_rich="test description",
depth=0,
),
mocker.MagicMock(
read_only=False,
required=False,
path="path2",
description="test description 2",
description_rich="test description 2",
format="json",
nullable=True,
depth=0,
Expand Down
Loading

0 comments on commit 1677e08

Please sign in to comment.