Skip to content

Commit

Permalink
Quote jobs.*.containers.volumes entries in GitHub Actions workflow yamls
Browse files Browse the repository at this point in the history
  • Loading branch information
lalten committed Aug 5, 2024
1 parent 0b60ee2 commit 238c47d
Show file tree
Hide file tree
Showing 3 changed files with 54 additions and 151 deletions.
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ variable, use `fix_code`:
- item
- item
```
- Quote strings that contain `:` to prevent some parsers from misinterpreting them as mappings.
- Quote jobs.*.containers.volumes entries in GitHub Actions workflow yamls to not confuse GitHubs Yaml parser.

# Configuration

Expand Down
102 changes: 17 additions & 85 deletions src/yamlfix/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

import logging
import re
from collections.abc import Mapping
from functools import partial
from io import StringIO
from typing import Any, Callable, List, Match, NamedTuple, Optional, Tuple
from typing import Any, Callable, List, Match, Optional, Tuple

from ruyaml.comments import CommentedMap, CommentedSeq
from ruyaml.main import YAML
from ruyaml.nodes import MappingNode, Node, ScalarNode, SequenceNode
from ruyaml.representer import RoundTripRepresenter
from ruyaml.scalarstring import DoubleQuotedScalarString, SingleQuotedScalarString
from ruyaml.tokens import CommentToken

from yamlfix.model import YamlfixConfig, YamlNodeStyle
Expand Down Expand Up @@ -351,7 +351,6 @@ def fix(self, source_code: str) -> str:
self._fix_truthy_strings,
self._fix_jinja_variables,
self._ruamel_yaml_fixer,
self._quote_strings_with_colons,
self._restore_truthy_strings,
self._restore_jinja_variables,
self._restore_double_exclamations,
Expand Down Expand Up @@ -382,12 +381,27 @@ def _ruamel_yaml_fixer(self, source_code: str) -> str:
# Return the output to a string
string_stream = StringIO()
for source_dict in source_dicts:
self._quote_gha_container_volumes(source_dict)
self.yaml.dump(source_dict, string_stream)
source_code = string_stream.getvalue()
string_stream.close()

return source_code.strip()

def _quote_gha_container_volumes(
self, source_dict: CommentedMap | CommentedSeq
) -> None:
"""Quote jobs.*.container.volumes entries."""
if not isinstance(source_dict, CommentedMap):
return
scalar_type = SingleQuotedScalarString
if self.config.quote_representation == '"':
scalar_type = DoubleQuotedScalarString
for job in source_dict.get("jobs", {}).values():
if volumes := job.get("container", {}).get("volumes", []):
for i, _ in enumerate(volumes):
volumes[i] = scalar_type(volumes[i])

@staticmethod
def _fix_top_level_lists(source_code: str) -> str:
"""Deindent the source with a top level list.
Expand Down Expand Up @@ -798,85 +812,3 @@ def _restore_jinja_variables(source_code: str) -> str:
fixed_source_lines.append(line)

return "\n".join(fixed_source_lines)

def _quote_strings_with_colons(self, source_code: str) -> str:
"""Fix strings with colons to be quoted.
Example:
volumes: [/root:/mapped]
becomes
volumes: ["/root:/mapped"]
We do this by
1. loading the yaml
2. recursively scanning for strings that
* contain colons
* are not already quoted
* are not multi-line strings (see note).
3. Adding quotes at the string start and end locations in the source_code.
Note: Multi-line strings are not supported because ruyaml only provides the
start location of a scalar, but not the end location. For single-line strings
you can calculate the end location by adding the length of the string to the
start, but for strings broken over multiple lines this is not straightforward.
"""
log.debug("Fixing unquoted strings with colons...")

class ToFix(NamedTuple):
"""Where to insert quotes."""

line: int
start: int
end: int

positions_to_quote = set()
lines = source_code.splitlines()

def add(item: str, line: int, col: int) -> None:
is_quoted = lines[line][col] in ['"', "'"]
if ":" in item and not is_quoted:
to_fix = ToFix(
line=line,
start=col,
end=col + len(item),
)
if to_fix.end <= len(lines[to_fix.line]):
positions_to_quote.add(to_fix)
else:
log.debug("Skipping %r which is multi-line", item)

def check(value: CommentedSeq | Mapping[str, Any]) -> None:
if isinstance(value, CommentedSeq):
for i, item in enumerate(value):
if isinstance(item, str):
line, col = value.lc.item(i)
add(item, line, col)
else:
check(item)
elif isinstance(value, CommentedMap):
for key, item in value.items():
if isinstance(item, str):
try:
line, col = value.lc.value(key)
except (KeyError, TypeError):
# May not be available if merged from an anchor
pass
else:
add(item, line, col)
else:
check(item)

source_dicts = self.yaml.load_all(source_code)
for source_dict in source_dicts:
check(source_dict)

for to_fix in sorted(positions_to_quote, reverse=True):
lines[to_fix.line] = (self.config.quote_representation or "'").join(
[
lines[to_fix.line][: to_fix.start], # noqa: E203: black disagrees
lines[to_fix.line][to_fix.start : to_fix.end], # noqa: E203
lines[to_fix.line][to_fix.end :], # noqa: E203
]
)

return "\n".join(lines)
101 changes: 36 additions & 65 deletions tests/unit/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,6 @@ def test_fix_code_functions_emit_debug_logs(
"Fixing comments...",
"Fixing top level lists...",
"Fixing flow-style lists...",
"Fixing unquoted strings with colons...",
}
assert set(caplog.messages) == expected_logs
for record in caplog.records:
Expand Down Expand Up @@ -1040,88 +1039,60 @@ def test_fix_code_fix_whitelines(
("source", "config", "desired_source"),
[
(
"volumes: [/root:/mapped, a:b, 'c:d']",
YamlfixConfig(sequence_style=YamlNodeStyle.FLOW_STYLE),
dedent(
"""\
---
volumes: ['/root:/mapped', 'a:b', 'c:d']
"""
),
),
(
dedent(
"""\
volumes:
- /root:/mapped
- a:b
- 'c:d'
jobs:
test:
container:
volumes:
- /data:/data
- a:b # commented
- 'c:d'
- >-
multi:
line
"""
),
YamlfixConfig(sequence_style=YamlNodeStyle.BLOCK_STYLE),
YamlfixConfig(sequence_style=YamlNodeStyle.FLOW_STYLE),
dedent(
"""\
---
volumes:
- '/root:/mapped'
- 'a:b'
- 'c:d'
jobs:
test:
container:
volumes:
- '/data:/data'
- 'a:b' # commented
- 'c:d'
- 'multi: line'
"""
),
),
(
dedent(
"""\
test:
- "this one:\
is ok"
- fix this:one
- |
multiline strings:
are not supported yet
- >-
multiline strings:
are not supported yet
"""
),
YamlfixConfig(sequence_style=YamlNodeStyle.BLOCK_STYLE),
dedent(
"""\
---
test:
- 'this one:\
is ok'
- 'fix this:one'
- |
multiline strings:
are not supported yet
- >-
multiline strings:
are not supported yet
"""
),
),
(
dedent(
"""\
merge0: &anchor
host: host.docker.internal:host-gateway
merge1:
<<: *anchor
merge2:
<<: *anchor
jobs:
test2:
container:
volumes:
- /data:/data
- a:b
- 'c:d'
- >-
multi:
line
"""
),
None,
YamlfixConfig(sequence_style=YamlNodeStyle.FLOW_STYLE),
dedent(
"""\
---
merge0: &anchor
host: 'host.docker.internal:host-gateway'
merge1:
<<: *anchor
merge2:
<<: *anchor
jobs:
test2:
container:
volumes: ['/data:/data', 'a:b', 'c:d', 'multi: line']
"""
),
),
Expand All @@ -1131,9 +1102,9 @@ def test_strings_with_colons_are_quoted(
self, source: str, config: Optional[YamlfixConfig], desired_source: str
) -> None:
"""
Given: Code with a string containing `:`
Given: A GitHub Action workflow yaml containing jobs.*.containers.volumes
When: fix_code is run
Then: The string is quoted and not turned into a mapping
Then: The volumes entries are quoted
"""
result = fix_code(source, config=config)

Expand Down

0 comments on commit 238c47d

Please sign in to comment.