Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/e2e-suite.yml
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ jobs:
steps:
- name: Notify Slack
id: main_message
uses: slackapi/slack-github-action@v2.0.0
uses: slackapi/slack-github-action@v2.1.0
with:
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
Expand Down Expand Up @@ -326,7 +326,7 @@ jobs:

- name: Test summary thread
if: success()
uses: slackapi/slack-github-action@v2.0.0
uses: slackapi/slack-github-action@v2.1.0
with:
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/nightly-smoke-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:

- name: Notify Slack
if: always() && github.repository == 'linode/linode-cli' # Run even if integration tests fail and only on main repository
uses: slackapi/slack-github-action@v2.0.0
uses: slackapi/slack-github-action@v2.1.0
with:
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
steps:
- name: Notify Slack - Main Message
id: main_message
uses: slackapi/slack-github-action@v2.0.0
uses: slackapi/slack-github-action@v2.1.0
with:
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
Expand Down Expand Up @@ -67,7 +67,7 @@ jobs:
result-encoding: string

- name: Build and push to DockerHub
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # pin@v6.16.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # pin@v6.18.0
with:
context: .
file: Dockerfile
Expand Down
7 changes: 5 additions & 2 deletions linodecli/api_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
ExplicitNullValue,
OpenAPIOperation,
)
from .baked.util import get_path_segments
from .helpers import handle_url_overrides

