Coverage for src/shellman/tags.py: 88.59%
255 statements
« prev ^ index » next coverage.py v7.7.1, created at 2025-03-27 14:35 +0100
« prev ^ index » next coverage.py v7.7.1, created at 2025-03-27 14:35 +0100
1"""Section module.
3This module contains the Section class.
4"""
6from __future__ import annotations
8import re
9import sys
10import warnings
11from dataclasses import dataclass
12from functools import cached_property
13from typing import TYPE_CHECKING, Any, ClassVar
15# YORE: EOL 3.10: Replace block with line 4.
16if sys.version_info < (3, 11):
17 from typing_extensions import Self
18else:
19 from typing import Self
21if TYPE_CHECKING:
22 from collections.abc import Sequence
24 from shellman.reader import DocLine
27# YORE: Bump 2: Remove block.
28def __getattr__(name: str) -> Any:
29 if name == "NameDescTag": 29 ↛ 30line 29 didn't jump to line 30 because the condition on line 29 was never true
30 warnings.warn("NameDescTag is deprecated, use ValueDescTag instead.", DeprecationWarning, stacklevel=2)
31 return ValueDescTag
32 raise AttributeError(f"module {__name__} has no attribute {name}")
35class Tag:
36 """Base class for tags."""
38 @classmethod
39 def from_lines(cls, lines: Sequence[DocLine]) -> Tag:
40 """Parse a sequence of lines into a tag instance.
42 Parameters:
43 lines: The sequence of lines to parse.
45 Returns:
46 A tag instance.
47 """
48 raise NotImplementedError
51@dataclass
52class TextTag(Tag):
53 """A simple tag holding text only."""
55 text: str
56 """The tag's text."""
58 @classmethod
59 def from_lines(cls, lines: Sequence[DocLine]) -> TextTag: # noqa: D102
60 return cls(text="\n".join(line.value for line in lines))
63@dataclass
64class ValueDescTag(Tag):
65 """A tag holding a value and a description."""
67 tag: ClassVar[str]
68 """The tag name."""
70 value_field_name: ClassVar[str] = "name"
71 """The name of the field containing the value."""
73 description_field_name: ClassVar[str] = "description"
74 """The name of the field containing the description."""
76 @classmethod
77 def from_lines(cls, lines: Sequence[DocLine]) -> Self: # noqa: D102
78 value, description = "", []
79 for line in lines:
80 if line.tag == cls.tag:
81 split = line.value.split(" ", 1)
82 if len(split) > 1: 82 ↛ 86line 82 didn't jump to line 86 because the condition on line 82 was always true
83 value = split[0]
84 description.append(split[1])
85 else:
86 value = split[0]
87 else:
88 description.append(line.value)
89 return cls(**{cls.value_field_name: value, cls.description_field_name: "\n".join(description)})
92@dataclass
93class AuthorTag(TextTag):
94 """A tag representing an author."""
97@dataclass
98class BugTag(TextTag):
99 """A tag representing a bug note."""
102@dataclass
103class BriefTag(TextTag):
104 """A tag representing a summary."""
107@dataclass
108class CaveatTag(TextTag):
109 """A tag representing caveats."""
112@dataclass
113class CopyrightTag(TextTag):
114 """A tag representing copyright information."""
117@dataclass
118class DateTag(TextTag):
119 """A tag representing a date."""
122@dataclass
123class DescTag(TextTag):
124 """A tag representing a description."""
127@dataclass
128class EnvTag(ValueDescTag):
129 """A tag representing an environment variable used by the script."""
131 tag: ClassVar[str] = "env"
133 name: str
134 """The environment variable name."""
135 description: str
136 """The environment variable description."""
139@dataclass
140class ErrorTag(TextTag):
141 """A tag representing a known error."""
144@dataclass
145class ExampleTag(Tag):
146 """A tag representing a code/shell example."""
148 brief: str
149 """The example's summary."""
150 code: str
151 """The example's code."""
152 code_lang: str
153 """The example's language."""
154 description: str
155 """The example's description."""
157 @classmethod
158 def from_lines(cls, lines: Sequence[DocLine]) -> ExampleTag: # noqa: D102
159 brief, code, description = [], [], []
160 code_lang = ""
161 current = None
162 for line in lines:
163 if line.tag == "example":
164 if line.value: 164 ↛ 166line 164 didn't jump to line 166 because the condition on line 164 was always true
165 brief.append(line.value)
166 current = "brief"
167 elif line.tag == "example-code":
168 if line.value: 168 ↛ 170line 168 didn't jump to line 170 because the condition on line 168 was always true
169 code_lang = line.value
170 current = "code"
171 elif line.tag == "example-description":
172 if line.value: 172 ↛ 174line 172 didn't jump to line 174 because the condition on line 172 was always true
173 description.append(line.value)
174 current = "description"
175 elif current == "brief": 175 ↛ 176line 175 didn't jump to line 176 because the condition on line 175 was never true
176 brief.append(line.value)
177 elif current == "code": 177 ↛ 179line 177 didn't jump to line 179 because the condition on line 177 was always true
178 code.append(line.value)
179 elif current == "description":
180 description.append(line.value)
182 return ExampleTag(
183 brief="\n".join(brief),
184 code="\n".join(code),
185 code_lang=code_lang,
186 description="\n".join(description),
187 )
190@dataclass
191class ExitTag(ValueDescTag):
192 """A tag representing an exit code."""
194 tag: ClassVar[str] = "exit"
195 value_field_name: ClassVar[str] = "code"
197 code: str
198 """The exit code value."""
199 description: str
200 """The exit code description."""
203@dataclass
204class FileTag(ValueDescTag):
205 """A tag representing a file used by a script."""
207 tag: ClassVar[str] = "file"
209 name: str
210 """The file name/path."""
211 description: str
212 """The file description."""
215@dataclass
216class FunctionTag(Tag):
217 """A tag representing a shell function."""
219 prototype: str
220 """The function's prototype."""
221 brief: str
222 """The function's summary."""
223 description: str
224 """The function's description."""
225 arguments: Sequence[str]
226 """The function's arguments."""
227 preconditions: Sequence[str]
228 """The function's preconditions."""
229 return_codes: Sequence[str]
230 """The function's return codes."""
231 seealso: Sequence[str]
232 """The function's "see also" information."""
233 stderr: Sequence[str]
234 """The function's standard error."""
235 stdin: Sequence[str]
236 """The function's standard input."""
237 stdout: Sequence[str]
238 """The function's standard output."""
240 @classmethod
241 def from_lines(cls, lines: Sequence[DocLine]) -> FunctionTag: # noqa: D102
242 brief = ""
243 prototype = ""
244 description = []
245 arguments = []
246 return_codes = []
247 preconditions = []
248 seealso = []
249 stderr = []
250 stdin = []
251 stdout = []
252 for line in lines:
253 if line.tag == "function":
254 prototype = line.value
255 elif line.tag == "function-brief":
256 brief = line.value
257 elif line.tag == "function-description":
258 description.append(line.value)
259 elif line.tag == "function-argument":
260 arguments.append(line.value)
261 elif line.tag == "function-precondition": 261 ↛ 262line 261 didn't jump to line 262 because the condition on line 261 was never true
262 preconditions.append(line.value)
263 elif line.tag == "function-return": 263 ↛ 265line 263 didn't jump to line 265 because the condition on line 263 was always true
264 return_codes.append(line.value)
265 elif line.tag == "function-seealso":
266 seealso.append(line.value)
267 elif line.tag == "function-stderr": 267 ↛ anywhereline 267 didn't jump anywhere: it always raised an exception.
268 stderr.append(line.value)
269 elif line.tag == "function-stdin":
270 stdin.append(line.value)
271 elif line.tag == "function-stdout": 271 ↛ 274line 271 didn't jump to line 274 because the condition on line 271 was always true
272 stdout.append(line.value)
273 else:
274 description.append(line.value)
276 return FunctionTag(
277 prototype=prototype,
278 brief=brief,
279 description="\n".join(description),
280 arguments=arguments,
281 preconditions=preconditions,
282 return_codes=return_codes,
283 seealso=seealso,
284 stderr=stderr,
285 stdin=stdin,
286 stdout=stdout,
287 )
290@dataclass
291class HistoryTag(TextTag):
292 """A tag representing a script's history."""
295@dataclass
296class LicenseTag(TextTag):
297 """A tag representing a license."""
300@dataclass
301class NoteTag(TextTag):
302 """A tag representing a note."""
305@dataclass
306class OptionTag(Tag):
307 """A tag representing a command-line option."""
309 short: str
310 """The option short flag."""
311 long: str
312 """The option long flag."""
313 positional: str
314 """The option positional arguments."""
315 default: str
316 """The option default value."""
317 group: str
318 """The option group."""
319 description: str
320 """The option description."""
322 @cached_property
323 def signature(self) -> str:
324 """The signature of the option."""
325 sign = ""
326 if self.short: 326 ↛ 332line 326 didn't jump to line 332 because the condition on line 326 was always true
327 sign = self.short
328 if self.long: 328 ↛ 330line 328 didn't jump to line 330 because the condition on line 328 was always true
329 sign += ", "
330 elif self.positional: 330 ↛ 331line 330 didn't jump to line 331 because the condition on line 330 was never true
331 sign += " "
332 if self.long: 332 ↛ 336line 332 didn't jump to line 336 because the condition on line 332 was always true
333 if not self.short: 333 ↛ 334line 333 didn't jump to line 334 because the condition on line 333 was never true
334 sign += " "
335 sign += self.long + " "
336 if self.positional:
337 sign += self.positional
338 return sign
340 @classmethod
341 def from_lines(cls, lines: Sequence[DocLine]) -> OptionTag: # noqa: D102
342 short, long, positional, default, group = "", "", "", "", ""
343 description = []
344 for line in lines:
345 if line.tag == "option":
346 search = re.search(
347 r"^(?P<short>-\w)?(?:, )?(?P<long>--[\w-]+)? ?(?P<positional>.+)?",
348 line.value,
349 )
350 if search: 350 ↛ 353line 350 didn't jump to line 353 because the condition on line 350 was always true
351 short, long, positional = search.groups(default="")
352 else:
353 positional = line.value
354 elif line.tag == "option-default":
355 default = line.value
356 elif line.tag == "option-group":
357 group = line.value
358 else:
359 description.append(line.value)
360 return OptionTag(
361 short=short,
362 long=long,
363 positional=positional,
364 default=default,
365 group=group,
366 description="\n".join(description),
367 )
370@dataclass
371class SeealsoTag(TextTag):
372 """A tag representing "See Also" information."""
375@dataclass
376class StderrTag(TextTag):
377 """A tag representing the standard error of a script/function."""
380@dataclass
381class StdinTag(TextTag):
382 """A tag representing the standard input of a script/function."""
385@dataclass
386class StdoutTag(TextTag):
387 """A tag representing the standard output of a script/function."""
390@dataclass
391class UsageTag(Tag):
392 """A tag representing the command-line usage of a script."""
394 program: str
395 """The program name."""
396 command: str
397 """The command-line usage."""
399 @classmethod
400 def from_lines(cls, lines: Sequence[DocLine]) -> UsageTag: # noqa: D102
401 program, command = "", ""
402 split = lines[0].value.split(" ", 1)
403 if len(split) > 1: 403 ↛ 406line 403 didn't jump to line 406 because the condition on line 403 was always true
404 program, command = split
405 else:
406 program = split[0]
407 if len(lines) > 1:
408 command = command + "\n" + "\n".join(line.value for line in lines[1:])
409 return UsageTag(program=program, command=command)
412@dataclass
413class VersionTag(TextTag):
414 """A tag representing a version."""
417TAGS: dict[str | None, type[Tag]] = {
418 None: TextTag,
419 "author": AuthorTag,
420 "bug": BugTag,
421 "brief": BriefTag,
422 "caveat": CaveatTag,
423 "copyright": CopyrightTag,
424 "date": DateTag,
425 "desc": DescTag,
426 "env": EnvTag,
427 "error": ErrorTag,
428 "example": ExampleTag,
429 "exit": ExitTag,
430 "file": FileTag,
431 "function": FunctionTag,
432 "history": HistoryTag,
433 "license": LicenseTag,
434 "note": NoteTag,
435 "option": OptionTag,
436 "seealso": SeealsoTag,
437 "stderr": StderrTag,
438 "stdin": StdinTag,
439 "stdout": StdoutTag,
440 "usage": UsageTag,
441 "version": VersionTag,
442}