Skip to content
Open
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
18 changes: 18 additions & 0 deletions .github/actions/timed-check/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Timed Python Check
inputs:
script:
required: true
display:
required: true
runs:
using: composite
steps:
- shell: bash
run: |
mkdir -p "$RUNNER_TEMP/check_results"
s=$(date +%s%N)
python "${{ inputs.script }}" && outcome=success || outcome=failure
time=$(awk "BEGIN{printf \"%.3f\",($(date +%s%N)-$s)/1e9}")
printf '{"outcome":"%s","time":"%s"}' "$outcome" "$time" \
> "$RUNNER_TEMP/check_results/${{ inputs.display }}.json"
[ "$outcome" = "success" ]
83 changes: 83 additions & 0 deletions .github/checks/check_eps_savefig.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
import os
import re
import sys

def IsExcludedPath(path, excluded):
norm = os.path.normpath(path)
return any(norm == e or norm.startswith(e + os.sep) for e in excluded)

def IsHidden(name):
return name.startswith(".")

def IsIgnoredDir(name):
return IsHidden(name) or name == "__pycache__"

def ReadFile(path):
try:
with open(path, "r", encoding="utf-8") as fh:
return fh.read()
except Exception:
return None

def WalkFiles(root, excluded, extension):
for dir_path, dir_names, file_names in os.walk(root):
if IsExcludedPath(dir_path, excluded):
dir_names[:] = []
continue

dir_names[:] = [d for d in dir_names if not IsIgnoredDir(d)]

for file_name in file_names:
if file_name.endswith(extension) and not IsHidden(file_name):
yield os.path.join(dir_path, file_name)

def CheckEpsSavefig(content):
eps_savefig_patterns = [
r'\.savefig\([^)]*[\'"].*eps.*[\'"][^)]*\)',
r'\.savefig\([^)]*format\s*=\s*[\'"]eps[\'"][^)]*\)'
]

eps_lines = []
for line_num, line in enumerate(content.split('\n'), 1):
for pattern in eps_savefig_patterns:
if re.search(pattern, line, re.IGNORECASE):
eps_lines.append(f"Line {line_num}: {line.strip()}")
break

remove_count = content.count('remove_eps_info(')
return eps_lines if len(eps_lines) != remove_count else []

def CollectEpsProblems(root, excluded):
problems = []
for file_path in WalkFiles(root, excluded, '.py'):
content = ReadFile(file_path)
if content is None:
continue
file_problems = CheckEpsSavefig(content)
if file_problems:
problems.append({'file': file_path, 'issues': file_problems})
return problems

def Main():
source_root = "source"
excluded = ["source/lib", "source/raw", "source/scrape"]

problems = CollectEpsProblems(source_root, excluded)

if problems:
print("EPS savefig check failed!")
print("\nPython files using .savefig(*eps*) without remove_eps_info():")
for problem in problems:
print(f"\nFile: {problem['file']}")
for issue in problem['issues']:
print(f" {issue}")
print("\nTo fix: Add 'from source.lib.JMSLab.remove_eps_info import remove_eps_info'")
print("and call 'remove_eps_info(filename)' after each EPS savefig.")
return 1
else:
print("EPS savefig check: all checks passed.")
return 0

if __name__ == "__main__":
sys.exit(Main())
38 changes: 38 additions & 0 deletions .github/checks/check_newlines.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
import subprocess
import sys
from pathlib import Path

EXCLUDED_EXT = {".log", ".txt"}

INCLUDED_DIRS = {"source", "output"}

def GetTrackedFiles():
return [Path(p) for p in subprocess.run(["git","ls-files"], stdout=subprocess.PIPE, check=True).stdout.decode().splitlines() if p.strip() and Path(p).parts[0] in INCLUDED_DIRS]

def IsBinary(p):
try:
return b"\0" in p.read_bytes()[:4096]
except Exception:
return True

def NeedsNewline(p):
try:
d = p.read_bytes()
return len(d)==0 or not d.endswith(b"\n")
except Exception:
return False

