Coverage for pyVersioning/CLI.py: 74%

248 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-17 22:22 +0000

1# ==================================================================================================================== # 

2# __ __ _ _ # 

3# _ __ _ \ \ / /__ _ __ ___(_) ___ _ __ (_)_ __ __ _ # 

4# | '_ \| | | \ \ / / _ \ '__/ __| |/ _ \| '_ \| | '_ \ / _` | # 

5# | |_) | |_| |\ V / __/ | \__ \ | (_) | | | | | | | | (_| | # 

6# | .__/ \__, | \_/ \___|_| |___/_|\___/|_| |_|_|_| |_|\__, | # 

7# |_| |___/ |___/ # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2020-2025 Patrick Lehmann - Bötzingen, Germany # 

15# # 

16# Licensed under the Apache License, Version 2.0 (the "License"); # 

17# you may not use this file except in compliance with the License. # 

18# You may obtain a copy of the License at # 

19# # 

20# http://www.apache.org/licenses/LICENSE-2.0 # 

21# # 

22# Unless required by applicable law or agreed to in writing, software # 

23# distributed under the License is distributed on an "AS IS" BASIS, # 

24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

25# See the License for the specific language governing permissions and # 

26# limitations under the License. # 

27# # 

28# SPDX-License-Identifier: Apache-2.0 # 

29# ==================================================================================================================== # 

30# 

31from argparse import RawDescriptionHelpFormatter, Namespace, ArgumentError 

32from collections import namedtuple 

33from pathlib import Path 

34from textwrap import dedent 

35from typing import NoReturn, Optional as Nullable 

36 

37from pyTooling.Attributes import Entity 

38from pyTooling.Decorators import export 

39from pyTooling.Attributes.ArgParse import ArgParseHelperMixin, CommandGroupAttribute 

40from pyTooling.Attributes.ArgParse import DefaultHandler, CommandHandler 

41from pyTooling.Attributes.ArgParse.Argument import StringArgument, PathArgument 

42from pyTooling.Attributes.ArgParse.Flag import FlagArgument 

43from pyTooling.Attributes.ArgParse.ValuedFlag import LongValuedFlag 

44from pyTooling.TerminalUI import TerminalApplication, Severity, Mode 

45 

46from pyVersioning import __version__, __author__, __email__, __copyright__, __license__ 

47from pyVersioning import Versioning, Platforms, Project, SelfDescriptive 

48from pyVersioning.Configuration import Configuration 

49 

50 

51@export 

52class ProjectAttributeGroup(CommandGroupAttribute): 

53 """ 

54 This attribute group applies the following ArgParse attributes: 

55 

56 * ``--project-name`` 

57 * ``--project-variant`` 

58 * ``--project-version`` 

59 """ 

60 def __call__(self, func: Entity) -> Entity: 

61 self._AppendAttribute(func, LongValuedFlag("--project-name", dest="ProjectName", metaName="<Name>", optional=True, help="Name of the project.")) # pylint: disable=line-too-long 

62 self._AppendAttribute(func, LongValuedFlag("--project-variant", dest="ProjectVariant", metaName="<Variant>", optional=True, help="Variant of the project.")) # pylint: disable=line-too-long 

63 self._AppendAttribute(func, LongValuedFlag("--project-version", dest="ProjectVersion", metaName="<Version>", optional=True, help="Version of the project.")) # pylint: disable=line-too-long 

64 return func 

65 

66 

67@export 

68class CompilerAttributeGroup(CommandGroupAttribute): 

69 """ 

70 This attribute group applies the following ArgParse attributes: 

71 

72 * ``--compiler-name`` 

73 * ``--compiler-version`` 

74 * ``--compiler-config`` 

75 * ``--compiler-options`` 

76 """ 

77 def __call__(self, func: Entity) -> Entity: 

78 self._AppendAttribute(func, LongValuedFlag("--compiler-name", dest="CompilerName", metaName="<Name>", optional=True, help="Used compiler.")) # pylint: disable=line-too-long 

79 self._AppendAttribute(func, LongValuedFlag("--compiler-version", dest="CompilerVersion", metaName="<Version>", optional=True, help="Used compiler version.")) # pylint: disable=line-too-long 

80 self._AppendAttribute(func, LongValuedFlag("--compiler-config", dest="CompilerConfig", metaName="<Config>", optional=True, help="Used compiler configuration.")) # pylint: disable=line-too-long 

81 self._AppendAttribute(func, LongValuedFlag("--compiler-options", dest="CompilerOptions", metaName="<Options>", optional=True, help="Used compiler options.")) # pylint: disable=line-too-long 

