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