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

1# SPDX-FileCopyrightText: 2026 Arcangelo Massari <github@a.arcangelomassari.com> 

2# 

3# SPDX-License-Identifier: ISC 

4 

5from __future__ import annotations 

6 

7import subprocess 

8from io import StringIO 

9from pathlib import Path 

10 

11from ruamel.yaml import YAML 

12from ruamel.yaml.comments import CommentedMap, CommentedSeq 

13 

14from software_citation_sync.config import CitationConfig, ProjectMetadata 

15 

16 

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 

25 

26 

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() 

35 

36 

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 ) 

49 

50 

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 

63 

64 

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 

72 

73 

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 

78 

79 

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",) 

87 

88 

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) 

94 

95 

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"])