Permalink
Cannot retrieve contributors at this time
Fetching contributors…
| import json | |
| import jsonschema | |
| import logging | |
| import os | |
| import tempfile | |
| from inspect import getargspec | |
| from path import Path as path | |
| from ruamel import yaml | |
| from charmtools import utils | |
| from charmtools.build.errors import BuildError | |
| log = logging.getLogger(__name__) | |
| class Tactic(object): | |
| """ | |
| Tactics are first considered in the context of the config layer being | |
| called the config layer will attempt to (using its author provided info) | |
| create a tactic for a given file. That will later be intersected with any | |
| later layers to create a final single plan for each element of the output | |
| charm. | |
| Callable that will implement some portion of the charm composition | |
| Subclasses should implement __str__ and __call__ which should take whatever | |
| actions are needed. | |
| """ | |
| kind = "static" # used in signatures | |
| _warnings = {} # deprecation warnings we've shown | |
| @classmethod | |
| def get(cls, entity, target, layer, next_config, existing_tactic): | |
| """ | |
| Factory method to get an instance of the correct Tactic to handle the | |
| given entity. | |
| """ | |
| for candidate in next_config.tactics + DEFAULT_TACTICS: | |
| argspec = getargspec(candidate.trigger) | |
| if len(argspec.args) == 2: | |
| # old calling convention | |
| name = candidate.__name__ | |
| if name not in Tactic._warnings: | |
| Tactic._warnings[name] = True | |
| log.warn( | |
| 'Deprecated method signature for trigger in %s', name) | |
| args = [entity.relpath(layer.directory)] | |
| else: | |
| # new calling convention | |
| args = [entity, target, layer, next_config] | |
| if candidate.trigger(*args): | |
| tactic = candidate(entity, target, layer, next_config) | |
| if existing_tactic is not None: | |
| tactic = tactic.combine(existing_tactic) | |
| return tactic | |
| raise BuildError('Unable to process file: {} ' | |
| '(no tactics matched)'.format(entity)) | |
| def __init__(self, entity, target, layer, next_config): | |
| self.entity = entity | |
| self._layer = layer | |
| self._target = target | |
| self.data = None | |
| self._next_config = next_config | |
| def __call__(self): | |
| raise NotImplementedError | |
| def __str__(self): | |
| return "{}: {} -> {}".format( | |
| self.__class__.__name__, self.entity, self.target_file) | |
| @property | |
| def layer(self): | |
| """The file in the current layer under consideration""" | |
| return self._layer | |
| @property | |
| def current(self): | |
| """Alias for `Tactic.layer`""" | |
| return self.layer | |
| @property | |
| def target(self): | |
| """The target (final) layer.""" | |
| return self._target | |
| @property | |
| def relpath(self): | |
| return self.entity.relpath(self.layer.directory) | |
| @property | |
| def target_file(self): | |
| target = self.target.directory / self.relpath | |
| return target | |
| @property | |
| def layer_name(self): | |
| return self.layer.name | |
| @property | |
| def repo_path(self): | |
| return path("/".join(self.layer.directory.splitall()[-2:])) | |
| @property | |
| def config(self): | |
| """ | |
| Return the combined config from the layer above this (if any), this, | |
| and all lower layers. | |
| Note that it includes one layer higher so that the tactic can make | |
| decisions based on the upcoming layer. | |
| """ | |
| return self._next_config | |
| def combine(self, existing): | |
| """Produce a tactic informed by the last tactic for an entry. | |
| This is when a rule in a higher level charm overrode something in | |
| one of its bases for example.""" | |
| return self | |
| @classmethod | |
| def trigger(cls, entity, target, layer, next_config): | |
| """Should the rule trigger for a given path object""" | |
| return False | |
| def sign(self): | |
| """return sign in the form {relpath: (origin layer, SHA256)} | |
| """ | |
| target = self.target_file | |
| sig = {} | |
| if target.exists() and target.isfile(): | |
| sig[self.relpath] = (self.layer.url, | |
| self.kind, | |
| utils.sign(self.target_file)) | |
| return sig | |
| def lint(self): | |
| return True | |
| def read(self): | |
| return None | |
| class ExactMatch(object): | |
| FILENAME = None | |
| @classmethod | |
| def trigger(cls, entity, target, layer, next_config): | |
| relpath = entity.relpath(layer.directory) | |
| return cls.FILENAME == relpath | |
| class IgnoreTactic(Tactic): | |
| """ | |
| Tactic to handle per-layer ignores. | |
| If a given layer's ``layer.yaml`` has an ``ignore`` list, then any file | |
| or directory included in that list that is provided by base layers will | |
| be ignored, though any matching file or directory provided by the current | |
| or any higher level layers will be included. | |
| The ``ignore`` list uses the same format as a ``.gitignore`` file. | |
| """ | |
| @classmethod | |
| def trigger(cls, entity, target, layer, next_config): | |
| """ | |
| Match if the given entity will be ignored by the next layer. | |
| """ | |
| relpath = entity.relpath(layer.directory) | |
| ignored = utils.ignore_matcher(next_config.ignores) | |
| return not ignored(relpath) | |
| def __call__(cls): | |
| """ | |
| If this tactic has not been replaced by another from a higher layer, | |
| then we want to drop the file entirely, so do nothing. | |
| """ | |
| pass | |
| class ExcludeTactic(Tactic): | |
| """ | |
| Tactic to handle per-layer excludes. | |
| If a given layer's ``layer.yaml`` has an ``exclude`` list, then any file | |
| or directory included in that list that is provided by the current layer | |
| will be ignored, though any matching file or directory provided by base | |
| layers or any higher level layers will be included. | |
| The ``exclude`` list uses the same format as a ``.gitignore`` file. | |
| """ | |
| @classmethod | |
| def trigger(cls, entity, target, layer, next_config): | |
| """ | |
| Match if the given entity is excluded by the current layer. | |
| """ | |
| relpath = entity.relpath(layer.directory) | |
| excluded = utils.ignore_matcher(layer.config.excludes) | |
| return not excluded(relpath) | |
| def combine(self, existing): | |
| """ | |
| Combine with the tactic for this file from the lower layer by | |
| returning the existing tactic, excluding any file or data from | |
| this layer. | |
| """ | |
| return existing | |
| def __call__(self): | |
| """ | |
| If no lower or higher level layer has provided a tactic for this file, | |
| then we want to just skip processing of this file, so do nothing. | |
| """ | |
| pass | |
| class CopyTactic(Tactic): | |
| def __call__(self): | |
| if self.entity.isdir(): | |
| log.debug('Creating %s', self.target_file) | |
| return self.target_file.makedirs_p() | |
| target = self.target_file | |
| log.debug("Copying %s: %s", self.layer_name, target) | |
| # Ensure the path exists | |
| target.dirname().makedirs_p() | |
| if (self.entity != target) and not target.exists() \ | |
| or not self.entity.samefile(target): | |
| data = self.read() | |
| if data: | |
| target.write_bytes(data) | |
| self.entity.copymode(target) | |
| else: | |
| self.entity.copy2(target) | |
| def __str__(self): | |
| return "Copy {}".format(self.entity) | |
| @classmethod | |
| def trigger(cls, entity, target, layer, next_config): | |
| return True | |
| class InterfaceCopy(Tactic): | |
| def __init__(self, interface, relation_name, role, target, config): | |
| self.interface = interface | |
| self.relation_name = relation_name | |
| self.role = role | |
| self._target = target | |
| self._next_config = config | |
| @property | |
| def target(self): | |
| return self._target / "hooks/relations" / self.interface.name | |
| def __call__(self): | |
| # copy the entire tree into the | |
| # hooks/relations/<interface> | |
| # directory | |
| log.debug("Copying Interface %s: %s", | |
| self.interface.name, self.target) | |
| ignorer = utils.ignore_matcher(self.config.ignores + | |
| self.interface.config.ignores) | |
| for entity, _ in utils.walk(self.interface.directory, | |
| lambda x: True, | |
| matcher=ignorer, | |
| kind="files"): | |
| target = entity.relpath(self.interface.directory) | |
| target = (self.target / target).normpath() | |
| target.parent.makedirs_p() | |
| entity.copy2(target) | |
| init = self.target / "__init__.py" | |
| if not init.exists(): | |
| # ensure we can import from here directly | |
| init.touch() | |
| def __str__(self): | |
| return "Copy Interface {}".format(self.interface.name) | |
| def sign(self): | |
| """return sign in the form {relpath: (origin layer, SHA256)} | |
| """ | |
| sigs = {} | |
| for entry, sig in utils.walk(self.target, | |
| utils.sign, kind="files"): | |
| relpath = entry.relpath(self._target.directory) | |
| sigs[relpath] = (self.interface.url, "static", sig) | |
| return sigs | |
| def lint(self): | |
| impl = self.interface.directory / self.role + '.py' | |
| if not impl.exists(): | |
| log.error('Missing implementation for interface role: %s.py', | |
| self.role) | |
| return False | |
| valid = True | |
| for entry in self.interface.directory.walkfiles(): | |
| if entry.splitext()[1] != ".py": | |
| continue | |
| relpath = entry.relpath(self._target.directory) | |
| target = self._target.directory / relpath | |
| if not target.exists(): | |
| continue | |
| unchanged = utils.delta_python_dump(entry, target, | |
| from_name=relpath) | |
| if not unchanged: | |
| valid = False | |
| return valid | |
| class DynamicHookBind(Tactic): | |
| HOOKS = [] | |
| def __init__(self, name, owner, target, config, template_file): | |
| self.name = name | |
| self.owner = owner | |
| self._target = target | |
| self._template_file = template_file | |
| self.targets = [self._target / "hooks" / hook.format(name) | |
| for hook in self.HOOKS] | |
| def __call__(self): | |
| template = self._template_file.text() | |
| for target in self.targets: | |
| target.parent.makedirs_p() | |
| target.write_text(template.format(self.name)) | |
| target.chmod(0o755) | |
| def sign(self): | |
| """return sign in the form {relpath: (origin layer, SHA256)} | |
| """ | |
| sigs = {} | |
| for target in self.targets: | |
| rel = target.relpath(self._target.directory) | |
| sigs[rel] = (self.owner, | |
| "dynamic", | |
| utils.sign(target)) | |
| return sigs | |
| def __str__(self): | |
| return "{}: {}".format(self.__class__.__name__, self.name) | |
| class InterfaceBind(DynamicHookBind): | |
| HOOKS = [ | |
| '{}-relation-joined', | |
| '{}-relation-changed', | |
| '{}-relation-broken', | |
| '{}-relation-departed' | |
| ] | |
| class StorageBind(DynamicHookBind): | |
| HOOKS = [ | |
| '{}-storage-attached', | |
| '{}-storage-detaching', | |
| ] | |
| class ManifestTactic(ExactMatch, Tactic): | |
| FILENAME = ".composer.manifest" | |
| def __call__(self): | |
| # Don't copy manifests, they are regenerated | |
| pass | |
| class SerializedTactic(ExactMatch, Tactic): | |
| kind = "dynamic" | |
| section = None | |
| prefix = None | |
| def __init__(self, *args, **kwargs): | |
| super(SerializedTactic, self).__init__(*args, **kwargs) | |
| self.data = {} | |
| self._read = False | |
| def load(self, fn): | |
| raise NotImplementedError('Must be implemented in subclass: load') | |
| def dump(self, data): | |
| raise NotImplementedError('Must be implemented in subclass: dump') | |
| def read(self): | |
| if not self._read: | |
| self.data = self.load(self.entity.open()) or {} | |
| self._read = True | |
| def combine(self, existing): | |
| # make sure both versions are read in | |
| existing.read() | |
| self.read() | |
| # merge them | |
| if existing.data and self.data: | |
| self.data = utils.deepmerge(existing.data, self.data) | |
| elif existing.data: | |
| self.data = dict(existing.data) | |
| return self | |
| def apply_edits(self): | |
| # Apply any editing rules from config | |
| config = self.config | |
| if config: | |
| section = config.get(self.section) | |
| if section: | |
| dels = section.get('deletes', []) | |
| if self.prefix: | |
| namespace = self.data.get(self.prefix, {}) | |
| else: | |
| namespace = self.data | |
| for key in dels: | |
| # TODO: Chuck edit this thing | |
| utils.delete_path(key, namespace) | |
| if not self.target_file.parent.exists(): | |
| self.target_file.parent.makedirs_p() | |
| def process(self): | |
| self.read() | |
| self.apply_edits() | |
| return self.data | |
| def __call__(self): | |
| self.dump(self.process()) | |
| return self.data | |
| class YAMLTactic(SerializedTactic): | |
| """Rule Driven YAML generation""" | |
| prefix = None | |
| def load(self, fn): | |
| """Load the yaml file and return the contents as objects.""" | |
| try: | |
| return yaml.load(fn, Loader=yaml.RoundTripLoader) | |
| except yaml.YAMLError as e: | |
| log.debug(e) | |
| raise BuildError("Failed to process {0}. " | |
| "Ensure the YAML is valid".format(fn.name)) | |
| def dump(self, data): | |
| """Write the data to the target yaml file.""" | |
| with open(self.target_file, 'w') as fd: | |
| yaml.dump(data, fd, | |
| Dumper=yaml.RoundTripDumper, | |
| default_flow_style=False, | |
| default_style='"') | |
| class JSONTactic(SerializedTactic): | |
| """Rule Driven JSON generation""" | |
| prefix = None | |
| def load(self, fn): | |
| return json.load(fn) | |
| def dump(self, data): | |
| json.dump(data, self.target_file.open('w'), indent=2) | |
| class LayerYAML(YAMLTactic): | |
| """ | |
| Process the ``layer.yaml`` file from each layer, and generate the | |
| resulting ``layer.yaml`` for the built charm. | |
| The input ``layer.yaml`` files can contain the following sections: | |
| * ``includes`` This is the heart of layering. Layers and interface | |
| layers referenced in this list value are pulled in during charm | |
| build and combined with each other to produce the final layer. | |
| * ``config``, ``metadata``, ``dist``, or ``resources`` These objects can | |
| contain a ``deletes`` object to list keys that should be deleted from | |
| the resulting ``<section>.yaml``. | |
| * ``defines`` This object can contain a jsonschema used to defined and | |
| validate options passed to this layer from another layer. The options | |
| and schema will be namespaced by the current layer name. For example, | |
| layer "foo" defining ``bar: {type: string}`` will accept | |
| ``options: {foo: {bar: "foo"}}`` in the final ``layer.yaml``. | |
| * ``options`` This object can contain option name/value sections for | |
| other layers. For example, if the current layer includes the previously | |
| referenced "foo" layer, it could include ``foo: {bar: "foo"}`` in its | |
| ``options`` section. | |
| """ | |
| FILENAMES = ["layer.yaml", "composer.yaml"] | |
| def __init__(self, *args, **kwargs): | |
| super(LayerYAML, self).__init__(*args, **kwargs) | |
| self.schema = { | |
| 'type': 'object', | |
| 'properties': {}, | |
| 'additionalProperties': False, | |
| } | |
| @property | |
| def target_file(self): | |
| # force the non-deprecated name | |
| return self.target.directory / "layer.yaml" | |
| @classmethod | |
| def trigger(cls, entity, target, layer, next_config): | |
| relpath = entity.relpath(layer.directory) | |
| return relpath in cls.FILENAMES | |
| def read(self): | |
| if not self._read: | |
| super(LayerYAML, self).read() | |
| ignores = self.data.get('ignore') | |
| if isinstance(ignores, list): | |
| self.data['ignore'] = { | |
| self.layer_name: ignores, | |
| } | |
| self.data.setdefault('options', {}) | |
| self.schema['properties'] = { | |
| self.layer_name: { | |
| 'type': 'object', | |
| 'properties': self.data.pop('defines', {}), | |
| 'default': {}, | |
| }, | |
| } | |
| def combine(self, existing): | |
| self.read() | |
| existing.read() | |
| super(LayerYAML, self).combine(existing) | |
| self.schema = utils.deepmerge(existing.schema, self.schema) | |
| return self | |
| def lint(self): | |
| self.read() | |
| defined_layer_names = set(self.schema['properties'].keys()) | |
| options_layer_names = set(self.data['options'].keys()) | |
| unknown_layer_names = options_layer_names - defined_layer_names | |
| if unknown_layer_names: | |
| log.error('Options set for undefined layer{s}: {layers}'.format( | |
| s='s' if len(unknown_layer_names) > 1 else '', | |
| layers=', '.join(unknown_layer_names))) | |
| return False | |
| validator = extend_with_default( | |
| jsonschema.Draft4Validator)(self.schema) | |
| valid = True | |
| for error in validator.iter_errors(self.data['options']): | |
| log.error('Invalid value for option %s: %s', | |
| '.'.join(error.absolute_path), error.message) | |
| valid = False | |
| return valid | |
| def __call__(self): | |
| # rewrite includes to be the current source | |
| data = self.data | |
| if data is None: | |
| return | |
| # The split should result in the series/charm path only | |
| # XXX: there will be strange interactions with cs: vs local: | |
| if 'is' not in data: | |
| data['is'] = str(self.layer.url) | |
| inc = data.get('includes', []) or [] | |
| norm = [] | |
| for i in inc: | |
| if ":" in i: | |
| norm.append(i) | |
| else: | |
| # Attempt to normalize to a repository base | |
| norm.append("/".join(path(i).splitall()[-2:])) | |
| if norm: | |
| data['includes'] = norm | |
| if not self.target_file.parent.exists(): | |
| self.target_file.parent.makedirs_p() | |
| self.dump(data) | |
| return data | |
| def sign(self): | |
| """return sign in the form {relpath: (origin layer, SHA256)} | |
| """ | |
| target = self.target_file | |
| sig = {} | |
| if target.exists() and target.isfile(): | |
| sig["layer.yaml"] = (self.layer.url, | |
| self.kind, | |
| utils.sign(self.target_file)) | |
| return sig | |
| class MetadataYAML(YAMLTactic): | |
| """Rule Driven metadata.yaml generation""" | |
| section = "metadata" | |
| FILENAME = "metadata.yaml" | |
| KEY_ORDER = [ | |
| "name", | |
| "summary", | |
| "maintainer", | |
| "maintainers", | |
| "description", | |
| "tags", | |
| "series", | |
| "requires", | |
| "provides", | |
| "peers", | |
| ] | |
| def __init__(self, *args, **kwargs): | |
| super(MetadataYAML, self).__init__(*args, **kwargs) | |
| self.storage = {} | |
| self.maintainer = None | |
| self.maintainers = [] | |
| def read(self): | |
| if not self._read: | |
| super(MetadataYAML, self).read() | |
| self.storage = {name: self.layer.url | |
| for name in self.data.get('storage', {}).keys()} | |
| self.maintainer = self.data.get('maintainer') | |
| self.maintainers = self.data.get('maintainers') | |
| def combine(self, existing): | |
| self.read() | |
| series = self.data.get('series', []) | |
| super(MetadataYAML, self).combine(existing) | |
| if series: | |
| self.data['series'] = series + existing.data.get('series', []) | |
| self.storage.update(existing.storage) | |
| return self | |
| def apply_edits(self): | |
| super(MetadataYAML, self).apply_edits() | |
| # Remove the merged maintainers from the self.data | |
| self.data.pop('maintainer', None) | |
| self.data.pop('maintainers', []) | |
| # Set the maintainer and maintainers only from this layer. | |
| if self.maintainer: | |
| self.data['maintainer'] = self.maintainer | |
| if self.maintainers: | |
| self.data['maintainers'] = self.maintainers | |
| if 'series' in self.data: | |
| self.data['series'] = list(utils.OrderedSet(self.data['series'])) | |
| if not self.config or not self.config.get(self.section): | |
| return | |
| for key in self.config[self.section].get('deletes', []): | |
| if not key.startswith('storage.'): | |
| continue | |
| _, name = key.split('.', 1) | |
| if '.' in name: | |
| continue | |
| self.storage.pop(name, None) | |
| def dump(self, data): | |
| final = yaml.comments.CommentedMap() | |
| # attempt keys in the desired order | |
| for k in self.KEY_ORDER: | |
| if k in data: | |
| final[k] = data[k] | |
| # Get the remaining keys that are unordered. | |
| remaining = set(data.keys()) - set(self.KEY_ORDER) | |
| for k in sorted(remaining): | |
| final[k] = data[k] | |
| super(MetadataYAML, self).dump(final) | |
| class ConfigYAML(YAMLTactic): | |
| """Rule driven config.yaml generation""" | |
| section = "config" | |
| prefix = "options" | |
| FILENAME = "config.yaml" | |
| class ActionsYAML(YAMLTactic): | |
| """Rule driven actions.yaml generation""" | |
| section = "actions" | |
| FILENAME = "actions.yaml" | |
| class DistYAML(YAMLTactic): | |
| """Rule driven dist.yaml generation""" | |
| section = "dist" | |
| prefix = None | |
| FILENAME = "dist.yaml" | |
| class ResourcesYAML(YAMLTactic): | |
| """Rule driven resources.yaml generation""" | |
| section = "resources" | |
| prefix = None | |
| FILENAME = "resources.yaml" | |
| class InstallerTactic(Tactic): | |
| def __str__(self): | |
| return "Installing software to {}".format(self.relpath) | |
| @classmethod | |
| def trigger(cls, entity, target, layer, next_config): | |
| relpath = entity.relpath(layer.directory) | |
| ext = relpath.splitext()[1] | |
| return ext in [".pypi", ] | |
| def __call__(self): | |
| # install package reference in trigger file | |
| # in place directory of target | |
| # XXX: Should this map multiline to "-r", self.entity | |
| spec = self.entity.text().strip() | |
| target = self.target_file.dirname() | |
| log.debug("pip installing {} as {}".format( | |
| spec, target)) | |
| with utils.tempdir(chdir=False) as temp_dir: | |
| # We do this dance so we don't have | |
| # to guess package and .egg file names | |
| # we move everything in the tempdir to the target | |
| # and track it for later use in sign() | |
| localenv = os.environ.copy() | |
| localenv['PYTHONUSERBASE'] = temp_dir | |
| utils.Process(("pip3", | |
| "install", | |
| "--user", | |
| "--ignore-installed", | |
| spec), env=localenv).exit_on_error()() | |
| self._tracked = [] | |
| # We now manage two classes of explicit mappings | |
| # When python packages are installed into a prefix | |
| # we know that bin/* should map to <charmdir>/bin/ | |
| # and lib/python*/site-packages/* should map to | |
| # <target>/* | |
| src_paths = ["bin/*", "lib/python*/site-packages/*"] | |
| for p in src_paths: | |
| for d in temp_dir.glob(p): | |
| if not d.exists(): | |
| continue | |
| bp = d.relpath(temp_dir) | |
| if bp.startswith("bin/"): | |
| dst = self.target / bp | |
| elif bp.startswith("lib"): | |
| dst = target / d.name | |
| else: | |
| dst = target / bp | |
| if dst.exists(): | |
| if dst.isdir(): | |
| dst.rmtree_p() | |
| elif dst.isfile(): | |
| dst.remove() | |
| if not dst.parent.exists(): | |
| dst.parent.makedirs_p() | |
| log.debug("Installer moving {} to {}".format(d, dst)) | |
| d.move(dst) | |
| self._tracked.append(dst) | |
| def sign(self): | |
| """return sign in the form {relpath: (origin layer, SHA256)} | |
| """ | |
| sigs = {} | |
| for d in self._tracked: | |
| if d.isdir(): | |
| for entry, sig in utils.walk(d, | |
| utils.sign, kind="files"): | |
| relpath = entry.relpath(self.target.directory) | |
| sigs[relpath] = (self.layer.url, "dynamic", sig) | |
| elif d.isfile(): | |
| relpath = d.relpath(self.target.directory) | |
| sigs[relpath] = ( | |
| self.layer.url, "dynamic", utils.sign(d)) | |
| return sigs | |
| class WheelhouseTactic(ExactMatch, Tactic): | |
| kind = "dynamic" | |
| FILENAME = 'wheelhouse.txt' | |
| def __init__(self, *args, **kwargs): | |
| super(WheelhouseTactic, self).__init__(*args, **kwargs) | |
| self.tracked = [] | |
| self.previous = [] | |
| self._venv = None | |
| def __str__(self): | |
| directory = self.target.directory / 'wheelhouse' | |
| return "Building wheelhouse in {}".format(directory) | |
| def combine(self, existing): | |
| self.previous = existing.previous + [existing] | |
| return self | |
| def _add(self, wheelhouse, *reqs): | |
| with utils.tempdir(chdir=False) as temp_dir: | |
| # put in a temp dir first to ensure we track all of the files | |
| self._pip('download', '--no-binary', ':all:', '-d', temp_dir, | |
| *reqs) | |
| for wheel in temp_dir.files(): | |
| dest = wheelhouse / wheel.basename() | |
| dest.remove_p() | |
| wheel.move(wheelhouse) | |
| self.tracked.append(dest) | |
| def _run_in_venv(self, *args): | |
| assert self._venv is not None | |
| # have to use bash to activate the venv properly first | |
| return utils.Process(('bash', '-c', ' '.join( | |
| ('.', self._venv / 'bin' / 'activate', ';') + args | |
| ))).exit_on_error()() | |
| def _pip(self, *args): | |
| return self._run_in_venv('pip3', *args) | |
| def __call__(self): | |
| create_venv = self._venv is None | |
| self._venv = self._venv or path(tempfile.mkdtemp()) | |
| wheelhouse = self.target.directory / 'wheelhouse' | |
| wheelhouse.mkdir_p() | |
| if create_venv: | |
| # create venv without pip and use easy_install to install newer | |
| # version; use patched version if running in snap to include: | |
| # https://github.com/pypa/pip/blob/master/news/4320.bugfix | |
| utils.Process( | |
| ('virtualenv', '--python', 'python3', '--no-pip', self._venv) | |
| ).exit_on_error()() | |
| self._run_in_venv('easy_install', | |
| 'pip' if 'SNAP' not in os.environ else | |
| os.path.join(os.environ['SNAP'], | |
| 'pip-10.0.0.dev0.zip')) | |
| # we are the top layer; process all lower layers first | |
| for tactic in self.previous: | |
| tactic() | |
| # process this layer | |
| self._add(wheelhouse, '-r', self.entity) | |
| # clean up | |
| if create_venv: | |
| self._venv.rmtree_p() | |
| self._venv = None | |
| def sign(self): | |
| """return sign in the form {relpath: (origin layer, SHA256)} | |
| """ | |
| sigs = {} | |
| for tactic in self.previous: | |
| sigs.update(tactic.sign()) | |
| for d in self.tracked: | |
| relpath = d.relpath(self.target.directory) | |
| sigs[relpath] = ( | |
| self.layer.url, "dynamic", utils.sign(d)) | |
| return sigs | |
| class CopyrightTactic(Tactic): | |
| def __init__(self, *args, **kwargs): | |
| super(CopyrightTactic, self).__init__(*args, **kwargs) | |
| self.previous = [] | |
| self.toplevel = True | |
| self.relpath_target = self.relpath | |
| def combine(self, existing): | |
| self.previous = existing.previous + [existing] | |
| existing.previous = [] | |
| existing.toplevel = False | |
| existing.relpath_target += ".{}-{}".format( | |
| existing.layer.NAMESPACE, | |
| existing.layer.name) | |
| return self | |
| @property | |
| def target_file(self): | |
| target = self.target.directory / self.relpath_target | |
| return target | |
| @classmethod | |
| def trigger(cls, entity, target, layer, next_config): | |
| relpath = entity.relpath(layer.directory) | |
| return relpath == "copyright" | |
| def __call__(self): | |
| # Process the `copyright` file for all levels below us. | |
| for tactic in self.previous: | |
| tactic() | |
| self.target_file.dirname().makedirs_p() | |
| # Only copy file if it changed | |
| if not self.target_file.exists()\ | |
| or not self.entity.samefile(self.target_file): | |
| data = self.read() | |
| if data: | |
| self.target_file.write_bytes(data) | |
| self.entity.copymode(self.target_file) | |
| else: | |
| self.entity.copy2(self.target_file) | |
| def sign(self): | |
| """return sign in the form {relpath: (origin layer, SHA256)} | |
| """ | |
| sigs = {} | |
| # sign the `copyright` file for all levels below us. | |
| for tactic in self.previous: | |
| sigs.update(tactic.sign()) | |
| relpath = self.target_file.relpath(self.target.directory) | |
| sigs[relpath] = (self.layer.url, | |
| self.kind, | |
| utils.sign(self.target_file)) | |
| return sigs | |
| def load_tactic(dpath, basedir): | |
| """Load a tactic from the current layer using a dotted path. The last | |
| element in the path should be a Tactic subclass | |
| """ | |
| obj = utils.load_class(dpath, basedir) | |
| if not issubclass(obj, Tactic): | |
| raise ValueError("Expected to load a tactic for %s" % dpath) | |
| return obj | |
| def extend_with_default(validator_class): | |
| """ | |
| Extend a jsonschema validator to propagate default values prior | |
| to validating. | |
| """ | |
| validate_properties = validator_class.VALIDATORS["properties"] | |
| def set_defaults(validator, properties, instance, schema): | |
| for prop, subschema in properties.iteritems(): | |
| if "default" in subschema: | |
| instance.setdefault(prop, subschema["default"]) | |
| for error in validate_properties( | |
| validator, properties, instance, schema): | |
| yield error | |
| return jsonschema.validators.extend( | |
| validator_class, {"properties": set_defaults}, | |
| ) | |
| DEFAULT_TACTICS = [ | |
| IgnoreTactic, | |
| ExcludeTactic, | |
| ManifestTactic, | |
| WheelhouseTactic, | |
| InstallerTactic, | |
| CopyrightTactic, | |
| DistYAML, | |
| ResourcesYAML, | |
| MetadataYAML, | |
| ConfigYAML, | |
| ActionsYAML, | |
| LayerYAML, | |
| CopyTactic | |
| ] |