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(
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 in the given directory. Nested .gitignore Not supported"
207 ),
208 ] = True,
209 comment_type: Annotated[
210 CommentType,
211 typer.Option(
212 "--comment-type",
213 "-c",
214 help="The relevant file extensions which use the specified the comment type will be discovered.",
215 ),
216 ] = CommentType.cpp,
217) -> None:
218 """Discover the filepaths from the given root directory."""
219
220 src_discover_dict: SourceDiscoverConfigType = {
221 "src_dir": src_dir,
222 "exclude": exclude,
223 "include": include,
224 "gitignore": gitignore,
225 "comment_type": comment_type,
226 }
227
228 src_discover_config = SourceDiscoverConfig(**src_discover_dict)
229
230 errors = src_discover_config.check_schema()
231 if errors:
232 raise typer.BadParameter(f"{linesep.join(errors)}")
233
234 source_discover = SourceDiscover(src_discover_config)
235 typer.echo(f"{len(source_discover.source_paths)} files discovered")
236 for file_path in source_discover.source_paths:
237 typer.echo(file_path)
238
239
240@write_app.command("rst", no_args_is_help=True)
241def write_rst( # noqa: PLR0913 # for CLI, so it takes as many as it requires
242 jsonpath: Annotated[
243 Path,
244 typer.Argument(
245 ...,
246 help="Path of the JSON file which contains the extracted markers",
247 show_default=False,
248 dir_okay=False,
249 file_okay=True,
250 exists=True,
251 resolve_path=True,
252 ),
253 ],
[docs]254 # @CLI command to generate needextend RST file from extracted markers, IMPL_CLI_WRITE, impl, [FE_CLI_WRITE]
255 outpath: Annotated[
256 Path,
257 typer.Option(
258 "--outpath",
259 "-o",
260 help="The output path for generated rst file",
261 show_default=True,
262 dir_okay=False,
263 file_okay=True,
264 exists=False,
265 ),
266 ] = Path("needextend.rst"),
267 remote_url_field: Annotated[
268 str,
269 typer.Option(
270 "--remote-url-field",
271 "-r",
272 help="The field name for the remote url",
273 show_default=True,
274 ),
275 ] = "remote_url", # to show default value in this CLI
276 title: Annotated[
277 str | None,
278 typer.Option(
279 "--title",
280 "-t",
281 help="Give the title to the generated RST file",
282 show_default=True,
283 ),
284 ] = None, # to show default value in this CLI
285 verbose: OptVerbose = False,
286 quiet: OptQuiet = False,
287) -> None:
288 """Generate needextend.rst from the extracted obj in JSON."""
289 logger.configure(verbose, quiet)
290 try:
291 with jsonpath.open("r") as f:
292 marked_content = json.load(f)
293 except Exception as e:
294 raise typer.BadParameter(
295 f"Failed to load marked content from {jsonpath}: {e}"
296 ) from e
297
298 marked_objs: list[MarkedObjType] = [
299 obj for objs in marked_content.values() for obj in objs
300 ]
301
302 needextend_texts, errors = convert_marked_content(
303 marked_objs, remote_url_field, title
304 )
305 if errors:
306 raise typer.BadParameter(
307 f"Errors occurred during conversion: {linesep.join(errors)}"
308 )
309 with outpath.open("w") as f:
310 f.writelines(needextend_texts)
311 typer.echo(f"Generated {outpath}")
312
313
314def load_config_from_toml(toml_file: Path) -> CodeLinksConfigType:
315 try:
316 with toml_file.open("rb") as f:
317 toml_data = tomllib.load(f)
318
319 except Exception as e:
320 raise typer.BadParameter(
321 f"Failed to load CodeLinks configuration from {toml_file}"
322 ) from e
323
324 codelink_dict = toml_data.get("codelinks")
325
326 if not codelink_dict:
327 raise typer.BadParameter(f"No 'codelinks' section found in {toml_file}")
328
329 return cast(CodeLinksConfigType, codelink_dict)
330
331
332if __name__ == "__main__":
333 app()