Skip to content

Commit b04e091

Browse files
authored
Merge pull request #112 from olehermanse/csv
cfengine lint: Added checking of yaml files
2 parents 6362264 + 28ad058 commit b04e091

6 files changed

Lines changed: 175 additions & 3 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ dependencies = [
1515
"tree-sitter-cfengine>=1.1.12",
1616
"tree-sitter>=0.25",
1717
"markdown-it-py>=3.0.0",
18+
"PyYAML>=6.0.3",
1819
]
1920
classifiers = [
2021
"Development Status :: 3 - Alpha",

‎src/cfengine_cli/lint.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- cfbs.json (CFEngine Build project files)
77
- *.json (basic JSON syntax checking)
88
- *.csv (basic CSV syntax + RFC 4180 CRLF record terminator check)
9+
- *.yml / *.yaml (basic YAML syntax checking)
910
1011
This is performed in 3 steps:
1112
1. Parsing - Read the .cf files and convert them into syntax trees
@@ -40,9 +41,10 @@
4041
from cfbs.cfbs_config import CFBSConfig
4142
from cfbs.utils import find
4243
from cfengine_cli.lint_csv import check_csv_file
44+
from cfengine_cli.lint_yml import check_yml_file
4345
from cfengine_cli.utils import UserError
4446

45-
LINT_EXTENSIONS = (".cf", ".cf.sub", ".json", ".csv")
47+
LINT_EXTENSIONS = (".cf", ".cf.sub", ".json", ".csv", ".yml", ".yaml")
4648
DEFAULT_NAMESPACE = "default"
4749
VARS_TYPES = {
4850
"data",
@@ -1196,6 +1198,9 @@ def _lint_main(
11961198
if filename.endswith(".csv"):
11971199
errors += _lint_csv(filename)
11981200
continue
1201+
if filename.endswith((".yml", ".yaml")):
1202+
errors += _lint_yml(filename)
1203+
continue
11991204
assert filename.endswith((".cf", ".cf.sub"))
12001205
policy_file = PolicyFile(filename, snippet)
12011206
r = _check_syntax(policy_file, state)
@@ -1346,6 +1351,19 @@ def _lint_csv(filename: str) -> int:
13461351
return r
13471352

13481353

1354+
def _lint_yml(filename: str) -> int:
1355+
"""Lint a YAML file: check that it parses and contains at least one
1356+
non-null document."""
1357+
assert os.path.isfile(filename)
1358+
problem = check_yml_file(filename)
1359+
r = 0
1360+
if problem is not None:
1361+
print(f"{filename}: {problem}")
1362+
r = 1
1363+
print(_pass_fail_filename(filename, r))
1364+
return r
1365+
1366+
13491367
# ---------------------------------------------------------------------------
13501368
# Syntax error detection (used by both linter and formatter)
13511369
# ---------------------------------------------------------------------------

‎src/cfengine_cli/lint_yml.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""YAML file validation.
2+
3+
Checks that a YAML file parses successfully (per the PyYAML safe loader)
4+
and is not empty. Null documents (e.g. a bare `---`) are accepted.
5+
"""
6+
7+
import yaml
8+
9+
10+
def check_yml_file(filename: str) -> str | None:
11+
"""Check a YAML file: parses, and is not empty.
12+
13+
Null documents (a bare document marker with no content) are accepted;
14+
only files where PyYAML produces no documents at all are rejected.
15+
16+
Returns None if valid, otherwise a short description of the problem.
17+
"""
18+
try:
19+
with open(filename, "r") as f:
20+
documents = list(yaml.safe_load_all(f))
21+
except (OSError, yaml.YAMLError) as e:
22+
return str(e)
23+
if not documents:
24+
return "empty file"
25+
return None

‎tests/shell/005-lint.sh‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ printf "" > "$tmpdir/empty.csv"
1717
# Empty policy file
1818
printf "" > "$tmpdir/empty.cf"
1919

20+
# Empty YAML file
21+
printf "" > "$tmpdir/empty.yml"
22+
2023
# CSV with LF-only line endings
2124
printf 'a,b,c\n1,2,3\n' > "$tmpdir/bad.csv"
2225

@@ -26,6 +29,9 @@ printf 'abc\n' > "$tmpdir/bad.json"
2629
# Policy file with just some characters
2730
printf 'abc\n' > "$tmpdir/bad.cf"
2831

32+
# YAML with unclosed flow sequence
33+
printf 'items: [1, 2, 3\n' > "$tmpdir/bad.yaml"
34+
2935
# Run lint on the folder - expect non-zero exit
3036
if cfengine lint "$tmpdir" > "$output_file" 2>&1; then
3137
cat "$output_file"
@@ -38,9 +44,11 @@ cat "$output_file"
3844
grep -q "FAIL:.*empty.json" "$output_file"
3945
grep -q "FAIL:.*empty.csv" "$output_file"
4046
grep -q "FAIL:.*empty.cf" "$output_file"
47+
grep -q "FAIL:.*empty.yml" "$output_file"
4148
grep -q "FAIL:.*bad.csv" "$output_file"
4249
grep -q "FAIL:.*bad.json" "$output_file"
4350
grep -q "FAIL:.*bad.cf" "$output_file"
51+
grep -q "FAIL:.*bad.yaml" "$output_file"
4452

45-
# Verify total error count is 6
46-
grep -q "Failure, 6 errors in total" "$output_file"
53+
# Verify total error count is 8
54+
grep -q "Failure, 8 errors in total" "$output_file"

‎tests/unit/test_lint_yml.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import os
2+
import tempfile
3+
4+
from cfengine_cli.lint_yml import check_yml_file
5+
6+
7+
def _write_temp_yml(content: bytes) -> str:
8+
fd, path = tempfile.mkstemp(suffix=".yml")
9+
with os.fdopen(fd, "wb") as f:
10+
f.write(content)
11+
return path
12+
13+
14+
VALID = [
15+
("simple_mapping", b"key: value\n"),
16+
("nested_mapping", b"top:\n nested: value\n list:\n - 1\n - 2\n"),
17+
("flow_sequence", b"items: [1, 2, 3]\n"),
18+
("flow_mapping", b"obj: {a: 1, b: 2}\n"),
19+
("multi_document", b"---\na: 1\n---\nb: 2\n"),
20+
("scalar_only", b"42\n"),
21+
("string_only", b'"hello"\n'),
22+
("list_only", b"- one\n- two\n"),
23+
("multi_doc_first_null", b"---\n---\nname: cfengine\n"),
24+
("only_null_document", b"---\n"),
25+
("multiple_null_documents", b"---\n---\n---\n"),
26+
]
27+
28+
INVALID = [
29+
("empty_file", b""),
30+
("only_whitespace", b" \n\n"),
31+
("unclosed_flow_sequence", b"items: [1, 2, 3\n"),
32+
("unclosed_flow_mapping", b"obj: {a: 1, b: 2\n"),
33+
("bad_indentation", b"top:\n nested: value\n bad: value\n"),
34+
("tab_indentation", b"top:\n\tnested: value\n"),
35+
("duplicate_key_via_alias", b"a: &x 1\nb: *y\n"),
36+
]
37+
38+
39+
def test_check_yml_file_accepts_valid():
40+
for name, content in VALID:
41+
path = _write_temp_yml(content)
42+
try:
43+
assert check_yml_file(path) is None, f"Expected valid: {name}"
44+
finally:
45+
os.unlink(path)
46+
47+
48+
def test_check_yml_file_rejects_invalid():
49+
for name, content in INVALID:
50+
path = _write_temp_yml(content)
51+
try:
52+
assert check_yml_file(path) is not None, f"Expected invalid: {name}"
53+
finally:
54+
os.unlink(path)

0 commit comments

Comments
 (0)