def ProcessFiles():
missing = [p for p in GetTrackedFiles() if p.exists() and p.suffix not in EXCLUDED_EXT and not IsBinary(p) and NeedsNewline(p)]
if not missing:
print("No files missing trailing newlines."); return 0
print("Files missing trailing newline:")
for p in missing: print(" -", p)
return 1

def Main():
return ProcessFiles()

if __name__=="__main__":
sys.exit(Main())
27 changes: 27 additions & 0 deletions .github/checks/check_sconscript_log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
import sys
from pathlib import Path

TARGET = "terminated because of errors."

def Main():
bad = []
for p in Path(".").rglob("*.log"):
try:
if TARGET in p.read_text(errors="replace"):
bad.append(p)
except Exception:
pass

if not bad:
print("No log files contain the error string.")
return 0

print("Problematic log files:")
for p in bad:
print(" -", p)

return 1

if __name__ == "__main__":
sys.exit(Main())
113 changes: 113 additions & 0 deletions .github/checks/check_sconscripts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
import os
import re
import sys
from pathlib import Path

ROOT = Path("source")
EXCLUDED = {Path("source/lib"), Path("source/raw"), Path("source/scrape")}
PAPER_DIR = Path("source/paper")
PAPER_EXTS = {".bib", ".tex", ".lyx"}


def Main():
missing_dirs, missing_mentions = CollectProblems()
missing_in_sconstruct = SourceFoldersMissingInSConstruct()
any_problems = bool(missing_dirs or missing_mentions or missing_in_sconstruct)
if any_problems:
print("SConscript/SConstruct summary of missing items:")
if missing_dirs:
print("\nFolders missing SConscript:")
for p in missing_dirs:
print(p)
if missing_mentions:
print("\nFiles not mentioned in their SConscript:")
for p in missing_mentions:
print(p)
if missing_in_sconstruct:
print("\nTop-level source folders missing from root SConstruct:")
for p in missing_in_sconstruct:
print(p)
else:
print("SConscript/SConstruct summary: all checks passed.")
return 1 if any_problems else 0


def CollectProblems():
missing_dirs = []
missing_mentions = []
for dir_path, dir_names, file_names in os.walk(ROOT):
dir_path = Path(dir_path)
if IsExcluded(dir_path):
dir_names[:] = []
continue
dir_names[:] = sorted(d for d in dir_names if not IsIgnored(d))
file_names = [f for f in file_names if not IsIgnored(f)]
if dir_path == ROOT or (not dir_names and not file_names):
continue
content = Read(dir_path / "SConscript")
if content is None:
parent_content = Read(dir_path.parent / "SConscript")
if parent_content is None:
missing_dirs.append(dir_path)
continue
for f in sorted(f for f in file_names if f != "SConscript"):
if ShouldCheck(dir_path, f) and not IsMentioned(content, f, dir_path):
missing_mentions.append(f"{dir_path} -> {f}")
for subdir in dir_names:
if re.search(rf"\b{re.escape(subdir)}\b", content):
continue
subdir_path = dir_path / subdir
try:
subfiles = sorted(
e for e in os.listdir(subdir_path)
if not IsIgnored(e) and (subdir_path / e).is_file() and ShouldCheck(subdir_path, e)
)
except Exception:
missing_dirs.append(subdir_path)
continue
for f in subfiles:
if not IsMentioned(content, f, subdir_path):
missing_mentions.append(f"{dir_path} -> {subdir}/{f}")
return missing_dirs, missing_mentions


def IsExcluded(dir_path):
return any(dir_path == e or dir_path.is_relative_to(e) for e in EXCLUDED)


def IsIgnored(name):
return name.startswith(".") or name == "__pycache__"


def ShouldCheck(dir_path, name):
if dir_path.is_relative_to(PAPER_DIR):
return Path(name).suffix in PAPER_EXTS
return True


def Read(path):
try:
return Path(path).read_text(encoding="utf-8")
except Exception:
return None


