ariatosca-dev mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From dankil...@apache.org
Subject incubator-ariatosca git commit: ARIA-23 TBD [Forced Update!]
Date Sun, 20 Nov 2016 07:15:37 GMT
Repository: incubator-ariatosca
Updated Branches:
  refs/heads/ARIA-23-integrate-csar-packager b9abc95bc -> ad2ace027 (forced update)


ARIA-23 TBD


Project: http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/repo
Commit: http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/commit/ad2ace02
Tree: http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/tree/ad2ace02
Diff: http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/diff/ad2ace02

Branch: refs/heads/ARIA-23-integrate-csar-packager
Commit: ad2ace027316e0314daf376d7c76503563b1f4ef
Parents: 3323819
Author: Dan Kilman <dank@gigaspaces.com>
Authored: Thu Nov 17 12:43:49 2016 +0200
Committer: Dan Kilman <dank@gigaspaces.com>
Committed: Sun Nov 20 09:15:31 2016 +0200

----------------------------------------------------------------------
 aria/cli/args_parser.py    |  48 ++++++++++
 aria/cli/cli.py            |   6 ++
 aria/cli/commands.py       |  73 ++++++++++++++-
 aria/cli/csar/__init__.py  |  14 +++
 aria/cli/csar/constants.py |  29 ++++++
 aria/cli/csar/reader.py    | 199 ++++++++++++++++++++++++++++++++++++++++
 aria/cli/csar/signature.py |  42 +++++++++
 aria/cli/csar/writer.py    |  40 ++++++++
 8 files changed, 450 insertions(+), 1 deletion(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/ad2ace02/aria/cli/args_parser.py
----------------------------------------------------------------------
diff --git a/aria/cli/args_parser.py b/aria/cli/args_parser.py
index 56fd074..8382fd1 100644
--- a/aria/cli/args_parser.py
+++ b/aria/cli/args_parser.py
@@ -69,6 +69,9 @@ def config_parser(parser=None):
     add_execute_parser(sub_parser)
     add_parse_parser(sub_parser)
     add_spec_parser(sub_parser)
+    add_csar_create_parser(sub_parser)
+    add_csar_open_parser(sub_parser)
+    add_csar_validate_parser(sub_parser)
     return parser
 
 
@@ -199,3 +202,48 @@ def add_spec_parser(spec):
         '--csv',
         action='store_true',
         help='output as CSV')
+
+
+@sub_parser_decorator(
+    name='csar-create',
+    help='Create a CSAR file from a TOSCA service template directory',
+    formatter_class=SmartFormatter)
+def add_csar_create_parser(parse):
+    parse.add_argument(
+        'source',
+        help='Service template directory')
+    parse.add_argument(
+        'entry',
+        help='Entry definition file relative to service template directory')
+    parse.add_argument(
+        '-d', '--destination',
+        help='Output CSAR zip destination',
+        required=True)
+    parse.add_argument(
+        '-a', '--author',
+        help='Service template author (injected into CSAR metadata file)',
+        default='TOSCA')
+
+
+@sub_parser_decorator(
+    name='csar-open',
+    help='Extracts a CSAR file to a TOSCA service template directory',
+    formatter_class=SmartFormatter)
+def add_csar_open_parser(parse):
+    parse.add_argument(
+        'source',
+        help='CSAR file location')
+    parse.add_argument(
+        '-d', '--destination',
+        help='Output directory to extract the CSAR into',
+        required=True)
+
+
+@sub_parser_decorator(
+    name='csar-validate',
+    help='Validates a CSAR file',
+    formatter_class=SmartFormatter)
+def add_csar_validate_parser(parse):
+    parse.add_argument(
+        'source',
+        help='CSAR file location')

http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/ad2ace02/aria/cli/cli.py
----------------------------------------------------------------------
diff --git a/aria/cli/cli.py b/aria/cli/cli.py
index ad9784c..c5830d5 100644
--- a/aria/cli/cli.py
+++ b/aria/cli/cli.py
@@ -33,6 +33,9 @@ from .commands import (
     ExecuteCommand,
     ParseCommand,
     SpecCommand,
+    CSARCreateCommand,
+    CSAROpenCommand,
+    CSARValidateCommand,
 )
 
 __version__ = '0.1.0'
