diff --git a/.github/scripts/generate_metadata_files.py b/.github/scripts/generate_metadata_files.py
new file mode 100644
index 00000000..2a3744eb
--- /dev/null
+++ b/.github/scripts/generate_metadata_files.py
@@ -0,0 +1,106 @@
+"""Generate CITATION.cff and bioschemas.yml from metadata.yml."""
+
+import json
+
+import yaml
+
+METADATA_FILE = "metadata.yml"
+CITATION_FILE = "CITATION.cff"
+BIOSCHEMAS_FILE = "bioschemas.yml"
+
+TITLE_SUFFIX = " - CodeRefinery lesson"
+
+SPDX_LICENSE_URLS = {
+ "CC-BY-4.0": "https://creativecommons.org/licenses/by/4.0/",
+}
+
+
+def load_yaml(path):
+ with open(path, encoding="utf-8") as f:
+ return yaml.safe_load(f)
+
+
+def license_url(spdx_id):
+ return SPDX_LICENSE_URLS.get(spdx_id, spdx_id)
+
+
+def bioschemas_authors(authors):
+ result = []
+ for author in authors:
+ if "family-names" in author:
+ result.append(
+ {
+ "@type": "Person",
+ "name": f"{author['given-names']} {author['family-names']}",
+ }
+ )
+ else:
+ result.append({"@type": "Organization", "name": author["name"]})
+ return result
+
+
+def write_citation_cff(meta):
+ citation = {
+ "cff-version": "1.2.0",
+ "message": (
+ "If you use this lesson material, please cite it using "
+ "these metadata."
+ ),
+ "authors": meta["authors"],
+ "title": meta["title"] + TITLE_SUFFIX,
+ "type": "dataset",
+ "abstract": meta.get("abstract", ""),
+ "version": meta["version"],
+ "doi": meta["doi"],
+ "date-released": meta["version"],
+ "url": meta["url"],
+ "license": meta["license"],
+ "repository-code": meta["repository-code"],
+ }
+
+ if meta.get("maintainers"):
+ citation["contact"] = meta["maintainers"]
+
+ with open(CITATION_FILE, "w", encoding="utf-8") as f:
+ yaml.safe_dump(citation, f, sort_keys=False, allow_unicode=True)
+
+
+def write_bioschemas(meta):
+ bioschemas = {
+ "@context": "https://schema.org/",
+ "@type": "LearningResource",
+ "@id": meta["url"],
+ "description": meta.get("abstract", ""),
+ "keywords": ", ".join(meta.get("keywords", [])),
+ "name": meta["title"] + TITLE_SUFFIX,
+ "author": bioschemas_authors(meta["authors"]),
+ # Disabled for now.
+ # "maintainer": bioschemas_authors(meta.get("maintainers", [])),
+ "about": meta.get("abstract", ""),
+ "audience": meta.get("audience", ""),
+ "competencyRequired": meta.get("competencyRequired", ""),
+ "educationalLevel": meta.get("educationalLevel", ""),
+ "identifier": f"https://doi.org/{meta['doi']}",
+ "inLanguage": meta.get("inLanguage", ""),
+ "learningResourceType": meta.get("learningResourceType", ""),
+ "license": license_url(meta["license"]),
+ "teaches": meta.get("teaches", ""),
+ "url": meta["url"],
+ "accessibilitySummary": meta.get("accessibilitySummary", ""),
+ "isPartOf": meta.get("isPartOf", ""),
+ "version": meta["version"],
+ }
+
+ with open(BIOSCHEMAS_FILE, "w", encoding="utf-8") as f:
+ json.dump(bioschemas, f, indent=4)
+ f.write("\n")
+
+
+def main():
+ meta = load_yaml(METADATA_FILE)
+ write_citation_cff(meta)
+ write_bioschemas(meta)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/scripts/publish_to_zenodo.py b/.github/scripts/publish_to_zenodo.py
index b3057f62..031a8cec 100644
--- a/.github/scripts/publish_to_zenodo.py
+++ b/.github/scripts/publish_to_zenodo.py
@@ -13,6 +13,8 @@
BASE_URL = "https://zenodo.org/api"
+TITLE_SUFFIX = " - CodeRefinery lesson"
+
json_headers = {
"Authorization": f"Bearer {ACCESS_TOKEN}",
@@ -23,47 +25,50 @@
"Authorization": f"Bearer {ACCESS_TOKEN}"
}
-def load_citation():
- with open("CITATION.cff", encoding="utf-8") as f:
+def load_metadata():
+ with open("metadata.yml", encoding="utf-8") as f:
return yaml.safe_load(f)
-def cff_to_creators(authors):
+def authors_to_creators(authors):
creators = []
for author in authors:
if "family-names" in author:
- creators.append(
- {
- "name": (
- f"{author['family-names']}, "
- f"{author['given-names']}"
- )
- }
- )
+ creator = {
+ "name": (
+ f"{author['family-names']}, "
+ f"{author['given-names']}"
+ )
+ }
else:
- creators.append({"name": author["name"]})
+ creator = {"name": author["name"]}
+
+ if author.get("orcid"):
+ creator["orcid"] = author["orcid"].removeprefix("https://orcid.org/")
+
+ creators.append(creator)
return creators
-# Load metadata from CITATION.cff
+# Load metadata from metadata.yml
-cff = load_citation()
+meta = load_metadata()
-description = cff.get("abstract", "")
+description = meta.get("abstract", "")
-if cff.get("repository-code"):
+if meta.get("repository-code"):
description += (
f'
Source code: ' - f'{cff["repository-code"]}
' + f'{meta["repository-code"]}' ) -if cff.get("url"): +if meta.get("url"): description += ( f'Lesson website: ' - f'{cff["url"]}
' + f'{meta["url"]}' ) description += ( @@ -72,15 +77,21 @@ def cff_to_creators(authors): ) metadata = { - "title": cff["title"], - "upload_type": "dataset", + "title": meta["title"] + TITLE_SUFFIX, + "upload_type": "lesson", "description": description, - "creators": cff_to_creators(cff["authors"]), - "keywords": cff.get("keywords", []), - "version": str(cff.get("version")) if cff.get("version") is not None else None, - "license": cff.get("license"), + "creators": authors_to_creators(meta["authors"]), + "keywords": meta.get("keywords", []), + "version": meta.get("version"), + "license": meta.get("license"), } +if meta.get("maintainers"): + contributors = authors_to_creators(meta["maintainers"]) + for contributor in contributors: + contributor["type"] = "ContactPerson" + metadata["contributors"] = contributors + # Discard any unpublished draft left over from a previous failed run. # Zenodo only allows one unpublished new-version draft per concept at @@ -161,45 +172,14 @@ def cff_to_creators(authors): print(f"Removed inherited file {f['filename']}") - -# Upload release archive - +# Upload lesson PDF from the gh-pages branch +# (built by sphinx.yml as OUTPUT_BASENAME.pdf, OUTPUT_BASENAME being +# "{owner}-{repo}") tag = os.environ["GITHUB_REF_NAME"] repo_name = os.environ['GITHUB_REPOSITORY'] owner, repo = repo_name.split("/", 1) - -archive_url = ( - f"https://github.com/" - f"{repo_name}" - f"/archive/refs/tags/{tag}.zip" -) - -archive_name = f"{owner}-{repo}-{tag}.zip" - -download = requests.get(archive_url) -download.raise_for_status() - -with open(archive_name, "wb") as fp: - fp.write(download.content) - -with open(archive_name, "rb") as fp: - r = requests.put( - f"{bucket_url}/{archive_name}", - data=fp, - headers=upload_headers - ) - -r.raise_for_status() - -print(f"Uploaded {archive_name}") - - -# Upload lesson PDF from the gh-pages branch -# (built by sphinx.yml as OUTPUT_BASENAME.pdf, OUTPUT_BASENAME being -# "{owner}-{repo}") - pdf_source_name = f"{owner}-{repo}.pdf" pdf_name = f"{owner}-{repo}-{tag}.pdf" @@ -225,29 +205,33 @@ def cff_to_creators(authors): print(f"Uploaded {pdf_name}") +# Upload release archive -# Show the PDF first (Zenodo previews the first file in the list). -r = requests.get( - f"{BASE_URL}/deposit/depositions/{deposition_id}/files", - headers=json_headers, +archive_url = ( + f"https://github.com/" + f"{repo_name}" + f"/archive/refs/tags/{tag}.zip" ) -r.raise_for_status() +archive_name = f"{owner}-{repo}-{tag}.zip" -files = r.json() +download = requests.get(archive_url) +download.raise_for_status() -sort_order = sorted(files, key=lambda f: f["filename"] != pdf_name) +with open(archive_name, "wb") as fp: + fp.write(download.content) -r = requests.put( - f"{BASE_URL}/deposit/depositions/{deposition_id}/files/sort", - data=json.dumps([{"id": f["id"]} for f in sort_order]), - headers=json_headers, -) +with open(archive_name, "rb") as fp: + r = requests.put( + f"{bucket_url}/{archive_name}", + data=fp, + headers=upload_headers + ) r.raise_for_status() -print(f"File order: {[f['filename'] for f in sort_order]}") +print(f"Uploaded {archive_name}") # Update metadata diff --git a/.github/workflows/generate-metadata.yml b/.github/workflows/generate-metadata.yml new file mode 100644 index 00000000..8dbc95b3 --- /dev/null +++ b/.github/workflows/generate-metadata.yml @@ -0,0 +1,32 @@ +name: Generate metadata files + +on: + push: + branches: [main] + paths: + - metadata.yml + +jobs: + generate: + name: Generate CITATION.cff and bioschemas.yml + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: pip install pyyaml + + - name: Generate CITATION.cff and bioschemas.yml + run: python .github/scripts/generate_metadata_files.py + + - name: Commit generated files + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add CITATION.cff bioschemas.yml + git diff --staged --quiet || git commit -m "Regenerate CITATION.cff and bioschemas.yml from metadata.yml" + git push diff --git a/.github/workflows/validate-cff.yml b/.github/workflows/validate-cff.yml deleted file mode 100644 index 29b41aba..00000000 --- a/.github/workflows/validate-cff.yml +++ /dev/null @@ -1,20 +0,0 @@ -on: - push: - paths: - - CITATION.cff - workflow_dispatch: - -name: CITATION.cff -jobs: - Validate-CITATION-cff: - runs-on: ubuntu-latest - name: Validate CITATION.cff - env: - GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} - - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Validate CITATION.cff - uses: dieghernan/cff-validator@main diff --git a/.gitignore b/.gitignore index f6cc74cd..33293b46 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ _build/ venv/ .*.sw? *DS_Store +*__pycache__ diff --git a/content/conf.py b/content/conf.py index 36551207..2ecf98c6 100644 --- a/content/conf.py +++ b/content/conf.py @@ -9,9 +9,9 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) +import os +import sys +sys.path.insert(0, os.path.abspath('.')) # -- Project information ----------------------------------------------------- @@ -40,6 +40,7 @@ "sphinx_rtd_theme_ext_color_contrast", "sphinx_coderefinery_branding", "sphinx_bioschemas", + "lesson_metadata", ] # Settings for myst_nb: diff --git a/content/lesson_metadata.py b/content/lesson_metadata.py new file mode 100644 index 00000000..c459f36d --- /dev/null +++ b/content/lesson_metadata.py @@ -0,0 +1,65 @@ +"""Sphinx directive that renders metadata.yml as a table.""" + +import os + +import yaml +from docutils import nodes +from docutils.parsers.rst import Directive + + +def _author_name(author): + if "family-names" in author: + return f"{author['given-names']} {author['family-names']}" + return author["name"] + + +class LessonMetadataDirective(Directive): + """Renders the repository's metadata.yml as an HTML table.""" + + has_content = False + required_arguments = 0 + optional_arguments = 0 + + def run(self): + env = self.state.document.settings.env + metadata_path = os.path.join(env.srcdir, "..", "metadata.yml") + + with open(metadata_path, encoding="utf-8") as f: + meta = yaml.safe_load(f) + + fields = [ + ("Title", meta.get("title")), + ( + "Authors", + ", ".join(_author_name(a) for a in meta.get("authors", [])), + ), + ("Version", meta.get("version", "")), + ("DOI", meta.get("doi")), + ("License", meta.get("license")), + ("Lesson website", meta.get("url")), + ("Source repository", meta.get("repository-code")), + ("Keywords", ", ".join(meta.get("keywords", []))), + ("Educational level", meta.get("educationalLevel")), + ("Language", meta.get("inLanguage")), + ("Teaches", meta.get("teaches")), + ("Is part of", meta.get("isPartOf")), + ("Audience", meta.get("audience")), + ("Competency required", meta.get("competencyRequired")), + ("Accessibility summary", meta.get("accessibilitySummary")), + ("Learning resource type", meta.get("learningResourceType")), + ] + + rows = "\n".join( + f"