From 3dec1014145ad84cca014f545bad75f7c6340392 Mon Sep 17 00:00:00 2001 From: Hannes Schnaitter Date: Thu, 12 Mar 2026 14:42:11 +0100 Subject: [PATCH 1/4] feat(ci): add pre-commit config with ruff, mypy hooks, and type stubs --- .pre-commit-config.yaml | 20 ++++++++++++++++++++ dev-requirements.txt | 1 + 2 files changed, 21 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..46aa79c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,20 @@ +repos: + - repo: local + hooks: + - id: ruff + name: ruff + entry: bash -c 'ruff check --fix --target-version=py$(cat .python-version | tr -d "\n." ) "$@"' -- + language: system + types: [python] + + - id: ruff-format + name: ruff-format + entry: bash -c 'ruff format --target-version=py$(cat .python-version | tr -d "\n.") "$@"' -- + language: system + types: [python] + + - id: mypy + name: mypy + entry: bash -c 'mypy --python-version=$(cat .python-version | tr -d "\n") "$@"' -- + language: system + types: [python] diff --git a/dev-requirements.txt b/dev-requirements.txt index 5c2d287..7c80a9b 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,6 +1,7 @@ pyyaml mypy ruff +pre-commit types-PyYAML rdflib jupytext From d42d875a453980bb58c0e79bb5a1fcf08801a547 Mon Sep 17 00:00:00 2001 From: Hannes Schnaitter Date: Thu, 12 Mar 2026 14:41:55 +0100 Subject: [PATCH 2/4] chore(ruff): suppress E501 and E402 lint rules --- ruff.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ruff.toml b/ruff.toml index 7ed0671..dfcf770 100644 --- a/ruff.toml +++ b/ruff.toml @@ -70,9 +70,11 @@ ignore = [ # Code style "COM812", # Trailing comma missing - prefer to let formatter handle this + "E501", # Line too long (> 100) # Import location "PLC0415", # Import should be at top-level - some imports are conditional for CLI + "E402", # Module level import not at top of file ] [lint.per-file-ignores] From 8de408c3029617992b2ba6fc9657fffcda86bc88 Mon Sep 17 00:00:00 2001 From: Hannes Schnaitter Date: Thu, 12 Mar 2026 14:42:21 +0100 Subject: [PATCH 3/4] feat(quadriga): apply ruff and mypy fixes across codebase --- .zenodo.json | 2 +- quadriga/assessment.py | 88 ++++++++++++------- quadriga/metadata/create_rdfxml.py | 12 +-- quadriga/metadata/create_zenodo_json.py | 6 +- quadriga/metadata/extract_from_lernziele.py | 97 +++++++++++---------- quadriga/metadata/inject_all_metadata.py | 88 ++++++++++++++----- quadriga/metadata/run_all.py | 10 +-- quadriga/metadata/utils.py | 7 +- quadriga/metadata/validate_schema.py | 20 ++--- 9 files changed, 202 insertions(+), 128 deletions(-) diff --git a/.zenodo.json b/.zenodo.json index 9efa954..9b52cf1 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -17,7 +17,7 @@ "affiliation": "Universität Potsdam" } ], - "description": "

Dieses interaktive Lehrbuch führt in die Erstellung von Open Educational Resources (OER) nach den Empfehlungen und Vorgaben des Datenkompetenzzentrumgs QUADRIGA als Jupyter Book ein. Diese Empfehlungen und Vorgaben sind einerseits inhaltlicher und didaktischer andererseits technischer Natur. Diese OER richtet sich vorrangig an OER-Erstellende und kann als Startpunkt (Template) für die Erstellung eigener OER genutzt werden. Einzelne (Unter-)Kapitel bieten zudem Erläuterungen zu Funktionalitäten für Lernende.

\n

Dieses interaktive Lehrbuch kann als Web-Version verwendet, zur individuellen Anpassung heruntergeladen werden und steht darüber hinaus auch auf GitHub zur Verfügung.

\n

Die QUADRIGA-OER sind nach einem einheitlichen Template gestaltet, werden nach einem standardisierten Verfahren qualitätsgeprüft und mit Metadaten nach dem QUADRIGA-Metadatenschema ausgezeichnet.

\n
QUADRIGA Datenkompetenzzentrum
\n

QUADRIGA ist das Datenkompetenzzentrum der Wissenschaftsregion Berlin-Brandenburg. Für die beiden Anwendungsdomänen Digital Humanities und Verwaltungswissenschaft entstehen unter der Einbindung der Expertise der beiden Disziplinen Informatik und Informationswissenschaft Selbstlernangebote, die als OER in Form von Jupyter Books zur freien Nachnutzung zur Verfügung gestellt werden. Um den Forschungsprozess möglichst realistisch abzubilden, basieren die OER auf Fallstudien, denen wiederum ein eigens für das Projekt entwickeltes Datenkompetenzframework zugrunde liegt. Die Fallstudien nehmen drei für die Anwendungsdomänen repräsentativen Datentypen in den Blick: Bewegtes Bild, Tabelle und Text.

\n

Zu den Zielgruppen von QUADRIGA zählen insbesondere promovierende und promovierte Wissenschaftler*innen der genannten Disziplinen, die den Umgang mit digitalen Daten, Methoden und Werkzeugen erlernen und weiterentwickeln wollen.

\n

QUADRIGA ist eins von 11 Datenkompetenzzentren in Deutschland und wird vom Bundesministerium für Forschung, Technologie und Raumfahrt (BMFTR) und von der Europäischen Union im Rahmen von NextGenerationEU finanziert. Zu den Verbundpartnern zählen:\n

    \n
  • Universität Potsdam (Verbundkoordination) (Förderkennzeichen: 16DKZ2034A)
  • \n
  • Filmuniversität Babelsberg KONRAD WOLF (Förderkennzeichen: 16DKZ2034B)
  • \n
  • Fachhochschule Potsdam (Förderkennzeichen: 16DKZ2034C)
  • \n
  • Fraunhofer FOKUS (Förderkennzeichen: 16DKZ2034D)
  • \n
  • Freie Universität Berlin (Förderkennzeichen: 16DKZ2034E)
  • \n
  • Technische Universität Berlin (Förderkennzeichen: 16DKZ2034F)
  • \n
  • Gesellschaft für Informatik (Förderkennzeichen: 16DKZ2034G)
  • \n
  • Humboldt-Universität zu Berlin (Förderkennzeichen: 16DKZ2034H)
  • \n
\n

\n\n

Mehr zum Aufbau und zur Umsetzung des Projekts können Sie im Umsetzungskonzept erfahren.

\n\n

Weitere Informationen sowie Publikationen finden Sie auf der Webseite, in der Zenodo-Community und der GitHub-Organisation des Projekts.

\n", + "description": "

Dieses interaktive Lehrbuch führt in die Erstellung von Open Educational Resources (OER) nach den Empfehlungen und Vorgaben des Datenkompetenzzentrumgs QUADRIGA als Jupyter Book ein. Diese Empfehlungen und Vorgaben sind einerseits inhaltlicher und didaktischer andererseits technischer Natur. Diese OER richtet sich vorrangig an OER-Erstellende und kann als Startpunkt (Template) für die Erstellung eigener OER genutzt werden. Einzelne (Unter-)Kapitel bieten zudem Erläuterungen zu Funktionalitäten für Lernende.

\n

Dieses interaktive Lehrbuch kann als Web-Version verwendet, zur individuellen Anpassung heruntergeladen werden und steht darüber hinaus auch auf GitHub zur Verfügung.

\n

Die QUADRIGA-OER sind nach einem einheitlichen Template gestaltet, werden nach einem standardisierten Verfahren qualitätsgeprüft und mit Metadaten nach dem QUADRIGA-Metadatenschema ausgezeichnet.

\n
QUADRIGA Datenkompetenzzentrum
\n

QUADRIGA ist das Datenkompetenzzentrum der Wissenschaftsregion Berlin-Brandenburg. Für die beiden Anwendungsdomänen Digital Humanities und Verwaltungswissenschaft entstehen unter der Einbindung der Expertise der beiden Disziplinen Informatik und Informationswissenschaft Selbstlernangebote, die als OER in Form von Jupyter Books zur freien Nachnutzung zur Verfügung gestellt werden. Um den Forschungsprozess möglichst realistisch abzubilden, basieren die OER auf Fallstudien, denen wiederum ein eigens für das Projekt entwickeltes Datenkompetenzframework zugrunde liegt. Die Fallstudien nehmen drei für die Anwendungsdomänen repräsentativen Datentypen in den Blick: Bewegtes Bild, Tabelle und Text.

\n

Zu den Zielgruppen von QUADRIGA zählen insbesondere promovierende und promovierte Wissenschaftler*innen der genannten Disziplinen, die den Umgang mit digitalen Daten, Methoden und Werkzeugen erlernen und weiterentwickeln wollen.

\n

QUADRIGA ist eins von 11 Datenkompetenzzentren in Deutschland und wird vom Bundesministerium für Forschung, Technologie und Raumfahrt (BMFTR) und von der Europäischen Union im Rahmen von NextGenerationEU finanziert. Zu den Verbundpartnern zählen:\n

    \n
  • Universität Potsdam (Verbundkoordination) (Förderkennzeichen: 16DKZ2034A)
  • \n
  • Filmuniversität Babelsberg KONRAD WOLF (Förderkennzeichen: 16DKZ2034B)
  • \n
  • Fachhochschule Potsdam (Förderkennzeichen: 16DKZ2034C)
  • \n
  • Fraunhofer FOKUS (Förderkennzeichen: 16DKZ2034D)
  • \n
  • Freie Universität Berlin (Förderkennzeichen: 16DKZ2034E)
  • \n
  • Technische Universität Berlin (Förderkennzeichen: 16DKZ2034F)
  • \n
  • Gesellschaft für Informatik (Förderkennzeichen: 16DKZ2034G)
  • \n
  • Humboldt-Universität zu Berlin (Förderkennzeichen: 16DKZ2034H)
  • \n
\n

\n\n

Mehr zum Aufbau und zur Umsetzung des Projekts können Sie im Umsetzungskonzept erfahren.

\n\n

Weitere Informationen sowie Publikationen finden Sie auf der Webseite, in der Zenodo-Community und der GitHub-Organisation des Projekts.

\n", "publication_date": "2026-03-11", "keywords": [ "Open Educational Resources", diff --git a/quadriga/assessment.py b/quadriga/assessment.py index bfb29d1..0b9c762 100644 --- a/quadriga/assessment.py +++ b/quadriga/assessment.py @@ -1,9 +1,10 @@ -from IPython.display import HTML import json import uuid +from IPython.display import HTML + -def create_answer_box(question_id, rows=4): +def create_answer_box(question_id: str, rows: int = 4) -> HTML: """Create an answer box with a submit button.""" return HTML(f"""
@@ -16,7 +17,7 @@ def create_answer_box(question_id, rows=4): class DragDropQuiz: """ A simple drag-and-drop quiz generator for Jupyter Books. - + Usage: quiz = DragDropQuiz() quiz.create_matching_quiz( @@ -26,14 +27,23 @@ class DragDropQuiz: correct_mapping={"Description 1": "Option A", "Description 2": "Option B", "Description 3": "Option C"} ) """ - - def __init__(self): + + def __init__(self) -> None: self.quiz_counter = 0 - - def create_matching_quiz(self, title, descriptions, options, correct_mapping, show_feedback=True, feedback_messages=None): + + def create_matching_quiz( + self, + title: str, + descriptions: list[str], + options: list[str], + correct_mapping: dict, + *, + show_feedback: bool = True, + feedback_messages: dict | None = None, + ) -> HTML: """ Create a drag-and-drop matching quiz. - + Parameters: - title (str): The quiz title/question - descriptions (list): List of items to be matched (static labels) @@ -44,76 +54,92 @@ def create_matching_quiz(self, title, descriptions, options, correct_mapping, sh """ self.quiz_counter += 1 quiz_id = f"drag_drop_quiz_{self.quiz_counter}_{uuid.uuid4().hex[:8]}" - + # Set default feedback messages if none provided if feedback_messages is None: feedback_messages = { "correct": "Perfekt! Alle {total} Zuordnungen sind korrekt!", "incorrect": "Leider sind keine Zuordnungen korrekt. Versuchen Sie es noch einmal!", - "partial": "Teilweise richtig: {correct} von {total} Zuordnungen sind korrekt." + "partial": "Teilweise richtig: {correct} von {total} Zuordnungen sind korrekt.", } - + # Convert correct mapping to use indices for easier JavaScript handling desc_to_idx = {desc: i for i, desc in enumerate(descriptions)} opt_to_idx = {opt: i for i, opt in enumerate(options)} - + correct_pairs = [] for desc, opt in correct_mapping.items(): if desc in desc_to_idx and opt in opt_to_idx: correct_pairs.append([desc_to_idx[desc], opt_to_idx[opt]]) - + html_content = self._generate_html( - quiz_id, title, descriptions, options, correct_pairs, show_feedback, feedback_messages + quiz_id, + title, + descriptions, + options, + correct_pairs, + show_feedback=show_feedback, + feedback_messages=feedback_messages, ) - + return HTML(html_content) - - def _generate_html(self, quiz_id, title, descriptions, options, correct_pairs, show_feedback, feedback_messages): + + def _generate_html( + self, + quiz_id: str, + title: str, + descriptions: list[str], + options: list[str], + correct_pairs: list[list[int]], + *, + show_feedback: bool, + feedback_messages: dict, + ) -> str: """Generate the complete HTML for the drag-and-drop quiz.""" - + # Generate static description labels with drop zones description_zones = "" for i, desc in enumerate(descriptions): - description_zones += f''' + description_zones += f"""
{desc}
Hier ablegen
- ''' - + """ + # Generate draggable options draggable_options = "" for i, option in enumerate(options): - draggable_options += f''' + draggable_options += f"""
{option}
- ''' - - return f''' + """ + + return f"""
{title}
- +
{description_zones}
- +
Ziehen Sie diese zu den passenden Beschreibungen
{draggable_options}
- +
- +
- ''' - - \ No newline at end of file + """ diff --git a/quadriga/metadata/create_rdfxml.py b/quadriga/metadata/create_rdfxml.py index e075256..af9584e 100644 --- a/quadriga/metadata/create_rdfxml.py +++ b/quadriga/metadata/create_rdfxml.py @@ -17,8 +17,8 @@ from pathlib import Path from typing import Any -from rdflib import RDF, Graph, Literal, Namespace, URIRef # type: ignore[import-not-found] -from rdflib.namespace import DCTERMS, SKOS, XSD # type: ignore[import-not-found] +from rdflib import RDF, Graph, Literal, Namespace, URIRef +from rdflib.namespace import DCTERMS, SKOS, XSD from .utils import extract_keywords, get_file_path, get_repo_root, load_yaml_file @@ -153,9 +153,7 @@ def add_person( graph.add((orcid_node, RDF.type, SCHEMA.PropertyValue)) graph.add((orcid_node, SCHEMA.propertyID, Literal("ORCID"))) graph.add((orcid_node, SCHEMA.value, Literal(clean_orcid_id))) - graph.add( - (orcid_node, SCHEMA.url, URIRef(f"https://orcid.org/{clean_orcid_id}")) - ) + graph.add((orcid_node, SCHEMA.url, URIRef(f"https://orcid.org/{clean_orcid_id}"))) graph.add((person_uri, SCHEMA.identifier, orcid_node)) # affiliation -> schema:affiliation (mapped in both author and contributor) @@ -290,9 +288,7 @@ def add_chapter( # learning-objectives -> educationalAlignment with AlignmentObject if chapter_data.get("learning-objectives"): for obj_index, obj_data in enumerate(chapter_data["learning-objectives"]): - obj_uri = add_learning_objective( - graph, obj_data, base_uri, chapter_index, obj_index - ) + obj_uri = add_learning_objective(graph, obj_data, base_uri, chapter_index, obj_index) if obj_uri: graph.add((chapter_uri, SCHEMA.educationalAlignment, obj_uri)) diff --git a/quadriga/metadata/create_zenodo_json.py b/quadriga/metadata/create_zenodo_json.py index dd63bd1..e24eb24 100644 --- a/quadriga/metadata/create_zenodo_json.py +++ b/quadriga/metadata/create_zenodo_json.py @@ -250,15 +250,15 @@ def create_zenodo_json() -> bool | None: return False # description - description = "

" + metadata.get("description") + "

" + description = "

" + metadata.get("description", "TODO TODO TODO") + "

" description_base = f"""

Dieses interaktive Lehrbuch kann als Web-Version verwendet, zur individuellen Anpassung heruntergeladen werden und steht darüber hinaus auch auf GitHub zur Verfügung.

-

Die QUADRIGA-OER sind nach einem einheitlichen Template gestaltet, werden nach einem standardisierten Verfahren qualitätsgeprüft und mit Metadaten nach dem QUADRIGA-Metadatenschema ausgezeichnet.

+

Die QUADRIGA-OER sind nach einem einheitlichen Template gestaltet, werden nach einem standardisierten Verfahren qualitätsgeprüft und mit Metadaten nach dem QUADRIGA-Metadatenschema ausgezeichnet.

QUADRIGA Datenkompetenzzentrum

QUADRIGA ist das Datenkompetenzzentrum der Wissenschaftsregion Berlin-Brandenburg. Für die beiden Anwendungsdomänen Digital Humanities und Verwaltungswissenschaft entstehen unter der Einbindung der Expertise der beiden Disziplinen Informatik und Informationswissenschaft Selbstlernangebote, die als OER in Form von Jupyter Books zur freien Nachnutzung zur Verfügung gestellt werden. Um den Forschungsprozess möglichst realistisch abzubilden, basieren die OER auf Fallstudien, denen wiederum ein eigens für das Projekt entwickeltes Datenkompetenzframework zugrunde liegt. Die Fallstudien nehmen drei für die Anwendungsdomänen repräsentativen Datentypen in den Blick: Bewegtes Bild, Tabelle und Text.

Zu den Zielgruppen von QUADRIGA zählen insbesondere promovierende und promovierte Wissenschaftler*innen der genannten Disziplinen, die den Umgang mit digitalen Daten, Methoden und Werkzeugen erlernen und weiterentwickeln wollen.

-

QUADRIGA ist eins von 11 Datenkompetenzzentren in Deutschland und wird vom Bundesministerium für Forschung, Technologie und Raumfahrt (BMFTR) und von der Europäischen Union im Rahmen von NextGenerationEU finanziert. Zu den Verbundpartnern zählen: +

QUADRIGA ist eins von 11 Datenkompetenzzentren in Deutschland und wird vom Bundesministerium für Forschung, Technologie und Raumfahrt (BMFTR) und von der Europäischen Union im Rahmen von NextGenerationEU finanziert. Zu den Verbundpartnern zählen:

  • Universität Potsdam (Verbundkoordination) (Förderkennzeichen: 16DKZ2034A)
  • Filmuniversität Babelsberg KONRAD WOLF (Förderkennzeichen: 16DKZ2034B)
  • diff --git a/quadriga/metadata/extract_from_lernziele.py b/quadriga/metadata/extract_from_lernziele.py index 90a7892..0cbc664 100644 --- a/quadriga/metadata/extract_from_lernziele.py +++ b/quadriga/metadata/extract_from_lernziele.py @@ -1,11 +1,14 @@ """Extract learning objectives with metadata from Lernziele.md files.""" from __future__ import annotations + import logging import re +import sys from pathlib import Path from typing import Any -from .utils import get_repo_root, save_yaml_file, get_file_path, load_yaml_file, iter_toc_files + +from .utils import get_file_path, get_repo_root, iter_toc_files, load_yaml_file, save_yaml_file logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") logger = logging.getLogger(__name__) @@ -25,20 +28,20 @@ def parse_metadata_comment(comment: str) -> dict[str, str]: """Parse 'competency: X | bloom: Y' from the inner text of an HTML comment.""" metadata = {} - for part in comment.split('|'): - part = part.strip() - if ':' not in part: + for part_raw in comment.split("|"): + part = part_raw.strip() + if ":" not in part: continue - key, value = part.split(':', 1) - key = key.strip().lower().replace(' ', '-') + key, value = part.split(":", 1) + key = key.strip().lower().replace(" ", "-") value = value.strip() if not value: continue - if key == 'bloom': - metadata['blooms-category'] = value - elif key == 'competency': + if key == "bloom": + metadata["blooms-category"] = value + elif key == "competency": metadata[key] = value - metadata['data-flow'] = derive_data_flow(value) + metadata["data-flow"] = derive_data_flow(value) return metadata @@ -47,14 +50,14 @@ def validate_objective_metadata(objective_data: dict[str, Any]) -> list[str]: """Fill in missing competency/bloom/data-flow defaults and return names of missing fields.""" missing_fields = [] - if not objective_data.get('competency'): - missing_fields.append('competency') - objective_data['competency'] = DEFAULT_COMPETENCY - objective_data['data-flow'] = DEFAULT_DATA_FLOW + if not objective_data.get("competency"): + missing_fields.append("competency") + objective_data["competency"] = DEFAULT_COMPETENCY + objective_data["data-flow"] = DEFAULT_DATA_FLOW - if not objective_data.get('blooms-category'): - missing_fields.append('blooms-category') - objective_data['blooms-category'] = DEFAULT_BLOOM + if not objective_data.get("blooms-category"): + missing_fields.append("blooms-category") + objective_data["blooms-category"] = DEFAULT_BLOOM return missing_fields @@ -92,7 +95,7 @@ def extract_admonition_blocks( validation_issues = [] # Pattern to match admonition blocks preceded by - admonition_pattern = r'\s*\n```\{admonition\}\s+(.+?)\n((?::[^\n]+\n)*)((?:(?!```).)+)```' + admonition_pattern = r"\s*\n```\{admonition\}\s+(.+?)\n((?::[^\n]+\n)*)((?:(?!```).)+)```" matches = re.finditer(admonition_pattern, content, re.DOTALL | re.MULTILINE) @@ -102,7 +105,7 @@ def extract_admonition_blocks( body = match.group(4).strip() # Parse title - extract text and reference - title_match = re.match(r'\[(.+?)\]\((.+?)\)(\s*\(\*(.+?)\*\))?', title_line) + title_match = re.match(r"\[(.+?)\]\((.+?)\)(\s*\(\*(.+?)\*\))?", title_line) if not title_match: logger.warning("Could not parse admonition title: %s", title_line) @@ -112,7 +115,7 @@ def extract_admonition_blocks( # Learning goal learning_goal_match = re.search( - r'', + r"", body, re.DOTALL, ) @@ -120,34 +123,37 @@ def extract_admonition_blocks( learning_goal = normalize_whitespace(learning_goal_match.group(1)) else: learning_goal = "TODO" - validation_issues.append({ - 'section': section_title, - 'missing_fields': ['learning-goal'] - }) + validation_issues.append( + {"section": section_title, "missing_fields": ["learning-goal"]} + ) # Strip all START/END markers before parsing objectives - body_cleaned = re.sub(r'\s*', '', body) - body_cleaned = re.sub(r'\s*', '', body_cleaned) + body_cleaned = re.sub(r"\s*", "", body) + body_cleaned = re.sub(r"\s*", "", body_cleaned) # Parse numbered objectives with optional inline metadata comment objectives = [] - objective_pattern = r'\d+\.\s+(.+?)(?:(?:\n\s*|(?=)?(?=\n\d+\.|\n\n|$)' + objective_pattern = ( + r"\d+\.\s+(.+?)(?:(?:\n\s*|(?=)?(?=\n\d+\.|\n\n|$)" + ) for obj_match in re.finditer(objective_pattern, body_cleaned, re.DOTALL): objective_text = normalize_whitespace(obj_match.group(1)) metadata_comment = obj_match.group(2) - objective_data = {'learning-objective': objective_text} + objective_data = {"learning-objective": objective_text} if metadata_comment: objective_data.update(parse_metadata_comment(metadata_comment)) missing_fields = validate_objective_metadata(objective_data) if missing_fields: - validation_issues.append({ - 'section': section_title, - 'objective': objective_text, - 'missing_fields': missing_fields - }) + validation_issues.append( + { + "section": section_title, + "objective": objective_text, + "missing_fields": missing_fields, + } + ) objectives.append(objective_data) @@ -155,11 +161,11 @@ def extract_admonition_blocks( continue block_data: dict[str, Any] = { - 'learning-goal': learning_goal, - 'objectives': objectives, + "learning-goal": learning_goal, + "objectives": objectives, } - block_data['chapter'] = chapter + block_data["chapter"] = chapter blocks.append(block_data) @@ -176,7 +182,7 @@ def extract_from_lernziele_file( logger.info("Extracted %d admonition blocks from %s", len(blocks), md_file_path.name) return blocks, issues except FileNotFoundError: - logger.error("File not found: %s", md_file_path) + logger.exception("File not found: %s", md_file_path) return [], [] except Exception: logger.exception("Error reading file: %s", md_file_path) @@ -194,7 +200,7 @@ def generate_validation_report( report = f"⚠️ Found {len(validation_issues)} issues with missing metadata:\n\n" for i, issue in enumerate(validation_issues, 1): report += f"{i}. Section: {issue['section']}\n" - if 'objective' in issue: + if "objective" in issue: report += f" Objective: {issue['objective'][:60]}...\n" report += f" Missing: {', '.join(issue['missing_fields'])}\n\n" report += "\nHow to fix:\n" @@ -216,7 +222,7 @@ def merge_learning_objectives_into_metadata() -> bool: md_file = None for file_str in iter_toc_files(toc_data or {}): p = Path(file_str) - if re.search(r'lernziel|learning.?objective|learning.?outcome', p.stem, re.IGNORECASE): + if re.search(r"lernziel|learning.?objective|learning.?outcome", p.stem, re.IGNORECASE): if p.suffix not in [".md", ".ipynb"]: p = p.with_suffix(".md") full_path = get_file_path(p, repo_root) @@ -259,13 +265,16 @@ def merge_learning_objectives_into_metadata() -> bool: chapter_objectives: dict[str, list] = {} chapter_learning_goals: dict[str, str] = {} for section in sections: - chapter = section.get('chapter') + chapter = section.get("chapter") if not chapter: continue - chapter_objectives.setdefault(chapter, []).extend(section['objectives']) - learning_goal = section.get('learning-goal') + chapter_objectives.setdefault(chapter, []).extend(section["objectives"]) + learning_goal = section.get("learning-goal") if learning_goal: - if chapter in chapter_learning_goals and chapter_learning_goals[chapter] != learning_goal: + if ( + chapter in chapter_learning_goals + and chapter_learning_goals[chapter] != learning_goal + ): logger.warning( "Multiple learning goals found for chapter '%s'. Using first one.", chapter, @@ -314,4 +323,4 @@ def merge_learning_objectives_into_metadata() -> bool: if __name__ == "__main__": success = merge_learning_objectives_into_metadata() - exit(0 if success else 1) \ No newline at end of file + sys.exit(0 if success else 1) diff --git a/quadriga/metadata/inject_all_metadata.py b/quadriga/metadata/inject_all_metadata.py index bfbddcc..ccc3bb5 100644 --- a/quadriga/metadata/inject_all_metadata.py +++ b/quadriga/metadata/inject_all_metadata.py @@ -250,10 +250,10 @@ def format_author_name(author_data: dict) -> str: if given and family: return f"{given} {family}" - elif name: - return name + if name: + return str(name) - return given or family + return str(given) or str(family) def ensure_absolute_url(url: str, base_url: str) -> str: @@ -280,6 +280,7 @@ def create_opengraph_meta_tags( base_url: str, book_title: str, logo_filename: str, + *, is_chapter: bool = False, ) -> str: """ @@ -355,14 +356,20 @@ def create_opengraph_meta_tags( if not is_chapter: # Root page: book:release_date if "datePublished" in jsonld_data: - tags.append(f' ') + tags.append( + f' ' + ) # Note: books don't have a modified_time property in OpenGraph else: # Chapters: article:published_time and article:modified_time if "datePublished" in jsonld_data: - tags.append(f' ') + tags.append( + f' ' + ) if "dateModified" in jsonld_data: - tags.append(f' ') + tags.append( + f' ' + ) # Tags/keywords - use book:tag for root page only if not is_chapter and "keywords" in jsonld_data: @@ -495,6 +502,7 @@ def inject_all_metadata_into_html( html_path: Path, og_tags: str, jsonld_content: str, + *, add_link_elements: bool = True, ) -> bool: """ @@ -526,7 +534,10 @@ def inject_all_metadata_into_html( html_content = f.read() # Check if metadata is already present (avoid duplicates) - if '' in html_content: + if ( + '' in html_content + ): logger.debug("Metadata already present in %s, skipping", html_path.name) return True @@ -539,7 +550,9 @@ def inject_all_metadata_into_html( # 2. JSON-LD structured data if jsonld_content: - injection_parts.append(f' ') + injection_parts.append( + f' ' + ) # 3. RDF discovery links if add_link_elements: @@ -560,7 +573,9 @@ def inject_all_metadata_into_html( injection_point = None # Try to inject after viewport meta tag (best practice for OpenGraph) - viewport_match = re.search(r"(]*>\s*)", html_content, re.IGNORECASE) + viewport_match = re.search( + r"(]*>\s*)", html_content, re.IGNORECASE + ) if viewport_match: injection_point = viewport_match.end() else: @@ -576,10 +591,18 @@ def inject_all_metadata_into_html( logger.warning("No tag found in %s, skipping", html_path.name) return False # For injection, add before the tag - html_content = html_content[:injection_point] + f"\n{full_injection}\n" + html_content[injection_point:] + html_content = ( + html_content[:injection_point] + + f"\n{full_injection}\n" + + html_content[injection_point:] + ) else: # For after viewport/charset injection, insert at found position - html_content = html_content[:injection_point] + f"\n{full_injection}\n\n" + html_content[injection_point:] + html_content = ( + html_content[:injection_point] + + f"\n{full_injection}\n\n" + + html_content[injection_point:] + ) # Write the modified HTML back with html_path.open("w", encoding="utf-8") as f: @@ -737,15 +760,19 @@ def inject_all_metadata( # Check if the redirect page has proper HTML structure if "]*>', index_content, re.IGNORECASE) + meta_refresh_match = re.search( + r"]*>", index_content, re.IGNORECASE + ) meta_refresh = meta_refresh_match.group(0) if meta_refresh_match else "" # Create proper HTML with OpenGraph metadata and meta refresh new_index_content = f""" - + {meta_refresh} @@ -760,13 +787,18 @@ def inject_all_metadata( # Write the new index.html with index_html.open("w", encoding="utf-8") as f: f.write(new_index_content) - logger.info("Successfully created index.html with OpenGraph metadata and redirect") + logger.info( + "Successfully created index.html with OpenGraph metadata and redirect" + ) + # Has proper HTML structure, inject normally + elif not inject_all_metadata_into_html( + index_html, og_tags, "", add_link_elements=False + ): + logger.warning("Failed to inject OpenGraph into index.html redirect page") else: - # Has proper HTML structure, inject normally - if not inject_all_metadata_into_html(index_html, og_tags, "", add_link_elements=False): - logger.warning("Failed to inject OpenGraph into index.html redirect page") - else: - logger.info("Successfully injected OpenGraph metadata into index.html redirect page") + logger.info( + "Successfully injected OpenGraph metadata into index.html redirect page" + ) except Exception: logger.exception("Error processing index.html redirect page") @@ -790,7 +822,9 @@ def inject_all_metadata( # Find the HTML file for this chapter chapter_html_path = get_html_path_from_url(chapter_url, build_dir) if not chapter_html_path: - logger.warning("Could not find HTML file for chapter: %s", chapter.get("name", "Unknown")) + logger.warning( + "Could not find HTML file for chapter: %s", chapter.get("name", "Unknown") + ) continue # Create chapter metadata for OpenGraph (combining chapter + book data) @@ -818,13 +852,19 @@ def inject_all_metadata( # Convert to formatted string chapter_jsonld_str = json.dumps(chapter_jsonld, ensure_ascii=False, indent=2) - chapter_jsonld_str = "\n".join(" " + line for line in chapter_jsonld_str.split("\n")) + chapter_jsonld_str = "\n".join( + " " + line for line in chapter_jsonld_str.split("\n") + ) # Inject both OpenGraph and JSON-LD into chapter HTML - if inject_all_metadata_into_html(chapter_html_path, chapter_og_tags, chapter_jsonld_str): + if inject_all_metadata_into_html( + chapter_html_path, chapter_og_tags, chapter_jsonld_str + ): chapters_injected += 1 else: - logger.warning("Failed to inject metadata into chapter: %s", chapter.get("name", "Unknown")) + logger.warning( + "Failed to inject metadata into chapter: %s", chapter.get("name", "Unknown") + ) logger.info("Injected metadata into %d chapter pages", chapters_injected) diff --git a/quadriga/metadata/run_all.py b/quadriga/metadata/run_all.py index 598f921..e0256e9 100644 --- a/quadriga/metadata/run_all.py +++ b/quadriga/metadata/run_all.py @@ -3,12 +3,12 @@ from __future__ import annotations import logging -import os import sys +from pathlib import Path # Add current working directory to sys.path if not present # This allows the script to run with python -m without package installation -cwd = os.getcwd() +cwd = str(Path.cwd()) if cwd not in sys.path: sys.path.insert(0, cwd) @@ -17,9 +17,9 @@ from quadriga.metadata.create_rdfxml import create_rdfxml from quadriga.metadata.create_zenodo_json import create_zenodo_json from quadriga.metadata.extract_from_book_config import extract_and_update +from quadriga.metadata.extract_from_lernziele import merge_learning_objectives_into_metadata from quadriga.metadata.update_citation_cff import update_citation from quadriga.metadata.validate_schema import validate_schema -from quadriga.metadata.extract_from_lernziele import merge_learning_objectives_into_metadata logger = logging.getLogger(__name__) @@ -35,7 +35,7 @@ def main() -> bool | None: ) logger.info("Running all metadata update scripts...") - + # Extract learning objectives try: logger.info("Extracting learning objectives from Lernziele.md...") @@ -45,7 +45,7 @@ def main() -> bool | None: except Exception: logger.exception("Unexpected error during learning objective extraction") return False - + # Validate metadata.yml against QUADRIGA schema first try: logger.info("Validating metadata.yml against QUADRIGA schema...") diff --git a/quadriga/metadata/utils.py b/quadriga/metadata/utils.py index d428943..6f234e2 100644 --- a/quadriga/metadata/utils.py +++ b/quadriga/metadata/utils.py @@ -10,9 +10,14 @@ import logging import re from pathlib import Path +from typing import TYPE_CHECKING import yaml +if TYPE_CHECKING: + from collections.abc import Generator + + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") logger = logging.getLogger(__name__) @@ -70,7 +75,7 @@ def get_file_path(relative_path: str | Path, repo_root: Path | None = None) -> P return repo_root / Path(relative_path) -def iter_toc_files(node: dict | list): +def iter_toc_files(node: dict | list) -> Generator: """Yield all 'file' path strings from a parsed _toc.yml, at any nesting depth. _toc.yml entries can be deeply nested under chapters, sections, and parts. diff --git a/quadriga/metadata/validate_schema.py b/quadriga/metadata/validate_schema.py index c7c3a6b..813d86e 100644 --- a/quadriga/metadata/validate_schema.py +++ b/quadriga/metadata/validate_schema.py @@ -18,9 +18,7 @@ logger = logging.getLogger(__name__) -QUADRIGA_SCHEMA_URL = ( - "https://quadriga-dk.github.io/quadriga-schema/v1.0.0/schema.json" -) +QUADRIGA_SCHEMA_URL = "https://quadriga-dk.github.io/quadriga-schema/v1.0.0/schema.json" # Cache directory and max age (24 hours) SCHEMA_CACHE_DIR = Path.cwd() / ".schema_cache" @@ -65,7 +63,7 @@ def _fetch_json(url: str) -> dict: age = time.time() - cache_file.stat().st_mtime if age < SCHEMA_CACHE_MAX_AGE: logger.debug("Using cached schema for %s (age: %ds)", url, int(age)) - return json.loads(cache_file.read_text(encoding="utf-8")) + return dict(json.loads(cache_file.read_text(encoding="utf-8"))) # Fetch from remote try: @@ -78,14 +76,16 @@ def _fetch_json(url: str) -> dict: cache_file.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") logger.debug("Cached schema for %s", url) except OSError: - logger.debug("Could not write schema cache (read-only filesystem), continuing without cache") + logger.debug( + "Could not write schema cache (read-only filesystem), continuing without cache" + ) - return data + return dict(data) except Exception: # Fall back to stale cache if available if cache_file.exists(): logger.warning("Failed to fetch %s, using stale cache", url) - return json.loads(cache_file.read_text(encoding="utf-8")) + return dict(json.loads(cache_file.read_text(encoding="utf-8"))) raise @@ -112,7 +112,7 @@ def _validate_metadata( from referencing.jsonschema import DRAFT202012 except ImportError: logger.warning( - "jsonschema package not installed – skipping schema validation. " + "jsonschema package not installed - skipping schema validation. " "Install it via: pip install jsonschema" ) return True, [] @@ -129,7 +129,7 @@ def retrieve(uri: str) -> Resource: return Resource.from_contents(data, default_specification=DRAFT202012) try: - registry: Registry = Registry(retrieve=retrieve) + registry: Registry = Registry(retrieve=retrieve) # type: ignore[call-arg] validator = Draft202012Validator(main_schema, registry=registry) errors = list(validator.iter_errors(metadata)) except Exception: @@ -159,7 +159,7 @@ def validate_schema() -> bool: logger.error("Could not load metadata.yml for validation.") return False - valid, errors = _validate_metadata(metadata) + valid, errors = _validate_metadata(dict(metadata)) if valid: logger.info("Schema validation passed.") return True From fbbdc4f89dce3e08bc94486b215798d5ac0c2aff Mon Sep 17 00:00:00 2001 From: Hannes Schnaitter Date: Thu, 12 Mar 2026 14:49:05 +0100 Subject: [PATCH 4/4] feat(ci): add lint workflow and gate update-metadata on lint success --- .github/workflows/lint.yml | 26 ++++++++++++++++++++++++++ .github/workflows/update-metadata.yml | 21 ++++++++++++++------- dev-requirements.txt | 1 + 3 files changed, 41 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..0f81258 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,26 @@ +name: Lint + +on: + push: + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version-file: '.python-version' + cache: pip + + - name: Install dependencies + run: | + pip install -r requirements.txt -r dev-requirements.txt types-jsonschema + + - name: Run pre-commit hooks + run: | + pre-commit run --all-files diff --git a/.github/workflows/update-metadata.yml b/.github/workflows/update-metadata.yml index 5e49502..a190623 100644 --- a/.github/workflows/update-metadata.yml +++ b/.github/workflows/update-metadata.yml @@ -1,17 +1,21 @@ name: Update Metadata on: - push: + workflow_run: + workflows: ["Lint"] + types: + - completed workflow_dispatch: # Prevent infinite loops when workflow commits changes concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.ref }} cancel-in-progress: true jobs: update-metadata: runs-on: ubuntu-latest + if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} # Need write permission to push changes permissions: contents: write @@ -19,15 +23,18 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 with: - ref: ${{ github.ref }} + ref: ${{ github.event.workflow_run.head_sha || github.ref }} fetch-depth: 0 - name: Extract version from tag (if triggered by tag) id: extract_version run: | - if [[ "$GITHUB_REF" == refs/tags/v* ]]; then - TAG_NAME=${GITHUB_REF#refs/tags/} - # Remove 'v' prefix if present (v1.0.0 -> 1.0.0) + REF="${{ github.event.workflow_run.head_branch || github.ref }}" + if [[ "$REF" == refs/tags/v* ]] || git tag --points-at HEAD | grep -q '^v'; then + TAG_NAME=$(git tag --points-at HEAD | grep '^v' | head -1) + if [[ -z "$TAG_NAME" && "$REF" == refs/tags/v* ]]; then + TAG_NAME=${REF#refs/tags/} + fi VERSION=${TAG_NAME#v} echo "version=$VERSION" >> $GITHUB_OUTPUT echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT @@ -35,7 +42,7 @@ jobs: echo "Triggered by version tag: $TAG_NAME (version: $VERSION)" else echo "is_tag=false" >> $GITHUB_OUTPUT - echo "Triggered by regular push to: $GITHUB_REF" + echo "Triggered by regular push to: $REF" fi - name: Set up Python diff --git a/dev-requirements.txt b/dev-requirements.txt index 7c80a9b..e4f1ef7 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -3,6 +3,7 @@ mypy ruff pre-commit types-PyYAML +types-jsonschema rdflib jupytext jsonschema