if TYPE_CHECKING:
Expand Down Expand Up @@ -364,13 +365,15 @@ def _build_request_body(
if v is None or k in param_names:
continue

path_segments = get_path_segments(k)

cur = expanded_json
for part in k.split(".")[:-1]:
for part in path_segments[:-1]:
if part not in cur:
cur[part] = {}
cur = cur[part]

cur[k.split(".")[-1]] = v
cur[path_segments[-1]] = v

return json.dumps(_traverse_request_body(expanded_json))

Expand Down
26 changes: 17 additions & 9 deletions linodecli/baked/operation.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
OpenAPIRequestArg,
)
from linodecli.baked.response import OpenAPIResponse
from linodecli.baked.util import unescape_arg_segment
from linodecli.exit_codes import ExitCodes
from linodecli.output.output_handler import OutputHandler
from linodecli.overrides import OUTPUT_OVERRIDES
Expand Down Expand Up @@ -649,6 +650,9 @@ def _add_args_post_put(
if arg.read_only:
continue

arg_name_unescaped = unescape_arg_segment(arg.name)
arg_path_unescaped = unescape_arg_segment(arg.path)

arg_type = (
arg.item_type if arg.datatype == "array" else arg.datatype
)
Expand All @@ -660,15 +664,17 @@ def _add_args_post_put(
if arg.datatype == "array":
# special handling for input arrays
parser.add_argument(
"--" + arg.path,
metavar=arg.name,
"--" + arg_path_unescaped,
dest=arg.path,
metavar=arg_name_unescaped,
action=ArrayAction,
type=arg_type_handler,
)
elif arg.is_child:
parser.add_argument(
"--" + arg.path,
metavar=arg.name,
"--" + arg_path_unescaped,
dest=arg.path,
metavar=arg_name_unescaped,
action=ListArgumentAction,
type=arg_type_handler,
)
Expand All @@ -677,7 +683,7 @@ def _add_args_post_put(
if arg.datatype == "string" and arg.format == "password":
# special case - password input
parser.add_argument(
"--" + arg.path,
"--" + arg_path_unescaped,
nargs="?",
action=PasswordPromptAction,
)
Expand All @@ -687,15 +693,17 @@ def _add_args_post_put(
"ssl-key",
):
parser.add_argument(
"--" + arg.path,
metavar=arg.name,
"--" + arg_path_unescaped,
dest=arg.path,
metavar=arg_name_unescaped,
action=OptionalFromFileAction,
type=arg_type_handler,
)
else:
parser.add_argument(
"--" + arg.path,
metavar=arg.name,
"--" + arg_path_unescaped,
dest=arg.path,
metavar=arg_name_unescaped,
type=arg_type_handler,
)

Expand Down
7 changes: 6 additions & 1 deletion linodecli/baked/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@

from linodecli.baked.parsing import simplify_description
from linodecli.baked.response import OpenAPIResponse
from linodecli.baked.util import _aggregate_schema_properties
from linodecli.baked.util import (
_aggregate_schema_properties,
escape_arg_segment,
)


class OpenAPIRequestArg:
Expand Down Expand Up @@ -152,6 +155,8 @@ def _parse_request_model(
return args

for k, v in properties.items():
k = escape_arg_segment(k)

# Handle nested objects which aren't read-only and have properties
if (
v.type == "object"
Expand Down
58 changes: 57 additions & 1 deletion linodecli/baked/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
Provides various utility functions for use in baking logic.
"""

import re
from collections import defaultdict
from typing import Any, Dict, Set, Tuple
from typing import Any, Dict, List, Set, Tuple

from openapi3.schemas import Schema

Expand Down Expand Up @@ -51,3 +52,58 @@ def _handle_schema(_schema: Schema):
# We only want to mark fields that are required by ALL subschema as required
set(key for key, count in required.items() if count == schema_count),
)


ESCAPED_PATH_DELIMITER_PATTERN = re.compile(r"(?<!\\)\.")


def escape_arg_segment(segment: str) -> str:
"""
Escapes periods in a segment by prefixing them with a backslash.

:param segment: The input string segment to escape.
:return: The escaped segment with periods replaced by '\\.'.
"""
return segment.replace(".", "\\.")


def unescape_arg_segment(segment: str) -> str:
"""
Reverses the escaping of periods in a segment, turning '\\.' back into '.'.

:param segment: The input string segment to unescape.
:return: The unescaped segment with '\\.' replaced by '.'.
"""
return segment.replace("\\.", ".")


def get_path_segments(path: str) -> List[str]:
"""
Splits a path string into segments using a delimiter pattern,
and unescapes any escaped delimiters in the resulting segments.

:param path: The full path string to split and unescape.
:return: A list of unescaped path segments.
"""
return [
unescape_arg_segment(seg)
for seg in ESCAPED_PATH_DELIMITER_PATTERN.split(path)
]


def get_terminal_keys(data: Dict[str, Any]) -> List[str]:
"""
Recursively retrieves all terminal (non-dict) keys from a nested dictionary.

:param data: The input dictionary, possibly nested.
:return: A list of all terminal keys (keys whose values are not dictionaries).
"""
ret = []

for k, v in data.items():
if isinstance(v, dict):
ret.extend(get_terminal_keys(v)) # recurse into nested dicts
else:
ret.append(k) # terminal key

return ret
22 changes: 19 additions & 3 deletions linodecli/output/output_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from rich.table import Column, Table

from linodecli.baked.response import OpenAPIResponse, OpenAPIResponseAttr
from linodecli.baked.util import get_terminal_keys


class OutputMode(Enum):
Expand Down Expand Up @@ -328,15 +329,30 @@ def _json_output(self, header, data, to):
Prints data in JSON format
"""
# Special handling for JSON headers.
# We're only interested in the last part of the column name.
header = [v.split(".")[-1] for v in header]
# We're only interested in the last part of the column name, unless the last
# part is a dotted key. If the last part is a dotted key, include the entire dotted key.

content = []
if len(data) and isinstance(data[0], dict): # we got delimited json in
parsed_header = []
terminal_keys = get_terminal_keys(data[0])

for v in header:
parts = v.split(".")
if (
len(parts) >= 2
and ".".join([parts[-2], parts[-1]]) in terminal_keys
):
parsed_header.append(".".join([parts[-2], parts[-1]]))
else:
parsed_header.append(parts[-1])

# parse down to the value we display
for row in data:
content.append(self._select_json_elements(header, row))
content.append(self._select_json_elements(parsed_header, row))
else: # this is a list
header = [v.split(".")[-1] for v in header]

for row in data:
content.append(dict(zip(header, row)))

Expand Down
Loading
Loading