Source code for skaff.config

#!/usr/bin/env python3

"""
Custom configuration type definition used for the 'driver' skaff module.
"""

# ------------------------------- MODULE INFO ---------------------------------
__all__ = ["SkaffConfig"]
# ------------------------------- MODULE INFO ---------------------------------

# --------------------------------- MODULES -----------------------------------
import collections
import copy
import glob
import os
if "posix" == os.name:
    import pwd
elif "nt" == os.name:
    import getpass
import shutil

from datetime import datetime
# --------------------------------- MODULES -----------------------------------


# --------------------------------- CLASSES -----------------------------------
[docs]class SkaffConfig: """ Configuration type used for the argument of 'skaff' function. """ # 'languages', 'licenses', and 'subdirectories' can only be determined # for a given 'paths' attribute since all the mutators associated with # the former three directly or indirectly call their corresponding _*probe # methods, which need info about 'paths'. # # Similarly, 'authors', 'language', 'license', and 'quiet' are associated # with a list of 'directories'. # # NOTE: 'languages', 'licenses', and 'templates' attributes may be dropped # without actually affecting the behavior of this class since mutators for # all three ('language_set', 'license_set', 'templates_set') automatically # call 'languages_probe', 'licenses_probe', and 'templates_probe' # respectively to enforce the dependency. # However, for verbosity and most importantly completeness they are listed # here as well: the only drawback I can think of is THREE MORE REDUNDANT # calls at object construction time. # # # A dependency graph for the first relation stated above is: # # +-----+ # |paths| # +-----+ # ^ # | # +---------+--------+--------------+ # |languages|licenses|subdirectories| # +---------+--------+--------------+ # ^ ^ # | | # +--------------------+ # | templates | # +--------------------+ # # A dependency graph for the second is: # # +-----------+ # |directories| # +-----------+ # ^ # | # +-------+--------+-------+-----+ # |authors|language|license|quiet| # +-------+--------+-------+-----+ # __ATTRIBUTES = ("paths", "languages", "licenses", "subdirectories", "directories", "authors", "language", "license", "quiet") __LANGUAGES = frozenset(("c", "cpp")) __LICENSE_FORMATS = frozenset((".txt", ".md")) __LICENSES = frozenset(("bsd2", "bsd3", "gpl2", "gpl3", "mit")) def __init__(self, directories, **kwargs): """ Constructs a new 'SkaffConfig' class instance. Note you can also specify 'directories' in keyword arguments; the value in keyword arguments will be used instead. Required arguments: 'directories': set of name(s) for the output project-directory(ies) Supported keyword arguments: 'authors': set of author(s) for the project(s) 'directories': set of name(s) for the output project-directory(ies) 'language': major programming language used; must be chosen from the 'languages_list' listing 'license': type of license; must be chosen from the 'licenses_list' listing 'paths': paths containing configuration, template, and license files 'quiet': no interactive CMakeLists.txt and Doxyfile editing 'subdirectories': set of name(s) for the subdirectory(ies) within the project(s)' base directory(ies) """ __METHODS = (self.paths_set, self.languages_probe, self.licenses_probe, self.subdirectories_set, # End of 1st dependency and begin of 2nd dependency self.directories_set, self.authors_set, self.language_set, self.license_set, self.quiet_set) # The value of 'directories' key will be used if it already exists; # otherwise fill in the value from the positional argument kwargs.setdefault("directories", directories) # Mapping between valid attributes(arguments) and corresponding methods relation = zip(SkaffConfig.__ATTRIBUTES, __METHODS) __ARGUMENTS = collections.OrderedDict(relation) # The actual internal per-instance mapping between # attributes(arguments) and corresponding values self.__config = dict.fromkeys(SkaffConfig.__ATTRIBUTES) for key in __ARGUMENTS: # Call corresponding mutator function with value specified in the # 'kwargs' dictionary if key is present if key in kwargs: __ARGUMENTS[key](kwargs[key]) # Otherwise let the mutator function use default value # NOTE: 'directories_set' would raise exception with default # 'None' actual parameter else: __ARGUMENTS[key]()
[docs] def authors_set(self, authors=None): """ Sets the author(s) of the project(s). 'authors' must be an iterable type containing 'str'(s). This member function is called by the constructor by default. Sets the single author to be the GECOS or name field of current logged-in user if 'authors' is left as default or 'None'. """ if None == authors: self.__config["authors"] = set() self.author_add(SkaffConfig.author_fetch()) return if not isinstance(authors, collections.Iterable): raise TypeError("'authors' argument must be iterable") if isinstance(authors, str): raise TypeError(("'authors' argument must be an iterable " "containing 'str' type")) if 0 == len(authors): raise ValueError("'authors' argument must not be empty") self.__config["authors"] = set() for author in authors: self.author_add(author)
[docs] def author_add(self, author): """ Adds 'author' to the internal 'database' if the name does not exist; otherwise do nothing. """ if not isinstance(author, str): raise TypeError("'author' argument must be 'str' type") if 0 == len(author): raise ValueError("'author' argument must not be empty") if not author.isprintable(): raise ValueError("'author' argument must be a valid name") self.__config["authors"].add(author)
[docs] def author_discard(self, author): """ Discards 'author' from the internal 'database' if the name exists; otherwise do nothing. """ if not isinstance(author, str): raise TypeError("'author' argument must be 'str' type") if 0 == len(author): raise ValueError("'author' argument must not be empty") if not author.isprintable(): raise ValueError("'author' argument must be a valid name") self.__config["authors"].discard(author)
[docs] def authors_get(self): """ Gets a generator containing author(s) for the project(s). """ authors = sorted(self.__config["authors"]) yield from (author for author in authors)
@staticmethod
[docs] def author_fetch(): """ Gets the current logged-in username from GECOS or name field. This member function is called by the constructor by default. Raises RuntimeError if both attempts fail. Note this 'staticmethod' may be automatically called from 'authors_get' member function under certain scenarios. """ # If the author's name is not explicitly stated in the commmand-line # argument, default to the GECOS field, which normally stands for the # full username of the current user; otherwise fall back to login name. author = None if "posix" == os.name: pw_record = pwd.getpwuid(os.getuid()) # In Ubuntu 16.04 LTS, the default GECOS field is suffixed by 3 # extra commas for some reason, so they are tested and removed if # it is the case. # Similar anomaly has not been found on the other linux distribution # tested (Fedora) or FreeBSD. if pw_record.pw_gecos: author = pw_record.pw_gecos if author.endswith(","): author = author.strip(",") elif pw_record.pw_name: author = pw_record.pw_name elif "nt" == os.name: author = getpass.getuser() if author: return author else: raise RuntimeError("Failed attempt to get default username")
@staticmethod
[docs] def basepath_fetch(): """ Returns the base directory name containing the skaff 'config' module. The extra 'os.path.abspath' invocation is to suppress relative path output; result includes a trailing path separator. """ return os.path.dirname(os.path.abspath(__file__)) + os.sep
[docs] def create(self, *args): """ Supported arguments: 'tree': 'license': 'template': """ # An 'OrderedDict' is required for the default case: # the 'tree' made of 'directories' and 'subdirectories' need to be # created before the selected license and template files options = ("tree", "license", "template") methods = (None, self.license_sign, None) actions = collections.OrderedDict(zip(options, methods)) if not all(isinstance(arg, str) for arg in args): raise TypeError("'args' must contain 'str' types") if not all(arg in options for arg in args): raise ValueError(("'args' must be selected from: " ", ".join(options))) # Make 'args' variable "point to" the tuple object 'options' variable # "points to" if 'args' is left as empty if 0 == len(args): args = options for arg in args: actions[arg]()
[docs] def directories_set(self, directories=None): """ Sets the name(s) of the outputting project-directory(ies). Platform-dependent path separator will be appended if missing. This member function is called by the constructor by default. 'directories' argument must be of 'collections.Iterable' type containing instance of 'str'(s). """ if not isinstance(directories, collections.Iterable): raise TypeError("'directories' argument must be iterable") if isinstance(directories, str): raise TypeError(("'directories' argument must be an iterable " "containing 'str' type")) if 0 == len(directories): raise ValueError("'directories' argument must not be empty") self.__config["directories"] = set() for directory in directories: self.directory_add(directory)
[docs] def directory_add(self, directory): """ Adds 'directory' to the internal 'database' if the name does not exist; otherwise do nothing. Platform-dependent path separator will be appended if missing. """ if not isinstance(directory, str): raise TypeError("'directory' argument must be 'str' type") if 0 == len(directory): raise ValueError("'directory' argument must not be empty") if not directory.isprintable(): raise ValueError("'directory' argument must be a valid file name") if not directory.endswith(os.sep): directory += os.sep self.__config["directories"].add(directory)
[docs] def directory_discard(self, directory): """ Discards 'directory' from the internal 'database' if the name exists; otherwise do nothing. Platform-dependent path separator will be appended if missing. """ if not isinstance(directory, str): raise TypeError("'directory' argument must be 'str' type") if 0 == len(directory): raise ValueError("'directory' argument must not be empty") if not directory.isprintable(): raise ValueError("'directory' argument must be a valid file name") if not directory.endswith(os.sep): directory += os.sep self.__config["directories"].discard(directory)
[docs] def directories_get(self): """ Gets a generator containing name(s) for the outputting project-directory(ies). """ directories = sorted(self.__config["directories"]) yield from (directory for directory in directories)
[docs] def hooks_set(self, **kwargs): pass
[docs] def hook_add(self, **kwargs): pass
[docs] def hook_discard(self, **kwargs): pass
[docs] def hooks_get(self, **kwargs): pass
[docs] def language_set(self, language=None): """ Sets the major programming language used. Defaults to 'c' language if left as empty or 'None'. This member function is called by the constructor by default. 'language' argument must be the ones listed in 'languages_list'. """ languages = self.languages_list() if None == language: self.__config["language"] = "c" return if language not in languages: raise ValueError(("'language' choice must be one of the following:" " " ", ".join(languages))) self.__config["language"] = language
[docs] def language_get(self): """ Gets the major programming language used. """ return self.__config["language"]
@staticmethod
[docs] def languages_fetch(): """ Gets a list containing the supported programming languages BY DEFAULT. User-defined programming languages are not shown. """ return sorted(SkaffConfig.__LANGUAGES)
[docs] def languages_list(self): """ Gets a generator containing the supported programming languages. By default they are the following: {"c", "cpp"}. """ languages = sorted(SkaffConfig.__LANGUAGES) yield from (language for language in languages)
[docs] def languages_probe(self): """ This member function is called by the constructor by default. """ pass
[docs] def license_set(self, license=None): """ Sets the type of license used. Defaults to 'bsd2' license if left as empty or 'None'. This member function is called by the constructor by default. 'license' argument must be the ones listed in 'licenses_list'. """ # Ensure all the stock licenses do indeed exist self.licenses_validate() # Also probes the user path for possible custom licenses self.licenses_probe() licenses = self.licenses_list() if None == license: self.__config["license"] = "bsd2" return if license not in licenses: raise ValueError(("'license' choice must be one of the following: " ", ".join(licenses))) self.__config["license"] = license
[docs] def license_get(self, fullname=False): """ Gets the type of license used if 'fullname' argument is set to 'False'; otherwise gets a list containing current selected license with fully qualified paths and file extensions attached. NOTE: The paths and file extensions associated with the current license are governed by the rules specified in the docstrings of 'paths_set' and 'licenses_list'. """ user_license_path = self.paths_get("license") system_license_path = (SkaffConfig.basepath_fetch() + "config" + os.sep + "license" + os.sep) license_results = list() if not fullname: return self.__config["license"] for extension in SkaffConfig.__LICENSE_FORMATS: user_license_file_name = (user_license_path + self.__config["license"] + extension) sys_license_file_name = (system_license_path + self.__config["license"] + extension) if os.path.isfile(user_license_file_name): license_results.append(user_license_file_name) elif os.path.isfile(sys_license_file_name): license_results.append(sys_license_file_name) else: raise FileNotFoundError(("License file '{}' not found".format( self.__config["license"]))) return license_results
[docs] def license_sign(self): """ Copies the license text (ends with ".txt" extension) chosen by authors to all the 'directories', signs it with authors and current year prepended if applicable; also copies the license markdown (ends with ".md" extension) chosen by authors to all the 'directories', prepends 'Overview' and 'License' section headers then rename it to 'README.md'; 'directories' must already exist. Note only licenses in {"bsd2", "bsd3", "mit"} will be signed by names in authors. """ copyright_line = "Copyright © {year}, {authors}\n".format( year=datetime.now().year, authors=", ".join(self.authors_get()) ) readme_template = ( "![{0}](misc{1}img{1}banner.png)\n" "\n## Overview\n" "\n## License\n" ) license_sources = self.license_get(fullname=True) sign_required_licenses = ("bsd2", "bsd3", "mit") for directory in self.directories_get(): lse_tgt = directory + "LICENSE.txt" readme_target = directory + "README.md" readme_header = readme_template.format(directory[:-1], os.sep) for lse_src in license_sources: if lse_src.endswith(".md"): with open(lse_src, "r", encoding="utf-8") as from_file: license_markdown = from_file.read() with open(readme_target, "w", encoding="utf-8") as to_file: to_file.write(readme_header) to_file.write(copyright_line) to_file.write(license_markdown) else: if self.license_get() in sign_required_licenses: with open(lse_src, "r", encoding="utf-8") as from_file: vanilla_license_text = from_file.read() with open(lse_tgt, "w", encoding="utf-8") as to_file: to_file.write(copyright_line) to_file.write(vanilla_license_text) else: shutil.copy(lse_src, lse_tgt)
@staticmethod
[docs] def licenses_fetch(): """ Gets a list containing the supported licenses BY DEFAULT. User-defined licenses are not shown. """ return sorted(SkaffConfig.__LICENSES)
[docs] def licenses_list(self, fullname=False): """ Gets a generator containing the supported licenses with or without paths and file exntensions, depending on whether 'fullname' is enabled. For new licenses added in the license path (refer to docstrings for 'paths_set' mutator member function for details), remember to call 'licenses_probe' mutator member function to actually add them to the internal database; otherwise they would not be listed. NOTE: For overridden licenses (licenses with the same name as the stock licenses but appear in the license path set by 'license_set'), this generator will only reflect the difference when 'fullname' argument is switched to 'True'. For exmaple, with license path set to "$HOME/.config/skaff/license/" and a custom 'bsd2' license is set up as: "$HOME/.config/skaff/license/bsd2.txt" "$HOME/.config/skaff/license/bsd2.md" then a licenses_list() invocation would only produce the SAME DEFAULT result as shown at the BOTTOM; only licenses_list(True) will generate fully qualified results like: "$HOME/.config/skaff/license/bsd2.txt" "$HOME/.config/skaff/license/bsd2.md" (Note the rest default stock licenses are left untouched; the stock bsd2 license in the system path would not be shown since it is overridden) "/usr/lib/python3/dist-packages/skaff/config/license/bsd3.txt" "/usr/lib/python3/dist-packages/skaff/config/license/bsd3.md" ... By default they are the following: {"bsd2", "bsd3", "gpl2", "gpl3", "mit"}. """ licenses = sorted(self.__config["licenses"]) user_license_path = self.paths_get("license") system_license_path = (SkaffConfig.basepath_fetch() + "config" + os.sep + "license" + os.sep) if not fullname: yield from (license for license in licenses) else: for license in licenses: for ext in SkaffConfig.__LICENSE_FORMATS: user_license_file_name = user_license_path + license + ext sys_license_file_name = system_license_path + license + ext if os.path.isfile(user_license_file_name): yield user_license_file_name elif os.path.isfile(sys_license_file_name): yield sys_license_file_name else: raise FileNotFoundError(("The corresponding files for " "'{}' license is not found" .format(license)))
[docs] def licenses_probe(self): """ Probes the 'license' path set by the 'paths_set' member function for new licenses and add them to the internal 'database' to be returned by 'licenses_list' member function. NOTE: normally this member function does not need to be called manually (if some new license files get copied to the 'license' path, for example) since 'license_set' automatically calls this member function; unless you just want to add those custom licenses to the internal 'database' WITHOUT switching the CURRENT license selected. """ # Reset the internal licenses database self.__config["licenses"] = set(SkaffConfig.__LICENSES) user_license_path = self.paths_get("license") # Temporary dictionary used for comparison between licenses # named with ".txt" and ".md" extension license_extensions = SkaffConfig.__LICENSE_FORMATS temp_license_dict = {key: set() for key in license_extensions} normalize_funcs = (os.path.basename, os.path.splitext, lambda x: x[0]) if not os.path.isdir(user_license_path): return for file_ext in temp_license_dict.keys(): for user_license in glob.iglob(user_license_path + "*" + file_ext): # Remove the file extension and path for func in normalize_funcs: user_license = func(user_license) temp_license_dict[file_ext].add(os.path.basename(user_license)) if temp_license_dict[".txt"] != temp_license_dict[".md"]: raise FileNotFoundError(("The number of license files end with " "those file extensions must equal; " "there must be a file with same name for " "each of the following file extension: " ", ".join(temp_license_dict.keys()))) self.__config["licenses"] |= temp_license_dict[".txt"]
[docs] def licenses_validate(self): """ Validates all the stock licenses distributed along with the 'skaff' program (does not change any internal states). By default they reside under license subdirectory of 'system' config path, which is not modifiable (see docstring of 'paths_set'): "/usr/lib/python3/dist-packages/skaff/config/license/" """ # The 'system' paths are hard-coded and cannot be changed freely; # in contrast to the 'user' paths set through the 'paths_set' # mutator member function interface system_config_path = (SkaffConfig.basepath_fetch() + "config" + os.sep) system_license_path = system_config_path + "license" + os.sep # system_template_path = system_config_path + "template" + os.sep file_extensions = (".txt", ".md") for system_path in (system_config_path, system_license_path): if not os.path.isdir(system_path): raise FileNotFoundError("The system path: '{}' does not exist" .format(system_path)) for license in SkaffConfig.__LICENSES: for file_ext in file_extensions: license_file = system_license_path + license + file_ext if not os.path.isfile(license_file): raise FileNotFoundError(("The stock version of license " "file: '{}' does not exist" .format(license_file)))
[docs] def paths_set(self, **kwargs): """ Sets the paths containing configuration, template, and license files. This member function is called by the constructor by default. NOTE: the paths containing configuration, template, and license files specified here will take precedence over files with identical name in the 'system' path. For example, if there is a file named 'bsd.txt' in the default path of license files: "$HOME/.config/skaff/license/bsd.txt" then the content within that file will be used instead of the supplied stock version "/usr/lib/python3/dist-packages/skaff/config/license/bsd.txt" when you create a project using 'bsd' license; same goes for templates. You can add new configuration, template, and licenses in this path and it will be discovered by corresponding _*probe member functions, which are called by the constructor by default. Supported keyword arguments: 'config': path containing 'skaff.conf' configuration file. Default is: "$HOME/.config/skaff/" 'license': path containing license files to be copied to the directories specified in 'directories_set' or 'directory_add'. Default is: "$HOME/.config/skaff/license/" 'template': path containing template files to be copied to the directories specified in 'directories_set', 'directory_add', 'subdirectories_set', or 'subdirectory_add'. Default is: "$HOME/.config/skaff/template/" """ keys = ("config", "license", "template") items = kwargs.items() user_config = os.path.expanduser("~") + os.sep + ".config" + os.sep +\ "skaff" + os.sep user_template = user_config + "template" + os.sep user_license = user_config + "license" + os.sep if not all(key in keys and isinstance(val, str) for key, val in items): raise ValueError(("values of 'kwargs' must be of 'str' type and " "keys must be one of the following keywords: " ", ".join(keys))) kwargs.setdefault("config", user_config) kwargs.setdefault("license", user_license) kwargs.setdefault("template", user_template) self.__config["paths"] = dict() for key in keys: self.__config["paths"][key] = kwargs[key]
[docs] def paths_get(self, *args): """ Gets the paths containing configuration, template, and license files. Accepted arguments are the following strings: 'config': 'license': 'template': If called without any actual argument, returns a deep copy of the internal dictionary containing all the stored key-value pairs. If called with multiple arguments, a list with corresponding results will be returned. """ result_paths = list() if not all(isinstance(arg, str) for arg in args): raise TypeError("'args' must contain 'str' types") if 0 == len(args): return copy.deepcopy(self.__config["paths"]) if 1 == len(args): return self.__config["paths"][args[0]] for arg in args: result_paths.append(self.__config["paths"][arg]) return result_paths
[docs] def quiet_set(self, quiet=None): """ Sets whether there is interactive CMakeLists.txt and Doxyfile editing. 'True' to turn off the interactive editing. Defaults to 'True' if left as empty or 'None'. This member function is called by the constructor by default. 'quiet' argument must be of 'bool' type. """ if None == quiet: self.__config["quiet"] = True return if not isinstance(quiet, bool): raise TypeError("'quiet' must be of 'bool' type") self.__config["quiet"] = quiet
[docs] def quiet_get(self): """ Gets whether there is interactive CMakeLists.txt and Doxyfile editing. """ return self.__config["quiet"]
[docs] def subdirectories_set(self, subdirectories=None): """ Sets the name(s) of the subdirectory(ies) within the project(s)' base directory(ies). Defaults to { "build", "cmake", "cmake" + os.sep + "modules", "cmake" + os.sep + "platforms", "contrib", "doc", "examples", "include", "misc", "misc" + os.sep + "conf", "misc" + os.sep + "img", "src", "tests", "tools" } if left as empty or 'None'. Platform-dependent path separator will be appended if missing. This member function is called by the constructor by default. 'subdirectories' argument must be of 'collections.Iterable' type containing instance of 'str'(s). """ if None == subdirectories: self.__config["subdirectories"] = { "build", "cmake", "cmake" + os.sep + "modules", "cmake" + os.sep + "platforms", "contrib", "doc", "examples", "include", "misc", "misc" + os.sep + "conf", "misc" + os.sep + "img", "src", "tests", "tools" } return if not isinstance(subdirectories, collections.Iterable): raise TypeError("'subdirectories' argument must be iterable") if isinstance(subdirectories, str): raise TypeError(("'subdirectories' argument must be an iterable " "containing 'str' type")) if 0 == len(subdirectories): raise ValueError("'subdirectories' argument must not be empty") self.__config["subdirectories"] = set() for directory in subdirectories: self.subdirectory_add(directory)
[docs] def subdirectory_add(self, subdirectory): """ Adds 'subdirectory' to the internal 'database' if the name does not exist; otherwise do nothing. Platform-dependent path separator will be appended if missing. """ if not isinstance(subdirectory, str): raise TypeError("'subdirectory' argument must be 'str' type") if 0 == len(subdirectory): raise ValueError("'subdirectory' argument must not be empty") if not subdirectory.isprintable(): raise ValueError(("'subdirectory' argument must be " "a valid file name")) if not subdirectory.endswith(os.sep): subdirectory += os.sep self.__config["subdirectories"].add(subdirectory)
[docs] def subdirectory_discard(self, subdirectory): """ Discards 'subdirectory' from the internal 'database' if the name exists; otherwise do nothing. Platform-dependent path separator will be appended if missing. """ if not isinstance(subdirectory, str): raise TypeError("'subdirectory' argument must be 'str' type") if 0 == len(subdirectory): raise ValueError("'subdirectory' argument must not be empty") if not subdirectory.isprintable(): raise ValueError(("'subdirectory' argument must be " "a valid file name")) if not subdirectory.endswith(os.sep): subdirectory += os.sep self.__config["subdirectories"].discard(subdirectory)
[docs] def subdirectories_get(self): """ Gets a generator containing name(s) of the subdirectory(ies) within the project(s)' base directory(ies). """ subdirectories = sorted(self.__config["subdirectories"]) yield from (subdirectory for subdirectory in subdirectories)
[docs] def templates_set(self, templates=None): """ """ pass
[docs] def template_add(self, template): """ """ pass
[docs] def template_discard(self, template): """ """ pass
[docs] def templates_get(self, fullname=False): """ """ pass
[docs] def templates_probe(self): """ """ pass
def _load(self, *args): """ """ pass def _save(self, *args): """ """ pass
# --------------------------------- CLASSES -----------------------------------