Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ dependencies = [
"cryptography>=40.0.0",
"typing-extensions>=4.15.0",
"python-benedict==0.30",
"ruamel-yaml>=0.17.0",
]

[dependency-groups]
Expand Down
19 changes: 17 additions & 2 deletions riocli/configtree/import_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,21 @@
# 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.
from collections.abc import Iterable
from pathlib import Path

import click
from benedict import benedict
from ruamel.yaml import YAML as _YAML
from click_help_colors import HelpColorsCommand
from yaspin.core import Yaspin

from riocli.config import new_v2_client
from riocli.configtree.etcd import import_in_etcd
from riocli.configtree.revision import Revision
from riocli.configtree.util import Metadata, export_to_files
from riocli.constants import Colors, Symbols
from riocli.utils.spinner import with_spinner

Check failure on line 28 in riocli/configtree/import_keys.py

View workflow job for this annotation

GitHub Actions / code-quality-checks

ruff (I001)

riocli/configtree/import_keys.py:14:1: I001 Import block is un-sorted or un-formatted help: Organize imports


@click.command(
Expand Down Expand Up @@ -277,6 +278,13 @@
return content, metadata


def _load_yaml_file(path: str) -> dict:
"""Load a YAML file using YAML 1.2 (ruamel.yaml) to avoid implicit type coercions."""
_yaml = _YAML(typ="safe")
with open(path) as f:
return _yaml.load(f) or {}


def _process_files_with_overrides(
files: Iterable[str],
overrides: Iterable[str],
Expand All @@ -295,7 +303,10 @@
if f.endswith("json"):
file_format = "json"

data[file_prefix] = benedict(f, format=file_format)
if file_format == "json":
data[file_prefix] = benedict(f, format="json")
else:
data[file_prefix] = benedict(_load_yaml_file(f))
spinner.write(
click.style(
f"{Symbols.SUCCESS} File {f} processed.",
Expand All @@ -313,7 +324,11 @@
if f.endswith("json"):
file_format = "json"

override.merge(benedict(f, format=file_format).unflatten(separator="/"))
if file_format == "json":
loaded = benedict(f, format="json")
else:
loaded = benedict(_load_yaml_file(f))
override.merge(loaded.unflatten(separator="/"))
Comment on lines +327 to +331

spinner.write(
click.style(
Expand Down
27 changes: 23 additions & 4 deletions riocli/configtree/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,31 @@
# 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.
from __future__ import annotations

import json
import os
from base64 import b64decode
from datetime import date, datetime
from typing import TYPE_CHECKING, Any

import yaml
from benedict import benedict
from ruamel.yaml import YAML
from munch import Munch, munchify, unmunchify
from rapyuta_io_sdk_v2 import walk_pages

from riocli.config import new_v2_client
from riocli.utils import tabulate_data
from riocli.utils.graph import Graphviz
from riocli.utils.state import StateFile

Check failure on line 30 in riocli/configtree/util.py

View workflow job for this annotation

GitHub Actions / code-quality-checks

ruff (I001)

riocli/configtree/util.py:14:1: I001 Import block is un-sorted or un-formatted help: Organize imports

if TYPE_CHECKING:
from collections.abc import Iterable

_yaml_reader = YAML(typ="safe")
_yaml_writer = YAML()
_yaml_writer.default_flow_style = False

MILESTONE_LABEL_KEY = "rapyuta.io/milestone"
TOP_KEYS_FILE = "top-keys"

Expand Down Expand Up @@ -219,6 +224,19 @@
)


def _to_plain(obj: Any) -> Any:
"""Recursively convert dict/list subclasses to plain Python containers.

ruamel.yaml's representer only handles exact dict/list types, not subclasses
like benedict or CommentedMap, so we normalise before dumping.
"""
if isinstance(obj, dict):
return {k: _to_plain(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_to_plain(v) for v in obj]
return obj


def export_to_files(base_dir: str, data: dict, file_format: str = "yaml") -> None:
base_dir = os.path.abspath(base_dir)

Expand All @@ -228,7 +246,8 @@
file_path = os.path.join(base_dir, f"{file_name}.{file_format}")
final_data = benedict(file_data)
if file_format == "yaml":
final_data.to_yaml(filepath=file_path)
with open(file_path, "w") as fh:
_yaml_writer.dump(_to_plain(final_data), fh)
elif file_format == "json":
final_data.to_json(filepath=file_path, indent=4)
else:
Expand Down Expand Up @@ -313,8 +332,8 @@
# appropriate data-type in Python (as well in exports), we are
# passing it through YAML parser.
try:
data = yaml.safe_load(data)
except yaml.YAMLError:
data = _yaml_reader.load(data)
except Exception:
Comment thread
Copilot marked this conversation as resolved.
Outdated
# Values are not guaranteed to be valid YAML, e.g. logging
# format strings like "[%(levelname)s] ...". Keep the raw
# string as-is when parsing fails.
Expand Down
25 changes: 25 additions & 0 deletions tests/unit/configtree/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,31 @@ def test_yaml_scalars_are_typed(self):
assert result["a/bool"] is True
assert result["a/str"] == "hello"

def test_yaml11_booleans_are_preserved_as_strings(self):
# YAML 1.1 coerces yes/no/on/off to bool; YAML 1.2 (ruamel) keeps them
# as strings — the core fix for the configtree import/export pipeline.
keys = {
"a/yes": {"data": _encode("yes")},
"a/no": {"data": _encode("no")},
"a/on": {"data": _encode("on")},
"a/off": {"data": _encode("off")},
}

result = combine_metadata(keys)

assert result["a/yes"] == "yes"
assert result["a/no"] == "no"
assert result["a/on"] == "on"
assert result["a/off"] == "off"

def test_octal_0777_is_decimal_777(self):
# YAML 1.1 (PyYAML) parses 0777 as octal 511; YAML 1.2 treats it as decimal.
keys = {"cfg/mode": {"data": _encode("0777")}}

result = combine_metadata(keys)

assert result["cfg/mode"] == 777

def test_non_yaml_value_is_kept_as_raw_string(self):
# Logging format strings are not valid YAML: the leading '[' starts a
# flow sequence and '%' cannot start a token. They must survive as-is
Expand Down
11 changes: 11 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading