Skip to content

Commit 5f03328

Browse files
authored
Merge pull request #10 from cdisc-org/changes
bring training up-to-date with prod
2 parents df03e14 + b04c7c5 commit 5f03328

11 files changed

Lines changed: 408 additions & 12 deletions

File tree

.github/scripts/run_validation.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ fi
3333
echo "Rule file: $RULE_YML"
3434

3535
# ---------------------------------------------------------------------------
36-
# Initialise report
36+
# Initialize report
3737
# ---------------------------------------------------------------------------
3838
{
3939
echo "# Rule Validation Report"
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
name: Validate YAML Formatting
2+
on:
3+
pull_request:
4+
paths:
5+
- 'Unpublished/**/rule.yml'
6+
- 'Published/**/rule.yml'
7+
types: [opened, synchronize, reopened]
8+
workflow_dispatch: {}
9+
jobs:
10+
check-yaml-format:
11+
runs-on: ubuntu-latest
12+
permissions:
13+
contents: read
14+
pull-requests: write
15+
steps:
16+
- name: Checkout
17+
uses: actions/checkout@v4
18+
with:
19+
fetch-depth: 0
20+
- name: Set up Python 3.12
21+
uses: actions/setup-python@v5
22+
with:
23+
python-version: '3.12'
24+
- name: Install pyyaml
25+
run: pip install pyyaml
26+
- name: Detect changed rule.yml files
27+
id: changed-files
28+
run: |
29+
if [ "${{ github.event_name }}" = "pull_request" ]; then
30+
FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD \
31+
| grep -E '^(Published|Unpublished)/.*/rule\.yml$' || true)
32+
else
33+
FILES=$(git diff --name-only HEAD~1 HEAD \
34+
| grep -E '^(Published|Unpublished)/.*/rule\.yml$' || true)
35+
fi
36+
if [ -z "$FILES" ]; then
37+
echo "No rule.yml files changed."
38+
echo "has_files=false" >> $GITHUB_OUTPUT
39+
else
40+
echo "has_files=true" >> $GITHUB_OUTPUT
41+
echo "$FILES" > /tmp/changed_rule_files.txt
42+
fi
43+
- name: Check YAML sorting and formatting
44+
id: format-check
45+
if: steps.changed-files.outputs.has_files == 'true'
46+
run: |
47+
FILES=$(cat /tmp/changed_rule_files.txt | tr '\n' ' ')
48+
python scripts/sort_yaml.py --check $FILES
49+
continue-on-error: true
50+
- name: Post format check result to PR
51+
if: always() && github.event_name == 'pull_request' && steps.changed-files.outputs.has_files == 'true'
52+
uses: actions/github-script@v7
53+
with:
54+
github-token: ${{ secrets.GITHUB_TOKEN }}
55+
script: |
56+
const outcome = '${{ steps.format-check.outcome }}';
57+
const marker = '<!-- yaml-format-check -->';
58+
let body = marker + '\n';
59+
if (outcome === 'success') {
60+
body += '## \u2705 YAML Format Check Passed\n\nAll changed `rule.yml` files are correctly sorted and formatted.';
61+
} else {
62+
body += '## \u274c YAML Format Check Failed\n\n';
63+
body += 'One or more `rule.yml` files are not correctly sorted/formatted alphabetically by key.\n\n';
64+
body += 'Run the following command locally to fix them:\n\n```bash\npython scripts/sort_yaml.py\n```\n\nThen commit and push.';
65+
}
66+
const { data: comments } = await github.rest.issues.listComments({
67+
owner: context.repo.owner, repo: context.repo.repo,
68+
issue_number: context.issue.number,
69+
});
70+
const existing = comments.find(c => c.user.type === 'Bot' && c.body.includes(marker));
71+
if (existing) {
72+
await github.rest.issues.updateComment({
73+
owner: context.repo.owner, repo: context.repo.repo,
74+
comment_id: existing.id, body,
75+
});
76+
} else {
77+
await github.rest.issues.createComment({
78+
owner: context.repo.owner, repo: context.repo.repo,
79+
issue_number: context.issue.number, body,
80+
});
81+
}
82+
- name: Fail if format check failed
83+
if: steps.format-check.outcome == 'failure'
84+
run: |
85+
echo "YAML format check failed. Run 'python scripts/sort_yaml.py' to fix."
86+
exit 1

.pre-commit-config.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
repos:
2+
- repo: local
3+
hooks:
4+
- id: sort-yaml-rules
5+
name: Sort and format rule YAML files
6+
language: python
7+
entry: python scripts/sort_yaml.py --check
8+
types: [yaml]
9+
files: ".*/rule\\.yml$"
10+
additional_dependencies: [pyyaml]
11+
pass_filenames: true

.vscode/extensions.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"recommendations": [
33
"redhat.vscode-yaml",
44
"GrapeCity.gc-excelviewer",
5-
"mechatroner.rainbow-csv"
5+
"mechatroner.rainbow-csv",
6+
"emeraldwalk.runonsave"
67
]
78
}

.vscode/settings.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
{
22
"yaml.schemas": {
33
"https://raw.githubusercontent.com/cdisc-org/cdisc-rules-engine/refs/heads/main/resources/schema/rule-merged/CORE-base.json": "rule.yml"
4+
},
5+
"emeraldwalk.runonsave": {
6+
"commands": [
7+
{
8+
"match": "rule\\.yml$",
9+
"isAsync": false,
10+
"cmd": "${workspaceFolder}/venv/bin/python ${workspaceFolder}/scripts/sort_yaml.py ${file}"
11+
}
12+
]
413
}
514
}

.vscode/tasks.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"version": "2.0.0",
3+
"tasks": [
4+
{
5+
"label": "Sort & Format rule.yml",
6+
"type": "shell",
7+
"command": "${workspaceFolder}/venv/bin/python",
8+
"args": ["${workspaceFolder}/scripts/sort_yaml.py", "${file}"],
9+
"presentation": {
10+
"reveal": "silent",
11+
"panel": "shared"
12+
},
13+
"problemMatcher": []
14+
},
15+
{
16+
"label": "Sort & Format ALL rule.yml files",
17+
"type": "shell",
18+
"command": "${workspaceFolder}/venv/bin/python",
19+
"args": ["${workspaceFolder}/scripts/sort_yaml.py"],
20+
"presentation": {
21+
"reveal": "always",
22+
"panel": "shared"
23+
},
24+
"problemMatcher": []
25+
}
26+
]
27+
}
28+

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ Unpublished/
223223
MAC: `./run/bash_run.sh`
224224
- If you haven't run the setup script before, don't worry; it will run automatically when you execute this command.
225225
- You will be prompted to select the rule you wish to run, as well as the test case(s).
226+
- You will also be prompted with what logs you would like to see and if you want them captured in a txt file for each test case (these are useful attachments to issues created for CORE Rules Engine Bug Reports)
226227

227228
**Verify Results.**
228229

