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