Coverage for src/software_citation_sync/cff.py: 87%
67 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-27 17:58 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-27 17:58 +0000
1# SPDX-FileCopyrightText: 2026 Arcangelo Massari <github@a.arcangelomassari.com>
2#
3# SPDX-License-Identifier: ISC
5from __future__ import annotations
7import subprocess
8from io import StringIO
9from pathlib import Path
11from ruamel.yaml import YAML
12from ruamel.yaml.comments import CommentedMap, CommentedSeq
14from software_citation_sync.config import CitationConfig, ProjectMetadata
17def load_citation(path: Path) -> CommentedMap:
18 yaml = YAML()
19 with path.open(encoding="utf-8") as file:
20 data = yaml.load(file)
21 if not isinstance(data, CommentedMap):
22 msg = f"{path} must contain a YAML mapping"
23 raise TypeError(msg)
24 return data
27def render_citation(data: CommentedMap) -> str:
28 yaml = YAML()
29 yaml.preserve_quotes = True
30 yaml.indent(mapping=2, sequence=4, offset=2)
31 yaml.width = 4096
32 buffer = StringIO()
33 yaml.dump(data, buffer)
34 return buffer.getvalue()
37def render_citation_bibtex(data: CommentedMap, reference: str) -> str:
38 return "\n".join(
39 [
40 f"@software{{{reference},",
41 f"author = {{{_bibtex_authors(data['authors'])}}},",
42 f"title = {{{data['title']}}},",
43 f"url = {{{data['url']}}},",
44 f"version = {{{data['version']}}},",
45 f"year = {{{str(data['date-released'])[:4]}}}",
46 "}",
47 ],
48 )
51def create_citation(project_metadata: ProjectMetadata) -> CommentedMap:
52 data = CommentedMap()
53 data["cff-version"] = "1.2.0"
54 data["message"] = "If you use this software, please cite it using these metadata."
55 data["title"] = project_metadata.title
56 authors = CommentedSeq()
57 for author_name in project_metadata.authors:
58 author = CommentedMap()
59 author["name"] = author_name
60 authors.append(author)
61 data["authors"] = authors
62 return data
65def write_citation(path: Path, data: CommentedMap) -> bool:
66 old_content = path.read_text(encoding="utf-8") if path.exists() else None
67 new_content = render_citation(data)
68 if old_content == new_content:
69 return False
70 path.write_text(new_content, encoding="utf-8")
71 return True
74def apply_citation_metadata(data: CommentedMap, config: CitationConfig) -> None:
75 data["version"] = config.version
76 data["date-released"] = config.date_released
77 data["url"] = config.software_heritage_url
80def validate_with_cffconvert(path: Path) -> tuple[str, ...]:
81 command = ["cffconvert", "--validate", "-i", str(path)]
82 result = subprocess.run(command, capture_output=True, text=True, check=False)
83 if result.returncode == 0:
84 return ()
85 output = "\n".join(part for part in (result.stdout.strip(), result.stderr.strip()) if part)
86 return (output or "cffconvert validation failed",)
89def _bibtex_authors(authors: object) -> str:
90 if not isinstance(authors, CommentedSeq):
91 msg = "CITATION.cff `authors` must be a sequence"
92 raise TypeError(msg)
93 return " and ".join(_bibtex_author(author) for author in authors)
96def _bibtex_author(author: object) -> str:
97 if not isinstance(author, CommentedMap):
98 msg = "CITATION.cff author entries must be mappings"
99 raise TypeError(msg)
100 if "family-names" in author and "given-names" in author:
101 return f"{author['family-names']}, {author['given-names']}"
102 return str(author["name"])