1from collections import deque
2import json
3from os import linesep
4from pathlib import Path
5import tomllib
6from typing import Annotated, TypeAlias, cast
7
8import typer
9
10from sphinx_codelinks.analyse.projects import AnalyseProjects
11from sphinx_codelinks.config import (
12 CodeLinksConfig,
13 CodeLinksConfigType,
14 CodeLinksProjectConfigType,
15 anchor_preproc_paths,
16 generate_project_configs,
17)
18from sphinx_codelinks.logger import configure_cli, logger
19from sphinx_codelinks.needextend_write import MarkedObjType, convert_marked_content
20from sphinx_codelinks.source_discover.config import (
21 CommentType,
22 SourceDiscoverConfig,
23 SourceDiscoverConfigType,
24)
25from sphinx_codelinks.source_discover.source_discover import SourceDiscover
26
27app = typer.Typer(
28 no_args_is_help=True, context_settings={"help_option_names": ["-h", "--help"]}
29)
30write_app = typer.Typer(
31 help="Export marked content to other formats", no_args_is_help=True
32)
33app.add_typer(write_app, name="write", rich_help_panel="Sub-menus")
34
35OptVerbose: TypeAlias = Annotated[ # noqa: UP040 # has to be TypeAlias
36 bool,
37 typer.Option(
38 ...,
39 "-v",
40 "--verbose",
41 is_flag=True,
42 help="Show debug information",
43 rich_help_panel="Logging",
44 ),
45]
46OptQuiet: TypeAlias = Annotated[ # noqa: UP040 # has to be TypeAlias
47 bool,
48 typer.Option(
49 ...,
50 "-q",
51 "--quiet",
52 is_flag=True,
53 help="Only show errors and warnings",
54 rich_help_panel="Logging",
55 ),
56]
57
58
59@app.command(no_args_is_help=True)
60def analyse( # noqa: PLR0912 # for CLI, so it needs the branches
61 config: Annotated[
62 Path,
63 typer.Argument(
64 help="The toml config file",
65 show_default=False,
66 dir_okay=False,
67 file_okay=True,
68 exists=True,
69 ),
70 ],
71 projects: Annotated[
72 list[str] | None,
73 typer.Option(
74 "--project",
75 "-p",
76 help="Specify the project name of the config. If not specified, take all",
77 show_default=True,
78 ),
79 ] = None,
80 outdir: Annotated[
81 Path | None,
82 typer.Option(
83 "--outdir",
84 "-o",
85 help="The output directory. When given, this overwrites the config's outdir",
86 show_default=True,
87 dir_okay=True,
88 file_okay=False,
89 exists=True,
90 ),
91 ] = None,
92 verbose: OptVerbose = False,
93 quiet: OptQuiet = False,
94) -> None:
95 """Analyse marked content in source code."""
[docs] 96 # @CLI command to analyse source code and extract traceability markers, IMPL_CLI_ANALYZE, impl, [FE_CLI_ANALYZE]
97 configure_cli(verbose, quiet)
98
99 data: CodeLinksConfigType = load_config_from_toml(config)
100
101 try:
102 codelinks_config = CodeLinksConfig(**data)
103 generate_project_configs(codelinks_config.projects)
104 except TypeError as e:
105 raise typer.BadParameter(str(e)) from e
106
107 errors: deque[str] = deque()
108 if outdir:
109 codelinks_config.outdir = outdir
110
111 project_errors: list[str] = []
112 if projects:
113 for project in projects:
114 if project not in codelinks_config.projects:
115 if not project_errors:
116 project_errors.append("The following projects are not found:")
117 project_errors.append(project)
118 if project_errors:
119 raise typer.BadParameter(f"{linesep.join(project_errors)}")
120
121 specifed_project_configs: dict[str, CodeLinksProjectConfigType] = {}
122 for project, _config in codelinks_config.projects.items():
123 if projects and project not in projects:
124 continue
125 # Get source_discover configuration
126 src_discover_config = _config["source_discover_config"]
127
128 src_discover_errors = src_discover_config.check_schema()
129
130 if src_discover_errors:
131 errors.appendleft("Invalid source discovery configuration:")
132 errors.extend(src_discover_errors)
133 if errors:
134 raise typer.BadParameter(f"{linesep.join(errors)}")
135
136 # src dir shall be relevant to the config file's location
137 src_discover_config.src_dir = (
138 config.parent / src_discover_config.src_dir
139 ).resolve()
140
141 src_discover = SourceDiscover(src_discover_config)
142
143 # Init source analyse config
144 analyse_config = _config["analyse_config"]
145 analyse_config.src_files = src_discover.source_paths
146 analyse_config.src_dir = Path(src_discover.src_discover_config.src_dir)
147
148 # git_root shall be relative to the config file's location (like src_dir)
149 if analyse_config.git_root is not None:
150 analyse_config.git_root = (
151 config.parent / analyse_config.git_root
152 ).resolve()
153
154 # preprocessor compile_commands / include dirs are relative to the config
155 # file's location too (like src_dir / git_root).
156 if analyse_config.preprocessor is not None:
157 analyse_config.preprocessor = anchor_preproc_paths(
158 analyse_config.preprocessor, config.parent
159 )
160
161 analyse_errors = analyse_config.check_fields_configuration()
162 errors.extend(analyse_errors)
163 if errors:
164 raise typer.BadParameter(f"{linesep.join(errors)}")
165
166 specifed_project_configs[project] = {"analyse_config": analyse_config}
167
168 codelinks_config.projects = specifed_project_configs
169 analyse_projects = AnalyseProjects(codelinks_config)
170 analyse_projects.run()
171
172 # Output warnings to console for CLI users
173 for src_analyse in analyse_projects.projects_analyse.values():
174 for warning in src_analyse.oneline_warnings:
175 logger.warning(
176 f"Oneline parser warning in {warning.file_path}:{warning.lineno} "
177 f"- {warning.sub_type}: {warning.msg}",
178 )
179
180 analyse_projects.dump_markers()
181
182
183@app.command(no_args_is_help=True)
184def discover( # noqa: PLR0913 # CLI command requires multiple parameters
185 src_dir: Annotated[
186 Path,
187 typer.Argument(
188 ...,
189 help="Root directory for discovery",
190 show_default=False,
191 dir_okay=True,
192 file_okay=False,
193 exists=True,
194 resolve_path=True,
195 ),
196 ],
197 exclude: Annotated[
198 list[str],
199 typer.Option(
200 "--excludes",
201 "-e",
202 help="Glob patterns to be excluded.",
203 ),
204 ] = [], # noqa: B006 # to show the default value on CLI
205 include: Annotated[
206 list[str],
207 typer.Option(
208 "--includes",
209 "-i",
210 help="Glob patterns to be included.",
211 ),
212 ] = [], # noqa: B006 # to show the default value on CLI
[docs]213 # @CLI command to discover source files recursively with gitignore support, IMPL_CLI_DISCOVER, impl, [FE_CLI_DISCOVER]
214 gitignore: Annotated[
215 bool,
216 typer.Option(
217 help="Respect .gitignore files in the given directory and its parents"
218 ),
219 ] = True,
220 follow_links: Annotated[
221 bool,
222 typer.Option(help="Follow symbolic links during file discovery"),
223 ] = False,
224 comment_type: Annotated[
225 CommentType,
226 typer.Option(
227 "--comment-type",
228 "-c",
229 help="The relevant file extensions which use the specified the comment type will be discovered.",
230 ),
231 ] = CommentType.cpp,
232) -> None:
233 """Discover the filepaths from the given root directory."""
234
235 src_discover_dict: SourceDiscoverConfigType = {
236 "src_dir": src_dir,
237 "exclude": exclude,
238 "include": include,
239 "gitignore": gitignore,
240 "follow_links": follow_links,
241 "comment_type": comment_type,
242 }
243
244 src_discover_config = SourceDiscoverConfig(**src_discover_dict)
245
246 errors = src_discover_config.check_schema()
247 if errors:
248 raise typer.BadParameter(f"{linesep.join(errors)}")
249
250 source_discover = SourceDiscover(src_discover_config)
251 typer.echo(f"{len(source_discover.source_paths)} files discovered")
252 for file_path in source_discover.source_paths:
253 typer.echo(file_path)
254
255
256@write_app.command("rst", no_args_is_help=True)
257def write_rst( # noqa: PLR0913 # for CLI, so it takes as many as it requires
258 jsonpath: Annotated[
259 Path,
260 typer.Argument(
261 ...,
262 help="Path of the JSON file which contains the extracted markers",
263 show_default=False,
264 dir_okay=False,
265 file_okay=True,
266 exists=True,
267 resolve_path=True,
268 ),
269 ],
[docs]270 # @CLI command to generate needextend RST file from extracted markers, IMPL_CLI_WRITE, impl, [FE_CLI_WRITE]
271 outpath: Annotated[
272 Path,
273 typer.Option(
274 "--outpath",
275 "-o",
276 help="The output path for generated rst file",
277 show_default=True,
278 dir_okay=False,
279 file_okay=True,
280 exists=False,
281 ),
282 ] = Path("needextend.rst"),
283 remote_url_field: Annotated[
284 str,
285 typer.Option(
286 "--remote-url-field",
287 "-r",
288 help="The field name for the remote url",
289 show_default=True,
290 ),
291 ] = "remote_url", # to show default value in this CLI
292 title: Annotated[
293 str | None,
294 typer.Option(
295 "--title",
296 "-t",
297 help="Give the title to the generated RST file",
298 show_default=True,
299 ),
300 ] = None, # to show default value in this CLI
301 verbose: OptVerbose = False,
302 quiet: OptQuiet = False,
303) -> None:
304 """Generate needextend.rst from the extracted obj in JSON."""
305 configure_cli(verbose, quiet)
306 try:
307 with jsonpath.open("r") as f:
308 marked_content = json.load(f)
309 except Exception as e:
310 raise typer.BadParameter(
311 f"Failed to load marked content from {jsonpath}: {e}"
312 ) from e
313
314 marked_objs: list[MarkedObjType] = [
315 obj for objs in marked_content.values() for obj in objs
316 ]
317
318 needextend_texts, errors = convert_marked_content(
319 marked_objs, remote_url_field, title
320 )
321 if errors:
322 raise typer.BadParameter(
323 f"Errors occurred during conversion: {linesep.join(errors)}"
324 )
325 with outpath.open("w") as f:
326 f.writelines(needextend_texts)
327 typer.echo(f"Generated {outpath}")
328
329
330def load_config_from_toml(toml_file: Path) -> CodeLinksConfigType:
331 try:
332 with toml_file.open("rb") as f:
333 toml_data = tomllib.load(f)
334
335 except Exception as e:
336 raise typer.BadParameter(
337 f"Failed to load CodeLinks configuration from {toml_file}"
338 ) from e
339
340 codelink_dict = toml_data.get("codelinks")
341
342 if not codelink_dict:
343 raise typer.BadParameter(f"No 'codelinks' section found in {toml_file}")
344
345 return cast(CodeLinksConfigType, codelink_dict)
346
347
348if __name__ == "__main__":
349 app()