@@ -50,6 +53,9 @@ class AriaCli(LoggerMixin):
             'execute': ExecuteCommand.with_logger(base_logger=self.logger),
             'parse': ParseCommand.with_logger(base_logger=self.logger),
             'spec': SpecCommand.with_logger(base_logger=self.logger),
+            'csar-create': CSARCreateCommand.with_logger(base_logger=self.logger),
+            'csar-open': CSAROpenCommand.with_logger(base_logger=self.logger),
+            'csar-validate': CSARValidateCommand.with_logger(base_logger=self.logger),
         }
 
     def __enter__(self):

http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/ad2ace02/aria/cli/commands.py
----------------------------------------------------------------------
diff --git a/aria/cli/commands.py b/aria/cli/commands.py
index 57118a7..f977f25 100644
--- a/aria/cli/commands.py
+++ b/aria/cli/commands.py
@@ -21,6 +21,8 @@ import json
 import os
 import sys
 import csv
+import shutil
+import tempfile
 from glob import glob
 from importlib import import_module
 
@@ -43,7 +45,7 @@ from ..parser.consumption import (
     Inputs,
     Instance
 )
-from ..parser.loading import (UriLocation, URI_LOADER_PREFIXES)
+from ..parser.loading import (LiteralLocation, UriLocation, URI_LOADER_PREFIXES)
 from ..utils.application import StorageManager
 from ..utils.caching import cachedmethod
 from ..utils.console import (puts, Colored, indent)
@@ -61,6 +63,8 @@ from .storage import (
     user_space,
     local_storage,
 )
+from .csar import writer
+from .csar.reader import CSARReader
 
 
 class BaseCommand(LoggerMixin):
@@ -388,3 +392,70 @@ class SpecCommand(BaseCommand):
                         with indent(2):
                             for k, v in details.iteritems():
                                 puts('%s: %s' % (Colored.magenta(k), v))
+
+
+class BaseCSARCommand(BaseCommand):
+
+    @staticmethod
+    def _parse_and_dump(reader):
+        context = ConsumptionContext()
+        context.loading.prefixes += [os.path.join(reader.destination, 'definitions')]
+        context.presentation.location = LiteralLocation(reader.entry_definitions_yaml)
+        chain = ConsumerChain(context, (Read, Validate, Model, Instance))
+        chain.consume()
+        if context.validation.dump_issues():
+            raise RuntimeError('Validation failed')
+        dumper = chain.consumers[-1]
+        dumper.dump()
+
+    def _read(self, source, destination):
+        reader = CSARReader(
+            source=source,
+            destination=destination,
+            logger=self.logger)
+        self.logger.info(
+            'Path: {r.destination}\n'
+            'Author: {r.author}\n'
+            'Version: {r.version}\n'
+            'Metadata file version: {r.metadata_file_version}\n'
+            'Entry definitions: {r.entry_definitions}'
+            .format(r=reader))
+        self._parse_and_dump(reader)
+
+    def _validate(self, source):
+        workdir = tempfile.mkdtemp()
+        try:
+            self._read(
+                source=source,
+                destination=workdir)
+        finally:
+            shutil.rmtree(workdir, ignore_errors=True)
+
+
+class CSARCreateCommand(BaseCSARCommand):
+
+    def __call__(self, args_namespace, unknown_args):
+        super(CSARCreateCommand, self).__call__(args_namespace, unknown_args)
+        writer.write(
+            source=args_namespace.source,
+            entry=args_namespace.entry,
+            destination=args_namespace.destination,
+            author=args_namespace.author,
+            logger=self.logger)
+        self._validate(args_namespace.destination)
+
+
+class CSAROpenCommand(BaseCSARCommand):
+
+    def __call__(self, args_namespace, unknown_args):
+        super(CSAROpenCommand, self).__call__(args_namespace, unknown_args)
+        self._read(
+            source=args_namespace.source,
+            destination=args_namespace.destination)
+
+
+class CSARValidateCommand(BaseCSARCommand):
+
+    def __call__(self, args_namespace, unknown_args):
+        super(CSARValidateCommand, self).__call__(args_namespace, unknown_args)
+        self._validate(args_namespace.source)