82 return func 

83 

84 

85ArgNames = namedtuple("ArgNames", ( 

86 "Command", 

87 "Template", 

88 "Filename", 

89 "ProjectName", 

90 "ProjectVariant", 

91 "ProjectVersion", 

92 "CompilerName", 

93 "CompilerVersion", 

94 "CompilerConfig", 

95 "CompilerOptions" 

96)) 

97 

98 

99@export 

100class Application(TerminalApplication, ArgParseHelperMixin): 

101 """ 

102 pyVersioning's command line interface application class. 

103 """ 

104 # TODO: First: fix bug in pyTooling 

105 # HeadLine: ClassVar[str] = "Version file generator." 

106 

107 __configFile: Path 

108 _config: Nullable[Configuration] 

109 _versioning: Nullable[Versioning] 

110 

111 def __init__(self) -> None: 

112 super().__init__(Mode.TextToStdOut_ErrorsToStdErr) 

113 

114 self.__class__.HeadLine = "Version file generator." 

115 

116 self.__configFile = Path(".pyVersioning.yml") 

117 self._config = None 

118 self._versioning = None 

119 

120 ArgParseHelperMixin.__init__( 

121 self, 

122 description=self.HeadLine, 

123 formatter_class=RawDescriptionHelpFormatter, 

124 add_help=False, 

125 exit_on_error=False 

126 ) 

127 

128 self._LOG_MESSAGE_FORMAT__[Severity.Fatal] = "{DARK_RED}[FATAL] {message}{NOCOLOR}" 

129 self._LOG_MESSAGE_FORMAT__[Severity.Error] = "{RED}[ERROR] {message}{NOCOLOR}" 

130 self._LOG_MESSAGE_FORMAT__[Severity.Warning] = "{YELLOW}[WARNING] {message}{NOCOLOR}" 

131 self._LOG_MESSAGE_FORMAT__[Severity.Normal]= "{GRAY}{message}{NOCOLOR}" 

132 

133 def Initialize(self, configFile: Nullable[Path] = None) -> None: 

134 if configFile is None: 

135 if not self.__configFile.exists(): 135 ↛ 139line 135 didn't jump to line 139 because the condition on line 135 was always true

136 self.WriteWarning(f"Configuration file '{self.__configFile}' does not exist.") 

137 self._config = Configuration() 

138 else: 

139 self.WriteVerbose(f"Reading configuration file '{self.__configFile}'") 

140 self._config = Configuration(self.__configFile) 

141 elif configFile.exists(): 

142 self.WriteVerbose(f"Reading configuration file '{configFile}'") 

143 self._config = Configuration(configFile) 

144 else: 

145 self.WriteError(f"Configuration file '{configFile}' does not exist.") 

146 self._config = Configuration() 

147 

148 self.WriteVerbose( "Creating internal data model ...") 

149 self._versioning = Versioning(self) 

150 self.WriteDebug( " Loading information from configuration file ...") 

151 self._versioning.LoadDataFromConfiguration(self._config) 

152 self.WriteDebug( " Collecting information from environment ...") 

153 self._versioning.CollectData() 

154 

155 def Run(self) -> NoReturn: 

156 try: 

157 super().Run() # todo: enableAutoComplete ?? 

158 except ArgumentError as ex: 

159 self._PrintHeadline() 

160 self.WriteError(ex) 

161 self.Exit(2) 

162 

163 self.Exit() 

164 

165 def _PrintHeadline(self) -> None: 

166 """Helper method to print the program headline.""" 

167 self.WriteNormal("{HEADLINE}{line}".format(line="=" * 80, **TerminalApplication.Foreground)) 

168 self.WriteNormal("{HEADLINE}{headline: ^80s}".format(headline=self.HeadLine, **TerminalApplication.Foreground)) 

169 self.WriteNormal("{HEADLINE}{line}".format(line="=" * 80, **TerminalApplication.Foreground)) 

170 

171 def _PrintVersion(self) -> None: 

172 """Helper method to print the version information.""" 

173 self.WriteNormal(f"Author: {__author__} ({__email__})") 

174 self.WriteNormal(f"Copyright: {__copyright__}") 

175 self.WriteNormal(f"License: {__license__}") 

176 self.WriteNormal(f"Version: {__version__}") 

177 

178 @DefaultHandler() 

179 @FlagArgument(short="-v", long="--verbose", dest="Verbose", help="Print verbose messages.") 

