Merge #273 Deduplicate configuration a bit
This commit is contained in:
commit
c80b7c6894
@ -735,6 +735,47 @@ Example
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
Common options for Live Images, Live Media and Image Build
|
||||||
|
==========================================================
|
||||||
|
|
||||||
|
All images can have ``ksurl``, ``version``, ``release`` and ``target``
|
||||||
|
specified. Since this can create a lot of duplication, there are global options
|
||||||
|
that can be used instead.
|
||||||
|
|
||||||
|
For each of the phases, if the option is not specified for a particular
|
||||||
|
deliverable, an option named ``<PHASE_NAME>_<OPTION>`` is checked. If that is
|
||||||
|
not specified either, the last fallback is ``global_<OPTION>``. If even that is
|
||||||
|
unset, the value is considered to not be specified.
|
||||||
|
|
||||||
|
The kickstart URL is configured by these options.
|
||||||
|
|
||||||
|
* ``global_ksurl`` -- global fallback setting
|
||||||
|
* ``live_media_ksurl``
|
||||||
|
* ``image_build_ksurl``
|
||||||
|
* ``live_images_ksurl``
|
||||||
|
|
||||||
|
Target is specified by these settings. For live images refer to ``live_target``.
|
||||||
|
|
||||||
|
* ``global_target`` -- global fallback setting
|
||||||
|
* ``live_media_target``
|
||||||
|
* ``image_build_target``
|
||||||
|
|
||||||
|
Version is specified by these options.
|
||||||
|
|
||||||
|
* ``global_version`` -- global fallback setting
|
||||||
|
* ``live_media_version``
|
||||||
|
* ``image_build_version``
|
||||||
|
* ``live_images_version``
|
||||||
|
|
||||||
|
Release is specified by these options. If set explicitly to ``None``, a value
|
||||||
|
will be generated based on date, compose type and respin.
|
||||||
|
|
||||||
|
* ``global_release`` -- global fallback setting
|
||||||
|
* ``live_media_release``
|
||||||
|
* ``image_build_release``
|
||||||
|
* ``live_images_release``
|
||||||
|
|
||||||
|
|
||||||
Live Images Settings
|
Live Images Settings
|
||||||
====================
|
====================
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
|
|
||||||
from pungi.checks import validate_options
|
from pungi.checks import validate_options
|
||||||
|
from pungi import util
|
||||||
|
|
||||||
|
|
||||||
class PhaseBase(object):
|
class PhaseBase(object):
|
||||||
@ -84,3 +85,67 @@ class ConfigGuardedPhase(PhaseBase):
|
|||||||
self.compose.log_info("Config section '%s' was not found. Skipping." % self.name)
|
self.compose.log_info("Config section '%s' was not found. Skipping." % self.name)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class ImageConfigMixin(object):
|
||||||
|
"""
|
||||||
|
A mixin for phase that needs to access image related settings: ksurl,
|
||||||
|
version, target and release.
|
||||||
|
|
||||||
|
First, it checks config object given as argument, then it checks
|
||||||
|
phase-level configuration and finally falls back to global configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(ImageConfigMixin, self).__init__(*args, **kwargs)
|
||||||
|
self._phase_ksurl = None
|
||||||
|
|
||||||
|
def get_config(self, cfg, opt):
|
||||||
|
return cfg.get(
|
||||||
|
opt, self.compose.conf.get(
|
||||||
|
'{}_{}'.format(self.name, opt), self.compose.conf.get(
|
||||||
|
'global_{}'.format(opt))))
|
||||||
|
|
||||||
|
def get_release(self, cfg):
|
||||||
|
"""
|
||||||
|
If release is set explicitly to None, replace it with date and respin.
|
||||||
|
Uses configuration passed as argument, phase specific settings and
|
||||||
|
global settings.
|
||||||
|
"""
|
||||||
|
for key, conf in [('release', cfg),
|
||||||
|
('{}_release'.format(self.name), self.compose.conf),
|
||||||
|
('global_release', self.compose.conf)]:
|
||||||
|
if key in conf:
|
||||||
|
return conf[key] or self.compose.image_release
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_ksurl(self, cfg):
|
||||||
|
"""
|
||||||
|
Get ksurl from `cfg`. If not present, fall back to phase defined one or
|
||||||
|
global one.
|
||||||
|
"""
|
||||||
|
if 'ksurl' in cfg:
|
||||||
|
return util.resolve_git_url(cfg['ksurl'])
|
||||||
|
if '{}_ksurl'.format(self.name) in self.compose.conf:
|
||||||
|
return self.phase_ksurl
|
||||||
|
if 'global_ksurl' in self.compose.conf:
|
||||||
|
return self.global_ksurl
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def phase_ksurl(self):
|
||||||
|
"""Get phase level ksurl, making sure to resolve it only once."""
|
||||||
|
# The phase-level setting is cached as instance attribute of the phase.
|
||||||
|
if not self._phase_ksurl:
|
||||||
|
ksurl = self.compose.conf.get('{}_ksurl'.format(self.name))
|
||||||
|
self._phase_ksurl = util.resolve_git_url(ksurl)
|
||||||
|
return self._phase_ksurl
|
||||||
|
|
||||||
|
@property
|
||||||
|
def global_ksurl(self):
|
||||||
|
"""Get global ksurl setting, making sure to resolve it only once."""
|
||||||
|
# The global setting is cached in the configuration object.
|
||||||
|
if '_global_ksurl' not in self.compose.conf:
|
||||||
|
ksurl = self.compose.conf.get('global_ksurl')
|
||||||
|
self.compose.conf['_global_ksurl'] = util.resolve_git_url(ksurl)
|
||||||
|
return self.compose.conf['_global_ksurl']
|
||||||
|
@ -5,8 +5,8 @@ import os
|
|||||||
import time
|
import time
|
||||||
from kobo import shortcuts
|
from kobo import shortcuts
|
||||||
|
|
||||||
from pungi.util import get_variant_data, resolve_git_url, makedirs, get_mtime, get_file_size, failable
|
from pungi.util import get_variant_data, makedirs, get_mtime, get_file_size, failable
|
||||||
from pungi.phases.base import PhaseBase
|
from pungi.phases import base
|
||||||
from pungi.linker import Linker
|
from pungi.linker import Linker
|
||||||
from pungi.paths import translate_path
|
from pungi.paths import translate_path
|
||||||
from pungi.wrappers.kojiwrapper import KojiWrapper
|
from pungi.wrappers.kojiwrapper import KojiWrapper
|
||||||
@ -14,21 +14,41 @@ from kobo.threads import ThreadPool, WorkerThread
|
|||||||
from productmd.images import Image
|
from productmd.images import Image
|
||||||
|
|
||||||
|
|
||||||
class ImageBuildPhase(PhaseBase):
|
class ImageBuildPhase(base.ImageConfigMixin, base.ConfigGuardedPhase):
|
||||||
"""class for wrapping up koji image-build"""
|
"""class for wrapping up koji image-build"""
|
||||||
name = "image_build"
|
name = "image_build"
|
||||||
|
|
||||||
def __init__(self, compose):
|
config_options = [
|
||||||
PhaseBase.__init__(self, compose)
|
{
|
||||||
self.pool = ThreadPool(logger=self.compose._logger)
|
"name": "image_build",
|
||||||
|
"expected_types": [dict],
|
||||||
|
"optional": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "image_build_ksurl",
|
||||||
|
"expected_types": [str],
|
||||||
|
"optional": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "image_build_target",
|
||||||
|
"expected_types": [str],
|
||||||
|
"optional": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "image_build_release",
|
||||||
|
"expected_types": [str, type(None)],
|
||||||
|
"optional": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "image_build_version",
|
||||||
|
"expected_types": [str],
|
||||||
|
"optional": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
def skip(self):
|
def __init__(self, compose):
|
||||||
if PhaseBase.skip(self):
|
super(ImageBuildPhase, self).__init__(compose)
|
||||||
return True
|
self.pool = ThreadPool(logger=self.compose._logger)
|
||||||
if not self.compose.conf.get(self.name):
|
|
||||||
self.compose.log_info("Config section '%s' was not found. Skipping" % self.name)
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _get_install_tree(self, image_conf, variant):
|
def _get_install_tree(self, image_conf, variant):
|
||||||
"""
|
"""
|
||||||
@ -100,14 +120,20 @@ class ImageBuildPhase(PhaseBase):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Replace possible ambiguous ref name with explicit hash.
|
# Replace possible ambiguous ref name with explicit hash.
|
||||||
if 'ksurl' in image_conf['image-build']:
|
ksurl = self.get_ksurl(image_conf['image-build'])
|
||||||
image_conf["image-build"]['ksurl'] = resolve_git_url(image_conf["image-build"]['ksurl'])
|
if ksurl:
|
||||||
|
image_conf["image-build"]['ksurl'] = ksurl
|
||||||
|
|
||||||
image_conf["image-build"]["variant"] = variant
|
image_conf["image-build"]["variant"] = variant
|
||||||
|
|
||||||
image_conf["image-build"]["install_tree"] = self._get_install_tree(image_conf['image-build'], variant)
|
image_conf["image-build"]["install_tree"] = self._get_install_tree(image_conf['image-build'], variant)
|
||||||
|
|
||||||
self._set_release(image_conf['image-build'])
|
release = self.get_release(image_conf['image-build'])
|
||||||
|
if release:
|
||||||
|
image_conf['image-build']['release'] = release
|
||||||
|
|
||||||
|
image_conf['image-build']['version'] = self.get_config(image_conf['image-build'], 'version')
|
||||||
|
image_conf['image-build']['target'] = self.get_config(image_conf['image-build'], 'target')
|
||||||
|
|
||||||
# transform format into right 'format' for image-build
|
# transform format into right 'format' for image-build
|
||||||
# e.g. 'docker,qcow2'
|
# e.g. 'docker,qcow2'
|
||||||
|
@ -150,6 +150,27 @@ class InitPhase(PhaseBase):
|
|||||||
"optional": True,
|
"optional": True,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# Configuration shared by all image building phases.
|
||||||
|
{
|
||||||
|
"name": "global_ksurl",
|
||||||
|
"expected_types": [str],
|
||||||
|
"optional": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "global_target",
|
||||||
|
"expected_types": [str],
|
||||||
|
"optional": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "global_release",
|
||||||
|
"expected_types": [str, type(None)],
|
||||||
|
"optional": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "global_version",
|
||||||
|
"expected_types": [str],
|
||||||
|
"optional": True,
|
||||||
|
},
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -27,8 +27,8 @@ from productmd.images import Image
|
|||||||
|
|
||||||
from pungi.wrappers.kojiwrapper import KojiWrapper
|
from pungi.wrappers.kojiwrapper import KojiWrapper
|
||||||
from pungi.wrappers.iso import IsoWrapper
|
from pungi.wrappers.iso import IsoWrapper
|
||||||
from pungi.phases.base import PhaseBase
|
from pungi.phases import base
|
||||||
from pungi.util import get_arch_variant_data, resolve_git_url, makedirs, get_mtime, get_file_size, failable
|
from pungi.util import get_arch_variant_data, makedirs, get_mtime, get_file_size, failable
|
||||||
from pungi.paths import translate_path
|
from pungi.paths import translate_path
|
||||||
|
|
||||||
|
|
||||||
@ -38,8 +38,8 @@ if sys.version_info[0] == 3:
|
|||||||
return (a > b) - (a < b)
|
return (a > b) - (a < b)
|
||||||
|
|
||||||
|
|
||||||
class LiveImagesPhase(PhaseBase):
|
class LiveImagesPhase(base.ImageConfigMixin, base.ConfigGuardedPhase):
|
||||||
name = "liveimages"
|
name = "live_images"
|
||||||
|
|
||||||
config_options = (
|
config_options = (
|
||||||
{
|
{
|
||||||
@ -71,20 +71,28 @@ class LiveImagesPhase(PhaseBase):
|
|||||||
"name": "live_images_no_rename",
|
"name": "live_images_no_rename",
|
||||||
"expected_types": [bool],
|
"expected_types": [bool],
|
||||||
"optional": True,
|
"optional": True,
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
"name": "live_images_ksurl",
|
||||||
|
"expected_types": [str],
|
||||||
|
"optional": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "live_images_release",
|
||||||
|
"expected_types": [str, type(None)],
|
||||||
|
"optional": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "live_images_version",
|
||||||
|
"expected_types": [str],
|
||||||
|
"optional": True,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, compose):
|
def __init__(self, compose):
|
||||||
PhaseBase.__init__(self, compose)
|
super(LiveImagesPhase, self).__init__(compose)
|
||||||
self.pool = ThreadPool(logger=self.compose._logger)
|
self.pool = ThreadPool(logger=self.compose._logger)
|
||||||
|
|
||||||
def skip(self):
|
|
||||||
if PhaseBase.skip(self):
|
|
||||||
return True
|
|
||||||
if not self.compose.conf.get("live_images"):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _get_extra_repos(self, arch, variant, extras):
|
def _get_extra_repos(self, arch, variant, extras):
|
||||||
repo = []
|
repo = []
|
||||||
for extra in extras:
|
for extra in extras:
|
||||||
@ -109,19 +117,13 @@ class LiveImagesPhase(PhaseBase):
|
|||||||
repos.extend(self._get_extra_repos(arch, variant, force_list(data.get('repo_from', []))))
|
repos.extend(self._get_extra_repos(arch, variant, force_list(data.get('repo_from', []))))
|
||||||
return repos
|
return repos
|
||||||
|
|
||||||
def _get_release(self, image_conf):
|
|
||||||
"""If release is set explicitly to None, replace it with date and respin."""
|
|
||||||
if 'release' in image_conf and image_conf['release'] is None:
|
|
||||||
return self.compose.image_release
|
|
||||||
return image_conf.get('release', None)
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
symlink_isos_to = self.compose.conf.get("symlink_isos_to", None)
|
symlink_isos_to = self.compose.conf.get("symlink_isos_to", None)
|
||||||
commands = []
|
commands = []
|
||||||
|
|
||||||
for variant in self.compose.variants.values():
|
for variant in self.compose.variants.values():
|
||||||
for arch in variant.arches + ["src"]:
|
for arch in variant.arches + ["src"]:
|
||||||
for data in get_arch_variant_data(self.compose.conf, "live_images", arch, variant):
|
for data in get_arch_variant_data(self.compose.conf, self.name, arch, variant):
|
||||||
subvariant = data.get('subvariant', variant.uid)
|
subvariant = data.get('subvariant', variant.uid)
|
||||||
type = data.get('type', 'live')
|
type = data.get('type', 'live')
|
||||||
|
|
||||||
@ -138,12 +140,12 @@ class LiveImagesPhase(PhaseBase):
|
|||||||
|
|
||||||
cmd = {
|
cmd = {
|
||||||
"name": data.get('name'),
|
"name": data.get('name'),
|
||||||
"version": data.get("version", None),
|
"version": self.get_config(data, 'version'),
|
||||||
"release": self._get_release(data),
|
"release": self.get_release(data),
|
||||||
"dest_dir": dest_dir,
|
"dest_dir": dest_dir,
|
||||||
"build_arch": arch,
|
"build_arch": arch,
|
||||||
"ks_file": data['kickstart'],
|
"ks_file": data['kickstart'],
|
||||||
"ksurl": None,
|
"ksurl": self.get_ksurl(data),
|
||||||
# Used for images wrapped in RPM
|
# Used for images wrapped in RPM
|
||||||
"specfile": data.get("specfile", None),
|
"specfile": data.get("specfile", None),
|
||||||
# Scratch (only taken in consideration if specfile
|
# Scratch (only taken in consideration if specfile
|
||||||
@ -157,9 +159,6 @@ class LiveImagesPhase(PhaseBase):
|
|||||||
"subvariant": subvariant,
|
"subvariant": subvariant,
|
||||||
}
|
}
|
||||||
|
|
||||||
if 'ksurl' in data:
|
|
||||||
cmd['ksurl'] = resolve_git_url(data['ksurl'])
|
|
||||||
|
|
||||||
cmd["repos"] = self._get_repos(arch, variant, data)
|
cmd["repos"] = self._get_repos(arch, variant, data)
|
||||||
|
|
||||||
# Signing of the rpm wrapped image
|
# Signing of the rpm wrapped image
|
||||||
@ -194,11 +193,6 @@ class LiveImagesPhase(PhaseBase):
|
|||||||
return self.compose.get_image_name(arch, variant, disc_type=disc_type,
|
return self.compose.get_image_name(arch, variant, disc_type=disc_type,
|
||||||
disc_num=None, format=format)
|
disc_num=None, format=format)
|
||||||
|
|
||||||
def stop(self, *args, **kwargs):
|
|
||||||
PhaseBase.stop(self, *args, **kwargs)
|
|
||||||
if self.skip():
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
class CreateLiveImageThread(WorkerThread):
|
class CreateLiveImageThread(WorkerThread):
|
||||||
EXTS = ('.iso', '.raw.xz')
|
EXTS = ('.iso', '.raw.xz')
|
||||||
|
@ -4,8 +4,8 @@ import os
|
|||||||
import time
|
import time
|
||||||
from kobo import shortcuts
|
from kobo import shortcuts
|
||||||
|
|
||||||
from pungi.util import get_variant_data, resolve_git_url, makedirs, get_mtime, get_file_size, failable
|
from pungi.util import get_variant_data, makedirs, get_mtime, get_file_size, failable
|
||||||
from pungi.phases.base import PhaseBase
|
from pungi.phases.base import ConfigGuardedPhase, ImageConfigMixin
|
||||||
from pungi.linker import Linker
|
from pungi.linker import Linker
|
||||||
from pungi.paths import translate_path
|
from pungi.paths import translate_path
|
||||||
from pungi.wrappers.kojiwrapper import KojiWrapper
|
from pungi.wrappers.kojiwrapper import KojiWrapper
|
||||||
@ -13,7 +13,7 @@ from kobo.threads import ThreadPool, WorkerThread
|
|||||||
from productmd.images import Image
|
from productmd.images import Image
|
||||||
|
|
||||||
|
|
||||||
class LiveMediaPhase(PhaseBase):
|
class LiveMediaPhase(ImageConfigMixin, ConfigGuardedPhase):
|
||||||
"""class for wrapping up koji spin-livemedia"""
|
"""class for wrapping up koji spin-livemedia"""
|
||||||
name = 'live_media'
|
name = 'live_media'
|
||||||
|
|
||||||
@ -37,21 +37,17 @@ class LiveMediaPhase(PhaseBase):
|
|||||||
"name": "live_media_release",
|
"name": "live_media_release",
|
||||||
"expected_types": [str, type(None)],
|
"expected_types": [str, type(None)],
|
||||||
"optional": True,
|
"optional": True,
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
"name": "live_media_version",
|
||||||
|
"expected_types": [str],
|
||||||
|
"optional": True,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, compose):
|
def __init__(self, compose):
|
||||||
super(LiveMediaPhase, self).__init__(compose)
|
super(LiveMediaPhase, self).__init__(compose)
|
||||||
self.pool = ThreadPool(logger=self.compose._logger)
|
self.pool = ThreadPool(logger=self.compose._logger)
|
||||||
self._global_ksurl = None
|
|
||||||
|
|
||||||
def skip(self):
|
|
||||||
if super(LiveMediaPhase, self).skip():
|
|
||||||
return True
|
|
||||||
if not self.compose.conf.get(self.name):
|
|
||||||
self.compose.log_info("Config section '%s' was not found. Skipping" % self.name)
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _get_repos(self, image_conf, variant):
|
def _get_repos(self, image_conf, variant):
|
||||||
"""
|
"""
|
||||||
@ -84,15 +80,6 @@ class LiveMediaPhase(PhaseBase):
|
|||||||
arches = set(image_conf.get('arches', [])) & arches
|
arches = set(image_conf.get('arches', [])) & arches
|
||||||
return sorted(arches)
|
return sorted(arches)
|
||||||
|
|
||||||
def _get_release(self, image_conf):
|
|
||||||
"""If release is set explicitly to None, replace it with date and respin.
|
|
||||||
Uses both image configuration and global config.
|
|
||||||
"""
|
|
||||||
for key, conf in [('release', image_conf), ('live_media_release', self.compose.conf)]:
|
|
||||||
if key in conf and conf[key] is None:
|
|
||||||
return self.compose.image_release
|
|
||||||
return image_conf.get('release', self.compose.conf.get('live_media_release'))
|
|
||||||
|
|
||||||
def _get_install_tree(self, image_conf, variant):
|
def _get_install_tree(self, image_conf, variant):
|
||||||
if 'install_tree_from' in image_conf:
|
if 'install_tree_from' in image_conf:
|
||||||
variant_uid = image_conf['install_tree_from']
|
variant_uid = image_conf['install_tree_from']
|
||||||
@ -107,23 +94,6 @@ class LiveMediaPhase(PhaseBase):
|
|||||||
self.compose.paths.compose.os_tree('$basearch', variant, create_dir=False)
|
self.compose.paths.compose.os_tree('$basearch', variant, create_dir=False)
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
|
||||||
def global_ksurl(self):
|
|
||||||
"""Get globally configure kickstart URL. It will only be resolved once."""
|
|
||||||
if not self._global_ksurl:
|
|
||||||
ksurl = self.compose.conf.get('live_media_ksurl')
|
|
||||||
self._global_ksurl = resolve_git_url(ksurl)
|
|
||||||
return self._global_ksurl
|
|
||||||
|
|
||||||
def _get_ksurl(self, image_conf):
|
|
||||||
"""Get ksurl from `image_conf`. If not present, fall back to global one."""
|
|
||||||
if 'ksurl' in image_conf:
|
|
||||||
return resolve_git_url(image_conf['ksurl'])
|
|
||||||
return self.global_ksurl
|
|
||||||
|
|
||||||
def _get_config(self, image_conf, opt):
|
|
||||||
return image_conf.get(opt, self.compose.conf.get('live_media_' + opt))
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
for variant in self.compose.get_variants():
|
for variant in self.compose.get_variants():
|
||||||
arches = set([x for x in variant.arches if x != 'src'])
|
arches = set([x for x in variant.arches if x != 'src'])
|
||||||
@ -132,20 +102,20 @@ class LiveMediaPhase(PhaseBase):
|
|||||||
name = image_conf.get(
|
name = image_conf.get(
|
||||||
'name', "%s-%s-Live" % (self.compose.ci_base.release.short, subvariant))
|
'name', "%s-%s-Live" % (self.compose.ci_base.release.short, subvariant))
|
||||||
config = {
|
config = {
|
||||||
'target': self._get_config(image_conf, 'target'),
|
'target': self.get_config(image_conf, 'target'),
|
||||||
'arches': self._get_arches(image_conf, arches),
|
'arches': self._get_arches(image_conf, arches),
|
||||||
'ksfile': image_conf['kickstart'],
|
'ksfile': image_conf['kickstart'],
|
||||||
'ksurl': self._get_ksurl(image_conf),
|
'ksurl': self.get_ksurl(image_conf),
|
||||||
'ksversion': image_conf.get('ksversion'),
|
'ksversion': image_conf.get('ksversion'),
|
||||||
'scratch': image_conf.get('scratch', False),
|
'scratch': image_conf.get('scratch', False),
|
||||||
'release': self._get_release(image_conf),
|
'release': self.get_release(image_conf),
|
||||||
'skip_tag': image_conf.get('skip_tag'),
|
'skip_tag': image_conf.get('skip_tag'),
|
||||||
'name': name,
|
'name': name,
|
||||||
'subvariant': subvariant,
|
'subvariant': subvariant,
|
||||||
'title': image_conf.get('title'),
|
'title': image_conf.get('title'),
|
||||||
'repo': self._get_repos(image_conf, variant),
|
'repo': self._get_repos(image_conf, variant),
|
||||||
'install_tree': self._get_install_tree(image_conf, variant),
|
'install_tree': self._get_install_tree(image_conf, variant),
|
||||||
'version': self._get_config(image_conf, 'version'),
|
'version': self.get_config(image_conf, 'version'),
|
||||||
}
|
}
|
||||||
self.pool.add(LiveMediaThread(self.pool))
|
self.pool.add(LiveMediaThread(self.pool))
|
||||||
self.pool.queue_put((self.compose, variant, config))
|
self.pool.queue_put((self.compose, variant, config))
|
||||||
|
@ -98,6 +98,65 @@ class TestImageBuildPhase(PungiTestCase):
|
|||||||
[mock.call((compose, client_args)),
|
[mock.call((compose, client_args)),
|
||||||
mock.call((compose, server_args))])
|
mock.call((compose, server_args))])
|
||||||
|
|
||||||
|
@mock.patch('pungi.phases.image_build.ThreadPool')
|
||||||
|
def test_image_build_phase_global_options(self, ThreadPool):
|
||||||
|
compose = DummyCompose(self.topdir, {
|
||||||
|
'image_build_ksurl': 'git://git.fedorahosted.org/git/spin-kickstarts.git',
|
||||||
|
'image_build_release': None,
|
||||||
|
'image_build_target': 'f24',
|
||||||
|
'image_build_version': 'Rawhide',
|
||||||
|
'image_build': {
|
||||||
|
'^Server$': [
|
||||||
|
{
|
||||||
|
'image-build': {
|
||||||
|
'format': [('docker', 'tar.xz')],
|
||||||
|
'name': 'Fedora-Docker-Base',
|
||||||
|
'kickstart': "fedora-docker-base.ks",
|
||||||
|
'distro': 'Fedora-20',
|
||||||
|
'disk_size': 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'koji_profile': 'koji',
|
||||||
|
})
|
||||||
|
|
||||||
|
phase = ImageBuildPhase(compose)
|
||||||
|
|
||||||
|
phase.run()
|
||||||
|
self.maxDiff = None
|
||||||
|
|
||||||
|
# assert at least one thread was started
|
||||||
|
self.assertTrue(phase.pool.add.called)
|
||||||
|
server_args = {
|
||||||
|
"format": [('docker', 'tar.xz')],
|
||||||
|
"image_conf": {
|
||||||
|
'image-build': {
|
||||||
|
'install_tree': self.topdir + '/compose/Server/$arch/os',
|
||||||
|
'kickstart': 'fedora-docker-base.ks',
|
||||||
|
'format': 'docker',
|
||||||
|
'repo': self.topdir + '/compose/Server/$arch/os',
|
||||||
|
'variant': compose.variants['Server'],
|
||||||
|
'target': 'f24',
|
||||||
|
'disk_size': 3,
|
||||||
|
'name': 'Fedora-Docker-Base',
|
||||||
|
'arches': 'amd64,x86_64',
|
||||||
|
'version': 'Rawhide',
|
||||||
|
'ksurl': 'git://git.fedorahosted.org/git/spin-kickstarts.git',
|
||||||
|
'distro': 'Fedora-20',
|
||||||
|
'release': '20151203.t.0',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"conf_file": self.topdir + '/work/image-build/Server/docker_Fedora-Docker-Base.cfg',
|
||||||
|
"image_dir": self.topdir + '/compose/Server/%(arch)s/images',
|
||||||
|
"relative_image_dir": 'Server/%(arch)s/images',
|
||||||
|
"link_type": 'hardlink-or-copy',
|
||||||
|
"scratch": False,
|
||||||
|
}
|
||||||
|
self.maxDiff = None
|
||||||
|
self.assertItemsEqual(phase.pool.queue_put.mock_calls,
|
||||||
|
[mock.call((compose, server_args))])
|
||||||
|
|
||||||
@mock.patch('pungi.phases.image_build.ThreadPool')
|
@mock.patch('pungi.phases.image_build.ThreadPool')
|
||||||
def test_image_build_filter_all_variants(self, ThreadPool):
|
def test_image_build_filter_all_variants(self, ThreadPool):
|
||||||
compose = DummyCompose(self.topdir, {
|
compose = DummyCompose(self.topdir, {
|
||||||
@ -321,7 +380,7 @@ class TestImageBuildPhase(PungiTestCase):
|
|||||||
args, kwargs = phase.pool.queue_put.call_args
|
args, kwargs = phase.pool.queue_put.call_args
|
||||||
self.assertTrue(args[0][1].get('scratch'))
|
self.assertTrue(args[0][1].get('scratch'))
|
||||||
|
|
||||||
@mock.patch('pungi.phases.image_build.resolve_git_url')
|
@mock.patch('pungi.util.resolve_git_url')
|
||||||
@mock.patch('pungi.phases.image_build.ThreadPool')
|
@mock.patch('pungi.phases.image_build.ThreadPool')
|
||||||
def test_image_build_resolve_ksurl(self, ThreadPool, resolve_git_url):
|
def test_image_build_resolve_ksurl(self, ThreadPool, resolve_git_url):
|
||||||
compose = DummyCompose(self.topdir, {
|
compose = DummyCompose(self.topdir, {
|
||||||
|
@ -221,7 +221,7 @@ class TestLiveImagesPhase(PungiTestCase):
|
|||||||
'amd64'))])
|
'amd64'))])
|
||||||
|
|
||||||
@mock.patch('pungi.phases.live_images.ThreadPool')
|
@mock.patch('pungi.phases.live_images.ThreadPool')
|
||||||
@mock.patch('pungi.phases.live_images.resolve_git_url')
|
@mock.patch('pungi.util.resolve_git_url')
|
||||||
def test_spin_appliance(self, resolve_git_url, ThreadPool):
|
def test_spin_appliance(self, resolve_git_url, ThreadPool):
|
||||||
compose = DummyCompose(self.topdir, {
|
compose = DummyCompose(self.topdir, {
|
||||||
'live_images': [
|
'live_images': [
|
||||||
@ -270,6 +270,110 @@ class TestLiveImagesPhase(PungiTestCase):
|
|||||||
self.assertEqual(resolve_git_url.mock_calls,
|
self.assertEqual(resolve_git_url.mock_calls,
|
||||||
[mock.call('https://git.example.com/kickstarts.git?#HEAD')])
|
[mock.call('https://git.example.com/kickstarts.git?#HEAD')])
|
||||||
|
|
||||||
|
@mock.patch('pungi.phases.live_images.ThreadPool')
|
||||||
|
@mock.patch('pungi.util.resolve_git_url')
|
||||||
|
def test_spin_appliance_phase_global_settings(self, resolve_git_url, ThreadPool):
|
||||||
|
compose = DummyCompose(self.topdir, {
|
||||||
|
'live_images_ksurl': 'https://git.example.com/kickstarts.git?#HEAD',
|
||||||
|
'live_images_release': None,
|
||||||
|
'live_images_version': 'Rawhide',
|
||||||
|
'live_images': [
|
||||||
|
('^Client$', {
|
||||||
|
'amd64': {
|
||||||
|
'kickstart': 'test.ks',
|
||||||
|
'additional_repos': ['http://example.com/repo/'],
|
||||||
|
'repo_from': ['Everything'],
|
||||||
|
'type': 'appliance',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
resolve_git_url.return_value = 'https://git.example.com/kickstarts.git?#CAFEBABE'
|
||||||
|
|
||||||
|
phase = LiveImagesPhase(compose)
|
||||||
|
|
||||||
|
phase.run()
|
||||||
|
|
||||||
|
# assert at least one thread was started
|
||||||
|
self.assertTrue(phase.pool.add.called)
|
||||||
|
self.maxDiff = None
|
||||||
|
self.assertItemsEqual(phase.pool.queue_put.mock_calls,
|
||||||
|
[mock.call((compose,
|
||||||
|
{'ks_file': 'test.ks',
|
||||||
|
'build_arch': 'amd64',
|
||||||
|
'dest_dir': self.topdir + '/compose/Client/amd64/images',
|
||||||
|
'scratch': False,
|
||||||
|
'repos': [self.topdir + '/compose/Client/amd64/os',
|
||||||
|
'http://example.com/repo/',
|
||||||
|
self.topdir + '/compose/Everything/amd64/os'],
|
||||||
|
'label': '',
|
||||||
|
'name': None,
|
||||||
|
'filename': 'image-name',
|
||||||
|
'version': 'Rawhide',
|
||||||
|
'specfile': None,
|
||||||
|
'sign': False,
|
||||||
|
'type': 'appliance',
|
||||||
|
'release': '20151203.t.0',
|
||||||
|
'subvariant': 'Client',
|
||||||
|
'ksurl': 'https://git.example.com/kickstarts.git?#CAFEBABE'},
|
||||||
|
compose.variants['Client'],
|
||||||
|
'amd64'))])
|
||||||
|
self.assertEqual(resolve_git_url.mock_calls,
|
||||||
|
[mock.call('https://git.example.com/kickstarts.git?#HEAD')])
|
||||||
|
|
||||||
|
@mock.patch('pungi.phases.live_images.ThreadPool')
|
||||||
|
@mock.patch('pungi.util.resolve_git_url')
|
||||||
|
def test_spin_appliance_global_settings(self, resolve_git_url, ThreadPool):
|
||||||
|
compose = DummyCompose(self.topdir, {
|
||||||
|
'global_ksurl': 'https://git.example.com/kickstarts.git?#HEAD',
|
||||||
|
'global_release': None,
|
||||||
|
'global_version': 'Rawhide',
|
||||||
|
'live_images': [
|
||||||
|
('^Client$', {
|
||||||
|
'amd64': {
|
||||||
|
'kickstart': 'test.ks',
|
||||||
|
'additional_repos': ['http://example.com/repo/'],
|
||||||
|
'repo_from': ['Everything'],
|
||||||
|
'type': 'appliance',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
resolve_git_url.return_value = 'https://git.example.com/kickstarts.git?#CAFEBABE'
|
||||||
|
|
||||||
|
phase = LiveImagesPhase(compose)
|
||||||
|
|
||||||
|
phase.run()
|
||||||
|
|
||||||
|
# assert at least one thread was started
|
||||||
|
self.assertTrue(phase.pool.add.called)
|
||||||
|
self.maxDiff = None
|
||||||
|
self.assertItemsEqual(phase.pool.queue_put.mock_calls,
|
||||||
|
[mock.call((compose,
|
||||||
|
{'ks_file': 'test.ks',
|
||||||
|
'build_arch': 'amd64',
|
||||||
|
'dest_dir': self.topdir + '/compose/Client/amd64/images',
|
||||||
|
'scratch': False,
|
||||||
|
'repos': [self.topdir + '/compose/Client/amd64/os',
|
||||||
|
'http://example.com/repo/',
|
||||||
|
self.topdir + '/compose/Everything/amd64/os'],
|
||||||
|
'label': '',
|
||||||
|
'name': None,
|
||||||
|
'filename': 'image-name',
|
||||||
|
'version': 'Rawhide',
|
||||||
|
'specfile': None,
|
||||||
|
'sign': False,
|
||||||
|
'type': 'appliance',
|
||||||
|
'release': '20151203.t.0',
|
||||||
|
'subvariant': 'Client',
|
||||||
|
'ksurl': 'https://git.example.com/kickstarts.git?#CAFEBABE'},
|
||||||
|
compose.variants['Client'],
|
||||||
|
'amd64'))])
|
||||||
|
self.assertEqual(resolve_git_url.mock_calls,
|
||||||
|
[mock.call('https://git.example.com/kickstarts.git?#HEAD')])
|
||||||
|
|
||||||
@mock.patch('pungi.phases.live_images.ThreadPool')
|
@mock.patch('pungi.phases.live_images.ThreadPool')
|
||||||
def test_live_image_build_custom_type(self, ThreadPool):
|
def test_live_image_build_custom_type(self, ThreadPool):
|
||||||
compose = DummyCompose(self.topdir, {
|
compose = DummyCompose(self.topdir, {
|
||||||
|
@ -75,9 +75,9 @@ class TestLiveMediaPhase(PungiTestCase):
|
|||||||
'subvariant': 'Server',
|
'subvariant': 'Server',
|
||||||
}))])
|
}))])
|
||||||
|
|
||||||
@mock.patch('pungi.phases.livemedia_phase.resolve_git_url')
|
@mock.patch('pungi.util.resolve_git_url')
|
||||||
@mock.patch('pungi.phases.livemedia_phase.ThreadPool')
|
@mock.patch('pungi.phases.livemedia_phase.ThreadPool')
|
||||||
def test_live_media_with_global_opts(self, ThreadPool, resolve_git_url):
|
def test_live_media_with_phase_global_opts(self, ThreadPool, resolve_git_url):
|
||||||
compose = DummyCompose(self.topdir, {
|
compose = DummyCompose(self.topdir, {
|
||||||
'live_media_ksurl': 'git://example.com/repo.git#HEAD',
|
'live_media_ksurl': 'git://example.com/repo.git#HEAD',
|
||||||
'live_media_target': 'f24',
|
'live_media_target': 'f24',
|
||||||
@ -171,6 +171,102 @@ class TestLiveMediaPhase(PungiTestCase):
|
|||||||
'subvariant': 'Server',
|
'subvariant': 'Server',
|
||||||
}))])
|
}))])
|
||||||
|
|
||||||
|
@mock.patch('pungi.util.resolve_git_url')
|
||||||
|
@mock.patch('pungi.phases.livemedia_phase.ThreadPool')
|
||||||
|
def test_live_media_with_global_opts(self, ThreadPool, resolve_git_url):
|
||||||
|
compose = DummyCompose(self.topdir, {
|
||||||
|
'global_ksurl': 'git://example.com/repo.git#HEAD',
|
||||||
|
'global_target': 'f24',
|
||||||
|
'global_release': 'RRR',
|
||||||
|
'global_version': 'Rawhide',
|
||||||
|
'live_media': {
|
||||||
|
'^Server$': [
|
||||||
|
{
|
||||||
|
'kickstart': 'file.ks',
|
||||||
|
'name': 'Fedora Server Live',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'kickstart': 'different.ks',
|
||||||
|
'name': 'Fedora Server Live',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'kickstart': 'yet-another.ks',
|
||||||
|
'name': 'Fedora Server Live',
|
||||||
|
'ksurl': 'git://different.com/repo.git',
|
||||||
|
'target': 'f25',
|
||||||
|
'release': 'XXX',
|
||||||
|
'version': '25',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'koji_profile': 'koji',
|
||||||
|
})
|
||||||
|
|
||||||
|
resolve_git_url.return_value = 'git://example.com/repo.git#BEEFCAFE'
|
||||||
|
|
||||||
|
phase = LiveMediaPhase(compose)
|
||||||
|
|
||||||
|
phase.run()
|
||||||
|
self.assertTrue(phase.pool.add.called)
|
||||||
|
self.assertItemsEqual(resolve_git_url.mock_calls,
|
||||||
|
[mock.call('git://example.com/repo.git#HEAD'),
|
||||||
|
mock.call('git://different.com/repo.git')])
|
||||||
|
self.assertEqual(phase.pool.queue_put.call_args_list,
|
||||||
|
[mock.call((compose,
|
||||||
|
compose.variants['Server'],
|
||||||
|
{
|
||||||
|
'arches': ['amd64', 'x86_64'],
|
||||||
|
'ksfile': 'file.ks',
|
||||||
|
'ksurl': 'git://example.com/repo.git#BEEFCAFE',
|
||||||
|
'ksversion': None,
|
||||||
|
'name': 'Fedora Server Live',
|
||||||
|
'release': 'RRR',
|
||||||
|
'repo': [self.topdir + '/compose/Server/$basearch/os'],
|
||||||
|
'scratch': False,
|
||||||
|
'skip_tag': None,
|
||||||
|
'target': 'f24',
|
||||||
|
'title': None,
|
||||||
|
'install_tree': self.topdir + '/compose/Server/$basearch/os',
|
||||||
|
'version': 'Rawhide',
|
||||||
|
'subvariant': 'Server',
|
||||||
|
})),
|
||||||
|
mock.call((compose,
|
||||||
|
compose.variants['Server'],
|
||||||
|
{
|
||||||
|
'arches': ['amd64', 'x86_64'],
|
||||||
|
'ksfile': 'different.ks',
|
||||||
|
'ksurl': 'git://example.com/repo.git#BEEFCAFE',
|
||||||
|
'ksversion': None,
|
||||||
|
'name': 'Fedora Server Live',
|
||||||
|
'release': 'RRR',
|
||||||
|
'repo': [self.topdir + '/compose/Server/$basearch/os'],
|
||||||
|
'scratch': False,
|
||||||
|
'skip_tag': None,
|
||||||
|
'target': 'f24',
|
||||||
|
'title': None,
|
||||||
|
'install_tree': self.topdir + '/compose/Server/$basearch/os',
|
||||||
|
'version': 'Rawhide',
|
||||||
|
'subvariant': 'Server',
|
||||||
|
})),
|
||||||
|
mock.call((compose,
|
||||||
|
compose.variants['Server'],
|
||||||
|
{
|
||||||
|
'arches': ['amd64', 'x86_64'],
|
||||||
|
'ksfile': 'yet-another.ks',
|
||||||
|
'ksurl': 'git://example.com/repo.git#BEEFCAFE',
|
||||||
|
'ksversion': None,
|
||||||
|
'name': 'Fedora Server Live',
|
||||||
|
'release': 'XXX',
|
||||||
|
'repo': [self.topdir + '/compose/Server/$basearch/os'],
|
||||||
|
'scratch': False,
|
||||||
|
'skip_tag': None,
|
||||||
|
'target': 'f25',
|
||||||
|
'title': None,
|
||||||
|
'install_tree': self.topdir + '/compose/Server/$basearch/os',
|
||||||
|
'version': '25',
|
||||||
|
'subvariant': 'Server',
|
||||||
|
}))])
|
||||||
|
|
||||||
@mock.patch('pungi.phases.livemedia_phase.ThreadPool')
|
@mock.patch('pungi.phases.livemedia_phase.ThreadPool')
|
||||||
def test_live_media_non_existing_install_tree(self, ThreadPool):
|
def test_live_media_non_existing_install_tree(self, ThreadPool):
|
||||||
compose = DummyCompose(self.topdir, {
|
compose = DummyCompose(self.topdir, {
|
||||||
@ -217,7 +313,7 @@ class TestLiveMediaPhase(PungiTestCase):
|
|||||||
with self.assertRaisesRegexp(RuntimeError, r'no.+Missing.+when building.+Server'):
|
with self.assertRaisesRegexp(RuntimeError, r'no.+Missing.+when building.+Server'):
|
||||||
phase.run()
|
phase.run()
|
||||||
|
|
||||||
@mock.patch('pungi.phases.livemedia_phase.resolve_git_url')
|
@mock.patch('pungi.util.resolve_git_url')
|
||||||
@mock.patch('pungi.phases.livemedia_phase.ThreadPool')
|
@mock.patch('pungi.phases.livemedia_phase.ThreadPool')
|
||||||
def test_live_media_full(self, ThreadPool, resolve_git_url):
|
def test_live_media_full(self, ThreadPool, resolve_git_url):
|
||||||
compose = DummyCompose(self.topdir, {
|
compose = DummyCompose(self.topdir, {
|
||||||
|
73
tests/test_phase_base.py
Normal file
73
tests/test_phase_base.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env python2
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import mock
|
||||||
|
import unittest
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from pungi.phases import base
|
||||||
|
from tests.helpers import DummyCompose, PungiTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class Phase1(base.ImageConfigMixin, base.PhaseBase):
|
||||||
|
name = 'phase1'
|
||||||
|
|
||||||
|
|
||||||
|
class Phase2(base.ImageConfigMixin, base.PhaseBase):
|
||||||
|
name = 'phase2'
|
||||||
|
|
||||||
|
|
||||||
|
class Phase3(base.ImageConfigMixin, base.PhaseBase):
|
||||||
|
name = 'phase3'
|
||||||
|
|
||||||
|
|
||||||
|
class DummyResolver(object):
|
||||||
|
def __init__(self):
|
||||||
|
self.num = 0
|
||||||
|
|
||||||
|
def __call__(self, url):
|
||||||
|
self.num += 1
|
||||||
|
return url.replace('HEAD', 'RES' + str(self.num))
|
||||||
|
|
||||||
|
|
||||||
|
class ImageConfigMixinTestCase(PungiTestCase):
|
||||||
|
|
||||||
|
@mock.patch('pungi.util.resolve_git_url', new_callable=DummyResolver)
|
||||||
|
def test_git_url_resolved_once(self, resolve_git_url):
|
||||||
|
compose = DummyCompose(self.topdir, {
|
||||||
|
'global_ksurl': 'git://example.com/repo.git?#HEAD',
|
||||||
|
'phase1_ksurl': 'git://example.com/another.git?#HEAD',
|
||||||
|
})
|
||||||
|
|
||||||
|
p1 = Phase1(compose)
|
||||||
|
p2 = Phase2(compose)
|
||||||
|
p3 = Phase3(compose)
|
||||||
|
|
||||||
|
self.assertEqual(p1.get_ksurl({}),
|
||||||
|
'git://example.com/another.git?#RES1')
|
||||||
|
# Phase-level setting retrieved second time.
|
||||||
|
self.assertEqual(p1.get_ksurl({}),
|
||||||
|
'git://example.com/another.git?#RES1')
|
||||||
|
|
||||||
|
self.assertEqual(p2.get_ksurl({}),
|
||||||
|
'git://example.com/repo.git?#RES2')
|
||||||
|
# Global setting retrieved again from same phase.
|
||||||
|
self.assertEqual(p2.get_ksurl({}),
|
||||||
|
'git://example.com/repo.git?#RES2')
|
||||||
|
|
||||||
|
# Global setting retrieved from another phase.
|
||||||
|
self.assertEqual(p3.get_ksurl({}),
|
||||||
|
'git://example.com/repo.git?#RES2')
|
||||||
|
|
||||||
|
# Local setting ignores global ones.
|
||||||
|
self.assertEqual(p3.get_ksurl({'ksurl': 'git://example.com/more.git?#HEAD'}),
|
||||||
|
'git://example.com/more.git?#RES3')
|
||||||
|
|
||||||
|
self.assertEqual(resolve_git_url.num, 3, 'Resolver was not called three times')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
Loading…
Reference in New Issue
Block a user