http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/ad2ace02/aria/cli/csar/__init__.py
----------------------------------------------------------------------
diff --git a/aria/cli/csar/__init__.py b/aria/cli/csar/__init__.py
new file mode 100644
index 0000000..ae1e83e
--- /dev/null
+++ b/aria/cli/csar/__init__.py
@@ -0,0 +1,14 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.

http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/ad2ace02/aria/cli/csar/constants.py
----------------------------------------------------------------------
diff --git a/aria/cli/csar/constants.py b/aria/cli/csar/constants.py
new file mode 100644
index 0000000..742b780
--- /dev/null
+++ b/aria/cli/csar/constants.py
@@ -0,0 +1,29 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+META_FILE = 'TOSCA-Metadata/TOSCA.meta'
+
+META_FILE_VERSION_KEY = 'TOSCA-Meta-File-Version'
+META_FILE_VERSION_VALUE = '1.0'
+META_CSAR_VERSION_KEY = 'CSAR-Version'
+META_CSAR_VERSION_VALUE = '1.1'
+META_CREATED_BY_KEY = 'Created-By'
+META_ENTRY_DEFINITIONS_KEY = 'Entry-Definitions'
+
+META_TEMPLATE_NAME_KEY = 'template_name'
+META_TEMPLATE_AUTHOR_KEY = 'template_author'
+META_TEMPLATE_VERSION_KEY = 'template_version'
+
+META_MIMETYPES_GLOB = 'mimetypes/*.types'