180 @FlagArgument(short="-d", long="--debug", dest="Debug", help="Print debug messages.") 

181 @LongValuedFlag("--config-file", dest="ConfigFile", metaName="<pyVersioning.yaml>", optional=True, help="Path to pyVersioning.yaml .") 

182 def HandleDefault(self, args: Namespace) -> None: 

183 """Handle program calls for no given command.""" 

184 self.Configure(verbose=args.Verbose, debug=args.Debug) 

185 self._PrintHeadline() 

186 self._PrintVersion() 

187 self.WriteNormal("") 

188 self.MainParser.print_help(self._stdout) 

189 

190 @CommandHandler("help", help="Display help page(s) for the given command name.") 

191 @StringArgument(dest="Command", metaName="Command", optional=True, help="Print help page(s) for a command.") 

192 def HandleHelp(self, args: Namespace) -> None: 

193 """Handle program calls for command ``help``.""" 

194 self.Configure(verbose=args.Verbose, debug=args.Debug) 

195 self._PrintHeadline() 

196 self._PrintVersion() 

197 self.WriteNormal("") 

198 

199 if args.Command is None: 199 ↛ 201line 199 didn't jump to line 201 because the condition on line 199 was always true

200 self.MainParser.print_help(self._stdout) 

201 elif args.Command == "help": 

202 self.WriteError("This is a recursion ...") 

203 else: 

204 try: 

205 self.SubParsers[args.Command].print_help(self._stdout) 

206 except KeyError: 

207 self.WriteError(f"Command {args.Command} is unknown.") 

208 

209 @CommandHandler("version", help="Display version information.", description="Display version information.") 

210 def HandleVersion(self, args: Namespace) -> None: 

211 """Handle program calls for command ``version``.""" 

212 self.Configure(verbose=args.Verbose, debug=args.Debug) 

213 self._PrintHeadline() 

214 self._PrintVersion() 

215 

216 @CommandHandler("variables", help="Print all available variables.") 

217 @ProjectAttributeGroup("dummy") 

218 @CompilerAttributeGroup("flummy") 

219 def HandleVariables(self, args: Namespace) -> None: 

220 """Handle program calls for command ``variables``.""" 

221 self.Configure(verbose=args.Verbose, debug=args.Debug, quiet=True) 

222 self._PrintHeadline() 

223 self.Initialize(Path(args.ConfigFile) if args.ConfigFile is not None else None) 

224 

225 self.UpdateProject(args) 

226 self.UpdateCompiler(args) 

227 

228 def _print(key, value, indent): 

229 key = (" " * indent) + str(key) 

230 self.WriteQuiet(f"{key:24}: {value!s}") 

231 if isinstance(value, SelfDescriptive): 

232 for k, v in value.KeyValuePairs(): 

233 _print(k, v, indent + 1) 

234 

235 for key,value in self._versioning.Variables.items(): 

236 _print(key, value, 0) 

237 

238 @CommandHandler("field", help="Return a single pyVersioning field.") 

239 @ProjectAttributeGroup("dummy") 

240 @CompilerAttributeGroup("flummy") 

241 @StringArgument(dest="Field", metaName="<Field name>", help="Field to return.") 

242 @PathArgument(dest="Filename", metaName="<Output file>", optional=True, help="Output filename.") 

243 def HandleField(self, args: Namespace) -> None: 

244 """Handle program calls for command ``field``.""" 

245 self.Configure(verbose=args.Verbose, debug=args.Debug) #, quiet=args.Filename is None) 

246 self._PrintHeadline() 

247 self.Initialize(None if args.ConfigFile is None else Path(args.ConfigFile)) 

248 

249 query = f"{{{args.Field}}}" 

250 

251 content = self.FillOutTemplate(query) 

252 

253 self.WriteOutput( 

254 None if args.Filename is None else Path(args.Filename), 

255 content 

256 ) 

257 

258 @CommandHandler("fillout", help="Read a template and replace tokens with version information.") 

259 @ProjectAttributeGroup("dummy") 

260 @CompilerAttributeGroup("flummy") 

261 @PathArgument(dest="Template", metaName="<Template file>", help="Template input filename.") 

262 @PathArgument(dest="Filename", metaName="<Output file>", optional=True, help="Output filename.") 

263 def HandleFillOut(self, args: Namespace) -> None: 

264 """Handle program calls for command ``fillout``.""" 

265 self.Configure(verbose=args.Verbose, debug=args.Debug, quiet=args.Filename is None) 

266 self._PrintHeadline() 

267 self.Initialize(None if args.ConfigFile is None else Path(args.ConfigFile)) 

