File indexing completed on 2026-04-17 07:46:15
0001
0002
0003
0004
0005
0006
0007
0008
0009
0010 """
0011 Synchronize citation metadata from CITATION.cff to .zenodo.json and AUTHORS.
0012
0013 This script maintains CITATION.cff as the single source of truth for citation
0014 metadata and generates .zenodo.json and AUTHORS files from it.
0015
0016 Usage:
0017 sync_citation_metadata.py # Update both files
0018 sync_citation_metadata.py --check # Check if files are in sync
0019 """
0020
0021 import json
0022 import sys
0023 from pathlib import Path
0024 from typing import Annotated
0025
0026 import typer
0027 import yaml
0028 from rich.console import Console
0029 from rich.panel import Panel
0030
0031 app = typer.Typer(
0032 help="Synchronize citation metadata from CITATION.cff",
0033 add_completion=False,
0034 )
0035 console = Console()
0036
0037
0038 def format_author_name(author: dict) -> str:
0039 """
0040 Format author name from CFF format.
0041
0042 Handles:
0043 - Standard: {given-names} {family-names}
0044 - With particle: {given-names} {name-particle} {family-names}
0045 - Missing given-names: {family-names}
0046 """
0047 parts = []
0048 if "given-names" in author:
0049 parts.append(author["given-names"])
0050 if "name-particle" in author:
0051 parts.append(author["name-particle"])
0052 parts.append(author["family-names"])
0053 return " ".join(parts)
0054
0055
0056 def extract_orcid_id(orcid: str) -> str:
0057 """
0058 Extract bare ORCID ID from URL or return as-is.
0059
0060 Transforms:
0061 - https://orcid.org/0000-0002-2298-3605 -> 0000-0002-2298-3605
0062 - 0000-0002-2298-3605 -> 0000-0002-2298-3605
0063 """
0064 if not orcid.startswith("https://orcid.org/"):
0065 raise ValueError(f"Invalid ORCID format: {orcid}")
0066 return orcid.replace("https://orcid.org/", "")
0067
0068
0069 def cff_author_to_zenodo_creator(author: dict) -> dict:
0070 """
0071 Convert CFF author to Zenodo creator format.
0072
0073 CFF format:
0074 given-names: FirstName
0075 family-names: LastName
0076 affiliation: Institution
0077 orcid: https://orcid.org/XXXX-XXXX-XXXX-XXXX
0078
0079 Zenodo format:
0080 name: FirstName LastName
0081 affiliation: Institution
0082 orcid: XXXX-XXXX-XXXX-XXXX
0083 """
0084 creator = {
0085 "affiliation": author.get("affiliation", ""),
0086 "name": format_author_name(author),
0087 }
0088
0089 if "orcid" in author:
0090 creator["orcid"] = extract_orcid_id(author["orcid"])
0091
0092 return creator
0093
0094
0095 def cff_authors_to_authors_list(authors: list) -> str:
0096 """
0097 Generate AUTHORS file content from CFF authors.
0098
0099 Format:
0100 The following people have contributed to the project (in alphabetical order):
0101
0102 - FirstName LastName, Affiliation
0103 - ...
0104
0105 Not associated with scientific/academic organisations:
0106
0107 - FirstName LastName
0108 - ...
0109
0110 See also the contributors list on github:
0111 https://github.com/acts-project/acts/graphs/contributors
0112 """
0113
0114 affiliated = [a for a in authors if a.get("affiliation")]
0115 unaffiliated = [a for a in authors if not a.get("affiliation")]
0116
0117
0118 affiliated.sort(key=lambda a: a["family-names"])
0119 unaffiliated.sort(key=lambda a: a["family-names"])
0120
0121 lines = [
0122 "The following people have contributed to the project (in alphabetical order):\n"
0123 ]
0124
0125
0126 for author in affiliated:
0127 name = format_author_name(author)
0128 affiliation = author["affiliation"]
0129 lines.append(f"- {name}, {affiliation}")
0130
0131
0132 if unaffiliated:
0133 lines.append("\nNot associated with scientific/academic organisations:\n")
0134 for author in unaffiliated:
0135 name = format_author_name(author)
0136 lines.append(f"- {name}")
0137
0138
0139 lines.append("\nSee also the contributors list on github:")
0140 lines.append("https://github.com/acts-project/acts/graphs/contributors")
0141
0142 return "\n".join(lines) + "\n"
0143
0144
0145 def generate_zenodo_json(cff_data: dict, existing_zenodo: dict) -> dict:
0146 """
0147 Generate .zenodo.json content from CITATION.cff.
0148
0149 Preserves static fields from existing .zenodo.json and updates:
0150 - creators (from authors)
0151 - version
0152 - title (formatted as "acts-project/acts: v{version}")
0153 """
0154
0155 zenodo_data = existing_zenodo.copy()
0156
0157
0158 zenodo_data["creators"] = [
0159 cff_author_to_zenodo_creator(author) for author in cff_data["authors"]
0160 ]
0161 zenodo_data["version"] = cff_data["version"]
0162 zenodo_data["title"] = f"acts-project/acts: {cff_data['version']}"
0163
0164 return zenodo_data
0165
0166
0167 @app.command()
0168 def generate(
0169 citation_file: Annotated[Path, typer.Option(help="Path to CITATION.cff")] = Path(
0170 "CITATION.cff"
0171 ),
0172 zenodo_file: Annotated[Path, typer.Option(help="Path to .zenodo.json")] = Path(
0173 ".zenodo.json"
0174 ),
0175 authors_file: Annotated[Path, typer.Option(help="Path to AUTHORS")] = Path(
0176 "AUTHORS"
0177 ),
0178 check: Annotated[
0179 bool,
0180 typer.Option("--check", help="Check if files are in sync"),
0181 ] = False,
0182 ):
0183 """
0184 Generate .zenodo.json and AUTHORS from CITATION.cff.
0185
0186 In default mode, updates the files.
0187 In --check mode, verifies files are in sync and exits with code 1 if not.
0188 """
0189
0190 if not citation_file.exists():
0191 console.print(f"[red]Error: {citation_file} not found[/red]")
0192 raise typer.Exit(1)
0193
0194 try:
0195 with open(citation_file, "r", encoding="utf-8") as f:
0196 cff_data = yaml.safe_load(f)
0197 except yaml.YAMLError as e:
0198 console.print(f"[red]Error parsing {citation_file}: {e}[/red]")
0199 raise typer.Exit(1)
0200
0201
0202 if "authors" not in cff_data:
0203 console.print(f"[red]Error: 'authors' field missing in {citation_file}[/red]")
0204 raise typer.Exit(1)
0205 if "version" not in cff_data:
0206 console.print(f"[red]Error: 'version' field missing in {citation_file}[/red]")
0207 raise typer.Exit(1)
0208
0209
0210 existing_zenodo = {}
0211 if zenodo_file.exists():
0212 try:
0213 with open(zenodo_file, "r", encoding="utf-8") as f:
0214 existing_zenodo = json.load(f)
0215 except json.JSONDecodeError as e:
0216 console.print(f"[red]Error parsing {zenodo_file}: {e}[/red]")
0217 raise typer.Exit(1)
0218 else:
0219 console.print(
0220 f"[yellow]Warning: {zenodo_file} not found, will create new file[/yellow]"
0221 )
0222
0223
0224 new_zenodo_data = generate_zenodo_json(cff_data, existing_zenodo)
0225 new_zenodo_content = (
0226 json.dumps(new_zenodo_data, indent=2, ensure_ascii=False) + "\n"
0227 )
0228
0229 new_authors_content = cff_authors_to_authors_list(cff_data["authors"])
0230
0231
0232 if check:
0233 files_out_of_sync = []
0234
0235
0236 if zenodo_file.exists():
0237 existing_zenodo_content = zenodo_file.read_text(encoding="utf-8")
0238 if existing_zenodo_content != new_zenodo_content:
0239 files_out_of_sync.append(str(zenodo_file))
0240 else:
0241 files_out_of_sync.append(str(zenodo_file))
0242
0243
0244 if authors_file.exists():
0245 existing_authors_content = authors_file.read_text(encoding="utf-8")
0246 if existing_authors_content != new_authors_content:
0247 files_out_of_sync.append(str(authors_file))
0248 else:
0249 files_out_of_sync.append(str(authors_file))
0250
0251 if files_out_of_sync:
0252 console.print()
0253 console.print(
0254 Panel(
0255 "[red]Citation metadata files are out of sync![/red]\n\n"
0256 + "\n".join(f" - {f}" for f in files_out_of_sync)
0257 + "\n\n"
0258 + f"Run: [cyan]python {sys.argv[0]} generate[/cyan] to update them.",
0259 title="Pre-commit Check Failed",
0260 border_style="red",
0261 )
0262 )
0263 raise typer.Exit(1)
0264 else:
0265 console.print("[green]✓[/green] Citation metadata files are in sync")
0266 raise typer.Exit(0)
0267
0268
0269 console.print()
0270 console.print(
0271 Panel(
0272 f"Generating citation metadata from [cyan]{citation_file}[/cyan]",
0273 border_style="green",
0274 )
0275 )
0276 console.print()
0277
0278
0279 try:
0280 zenodo_file.write_text(new_zenodo_content, encoding="utf-8")
0281 console.print(f"[green]✓[/green] Updated [cyan]{zenodo_file}[/cyan]")
0282 except Exception as e:
0283 console.print(f"[red]Error writing {zenodo_file}: {e}[/red]")
0284 raise typer.Exit(1)
0285
0286
0287 try:
0288 authors_file.write_text(new_authors_content, encoding="utf-8")
0289 console.print(f"[green]✓[/green] Updated [cyan]{authors_file}[/cyan]")
0290 except Exception as e:
0291 console.print(f"[red]Error writing {authors_file}: {e}[/red]")
0292 raise typer.Exit(1)
0293
0294 console.print()
0295 console.print("[green]✓[/green] Citation metadata synchronized successfully")
0296
0297
0298 if __name__ == "__main__":
0299 app()