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

1"""Section module. 

2 

3This module contains the Section class. 

4""" 

5 

6from __future__ import annotations 

7 

8import re 

9import sys 

10import warnings 

11from dataclasses import dataclass 

12from functools import cached_property 

13from typing import TYPE_CHECKING, Any, ClassVar 

14 

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 

20 

21if TYPE_CHECKING: 

22 from collections.abc import Sequence 

23 

24 from shellman.reader import DocLine 

25 

26 

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}") 

33 

34 

35class Tag: 

36 """Base class for tags.""" 

37 

38 @classmethod 

39 def from_lines(cls, lines: Sequence[DocLine]) -> Tag: 

40 """Parse a sequence of lines into a tag instance. 

41 

42 Parameters: 

43 lines: The sequence of lines to parse. 

44 

45 Returns: 

46 A tag instance. 

47 """ 

48 raise NotImplementedError 

49 

50 

51@dataclass 

52class TextTag(Tag): 

53 """A simple tag holding text only.""" 

54 

55 text: str 

56 """The tag's text.""" 

57 

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)) 

61 

62 

63@dataclass 

64class ValueDescTag(Tag): 

65 """A tag holding a value and a description.""" 

66 

67 tag: ClassVar[str] 

68 """The tag name.""" 

69 

70 value_field_name: ClassVar[str] = "name" 

71 """The name of the field containing the value.""" 

72 

73 description_field_name: ClassVar[str] = "description" 

74 """The name of the field containing the description.""" 

75 

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)}) 

90 

91 

92@dataclass 

93class AuthorTag(TextTag): 

94 """A tag representing an author.""" 

95 

96 

97@dataclass 

98class BugTag(TextTag): 

99 """A tag representing a bug note.""" 

100 

101 

102@dataclass 

103class BriefTag(TextTag): 

104 """A tag representing a summary.""" 

105 

106 

107@dataclass 

108class CaveatTag(TextTag): 

109 """A tag representing caveats.""" 

110 

111 

112@dataclass 

113class CopyrightTag(TextTag): 

114 """A tag representing copyright information.""" 

115 

116 

117@dataclass 

118class DateTag(TextTag): 

119 """A tag representing a date.""" 

120 

121 

122@dataclass 

123class DescTag(TextTag): 

124 """A tag representing a description.""" 

125 

126 

127@dataclass 

128class EnvTag(ValueDescTag): 

129 """A tag representing an environment variable used by the script.""" 

130 

131 tag: ClassVar[str] = "env" 

132 

133 name: str 

134 """The environment variable name.""" 

135 description: str 

136 """The environment variable description.""" 

137 

138 

139@dataclass 

140class ErrorTag(TextTag): 

141 """A tag representing a known error.""" 

142 

143 

144@dataclass 

145class ExampleTag(Tag): 

146 """A tag representing a code/shell example.""" 

147 

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.""" 

156 

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) 

181 

182 return ExampleTag( 

183 brief="\n".join(brief), 

184 code="\n".join(code), 

185 code_lang=code_lang, 

186 description="\n".join(description), 

187 ) 

188 

189 

190@dataclass 

191class ExitTag(ValueDescTag): 

192 """A tag representing an exit code.""" 

193 

194 tag: ClassVar[str] = "exit" 

195 value_field_name: ClassVar[str] = "code" 

196 

197 code: str 

198 """The exit code value.""" 

199 description: str 

200 """The exit code description.""" 

201 

202 

203@dataclass 

204class FileTag(ValueDescTag): 

205 """A tag representing a file used by a script.""" 

206 

207 tag: ClassVar[str] = "file" 

208 

209 name: str 

210 """The file name/path.""" 

211 description: str 

212 """The file description.""" 

213 

214 

215@dataclass 

216class FunctionTag(Tag): 

217 """A tag representing a shell function.""" 

218 

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.""" 

239 

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) 

275 

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 ) 

288 

289 

290@dataclass 

291class HistoryTag(TextTag): 

292 """A tag representing a script's history.""" 

293 

294 

295@dataclass 

296class LicenseTag(TextTag): 

297 """A tag representing a license.""" 

298 

299 

300@dataclass 

301class NoteTag(TextTag): 

302 """A tag representing a note.""" 

303 

304 

305@dataclass 

306class OptionTag(Tag): 

307 """A tag representing a command-line option.""" 

308 

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.""" 

321 

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 

339 

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 ) 

368 

369 

370@dataclass 

371class SeealsoTag(TextTag): 

372 """A tag representing "See Also" information.""" 

373 

374 

375@dataclass 

376class StderrTag(TextTag): 

377 """A tag representing the standard error of a script/function.""" 

378 

379 

380@dataclass 

381class StdinTag(TextTag): 

382 """A tag representing the standard input of a script/function.""" 

383 

384 

385@dataclass 

386class StdoutTag(TextTag): 

387 """A tag representing the standard output of a script/function.""" 

388 

389 

390@dataclass 

391class UsageTag(Tag): 

392 """A tag representing the command-line usage of a script.""" 

393 

394 program: str 

395 """The program name.""" 

396 command: str 

397 """The command-line usage.""" 

398 

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) 

410 

411 

412@dataclass 

413class VersionTag(TextTag): 

414 """A tag representing a version.""" 

415 

416 

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}