scripts/sort_yaml.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Sort and format rule YAML files alphabetically and recursively by key name.
4+
5+
This matches the auto-format/auto-sort behavior of the CDISC conformance rules editor.
6+
7+
Usage:
8+
# Format files in-place (default: all rule.yml under Published/ and Unpublished/)
9+
python scripts/sort_yaml.py
10+
11+
# Format specific files
12+
python scripts/sort_yaml.py path/to/rule.yml another/rule.yml
13+
14+
# Check mode: exit with code 1 if any file is not formatted correctly
15+
python scripts/sort_yaml.py --check [files...]
16+
"""
17+
18+
import sys
19+
import argparse
20+
from pathlib import Path
21+
22+
try:
23+
import yaml
24+
except ImportError:
25+
print("ERROR: pyyaml is not installed. Run: pip install pyyaml", file=sys.stderr)
26+
sys.exit(1)
27+
28+
29+
# ---------------------------------------------------------------------------
30+
# Custom YAML Dumper
31+
# ---------------------------------------------------------------------------
32+
33+
class _SortedDumper(yaml.Dumper):
34+
"""YAML Dumper that produces consistent, human-readable output."""
35+
pass
36+
37+
38+
def _str_representer(dumper: yaml.Dumper, data: str):
39+
"""Represent strings: use literal block style for multi-line, plain otherwise.
40+
Strings that look like YAML scalars (booleans, numbers) are quoted.
41+
"""
42+
if "\n" in data:
43+
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
44+
return dumper.represent_scalar("tag:yaml.org,2002:str", data)
45+
46+
47+
_SortedDumper.add_representer(str, _str_representer)
48+
49+
50+
# ---------------------------------------------------------------------------
51+
# Core logic
52+
# ---------------------------------------------------------------------------
53+
54+
def sort_recursive(obj):
55+
"""Recursively sort dict keys alphabetically. Lists are preserved as-is."""
56+
if isinstance(obj, dict):
57+
return {k: sort_recursive(obj[k]) for k in sorted(obj.keys(), key=str)}
58+
if isinstance(obj, list):
59+
return [sort_recursive(item) for item in obj]
60+
return obj
61+
62+
63+
def canonical(content: str) -> str:
64+
"""Return the canonical (sorted + formatted) representation of a YAML string."""
65+
data = yaml.safe_load(content)
66+
if data is None:
67+
return content
68+
sorted_data = sort_recursive(data)
69+
return yaml.dump(
70+
sorted_data,
71+
Dumper=_SortedDumper,
72+
default_flow_style=False,
73+
allow_unicode=True,
74+
indent=2,
75+
sort_keys=False, # we already sorted manually
76+
width=100,
77+
)
78+
79+
80+
def find_rule_files(root: Path) -> list[Path]:
81+
"""Find all rule.yml files under Published/ and Unpublished/."""
82+
files = []
83+
for folder in ("Published", "Unpublished"):
84+
folder_path = root / folder
85+
if folder_path.exists():
86+
files.extend(folder_path.rglob("rule.yml"))
87+
return sorted(files)
88+
89+
90+
def process_files(files: list[Path], check_mode: bool) -> int:
91+
"""Format (or check) the given files. Returns exit code."""
92+
changed = []
93+
errors = []
94+
95+
for path in files:
96+
try:
97+
original = path.read_text(encoding="utf-8")
98+
formatted = canonical(original)
99+
except Exception as exc:
100+
errors.append(f" {path}: {exc}")
101+
continue
102+
103+
if original != formatted:
104+
changed.append(path)
105+
if not check_mode:
106+
path.write_text(formatted, encoding="utf-8")
107+
print(f" Formatted: {path}")
108+
109+
if errors:
110+
print("\nERROR: Failed to process the following files:", file=sys.stderr)
111+
for e in errors:
112+
print(e, file=sys.stderr)
113+
return 1
114+
115+
if check_mode:
116+
if changed:
117+
print(
118+
"\nThe following rule.yml files are not correctly sorted/formatted:\n",
119+
file=sys.stderr,
120+
)
121+
for p in changed:
122+
print(f" {p}", file=sys.stderr)
123+
print(
124+
"\nRun `python scripts/sort_yaml.py` to fix them automatically.",
125+
file=sys.stderr,
126+
)
127+
return 1
128+
else:
129+
print("All rule.yml files are correctly sorted and formatted.")
130+
else:
131+
if not changed:
132+
print("All rule.yml files are already correctly sorted and formatted.")
133+
134+
return 0
135+
136+
137+
# ---------------------------------------------------------------------------
138+
# Entry point
139+
# ---------------------------------------------------------------------------
140+
141+
def main():
142+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
143+
parser.add_argument(
144+
"--check",
145+
action="store_true",
146+
help="Check mode: exit 1 if any file needs formatting, without modifying files.",
147+
)
148+
parser.add_argument(
149+
"files",
150+
nargs="*",
151+
type=Path,
152+
help="rule.yml files to process. Defaults to all rule.yml files under Published/ and Unpublished/.",
153+
)
154+
args = parser.parse_args()
155+
156+
repo_root = Path(__file__).resolve().parent.parent
157+
158+
if args.files:
159+
files = [p.resolve() for p in args.files]
160+
else:
161+
files = find_rule_files(repo_root)
162+
163+
if not files:
164+
print("No rule.yml files found.")
165+
return 0
166+
167+
mode = "Checking" if args.check else "Formatting"
168+
print(f"{mode} {len(files)} rule.yml file(s)...")
169+
170+
sys.exit(process_files(files, check_mode=args.check))
171+
172+
173+
if __name__ == "__main__":
174+
main()
175+
176+

setup/bash_setup.sh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,4 +145,15 @@ fi
145145

146146
VENV_PYTHON=$(which python)
147147

148+
echo "Installing pre-commit..."
149+
pip install pre-commit --index-url https://pypi.org/simple/ --quiet 2>/dev/null || \
150+
pip install pre-commit --quiet 2>/dev/null || true
151+
if command -v pre-commit >/dev/null 2>&1; then
152+
pre-commit install
153+
echo "Pre-commit hook installed."
154+
else
155+
echo "Warning: pre-commit not found on PATH after install; skipping hook setup."
156+
echo "You can install it manually with: pip install pre-commit && pre-commit install"
157+
fi
158+
148159
echo "Setup completed successfully!"

setup/windows_setup.bat

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,18 @@ if !errorlevel! neq 0 (
177177
exit /b 1
178178
)
179179

180+
echo.
181+
echo Installing pre-commit...
182+
python -m pip install pre-commit --index-url https://pypi.org/simple/ --quiet 2>nul || python -m pip install pre-commit --quiet 2>nul
183+
where pre-commit >nul 2>&1
184+
if !errorlevel! equ 0 (
185+
pre-commit install
186+
echo Pre-commit hook installed.
187+
) else (
188+
echo Warning: pre-commit not found on PATH; skipping hook setup.
189+
echo You can install it manually with: pip install pre-commit ^&^& pre-commit install
190+
)
191+
180192
echo.
181193
echo Setup completed successfully!
182194
pause

0 commit comments

Comments
 (0)