Coverage for src/somesy/cli/init.py: 63%

120 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-18 08:48 +0000

1"""Set config files for somesy.""" 

2 

3import logging 

4from pathlib import Path 

5from typing import Any 

6 

7import typer 

8 

9from somesy.commands import init_config, write_somesy_file 

10from somesy.core.core import discover_input 

11from somesy.core.log import SomesyLogLevel, set_log_level 

12from somesy.core.models import Person, SomesyConfig 

13from somesy.core.types import LicenseEnum 

14from somesy.git.harvest import harvest as harvest_git 

15from somesy.harvest import harvest_sources 

16from somesy.merge import merge_metadata 

17 

18from .util import file_arg_config, wrap_exceptions 

19 

20logger = logging.getLogger("somesy") 

21app = typer.Typer() 

22 

23 

24def _prompt_missing_metadata( 

25 sources: list[tuple[Path, dict[str, Any]]], 

26 git_metadata, 

27 non_interactive: bool = False, 

28) -> dict[str, Any]: 

29 """Prompt or warn for required metadata that harvesting did not provide.""" 

30 harvested = [content for _, content in sources] 

31 if git_metadata is not None: 

32 harvested.append( 

33 git_metadata.model_dump(exclude_none=True, exclude_defaults=False) 

34 ) 

35 fallback: dict[str, Any] = {} 

36 

37 for field, prompt in { 

38 "name": "Project name", 

39 "description": "Project description", 

40 }.items(): 

41 if not any(source.get(field) for source in harvested): 

42 if non_interactive: 

43 logger.warning("Missing required metadata: %s", field) 

44 else: 

45 fallback[field] = typer.prompt(prompt) 

46 

47 if not any(source.get("license") for source in harvested): 

48 if non_interactive: 

49 logger.warning("Missing required metadata: license") 

50 else: 

51 while True: 

52 value = typer.prompt("SPDX license") 

53 try: 

54 fallback["license"] = LicenseEnum(value) 

55 break 

56 except ValueError: 

57 typer.echo(f"Unknown SPDX license: {value}") 

58 

59 def is_author(person: Any) -> bool: 

60 return ( 

61 person.get("author", False) 

62 if isinstance(person, dict) 

63 else getattr(person, "author", False) 

64 ) 

65 

66 has_author = any( 

67 any(is_author(person) for person in source.get(key, []) or []) 

68 for source in harvested 

69 for key in ("people", "entities", "authors") 

70 ) 

71 if not has_author: 

72 if non_interactive: 

73 logger.warning("Missing required metadata: author") 

74 else: 

75 author_type = typer.prompt( 

76 "Author type", type=str, default="person" 

77 ).lower() 

78 while author_type not in {"person", "entity"}: 

79 typer.echo("Author type must be 'person' or 'entity'.") 

80 author_type = typer.prompt("Author type", default="person").lower() 

81 if author_type == "person": 

82 person = { 

83 "given_names": typer.prompt("Author given names"), 

84 "family_names": typer.prompt("Author family names"), 

85 "author": True, 

86 } 

87 email = typer.prompt("Author email", default="") 

88 if email: 

89 person["email"] = email 

90 fallback["people"] = [Person(**person)] 

91 else: 

92 name = typer.prompt("Author organization") 

93 fallback["entities"] = [{"name": name, "author": True}] 

94 email = typer.prompt("Author email", default="") 

95 if email: 

96 fallback["entities"][0]["email"] = email 

97 

98 return fallback 

99 

100 

101@app.callback(invoke_without_command=True) 

102@wrap_exceptions 

103def initialize( 

104 ctx: typer.Context, 

105 output_file: Path = typer.Option( 

106 Path("somesy.toml"), 

107 "--output-file", 

108 "-o", 

109 help="Path for the generated somesy.toml file (default: somesy.toml).", 

110 **file_arg_config, 

111 ), 

112 overwrite: bool = typer.Option(False, "--overwrite"), 

113 non_interactive: bool = typer.Option( 

114 False, 

115 "--non-interactive", 

116 help="Do not prompt for missing metadata; report warnings instead.", 

117 ), 

118): 

119 """Harvest project metadata and create a somesy.toml file.""" 

120 if ctx.invoked_subcommand is not None: 

121 return 

122 root = Path.cwd() 

123 sources = harvest_sources(root) 

124 git_metadata = harvest_git(root) 

125 fallback = _prompt_missing_metadata(sources, git_metadata, non_interactive) 

126 source_content = [content for _, content in sources] 

127 if fallback: 

128 source_content.append(fallback) 

129 metadata = merge_metadata( 

130 source_content, git_metadata, allow_incomplete=non_interactive 

131 ) 

132 source_names = {path.name for path, _ in sources} 

133 incomplete = ( 

134 any( 

135 getattr(metadata, field, None) is None 

136 for field in ("name", "description", "license") 

137 ) 

138 or not metadata.authors() 

139 ) 