http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/ad2ace02/aria/cli/csar/reader.py
----------------------------------------------------------------------
diff --git a/aria/cli/csar/reader.py b/aria/cli/csar/reader.py
new file mode 100644
index 0000000..aa5e3aa
--- /dev/null
+++ b/aria/cli/csar/reader.py
@@ -0,0 +1,199 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import base64
+import os
+import mimetypes
+import hashlib
+import glob
+import pprint
+import zipfile
+import functools
+
+from ruamel import yaml
+
+from . import constants
+
+
+class CSARReader(object):
+
+    def __init__(self, source, destination, logger):
+        self.log = logger
+        self.source = os.path.normpath(source)
+        self.destination = destination
+        self.metadata = {}
+        self._extract()
+        self._validate()
+
+    @property
+    def has_metadata_file(self):
+        return os.path.isfile(os.path.join(self.destination, constants.META_FILE))
+
+    @property
+    def artifacts(self):
+        return self.metadata.get('artifacts', {})
+
+    @property
+    def author(self):
+        return (self.metadata.get(constants.META_CREATED_BY_KEY) or
+                self.metadata.get(constants.META_TEMPLATE_AUTHOR_KEY))
+
+    @property
+    def version(self):
+        return (self.metadata.get(constants.META_CSAR_VERSION_KEY) or
+                self.metadata.get(constants.META_TEMPLATE_VERSION_KEY))
+
+    @property
+    def metadata_file_version(self):
+        return self.metadata.get(constants.META_FILE_VERSION_KEY)
+
+    @property
+    def template_name(self):
+        return self.metadata.get(constants.META_TEMPLATE_NAME_KEY)
+
+    @property
+    def entry_definitions(self):
+        return self.metadata.get(constants.META_ENTRY_DEFINITIONS_KEY)
+
+    @property
+    def entry_definitions_yaml(self):
+        with open(os.path.join(self.destination, self.entry_definitions)) as f:
+            return yaml.load(f)
+
+    def _extract(self):
+        if not self.source:
+            raise RuntimeError('Missing CSAR file')
+        if not zipfile.is_zipfile(self.source):
+            raise RuntimeError('CSAR file is not in ZIP format')
+        self.log.debug('Extracting CSAR contents')
+        if not os.path.isdir(self.destination):
+            os.mkdir(self.destination)
+        with zipfile.ZipFile(self.source) as f:
+            f.extractall(self.destination)
+        self.log.debug('CSAR contents successfully extracted')
+
+    def _validate(self):
+        if self.has_metadata_file:
+            self.metadata.update(self._validate_metadata_file())
+        else:
+            self.metadata.update(self._validate_metadata_inline())
+        self._validate_entry_definitions()
+        self._validate_artifacts()
+
+    @staticmethod
+    def _validate_metadata_key(metadata, key, expected=None):
+        if not metadata.get(key):
+            raise RuntimeError('Missing metadata "{0}"'.format(key))
+        if expected and str(metadata[key]) != expected:
+            raise RuntimeError('Metadata "{0}" must be {1}'.format(key, expected))
+
+    def _validate_metadata_file(self):
+        csar_metafile = os.path.join(self.destination, constants.META_FILE)
+        self.log.debug('CSAR metadata file: {0}'.format(csar_metafile))
+        self.log.debug('Attempting to parse CSAR metadata YAML')
+        with open(csar_metafile) as f:
+            metadata = yaml.load(f)
+        self.log.debug('CSAR metadata:\n{0}'.format(pprint.pformat(metadata)))
+        validate_key = functools.partial(self._validate_metadata_key, metadata)
+        validate_key(constants.META_FILE_VERSION_KEY, expected=constants.META_FILE_VERSION_VALUE)
+        validate_key(constants.META_CSAR_VERSION_KEY, expected=constants.META_CSAR_VERSION_VALUE)
+        validate_key(constants.META_CREATED_BY_KEY)
+        validate_key(constants.META_ENTRY_DEFINITIONS_KEY)
+        return metadata
+
+    def _validate_metadata_inline(self):
+        self.log.debug('Searching for TOSCA template file with metadata')
+        root_definitions = []
+        for ext in ['yaml', 'yml']:
+            root_definitions.extend(glob.glob('{0}/*.{1}'.format(self.destination, ext)))
+        if len(root_definitions) != 1:
+            raise RuntimeError('Exactly 1 YAML file must exist in the CSAR root directory')
+        root_definition = root_definitions[0]
+        self.log.debug('Attempting to parse CSAR metadata YAML')
+        with open(root_definition) as def_file:
+            definition_data = yaml.load(def_file)
+        metadata = definition_data.get('metadata')
+        if not metadata:
+            raise RuntimeError('Missing metadata section')
+        validate_key = functools.partial(self._validate_metadata_key, metadata)
+        validate_key(constants.META_TEMPLATE_VERSION_KEY,
+                     expected=constants.META_CSAR_VERSION_VALUE)
+        validate_key(constants.META_TEMPLATE_AUTHOR_KEY)
+        validate_key(constants.META_TEMPLATE_NAME_KEY)
+        metadata[constants.META_ENTRY_DEFINITIONS_KEY] = root_definition
+        return metadata
+
+    def _validate_entry_definitions(self):
+        self.log.debug('CSAR entry definitions: {0}'.format(self.entry_definitions))
+        if not self.has_metadata_file:
+            self.log.debug('Using inline metadata; skipping...')
+            return
+        if not os.path.isfile(os.path.join(self.destination, self.entry_definitions)):
+            raise RuntimeError('"{0}" points to "{1}", but the file does not exist'.format(
+                constants.META_ENTRY_DEFINITIONS_KEY, self.entry_definitions))
+
+    def _validate_artifacts(self):
+        self.log.debug('Searching for user-defined MIME types')
+        types = glob.glob(os.path.join(self.destination, constants.META_MIMETYPES_GLOB))
+        self.log.debug('Loading {0} user-defined MIME types'.format(len(types)))
+        mimetypes.init(types or None)
+        self.log.debug('Checking for artifacts')
+        if not self.artifacts:
+            self.log.debug('No artifacts declared')
+            return
+        for name, artifact in self.artifacts.iteritems():
+            self._validate_artifact(name, artifact)
+
+    def _validate_artifact(self, name, artifact):
+        self.log.debug('Validating artifact: {0}'.format(name))
+        self.log.debug('Checking if artifact file exists')
+        artifact_path = os.path.join(self.destination, name)
+        if not os.path.isfile(artifact_path):
+            raise RuntimeError('Artifact "{0}" declared, but file does not exist'.format(name))
+        if 'content-type' not in artifact:
+            raise RuntimeError('Artifact missing "content-type"')
+        self.log.debug('Artifact content-type: {0}'.format(artifact['content-type']))
+        split_content_type = artifact['content-type'].split('/')
+        if len(split_content_type) < 2:
+            raise RuntimeError('Artifact content-type must comply with the "type/subtype"
'
+                               'structure')
+        if not split_content_type[-1].startswith('vnd.'):
+            self.log.warn('Artifact content-type subtype should start with "vnd."')
+        self.log.debug('Checking content-type against known MIME types')
+        if artifact['content-type'] not in mimetypes.types_map.values():
+            self.log.warn('Could not match artifact content-type with any known MIME type')
+        self.log.debug('Checking artifact MIME type against content-type')
+        mime_type = mimetypes.guess_type(artifact_path)[0]
+        if mime_type is None:
+            self.log.warn('Could not match artifact to a known MIME type')
+        if mime_type != artifact['content-type']:
+            self.log.warn('Artifact content-type does not match the artifacts MIME type')
+        if 'signature' in artifact:
+            sig = artifact['signature']
+            algorithm = sig.get('algorithm')
+            digest = sig.get('digest')
+            if not algorithm:
+                raise RuntimeError('Artifact signature declared, but no algorithm was found')
+            if not digest:
+                raise RuntimeError('Artifact signature declared, but no digest was found')
+            self.log.debug('Decoding base64-encoded artifact digest')
+            digest = base64.b64decode(digest).strip()
+            self.log.debug('Decoded artifact digest: {0}'.format(digest))
+            self.log.debug('Calculating {0} digest of artifact {1}'.format(algorithm, name))
+            with open(artifact_path, 'rb') as f:
+                algorithm_digest = hashlib.new(algorithm, f.read()).hexdigest()
+            self.log.debug('Calculated artifact digest: {0}'.format(algorithm_digest))
+            if digest != algorithm_digest:
+                raise RuntimeError('Artifact digest mismatch')

