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
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-18 08:48 +0000
1"""Set config files for somesy."""
3import logging
4from pathlib import Path
5from typing import Any
7import typer
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
18from .util import file_arg_config, wrap_exceptions
20logger = logging.getLogger("somesy")
21app = typer.Typer()
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] = {}
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)
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}")
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 )
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
98 return fallback
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}")
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()
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)}
171 # ----
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
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
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
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
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
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
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
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
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
236 # ----
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?")
244 set_log_level(
245 SomesyLogLevel.from_flags(
246 debug=options["debug"],
247 verbose=options["verbose"],
248 info=options["show_info"],
249 )
250 )
252 logger.debug(f"CLI options entered: {options}")
254 init_config(input_file, options)
255 logger.info(
256 f"[bold green]Input file is updated/created at {input_file}[/bold green]"
257 )