268 

269 templateFile = Path(args.Template) 

270 if not templateFile.exists(): 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true

271 self.WriteError(f"Template file '{templateFile}' does not exist.") 

272 

273 template = templateFile.read_text(encoding="utf-8") 

274 

275 self.UpdateProject(args) 

276 self.UpdateCompiler(args) 

277 

278 content = self.FillOutTemplate(template) 

279 

280 self.WriteOutput( 

281 None if args.Filename is None else Path(args.Filename), 

282 content 

283 ) 

284 

285 @CommandHandler("json", help="Write all available variables as JSON.") 

286 @ProjectAttributeGroup("dummy") 

287 @CompilerAttributeGroup("flummy") 

288 @PathArgument(dest="Filename", metaName="<Output file>", optional=True, help="Output filename.") 

289 def HandleJSON(self, args: Namespace) -> None: 

290 """Handle program calls for command ``json``.""" 

291 self.Configure(verbose=args.Verbose, debug=args.Debug, quiet=args.Filename is None) 

292 self.Initialize(Path(args.ConfigFile) if args.ConfigFile is not None else None) 

293 

294 self.UpdateProject(args) 

295 self.UpdateCompiler(args) 

296 

297 template = dedent("""\ 

298 {{ 

299 "format": "1.1", 

300 "version": {{ 

301 "name": "{version!s}", 

302 "major": {version.Major}, 

303 "minor": {version.Minor}, 

304 "patch": {version.Patch}, 

305 "flags": {version.Flags.value} 

306 }} 

307 }} 

308 """) 

309 

310 content = self._versioning.FillOutTemplate(template) 

311 

312 self.WriteOutput( 

313 None if args.Filename is None else Path(args.Filename), 

314 content 

315 ) 

316 

317 @CommandHandler("yaml", help="Write all available variables as YAML.") 

318 @ProjectAttributeGroup("dummy") 

319 @CompilerAttributeGroup("flummy") 

320 @PathArgument(dest="Filename", metaName="<Output file>", optional=True, help="Output filename.") 

321 def HandleYAML(self, args: Namespace) -> None: 

322 """Handle program calls for command ``yaml``.""" 

323 self.Configure(verbose=args.Verbose, debug=args.Debug, quiet=args.Filename is None) 

324 self.Initialize(Path(args.ConfigFile) if args.ConfigFile is not None else None) 

325 

326 self.UpdateProject(args) 

327 self.UpdateCompiler(args) 

328 

329 yamlEnvironment = "\n" 

330 # for key, value in self._versioning.variables["env"].as_dict().items(): 

331 # yamlEnvironment += f" {key}: {value}\n" 

332 

333 yamlAppVeyor = "\n# not found" 

334 yamlGitHub = "\n# not found" 

335 yamlGitLab = "\n# not found" 

336 yamlTravis = "\n# not found" 

337 if self._versioning.Platform is Platforms.AppVeyor: 337 ↛ 338line 337 didn't jump to line 338 because the condition on line 337 was never true

338 yamlAppVeyor = "\n" 

339 for key, value in self._versioning.Variables["appveyor"].as_dict().items(): 

340 yamlAppVeyor += f" {key}: {value}\n" 

341 elif self._versioning.Platform is Platforms.GitHub: 341 ↛ 345line 341 didn't jump to line 345 because the condition on line 341 was always true

342 yamlGitHub = "\n" 

343 for key, value in self._versioning.Variables["github"].as_dict().items(): 

344 yamlGitHub += f" {key}: {value}\n" 

345 elif self._versioning.Platform is Platforms.GitLab: 

346 yamlGitLab = "\n" 

347 for key, value in self._versioning.Variables["gitlab"].as_dict().items(): 

348 yamlGitLab += f" {key}: {value}\n" 

349 elif self._versioning.Platform is Platforms.Travis: 

350 yamlTravis = "\n" 

351 for key, value in self._versioning.Variables["travis"].as_dict().items(): 

352 yamlTravis += f" {key}: {value}\n" 

353 

