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/.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/.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.
\nDieses interaktive Lehrbuch kann als Web-Version verwendet, zur individuellen Anpassung heruntergeladen werden und steht darüber hinaus auch auf GitHub zur Verfügung.
\nDie QUADRIGA-OER sind nach einem einheitlichen Template gestaltet, werden nach einem standardisierten Verfahren qualitätsgeprüft und mit Metadaten nach dem QUADRIGA-Metadatenschema ausgezeichnet.
\nQUADRIGA Datenkompetenzzentrum \nQUADRIGA 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.
\nZu 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.
\nQUADRIGA 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\nMehr zum Aufbau und zur Umsetzung des Projekts können Sie im Umsetzungskonzept erfahren.
\n\nWeitere 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.
\nDieses interaktive Lehrbuch kann als Web-Version verwendet, zur individuellen Anpassung heruntergeladen werden und steht darüber hinaus auch auf GitHub zur Verfügung.
\nDie QUADRIGA-OER sind nach einem einheitlichen Template gestaltet, werden nach einem standardisierten Verfahren qualitätsgeprüft und mit Metadaten nach dem QUADRIGA-Metadatenschema ausgezeichnet.
\nQUADRIGA Datenkompetenzzentrum \nQUADRIGA 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.
\nZu 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.
\nQUADRIGA 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\nMehr zum Aufbau und zur Umsetzung des Projekts können Sie im Umsetzungskonzept erfahren.
\n\nWeitere 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/dev-requirements.txt b/dev-requirements.txt
index 5c2d287..e4f1ef7 100644
--- a/dev-requirements.txt
+++ b/dev-requirements.txt
@@ -1,7 +1,9 @@
pyyaml
mypy
ruff
+pre-commit
types-PyYAML
+types-jsonschema
rdflib
jupytext
jsonschema
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"""
- '''
-
+ """
+
# Generate draggable options
draggable_options = ""
for i, option in enumerate(options):
- draggable_options += f'''
+ draggable_options += f"""
{option}
- '''
-
- return f'''
+ """
+
+ return f"""
{title}
-
+
-
+
Ziehen Sie diese zu den passenden Beschreibungen
{draggable_options}
-
+
Antwort prüfen
Zurücksetzen
-
+
- '''
-
-
\ 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
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]