mirror of
https://github.com/UiO-Gaming/dokumenter.git
synced 2026-11-05 13:56:49 +01:00
add workflow to render and archive statutes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ea5830c518
commit
4bb25f7f23
2 files changed
+351
No files matched your search
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Archive, render and check the statutes in "styrende dokumenter/vedtekter".
|
||||
|
||||
vedtekter.py build [--dry-run] archive the current PDF and render a new one where the LaTeX source has changed
|
||||
vedtekter.py check check that the metadata of the current PDFs matches their file names
|
||||
|
||||
Requires pikepdf, and latexmk with pdflatex for rendering.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pikepdf
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
VEDTEKTER = ROOT / "styrende dokumenter" / "vedtekter"
|
||||
GJELDENDE = VEDTEKTER / "gjeldende"
|
||||
KILDE = VEDTEKTER / "kilde"
|
||||
UTGAATTE = VEDTEKTER / "utgåtte"
|
||||
|
||||
AUTHOR = "UiO Gaming"
|
||||
OSLO = ZoneInfo("Europe/Oslo")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Language:
|
||||
code: str
|
||||
name: str
|
||||
date_format: str
|
||||
front_page_date: str
|
||||
front_page_date_format: str
|
||||
|
||||
@property
|
||||
def source(self):
|
||||
return KILDE / self.code
|
||||
|
||||
@property
|
||||
def archive(self):
|
||||
return UTGAATTE / self.code
|
||||
|
||||
def format(self, d):
|
||||
return d.strftime(self.date_format)
|
||||
|
||||
def parse(self, s):
|
||||
return datetime.strptime(s, self.date_format).date()
|
||||
|
||||
def current_name(self, start):
|
||||
return f"{self.name} ({self.format(start)} - ).pdf"
|
||||
|
||||
def archived_name(self, number, start, end):
|
||||
return f"{number:02d} - {self.name} ({self.format(start)} - {self.format(end)}).pdf"
|
||||
|
||||
def current(self):
|
||||
"""The current PDF and its start date."""
|
||||
pattern = re.compile(rf"{re.escape(self.name)} \((.+) - \)\.pdf")
|
||||
matches = [(p, m) for p in GJELDENDE.glob("*.pdf") if (m := pattern.fullmatch(p.name))]
|
||||
if len(matches) != 1:
|
||||
sys.exit(f"Expected exactly one current PDF for '{self.name}' in {GJELDENDE}, found {len(matches)}")
|
||||
path, match = matches[0]
|
||||
return path, self.parse(match.group(1))
|
||||
|
||||
def archived(self):
|
||||
"""Number and start date of every archived PDF."""
|
||||
pattern = re.compile(rf"(\d+) - {re.escape(self.name)} \((.+) - (.+)\)\.pdf")
|
||||
return {
|
||||
int(m.group(1)): self.parse(m.group(2))
|
||||
for p in self.archive.glob("*.pdf")
|
||||
if (m := pattern.fullmatch(p.name))
|
||||
}
|
||||
|
||||
def source_date(self):
|
||||
"""The date on the front page of the LaTeX source."""
|
||||
text = (self.source / "front_page.tex").read_text(encoding="utf-8")
|
||||
match = re.search(self.front_page_date, text)
|
||||
if not match:
|
||||
sys.exit(f"Could not find the date in {self.source / 'front_page.tex'}")
|
||||
return datetime.strptime(match.group(1), self.front_page_date_format).date()
|
||||
|
||||
|
||||
NORWEGIAN = Language(
|
||||
code="no",
|
||||
name="Vedtekter UiO Gaming",
|
||||
date_format="%d.%m.%Y",
|
||||
front_page_date=r"Sist revidert:\s*\\\\\s*(\d{2}\.\d{2}\.\d{4})",
|
||||
front_page_date_format="%d.%m.%Y",
|
||||
)
|
||||
ENGLISH = Language(
|
||||
code="en",
|
||||
name="Statutes UiO Gaming",
|
||||
date_format="%Y-%m-%d",
|
||||
front_page_date=r"Last changed:\s*\\\\\s*([A-Z][a-z]+ \d{1,2}, \d{4})",
|
||||
front_page_date_format="%B %d, %Y",
|
||||
)
|
||||
|
||||
|
||||
def git(*args, check=True):
|
||||
return subprocess.run(["git", *args], cwd=ROOT, capture_output=True, text=True, check=check)
|
||||
|
||||
|
||||
def relative(path):
|
||||
return str(path.relative_to(ROOT))
|
||||
|
||||
|
||||
def source_changed(language, current):
|
||||
"""Whether the LaTeX source has changed since the current PDF was last committed."""
|
||||
last = git("log", "-1", "--format=%H", "--", relative(current)).stdout.strip()
|
||||
if not last:
|
||||
return True
|
||||
git("update-index", "-q", "--refresh", check=False)
|
||||
return git("diff", "--quiet", last, "--", relative(language.source), check=False).returncode != 0
|
||||
|
||||
|
||||
def midnight(d):
|
||||
return datetime(d.year, d.month, d.day, tzinfo=OSLO)
|
||||
|
||||
|
||||
def pdf_date(d):
|
||||
dt = midnight(d)
|
||||
minutes = int(dt.utcoffset().total_seconds()) // 60
|
||||
sign = "+" if minutes >= 0 else "-"
|
||||
hours, minutes = divmod(abs(minutes), 60)
|
||||
return f"D:{dt:%Y%m%d%H%M%S}{sign}{hours:02d}'{minutes:02d}'"
|
||||
|
||||
|
||||
def parse_pdf_date(s):
|
||||
match = re.fullmatch(r"D:(\d{14})(?:(Z)|([+-])(\d{2})'(\d{2})'?)?", s)
|
||||
if not match:
|
||||
return None
|
||||
dt = datetime.strptime(match.group(1), "%Y%m%d%H%M%S")
|
||||
if match.group(3):
|
||||
offset = timedelta(hours=int(match.group(4)), minutes=int(match.group(5)))
|
||||
dt = dt.replace(tzinfo=timezone(offset if match.group(3) == "+" else -offset))
|
||||
else:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(OSLO).date()
|
||||
|
||||
|
||||
def title(path):
|
||||
return re.sub(r"^\d+ - ", "", path.stem)
|
||||
|
||||
|
||||
def write_metadata(source, target, start):
|
||||
"""Replace all metadata of the PDF with the standard metadata for the statutes."""
|
||||
with pikepdf.open(source, allow_overwriting_input=True) as pdf:
|
||||
if "/Metadata" in pdf.Root:
|
||||
del pdf.Root.Metadata
|
||||
pdf.docinfo = pdf.make_indirect(
|
||||
pikepdf.Dictionary(
|
||||
Title=pikepdf.String(title(target)),
|
||||
Subject=pikepdf.String(""),
|
||||
Keywords=pikepdf.String(""),
|
||||
Author=pikepdf.String(AUTHOR),
|
||||
Creator=pikepdf.String(""),
|
||||
Producer=pikepdf.String(""),
|
||||
CreationDate=pikepdf.String(pdf_date(start)),
|
||||
ModDate=pikepdf.String(pdf_date(start)),
|
||||
Trapped=pikepdf.Name("/False"),
|
||||
)
|
||||
)
|
||||
pdf.save(target, deterministic_id=True)
|
||||
|
||||
|
||||
def render(language, start, target):
|
||||
with tempfile.TemporaryDirectory() as out:
|
||||
env = dict(os.environ, SOURCE_DATE_EPOCH=str(int(midnight(start).timestamp())), FORCE_SOURCE_DATE="1")
|
||||
result = subprocess.run(
|
||||
["latexmk", "-pdf", "-interaction=nonstopmode", "-halt-on-error", f"-outdir={out}", "main.tex"],
|
||||
cwd=language.source,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(result.stdout, result.stderr, sep="\n", file=sys.stderr)
|
||||
sys.exit(f"{language.code}: LaTeX failed")
|
||||
write_metadata(Path(out) / "main.pdf", target, start)
|
||||
|
||||
|
||||
def build_language(language, dry_run, norwegian):
|
||||
"""Archive and render one language.
|
||||
|
||||
`norwegian` maps the number of every Norwegian version to its start date, with the current version as None.
|
||||
It is updated with what was done to the Norwegian statutes. Returns a description of what was done, or None.
|
||||
"""
|
||||
current, current_start = language.current()
|
||||
if not source_changed(language, current):
|
||||
print(f"{language.code}: the source has not changed since {current.name} was committed")
|
||||
return None
|
||||
|
||||
new_start = language.source_date()
|
||||
if new_start < current_start:
|
||||
sys.exit(f"{language.code}: the front page date {new_start} is older than the current statutes ({current_start})")
|
||||
if language is not NORWEGIAN and new_start not in norwegian.values():
|
||||
sys.exit(f"{language.code}: the front page date {new_start} does not match any Norwegian version")
|
||||
|
||||
target = GJELDENDE / language.current_name(new_start)
|
||||
if new_start == current_start:
|
||||
if not dry_run:
|
||||
render(language, new_start, target)
|
||||
return f"Re-rendered `{target.name}` without a new date"
|
||||
|
||||
if language is NORWEGIAN:
|
||||
number = max((n for n in norwegian if n is not None), default=0) + 1
|
||||
norwegian[number] = current_start
|
||||
norwegian[None] = new_start
|
||||
else:
|
||||
number = next((n for n, s in norwegian.items() if n is not None and s == current_start), None)
|
||||
if number is None:
|
||||
sys.exit(f"{language.code}: no archived Norwegian version starts at {current_start}")
|
||||
archived = language.archive / language.archived_name(number, current_start, new_start)
|
||||
if not dry_run:
|
||||
with tempfile.TemporaryDirectory() as out:
|
||||
rendered = Path(out) / target.name
|
||||
render(language, new_start, rendered)
|
||||
git("mv", relative(current), relative(archived))
|
||||
write_metadata(archived, archived, current_start)
|
||||
shutil.move(rendered, target)
|
||||
return f"Archived `{current.name}` as `{archived.name}` and rendered `{target.name}`"
|
||||
|
||||
|
||||
def build(dry_run):
|
||||
norwegian = {**NORWEGIAN.archived(), None: NORWEGIAN.current()[1]}
|
||||
summaries = []
|
||||
for language in (NORWEGIAN, ENGLISH):
|
||||
summary = build_language(language, dry_run, norwegian)
|
||||
if summary:
|
||||
print(f"{language.code}: {'(dry run) ' if dry_run else ''}{summary}")
|
||||
summaries.append(summary)
|
||||
|
||||
if "GITHUB_OUTPUT" in os.environ:
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
|
||||
f.write("summary<<EOF\n" + "\n".join(f"- {s}" for s in summaries) + "\nEOF\n")
|
||||
|
||||
|
||||
def check():
|
||||
errors = []
|
||||
for language in (NORWEGIAN, ENGLISH):
|
||||
path, start = language.current()
|
||||
with pikepdf.open(path) as pdf:
|
||||
info = pdf.docinfo
|
||||
if str(info.get("/Title", "")) != title(path):
|
||||
errors.append(f"{path.name}: the title is '{info.get('/Title')}'")
|
||||
if str(info.get("/Author", "")) != AUTHOR:
|
||||
errors.append(f"{path.name}: the author is '{info.get('/Author')}'")
|
||||
for key in ("/Creator", "/Producer"):
|
||||
if str(info.get(key, "")):
|
||||
errors.append(f"{path.name}: {key[1:]} is '{info[key]}'")
|
||||
for key in ("/CreationDate", "/ModDate"):
|
||||
if parse_pdf_date(str(info.get(key, ""))) != start:
|
||||
errors.append(f"{path.name}: {key[1:]} is '{info.get(key)}', expected {start}")
|
||||
if "/Metadata" in pdf.Root:
|
||||
errors.append(f"{path.name}: has XMP metadata")
|
||||
for error in errors:
|
||||
print(error, file=sys.stderr)
|
||||
if errors:
|
||||
sys.exit(1)
|
||||
print("The metadata of the current statutes is correct")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
build_parser = commands.add_parser("build", help="archive and render the statutes where the source has changed")
|
||||
build_parser.add_argument("--dry-run", action="store_true", help="print what would be done without doing it")
|
||||
commands.add_parser("check", help="check the metadata of the current statutes")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "build":
|
||||
build(args.dry_run)
|
||||
else:
|
||||
check()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,67 @@
|
||||
name: Vedtekter
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "styrende dokumenter/vedtekter/kilde/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- "styrende dokumenter/vedtekter/gjeldende/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip install pikepdf==10.17.0
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends latexmk texlive-latex-extra texlive-lang-european cm-super
|
||||
|
||||
- name: Build statutes
|
||||
id: build
|
||||
run: python .github/scripts/vedtekter.py build
|
||||
|
||||
- name: Check metadata
|
||||
run: python .github/scripts/vedtekter.py check
|
||||
|
||||
- name: Open pull request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
branch: automatisk/vedtekter
|
||||
delete-branch: true
|
||||
commit-message: render statutes
|
||||
title: Oppdater vedtekter
|
||||
body: |
|
||||
${{ steps.build.outputs.summary }}
|
||||
|
||||
Sjekk at PDF-ene ser riktige ut før du merger.
|
||||
|
||||
check:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- run: pip install pikepdf==10.17.0
|
||||
|
||||
- name: Check metadata
|
||||
run: python .github/scripts/vedtekter.py check
|
||||
Reference in new issue
Block a user