Implement upload call to the repository API
* Add multiform/part-data to the allowed content type * Get json and file from request Partially-implements blueprint murano-repository-api-v2 Implements blueprint publish-app-to-catalog Change-Id: I2ed8887a235739f6316695eb13879cc494f2f042
This commit is contained in:
parent
39c2726540
commit
70f4bdb7cc
@ -25,6 +25,14 @@ SEARCH_MAPPING = {'fqn': 'fully_qualified_name',
|
|||||||
'name': 'name',
|
'name': 'name',
|
||||||
'created': 'created'
|
'created': 'created'
|
||||||
}
|
}
|
||||||
|
PKG_PARAMS_MAP = {'display_name': 'name',
|
||||||
|
'full_name': 'fully_qualified_name',
|
||||||
|
'raw_ui': 'ui_definition',
|
||||||
|
'logo': 'logo',
|
||||||
|
'package_type': 'type',
|
||||||
|
'description': 'description',
|
||||||
|
'author': 'author',
|
||||||
|
'classes': 'class_definition'}
|
||||||
|
|
||||||
|
|
||||||
def get_draft(environment_id=None, session_id=None):
|
def get_draft(environment_id=None, session_id=None):
|
||||||
|
@ -13,16 +13,23 @@
|
|||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
|
import cgi
|
||||||
|
import jsonschema
|
||||||
|
import tempfile
|
||||||
|
|
||||||
from oslo.config import cfg
|
from oslo.config import cfg
|
||||||
|
from sqlalchemy import exc as sql_exc
|
||||||
from webob import exc
|
from webob import exc
|
||||||
|
|
||||||
import muranoapi.api.v1
|
import muranoapi.api.v1
|
||||||
|
from muranoapi.api.v1 import schemas
|
||||||
from muranoapi.db.catalog import api as db_api
|
from muranoapi.db.catalog import api as db_api
|
||||||
from muranoapi.openstack.common import exception
|
from muranoapi.openstack.common import exception
|
||||||
from muranoapi.openstack.common.gettextutils import _ # noqa
|
from muranoapi.openstack.common.gettextutils import _ # noqa
|
||||||
from muranoapi.openstack.common import log as logging
|
from muranoapi.openstack.common import log as logging
|
||||||
from muranoapi.openstack.common import wsgi
|
from muranoapi.openstack.common import wsgi
|
||||||
|
from muranoapi.packages import application_package as app_pkg
|
||||||
|
from muranoapi.packages import exceptions as pkg_exc
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
CONF = cfg.CONF
|
CONF = cfg.CONF
|
||||||
@ -30,6 +37,7 @@ CONF = cfg.CONF
|
|||||||
SUPPORTED_PARAMS = muranoapi.api.v1.SUPPORTED_PARAMS
|
SUPPORTED_PARAMS = muranoapi.api.v1.SUPPORTED_PARAMS
|
||||||
LIST_PARAMS = muranoapi.api.v1.LIST_PARAMS
|
LIST_PARAMS = muranoapi.api.v1.LIST_PARAMS
|
||||||
ORDER_VALUES = muranoapi.api.v1.ORDER_VALUES
|
ORDER_VALUES = muranoapi.api.v1.ORDER_VALUES
|
||||||
|
PKG_PARAMS_MAP = muranoapi.api.v1.PKG_PARAMS_MAP
|
||||||
|
|
||||||
|
|
||||||
def _check_content_type(req, content_type):
|
def _check_content_type(req, content_type):
|
||||||
@ -61,10 +69,34 @@ def _get_filters(query_params):
|
|||||||
LOG.warning(_("Value of 'order_by' parameter is not valid. "
|
LOG.warning(_("Value of 'order_by' parameter is not valid. "
|
||||||
"Allowed values are: {0}. Skipping it.").format(
|
"Allowed values are: {0}. Skipping it.").format(
|
||||||
", ".join(ORDER_VALUES)))
|
", ".join(ORDER_VALUES)))
|
||||||
|
|
||||||
return filters
|
return filters
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_body(body):
|
||||||
|
if len(body.keys()) != 2:
|
||||||
|
msg = "multipart/form-data request should contain " \
|
||||||
|
"two parts: json and tar.gz archive"
|
||||||
|
LOG.error(msg)
|
||||||
|
raise exc.HTTPBadRequest(msg)
|
||||||
|
file_obj = None
|
||||||
|
package_meta = None
|
||||||
|
for part in body.values():
|
||||||
|
if isinstance(part, cgi.FieldStorage):
|
||||||
|
file_obj = part
|
||||||
|
# dict if json deserialized successfully
|
||||||
|
if isinstance(part, dict):
|
||||||
|
package_meta = part
|
||||||
|
if file_obj is None:
|
||||||
|
msg = _("There is no file package with application description")
|
||||||
|
LOG.error(msg)
|
||||||
|
raise exc.HTTPBadRequest(msg)
|
||||||
|
if package_meta is None:
|
||||||
|
msg = _("There is no json with meta information about package")
|
||||||
|
LOG.error(msg)
|
||||||
|
raise exc.HTTPBadRequest(msg)
|
||||||
|
return file_obj, package_meta
|
||||||
|
|
||||||
|
|
||||||
class Controller(object):
|
class Controller(object):
|
||||||
"""
|
"""
|
||||||
WSGI controller for application catalog resource in Murano v1 API
|
WSGI controller for application catalog resource in Murano v1 API
|
||||||
@ -100,6 +132,46 @@ class Controller(object):
|
|||||||
packages = db_api.package_search(filters, req.context)
|
packages = db_api.package_search(filters, req.context)
|
||||||
return {"packages": [package.to_dict() for package in packages]}
|
return {"packages": [package.to_dict() for package in packages]}
|
||||||
|
|
||||||
|
def upload(self, req, body=None):
|
||||||
|
"""
|
||||||
|
Upload new file archive for the new package
|
||||||
|
together with package metadata
|
||||||
|
"""
|
||||||
|
_check_content_type(req, 'multipart/form-data')
|
||||||
|
file_obj, package_meta = _validate_body(body)
|
||||||
|
try:
|
||||||
|
jsonschema.validate(package_meta, schemas.PKG_UPLOAD_SCHEMA)
|
||||||
|
except jsonschema.ValidationError as e:
|
||||||
|
LOG.exception(e)
|
||||||
|
raise exc.HTTPBadRequest(explanation=e.message)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile() as tempf:
|
||||||
|
content = file_obj.file.read()
|
||||||
|
if not content:
|
||||||
|
msg = _("Uploading file can't be empty")
|
||||||
|
raise exc.HTTPBadRequest(msg)
|
||||||
|
tempf.write(content)
|
||||||
|
package_meta['archive'] = content
|
||||||
|
try:
|
||||||
|
pkg_to_upload = app_pkg.load_from_file(tempf.name,
|
||||||
|
target_dir=None,
|
||||||
|
drop_dir=True)
|
||||||
|
except pkg_exc.PackageLoadError as e:
|
||||||
|
LOG.exception(e)
|
||||||
|
raise exc.HTTPBadRequest(e.message)
|
||||||
|
|
||||||
|
# extend dictionary for update db
|
||||||
|
for k, v in PKG_PARAMS_MAP.iteritems():
|
||||||
|
if hasattr(pkg_to_upload, k):
|
||||||
|
package_meta[v] = getattr(pkg_to_upload, k)
|
||||||
|
try:
|
||||||
|
package = db_api.package_upload(package_meta, req.context.tenant)
|
||||||
|
except sql_exc.SQLAlchemyError:
|
||||||
|
msg = _('Unable to save package in database')
|
||||||
|
LOG.exception(msg)
|
||||||
|
raise exc.HTTPServerError(msg)
|
||||||
|
return package.to_dict()
|
||||||
|
|
||||||
|
|
||||||
def create_resource():
|
def create_resource():
|
||||||
return wsgi.Resource(Controller())
|
return wsgi.Resource(Controller())
|
||||||
|
@ -133,4 +133,8 @@ class API(wsgi.Router):
|
|||||||
controller=catalog_resource,
|
controller=catalog_resource,
|
||||||
action='search',
|
action='search',
|
||||||
conditions={'method': ['GET']})
|
conditions={'method': ['GET']})
|
||||||
|
mapper.connect('/catalog/packages',
|
||||||
|
controller=catalog_resource,
|
||||||
|
action='upload',
|
||||||
|
conditions={'method': ['POST']})
|
||||||
super(API, self).__init__(mapper)
|
super(API, self).__init__(mapper)
|
||||||
|
@ -24,6 +24,32 @@ ENV_SCHEMA = {
|
|||||||
"required": ["id", "name"]
|
"required": ["id", "name"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PKG_UPLOAD_SCHEMA = {
|
||||||
|
"$schema": "http://json-schema.org/draft-04/schema#",
|
||||||
|
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"tags": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"uniqueItems": True
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"uniqueItems": True
|
||||||
|
},
|
||||||
|
"description": {"type": "string"},
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"is_public": {"type": "boolean"},
|
||||||
|
"enabled": {"type": "boolean"}
|
||||||
|
},
|
||||||
|
"required": ["categories"],
|
||||||
|
"additionalProperties": False
|
||||||
|
}
|
||||||
|
|
||||||
PKG_UPDATE_SCHEMA = {
|
PKG_UPDATE_SCHEMA = {
|
||||||
"$schema": "http://json-schema.org/draft-04/schema#",
|
"$schema": "http://json-schema.org/draft-04/schema#",
|
||||||
|
|
||||||
|
@ -26,11 +26,6 @@ SEARCH_MAPPING = muranoapi.api.v1.SEARCH_MAPPING
|
|||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_category_by_name(name):
|
|
||||||
session = db_session.get_session()
|
|
||||||
return session.query(models.Category).filter_by(name=name).first()
|
|
||||||
|
|
||||||
|
|
||||||
def category_get_names():
|
def category_get_names():
|
||||||
session = db_session.get_session()
|
session = db_session.get_session()
|
||||||
categories = []
|
categories = []
|
||||||
@ -40,11 +35,6 @@ def category_get_names():
|
|||||||
return categories
|
return categories
|
||||||
|
|
||||||
|
|
||||||
def get_tag_by_name(name):
|
|
||||||
session = db_session.get_session()
|
|
||||||
return session.query(models.Tag).filter_by(name=name).first()
|
|
||||||
|
|
||||||
|
|
||||||
def _package_get(package_id, session):
|
def _package_get(package_id, session):
|
||||||
package = session.query(models.Package).get(package_id)
|
package = session.query(models.Package).get(package_id)
|
||||||
if not package:
|
if not package:
|
||||||
@ -83,15 +73,18 @@ def package_get(package_id, context):
|
|||||||
return package
|
return package
|
||||||
|
|
||||||
|
|
||||||
def _get_categories(category_names):
|
def _get_categories(category_names, session=None):
|
||||||
"""
|
"""
|
||||||
Return existing category objects or raise an exception
|
Return existing category objects or raise an exception
|
||||||
:param category_names: name of categories to associate with package, list
|
:param category_names: name of categories to associate with package, list
|
||||||
:returns: list of Category objects to associate with package, list
|
:returns: list of Category objects to associate with package, list
|
||||||
"""
|
"""
|
||||||
|
if session is None:
|
||||||
|
session = db_session.get_session()
|
||||||
categories = []
|
categories = []
|
||||||
for ctg_name in category_names:
|
for ctg_name in category_names:
|
||||||
ctg_obj = get_category_by_name(ctg_name)
|
ctg_obj = session.query(models.Category).filter_by(
|
||||||
|
name=ctg_name).first()
|
||||||
if not ctg_obj:
|
if not ctg_obj:
|
||||||
# it's not allowed to specify non-existent categories
|
# it's not allowed to specify non-existent categories
|
||||||
raise exc.HTTPBadRequest(
|
raise exc.HTTPBadRequest(
|
||||||
@ -100,24 +93,39 @@ def _get_categories(category_names):
|
|||||||
return categories
|
return categories
|
||||||
|
|
||||||
|
|
||||||
def _get_tags(tag_names):
|
def _get_tags(tag_names, session=None):
|
||||||
"""
|
"""
|
||||||
Return existing tags object or create new ones
|
Return existing tags object or create new ones
|
||||||
:param tag_names: name of tags to associate with package, list
|
:param tag_names: name of tags to associate with package, list
|
||||||
:returns: list of Tag objects to associate with package, list
|
:returns: list of Tag objects to associate with package, list
|
||||||
"""
|
"""
|
||||||
|
if session is None:
|
||||||
|
session = db_session.get_session()
|
||||||
tags = []
|
tags = []
|
||||||
if tag_names:
|
for tag_name in tag_names:
|
||||||
for tag_name in tag_names:
|
tag_obj = session.query(models.Tag).filter_by(name=tag_name).first()
|
||||||
tag_obj = get_tag_by_name(tag_name)
|
if tag_obj:
|
||||||
if tag_obj:
|
tags.append(tag_obj)
|
||||||
tags.append(tag_obj)
|
else:
|
||||||
else:
|
tag_record = models.Tag(name=tag_name)
|
||||||
tag_record = models.Tag(name=tag_name)
|
tags.append(tag_record)
|
||||||
tags.append(tag_record)
|
|
||||||
return tags
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
def _get_class_definitions(class_names, session):
|
||||||
|
if session is None:
|
||||||
|
session = db_session.get_session()
|
||||||
|
classes = []
|
||||||
|
for name in class_names:
|
||||||
|
class_obj = session.query(models.Class).filter_by(name=name).first()
|
||||||
|
if class_obj:
|
||||||
|
classes.append(class_obj)
|
||||||
|
else:
|
||||||
|
class_record = models.Class(name=name)
|
||||||
|
classes.append(class_record)
|
||||||
|
return classes
|
||||||
|
|
||||||
|
|
||||||
def _do_replace(package, change):
|
def _do_replace(package, change):
|
||||||
path = change['path'][0]
|
path = change['path'][0]
|
||||||
value = change['value']
|
value = change['value']
|
||||||
@ -199,13 +207,12 @@ def package_update(pkg_id, changes, context):
|
|||||||
'replace': _do_replace,
|
'replace': _do_replace,
|
||||||
'remove': _do_remove}
|
'remove': _do_remove}
|
||||||
session = db_session.get_session()
|
session = db_session.get_session()
|
||||||
pkg = _package_get(pkg_id, session)
|
|
||||||
_authorize_package(pkg, context)
|
|
||||||
|
|
||||||
for change in changes:
|
|
||||||
pkg = operation_methods[change['op']](pkg, change)
|
|
||||||
|
|
||||||
with session.begin():
|
with session.begin():
|
||||||
|
pkg = _package_get(pkg_id, session)
|
||||||
|
_authorize_package(pkg, context)
|
||||||
|
|
||||||
|
for change in changes:
|
||||||
|
pkg = operation_methods[change['op']](pkg, change)
|
||||||
session.add(pkg)
|
session.add(pkg)
|
||||||
return pkg
|
return pkg
|
||||||
|
|
||||||
@ -309,3 +316,28 @@ def package_search(filters, context):
|
|||||||
query = query.limit(limit)
|
query = query.limit(limit)
|
||||||
|
|
||||||
return query.all()
|
return query.all()
|
||||||
|
|
||||||
|
|
||||||
|
def package_upload(values, tenant_id):
|
||||||
|
"""
|
||||||
|
Upload a package with new application
|
||||||
|
:param values: parameters describing the new package
|
||||||
|
:returns: detailed information about new package, dict
|
||||||
|
"""
|
||||||
|
session = db_session.get_session()
|
||||||
|
package = models.Package()
|
||||||
|
|
||||||
|
composite_attr_to_func = {'categories': _get_categories,
|
||||||
|
'tags': _get_tags,
|
||||||
|
'class_definition': _get_class_definitions}
|
||||||
|
with session.begin():
|
||||||
|
for attr, func in composite_attr_to_func.iteritems():
|
||||||
|
if values.get(attr):
|
||||||
|
result = func(values[attr], session)
|
||||||
|
setattr(package, attr, result)
|
||||||
|
del values[attr]
|
||||||
|
|
||||||
|
package.update(values)
|
||||||
|
package.owner_id = tenant_id
|
||||||
|
package.save(session)
|
||||||
|
return package
|
||||||
|
@ -263,12 +263,12 @@ class Package(BASE, ModelBase):
|
|||||||
'archive',
|
'archive',
|
||||||
'logo',
|
'logo',
|
||||||
'ui_definition']
|
'ui_definition']
|
||||||
nested_objects = ['categories', 'tags']
|
nested_objects = ['categories', 'tags', 'class_definition']
|
||||||
for key in not_serializable:
|
for key in not_serializable:
|
||||||
if key in d.keys():
|
if key in d.keys():
|
||||||
del d[key]
|
del d[key]
|
||||||
for key in nested_objects:
|
for key in nested_objects:
|
||||||
d[key] = [a.name for a in d.get(key)]
|
d[key] = [a.name for a in d.get(key, [])]
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@ -296,7 +296,8 @@ class Request(webob.Request):
|
|||||||
|
|
||||||
default_request_content_types = ('application/json',
|
default_request_content_types = ('application/json',
|
||||||
'application/xml',
|
'application/xml',
|
||||||
'application/murano-packages-json-patch')
|
'application/murano-packages-json-patch',
|
||||||
|
'multipart/form-data')
|
||||||
default_accept_types = ('application/json', 'application/xml')
|
default_accept_types = ('application/json', 'application/xml')
|
||||||
default_accept_type = 'application/json'
|
default_accept_type = 'application/json'
|
||||||
|
|
||||||
@ -632,7 +633,8 @@ class RequestDeserializer(object):
|
|||||||
self.body_deserializers = {
|
self.body_deserializers = {
|
||||||
'application/xml': XMLDeserializer(),
|
'application/xml': XMLDeserializer(),
|
||||||
'application/json': JSONDeserializer(),
|
'application/json': JSONDeserializer(),
|
||||||
'application/murano-packages-json-patch': JSONPatchDeserializer()
|
'application/murano-packages-json-patch': JSONPatchDeserializer(),
|
||||||
|
'multipart/form-data': FormDataDeserializer()
|
||||||
}
|
}
|
||||||
self.body_deserializers.update(body_deserializers or {})
|
self.body_deserializers.update(body_deserializers or {})
|
||||||
|
|
||||||
@ -682,7 +684,7 @@ class RequestDeserializer(object):
|
|||||||
LOG.debug(_("Unable to deserialize body as provided Content-Type"))
|
LOG.debug(_("Unable to deserialize body as provided Content-Type"))
|
||||||
raise
|
raise
|
||||||
|
|
||||||
return deserializer.deserialize(request.body, action)
|
return deserializer.deserialize(request, action)
|
||||||
|
|
||||||
def get_body_deserializer(self, content_type):
|
def get_body_deserializer(self, content_type):
|
||||||
try:
|
try:
|
||||||
@ -716,10 +718,10 @@ class RequestDeserializer(object):
|
|||||||
class TextDeserializer(ActionDispatcher):
|
class TextDeserializer(ActionDispatcher):
|
||||||
"""Default request body deserialization"""
|
"""Default request body deserialization"""
|
||||||
|
|
||||||
def deserialize(self, datastring, action='default'):
|
def deserialize(self, request, action='default'):
|
||||||
return self.dispatch(datastring, action=action)
|
return self.dispatch(request, action=action)
|
||||||
|
|
||||||
def default(self, datastring):
|
def default(self, request):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@ -731,7 +733,8 @@ class JSONDeserializer(TextDeserializer):
|
|||||||
msg = _("cannot understand JSON")
|
msg = _("cannot understand JSON")
|
||||||
raise exception.MalformedRequestBody(reason=msg)
|
raise exception.MalformedRequestBody(reason=msg)
|
||||||
|
|
||||||
def default(self, datastring):
|
def default(self, request):
|
||||||
|
datastring = request.body
|
||||||
return {'body': self._from_json(datastring)}
|
return {'body': self._from_json(datastring)}
|
||||||
|
|
||||||
|
|
||||||
@ -831,7 +834,6 @@ class JSONPatchDeserializer(TextDeserializer):
|
|||||||
ret.append(part.replace('~1', '/').replace('~0', '~').strip())
|
ret.append(part.replace('~1', '/').replace('~0', '~').strip())
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
def _validate_json_pointer(self, pointer):
|
def _validate_json_pointer(self, pointer):
|
||||||
"""Validate a json pointer.
|
"""Validate a json pointer.
|
||||||
|
|
||||||
@ -866,8 +868,8 @@ class JSONPatchDeserializer(TextDeserializer):
|
|||||||
msg = _('Nested paths are not allowed')
|
msg = _('Nested paths are not allowed')
|
||||||
raise webob.exc.HTTPBadRequest(explanation=msg)
|
raise webob.exc.HTTPBadRequest(explanation=msg)
|
||||||
|
|
||||||
def default(self, datastring):
|
def default(self, request):
|
||||||
return {'body': self._from_json_patch(datastring)}
|
return {'body': self._from_json_patch(request.body)}
|
||||||
|
|
||||||
|
|
||||||
class XMLDeserializer(TextDeserializer):
|
class XMLDeserializer(TextDeserializer):
|
||||||
@ -879,7 +881,8 @@ class XMLDeserializer(TextDeserializer):
|
|||||||
super(XMLDeserializer, self).__init__()
|
super(XMLDeserializer, self).__init__()
|
||||||
self.metadata = metadata or {}
|
self.metadata = metadata or {}
|
||||||
|
|
||||||
def _from_xml(self, datastring):
|
def _from_xml(self, request):
|
||||||
|
datastring = request.body
|
||||||
plurals = set(self.metadata.get('plurals', {}))
|
plurals = set(self.metadata.get('plurals', {}))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -934,3 +937,22 @@ class XMLDeserializer(TextDeserializer):
|
|||||||
|
|
||||||
def default(self, datastring):
|
def default(self, datastring):
|
||||||
return {'body': self._from_xml(datastring)}
|
return {'body': self._from_xml(datastring)}
|
||||||
|
|
||||||
|
|
||||||
|
class FormDataDeserializer(TextDeserializer):
|
||||||
|
def _from_json(self, datastring):
|
||||||
|
value = datastring
|
||||||
|
try:
|
||||||
|
LOG.debug(_("Trying deserialize '{0}' to json".format(datastring)))
|
||||||
|
value = jsonutils.loads(datastring)
|
||||||
|
except ValueError:
|
||||||
|
LOG.debug(_("Unable deserialize to json, using raw text"))
|
||||||
|
return value
|
||||||
|
|
||||||
|
def default(self, request):
|
||||||
|
form_data_parts = request.POST
|
||||||
|
result = []
|
||||||
|
for key, value in form_data_parts.iteritems():
|
||||||
|
if isinstance(value, basestring):
|
||||||
|
form_data_parts[key] = self._from_json(value)
|
||||||
|
return {'body': form_data_parts}
|
||||||
|
@ -206,6 +206,9 @@ def load_from_file(archive_path, target_dir=None, drop_dir=False):
|
|||||||
raise e.PackageLoadError('Target directory is not empty')
|
raise e.PackageLoadError('Target directory is not empty')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if not tarfile.is_tarfile(archive_path):
|
||||||
|
raise e.PackageFormatError("Uploading file should be a"
|
||||||
|
" 'tar.gz' archive")
|
||||||
package = tarfile.open(archive_path)
|
package = tarfile.open(archive_path)
|
||||||
package.extractall(path=target_dir)
|
package.extractall(path=target_dir)
|
||||||
return load_from_dir(target_dir, preload=True)
|
return load_from_dir(target_dir, preload=True)
|
||||||
|
Loading…
Reference in New Issue
Block a user