From dac2fa0c49f192691d84e2a4a59c60efe81282a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Palancher?= Date: Mon, 9 Nov 2026 15:28:26 +0100 Subject: [PATCH] Add command to generate dnf/yum repository conf Introduce rift repo generate subcommand to generate dnf/yum repository file from Rift project configuration. The command notably offers the following options: - -H, --host makes those repositories available under another hostname (and optionally scheme/port). - --token-proxy lets dnf reach IDP authenticated repositories through a running repos-token-proxy. Assisted-by: Claude Opus 5.5 --- lib/rift/controller.py | 124 +++++++++++++++++++++++ lib/rift/repository/rpm.py | 96 +++++++++++++++++- tests/controller.py | 200 +++++++++++++++++++++++++++++++++++++ tests/repository/rpm.py | 138 +++++++++++++++++++++++++ 4 files changed, 553 insertions(+), 5 deletions(-) diff --git a/lib/rift/controller.py b/lib/rift/controller.py index 0fcda741..1113c892 100644 --- a/lib/rift/controller.py +++ b/lib/rift/controller.py @@ -38,6 +38,7 @@ import logging import os import time +import urllib.parse from operator import attrgetter # Since pylint can not found rpm.error, disable this check @@ -54,6 +55,10 @@ from rift.package import RIFT_SUPPORTED_FORMATS, ProjectPackages from rift.patches import get_packages_from_patch from rift.repository import ProjectArchRepositories, StagingRepository +from rift.repository.rpm import ( + ArchRepositoriesRPM, + RepositoryHost, +) from rift.rpm import RPM, Spec from rift.sync import RepoSyncFactory from rift.test_results import TestCase, TestResults @@ -543,6 +548,43 @@ def make_parser(): help="packages to represent in the graph", ) + # Repo options + subprs = subparsers.add_parser("repo", help="Manipulate project repositories") + subprs_repo = subprs.add_subparsers( + dest="repo_cmd", metavar="COMMAND", title="possible commands" + ) + # Set required attribute after creation as required argument of + # add_subparsers() is not supported before Python 3.7. + subprs_repo.required = True + subsubprs = subprs_repo.add_parser( + "generate", + help="generate a dnf/yum repository file from project configuration", + ) + subsubprs.add_argument( + "-o", + "--output", + help="write the repository file to this path (default: stdout)", + ) + subsubprs.add_argument( + "--arch", + help=( + "architecture used to resolve repositories " + "(default: host architecture when the project supports it)" + ), + ) + subsubprs.add_argument( + "-H", + "--host", + help=( + "replacement host for declared repository URLs, " + "as host, host:port, scheme://host, or scheme://host:port" + ), + ) + subsubprs.add_argument( + "--token-proxy", + help="base URL of a running repos-token-proxy, such as http://127.0.0.1:8765", + ) + # Parse options return parser @@ -1611,6 +1653,84 @@ def staff_modules(config): return staff, modules +def parse_host(value): + """ + Parse a --host value into a RepositoryHost. + + The value is [scheme://]host[:port]. scheme is None when the value does not set one. + port is None when the value does not set one. + """ + host_form = "expected host, host:port, scheme://host, or scheme://host:port" + if "://" in value: + parts = urllib.parse.urlsplit(value) + else: + if any(char in value for char in "/?#@"): + raise RiftError(f"Invalid host '{value}': expected no path") + parts = urllib.parse.urlsplit(f"//{value}") + if parts.path not in ("",): + raise RiftError(f"Invalid host '{value}': expected no path") + if parts.query or parts.fragment or parts.username or parts.password: + raise RiftError(f"Invalid host '{value}': {host_form}") + if not parts.hostname or ("://" in value and not parts.scheme): + raise RiftError(f"Invalid host '{value}': {host_form}") + try: + port = parts.port + except ValueError as err: + raise RiftError(f"Invalid host '{value}': {host_form}") from err + scheme = parts.scheme if "://" in value else None + return RepositoryHost(scheme, parts.hostname, port) + + +def parse_token_proxy(value): + """ + Parse a --token-proxy value. + + The value must be an http URL with a host and no path other than /. + """ + parts = urllib.parse.urlsplit(value) + if parts.scheme != "http" or not parts.hostname: + raise RiftError( + f"Invalid token proxy URL '{value}': expected an http URL with a host" + ) + if parts.path not in ("", "/"): + raise RiftError( + f"Invalid token proxy URL '{value}': expected no path other than /" + ) + if parts.query or parts.fragment or parts.username or parts.password: + raise RiftError( + f"Invalid token proxy URL '{value}': expected no user info, query, " + "or fragment" + ) + return parts.netloc + + +def action_repo(args, config): + """Action for 'repo' sub-commands.""" + + assert args.repo_cmd in ("generate",) + # Use the architecture specified on the command line, or the chroot + # architecture for the project (ie. the host architecture if supported or the + # first supported architecture). + arch = args.arch or config.chroot_arch() + # Get the consumable repositories declared in project configuration for the + # architecture. The working repository is not included: passing None as + # working directory avoids creating it. + repos = ArchRepositoriesRPM(config, None, arch) + # Parse the host replacement specified on the command line, if any. + host = parse_host(args.host) if args.host else None + # Parse the token proxy base URL specified on the command line, if any. + token_proxy = parse_token_proxy(args.token_proxy) if args.token_proxy else None + # Render the repository file. + text = repos.generate(host=host, token_proxy=token_proxy) + # Write the repository file to the output file, if specified, or to stdout. + if args.output: + with open(args.output, "w", encoding="utf-8") as repo_file: + repo_file.write(text) + else: + print(text, end="") + return 0 + + def action(config, args): """ Manage rift actions on annex, vm, packages or repositories @@ -1686,6 +1806,10 @@ def action(config, args): if args.command == "graph": return action_graph(args, config, *staff_modules(config)) + # REPO + if args.command == "repo": + return action_repo(args, config) + return 0 diff --git a/lib/rift/repository/rpm.py b/lib/rift/repository/rpm.py index cda85a67..9b184089 100644 --- a/lib/rift/repository/rpm.py +++ b/lib/rift/repository/rpm.py @@ -39,10 +39,12 @@ import os import shutil import threading +import urllib.parse +from collections import namedtuple from subprocess import PIPE, STDOUT, CalledProcessError, Popen, run from rift import RiftError -from rift.config import _DEFAULT_REPO_CMD, _DEFAULT_REPOS_VARIANTS +from rift.config import _DEFAULT_REPO_CMD, _DEFAULT_REPOS_VARIANTS, _DEFAULT_VARIANT from rift.mock import Mock from rift.repository._base import ArchRepositoriesBase, StagingRepositoryBase from rift.rpm import RPM, Spec @@ -50,6 +52,8 @@ repo_lock = threading.Lock() +RepositoryHost = namedtuple("RepositoryHost", ["scheme", "hostname", "port"]) + class ConsumableRepository: """ @@ -101,12 +105,51 @@ def path(self): return self.url[len(self.FILE_SCHEME) :] return self.url - def generic_url(self, arch): + def generic_url(self, arch, url=None): + """ + Return the URL with all occurrences of the given architecture in its path + and query replaced by generic $basearch placeholder. When url is provided + (eg. as returned by replace_host()), it is used instead of the repository + URL. """ - Return the URL with all occurrences of the given architecture replaced - by generic $basearch placeholder. + parts = urllib.parse.urlsplit(url or self.url) + return urllib.parse.urlunsplit( + parts._replace( + path=parts.path.replace(arch, "$basearch"), + query=parts.query.replace(arch, "$basearch"), + ) + ) + + def replace_host(self, host): """ - return self.url.replace(arch, "$basearch") + Return the repository URL with its host replaced by the given + RepositoryHost. The repository URL is not modified. + + file:// URLs and URLs with no host are returned unchanged. The declared port is + dropped. A scheme on host overrides the declared scheme; otherwise the declared + scheme is kept. The path and query are kept. + """ + if self.is_file(): + return self.url + parts = urllib.parse.urlsplit(self.url) + if not parts.hostname: + return self.url + if host.scheme is None: + scheme = parts.scheme + else: + scheme = host.scheme + # Bracket IPv6 hosts so the port separator stays unambiguous. + if ":" in host.hostname: + hostname = f"[{host.hostname}]" + else: + hostname = host.hostname + if host.port is None: + netloc = hostname + else: + netloc = f"{hostname}:{host.port}" + return urllib.parse.urlunsplit( + (scheme, netloc, parts.path, parts.query, parts.fragment) + ) def exists(self): """ @@ -343,6 +386,49 @@ def for_variant(self, variant): return repos + def generate(self, host=None, token_proxy=None): + """ + Render a dnf/yum repository file for the supplementary repositories + declared in the project for the architecture (working repository is + excluded). + """ + stanzas = [] + prio = 1000 + for repo in self.supplementaries: + prio = repo.priority or (prio - 1) + proxy = None + # When repository is authenticated and token proxy is provided, the + # repository uses this proxy. + if token_proxy is not None and repo.authenticated(): + baseurl = f"http://{token_proxy}/{urllib.parse.quote(repo.name)}/" + else: + # If host is provided, use it in the repository URL. + url = repo.replace_host(host) if host is not None else repo.url + # Replaces the concrete architecture in declared URLs with + # $basearch. + baseurl = repo.generic_url(self.arch, url) + if repo.proxy and not repo.authenticated(): + proxy = repo.proxy + enabled = 1 if _DEFAULT_VARIANT in repo.variants else 0 + lines = [ + f"[{repo.name}]", + f"name={repo.name}", + f"baseurl={baseurl}", + "gpgcheck=0", + f"priority={prio}", + f"enabled={enabled}", + ] + if repo.module_hotfixes: + lines.append(f"module_hotfixes={repo.module_hotfixes}") + if repo.excludepkgs: + lines.append(f"excludepkgs={repo.excludepkgs}") + if proxy: + lines.append(f"proxy={proxy}") + stanzas.append("\n".join(lines)) + if not stanzas: + return "" + return "\n\n".join(stanzas) + "\n" + def delete_matching(self, package): """ Search for package matching given name in working local repository and diff --git a/tests/controller.py b/tests/controller.py index ae4c5ad6..f44ce46c 100644 --- a/tests/controller.py +++ b/tests/controller.py @@ -22,11 +22,14 @@ get_packages_to_build, main, make_parser, + parse_host, + parse_token_proxy, remove_packages, ) from rift.package._virtual import PackageVirtual from rift.package.oci import ActionableArchPackageOCI, PackageOCI from rift.package.rpm import ActionableArchPackageRPM, PackageRPM +from rift.repository.rpm import ArchRepositoriesRPM, RepositoryHost from rift.rpm import RPM from rift.test_results import TestCase, TestResults @@ -3602,6 +3605,112 @@ def test_action_changelog_unknown_maintainer(self, mock_mock): main(["changelog", "pkg", "-c", "basic change", "-t", "Fail"]) +class ControllerProjectActionRepoTest(RiftProjectTestCase): + """ + Tests class for Controller action repo + """ + + def setUp(self): + super().setUp() + self.config.set("arch", ["x86_64", "aarch64"]) + self.config.options["repos"] = { + "os": {"url": "https://pkgs.internal:8443/8.8/BaseOS/$arch/os"}, + "private": { + "url": "https://pkgs.internal/private/$arch", + "auth": "idp_token", + }, + "local": {"url": "file:///var/repos/$arch"}, + } + self.config.options["aarch64"] = { + "repos": {"alt": {"url": "https://pkgs.internal:8443/alt"}} + } + self.update_project_conf() + + def test_generate_stdout(self): + """Test repo generate renders x86_64 repositories on stdout.""" + buf = StringIO() + with patch.object( + ArchRepositoriesRPM, "generate", autospec=True, return_value="rendered\n" + ) as generate, patch("sys.stdout", buf): + rc = main(["repo", "generate", "--arch", "x86_64"]) + self.assertEqual(rc, 0) + self.assertEqual(buf.getvalue(), "rendered\n") + args, kwargs = generate.call_args + (repos,) = args + self.assertEqual(repos.arch, "x86_64") + self.assertCountEqual( + [repo.name for repo in repos.supplementaries], ["os", "private", "local"] + ) + self.assertIsNone(kwargs["host"]) + self.assertIsNone(kwargs["token_proxy"]) + + def test_generate_default_arch(self): + """Test repo generate uses project chroot architecture by default.""" + with patch.object( + ArchRepositoriesRPM, "generate", autospec=True, return_value="" + ) as generate, patch("sys.stdout", StringIO()): + rc = main(["repo", "generate"]) + self.assertEqual(rc, 0) + self.assertEqual(generate.call_args[0][0].arch, self.config.chroot_arch()) + + def test_generate_output_file(self): + """Test repo generate writes output file with host and token proxy.""" + output = os.path.join(self.projdir, "rift.repo") + with patch.object( + ArchRepositoriesRPM, "generate", autospec=True, return_value="rendered\n" + ) as generate: + rc = main( + [ + "repo", + "generate", + "-H", + "pkgs.external", + "--token-proxy", + "http://127.0.0.1:8765", + "-o", + output, + "--arch", + "x86_64", + ] + ) + self.assertEqual(rc, 0) + self.assertEqual(read_file(output), "rendered\n") + kwargs = generate.call_args[1] + self.assertEqual(kwargs["host"], RepositoryHost(None, "pkgs.external", None)) + self.assertEqual(kwargs["token_proxy"], "127.0.0.1:8765") + + def test_generate_arch_repos(self): + """Test repo generate resolves architecture specific repositories.""" + with patch.object( + ArchRepositoriesRPM, "generate", autospec=True, return_value="" + ) as generate, patch("sys.stdout", StringIO()): + rc = main( + ["repo", "generate", "--arch", "aarch64", "-H", "http://pkgs.external"] + ) + self.assertEqual(rc, 0) + args, kwargs = generate.call_args + (repos,) = args + self.assertEqual(repos.arch, "aarch64") + self.assertEqual([repo.name for repo in repos.supplementaries], ["alt"]) + self.assertEqual(kwargs["host"], RepositoryHost("http", "pkgs.external", None)) + + def test_generate_rendered(self): + """Test repo generate end-to-end rendering.""" + buf = StringIO() + with patch("sys.stdout", buf): + rc = main(["repo", "generate", "--arch", "x86_64"]) + self.assertEqual(rc, 0) + self.assertIn( + "[os]\nname=os\nbaseurl=https://pkgs.internal:8443/8.8/BaseOS/$basearch/os\n", + buf.getvalue(), + ) + + def test_generate_invalid_host(self): + """Test repo generate fails on invalid host.""" + with self.assertRaisesRegex(RiftError, "expected no path"): + main(["repo", "generate", "-H", "pkgs.external/extra"]) + + class ControllerArgumentsTest(RiftTestCase): """Arguments parsing tests for Controller module""" @@ -3912,3 +4021,94 @@ def test_make_parser_vm(self): args = ["vm", "build", "--url", "http://image", "--output"] with self.assertRaises(SystemExit): parser.parse_args(args) + + def test_parse_args_repo_missing_command(self): + """Test repo parser fails without sub-command.""" + with self.assertRaises(SystemExit): + make_parser().parse_args(["repo"]) + + def test_parse_args_repo_generate_defaults(self): + """Test repo generate parser defaults.""" + opts = make_parser().parse_args(["repo", "generate"]) + self.assertEqual(opts.repo_cmd, "generate") + self.assertIsNone(opts.output) + self.assertIsNone(opts.arch) + self.assertIsNone(opts.host) + self.assertIsNone(opts.token_proxy) + + def test_parse_args_repo_generate_options(self): + """Test repo generate parser options.""" + opts = make_parser().parse_args( + [ + "repo", + "generate", + "-o", + "out.repo", + "--arch", + "aarch64", + "--host", + "pkgs.external", + "--token-proxy", + "http://127.0.0.1:8765", + ] + ) + self.assertEqual(opts.output, "out.repo") + self.assertEqual(opts.arch, "aarch64") + self.assertEqual(opts.host, "pkgs.external") + self.assertEqual(opts.token_proxy, "http://127.0.0.1:8765") + short = make_parser().parse_args(["repo", "generate", "-H", "pkgs.external"]) + self.assertEqual(short.host, "pkgs.external") + + +class ReposConfParseTest(RiftTestCase): + """Tests for parse_host and parse_token_proxy.""" + + def test_parse_host(self): + self.assertEqual( + parse_host("pkgs.external"), + RepositoryHost(None, "pkgs.external", None), + ) + self.assertEqual( + parse_host("pkgs.external:9443"), + RepositoryHost(None, "pkgs.external", 9443), + ) + self.assertEqual( + parse_host("http://pkgs.external"), + RepositoryHost("http", "pkgs.external", None), + ) + self.assertEqual( + parse_host("http://pkgs.external:8080"), + RepositoryHost("http", "pkgs.external", 8080), + ) + with self.assertRaisesRegex(RiftError, "expected no path"): + parse_host("pkgs.external/extra") + with self.assertRaisesRegex(RiftError, "expected no path"): + parse_host("http://pkgs.external/extra") + with self.assertRaisesRegex(RiftError, "expected host, host:port"): + parse_host("http://user:pass@pkgs.external") + with self.assertRaisesRegex(RiftError, "expected host, host:port"): + parse_host("http://pkgs.external?arch=1") + with self.assertRaisesRegex(RiftError, "expected host, host:port"): + parse_host("http://") + with self.assertRaisesRegex(RiftError, "expected host, host:port"): + parse_host("pkgs.external:abc") + + def test_parse_token_proxy(self): + self.assertEqual( + parse_token_proxy("http://127.0.0.1:8765"), + "127.0.0.1:8765", + ) + self.assertEqual( + parse_token_proxy("http://127.0.0.1:8765/"), + "127.0.0.1:8765", + ) + with self.assertRaisesRegex(RiftError, "expected an http URL with a host"): + parse_token_proxy("https://127.0.0.1:8765") + with self.assertRaisesRegex(RiftError, "expected an http URL with a host"): + parse_token_proxy("http://") + with self.assertRaisesRegex(RiftError, "expected no path other than /"): + parse_token_proxy("http://127.0.0.1:8765/extra") + with self.assertRaisesRegex(RiftError, "expected no user info, query"): + parse_token_proxy("http://user:pass@127.0.0.1:8765") + with self.assertRaisesRegex(RiftError, "expected no user info, query"): + parse_token_proxy("http://127.0.0.1:8765?arch=1") diff --git a/tests/repository/rpm.py b/tests/repository/rpm.py index 433f5d5c..fdeb2b66 100644 --- a/tests/repository/rpm.py +++ b/tests/repository/rpm.py @@ -12,6 +12,7 @@ ArchRepositoriesRPM, ConsumableRepository, LocalRepository, + RepositoryHost, StagingRepositoryRPM, ) from rift.rpm import RPM @@ -366,6 +367,36 @@ def test_generic_url(self): self.assertEqual(repo.generic_url("x86_64"), "http:/{}".format(directory)) repo = ConsumableRepository("http://some/where/x86_64") self.assertEqual(repo.generic_url("x86_64"), "http://some/where/$basearch") + # Architecture is replaced in URL path and query, not in host. + repo = ConsumableRepository("http://x86_64.mirror/x86_64/os?arch=x86_64") + self.assertEqual( + repo.generic_url("x86_64"), + "http://x86_64.mirror/$basearch/os?arch=$basearch", + ) + repo = ConsumableRepository("file:///repos/x86_64") + self.assertEqual(repo.generic_url("x86_64"), "file:///repos/$basearch") + repo = ConsumableRepository("/repos/x86_64") + self.assertEqual(repo.generic_url("x86_64"), "/repos/$basearch") + + def test_replace_host(self): + url = "https://pkgs.internal:8443/x86_64/os?a=1" + repo = ConsumableRepository(url) + self.assertEqual( + repo.replace_host(RepositoryHost(None, "x86_64.mirror", None)), + "https://x86_64.mirror/x86_64/os?a=1", + ) + host_url = repo.replace_host(RepositoryHost("http", "::1", 8080)) + self.assertEqual(host_url, "http://[::1]:8080/x86_64/os?a=1") + self.assertEqual( + repo.generic_url("x86_64", host_url), "http://[::1]:8080/$basearch/os?a=1" + ) + # Repository URL is not modified. + self.assertEqual(repo.url, url) + for url in ("file:///repos/x86_64", "/repos/x86_64", "http:///missing"): + repo = ConsumableRepository(url) + self.assertEqual( + repo.replace_host(RepositoryHost(None, "pkgs.external", None)), url + ) def test_authenticated(self): local_repo = ConsumableRepository( @@ -546,6 +577,113 @@ def test_for_variant(self): [repo.name for repo in repos.for_variant(variant)], expected_result ) + def test_generate_render(self): + arch_repos = ArchRepositoriesRPM(self.config, None, "x86_64") + repo = ConsumableRepository( + "https://pkgs.internal/8.8/BaseOS/x86_64/os", + name="os", + options={ + "module_hotfixes": True, + "excludepkgs": "kernel", + "proxy": "http://127.0.0.1:8080", + }, + ) + arch_repos.supplementaries = [repo] + text = arch_repos.generate() + self.assertEqual( + text, + "\n".join( + [ + "[os]", + "name=os", + "baseurl=https://pkgs.internal/8.8/BaseOS/$basearch/os", + "gpgcheck=0", + "priority=999", + "enabled=1", + "module_hotfixes=True", + "excludepkgs=kernel", + "proxy=http://127.0.0.1:8080", + "", + ] + ), + ) + + def test_generate_priority(self): + arch_repos = ArchRepositoriesRPM(self.config, None, "x86_64") + repos = [ + ConsumableRepository("https://a.example/repo", name="a"), + ConsumableRepository("https://b.example/repo", name="b", priority=5), + ConsumableRepository("https://c.example/repo", name="c", priority=0), + ConsumableRepository("https://d.example/repo", name="d"), + ] + arch_repos.supplementaries = repos + text = arch_repos.generate() + self.assertIn("name=a\nbaseurl=https://a.example/repo\n", text) + self.assertIn("gpgcheck=0\npriority=999\n", text) + self.assertIn("name=b\nbaseurl=https://b.example/repo\n", text) + self.assertIn("priority=5\n", text) + self.assertIn("name=c\nbaseurl=https://c.example/repo\n", text) + self.assertIn("priority=4\n", text) + self.assertIn("name=d\nbaseurl=https://d.example/repo\n", text) + self.assertIn("priority=3\n", text) + + def test_generate_variants(self): + arch_repos = ArchRepositoriesRPM(self.config, None, "x86_64") + repos = [ + ConsumableRepository("https://a.example/repo", name="a"), + ConsumableRepository( + "https://b.example/repo", name="b", variants=["extra"] + ), + ConsumableRepository("https://c.example/repo", name="c", variants=["main"]), + ] + arch_repos.supplementaries = repos + text = arch_repos.generate() + self.assertIn("name=a\nbaseurl=https://a.example/repo\n", text) + self.assertIn("priority=999\nenabled=1\n", text) + self.assertIn("name=b\nbaseurl=https://b.example/repo\n", text) + self.assertIn("priority=998\nenabled=0\n", text) + self.assertIn("name=c\nbaseurl=https://c.example/repo\n", text) + self.assertIn("priority=997\nenabled=1\n", text) + + def test_generate_host_replacement(self): + arch_repos = ArchRepositoriesRPM(self.config, None, "x86_64") + arch_repos.supplementaries = [ + ConsumableRepository("https://pkgs.internal:8443/x86_64/os", name="os") + ] + text = arch_repos.generate(host=RepositoryHost("http", "pkgs.external", 8080)) + self.assertIn("baseurl=http://pkgs.external:8080/$basearch/os\n", text) + + def test_generate_token_proxy(self): + arch_repos = ArchRepositoriesRPM(self.config, None, "x86_64") + public = ConsumableRepository( + "https://pkgs.internal:8443/os/x86_64", + name="os", + options={"proxy": "http://proxy:3128"}, + ) + private = ConsumableRepository( + "https://pkgs.internal/private/x86_64", + name="private repo", + options={"auth": "idp_token", "proxy": "http://proxy:3128"}, + ) + arch_repos.supplementaries = [public, private] + text = arch_repos.generate( + host=RepositoryHost(None, "pkgs.external", None), + token_proxy="127.0.0.1:8765", + ) + self.assertIn( + "baseurl=https://pkgs.external/os/$basearch\n", + text, + ) + self.assertIn("proxy=http://proxy:3128\n", text) + self.assertIn("baseurl=http://127.0.0.1:8765/private%20repo/\n", text) + self.assertNotIn("proxy=", text.split("\n\n")[1]) + self.assertNotIn("pkgs.external/private", text) + + def test_generate_empty(self): + arch_repos = ArchRepositoriesRPM(self.config, None, "x86_64") + arch_repos.supplementaries = [] + self.assertEqual(arch_repos.generate(), "") + class StagingRepositoryRPMTest(RiftTestCase): """