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