http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/ad2ace02/aria/cli/csar/signature.py
----------------------------------------------------------------------
diff --git a/aria/cli/csar/signature.py b/aria/cli/csar/signature.py
new file mode 100644
index 0000000..1ba4340
--- /dev/null
+++ b/aria/cli/csar/signature.py
@@ -0,0 +1,42 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import hashlib
+import hmac
+import os
+
+
+def create_signature(key_data, csar_path, logger):
+    signature_builder = hmac.new(key_data, digestmod=hashlib.sha384)
+    with open(csar_path, 'rb') as f:
+        logger.debug('Using CSAR package at "{0}"'.format(csar_path))
+        logger.log.debug('Preparing to calculate CSAR signature')
+        while True:
+            block = f.read(4096)
+            if not block:
+                break
+            signature_builder.update(block)
+    signature = signature_builder.hexdigest()
+    logger.debug('Calculated CSAR signature as "{0}"'.format(signature))
+    return signature
+
+
+def verify_signature(key_data, signature, csar_path, logger):
+    if os.path.isfile(signature):
+        with open(signature) as f:
+            signature = f.read()
+    signature = signature.strip()
+    actual_signature = create_signature(key_data, csar_path, logger)
+    return hmac.compare_digest(signature, actual_signature)

http://git-wip-us.apache.org/repos/asf/incubator-ariatosca/blob/ad2ace02/aria/cli/csar/writer.py
----------------------------------------------------------------------
diff --git a/aria/cli/csar/writer.py b/aria/cli/csar/writer.py
new file mode 100644
index 0000000..2fbff33
--- /dev/null
+++ b/aria/cli/csar/writer.py
@@ -0,0 +1,40 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import os
+import zipfile
+
+from ruamel import yaml
+
+from . import constants
+
+
+def write(source, entry, destination, author, logger):
+    metadata = {
+        constants.META_FILE_VERSION_KEY: constants.META_FILE_VERSION_VALUE,
+        constants.META_CSAR_VERSION_KEY: constants.META_CSAR_VERSION_VALUE,
+        constants.META_CREATED_BY_KEY: author,
+        constants.META_ENTRY_DEFINITIONS_KEY: entry
+    }
+    logger.debug('Compressing root directory to ZIP')
+    with zipfile.ZipFile(destination, 'w', zipfile.ZIP_DEFLATED) as f:
+        for root, _, files in os.walk(source):
+            for file in files:
+                file_full_path = os.path.join(root, file)
+                file_relative_path = os.path.relpath(file_full_path, source)
+                logger.debug('Writing to archive: {0}'.format(file_relative_path))
+                f.write(file_full_path, file_relative_path)
+        logger.debug('Writing new metadata file to {0}'.format(constants.META_FILE))
+        f.writestr(constants.META_FILE, yaml.dump(metadata, default_flow_style=False))


Mime
View raw message