140 config = None 

141 if source_names or incomplete: 

142 config_data = { 

143 f"no_sync_{key}": filename not in source_names 

144 for key, filename in { 

145 "pyproject": "pyproject.toml", 

146 "package_json": "package.json", 

147 "julia": "Project.toml", 

148 "fortran": "fpm.toml", 

149 "pom_xml": "pom.xml", 

150 "mkdocs": "mkdocs.yml", 

151 "rust": "Cargo.toml", 

152 }.items() 

153 } 

154 config = SomesyConfig.model_validate(config_data) 

155 output = output_file if output_file.is_absolute() else root / output_file 

156 write_somesy_file(metadata, output, config=config, overwrite=overwrite) 

157 typer.echo(f"Created {output}") 

158 

159 

160@app.command() 

161@wrap_exceptions 

162def config(): 

163 """Set CLI configs for somesy.""" 

164 # check if input file exists, if not, try to find it from default list 

165 input_file_default = discover_input() 

166 

167 # prompt for inputs 

168 input_file = Path(typer.prompt("Input file path", default=input_file_default)) 

169 options: dict[str, Any] = {"input_file": Path(input_file)} 

170 

171 # ---- 

172 

173 options["no_sync_cff"] = not typer.confirm( 

174 "Do you want to sync to a CFF file?", default=True 

175 ) 

176 if cff_file := typer.prompt("CFF file path", default="CITATION.cff"): 

177 options["cff_file"] = cff_file 

178 

179 options["no_sync_codemeta"] = not typer.confirm( 

180 "Do you want to sync to a codemeta.json file?", default=True 

181 ) 

182 if codemeta_file := typer.prompt( 

183 "codemeta.json file path", default="codemeta.json" 

184 ): 

185 options["codemeta_file"] = codemeta_file 

186 

187 options["no_sync_pyproject"] = not typer.confirm( 

188 "Do you want to sync to a pyproject.toml file?", default=True 

189 ) 

190 if pyproject_file := typer.prompt( 

191 "pyproject.toml file path", default="pyproject.toml" 

192 ): 

193 options["pyproject_file"] = pyproject_file 

194 

195 options["sync_package_json"] = typer.confirm( 

196 "Do you want to sync to a package.json file?", default=False 

197 ) 

198 if package_json_file := typer.prompt( 

199 "package.json file path", default="package.json" 

200 ): 

201 options["package_json_file"] = package_json_file 

202 

203 options["no_sync_julia"] = not typer.confirm( 

204 "Do you want to sync to a Project.toml(Julia) file?", default=True 

205 ) 

206 if julia_file := typer.prompt( 

207 "Project.toml (Julia) file path", default="Project.toml" 

208 ): 

209 options["julia_file"] = julia_file 

210 

211 options["no_sync_fortran"] = not typer.confirm( 

212 "Do you want to sync to a fpm.toml(fortran) file?", default=True 

213 ) 

214 fortran_file = typer.prompt("fpm.toml(fortran) file path", default="fpm.toml") 

215 if fortran_file is not None or fortran_file != "": 

216 options["fortran_file"] = fortran_file 

217 

218 options["no_sync_pom_xml"] = not typer.confirm( 

219 "Do you want to sync to a pom.xml file?", default=True 

220 ) 

221 if pom_xml_file := typer.prompt("pom.xml file path", default="pom.xml"): 

222 options["pom_xml_file"] = pom_xml_file 

223 

224 options["no_sync_mkdocs"] = not typer.confirm( 

225 "Do you want to sync to a mkdocs.yml file?", default=True 

226 ) 

227 if mkdocs_file := typer.prompt("mkdocs.yml file path", default="mkdocs.yml"): 

228 options["mkdocs_file"] = mkdocs_file 

229 

230 options["no_sync_rust"] = not typer.confirm( 

231 "Do you want to sync to a Cargo.toml file?", default=True 

232 ) 

233 if rust_file := typer.prompt("Cargo.toml file path", default="Cargo.toml"): 

234 options["rust_file"] = rust_file 

235 

236 # ---- 

237 

238 options["show_info"] = typer.confirm( 

239 "Do you want to show info about the sync process?" 

240 ) 

241 options["verbose"] = typer.confirm("Do you want to show verbose logs?") 

242 options["debug"] = typer.confirm("Do you want to show debug logs?") 

243 

244 set_log_level( 

245 SomesyLogLevel.from_flags( 

246 debug=options["debug"], 

247 verbose=options["verbose"], 

248 info=options["show_info"], 

249 ) 

250 ) 

251 

252 logger.debug(f"CLI options entered: {options}") 

253 

254 init_config(input_file, options) 

255 logger.info( 

256 f"[bold green]Input file is updated/created at {input_file}[/bold green]" 

257 )