354 template = dedent("""\ 

355 format: "1.1" 

356 

357 version: 

358 name: "{version!s}" 

359 major: {version.Major} 

360 minor: {version.Minor} 

361 patch: {version.Patch} 

362 flags: {version.Flags} 

363 

364 git: 

365 commit: 

366 hash: {git.commit.hash} 

367 date: {git.commit.date} 

368 reference: {git.reference} 

369 branch: {git.branch} 

370 tag: {git.tag} 

371 repository: {git.repository} 

372 

373 project: 

374 name: {project.name} 

375 variant: {project.variant} 

376 

377 build: 

378 date: {build.date} 

379 compiler: 

380 name: {build.compiler.name} 

381 version: {build.compiler.version} 

382 configuration: {build.compiler.configuration} 

383 options: {build.compiler.options} 

384 

385 platform: 

386 ci-service: {platform.ci_service} 

387 appveyor: {yamlAppVeyor} 

388 github: {yamlGitHub} 

389 gitlab: {yamlGitLab} 

390 travis: {yamlTravis} 

391 env:{yamlEnvironment} 

392 """) 

393 

394 content = self.FillOutTemplate( 

395 template, 

396 yamlEnvironment=yamlEnvironment, 

397 yamlAppVeyor=yamlAppVeyor, 

398 yamlGitHub=yamlGitHub, 

399 yamlGitLab=yamlGitLab, 

400 yamlTravis=yamlTravis 

401 ) 

402 

403 self.WriteOutput( 

404 None if args.Filename is None else Path(args.Filename), 

405 content 

406 ) 

407 

408 def UpdateProject(self, args: Namespace) -> None: 

409 if "project" not in self._versioning.Variables: 409 ↛ 410line 409 didn't jump to line 410 because the condition on line 409 was never true

410 self._versioning.Variables["project"] = Project(args.ProjectName, args.ProjectVersion, args.ProjectVariant) 

411 elif args.ProjectName is not None: 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true

412 self._versioning.Variables["project"]._name = args.ProjectName 

413 

414 if args.ProjectVariant is not None: 414 ↛ 415line 414 didn't jump to line 415 because the condition on line 414 was never true

415 self._versioning.Variables["project"]._variant = args.ProjectVariant 

416 

417 if args.ProjectVersion is not None: 417 ↛ 418line 417 didn't jump to line 418 because the condition on line 417 was never true

418 self._versioning.Variables["project"]._version = args.ProjectVersion 

419 

420 def UpdateCompiler(self, args: Namespace) -> None: 

421 if args.CompilerName is not None: 421 ↛ 422line 421 didn't jump to line 422 because the condition on line 421 was never true

422 self._versioning.Variables["build"]._compiler._name = args.CompilerName 

423 if args.CompilerVersion is not None: 423 ↛ 424line 423 didn't jump to line 424 because the condition on line 423 was never true

424 self._versioning.Variables["build"]._compiler._version = args.CompilerVersion 

425 if args.CompilerConfig is not None: 425 ↛ 426line 425 didn't jump to line 426 because the condition on line 425 was never true

426 self._versioning.Variables["build"]._compiler._configuration = args.CompilerConfig 

427 if args.CompilerOptions is not None: 427 ↛ 428line 427 didn't jump to line 428 because the condition on line 427 was never true

428 self._versioning.Variables["build"]._compiler._options = args.CompilerOptions 

429 

430 def FillOutTemplate(self, template: str, **kwargs) -> str: 

431 self.WriteVerbose("Applying variables to template ...") 

432 return self._versioning.FillOutTemplate(template, **kwargs) 

433 

434 def WriteOutput(self, outputFile: Nullable[Path], content: str): 

435 if outputFile is not None: 435 ↛ 436line 435 didn't jump to line 436 because the condition on line 435 was never true

436 self.WriteVerbose(f"Writing output to '{outputFile}' ...") 

437 if not outputFile.parent.exists(): 

438 self.WriteWarning(f"Directory for file '{outputFile}' does not exist. Directory will be created") 

439 try: 

440 outputFile.parent.mkdir() 

441 except: 

442 self.WriteError(f"Failed to create the directory '{outputFile.parent}' for the output file.") 

443 elif outputFile.exists(): 

444 self.WriteWarning(f"Output file '{outputFile}' already exists. This file will be overwritten.") 

445 

446 self.ExitOnPreviousErrors() 

447 

448 outputFile.write_text(content, encoding="utf-8") 

449 else: 

450 self.WriteToStdOut(content) 

451 

452 

453def main() -> NoReturn: 

454 """Entrypoint for program execution.""" 

455 application = Application() 

456 application.CheckPythonVersion((3, 8, 0)) 

457 try: 

458 application.Run() 

459 # except ServiceException as ex: 

460 # application.PrintException(ex) 

461 except Exception as ex: 

462 application.PrintException(ex) 

463 

464 

465if __name__ == "__main__": 465 ↛ 466line 465 didn't jump to line 466 because the condition on line 465 was never true

466 main()