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