Skip to content

Commit 4e401a3

Browse files
committed
ci: Add script to sync CITATION.cff to other files
1 parent c8f9806 commit 4e401a3

File tree

2 files changed

+279
-0
lines changed

2 files changed

+279
-0
lines changed

.pre-commit-config.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,10 @@ repos:
105105
require_serial: true
106106
args: [--indent=4]
107107
exclude: ^.zenodo.json$
108+
109+
- id: sync_citation_metadata
110+
name: sync citation metadata
111+
entry: CI/sync_citation_metadata.py
112+
language: system
113+
files: ^(CITATION\.cff|\.zenodo\.json|AUTHORS)$
114+
pass_filenames: false

CI/sync_citation_metadata.py

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
#!/usr/bin/env python3
2+
# /// script
3+
# requires-python = ">=3.11"
4+
# dependencies = [
5+
# "typer",
6+
# "rich",
7+
# "pyyaml",
8+
# ]
9+
# ///
10+
"""
11+
Synchronize citation metadata from CITATION.cff to .zenodo.json and AUTHORS.
12+
13+
This script maintains CITATION.cff as the single source of truth for citation
14+
metadata and generates .zenodo.json and AUTHORS files from it.
15+
16+
Usage:
17+
sync_citation_metadata.py generate # Update both files
18+
sync_citation_metadata.py generate --check # Check if files are in sync
19+
"""
20+
21+
import json
22+
import sys
23+
from pathlib import Path
24+
from typing import Annotated
25+
26+
import typer
27+
import yaml
28+
from rich.console import Console
29+
from rich.panel import Panel
30+
31+
app = typer.Typer(
32+
help="Synchronize citation metadata from CITATION.cff",
33+
add_completion=False,
34+
)
35+
console = Console()
36+
37+
38+
def format_author_name(author: dict) -> str:
39+
"""
40+
Format author name from CFF format.
41+
42+
Handles:
43+
- Standard: {given-names} {family-names}
44+
- With particle: {given-names} {name-particle} {family-names}
45+
- Missing given-names: {family-names}
46+
"""
47+
parts = []
48+
if "given-names" in author:
49+
parts.append(author["given-names"])
50+
if "name-particle" in author:
51+
parts.append(author["name-particle"])
52+
parts.append(author["family-names"])
53+
return " ".join(parts)
54+
55+
56+
def extract_orcid_id(orcid: str) -> str:
57+
"""
58+
Extract bare ORCID ID from URL or return as-is.
59+
60+
Transforms:
61+
- https://orcid.org/0000-0002-2298-3605 -> 0000-0002-2298-3605
62+
- 0000-0002-2298-3605 -> 0000-0002-2298-3605
63+
"""
64+
if orcid.startswith("https://orcid.org/"):
65+
return orcid.replace("https://orcid.org/", "")
66+
return orcid
67+
68+
69+
def cff_author_to_zenodo_creator(author: dict) -> dict:
70+
"""
71+
Convert CFF author to Zenodo creator format.
72+
73+
CFF format:
74+
given-names: FirstName
75+
family-names: LastName
76+
affiliation: Institution
77+
orcid: https://orcid.org/XXXX-XXXX-XXXX-XXXX
78+
79+
Zenodo format:
80+
name: FirstName LastName
81+
affiliation: Institution
82+
orcid: XXXX-XXXX-XXXX-XXXX
83+
"""
84+
creator = {
85+
"affiliation": author.get("affiliation", ""),
86+
"name": format_author_name(author),
87+
}
88+
89+
if "orcid" in author:
90+
creator["orcid"] = extract_orcid_id(author["orcid"])
91+
92+
return creator
93+
94+
95+
def cff_authors_to_authors_list(authors: list) -> str:
96+
"""
97+
Generate AUTHORS file content from CFF authors.
98+
99+
Format:
100+
The following people have contributed to the project (in alphabetical order):
101+
102+
- FirstName LastName, Affiliation
103+
- ...
104+
"""
105+
# Sort by family name
106+
sorted_authors = sorted(authors, key=lambda a: a["family-names"])
107+
108+
lines = ["The following people have contributed to the project (in alphabetical order):\n"]
109+
110+
for author in sorted_authors:
111+
name = format_author_name(author)
112+
affiliation = author.get("affiliation", "")
113+
if affiliation:
114+
lines.append(f"- {name}, {affiliation}")
115+
else:
116+
lines.append(f"- {name}")
117+
118+
return "\n".join(lines) + "\n"
119+
120+
121+
def generate_zenodo_json(cff_data: dict, existing_zenodo: dict) -> dict:
122+
"""
123+
Generate .zenodo.json content from CITATION.cff.
124+
125+
Preserves static fields from existing .zenodo.json and updates:
126+
- creators (from authors)
127+
- version
128+
- title (formatted as "acts-project/acts: v{version}")
129+
"""
130+
# Start with existing data to preserve static fields
131+
zenodo_data = existing_zenodo.copy()
132+
133+
# Update from CITATION.cff
134+
zenodo_data["creators"] = [
135+
cff_author_to_zenodo_creator(author) for author in cff_data["authors"]
136+
]
137+
zenodo_data["version"] = cff_data["version"]
138+
zenodo_data["title"] = f"acts-project/acts: v{cff_data['version']}"
139+
140+
return zenodo_data
141+
142+
143+
@app.command()
144+
def generate(
145+
citation_file: Annotated[
146+
Path, typer.Option(help="Path to CITATION.cff")
147+
] = Path("CITATION.cff"),
148+
zenodo_file: Annotated[
149+
Path, typer.Option(help="Path to .zenodo.json")
150+
] = Path(".zenodo.json"),
151+
authors_file: Annotated[
152+
Path, typer.Option(help="Path to AUTHORS")
153+
] = Path("AUTHORS"),
154+
check: Annotated[
155+
bool, typer.Option("--check", help="Check if files are in sync (for pre-commit)")
156+
] = False,
157+
):
158+
"""
159+
Generate .zenodo.json and AUTHORS from CITATION.cff.
160+
161+
In default mode, updates the files.
162+
In --check mode, verifies files are in sync and exits with code 1 if not.
163+
"""
164+
# Read CITATION.cff
165+
if not citation_file.exists():
166+
console.print(f"[red]Error: {citation_file} not found[/red]")
167+
raise typer.Exit(1)
168+
169+
try:
170+
with open(citation_file, "r", encoding="utf-8") as f:
171+
cff_data = yaml.safe_load(f)
172+
except yaml.YAMLError as e:
173+
console.print(f"[red]Error parsing {citation_file}: {e}[/red]")
174+
raise typer.Exit(1)
175+
176+
# Validate required fields
177+
if "authors" not in cff_data:
178+
console.print(f"[red]Error: 'authors' field missing in {citation_file}[/red]")
179+
raise typer.Exit(1)
180+
if "version" not in cff_data:
181+
console.print(f"[red]Error: 'version' field missing in {citation_file}[/red]")
182+
raise typer.Exit(1)
183+
184+
# Read existing .zenodo.json
185+
existing_zenodo = {}
186+
if zenodo_file.exists():
187+
try:
188+
with open(zenodo_file, "r", encoding="utf-8") as f:
189+
existing_zenodo = json.load(f)
190+
except json.JSONDecodeError as e:
191+
console.print(f"[red]Error parsing {zenodo_file}: {e}[/red]")
192+
raise typer.Exit(1)
193+
else:
194+
console.print(f"[yellow]Warning: {zenodo_file} not found, will create new file[/yellow]")
195+
196+
# Generate new content
197+
new_zenodo_data = generate_zenodo_json(cff_data, existing_zenodo)
198+
new_zenodo_content = json.dumps(
199+
new_zenodo_data, indent=2, ensure_ascii=False
200+
) + "\n"
201+
202+
new_authors_content = cff_authors_to_authors_list(cff_data["authors"])
203+
204+
# Check mode: compare with existing files
205+
if check:
206+
files_out_of_sync = []
207+
208+
# Check .zenodo.json
209+
if zenodo_file.exists():
210+
existing_zenodo_content = zenodo_file.read_text(encoding="utf-8")
211+
if existing_zenodo_content != new_zenodo_content:
212+
files_out_of_sync.append(str(zenodo_file))
213+
else:
214+
files_out_of_sync.append(str(zenodo_file))
215+
216+
# Check AUTHORS
217+
if authors_file.exists():
218+
existing_authors_content = authors_file.read_text(encoding="utf-8")
219+
if existing_authors_content != new_authors_content:
220+
files_out_of_sync.append(str(authors_file))
221+
else:
222+
files_out_of_sync.append(str(authors_file))
223+
224+
if files_out_of_sync:
225+
console.print()
226+
console.print(
227+
Panel(
228+
"[red]Citation metadata files are out of sync![/red]\n\n"
229+
+ "\n".join(f" - {f}" for f in files_out_of_sync)
230+
+ "\n\n"
231+
+ f"Run: [cyan]python {sys.argv[0]} generate[/cyan] to update them.",
232+
title="Pre-commit Check Failed",
233+
border_style="red",
234+
)
235+
)
236+
raise typer.Exit(1)
237+
else:
238+
console.print("[green]✓[/green] Citation metadata files are in sync")
239+
raise typer.Exit(0)
240+
241+
# Write mode: update files
242+
console.print()
243+
console.print(
244+
Panel(
245+
f"Generating citation metadata from [cyan]{citation_file}[/cyan]",
246+
border_style="green",
247+
)
248+
)
249+
console.print()
250+
251+
# Write .zenodo.json
252+
try:
253+
zenodo_file.write_text(new_zenodo_content, encoding="utf-8")
254+
console.print(f"[green]✓[/green] Updated [cyan]{zenodo_file}[/cyan]")
255+
except Exception as e:
256+
console.print(f"[red]Error writing {zenodo_file}: {e}[/red]")
257+
raise typer.Exit(1)
258+
259+
# Write AUTHORS
260+
try:
261+
authors_file.write_text(new_authors_content, encoding="utf-8")
262+
console.print(f"[green]✓[/green] Updated [cyan]{authors_file}[/cyan]")
263+
except Exception as e:
264+
console.print(f"[red]Error writing {authors_file}: {e}[/red]")
265+
raise typer.Exit(1)
266+
267+
console.print()
268+
console.print("[green]✓[/green] Citation metadata synchronized successfully")
269+
270+
271+
if __name__ == "__main__":
272+
app()

0 commit comments

Comments
 (0)