Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-04-17 07:46:15

0001 #!/usr/bin/env python3
0002 # /// script
0003 # requires-python = ">=3.11"
0004 # dependencies = [
0005 #     "typer",
0006 #     "rich",
0007 #     "pyyaml",
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     # Separate authors with and without affiliations
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     # Sort each group by family name
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     # Add affiliated authors
0126     for author in affiliated:
0127         name = format_author_name(author)
0128         affiliation = author["affiliation"]
0129         lines.append(f"- {name}, {affiliation}")
0130 
0131     # Add unaffiliated section if there are any
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     # Add footer
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     # Start with existing data to preserve static fields
0155     zenodo_data = existing_zenodo.copy()
0156 
0157     # Update from CITATION.cff
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     # Read CITATION.cff
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     # Validate required fields
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     # Read existing .zenodo.json
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     # Generate new content
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     # Check mode: compare with existing files
0232     if check:
0233         files_out_of_sync = []
0234 
0235         # Check .zenodo.json
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         # Check AUTHORS
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     # Write mode: update files
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     # Write .zenodo.json
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     # Write AUTHORS
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()