diff --git a/contrib/bash-completion/bob b/contrib/bash-completion/bob index d3d9943db..3a2309a00 100644 --- a/contrib/bash-completion/bob +++ b/contrib/bash-completion/bob @@ -30,7 +30,7 @@ __bob_complete_dir() } __bob_commands="build dev clean graph help init jenkins ls project status \ - query-scm query-recipe query-path query-meta show" + query-scm query-recipe query-path query-meta show layers" # Complete a Bob path # @@ -79,10 +79,13 @@ __bob_complete_path() # Auto complete config files. They can be in directories and their '.yaml' # suffix is implicitly added by Bob. The file name might directly start # after '-c' making it a bit more complicated. - if [[ $prev = "-c" || $cur = -c?* ]] ; then + if [[ $prev = "-c" || $cur = -c?* || $prev = "-lc" || $cur = -lc* ]] ; then if [[ $cur = -c?* ]] ; then prefix="-c" cur="${cur:2}" + elif [[ $cur = -lc?* ]] ; then + prefix="-lc" + cur="${cur:3}" else prefix= fi @@ -386,6 +389,21 @@ __bob_status() __bob_complete_path "--attic --develop --recursive --no-sandbox --release --sandbox --show-clean --show-overrides --verbose -D -c -r -v" } +__bob_layers_status() +{ + __bob_complete_path "-c -D -v -lc --attic --no-attic" +} + +__bob_layers_update() +{ + __bob_complete_path "-c -D -v -lc --attic --no-attic" +} + +__bob_layers() +{ + __bob_subcommands "status update" "layers" +} + __bob_show() { if [[ "$prev" = "--format" ]] ; then diff --git a/doc/conf.py b/doc/conf.py index 9fee3826c..0c7d91e63 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -267,6 +267,7 @@ def __getattr__(cls, name): ('manpages/bob-graph', 'bob-graph', 'Generate dependency graph', ['Ralf Hubert'], 1), ('manpages/bob-init', 'bob-init', 'Initialize out-of-source build tree', ['Jan Klötzke'], 1), ('manpages/bob-jenkins', 'bob-jenkins', 'Configure Jenkins server', ['Jan Klötzke'], 1), + ('manpages/bob-layers', 'bob-layers', 'Manage Layers', ['BobBuildTool Team'], 1), ('manpages/bob-ls', 'bob-ls', 'List package hierarchy', ['Jan Klötzke'], 1), ('manpages/bobpaths', 'bobpaths', 'Specifying paths to Bob packages', ['Jan Klötzke'], 7), ('manpages/bob-project', 'bob-project', 'Create IDE project files', ['Jan Klötzke'], 1), diff --git a/doc/manpages/bob-layers.rst b/doc/manpages/bob-layers.rst new file mode 100644 index 000000000..0d457f7a6 --- /dev/null +++ b/doc/manpages/bob-layers.rst @@ -0,0 +1,76 @@ +.. _manpage-layers: + +bob-layers +========== + +.. only:: not man + + Name + ---- + + bob-layers - Handle layers + +Synopsis +-------- + +:: + + bob layers [-h] [-c CONFIGFILE] [-lc LAYERCONFIG] [-v] [-D DEFINES] + [--attic | --no-attic] + {update,status} + +Description +----------- + +Update layers or show their SCM-status. The following sub-commands are +available: + +``update`` + Updates the layers. + +``status`` + Show the SCM-status of each layer and optionally list modifications. See + :ref:`bob status ` for a description of the output + fields. + +Options +------- + +``--attic`` + Move layer workspace to attic if inline SCM switching is not possible. + (Default) + +``--no-attic`` + Do not move layer workspace to attic if inline SCM switching is not possible. + Instead a build error is issued. + +``-c CONFIGFILE`` + Use additional configuration file. + + The ``.yaml`` suffix is appended automatically and the configuration file + is searched relative to the project root directory unless an absolute path + is given. Bob will parse these user configuration files after + *default.yaml*. They are using the same schema. + + This option can be given multiple times. The files will be parsed in the + order as they appeared on the command line. + +``-lc LAYERCONFIG`` + Use additional layer configuration file. + + This is special kind of configuration file to control the layers checkout. Only + ``layersWhitelist`` and ``layersScmOverrides`` are supported. + + The ``.yaml`` suffix is appended automatically and the configuration file + is searched relative to the project root directory unless an absolute path + is given. + +``-D VAR=VALUE`` + Override default or set environment variable. + + Sets the variable ``VAR`` to ``VALUE``. This overrides the value possibly + set by ``default.yaml``, config files passed by ``-c`` or any file that was + included by either of these files. + +``-v, --verbose`` + Increase verbosity (may be specified multiple times) diff --git a/doc/manpages/index.rst b/doc/manpages/index.rst index 0f69ae5a5..9a4b73670 100644 --- a/doc/manpages/index.rst +++ b/doc/manpages/index.rst @@ -14,6 +14,7 @@ Contents: bob-graph bob-init bob-jenkins + bob-layers bob-ls bobpaths bob-project diff --git a/doc/manual/configuration.rst b/doc/manual/configuration.rst index 091e2c729..d31a5ecc7 100644 --- a/doc/manual/configuration.rst +++ b/doc/manual/configuration.rst @@ -1927,7 +1927,7 @@ equal. layers ~~~~~~ -Type: List of strings +Type: List of strings or SCM-Dictionary or List of SCM-Dictionaries The ``layers`` section consists of a list of layer names that are then expected in the ``layers`` directory relative to the ``conig.yaml`` referencing them:: @@ -1949,8 +1949,40 @@ of lower precedence. See :ref:`configuration` for more information. +Typically layers have their own VCS. To provide them to the root-recipes common +VCS-methods like git-submodules can be used. Another possibility is to provide a +SCM-Dictionary (see :ref:`configuration-recipes-scm`):: + + layers: + - name: myapp + scm: git + url: git@foo.bar:myapp.git + commit: ... + - bsp + +Only `git`,`svn`,`url` and `cvs` scm's are supported for layers. During layers +checkout the regular ``whitelist`` and ``scmOverrides`` settings are not used. +Instead the checkout could be controlled by ``layersWhitelist`` and +``layersScmOverrides``. + +If a scmSpec is given Bob takes care of the layer management: + +- layers are checked out / updated during bob-build (except build-only) +- bob layers command to update / show status (see :ref:`manpage-layers`). + .. _configuration-config-plugins: +layersWhitelist +~~~~~~~~~~~~~~~ + +Whitelist for layers update only. See :ref:`configuration-config-whitelist`. + +layersScmOverrides +~~~~~~~~~~~~~~~~~~ + +:ref:`configuration-config-scmOverrides` used by layers checkout / update. +Conditional overrides are not supported. + plugins ~~~~~~~ diff --git a/pym/bob/builder.py b/pym/bob/builder.py index 819e056ef..e7bd85b6f 100644 --- a/pym/bob/builder.py +++ b/pym/bob/builder.py @@ -1474,7 +1474,7 @@ async def _downloadPackage(self, packageStep, depth, packageBuildId): * still same build-id -> done * build-id changed -> prune and try download, fall back to build """ - layer = "/".join(packageStep.getPackage().getRecipe().getLayer()) + layer = packageStep.getPackage().getRecipe().getLayer() layerDownloadMode = None if layer: for mode in self.__downloadLayerModes: diff --git a/pym/bob/cmds/build/build.py b/pym/bob/cmds/build/build.py index 2d2f1d31a..d590f7232 100644 --- a/pym/bob/cmds/build/build.py +++ b/pym/bob/cmds/build/build.py @@ -9,6 +9,7 @@ from ...input import RecipeSet from ...intermediate import StepIR, PackageIR, RecipeIR, ToolIR, SandboxIR, \ RecipeSetIR +from ...layers import updateLayers from ...share import getShare from ...tty import setVerbosity, setTui, Warn from ...utils import copyTree, processDefines, EventLoopWrapper @@ -171,6 +172,8 @@ def _downloadLayerArgument(arg): help="Override default environment variable") parser.add_argument('-c', dest="configFile", default=[], action='append', help="Use config File") + parser.add_argument('-lc', dest="layerConfig", default=[], action='append', + help="Additional layer config") parser.add_argument('-e', dest="white_list", default=[], action='append', metavar="NAME", help="Preserve environment variable") parser.add_argument('-E', dest="preserve_env", default=False, action='store_true', @@ -224,6 +227,9 @@ def _downloadLayerArgument(arg): recipes.defineHook('developNameFormatter', LocalBuilder.developNameFormatter) recipes.defineHook('developNamePersister', None) recipes.setConfigFiles(args.configFile) + if args.build_mode != 'build-only': + setVerbosity(args.verbose) + updateLayers(recipes, loop, defines, args.verbose, args.attic, args.layerConfig) recipes.parse(defines) # if arguments are not passed on cmdline use them from default.yaml or set to default yalue diff --git a/pym/bob/cmds/layers.py b/pym/bob/cmds/layers.py new file mode 100644 index 000000000..99e3a8e05 --- /dev/null +++ b/pym/bob/cmds/layers.py @@ -0,0 +1,48 @@ +import argparse + +from ..input import RecipeSet +from ..layers import Layers, updateLayers +from ..tty import NORMAL, setVerbosity +from ..utils import EventLoopWrapper, processDefines +from .build.status import PackagePrinter + +def doLayers(argv, bobRoot): + parser = argparse.ArgumentParser(prog="bob layers", description='Handle layers') + parser.add_argument('action', type=str, choices=['update', 'status'], default="status", + help="Action: [update, status]") + parser.add_argument('-c', dest="configFile", default=[], action='append', + help="Use config File.") + parser.add_argument('-lc', dest="layerConfig", default=[], action='append', + help="Additional layer config") + parser.add_argument('-v', '--verbose', default=NORMAL, action='count', + help="Increase verbosity (may be specified multiple times)") + parser.add_argument('-D', default=[], action='append', dest="defines", + help="Override default environment variable") + group = parser.add_mutually_exclusive_group() + group.add_argument('--attic', action='store_true', default=True, + help="Move scm to attic if inline switch is not possible (default).") + group.add_argument('--no-attic', action='store_false', default=None, dest='attic', + help="Do not move to attic, instead fail the build.") + + args = parser.parse_args(argv) + + setVerbosity(args.verbose) + + defines = processDefines(args.defines) + + with EventLoopWrapper() as (loop, executor): + recipes = RecipeSet() + recipes.setConfigFiles(args.configFile) + if args.action == "update": + updateLayers(recipes, loop, defines, args.verbose, + args.attic, args.layerConfig) + + recipes.parse(defines) + + layers = Layers(recipes, loop, defines, args.attic) + layers.setLayerConfig(args.layerConfig) + layers.collect(False, args.verbose) + if args.action == "status": + pp = PackagePrinter(args.verbose, False, False) + layers.status(pp.show) + diff --git a/pym/bob/input.py b/pym/bob/input.py index 255f469c3..440012fe0 100644 --- a/pym/bob/input.py +++ b/pym/bob/input.py @@ -1718,6 +1718,36 @@ def validate(self, data): None) return data +class LayerSpec: + def __init__(self, name, scm=None): + self.__name = name; + self.__scm = scm + + def getName(self): + return self.__name + + def getScm(self): + return self.__scm + +class LayerValidator: + def __init__(self): + self.__scmValidator = ScmValidator({ + 'git' : GitScm.SCHEMA, + 'svn' : SvnScm.SCHEMA, + 'cvs' : CvsScm.SCHEMA, + 'url' : UrlScm.SCHEMA}) + + def validate(self, data): + if isinstance(data,str): + return LayerSpec(data) + if 'name' not in data: + raise schema.SchemaMissingKeyError("Missing 'name' key in {}".format(data), None) + _data = data.copy(); + name = _data.get('name') + del _data['name'] + + return LayerSpec (name, self.__scmValidator.validate(_data)[0]) + class VarDefineValidator: def __init__(self, keyword): self.__varName = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$') @@ -1947,7 +1977,7 @@ def createVirtualRoot(recipeSet, roots, properties): "buildScript" : "true", "packageScript" : "true" } - ret = Recipe(recipeSet, recipe, [], "", ".", "", "", properties) + ret = Recipe(recipeSet, recipe, "", "", ".", "", "", properties) ret.resolveClasses(Env()) return ret @@ -2914,7 +2944,30 @@ class RecipeSet: schema.Optional('max_depth') : int, }) - STATIC_CONFIG_SCHEMA = schema.Schema({ + SCM_SCHEMA = ScmValidator({ + 'git' : GitScm.SCHEMA, + 'svn' : SvnScm.SCHEMA, + 'cvs' : CvsScm.SCHEMA, + 'url' : UrlScm.SCHEMA, + 'import' : ImportScm.SCHEMA, + }) + + STATIC_CONFIG_LAYER_SPEC = { + schema.Optional('layersWhitelist') : [str], + schema.Optional('layersScmOverrides') : schema.Schema([{ + schema.Optional('match') : schema.Schema({ str: object }), + schema.Optional('del') : [str], + schema.Optional('set') : schema.Schema({ str: object }), + schema.Optional('replace') : schema.Schema({ + str : schema.Schema({ + 'pattern' : str, + 'replacement' : str + }) + }) + }]) + } + + STATIC_CONFIG_SCHEMA_SPEC = { schema.Optional('bobMinimumVersion') : str, # validated separately in preValidate schema.Optional('plugins') : [str], schema.Optional('policies') : schema.Schema( @@ -2939,24 +2992,49 @@ class RecipeSet: }, error="Invalid policy specified! Are you using an appropriate version of Bob?" ), - schema.Optional('layers') : [str], + schema.Optional('layers') : [LayerValidator()], schema.Optional('scriptLanguage', default=ScriptLanguage.BASH) : schema.And(schema.Or("bash", "PowerShell"), schema.Use(ScriptLanguage)), - }) - - SCM_SCHEMA = ScmValidator({ - 'git' : GitScm.SCHEMA, - 'svn' : SvnScm.SCHEMA, - 'cvs' : CvsScm.SCHEMA, - 'url' : UrlScm.SCHEMA, - 'import' : ImportScm.SCHEMA, - }) + } MIRRORS_SCHEMA = ScmValidator({ 'url' : UrlScm.MIRRORS_SCHEMA, }) + POLICIES = { + 'noUndefinedTools' : ( + "0.17.3.dev57", + InfoOnce("noUndefinedTools policy not set. Included but undefined tools are not detected at parsing time.", + help="See http://bob-build-tool.readthedocs.io/en/latest/manual/policies.html#noundefinedtools for more information.") + ), + 'scmIgnoreUser' : ( + "0.17.3.dev97", + InfoOnce("scmIgnoreUser policy not set. Authentication part URL is tainting binary artifacts.", + help="See http://bob-build-tool.readthedocs.io/en/latest/manual/policies.html#scmignoreuser for more information.") + ), + 'pruneImportScm' : ( + "0.17.3.dev102", + InfoOnce("pruneImportScm policy not set. Incremental builds of 'import' SCM may lead to wrong results.", + help="See http://bob-build-tool.readthedocs.io/en/latest/manual/policies.html#pruneimportscm for more information.") + ), + 'gitCommitOnBranch' : ( + "0.21.0.dev5", + InfoOnce("gitCommitOnBranch policy not set. Will not check if commit / tag is on configured branch.", + help="See http://bob-build-tool.readthedocs.io/en/latest/manual/policies.html#gitcommitonbranch for more information.") + ), + 'fixImportScmVariant' : ( + "0.22.1.dev34", + InfoOnce("fixImportScmVariant policy not set. Recipe variant calculation w/ import SCM is boguous.", + help="See http://bob-build-tool.readthedocs.io/en/latest/manual/policies.html#fiximportscmvariant for more information.") + ), + "defaultFileMode": ( + "0.24rc1", + InfoOnce("defaultFileMode policy not set. File mode of URL SCMs not set for locally copied files.", + help="See http://bob-build-tool.readthedocs.io/en/latest/manual/policies.html#defaultfilemode for more information.") + ), + } + _ignoreCmdConfig = False @classmethod def ignoreCommandCfg(cls): @@ -2991,38 +3069,8 @@ def __init__(self): self.__commandConfig = {} self.__uiConfig = {} self.__shareConfig = {} - self.__policies = { - 'noUndefinedTools' : ( - "0.17.3.dev57", - InfoOnce("noUndefinedTools policy not set. Included but undefined tools are not detected at parsing time.", - help="See http://bob-build-tool.readthedocs.io/en/latest/manual/policies.html#noundefinedtools for more information.") - ), - 'scmIgnoreUser' : ( - "0.17.3.dev97", - InfoOnce("scmIgnoreUser policy not set. Authentication part URL is tainting binary artifacts.", - help="See http://bob-build-tool.readthedocs.io/en/latest/manual/policies.html#scmignoreuser for more information.") - ), - 'pruneImportScm' : ( - "0.17.3.dev102", - InfoOnce("pruneImportScm policy not set. Incremental builds of 'import' SCM may lead to wrong results.", - help="See http://bob-build-tool.readthedocs.io/en/latest/manual/policies.html#pruneimportscm for more information.") - ), - 'gitCommitOnBranch' : ( - "0.21.0.dev5", - InfoOnce("gitCommitOnBranch policy not set. Will not check if commit / tag is on configured branch.", - help="See http://bob-build-tool.readthedocs.io/en/latest/manual/policies.html#gitcommitonbranch for more information.") - ), - 'fixImportScmVariant' : ( - "0.22.1.dev34", - InfoOnce("fixImportScmVariant policy not set. Recipe variant calculation w/ import SCM is boguous.", - help="See http://bob-build-tool.readthedocs.io/en/latest/manual/policies.html#fiximportscmvariant for more information.") - ), - "defaultFileMode": ( - "0.24rc1", - InfoOnce("defaultFileMode policy not set. File mode of URL SCMs not set for locally copied files.", - help="See http://bob-build-tool.readthedocs.io/en/latest/manual/policies.html#defaultfilemode for more information.") - ), - } + self.__layers = [] + self.__policies = RecipeSet.POLICIES.copy() self.__buildHooks = {} self.__sandboxOpts = {} self.__scmDefaults = {} @@ -3382,7 +3430,8 @@ def loadYaml(self, path, schema, default={}, preValidate=lambda x: None): else: return schema[0].validate(default) - def parse(self, envOverrides={}, platform=getPlatformString(), recipesRoot=""): + def parse(self, envOverrides={}, platform=getPlatformString(), recipesRoot="", + noLayers=False): if not recipesRoot and os.path.isfile(".bob-project"): try: with open(".bob-project") as f: @@ -3392,17 +3441,19 @@ def parse(self, envOverrides={}, platform=getPlatformString(), recipesRoot=""): recipesDir = os.path.join(recipesRoot, "recipes") if not os.path.isdir(recipesDir): raise ParseError("No recipes directory found in " + recipesDir) + self.__projectRoot = recipesRoot or os.getcwd() self.__cache.open() try: - self.__parse(envOverrides, platform, recipesRoot) + self.__parse(envOverrides, platform, recipesRoot, noLayers) finally: self.__cache.close() - self.__projectRoot = recipesRoot or os.getcwd() - def __parse(self, envOverrides, platform, recipesRoot=""): + def __parse(self, envOverrides, platform, recipesRoot="", noLayers=False): if platform not in ('cygwin', 'darwin', 'linux', 'msys', 'win32'): raise ParseError("Invalid platform: " + platform) self.__platform = platform + self.__layers = [] + self.__policies = RecipeSet.POLICIES.copy() self.__whiteList = set() if platform == 'win32': self.__whiteList |= set(["ALLUSERSPROFILE", "APPDATA", @@ -3431,7 +3482,10 @@ def __parse(self, envOverrides, platform, recipesRoot=""): os.path.join(os.path.expanduser("~"), '.config')), 'bob', 'default.yaml')) # Begin with root layer - self.__parseLayer([], "9999", recipesRoot) + self.__parseLayer(LayerSpec(""), "9999", recipesRoot, noLayers) + + if noLayers: + return # Out-of-tree builds may have a dedicated default.yaml if recipesRoot: @@ -3474,10 +3528,17 @@ def __parse(self, envOverrides, platform, recipesRoot=""): self.__rootRecipe = Recipe.createVirtualRoot(self, sorted(filteredRoots), self.__properties) self.__addRecipe(self.__rootRecipe) - def __parseLayer(self, layer, maxVer, recipesRoot): - rootDir = os.path.join(recipesRoot, *(os.path.join("layers", l) for l in layer)) + def __parseLayer(self, layerSpec, maxVer, recipesRoot, noLayers=False): + layer = layerSpec.getName() + + if layer in self.__layers: + return + self.__layers.append(layer) + + rootDir = os.path.join(recipesRoot, os.path.join("layers", layer) if layer != "" else "") if not os.path.isdir(rootDir or "."): - raise ParseError("Layer '{}' does not exist!".format("/".join(layer))) + raise ParseError(f"Layer '{layer}' does not exist!", + help="You probably want to run 'bob layers update' to fetch missing layers.") configYaml = os.path.join(rootDir, "config.yaml") def preValidate(data): @@ -3491,16 +3552,24 @@ def preValidate(data): if compareVersion(BOB_VERSION, minVer) < 0: raise ParseError("Your Bob is too old. At least version "+minVer+" is required!") - config = self.loadYaml(configYaml, (RecipeSet.STATIC_CONFIG_SCHEMA, b''), + config_spec = {**RecipeSet.STATIC_CONFIG_SCHEMA_SPEC, **RecipeSet.STATIC_CONFIG_LAYER_SPEC} + config = self.loadYaml(configYaml, (schema.Schema(config_spec), b''), preValidate=preValidate) + + # merge settings + for (name, value) in sorted([s for s in config.items() if s[0] in self.__settings], + key=lambda i: self.__settings[i[0]].priority): + self.__settings[name].merge(value) + minVer = config.get("bobMinimumVersion", "0.16") if compareVersion(maxVer, minVer) < 0: - raise ParseError("Layer '{}' reqires a higher Bob version than root project!" + raise ParseError("Layer '{}' requires a higher Bob version than root project!" .format("/".join(layer))) if compareVersion(minVer, "0.16") < 0: raise ParseError("Projects before bobMinimumVersion 0.16 are not supported!") maxVer = minVer # sub-layers must not have a higher bobMinimumVersion + # Determine policies. The root layer determines the default settings # implicitly by bobMinimumVersion or explicitly via 'policies'. All # sub-layer policies must not contradict root layer policies @@ -3515,10 +3584,13 @@ def preValidate(data): for (name, behaviour) in config.get("policies", {}).items(): self.__policies[name] = (behaviour, None) + if noLayers: + return + # First parse any sub-layers. Their settings have a lower precedence # and may be overwritten by higher layers. for l in config.get("layers", []): - self.__parseLayer(layer + [l], maxVer, recipesRoot) + self.__parseLayer(l, maxVer, recipesRoot) # Load plugins and re-create schemas as new keys may have been added self.__loadPlugins(rootDir, layer, config.get("plugins", [])) diff --git a/pym/bob/layers.py b/pym/bob/layers.py new file mode 100644 index 000000000..bfe47040e --- /dev/null +++ b/pym/bob/layers.py @@ -0,0 +1,262 @@ +import datetime +import os +import schema +import shutil +from .errors import BuildError +from .invoker import CmdFailedError, InvocationError, Invoker +from .scm import ScmOverride +from .state import BobState +from .stringparser import Env +from .input import RecipeSet, Scm, YamlCache +from .utils import INVALID_CHAR_TRANS +from .tty import DEBUG, EXECUTED, INFO, NORMAL, IMPORTANT, SKIPPED, WARNING, log + +class LayerStepSpec: + def __init__(self, path, whitelist): + self.__path = path + self.__whitelist = whitelist + + @property + def workspaceWorkspacePath(self): + return self.__path + + @property + def env(self): + return {} + + @property + def envWhiteList(self): + return self.__whitelist + +class Layer: + def __init__(self, name, root, recipes, yamlCache, defines, attic, + whitelist, overrides, scm=None): + self.__attic = attic + self.__created = False + self.__defines = defines + self.__layerDir = os.path.join(root, "layers", name) if len(name) else root + self.__name = name + self.__recipes = recipes + self.__root = root + self.__subLayers = [] + self.__scm = scm + self.__yamlCache = yamlCache + self.__whitelist = whitelist + self.__overrides = overrides + + async def __checkoutTask(self, verbose): + if self.__scm is None: + return + dir = self.__scm.getProperties(False).get("dir") + + invoker = Invoker(spec=LayerStepSpec(self.__layerDir, self.__whitelist), + preserveEnv= False, + noLogFiles = True, + showStdOut = verbose > INFO, + showStdErr = verbose > INFO, + trace = verbose >= DEBUG, + redirect=False, executor=None) + newState = {} + newState["digest"] = self.__scm.asDigestScript(), + newState["prop"] = {k:v for k,v in self.__scm.getProperties(False).items() if v is not None} + + oldState = BobState().getLayerState(self.__layerDir) + + if os.path.exists(self.__layerDir) and oldState is None: + raise BuildError(f"New layer checkout '{self.getName()}' collides with existing layer '{self.__layerDir}'!") + + created = False + if not os.path.isdir(self.__layerDir): + os.makedirs(self.__layerDir) + self.__created = True + + if not created \ + and self.__scm.isDeterministic() \ + and oldState is not None \ + and oldState["digest"] == newState["digest"]: + log("CHECKOUT: Layer " + + "'{}' skipped (up to date)".format(self.getName()), SKIPPED, INFO) + return + + if not created and oldState is not None and \ + newState["digest"] != oldState["digest"]: + + canSwitch = self.__scm.canSwitch(Scm(oldState["prop"], + Env(self.__defines), + overrides=self.__overrides, + recipeSet=self.__recipes)) + if canSwitch: + log("SWITCH: Layer '{}' .. ok".format(self.getName()), EXECUTED, INFO) + ret = await invoker.executeScmSwitch(self.__scm, oldState["prop"]) + if ret == 0: + BobState().setLayerState(self.__layerDir, newState) + return + + if not self.__attic: + raise BuildError("Layer '{}' inline switch not possible and move to attic disabled '{}'!" + .format(self.__name, self.__layerDir)) + atticName = datetime.datetime.now().isoformat().translate(INVALID_CHAR_TRANS)+"_"+os.path.basename(self.__layerDir) + log("ATTIC: Layer " + + "{} (move to ../../layers.attic/{})".format(self.__layerDir, atticName), WARNING) + atticPath = os.path.join(self.__layerDir, "..", "..", "layers.attic") + if not os.path.isdir(atticPath): + os.makedirs(atticPath) + atticPath = os.path.join(atticPath, atticName) + os.rename(self.__layerDir, atticPath) + BobState().delLayerState(self.__layerDir) + os.makedirs(self.__layerDir) + self.__created = True + + await self.__scm.invoke(invoker) + log("CHECKOUT: Layer " + + "'{}' .. ok".format(self.getName()), EXECUTED, NORMAL) + BobState().setLayerState(self.__layerDir, newState) + + def checkout(self, loop, verbose): + try: + j = loop.create_task(self.__checkoutTask(verbose)) + loop.run_until_complete(j) + except (CmdFailedError, InvocationError) as e: + if self.__created: + shutil.rmtree(self.__layerDir) + raise BuildError(f"Failed to checkout Layer {self.getName()}: {e.what}") + + def getWorkspace(self): + return self.__layerDir + + def getName(self): + return self.__name + + def getWhiteList(self): + return self.__layerWhitelist + + def loadYaml(self, path, schema): + if os.path.exists(path): + return self.__yamlCache.loadYaml(path, schema, {}, preValidate=lambda x: None) + return {} + + def parse(self): + configYaml = os.path.join(self.__layerDir, "config.yaml") + config = self.loadYaml(configYaml, (schema.Schema({**RecipeSet.STATIC_CONFIG_SCHEMA_SPEC, + **RecipeSet.STATIC_CONFIG_LAYER_SPEC}), b'')) + + self.__whitelist.append([c.upper() if self.__platform == "win32" else c + for c in config.get("layersWhiteList", []) ]) + + self.__overrides.extend([ ScmOverride(o) for o in config.get("layersScmOverrides", []) ]) + + for l in config.get('layers', []): + scmSpec = l.getScm() + if scmSpec is None: continue + scmSpec.update({'recipe':configYaml}) + layerScms = Scm(scmSpec, + Env(self.__defines), + overrides=self.__overrides, + recipeSet=self.__recipes) + self.__subLayers.append(Layer(l.getName(), + self.__root, + self.__recipes, + self.__yamlCache, + self.__defines, + self.__attic, + self.__whitelist, + self.__overrides, + layerScms)) + + def getSubLayers(self): + return self.__subLayers + + def status(self, printer): + if self.__scm is None: + return + status = self.__scm.status(self.__layerDir) + printer(status, self.__layerDir) + +class Layers: + def __init__(self, recipes, loop, defines, attic): + self.__layers = {} + self.__loop = loop + self.__recipes = recipes + self.__attic = attic + self.__defines = defines + self.__yamlCache = YamlCache() + self.__whitelist = [] + self.__overrides = [] + self.__layerConfigFiles = [] + + def __haveLayer(self, layer): + for depth,layers in self.__layers.items(): + for l in layers: + if l.getName() == layer.getName(): + return True + return False + + def __collect(self, depth, update, verbose): + self.__layers[depth+1] = [] + newLevel = False + for l in self.__layers[depth]: + if update: + l.checkout(self.__loop, verbose) + l.parse() + for subLayer in l.getSubLayers(): + if not self.__haveLayer(subLayer): + self.__layers[depth+1].append(subLayer) + newLevel = True + if newLevel: + self.__collect(depth + 1, update, verbose) + + def cleanupUnused(self): + old_layers = BobState().getLayers() + layers = [] + for level in sorted(self.__layers.keys()): + for layer in self.__layers[level]: + layers.append(layer.getWorkspace()) + + for d in [x for x in old_layers if x not in layers]: + if os.path.exists(d): + atticName = datetime.datetime.now().isoformat().translate(INVALID_CHAR_TRANS)+"_"+os.path.basename(d) + log("ATTIC: Layer " + + "{} (move to ../../layers.attic/{})".format(d, atticName), WARNING) + atticPath = os.path.join(d, "..", "..", "layers.attic") + if not os.path.isdir(atticPath): + os.makedirs(atticPath) + atticPath = os.path.join(atticPath, atticName) + os.rename(d, atticPath) + BobState().delLayerState(d) + + def collect(self, update, verbose=0): + self.__yamlCache.open() + try: + for c in self.__layerConfigFiles: + c += ".yaml" + if os.path.exists(c): + config = self.__yamlCache.loadYaml(c, (schema.Schema(RecipeSet.STATIC_CONFIG_LAYER_SPEC), b''), + {}, preValidate=lambda x: None) + self.__whitelist.append([c.upper() if self.__platform == "win32" else c + for c in config.get("layersWhiteList", []) ]) + self.__overrides.extend([ ScmOverride(o) for o in config.get("layersScmOverrides", []) ]) + else: + raise BuildError(f"Layer config file {c} not found" ) + + rootLayers = Layer("", os.getcwd(), self.__recipes, self.__yamlCache, + self.__defines, self.__attic, self.__whitelist, self.__overrides) + rootLayers.parse() + self.__layers[0] = rootLayers.getSubLayers(); + self.__collect(0, update, verbose) + finally: + self.__yamlCache.close() + + def setLayerConfig(self, configFiles): + self.__layerConfigFiles = configFiles + + def status(self, printer): + for level in sorted(self.__layers.keys()): + for layer in self.__layers[level]: + layer.status(printer) + +def updateLayers(recipes, loop, defines, verbose, attic, layerConfigs): + recipes.parse(defines, noLayers=True) + layers = Layers(recipes, loop, defines, attic) + layers.setLayerConfig(layerConfigs) + layers.collect(True, verbose) + layers.cleanupUnused() diff --git a/pym/bob/scripts.py b/pym/bob/scripts.py index 08da8f0ca..db0ae8c13 100644 --- a/pym/bob/scripts.py +++ b/pym/bob/scripts.py @@ -59,6 +59,11 @@ def __ls(*args, **kwargs): doLS(*args, **kwargs) return 0 +def __layers(*args, **kwargs): + from .cmds.layers import doLayers + doLayers(*args, **kwargs) + return 0 + def __project(*args, **kwargs): from .cmds.build.project import doProject doProject(*args, **kwargs) @@ -111,6 +116,7 @@ def __jenkinsExecute(*args, **kwargs): "help" : ('hl', __help, "Display help information about command"), "init" : ('hl', __init, "Initialize build tree"), "jenkins" : ('hl', __jenkins, "Configure Jenkins server"), + "layers" : ('hl', __layers, "Handle layers"), "ls" : ('hl', __ls, "List package hierarchy"), "project" : ('hl', __project, "Create project files"), "show" : ('hl', __show, "Show properties of a package"), diff --git a/pym/bob/state.py b/pym/bob/state.py index 45188c310..daa912ca3 100644 --- a/pym/bob/state.py +++ b/pym/bob/state.py @@ -314,6 +314,7 @@ def __init__(self): self.__dirty = False self.__dirStates = {} self.__buildState = {} + self.__layerStates = {} self.__lock = None self.__buildIdCache = None self.__variantIds = {} @@ -363,6 +364,7 @@ def __init__(self): self.__inputs = state["inputs"] self.__jenkins = state.get("jenkins", {}) self.__dirStates = state.get("dirStates", {}) + self.__layerStates = state.get("layerStates", {}) self.__buildState = state.get("buildState", {}) self.__variantIds = state.get("variantIds", {}) self.__atticDirs = state.get("atticDirs", {}) @@ -406,6 +408,7 @@ def __save(self): "inputs" : self.__inputs, "jenkins" : self.__jenkins, "dirStates" : self.__dirStates, + "layerStates" : self.__layerStates, "buildState" : self.__buildState, "variantIds" : self.__variantIds, "atticDirs" : self.__atticDirs, @@ -541,6 +544,25 @@ def delInputHashes(self, path): del self.__inputs[path] self.__save() + def getLayers(self): + return list(self.__layerStates.keys()) + + def hasLayerState(self, path): + return path in self.__layerStates + + def getLayerState(self, path): + ret = copy.deepcopy(self.__layerStates.get(path, None)) + return ret + + def setLayerState(self, path, digest): + self.__layerStates[path] = digest + self.__save() + + def delLayerState(self, path): + if path in self.__layerStates: + del self.__layerStates[path] + self.__save() + def getDirectories(self): return list(self.__dirStates.keys()) diff --git a/test/black-box/commands/run.sh b/test/black-box/commands/run.sh index 83d77d954..520c24b4b 100755 --- a/test/black-box/commands/run.sh +++ b/test/black-box/commands/run.sh @@ -18,7 +18,7 @@ for cmd, (hl, func, help) in sorted(availableCommands.items()): echo "$cmds" for c in $cmds; do case "$c" in - archive | init | jenkins | help | _*) + archive | init | jenkins | layers | help | _*) ;; clean) run_bob $c -DBAR=1 -c testconfig diff --git a/test/black-box/layers-checkout/__layers/bar/2/recipes/bar.yaml b/test/black-box/layers-checkout/__layers/bar/2/recipes/bar.yaml new file mode 100644 index 000000000..6f7962ee6 --- /dev/null +++ b/test/black-box/layers-checkout/__layers/bar/2/recipes/bar.yaml @@ -0,0 +1,3 @@ +packageScript: "/bin/true" +provideVars: + BAR_VERSION: "2" diff --git a/test/black-box/layers-checkout/__layers/baz/1/recipes/baz.yaml b/test/black-box/layers-checkout/__layers/baz/1/recipes/baz.yaml new file mode 100644 index 000000000..1413c5808 --- /dev/null +++ b/test/black-box/layers-checkout/__layers/baz/1/recipes/baz.yaml @@ -0,0 +1,3 @@ +packageScript: "/bin/true" +provideVars: + BAZ_VERSION: "1" diff --git a/test/black-box/layers-checkout/__layers/baz/2/recipes/baz.yaml b/test/black-box/layers-checkout/__layers/baz/2/recipes/baz.yaml new file mode 100644 index 000000000..7e1f8635d --- /dev/null +++ b/test/black-box/layers-checkout/__layers/baz/2/recipes/baz.yaml @@ -0,0 +1,3 @@ +packageScript: "/bin/true" +provideVars: + BAZ_VERSION: "2" diff --git a/test/black-box/layers-checkout/__layers/foo/config.yaml b/test/black-box/layers-checkout/__layers/foo/config.yaml new file mode 100644 index 000000000..bce8c0827 --- /dev/null +++ b/test/black-box/layers-checkout/__layers/foo/config.yaml @@ -0,0 +1,9 @@ +layers: + - bar: + checkoutSCM: + scm: import + url: "__layers/bar/2" + - baz: + checkoutSCM: + scm: import + url: "__layers/baz/1" diff --git a/test/black-box/layers-checkout/__layers/foo/recipes/foo.yaml b/test/black-box/layers-checkout/__layers/foo/recipes/foo.yaml new file mode 100644 index 000000000..7f85f00e3 --- /dev/null +++ b/test/black-box/layers-checkout/__layers/foo/recipes/foo.yaml @@ -0,0 +1,2 @@ +buildScript: "true" +packageScript: "true" diff --git a/test/black-box/layers-checkout/config.yaml b/test/black-box/layers-checkout/config.yaml new file mode 100644 index 000000000..ac95374d6 --- /dev/null +++ b/test/black-box/layers-checkout/config.yaml @@ -0,0 +1,9 @@ +layers: + - name: foo + scm: git + url: "file://${FOO_DIR}" + commit: "${FOO_COMMIT}" + - name: bar + scm: git + url: "file://${BAR_DIR}" + commit: "${BAR_1_COMMIT}" diff --git a/test/black-box/layers-checkout/layers_overrides.yaml b/test/black-box/layers-checkout/layers_overrides.yaml new file mode 100644 index 000000000..232d26f00 --- /dev/null +++ b/test/black-box/layers-checkout/layers_overrides.yaml @@ -0,0 +1,8 @@ +layersScmOverrides: + - + match: + url: "file://${FOO_DIR}" + del: [branch, tag, commit] + set: + branch: "branch_override" + diff --git a/test/black-box/layers-checkout/recipes/root.yaml b/test/black-box/layers-checkout/recipes/root.yaml new file mode 100644 index 000000000..80970720e --- /dev/null +++ b/test/black-box/layers-checkout/recipes/root.yaml @@ -0,0 +1,11 @@ +root: True + +depends: + - name: bar + use: [environment] + +buildVars: [BAR_VERSION] +buildScript: | + echo "${BAR_VERSION}" > bar +packageScript: | + cp $1/bar . diff --git a/test/black-box/layers-checkout/run.sh b/test/black-box/layers-checkout/run.sh new file mode 100755 index 000000000..322816817 --- /dev/null +++ b/test/black-box/layers-checkout/run.sh @@ -0,0 +1,174 @@ +#!/bin/bash -e +. ../../test-lib.sh 2>/dev/null || { echo "Must run in script directory!" ; exit 1 ; } + +foo_dir=$(mktemp -d) +bar_dir=$(mktemp -d) +baz_dir=$(mktemp -d) +trap 'rm -rf "$foo_dir" "$bar_dir" "$baz_dir" layers layers.attic log-status.txt' EXIT +cleanup + +# build the git layer bar/1 +pushd ${bar_dir} +mkdir recipes +cat > recipes/bar.yaml << EOF +packageScript: "/bin/true" +provideVars: + BAR_VERSION: "1" +EOF +git init . +git config user.email "bob@bob.bob" +git config user.name est + +git add . +git commit -m "first commit" +bar_c0=$(git rev-parse HEAD) + +sed -i 's/BAR_VERSION: "1"/BAR_VERSION: "3"/g' recipes/bar.yaml +git commit -a -m "bump bar" +bar_c1=$(git rev-parse HEAD) +popd # ${bar_dir} + +pushd ${foo_dir} +cat > config.yaml << 'EOF' +layers: + - name: bar + scm: git + url: "file://${BAR_DIR}" + commit: "${BAR_2_COMMIT}" + - name: baz + scm: git + url: "file://${BAZ_DIR}" + commit: "${BAZ_COMMIT}" + - name: baz1 + scm: git + url: "file://${BAZ_DIR}" + commit: "${BAZ1_COMMIT}" + +EOF +mkdir recipes +cat > recipes/foo.yaml << EOF +buildScript: "true" +packageScript: "true" +EOF +git init . +git config user.email "bob@bob.bob" +git config user.name est + +git add . +git commit -m "first commit" +foo_c0=$(git rev-parse HEAD) + +cat > config.yaml << 'EOF' +layers: + - name: bar + scm: git + url: "file://${BAR_DIR}" + commit: "${BAR_2_COMMIT}" +EOF +git add . +git commit -m "remove baz" +foo_c1=$(git rev-parse HEAD) + +git checkout -b branch_override +echo "override" > override +git add . +git commit -m "override" +popd # $foo_dir + +pushd ${baz_dir} +mkdir recipes +cat > recipes/baz.yaml << EOF +buildScript: "true" +packageScript: "true" +provideVars: + BAZ_VERSION: "1" +EOF +git init . +git config user.email "bob@bob.bob" +git config user.name est +git add . +git commit -m "first commit" +baz_c0=$(git rev-parse HEAD) +cat > recipes/baz.yaml << EOF +buildScript: "true" +packageScript: "true" +provideVars: + BAZ_VERSION: "2" +EOF +git commit -a -m "bump" +baz_c1=$(git rev-parse HEAD) + +mv recipes/baz.yaml recipes/baz1.yaml +git commit -a -m "rename" +baz_c2=$(git rev-parse HEAD) + +popd # $baz_dir + +# just build the root recipe. Layer should be fetched automatically. +run_bob dev root -DBAR_1_COMMIT=${bar_c0} -DBAR_2_COMMIT=${bar_c1} -DBAR_DIR=${bar_dir} \ + -DBAZ_DIR=${baz_dir} -DBAZ_COMMIT="${baz_c0}" \ + -DBAZ1_COMMIT="${baz_c2}" \ + -DFOO_DIR=${foo_dir} -DFOO_COMMIT="${foo_c0}" -vvv + +# run update +run_bob layers update -DBAR_1_COMMIT=${bar_c0} -DBAR_2_COMMIT=${bar_c1} -DBAR_DIR=${bar_dir} \ + -DBAZ_DIR=${baz_dir} -DBAZ_COMMIT="${baz_c0}" \ + -DBAZ1_COMMIT="${baz_c2}" \ + -DFOO_DIR=${foo_dir} -DFOO_COMMIT="${foo_c0}" + +# remove layers + clean +cleanup +rm -rf layers + +# if the layer already exists we fail +mkdir -p layers/bar + +expect_fail run_bob layers update -DBAR_1_COMMIT=${bar_c0} -DBAR_2_COMMIT=${bar_c1} -DBAR_DIR=${bar_dir} \ + -DBAZ_DIR=${baz_dir} -DBAZ_COMMIT="${baz_c0}" \ + -DBAZ1_COMMIT="${baz_c2}" \ + -DFOO_DIR=${foo_dir} -DFOO_COMMIT="${foo_c0}" + +rm -rf layers/bar + +# run update +run_bob layers update -DBAR_1_COMMIT=${bar_c0} -DBAR_2_COMMIT=${bar_c1} -DBAR_DIR=${bar_dir} \ + -DBAZ_DIR=${baz_dir} -DBAZ_COMMIT="${baz_c0}" \ + -DBAZ1_COMMIT="${baz_c2}" \ + -DFOO_DIR=${foo_dir} -DFOO_COMMIT="${foo_c0}" + +# make some changes in layers +echo "#foo" >> layers/bar/recipes/bar.yaml + +run_bob layers status -DBAR_1_COMMIT=${bar_c0} -DBAR_2_COMMIT=${bar_c1} -DBAR_DIR=${bar_dir} \ + -DBAZ_DIR=${baz_dir} -DBAZ_COMMIT="${baz_c0}" \ + -DBAZ1_COMMIT="${baz_c2}" \ + -DFOO_DIR=${foo_dir} -DFOO_COMMIT="${foo_c0}" | tee log-status.txt +grep -q 'STATUS.\+M.\+[/\]bar' log-status.txt + +# update bar to new revision (bar will be moved to attic) +run_bob layers update -DBAR_1_COMMIT=${bar_c1} -DBAR_2_COMMIT=${bar_c1} -DBAR_DIR=${bar_dir} \ + -DBAZ_DIR=${baz_dir} -DBAZ_COMMIT="${baz_c0}" \ + -DBAZ1_COMMIT="${baz_c2}" \ + -DFOO_DIR=${foo_dir} -DFOO_COMMIT="${foo_c0}" +expect_exist layers.attic/*_bar + +bar_now=$(git -C layers/bar rev-parse HEAD) +expect_equal ${bar_c1} ${bar_now} + +rm layers.attic -rf +# checkout new foo where the baz* layers have been removed. they should go to attic +run_bob layers update -DBAR_1_COMMIT=${bar_c1} -DBAR_2_COMMIT=${bar_c1} -DBAR_DIR=${bar_dir} \ + -DFOO_DIR=${foo_dir} -DFOO_COMMIT="${foo_c1}" + +expect_exist layers.attic/*_baz +expect_exist layers.attic/*_baz1 + +# test layersScmOverrides +run_bob layers update -DBAR_1_COMMIT=${bar_c1} -DBAR_2_COMMIT=${bar_c1} -DBAR_DIR=${bar_dir} \ + -DFOO_DIR=${foo_dir} -DFOO_COMMIT="${foo_c1}" \ + -lc layers_overrides -vv +expect_exist layers/foo/override + +# remove layers + clean +cleanup +rm -rf layers diff --git a/test/unit/test_input_recipe.py b/test/unit/test_input_recipe.py index d5c83a97f..b66d9b4eb 100644 --- a/test/unit/test_input_recipe.py +++ b/test/unit/test_input_recipe.py @@ -202,14 +202,14 @@ def parseAndPrepare(self, recipe, classes={}, name="foo", env={}): recipeSet.scriptLanguage = self.SCRIPT_LANGUAGE recipeSet.getPolicy = lambda x: None - cc = { n : Recipe(recipeSet, self.applyRecipeDefaults(r), [], n+".yaml", + cc = { n : Recipe(recipeSet, self.applyRecipeDefaults(r), "", n+".yaml", cwd, n, n, {}, False) for n, r in classes.items() } recipeSet.getClass = lambda x, cc=cc: cc[x] env = Env(env) env.funs = DEFAULT_STRING_FUNS - ret = Recipe(recipeSet, self.applyRecipeDefaults(recipe), [], name+".yaml", + ret = Recipe(recipeSet, self.applyRecipeDefaults(recipe), "", name+".yaml", cwd, name, name, {}) ret.resolveClasses(env) return ret.prepare(env, False, {})[0].refDeref([], {}, None, None) diff --git a/test/unit/test_input_recipeset.py b/test/unit/test_input_recipeset.py index ca0957201..682bd1f60 100644 --- a/test/unit/test_input_recipeset.py +++ b/test/unit/test_input_recipeset.py @@ -34,24 +34,25 @@ def tearDown(self): os.chdir(self.cwd) self.tmpdir.cleanup() - def writeRecipe(self, name, content, layer=[]): + def writeRecipe(self, name, content, layer=None): path = os.path.join("", - *(os.path.join("layers", l) for l in layer), + os.path.join("layers", layer) if layer is not None else "", "recipes") if path: os.makedirs(path, exist_ok=True) with open(os.path.join(path, name+".yaml"), "w") as f: f.write(textwrap.dedent(content)) - def writeClass(self, name, content, layer=[]): + def writeClass(self, name, content, layer=None): path = os.path.join("", - *(os.path.join("layers", l) for l in layer), + os.path.join("layers", layer) if layer is not None else "", "classes") if path: os.makedirs(path, exist_ok=True) with open(os.path.join(path, name+".yaml"), "w") as f: f.write(textwrap.dedent(content)) - def writeConfig(self, content, layer=[]): - path = os.path.join("", *(os.path.join("layers", l) for l in layer)) + def writeConfig(self, content, layer=None): + path = os.path.join("", + os.path.join("layers", layer) if layer is not None else "") if path: os.makedirs(path, exist_ok=True) with open(os.path.join(path, "config.yaml"), "w") as f: f.write(yaml.dump(content)) @@ -1342,26 +1343,26 @@ def setUp(self): self.writeConfig({ "bobMinimumVersion" : "0.24", "layers" : [ "l2" ], - }, layer=["l1_n1"]) + }, layer="l1_n1") self.writeRecipe("foo", """\ depends: - baz buildScript: "true" packageScript: "true" """, - layer=["l1_n1"]) + layer="l1_n1") self.writeRecipe("baz", """\ buildScript: "true" packageScript: "true" """, - layer=["l1_n1", "l2"]) + layer="l2") self.writeRecipe("bar", """\ buildScript: "true" packageScript: "true" """, - layer=["l1_n2"]) + layer="l1_n2") def testRegular(self): """Test that layers can be parsed""" @@ -1375,13 +1376,13 @@ def testRecipeObstruction(self): buildScript: "true" packageScript: "true" """, - layer=["l1_n2"]) + layer="l1_n2") self.assertRaises(ParseError, self.generate) def testClassObstruction(self): """Test that layers must not provide identical classes""" - self.writeClass("c", "", layer=["l1_n1", "l2"]) - self.writeClass("c", "", layer=["l1_n2"]) + self.writeClass("c", "", layer="l2") + self.writeClass("c", "", layer="l1_n2") self.assertRaises(ParseError, self.generate) def testMinimumVersion(self):