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
2 changes: 2 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Unreleased
:octicon:`code-review` API Changes
++++++++++++++++++++++++++++++++++

* The :py:attr:`Diagnosis.fix <tugboat.Diagnosis.fix>` field now accepts :py:class:`dict` types in addition to :py:class:`str`.
Use :py:class:`dict` when suggesting fixes that involve multiple fields or complex object replacements, while :py:class:`str` remain suitable for simple value corrections.
* Refactor :py:mod:`tugboat.constraints` to provide more flexible validation functions:

* Diagnostic messages have been improved for clarity.
Expand Down
66 changes: 62 additions & 4 deletions tests/console/formatters/test_console_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,16 +173,74 @@ def test_3(self, fixture_dir: Path):
"""
), f"Output:\n{report}"

def test_4(self, fixture_dir: Path):
manifest_path = fixture_dir / "sample-workflow.yaml"

formatter = ConsoleFormatter()
formatter.update(
content=manifest_path.read_text(),
diagnoses=[
DiagnosisModel.model_validate(
{
"type": "failure",
"line": 5,
"column": 1,
"code": "T04",
"loc": ("spec",),
"msg": "Test failure message",
"fix": {
"entries": [
{"name": "entry1", "value": 123},
]
},
}
)
],
)

with io.StringIO() as buffer:
formatter.dump(buffer)
report = buffer.getvalue()

assert report == IsOutputEqual(
"""\
T04 Test failure message
@:5:1

3 | metadata:
4 | generateName: hello-world-
5 | spec:
| └ T04 at $.spec
6 | entrypoint: hello-world
7 | templates:

Test failure message

Do you mean:
entries:
- name: entry1
value: 123

"""
), f"Output:\n{report}"


class IsOutputEqual(DirtyEquals[str]):

def __init__(self, text: str):
text = textwrap.dedent(text).rstrip(" ")
super().__init__(text)
self.text = text
super().__init__(textwrap.dedent(text))

def equals(self, other):
return self.text == other
expected_content: str
(expected_content,) = self._repr_args
for expected_line, actual_line in zip(
expected_content.splitlines(),
other.splitlines(),
strict=True,
):
if expected_line.rstrip() != actual_line.rstrip():
return False
return True


class TestCalcHighlightRange:
Expand Down
2 changes: 1 addition & 1 deletion tests/engine/test_pydantic.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ def test_artifact_prohibited_value_field(self):
"Use 'raw' artifact type instead."
),
"input": "value",
"fix": '{"raw": {"data": "foobar"}}',
"fix": {"raw": {"data": "foobar"}},
}

def test_parameter_value_type_error_1(self):
Expand Down
38 changes: 37 additions & 1 deletion tugboat/console/formatters/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from dataclasses import dataclass

import click
import ruamel.yaml
from ruamel.yaml.scalarstring import LiteralScalarString

from tugboat.console.formatters.base import OutputFormatter
from tugboat.settings import settings
Expand Down Expand Up @@ -220,12 +222,31 @@ def suggestion(self) -> str:
buf.write(" ")
buf.write(Style.DoYouMean.fmt("Do you mean: "))

if "\n" in self.diagnosis.fix:
# case: structured suggestion
if isinstance(self.diagnosis.fix, dict):
buf.write("\n")

suggested_structure = transform_multiline_strings(self.diagnosis.fix)
with io.StringIO() as yaml_buf:
yaml_dumper = ruamel.yaml.YAML()
yaml_dumper.preserve_quotes = True
yaml_dumper.default_flow_style = False
yaml_dumper.dump(suggested_structure, yaml_buf)

yaml_buf.seek(0)
for line in yaml_buf:
buf.write(" ")
buf.write(Style.Suggestion.fmt(line))

# case: multi-line string
elif "\n" in self.diagnosis.fix:
buf.write("|-\n")
for line in self.diagnosis.fix.splitlines():
buf.write(" ")
buf.write(Style.Suggestion.fmt(line))
buf.write("\n")

# case: single-line string or other types
else:
buf.write(Style.Suggestion.fmt(self.diagnosis.fix))
buf.write("\n")
Expand Down Expand Up @@ -314,3 +335,18 @@ def calc_highlight_range(line: str, offset: int, substr: Any) -> tuple[int, int]

col_end = col_start + len(value)
return col_start, col_end


def transform_multiline_strings(obj):
"""
Ensure that multi-line strings in the object are represented as literal
scalars.
"""
if isinstance(obj, dict):
return {k: transform_multiline_strings(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [transform_multiline_strings(item) for item in obj]
elif isinstance(obj, str) and "\n" in obj:
return LiteralScalarString(obj)
else:
return obj
6 changes: 5 additions & 1 deletion tugboat/engine/pydantic.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,11 @@ def translate_pydantic_error(error: ErrorDetails) -> Diagnosis: # noqa: C901
}

with contextlib.suppress(Exception):
diagnosis["fix"] = json.dumps({"raw": {"data": error["input"]}})
diagnosis["fix"] = {
"raw": {
"data": error["input"],
},
}

return diagnosis

Expand Down
13 changes: 8 additions & 5 deletions tugboat/engine/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ class DiagnosisModel(BaseModel):
default="failure",
description=(
"Diagnostic severity reported by the analyzer.\n"
"* `error`: analysis stopped because processing could not continue.\n"
"* `failure`: definite rule violation that prevents success.\n"
"* `warning`: potential issue that should be reviewed.\n"
"- `error`: analysis stopped because processing could not continue.\n"
"- `failure`: definite rule violation that prevents success.\n"
"- `warning`: potential issue that should be reviewed.\n"
),
),
]
Expand Down Expand Up @@ -106,11 +106,14 @@ class DiagnosisModel(BaseModel):
]

fix: Annotated[
str | None,
str | dict[str, Any] | None,
Field(
default=None,
description=(
"Analyzer's suggested fix presented as plain text. Treat this as guidance, not a guaranteed solution."
"Suggested fix for the detected issue. This is guidance only, not a guaranteed solution.\n"
"- String: Exact replacement text for the problematic field value.\n"
"- Dictionary: Structured fix with multiple field modifications.\n"
"- None: No automatic fix available."
),
),
]
Expand Down
15 changes: 10 additions & 5 deletions tugboat/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ class Diagnosis(TypedDict):
The diagnosis type.
When not provided, it defaults to "failure".

* ``error`` indicates a critical issue that prevents the analyzer from running.
* ``failure`` indicates an issue that the analyzer has detected.
* ``warning`` indicates a potential issue that the analyzer has detected.
- ``error`` indicates a critical issue that prevents the analyzer from running.
- ``failure`` indicates an issue that the analyzer has detected.
- ``warning`` indicates a potential issue that the analyzer has detected.
This is not a critical issue, but it may require attention.
"""

Expand Down Expand Up @@ -75,8 +75,13 @@ class Diagnosis(TypedDict):
Use :py:class:`Field` to indicate a specific field rather than a value.
"""

fix: NotRequired[str | None]
"""Possible alternative value to fix the issue."""
fix: NotRequired[str | dict[str, Any] | None]
"""
Suggested fix for the detected issue.

- Provided as a string: Represents the exact replacement text for the problematic field value.
- Provided as a dictionary: Represents a structured fix containing multiple field modifications.
"""

ctx: NotRequired[dict[str, Bundle]]
"""
Expand Down