Coverage for pyVersioning/CLI.py: 74%
248 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-17 22:22 +0000
« 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
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
46from pyVersioning import __version__, __author__, __email__, __copyright__, __license__
47from pyVersioning import Versioning, Platforms, Project, SelfDescriptive
48from pyVersioning.Configuration import Configuration
51@export
52class ProjectAttributeGroup(CommandGroupAttribute):
53 """
54 This attribute group applies the following ArgParse attributes:
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
67@export
68class CompilerAttributeGroup(CommandGroupAttribute):
69 """
70 This attribute group applies the following ArgParse attributes:
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
85ArgNames = namedtuple("ArgNames", (
86 "Command",
87 "Template",
88 "Filename",
89 "ProjectName",
90 "ProjectVariant",
91 "ProjectVersion",
92 "CompilerName",
93 "CompilerVersion",
94 "CompilerConfig",
95 "CompilerOptions"
96))
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."
107 __configFile: Path
108 _config: Nullable[Configuration]
109 _versioning: Nullable[Versioning]
111 def __init__(self) -> None:
112 super().__init__(Mode.TextToStdOut_ErrorsToStdErr)
114 self.__class__.HeadLine = "Version file generator."
116 self.__configFile = Path(".pyVersioning.yml")
117 self._config = None
118 self._versioning = None
120 ArgParseHelperMixin.__init__(
121 self,
122 description=self.HeadLine,
123 formatter_class=RawDescriptionHelpFormatter,
124 add_help=False,
125 exit_on_error=False
126 )
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}"
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()
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()
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)
163 self.Exit()
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))
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__}")
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)
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("")
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.")
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()
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)
225 self.UpdateProject(args)
226 self.UpdateCompiler(args)
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)
235 for key,value in self._versioning.Variables.items():
236 _print(key, value, 0)
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))
249 query = f"{{{args.Field}}}"
251 content = self.FillOutTemplate(query)
253 self.WriteOutput(
254 None if args.Filename is None else Path(args.Filename),
255 content
256 )
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))
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.")
273 template = templateFile.read_text(encoding="utf-8")
275 self.UpdateProject(args)
276 self.UpdateCompiler(args)
278 content = self.FillOutTemplate(template)
280 self.WriteOutput(
281 None if args.Filename is None else Path(args.Filename),
282 content
283 )
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)
294 self.UpdateProject(args)
295 self.UpdateCompiler(args)
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 """)
310 content = self._versioning.FillOutTemplate(template)
312 self.WriteOutput(
313 None if args.Filename is None else Path(args.Filename),
314 content
315 )
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)
326 self.UpdateProject(args)
327 self.UpdateCompiler(args)
329 yamlEnvironment = "\n"
330 # for key, value in self._versioning.variables["env"].as_dict().items():
331 # yamlEnvironment += f" {key}: {value}\n"
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"
354 template = dedent("""\
355 format: "1.1"
357 version:
358 name: "{version!s}"
359 major: {version.Major}
360 minor: {version.Minor}
361 patch: {version.Patch}
362 flags: {version.Flags}
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}
373 project:
374 name: {project.name}
375 variant: {project.variant}
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}
385 platform:
386 ci-service: {platform.ci_service}
387 appveyor: {yamlAppVeyor}
388 github: {yamlGitHub}
389 gitlab: {yamlGitLab}
390 travis: {yamlTravis}
391 env:{yamlEnvironment}
392 """)
394 content = self.FillOutTemplate(
395 template,
396 yamlEnvironment=yamlEnvironment,
397 yamlAppVeyor=yamlAppVeyor,
398 yamlGitHub=yamlGitHub,
399 yamlGitLab=yamlGitLab,
400 yamlTravis=yamlTravis
401 )
403 self.WriteOutput(
404 None if args.Filename is None else Path(args.Filename),
405 content
406 )
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
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
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
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
430 def FillOutTemplate(self, template: str, **kwargs) -> str:
431 self.WriteVerbose("Applying variables to template ...")
432 return self._versioning.FillOutTemplate(template, **kwargs)
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.")
446 self.ExitOnPreviousErrors()
448 outputFile.write_text(content, encoding="utf-8")
449 else:
450 self.WriteToStdOut(content)
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)
465if __name__ == "__main__": 465 ↛ 466line 465 didn't jump to line 466 because the condition on line 465 was never true
466 main()