def IsMentioned(content, name, dir_path):
rel = dir_path.relative_to(ROOT).as_posix()
return f"#source/{rel}/{name}" in content


def SourceFoldersMissingInSConstruct():
content = Read("SConstruct")
folders = sorted(
e for e in os.listdir(ROOT)
if (ROOT / e).is_dir() and not IsIgnored(e) and not IsExcluded(ROOT / e)
)
if content is None:
return folders
return [f for f in folders if f"source/{f}/SConscript" not in content]


if __name__ == "__main__":
sys.exit(Main())
6 changes: 6 additions & 0 deletions .github/checks/checks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[
{"name": "SCons DAG", "command": "/run-actions-dag"},
{"name": "Newlines", "command": "/run-actions-newlines"},
{"name": "EPS data", "command": "/run-actions-eps"},
{"name": "Build log", "command": "/run-actions-log"}
]
24 changes: 24 additions & 0 deletions .github/helper_scripts/parse_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env python3
import json
import os
import sys
from pathlib import Path

def LoadChecks():
checks_path = Path(__file__).parent.parent / 'checks' / 'checks.json'
return json.loads(checks_path.read_text())

def ParseCommands():
checks = LoadChecks()
comment = os.environ.get('COMMENT_BODY', '')
return [c['name'] for c in checks
if os.environ.get('GITHUB_EVENT_NAME') == 'push'
or '/run-actions-all' in comment
or c.get('command', '') in comment]

def Main():
print(json.dumps(ParseCommands()))
return 0

if __name__ == '__main__':
sys.exit(Main())
66 changes: 66 additions & 0 deletions .github/helper_scripts/post_check_results.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
import json
import os
import subprocess
import sys
from pathlib import Path

CHECKS_JSON = Path(__file__).parent.parent / 'checks' / 'checks.json'

def Main():
repo = os.environ["GITHUB_REPOSITORY"]
run_id = os.environ["GITHUB_RUN_ID"]

print("Runtime:")
rows, failed = CollectResults()

if "--post" in sys.argv:
PostResults(repo, run_id, rows, failed)

if failed:
print(f"Failed checks: {', '.join(failed)}")
return 1
return 0

def CollectResults():
checks = json.loads(CHECKS_JSON.read_text())
results_dir = Path(os.environ["RUNNER_TEMP"]) / "check_results"
rows, failed = [], []
STATUS = {"success": "✅", "failure": "❌"}
for check in checks:
check_name = check["name"]
result_file = results_dir / f"{check_name}.json"
if result_file.exists():
result = json.loads(result_file.read_text())
outcome = result["outcome"]
elapsed = result["time"]
print(f" {check_name}: {elapsed}s")
status_icon = STATUS.get(outcome, "❌")
if outcome != "success":
failed.append(check_name)
rows.append(f"| {check_name} | {status_icon} | {elapsed}s |")
else:
print(f" {check_name}: skipped")
rows.append(f"| {check_name} | SKIP | |")
return rows, failed

def PostResults(repo, run_id, rows, failed):
run_url = f"https://github.com/{repo}/actions/runs/{run_id}"
table = "\n".join(["| Check | Result | Time |", "|-------|--------|------|", *rows])
body = f"**Check Results** ([run details]({run_url}))\n\n{table}"
pr_num = os.environ["PR_NUMBER"]
pr_sha = os.environ["PR_SHA"]
subprocess.run([
"gh", "api", f"repos/{repo}/issues/{pr_num}/comments",
"--method", "POST", "-f", f"body={body}",
], check=True)
state = "failure" if failed else "success"
desc = f"Failed: {', '.join(failed)}" if failed else "All checks passed"
subprocess.run([
"gh", "api", f"repos/{repo}/statuses/{pr_sha}",
"-f", f"state={state}", "-f", "context=Checks",
"-f", f"description={desc}", "-f", f"target_url={run_url}",
], check=True)

if __name__ == "__main__":
sys.exit(Main())
Loading